メインコンテンツへスキップ

AIエージェントのツール呼び出しループを止める:5層ガードの実装

13 分で読めますAI API

次の危険なツール実行前に停止し、5層LoopGuardと固定fixtureで、再試行・停止・人間への移管を決める実装ガイド。

LoopGuardが実行前に反復と冪等性を確認し、成功、再試行可能、致命的エラーを別々の終了経路へ分ける図。

AIエージェントが同じツールを呼び続けているなら、モデルへの追加指示より先にruntimeを止めます。

今すぐ止める3手

  1. runをpauseする:自動retryとsub-agentの新規起動を止め、trace、最後の安全なstate、完了済みoperationを保存します。
  2. side effectを閉じる:書き込み、送信、課金、削除などのtoolは、次の実行前にdenyします。workerが別routeから実行できないことも確認します。
  3. 終端理由を返すSTOPPED_LOOP_GUARDとして、停止理由、途中成果、未解決の前提、checkpoint、再開条件をoperatorへ返します。

合格条件は、画面が静かになることではありません。次のunsafe callがtool実行前に拒否され、成功済みの副作用が再実行されず、traceに発火したguardが残り、同じ入力をreplayしても有限回で終わることです。

LoopGuardが実行前に反復と冪等性を確認し、成功、再試行可能、致命的エラーを別々の終了経路へ分ける図。

最大ステップは最後のfuseです。しかし、max stepsだけでは「前に進んでいるか」「そのerrorをretryしてよいか」「成功したtoolを再実行してよいか」は分かりません。本番では、Budget、Error contract、Stuck detection、Idempotency、Graceful stopを一つの実行経路に並べます。

「動いている」と「進んでいる」を分ける

正常な探索でも、searchやreadを何度か使うことがあります。tool名の重複だけで停止すると、必要なpaginationまで誤検知します。反対に、回答文が毎回違っても、取得した事実、完了したsubgoal、cursor、resource versionが変わらないなら進捗はありません。

traceには少なくとも次を記録します。

  • run_id、step、tool名、canonicalized arguments
  • tool resultのkindとerror code
  • 外部stateから導いたprogressVersion
  • side-effect用のoperationKey
  • runtimeが選んだdecisionとguard reason

この情報から、事故を次のように切り分けます。

観測パターン判定に使う信号runtimeの処置
同じtool、同じ有効引数fingerprintの出現回数per-call budget到達前にstop
空白、key順、無意味な既定値だけが違うcanonical JSON同一callとして集計
A → B → A → B短い履歴windowstrategy switchまたはpartial stop
resultは変わるがtask stateが不変progressVersionno-progress budgetでstop
成功したwriteをもう一度要求operation ledgercached successを返し、実行しない
同じ権限errorを繰り返すerror classとpreconditionNEEDS_HUMANへ遷移

fingerprintで削ってよいのは意味のない差だけです。page、cursor、version、tenant、destinationのように結果や副作用を変える値は残します。意味的な類似度をLLMに判定させる方法は補助にできますが、主制御には外部stateの差分を優先します。

Tool resultを5種類に正規化する

retry loopの多くは、toolが文字列しか返さず、controllerが失敗の意味を判断できないところから始まります。client toolを実行してtool_resultをモデルへ戻すのはアプリケーション側の役割だと、Anthropicのtool use概要tool call処理ガイドにも示されています。つまり、再実行の可否もruntimeで明示する必要があります。

kind同じ条件でretry遷移先
SUCCESS目的の取得、commit済みwriteしない次のsubgoalまたはCOMPLETED
PENDING非同期jobが実行中retry_afterとpoll budget内のみbounded wait
RETRYABLE一時的timeout、5xx、管理された429backoff付きで有限回RETRY
PRECONDITIONcredential期限切れ、version conflict条件変更後のみWAITING_FOR_CHANGE
FATAL403、policy denial、invalid schemaしないFAILEDまたはNEEDS_HUMAN

retryは「もう一度」ではなく、失敗を起こした前提を変更する操作です。server指定時間まで待つ、credential scopeを直す、schemaを修正する、新しいcursorを得る、といった変化が必要です。入力もstateも同じなら、retry countを消費する前にloopとして扱います。

5層LoopGuardを実装する

次のJavaScriptはframeworkに依存しない最小構成です。sameCallLimit = 2noProgressLimit = 3は後述の固定fixtureに使った値であり、全ての本番環境に最適な閾値ではありません。

js
const sortValue = (value) => { if (Array.isArray(value)) return value.map(sortValue); if (value && typeof value === "object") { return Object.fromEntries( Object.keys(value).sort().map((key) => [key, sortValue(value[key])]) ); } return typeof value === "string" ? value.trim() : value; }; const callId = ({ tool, args }) => `${tool}:${JSON.stringify(sortValue(args))}`; class LoopGuard { constructor({ maxSteps = 20, maxDurationMs = 60_000, sameCallLimit = 2, noProgressLimit = 3, ledger = new Map(), initialProgressVersion = 0, } = {}) { this.maxSteps = maxSteps; this.deadline = Date.now() + maxDurationMs; this.sameCallLimit = sameCallLimit; this.noProgressLimit = noProgressLimit; this.ledger = ledger; this.history = []; this.step = 0; this.progressVersion = initialProgressVersion; this.stalled = 0; } gate(call) { if (this.step >= this.maxSteps) return this.stop("step_budget"); if (Date.now() >= this.deadline) return this.stop("time_budget"); if (call.operationKey && this.ledger.has(call.operationKey)) { return { action: "use_completed_result", result: this.ledger.get(call.operationKey), }; } const id = callId(call); const sameCount = this.history.filter((x) => x.id === id).length; if (sameCount >= this.sameCallLimit) { return this.stop("same_call_repeat"); } const tail = this.history.slice(-3).map((x) => x.id); if (tail.length === 3 && tail[0] === tail[2] && tail[1] === id) { return this.stop("short_cycle"); } this.step += 1; return { action: "execute", id }; } observe({ id, result, nextProgressVersion, operationKey }) { if (nextProgressVersion > this.progressVersion) { this.progressVersion = nextProgressVersion; this.stalled = 0; this.history = []; } else { this.stalled += 1; } this.history.push({ id, kind: result.kind }); if (result.kind === "FATAL") { const terminalState = ["permission_denied", "policy_blocked", "needs_human"] .includes(result.code) ? "NEEDS_HUMAN" : "FAILED"; return this.stop(result.code || "fatal", terminalState); } if (result.kind === "SUCCESS") { if (operationKey) this.ledger.set(operationKey, result); if (result.terminal) return { action: "complete" }; } if (this.stalled >= this.noProgressLimit) return this.stop("no_progress"); if (result.kind === "SUCCESS") return { action: "continue" }; if (result.kind === "RETRYABLE") { return { action: "retry_after", waitMs: result.retryAfterMs }; } if (result.kind === "PRECONDITION") { return { action: "wait_for_changed_precondition" }; } return { action: "poll_bounded", waitMs: result.retryAfterMs }; } stop(reason, terminalState = "STOPPED_LOOP_GUARD") { return { action: "stop_partial", report: { terminalState, reason, completedSteps: this.step, progressVersion: this.progressVersion, resumeRequires: "verified_precondition_change", }, }; } }

実行側の規約は単純です。gate()executeを返したときだけtoolを呼びます。use_completed_resultなら台帳の結果を返します。stop_partialならplannerも停止します。resume時は保存済みcheckpointのversionをinitialProgressVersionへ渡し、最初の不変resultを新しい進捗と誤判定しないようにします。進捗確認後は反復検知windowを新しくするため、同じjob-idの正常なpollingを累積回数だけで止めません。非終端SUCCESSもno-progress判定を通ります。guardの出力を通常のtool errorとしてモデルへ戻して続行すると、停止判定そのものが次のloop inputになります。

5層の責任は次のように分かれます。

  1. Budget fuse:step、wall-clock、token/costの最悪値を制限する。
  2. Error classifier:retryable、precondition、pending、fatal、successを分ける。
  3. Loop detector:exact repeat、短周期、no-progressをhard capより前に見つける。
  4. Side-effect gate:operation keyと完了台帳で二重実行を防ぐ。
  5. Graceful stop:途中成果、checkpoint、human handoff、resume条件を返す。

hard capが発火したことは、fuseが動いた証拠にすぎません。根本原因の解消、途中成果の保存、副作用のrollbackまで証明したことにはなりません。

冪等性はwriteの直前で確認する

メール送信、決済、issue作成、データ削除は、2回目のstepで既に被害を出せます。20 stepの上限は間に合いません。そのため、同じ業務操作を識別するoperationKeyをcaller側で作り、tool実行直前にcompleted-operation ledgerを確認します。

例えば通知ならtenant + incident + notification_type、注文作成ならcaller生成のrequest idを使えます。ledger確認とbusiness commitは、providerのidempotency protocol、database transaction、unique constraintなどの原子的な境界に置きます。memory上のMapはfixture用であり、multi-worker、process restart、network partitionには不十分です。

二回目が正当な業務操作なら、新しいoperation keyと承認記録を作ります。既存keyを削除して強行すると、監査上は重複実行と区別できません。自動補償できないtoolで不整合が疑われる場合は、人間へのエスカレーションを終端状態にします。

固定fixture 5本の実行結果

Node.js上のfake toolと固定traceで、同じ実装を決定的に実行しました。argument keyはsortし、総step budget、同一call上限2、no-progress上限3、wall-clock、terminal state、単調なprogressVersionを使用しています。

Fixture入力観測した結果判定
success_after_one1回目でterminal success1 call後にcompleted正常完了を止めない
identical_retry同じsearchがretryable、進捗なし3回目の実行前にsame_call_repeat、side effectは2回だけexact repeatを事前阻止
alternating_cycleread/checkが交互、state不変3回実行後にno_progress振動を有限化
changed_state_then_successcursorとprogressVersionが前進異なる3 call後に成功正常な探索を許可
fatal_no_retrywriteがpermission_denied1回で停止fatalを再試行しない

これは、上記fixtureでの制御フローを確認した結果です。productionでの誤検知率、latency改善、費用削減率を示すbenchmarkではありません。

自社の回帰testには、argument jitter、長いPENDING、同一operationKeyの並列実行、再起動後のresume、成功responseの通信断を追加してください。合格判定は「exceptionが出た」ではなく、「guard発火後、fake providerまたはtool counterが増えていない」です。

グレースフル停止とresume

強制killだけではbroken stateが残ります。停止時には次のようなreportを保存します。

json
{ "terminal_state": "NEEDS_HUMAN", "guard_reason": "permission_denied", "completed_operations": ["read-config", "schema-check"], "last_safe_checkpoint": "checkpoint-17", "unmet_precondition": "repository write permission", "resume_condition": "scope change approved and verified", "next_action": "resume_from_checkpoint" }

terminal stateは最低でも分けます。

State意味再開
COMPLETED目的達成不要
STOPPED_LOOP_GUARDrepeat、cycle、no-progress、budgetで停止原因修正とreplay test後
NEEDS_HUMAN権限、承認、判断が必要人間が前提変更を確認後
PARTIAL安全な途中成果を返せる未完部分だけ再開
FAILED回復不能、代替なし新しいrunまたは終了

resumeはcheckpointから始め、completed ledgerと外部stateを再検証します。元の会話を最初から再投入して全toolを再生すると、成功済みoperationまで戻ってしまいます。「前提を変えた」だけでなく、その変更が実際に観測できたことを再開条件にします。

Frameworkの設定だけで終わらせない

主要frameworkには有界実行の仕組みがあります。ただし、business progressや冪等性を自動で理解するわけではありません。

Framework公式に確認できる停止機構application側に残る責任
OpenAI Agents SDKrunnerのmax_turnsと超過時のerror handling。Running agentsprogress、error taxonomy、operation ledger、checkpoint
LangChainmiddlewareでmodel/tool call数を制限し、超過時の挙動を設定。Built-in middlewaretoolをまたぐcycleとstate差分
Google ADKLoopAgentmax_iterationsとsub-agentからのexit signal。Loop agentsresult contract、冪等性、resume policy
Anthropic tool useapplicationがclient toolを実行してtool_resultを返す。Handle tool callscontroller loop全体と再実行可否

OpenAI Agents SDKのreset_tool_choiceは、強制されたtool choiceが次のturnでもtoolを選ばせ続ける特定パターンへの対策です。no-progress detection、総budget、side-effect safetyの代替ではありません。またmax_turns超過は「上限で止めた」ことだけを示し、safe recoveryを保証しません。

本番投入前チェック

  • guardをsystem promptではなく実際のtool execution boundaryに置いた
  • step、wall-clock、token/cost budgetがterminal reasonを返す
  • exact repeat、ABAB、no-progressを固定traceで再現できる
  • SUCCESSPENDINGRETRYABLEPRECONDITIONFATALを分けた
  • side-effect toolが永続operation ledgerを実行前に確認する
  • stop reportに途中成果、checkpoint、guard reason、resume conditionがある
  • resume前に関連preconditionの変化を外部stateで検証する
  • guard後のfake tool counterが増えないことをtestした

LoopGuardはtool control flowを止める仕組みであり、providerへの金額ベースの強制停止とは別です。モデルrequestが外部へ出る前の費用上限も必要なら、LLMエージェントAPIの費用キルスイッチを組み合わせてください。loopとpaid request pathの両方に、独立した停止点を持たせることができます。

#AIエージェント#ツール呼び出し#無限ループ#サーキットブレーカー
Share: