LLM向けTool-use APIは終了条件と再試行不可を返す設計にする
目次
Claude Codeが2025年7月21日に5時間で1,673,680,266トークンを消費したIssueは、単なる使いすぎではなかった。
GitHubの anthropics/claude-code#4095 では、plan-execution loop、cache token explosion、recursive hook calls、API error loopsの4要因が同時に確認されている。
DEV Communityの Tool-use API design for LLMs は、この手の事故を「モデルが賢いか」ではなく「ツール境界が曖昧か」で読む記事だった。
このブログでは以前、tmuxでClaude CodeとCodexを回す自動開発ループで待機中のAPI呼び出し削減やタイムアウトを扱った。
あれはエージェントを外から止める話だった。
今回の話はもう一段内側で、LLMに渡すツール結果そのものを「次に何をしてよいか分かる形」にする設計だ。
ツール結果に終了条件がないと、モデルは確認しに行く
LLMエージェントのループはだいたいこう動く。
flowchart TD
A[ユーザー要求] --> B[LLMが次の操作を判断]
B --> C[ツール呼び出し]
C --> D[ツール結果]
D --> E{目的を達成したと読めるか}
E -->|読める| F[ユーザーへ返答]
E -->|読めない| B
問題はDのツール結果が曖昧なときだ。
たとえば検索ツールが results: [] だけを返すと、モデルは「検索は成功したが0件」なのか「タイムアウトして空配列になった」なのか判断できない。
人間ならログを見る。LLMはもう一度ツールを呼ぶ。
原典が挙げている自己説明的なレスポンスは、検索条件のエコー、総件数、完了フラグ、次の行動ヒントを返す。
ポイントは is_complete と next_action_hint だ。
{
"status": "success",
"query_summary": {
"destination": "Shibuya, Tokyo",
"check_in": "2026-07-12",
"check_out": "2026-07-15",
"guests": 1,
"max_price": 150
},
"results": [
{ "id": "h_1234", "name": "Hotel Granbell", "price": 128, "currency": "USD" },
{ "id": "h_5678", "name": "Shibuya Stream", "price": 142, "currency": "USD" }
],
"total_matches": 2,
"is_complete": true,
"next_action_hint": "Found 2 matches. Present them to the user. Do not search again unless parameters change."
}
next_action_hint は少し気持ち悪い。
APIレスポンスにモデル向けの短い指示を混ぜているからだ。
でも、エージェントのツールレスポンスは人間向けのREST APIとは違う。
呼び出し元がLLMなら、結果データだけでなく会話状態も返すほうが実装に合う。
CLAUDE.mdが肥大化して困ってる人のためのトークン管理ガイドで、MCPを1API=1ツールの鏡像にするとツール定義が増えすぎると書いた。
今回の話も同じ方向を向いている。
LLMに生APIをそのまま見せるのではなく、LLMが迷わない単位へ包む。
MCPサーバーを作るなら、外部APIの薄いラッパーではなく、状態・完了条件・再試行可否を含むツールにするほうが事故りにくい。
空配列と失敗を同じ形で返さない
サイレント失敗は、クラッシュより厄介だ。
例外を握りつぶして空配列を返す実装は、通常のWebアプリでもデバッグしづらい。
LLMエージェントではさらに悪い。
モデルが「条件が厳しすぎた」と読んで、日付、場所、価格、キーワードを少しずつ変えながら呼び直す。
最低限、成功と失敗は形を分ける。
{
"status": "error",
"error_type": "timeout",
"error_message": "Supplier API did not respond within 5 seconds.",
"retryable": true,
"retry_after_ms": 2000,
"max_retries_remaining": 1
}
retryable: false の意味は大きい。
認証エラー、権限不足、入力値の論理エラーは、同じ引数で再実行しても成功しない。
モデルに「再試行してよいか」を推測させると、だいたい余計な再試行が生まれる。
この分離は、AIコーディングツールの壊れ方3種で書いた使用量枯渇の話ともつながる。
サブエージェントや自動修正ループでは、失敗の見え方が曖昧なほど呼び出し回数が膨らむ。
「失敗した」だけでは足りない。
「もう一度やる価値がある失敗か」をツール側で返す必要がある。
予算はプロンプトではなく実行側で切る
原典でいちばん現実的だったのは、オーケストレータ側の上限だ。
max_tool_calls と max_total_cost_usd を持ち、どちらかに達したらツールを無効化して最後の返答を作らせる。
これはモデルへのお願いではなく、実行基盤の制約だ。
「必要以上にツールを呼ばないで」とプロンプトに書いても、モデルが混乱しているターンでは効かない。
呼び出し回数、推定コスト、経過時間、同一ツールの連続回数は、LLMの外側で数えたほうがいい。
Claude Codeの1.67Bトークン事故でも、Issue本文には436件の0秒間隔リクエスト、224 requests/sec、1分で436Mトークンといった異常値が並んでいた。
この規模まで進む前に、セッション単位のトークン増加率やツール呼び出し回数で止めるべきだった。
flowchart TD
A[LLMレスポンス] --> B{tool_callsあり}
B -->|なし| C[最終返答]
B -->|あり| D{呼び出し予算内か}
D -->|内側| E[ツール実行]
E --> F[履歴へ追加]
F --> A
D -->|超過| G[toolsなしで最終返答を強制]
自分でエージェント基盤を組むなら、ここは早めに入れたい。
ログ出力やダッシュボードより先でもいい。
上限がないエージェントは、失敗したときに「止まる」のではなく「使い続ける」。
重複呼び出しは実行せず、前の結果を読ませる
同じツールを同じ引数で2回呼んだとき、外部APIへ再送する必要はない。
原典では直近5件のツール呼び出しをハッシュ化し、同一署名なら実行せず duplicate_call_blocked を返していた。
本番では全ツール呼び出しの約8%が不要な重複だったという。
ここで返すべきなのはHTTP 409のような機械的エラーではなく、LLMが復帰できる説明だ。
{
"status": "duplicate_call_blocked",
"message": "This exact search_hotels call was already made earlier in this conversation. Use the previous result instead of calling again.",
"retryable": false
}
「前の結果はもうコンテキストにある」と明示するのが効く。
モデルは自分の直前の出力やツール結果を見落とすことがある。
特に長いセッションでは、同じファイルを何度も読む、同じテストを何度も回す、同じAPIを少しだけ引数を変えて叩く動きが出る。
Claude Codeの品質劣化を定量分析したIssueの記事では、234,760件のツール呼び出しからRead:Edit比や無駄な停止フレーズを見ていた。
あの記事はモデル側の思考深度の話だったが、今回の設計は別の守り方になる。
モデルが浅く考えて同じ操作に戻っても、実行側が「それはもうやった」と返せる。
境界バリデーションはLLMの前ではなくツールの直前に置く
LLMに正しいJSON schemaを渡しても、実行時の値は壊れる。
日付が過去、チェックアウトがチェックインより前、人数が範囲外、数値が文字列、こういうミスは普通に出る。
これを外部APIまで流してしまうと、サプライヤー側の謎エラーが返り、モデルがまた推測で修正を始める。
境界で落とすほうがいい。
{
"status": "validation_error",
"errors": [
{
"field": "check_out",
"message": "check_out must be after check_in"
}
],
"user_facing_hint": "Confirm the checkout date with the user before retrying.",
"retryable_after_correction": true
}
PydanticやZodのようなバリデータを使うと、ツール定義用のschemaと実行時検証を揃えやすい。
ただしschemaを出すだけで終わらせない。
LLMがschemaに従わなかったときのエラーも、LLMが読める形にする。
ここは通常のAPI設計とかなり違う。
人間向けAPIなら、422とフィールドエラーで十分なことが多い。
LLM向けツールでは、次に人間へ確認すべきなのか、モデルが自分で補正して再試行してよいのか、ツール側が書いて返すほうがいい。
これ全部、コンテキスト記憶が壊れている話だ
ここまで並べて気づくのは、5パターン全部が同じ根を持つということだ。
is_complete が要るのは、モデルが「自分はもう十分な情報を得た」と判断できないからだ。
retryable: false が要るのは、「同じ条件で失敗した事実」を次のターンまで保持しないことへの対策だ。
duplicate_call_blocked が要る理由も同じで、モデルは「3ターン前にこれをやった」ことを参照しない。
LLMのコンテキストウィンドウは、そこに書いてあるだけで参照できているとは限らない。
Transformerのattentionは位置が遠いほど薄まるし、長いセッションではmiddle部分のツール結果に注意が向かなくなる。
128kトークンのコンテキストがあっても、モデルが実質的に使える「作業記憶」は遥かに狭い。
人間に例えるなら、机の上に資料が100枚広がっているが、目の前の3枚しか見ていない状態に近い。
ツール設計で is_complete や next_action_hint を返すのは、「いま手元にある3枚にこの情報を載せておく」ということだ。
モデルの読み飛ばしを、結果の側から補償している。
Claude Codeの1.67Bトークン事故で起きていたのも、セッションが長くなるにつれ以前のツール結果やplan stateがattentionの外に落ちたことで、同じ操作の再実行がループしたパターンだ。
予算上限や重複検出はそのループを外から断ち切る仕組みだが、根本にあるのは「さっき成功したことをモデルが覚えていない」だ。
MCPサーバーを設計するとき、この視点があるかないかで判断が変わる。
返すデータ量を増やすか減らすかの基準が「正確さ」ではなく「モデルが最新ターンだけ見て判断できるか」になる。
直前のツール結果1つで状況が把握できるなら、モデルは過去を掘り返さない。
結果が断片的で、3ターン前のレスポンスと突き合わせないと意味が取れない形なら、モデルは確認のためにもう一度呼ぶ。
原典のパターンは「ループ防止」「サイレント失敗防止」として整理されているが、守っているのはLLMの短期記憶の脆さだ。
コンテキスト圧縮が起きるとツール結果は消える
長いセッションでは、エージェント基盤がコンテキストを圧縮する。
Claude Codeなら会話履歴が一定量を超えるとcompactionが走り、古いターンの詳細が要約に置き換わる。
このとき最も情報が落ちるのがツール結果だ。
ファイルの中身、API応答のJSON、テスト出力、これらは要約では「ファイルを読んだ」「APIを呼んだ」程度に縮む。
圧縮後にモデルが参照できるのは、要約テキストと直近の数ターンだけだ。
3ターン前の検索結果が results: [] だったのか、2件ヒットしたのか、エラーだったのか、要約からは読み取れない。
だからモデルはもう一度同じツールを呼ぶ。
ここで is_complete: true や retryable: false がツール結果に含まれていれば、圧縮後の要約にもその事実が残りやすい。
「検索完了、2件取得、再検索不要」は要約しても意味が残る。
「results: [...]」は要約すると中身が消える。
ツール結果を自己完結型にする設計は、attentionの弱まりだけでなくcompactionにも耐える。
各ツール結果が「これだけ読めば状況が分かる」形なら、圧縮後も判断材料が消えない。
エージェントメモリは同じ問題をセッションをまたいで解こうとしている
MemGPTやLettaのようなエージェントメモリは、コンテキストウィンドウの外に永続的な記憶を置く。
セッションが切れても、前回どこまで進んだか、どのAPIが失敗したか、ユーザーの好みは何か、を保持する。
RAGも同じ方向だ。
全情報をコンテキストに載せるのではなく、必要になったとき検索して取り出す。
ツール結果の設計は、これとは別のレイヤーで同じ問題に対処している。
メモリシステムはセッション間の記憶、ツール結果設計はセッション内の短期記憶だ。
片方があっても片方は不要にならない。
永続メモリに「前回のホテル検索で2件見つかった」と記録されていても、今回のセッション内でモデルが同じ検索を繰り返すのは防げない。
それを防ぐのはツール結果の is_complete と重複検出であって、長期記憶ではない。
逆に言えば、ツール結果をどれだけ丁寧に設計しても、セッションをまたいだ繰り返しは防げない。
「昨日と同じ検索を今日また実行する」を止めるには、セッション間の記憶が要る。
ツール設計とメモリは、同じ記憶の問題を短期と長期で分担している。
ステートレスなツール結果はエージェントでは矛盾する
REST APIの設計原則はステートレスだ。
各リクエストが独立し、サーバーはクライアントの状態を持たない。
だがLLMエージェントのツールは、呼び出し元が「状態を持てない」側だ。
モデルは直前のレスポンスは見えるが、5ターン前のレスポンスは実質的に見えていないかもしれない。
だからツール結果はステートフルに設計するほうがいい。
「いまの状況はこうで、ここまで完了していて、次はこれをすべきか判断してほしい」を毎回返す。
RESTの美学には反するが、呼び出し元が人間のHTTPクライアントではなくLLMである以上、ステートレスな前提が通用しない。
MCPのツール定義でもこれは同じだ。
MCPサーバーが内部でセッション状態を保持し、各ツール結果に「現在の進捗」を含めて返す。
モデルは毎回、最新のツール結果だけで次の判断ができる。
過去のツール結果をスクロールバックする必要がなくなれば、ループも減る。
ツール呼び出しの記録がretryableを書き換える
is_complete も retryable も、いまの設計では静的だ。
ツール開発者が「このエラーは再試行可能」「この結果は完了」と決めて、コードに書く。
でも実際には、retryableかどうかは呼び出しの文脈で変わる。
たとえば外部APIのレートリミットエラー。
retryable: true にして retry_after_ms を返すのは正しい。
だが同じセッションで3回連続レートリミットに当たっているなら、4回目は retryable: false のほうが合理的だ。
これは静的なフラグでは表現できない。
ツール呼び出しの記録を蓄積すると、この判断を動的にできる。
セッション内の呼び出し履歴だけでなく、過去のセッションでの成功率、リトライ後の解決率、同一エラーの再発パターンを見る。
「このAPIのtimeoutエラーは、リトライで解決した割合が12%」「このvalidation_errorパターンは、モデルが自力で修正できた実績が0件」という記録があれば、retryableの値をハードコードではなく分類器の出力にできる。
ここで言う分類器は、大げさな機械学習モデルでなくてもいい。
直近N件のツール呼び出しログから特徴量を抽出して、リトライ成功率を予測するだけだ。
ロジスティック回帰やXGBoostでも十分動く。
特徴量は、エラー種別、同一ツールの連続呼び出し回数、セッション経過時間、直前の結果のステータスあたりが効く。
同じ考え方はループ検出にも使える。
前半で触れた重複呼び出し検出は、ツール名と引数のハッシュ一致で判定していた。
だがLLMのループは完全一致ではないことが多い。
検索キーワードを少しずつ変える、日付をずらす、件数を変えるといった「微妙に違う同じ操作」が続く。
分類器があれば、引数の類似度とセッション内の呼び出しパターンから「ループの兆候」を検出できる。
完全一致の重複検出はif文で書けるが、fuzzyなループ検出は分類器のほうが向いている。
この長期記録は、前のセクションで書いたエージェントメモリとは層が違う。
エージェントメモリはモデルの作業状態を保持する。
ツール呼び出し記録は、ツール基盤側のオペレーションデータだ。
モデルから直接参照するものではなく、オーケストレータがretryableやduplicate判定を下すための裏側の情報になる。
分類器がセッションをまたいで学習すると、ツールごとの「リトライ成功プロファイル」が蓄積される。
外部ホテル検索APIのtimeoutは5秒後のリトライで60%解決するが、決済APIの認証エラーはリトライしても0%解決しない。
この違いを静的にコードで分岐するのではなく、実績データからretryableのデフォルト値を自動で決める。
ツール開発者が「たぶんリトライ可能」と雑に設定したフラグが、運用データで校正される形になる。
同じデータはツール設計の品質指標にもなる。
特定のツールだけリトライ率やループ発生率が突出して高ければ、そのツールのレスポンス設計に問題がある。
is_complete が足りないのか、エラーメッセージが曖昧でモデルが状況判断できないのか、バリデーションが甘くて無効なリクエストが外部APIまで到達しているのか。
長期記録は、オーケストレータの実行時判断だけでなく、ツール自体の再設計にフィードバックできる。
人間向けのAPMがレスポンスタイムやエラー率からボトルネックを特定するのと構造は同じだ。
エージェント向けでは、リトライ率・ループ率・重複率がツール設計のボトルネックを示す指標になる。
違いは、観測結果がダッシュボードに出るのではなく、分類器を経由してオーケストレータの判断ロジックに直接反映されるところだ。