「モデルがJSON形式さえきちんと守ってくれればいい」と考えた瞬間から、実際の問題解決が始まりました。
社内プラットフォームに、自然言語でサービスを操作できるAIエージェントを組み込む作業を進めました。構成は2つのサービスに分けました。MCPサーバーはツール一覧とポリシー・承認・監査を担当し、エージェントホストは会話処理とLLM呼び出し、そしてツール実行ループを担当する構造です。原則は1つでした。推論は汎用LLMに委ね、実行の制御はプラットフォームが直接担うということです。
初期実装は動作していました。ただし、「動作する」ことと「信頼できる」ことは別でした。ループの判断が、プロンプトに付加したツール一覧と、モデルがテキストで出力したJSONを直接パースする方式に依存していたためです。形式が一度でも崩れると、2回再試行した後、ツールを一度も呼び出せないまま終了していました。
この記事では、プロンプトのJSONをパースする方式のツールループをprovider native tool callingに切り替え、手作業で実装していたLLM呼び出しとMCPクライアントをSpring AI 2.0に置き換えた過程をまとめます。症状はいくつもありましたが、根本原因は1つだったこと、そして実行レイヤーを丸ごと入れ替えながら、制御レイヤーには一行も手を加えなかったことが、今回の作業の要点です。
1. 技術選定の背景 — なぜプロンプトのJSONパースは失敗したのか
切り替えを決める前に、すでに動作していたループを徹底的にレビューしました。レビューで明らかになった問題は次のとおりです。
-
判断が形式に依存していました。ツール一覧を文字列としてプロンプトに付加し、モデルがテキストで出力した {"action":"call_tool", ...} を直接パースしていました。形式に違反すると、2回再試行した後、ツールを呼び出さずに終了していました。
-
観測結果が無制限に蓄積されていました。各ステップで、それまでの観測結果全体を再び連結してユーザーメッセージを組み立てていました。ステップが増えるにつれて、プロンプトは線形に膨らんでいきました。
-
ツールを呼び出さずにすぐ回答しても、それを阻止する仕組みがありませんでした。システムプロンプトに「業務データを捏造しないこと」と書いてあるだけで、それを強制するコードはありませんでした。
-
ツールが40個を超えているのに、リクエストごとにすべて送信していました。1ステップあたり約4,000トークンが、ツール一覧だけで消費されていました。
-
MCPクライアントをraw HttpClientとJSON-RPCで直接実装していました。また、initializeの後にnotifications/initializedを送信しておらず、プロトコル違反の可能性がありました。
当初は、これらの項目をそれぞれ別の改善課題として挙げていました。しかし原因をたどってみると、最初の3つには共通の根本原因がありました。LLM呼び出しのポートであるAgentAskRequestが、promptとsystemPromptという2つの文字列しか受け取れなかったことです。
【問題の根本原因 — 文字列2つだけのエンジンポート】
// Before: the engine port had no notion of a messages array.
public class AgentAskRequest {
private String prompt; // question + tool list + observations, all in here
private String systemPrompt; // rules go here
}
メッセージ配列という概念が構造に存在しなかったため、会話履歴を格納する場所も、ツール一覧を渡す場所もありませんでした。結局、ツール一覧も観測結果も、すべて文字列としてプロンプトに押し込む以外に方法がありませんでした。つまり、native tool callingを使わなかったのではなく、使えない構造だったのです。
結論は明確でした。個々の症状を1つずつ修正するのではなく、エンジンポートをmessages + toolsベースに引き上げ、実行経路をprovider native tool callingに切り替えることにしました。同時に、手作業で実装していたprovider REST呼び出しとMCPクライアントをSpring AI 2.0に置き換えることにしました。root build.gradleにはすでにspring-ai-bom:2.0.0が宣言されていたにもかかわらず、クライアント側のモジュールではSpring AIのクラスを1つもimportせず、同じ機能を手作業で実装していたためです。
2. 導入の過程と実装
作業は4つの段階で進めました。責任範囲の定義、エンジンポートの切り替え、ツール名と一覧の制御、そして制御レイヤーが不変であることの確認です。各段階を個別にロールバックできるよう、順序を決めました。
2.1 責任範囲の定義 — ライブラリに任せるものと、こちらに残すもの
コードを移す前に、まず境界を決めました。ライブラリに置き換えられるものと、私たちが必ず担うべきものを区別する作業です。
|
区分 |
Spring AIが担当 |
エージェントが直接担当 |
|---|---|---|
|
範囲 |
LLM呼び出し、メッセージ・tool_callsの変換、MCPプロトコル/クライアント、ツールスキーマ、メトリクス・トレーシング |
ループの進行ポリシー、ツール許可ポリシー、リスク・承認、呼び出し回数・時間の上限、監査、SSEイベント |
|
基準 |
プロトコルと通信 — 標準があり、私たちが独自によりよく作る理由がない領域 |
制御と責任 — 私たちのプラットフォームのポリシーが適用される領域 |
この表が、その後のすべての判断基準になりました。「これはどこに置くべきか」という疑問が出るたびに、標準の問題なのか、ポリシーの問題なのかを問えば答えが出ました。
2.2 エンジンポートの切り替え — ChatModelを直接呼び出し、実行は委ねない
Spring AIの導入で最も注意したのは、ツールの自動実行でした。Spring AIには、モデルが要求したツールをフレームワークが自動的に実行する経路があります。便利ですが、その経路を使うとポリシー・承認・監査を迂回してしまいます。
確認したところ、Spring AI 2.0のChatModel層はToolCallingManager.resolveToolDefinitionsだけを呼び出し、executeToolCallsは呼び出していませんでした。つまり、ChatModelを直接呼び出す限り、ツールが自動実行されることはありません。この性質を利用して、ツール定義はスキーマとして渡すだけで、実行意図だけを受け取るアダプターを設けました。
【実行意図だけを受け取るエンジンポート】
// The engine returns only the model's tool-call intent. It never executes.
public interface NativeToolCallingEngine extends AgentEngine {
AgentNativeToolResponse askWithTools(AgentNativeToolRequest request);
}
// Calling ChatModel directly: ToolCallback only supplies the schema, it is not run.
ChatResponse chatResponse = chatModel.call(new Prompt(nativeMessages(request), options));
AssistantMessage assistant = chatResponse.getResult().getOutput();
List<AgentNativeToolCall> toolCalls = toolCalls(assistant); // structured tool_calls
前のステップでのツール呼び出しと結果は、文字列として再び連結するのではなく、AssistantMessageとToolResponseMessageとしてメッセージ配列に追加しました。観測結果の蓄積という問題を、プロンプトの再構築ではなく、本来あるべき会話配列へと移したのです。
複数のtool callを受け付けて順次実行し、すべてのtool_call_idに必ず応答するようにしました。許可されていないツールや重複呼び出しがあった場合は、例外でループを停止させず、ERROR:で始まる観測結果として返し、モデルに再判断させるようにしました。
2.3 ツール名と一覧 — 制約は境界でのみ吸収する
切り替えの過程で、実務上の制約が1つありました。ツール名をnavi.<service>.<verb>形式にしていましたが、OpenAI系のfunction nameの仕様ではピリオド(.)が許可されていませんでした。
命名規則を変える選択肢もありましたが、そうするとMCP・ログ・ポリシー・監査で使われる名前まで、すべて変わってしまいます。制約がかかるのはLLMに渡すフィールド1か所だけなので、その境界でのみ参照テーブルを使って変換することにしました。
【名前の制約は境界の1か所で吸収】
// Convert the name only at the LLM boundary.
// MCP, policy and audit keep the original name.
private String nativeName(String qualifiedName) {
String sanitized = qualifiedName.replaceAll("[^A-Za-z0-9_-]", "_");
if (sanitized.length() <= MAX_NATIVE_NAME_LENGTH) return sanitized;
String hash = String.format("%08x", qualifiedName.hashCode());
return sanitized.substring(0, MAX_NATIVE_NAME_LENGTH - hash.length() - 1) + "_" + hash;
}
// If two names collapse into one, fail fast instead of silently overwriting.
String previous = nativeNames.putIfAbsent(nativeName, qualifiedName);
if (previous != null && !previous.equals(qualifiedName)) {
throw new AgentNativeToolException("Native tool name collision: " + previous);
}
名前の長さの上限によって切り詰めが発生するため、異なるツールが同じ名前に短縮された場合は、即座に例外をスローするようにしました。この種の衝突を黙って処理してしまうと、「モデルが間違ったツールを呼び出した」という形で、かなり後になってから発覚します。
ツール一覧そのものも絞り込みました。プロンプト内のドメイン語彙を使ってツール一覧を絞るセレクターを設け、該当がない場合は全件を維持するようにしました。また、挨拶やヘルプのようにツールが不要なリクエストは、MCPセッションを開く前に通常の応答を返して終了させ、ツールカタログには短いTTLキャッシュを設定しました。
2.4 制御レイヤーには手を加えない
今回の作業で最も重視した指標は、パフォーマンスではなく「何が変わらなかったか」でした。実行レイヤーを丸ごと置き換える作業なので、ポリシー・承認・監査を担うToolExecutionGatewayに変更が入っていたとすれば、それは境界の引き方が間違っていたことを意味します。
結果として、ToolExecutionGatewayには一行も変更がありませんでした。実行方式はプロンプトのJSONパースからnative tool callingに変わりましたが、ゲートウェイから見れば、引き続き「サーバーID + ツール名 + 引数」が渡されるだけだったためです。
一方で、fallbackは維持しました。native tool callingが保証するのは形式だけであり、応答が空だったり、モデルが沈黙したりする可能性は依然としてあるためです。ただし、ループモードとfinishReasonをSSE完了イベントと監査ログに併せて公開し、どの経路で終了したかが常に記録に残るようにしました。
3. 試行錯誤から得た教訓
第一に、症状を数えるより、根本原因を見つけるほうが早いということです。当初は、観測結果の膨張・形式エラー・引数検証を別々の項目として挙げていました。どれも、文字列2つだけのエンジンインターフェースという同じ制約から生じており、根本原因を直すと3つの問題が同時に解消しました。個別に対処していたら、3回修正しても、4つ目の症状に直面していたでしょう。
第二に、LLM連携のポートでは、最初から会話配列を第一級の要素として扱うべきです。単発の質疑応答から始める場合でも、messagesを基本のデータ構造にしておけば、後からツールやマルチターンを追加する際にインターフェースを作り直す必要がありません。実際、今回の切り替え作業の半分は、機能追加ではなく、このインターフェースの修正でした。
第三に、依存関係の追加は、宣言時ではなく使用時に完了します。BOMはすでに宣言されていましたが、クライアントモジュールでは同じ機能を手作業で実装しており、その状態がしばらく続いていました。手作業のJSON-RPCをなくしたことで、notifications/initializedの送信漏れバグも根本から解消されました。
第四に、便利な自動化経路が制御境界を迂回できないか、まず確認する必要があります。フレームワークが提供するツールの自動実行は魅力的ですが、その経路を使っていたら、承認と監査を欠いた実行が発生していたでしょう。ChatModel層がツールを実行しないことをコードで確認してから、導入を確定しました。
第五に、フォールバック経路は使われない間にひっそりと古びていきます。その後の整理段階で、正規表現で文をツールに固定する近道と、JSONによる判断ループをあわせて削除しました。どちらの経路も「うまくいかない場合に備えて」作ったものでしたが、実際には設定が空のため一度も使われず、フロー図を二倍にしているだけでした。
4. 移行結果
移行結果は次のようにまとめられます。
|
項目 |
移行前 |
移行後 |
|---|---|---|
|
判断経路 |
プロンプトのJSON解析 + 2回再試行 |
provider native tool calling(構造化されたtool_calls) |
|
会話状態 |
観測記録全体をステップごとに文字列として再構成 |
messages配列にassistant・toolメッセージとして蓄積 |
|
ツール一覧 |
40個以上をすべて送信、1ステップあたり約4,000トークン |
ドメインに基づく選択 + TTLキャッシュ、不要な場合はMCPに進まない |
|
MCPクライアント |
raw HttpClient + 独自実装のJSON-RPC |
Spring AI 2.0(プロトコルの欠落バグが解消) |
|
制御レイヤー |
ポリシー・承認・監査ゲートウェイ |
変更なし(0行) |
native経路とfallback経路のどちらもSpring AIの送信処理を通るようになり、providerコードが二系統に分かれることはありませんでした。ビルドとテストに合格し、単体テストを7種類追加しました。
副次的な効果もありました。native tool callingが要求するmessages配列は、会話セッションに必要なデータ構造とまったく同じだったため、今回の移行によって会話記憶機能の基盤も整いました。その後、複数セッションの会話履歴を追加する際に、サーバーの契約を再設計する必要はありませんでした。
5. 制限事項と今後の計画
第一に、モデルへの依存が残っています。社内で使用しているモデルがtool callを一度に1つだけ返すのか、複数返すのかは実測が必要でした。また、ポリシーに含めた並列実行フラグは、まだループで使われないまま残っています。並列実行は読み取り専用のツールに限定して段階的に有効化するのが安全だと考えています。
第二に、ツール一覧をドメインの語彙で絞り込む方法は、語彙が重複するドメインが増えると限界があります。その後の作業では、すべてのツールを公開する方式を、ツールインデックスと名前指定による読み込みに置き換える方向へ拡張しました。この点は別途まとめる予定です。
第三に、ツールの結果をモデルに言葉で説明させる限り、表現は毎回ぶれます。この問題への対応として、サーバーが表示仕様を提示し、画面側がそのまま描画する方向へ進みました。
今回の作業を通じて、エージェントを自分たちで作ることは、推論を自分たちで作ることを意味するわけではないと、あらためて確認できました。判断を行うのは常にLLMであり、私たちが作るのはループとツールの接続、そしてポリシーと監査です。その境界をコード構造で明確に定めていたため、実行レイヤー全体を置き換えながら、制御レイヤーには一行も手を加えずに済みました。
参考文献
-
Anthropic, “Model Context Protocol Specification”, https://modelcontextprotocol.io/specification
-
Spring AI, “Tool Calling”, https://docs.spring.io/spring-ai/reference/api/tools.html
-
Spring AI, “Model Context Protocol (MCP)”, https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html
-
OpenAI, “Function calling”, https://platform.openai.com/docs/guides/function-calling
-
Ollama, “Tool support”, https://ollama.com/blog/tool-support
Junny