Spring AIでFunction calling / Tool useを実装する

1. 概要

LLMは訓練データに含まれる知識だけを持っています。あなたの注文 ORD-1001 がどこにあるかは知りません。Function calling(ツール使用とも呼ばれます)はこの問題を解決します。モデルにいくつかの関数を説明します。モデルはいつどの関数を呼び出すべきか、どのパラメータを使うかを判断します。あなたのアプリケーションは関数を実行し、その結果をモデルに返します。

この記事はまずJSONレベルのメカニズムについて説明し、その後Spring AIを使ってカスタマーサポート用のアシスタントを構築します。このアシスタントは注文ステータスの照会とキャンセルができます。読了後、あなたはツールの宣言、安全なコンテキストの渡し方、エラー処理、および実際のモデル呼び出しなしのテスト作成ができるようになります。

2. Function callingの仕組み

2.1. 質問 – 呼び出し – 応答のループ

最もよくある誤解は、モデルが自分でコードを実行しないということです。単にJSONフォーマットの関数呼び出し要求を返すだけです。ツールを使った1つの会話のターンは次のように進行します:

  1. アプリケーションはユーザーの質問とツール一覧(名前、説明、パラメータのJSONスキーマ)を送信します。
  2. モデルは最終的な回答の代わりに tool_calls を返します。
  3. アプリケーションは対応する関数を実行し、結果を tool ロールのメッセージとして送り返します。
  4. モデルは結果を読みます。他のツールを呼び出すか、最終的な回答を生成することができます。

2.2. JSON形式の詳細

以下の形式はOpenAIスタイルです。他のプロバイダーも同様の構造を使いますが、フィールド名が異なります。リクエストには tools セクションがあります:

{
  "model": "gpt-4o-mini",
  "messages": [
    { "role": "user", "content": "注文 ORD-1001 はどこまで来ていますか?" }
  ],
  "tools": [{
    "type": "function",
    "function": {
      "name": "getOrderStatus",
      "description": "注文のステータスと予定配達日を照会する",
      "parameters": {
        "type": "object",
        "properties": { "orderId": { "type": "string" } },
        "required": ["orderId"]
      }
    }
  }]
}

モデルはすぐに回答しません。関数呼び出しを要求します:

{
  "role": "assistant",
  "tool_calls": [{
    "id": "call_abc123",
    "type": "function",
    "function": { "name": "getOrderStatus", "arguments": "{\"orderId\":\"ORD-1001\"}" }
  }]
}

アプリケーションは関数を実行し、結果を返します。必ず tool_call_id を一致させてください:

{ "role": "tool", "tool_call_id": "call_abc123",
  "content": "{\"orderId\":\"ORD-1001\",\"status\":\"SHIPPED\",\"eta\":\"2026-10-30\"}" }

今度はモデルが最終的な回答を生成できます。例えば:「注文 ORD-1001 は配送業者に引き渡されており、10月30日に到着予定です。」

覚えておくべき2つのポイント。1つ目は、arguments はモデルが生成したJSON文字列です。この文字列は型が間違っているか、フィールドが不足している可能性があります。2つ目は、description の品質がモデルが正しいツールを選ぶかどうかを決定します。

この処理ループを自分で書くことは難しくありませんが、かなり冗長です。Spring AIはこの部分をあらかじめ用意してくれます。

3. Spring AIのセットアップ

BOMと、OpenAI用のスターターを追加します。別のプロバイダーを使う場合、スターターを変えるだけで、ツール部分のコードは変わりません。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>

APIキーとモデルを設定します:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.2

低いtemperatureはモデルがより安定してツールを選ぶのに役立ちます。照会タスクでは「創造性」は必要ありません。

4. @Tool でツールを宣言する

シンプルなため、メモリ内の注文リポジトリを使います:

public record Order(String id, String customerId, String status, String eta) {}

public record OrderStatusReply(String orderId, String status, String eta) {}

ツールクラスは普通のbeanです。@Tool でマークされたメソッドはツールになります:

@Component
public class OrderTools {

    private final Map<String, Order> orders = new ConcurrentHashMap<>(Map.of(
        "ORD-1001", new Order("ORD-1001", "CUS-7", "SHIPPED", "2026-10-30"),
        "ORD-1002", new Order("ORD-1002", "CUS-7", "PENDING", "2026-11-02")
    ));

    @Tool(description = "注文のステータスと予定配達日を照会する")
    public OrderStatusReply getOrderStatus(
            @ToolParam(description = "注文ID、ORD-xxxx形式") String orderId,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        return new OrderStatusReply(order.id(), order.status(), order.eta());
    }

    @Tool(description = "まだ配送業者に引き渡されていない注文をキャンセルする。顧客が明確にキャンセルを望んだ場合のみ実行")
    public String cancelOrder(
            @ToolParam(description = "注文ID、ORD-xxxx形式") String orderId,
            @ToolParam(description = "顧客が指定したキャンセル理由", required = false) String reason,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        if (!"PENDING".equals(order.status())) {
            throw new IllegalStateException(
                "注文 " + orderId + " は " + order.status() + " 状態のため、キャンセルできません");
        }
        orders.put(orderId, new Order(order.id(), order.customerId(), "CANCELLED", null));
        return "注文 " + orderId + " をキャンセルしました";
    }

    private Order findOwnedOrder(String orderId, ToolContext context) {
        String customerId = (String) context.getContext().get("customerId");
        Order order = orders.get(orderId);
        if (order == null || !order.customerId().equals(customerId)) {
            throw new IllegalArgumentException("注文 " + orderId + " が見つかりません");
        }
        return order;
    }
}

いくつか重要なポイント:

  • @Tool(description = …) がセクション 2.2 のJSONの description です。デフォルトでは、ツール名はメソッド名です。
  • @ToolParam は各パラメータを説明します。required = false はパラメータをスキーマの required リストから除外します。
  • ToolContext 型のパラメータはスキーマに現れません。モデルは見ることも、値を指定することもできません。ログイン中のセッションから customerId を渡すために使います。これにより、モデルは他の顧客のIDを「でっちあげる」ことができません。
  • 戻り値の型 OrderStatusReply はSpring AIによってJSONにシリアライズされ、モデルに送り返されます。
  • 現在の顧客に属さない注文の場合、「権限がない」ではなく「見つかりません」と返します。この方法は、その注文が存在することの情報漏洩を避けます。

5. ChatClient 経由でツールを呼び出す

@Service
public class SupportAssistant {

    private final ChatClient chatClient;
    private final OrderTools orderTools;

    public SupportAssistant(ChatClient.Builder builder, OrderTools orderTools) {
        this.chatClient = builder
            .defaultSystem("""
                あなたはカスタマーサポートアシスタントです。
                ツールから取得したデータのみに基づいて回答してください。注文ステータスを推測しないでください。
                注文をキャンセルする前に、顧客に確認してください。""")
            .build();
        this.orderTools = orderTools;
    }

    public String answer(String customerId, String question) {
        return chatClient.prompt()
            .user(question)
            .tools(orderTools)
            .toolContext(Map.of("customerId", customerId))
            .call()
            .content();
    }
}

.tools(orderTools) はbeanの @Tool 付きメソッドをすべてスキャンし、その定義をモデルに送信します。モデルが tool_calls を返すと、Spring AIは3つのことを自動的に行います:arguments をパース、正しいメソッドを呼び出し、結果をモデルに送り返す。このループはモデルがテキストで回答するまで繰り返されます。.content() は最終的な回答だけを返します。

小さなコントローラで試してみましょう:

@RestController
@RequestMapping("/support")
public class SupportController {

    private final SupportAssistant assistant;

    public SupportController(SupportAssistant assistant) {
        this.assistant = assistant;
    }

    @PostMapping
    public String ask(@RequestBody String question, Principal principal) {
        return assistant.answer(principal.getName(), question);
    }
}

顧客 CUS-7 が「私の注文 ORD-1001 はどこにありますか?」と尋ねると、次のような結果が得られます:

あなたの注文 ORD-1001 は配送業者に引き渡されており、2026年10月30日に到着予定です。

テキストの具体的な内容は実行ごとに異なります。ただし、データは常にツールから取得されます。

6. 内部を見る:ToolCallback とスキーマ

Spring AIは各 @Tool メソッドを ToolCallback に変換します。それらを作成して、モデルが実際に受け取るものを確認できます:

ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
for (ToolCallback cb : callbacks) {
    System.out.println(cb.getToolDefinition().name());
    System.out.println(cb.getToolDefinition().inputSchema());
}

結果(短縮版):

cancelOrder
{"type":"object","properties":{"orderId":{"type":"string","description":"注文ID、ORD-xxxx形式"},
 "reason":{"type":"string","description":"顧客が指定したキャンセル理由"}},"required":["orderId"],...}
getOrderStatus
{"type":"object","properties":{"orderId":{"type":"string","description":"注文ID、ORD-xxxx形式"}},
 "required":["orderId"],...}

セクション 4で述べたように、ToolContext はスキーマに現れません。モデルが誤ったツールを選んだ場合、上記のようにスキーマを出力して確認するのが最初に行うべきことです。

7. モデルを呼び出さずにテストする

ユニットテストで実際のモデルを呼び出すのは、遅い、費用がかかる、結果が不安定になります。代わりに、2つのことをテストします:スキーマ(モデルが受け取るもの)と、モデルが送信するのと同じ arguments JSON文字列を受け取った場合のツールの動作。

class OrderToolsTest {

    private final ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
    private final ToolContext customer7 = new ToolContext(Map.of("customerId", "CUS-7"));

    private ToolCallback tool(String name) {
        return Arrays.stream(callbacks)
            .filter(cb -> cb.getToolDefinition().name().equals(name))
            .findFirst().orElseThrow();
    }

    @Test
    void schemaShouldNotExposeToolContext() {
        String schema = tool("getOrderStatus").getToolDefinition().inputSchema();

        assertThat(schema).contains("\"orderId\"").doesNotContain("context");
    }

    @Test
    void shouldReturnStatusAsJson() {
        String result = tool("getOrderStatus")
            .call("{\"orderId\":\"ORD-1001\"}", customer7);

        assertThat(result).contains("\"status\":\"SHIPPED\"", "\"eta\":\"2026-10-30\"");
    }

    @Test
    void shouldHideOrdersOfOtherCustomers() {
        ToolContext stranger = new ToolContext(Map.of("customerId", "CUS-99"));

        assertThatThrownBy(() -> tool("getOrderStatus").call("{\"orderId\":\"ORD-1001\"}", stranger))
            .hasRootCauseInstanceOf(IllegalArgumentException.class)
            .hasRootCauseMessage("注文 ORD-1001 が見つかりません");
    }

    @Test
    void shouldRefuseToCancelShippedOrder() {
        assertThatThrownBy(() -> tool("cancelOrder").call("{\"orderId\":\"ORD-1001\"}", customer7))
            .isInstanceOf(ToolExecutionException.class)
            .hasRootCauseMessage("注文 ORD-1001 は SHIPPED 状態のため、キャンセルできません");
    }

    @Test
    void shouldCancelPendingOrder() {
        String result = tool("cancelOrder")
            .call("{\"orderId\":\"ORD-1002\",\"reason\":\"間違えて注文した\"}", customer7);

        assertThat(result).contains("注文 ORD-1002 をキャンセルしました");
    }
}

MethodToolCallback はメソッドからのすべての例外を ToolExecutionException でラップします。したがって、上記のテストはルート原因を確認します。このテストスイートはミリ秒単位で実行され、制御できる部分をテストします。

モデルの動作(正しいツールを選んだか、キャンセル前に確認を求めたか)は別のテストです。実際のモデルで独立した評価スイートで実行すべきで、ユニットテストに含めるべきではありません。

8. ツールからのエラー処理

cancelOrder が実行中に例外を投げた場合はどうなりますか?デフォルト(spring.ai.tools.throw-exception-on-error=false)では、Spring AIはリクエストを破棄しません。例外のメッセージを取得し、ツール結果としてモデルに送り返します。モデルがメッセージを読んで顧客に説明します:

申し訳ございません。注文 ORD-1001 は既に配送業者に引き渡されているため、キャンセルできません。
配送後の返品手続きについてご説明しましょうか?

この動作は便利ですが、2つの結果をもたらします:

  • 例外のメッセージはモデルが読んで、通常、ユーザーに伝えられます。 スタックトレース、SQL、内部情報を含む例外を投げないでください。
  • インフラエラー(データベース障害など)を上層に直接渡したい場合は、以下を設定してください:
spring.ai.tools.throw-exception-on-error=true

合理的なアプローチは:ビジネスロジックエラー(注文済み、見つからない)はモデルに返し、インフラエラーは上に伝える。

9. よくあるエラーと、いつ使用しないべきか

注意: モデルが生成した arguments を、信頼できない入力と扱ってください。プロンプトインジェクションはモデルに任意の注文IDで cancelOrder を呼び出させるかもしれません。すべての権限チェックはツールコード内に、ToolContext またはセキュリティコンテキストに基づいて実装する必要があります。system promptの指示に頼らないでください。

よくあるエラー:

  • 説明が曖昧です。 「注文を取得」と書くと、モデルは「注文履歴を取得」と区別しにくくなります。ツールが何をするか、いつ使うべきか、いつ使うべきではないかを明確に説明してください。
  • 1つのリクエストで多すぎるツール。 各ツール定義はトークンを消費し、ツールリストが長いとモデルの選択が悪くなります。現在のユースケースに関連するツールのみを送信してください。
  • 副作用のあるツール(確認ステップなし)。 注文キャンセル、払い戻し、メール送信の場合、2ステップに分割することを検討してください。最初のツールは準備と概要を返すだけです。実際の操作はユーザーがUIでボタンをクリックした後に実行され、モデルが「顧客は同意したと思う」という理由だけではありません。
  • べき等性の欠如。 モデルは同じツールを2回呼び出す可能性があります。cancelOrder を設計して、2回目の呼び出しが害を及ぼさないようにしてください。
  • 大きなデータを返す。 500行の注文履歴全体をコンテキストにダンプするのは、トークン浪費でもあり、モデルを「混乱」させます。ツール内で絞り込んで要約してください。
  • 各ツール呼び出しがモデルへのラウンドトリップであることを忘れてください。 質問に3つのツールが必要な場合、数秒かかる可能性があります。高速応答が必要なフローに導入する前に、遅延を測定してください。

Function callingを使用してはいけない場合:

  • 処理フローが既に固定されて事前にわかっている(常に注文を照会した後に回答する)。この場合、サービスを直接呼び出し、結果をプロンプトに入れてください。この方法がより安価で、より高速で、より予測可能です。
  • 構造化された出力を生成する必要があるだけです。structured output(例:.call().entity(MyRecord.class))を使用してください。ツール使用でシミュレートしないでください。
  • 機密な操作(送金、データ削除)で、人間の確認ステップがない。

10. まとめ

Function callingは万能薬ではありません。モデルはJSONで関数呼び出し要求を返すだけであり、あなたのアプリケーションが実際の実行部分です。Spring AIは実行部分をシンプルにします:@Tool と @ToolParam がスキーマを生成し、ChatClient.tools(...) が呼び出し – 結果返却ループを処理し、ToolContext がモデルが触れられないセッション情報を渡します。

最も重要なのは、信頼の境界を正しく引くことです。説明を明確に書き、ツール内で権限チェックを実装してください。副作用のある操作については、ユーザーの確認が必要です。最後に、ToolCallback を使って実際のJSON引数文字列でツールをテストし、モデルが何を返そうと、あなたのコードが正しく動作することを確認してください。

llmspring-aifunction-callingtool-usejava