Skip to content
Published on

AIエージェントを本番で運用するということ — 冪等性、予算、そして自信満々の誤答

シェア
Authors

はじめに — トレース6,780件から出てきた重複実行8,042件

数日前、GeekNewsにAsk GN: MCPツールを複数つないでエージェントを運用している方へという投稿が上がりました。筆者はベンチマークのトレース6,780件を分析して重複ツール実行8,042件を見つけ、タスク完了の宣言のような無害な繰り返しを除いたあとも4,249件が残りました。そのうちエンティティIDで確認できるものだけを数えると、状態を変えるツールが実際に重複リソース(ドキュメント、スプレッドシートなど)を作ってしまった事例が159件あります。さらに「Timed out while waiting for response...10.0 seconds」という形のタイムアウト署名が付いた199件と、リソース衝突と見られるエラー74件が加わります。

筆者が自ら明かしている限界が重要です。このデータはすべてベンチマークのトレースであり、実際の本番サービスではありません。そして彼が指摘するとおり、ほとんどの公開データセットはそもそも重複を検出するようには設計されていません — 精度ベンチマークでは重複は単なる誤答として集計されるからです。つまりこの数字は本番での発生率の推定ではなく、「この失敗タイプは存在していて、誰も数えていない」という存在証明に近いものです。

コメントでセッション単位の冪等性キーを使えという提案が出ましたが、筆者は的確な反論をします — NotionやGitHubのようなサードパーティAPIには自分たちのキーを注入できない、というものです。このやり取りがこの記事の出発点です。

リトライ可能なツール呼び出し — 冪等性はフレームワークがくれるものではない

エージェントループの構造が問題を生みます。ループはおおよそこうです — モデルを呼び、応答にツール呼び出しがあれば実行し、結果を会話に付け足し、またモデルを呼ぶ。ここでリトライが三つの層で独立に起こります。

HTTP層。SDKが429や5xxや接続エラーを自動でリトライします。ほとんどのクライアントがデフォルトで2回のリトライを有効にしています。タイムアウトもリトライ対象なので、最悪の場合の実際の待ち時間はタイムアウト掛けるリトライ回数足す1になります。

ツール層。ツールの実装が内部でリトライします。

オーケストレーター層。実行全体が失敗すると最初からやり直します。

三つの層は互いを知りません。もっとも危険な組み合わせは三番目です。ツールを三つ順に呼んでいる途中で二番目がタイムアウトし、オーケストレーターが実行全体をリトライすると、一番目のツールはすでにメールを送っており、二番目は実はDBに書き込んだあと応答を返せなかっただけ、という状態がありえます。リトライのあとにはメール二通とレコード二件が残ります。

タイムアウトが特に厄介なのは、応答がないことが失敗を意味しないからです。上の分析でタイムアウト署名199件が別枠で集計されているのが、この問題を正確に指しています。

解決の骨格は古くからあるものです — 副作用のあるすべてのツール呼び出しに決定的なキーを付け、実行台帳を自分たちの側に置きます。

import hashlib, json

def idempotency_key(run_id: str, step_index: int, tool: str, args: dict) -> str:
    # 1) リトライのあいだに変わるフィールドは必ず除外する。
    #    タイムスタンプ、リクエストUUID、リトライカウンタ、「いま」計算される値。
    volatile = {"request_id", "timestamp", "now", "attempt", "trace_id"}
    stable = {k: v for k, v in args.items() if k not in volatile}

    # 2) シリアライズを固定する。sort_keysがないと同じ引数でも別のキーになる。
    material = json.dumps(
        {"run": run_id, "step": step_index, "tool": tool, "args": stable},
        sort_keys=True, separators=(",", ":"), ensure_ascii=False,
    )
    return hashlib.sha256(material.encode()).hexdigest()

step_indexをキーに入れるかどうかは設計判断です。入れれば「同じ実行の同じステップ」だけが重複排除されるので、モデルが意図的に同じツールを二度呼ぶ正常な動作を妨げません。外せばより攻撃的に止めますが、正常な繰り返し呼び出しまで飲み込みます。私は入れるほうをデフォルトにして、取り消せないツールについてだけ実行単位の全体キーを使います。

サードパーティAPIの問題に戻ると、キーを注入できないときに使える手は三つです。

第一に、自分たちの実行台帳。ツールを呼ぶ前にキーで台帳を引き、すでに成功した記録があれば保存済みの応答をそのまま返します。ウィンドウは24時間程度が無難です。台帳が「呼び出し開始」と「呼び出し完了」を別々に記録してはじめて、タイムアウトのケースを扱えます。

ledger[key] = {
  state:        started | succeeded | failed,
  started_at:   ...,
  external_id:  作成されたリソースの外部ID(成功時),
  response:     キャッシュされた応答,
}

# state == started なのに古い = 私たちは結果を知らない。
# ここでリトライすれば重複、しなければ取りこぼし。次の項目へ進む。

第二に、リード・ビフォア・ライト(read-before-write)。ほとんどのAPIは冪等性キーは受け取らなくても検索は受け取ります。ドキュメントを作る前に同じタイトルと同じ親を持つドキュメントを探し、あればそれを返します。完璧ではありません(競合は残ります)が、上のデータの159件のようなケースはほとんど捕まえます。

第三に、自然キーを自分たちで埋め込む。作成するリソースのどこか — タイトルの接尾辞、説明フィールド、カスタムプロパティ、ラベル — に実行キーを入れます。汚いやり方ですが、リード・ビフォア・ライトを正確にしてくれます。

そしてツールを最初に定義するときに副作用の等級を一緒に宣言しておけば、以降のすべてのポリシーがこの等級にぶら下がります。後ろの表で扱います。

もう一つ。モデルにツール結果を返すとき、失敗した呼び出しも必ず結果として返さなければなりません。エラーを黙って飲み込むと、モデルは応答のないツール呼び出しを見て同じ呼び出しを繰り返します。ほとんどのAPIがツール結果にエラーフラグを立てられるようにしてあるので、それを使います。並列ツール呼び出しも同じです — 一つの応答に複数のツール呼び出しが入っているなら、結果も一度にまとめて返さなければなりません。分けて送るとモデルは並列呼び出しをやめる方向に学習します(Anthropicのツール使用ドキュメントがこの動作を明示しています)。

予算とステップ上限 — 二つの異なる失敗

エージェントは二方向に壊れます。永遠に回るか、早すぎるタイミングで諦めるかです。

ステップ上限は無限ループへの防御です。ループ反復回数の上限であり、ハードカットです。ここでよくやる間違いは、上限に引っかかった終了を正常終了と同じに扱うことです。上限に引っかかったということはタスクが終わっていないという意味なのに、戻り値だけを見ると区別がつきません。終了理由を明示的に返し、上限超過を成功として集計してはいけません。

予算は性格が違います。トークン、コスト、実時間の上限です。そしてここに、ここ数年で生まれた有用な区別が一つあります — モデルが知っている予算と、知らない上限です。

応答トークンの上限のようなものは、モデルが知らないハードカットです。引っかかると文の途中で切れます。一方タスク予算はモデルに知らせる値なので、残りの予算を見ながら自分でペースを調整し、まとめに入ろうとします。Anthropic APIのタスクバジェットがこの方式で(マイグレーションドキュメントに説明があります)、最小値は2万トークンです。二つの違いは実務的に大きいです — ハードカットは切れた成果物を作り、知らせた予算は要約された成果物を作ります。

三つを一緒に張っておくことを勧めます。

  • ステップ上限: 無限ループ防御。超過時は理由を明示し、失敗として集計。
  • コスト予算: 実行単位で累積。超過が近づいたらモデルに知らせ、超過したら中断。
  • 実時間予算: 特にユーザーが待っている同期経路で。ツール一つのタイムアウトではなく、実行全体のデッドラインでなければなりません。

最後の項目を強調しておきたいです。HTTPクライアントのタイムアウト設定はたいていチャンク単位の読み取りタイムアウトなので、バイトが少しずつでも届いているかぎりリセットされ続けます。つまり総所要時間の上限ではありません。実行ループの外側で単調時計を使ってデッドラインを測り、自分で断ち切る必要があります。

非決定的な制御フローの可観測性

普通のサービスではコードが制御フローを定義するので、どの経路が実行されたかはコードを見ればわかります。エージェントはモデルが毎回違う経路を選びます。同じ入力で二度回すとツール呼び出しの順序が変わることがあります。だから「何が起きたのか」を事後に再構成できなければならず、これはあとから付け足せません。

実行一つがトレース一つで、ステップごとに子スパンを作ります。最低限これくらいは残すべきです。

# 実行単位
run.id                    リトライしても同一。実行台帳のキー材料。
run.trigger               user | schedule | webhook | retry
run.terminal_reason       completed | step_limit | budget | error | escalated

# ステップ単位(ループ1回)
step.index                反復回数
step.stop_reason          モデルがなぜ止まったか(ツール呼び出し / 終了 / トークン上限 / 拒否)
step.model_id             途中でモデルが変わったか見るために必要
step.input_tokens         キャッシュの読み書きと分けて
step.output_tokens

# ツール呼び出し単位
tool.name
tool.side_effect          none | read | write | irreversible
tool.idempotency_key
tool.attempt              1, 2, 3 ...
tool.outcome              ok | error | deduped | denied | timeout
tool.latency_ms
tool.external_id          作成されたリソースがあれば

# 累積
budget.cost_spent
budget.wall_clock_ms
escalation.reason         人に渡したなら、その理由

いくつかの設計判断が入っています。

tool.outcomededupedが別枠であるのは、重複排除が働いた回数それ自体が指標だからです。この値が急に増えたら、モデルが同じツールを繰り返し呼ぶ方向に変わったか、どこかでリトライが暴走しているという意味です。静かにうまく処理される代わりに見えなくなってしまうのを防ぎます。

tool.side_effectをスパンに残すのは事後分析のためです。「取り消せないツールが一つの実行で何回実行されたか」を問えなければなりません。

プロンプトとツールの入出力本文を残すかどうかは別の決定です。デバッグには必須ですが、個人情報と秘密がそのまま保存されます。実務的には、デフォルトはメタデータだけ残し、失敗した実行についてだけ本文を短い保持期間で保存する折衷が無難です。そしてその本文がそのままリプレイフィクスチャになります — ツールの応答まで丸ごと保存しておけば、コードを直したあとに同じ状況を決定的に再現できます。本番障害一件が回帰テスト一件に変わります。

標準化の状況も知っておく価値があります。OpenTelemetryのGenAIセマンティック規約は2026年半ば時点でいまだ開発段階であり、1.0がありません。2026年6月12日のv1.42.0リリースで関連する属性とスパンがメインリポジトリから専用リポジトリへ分離されましたが、これは安定化ではなく分離です。つまり属性名はまだ変わりうるということです。規約に従いつつ、ダッシュボードとアラートが属性名に直接ぶら下がらないよう一枚挟むほうが安全です。

ツール単位の権限境界

「エージェントにツールを20個つないだ」という文から事故が始まります。ツールを等級に分け、等級ごとに違うポリシーを張らなければなりません。

等級デフォルト権限リトライ方針必ず残すもの
読み取り専用検索、ファイル読み取り、参照API自動許可自由にリトライ呼び出し引数と結果サイズ
隔離された書き込みサンドボックスのファイル書き込み、一時テーブル自動許可リトライ可能変更されたパス
外部書き込み(取り消せる)チケット作成、ドキュメント作成、ブランチのプッシュ自動許可 + 冪等性キー必須台帳を確認してからリトライ冪等性キー、外部リソースID
外部書き込み(取り消しが困難)メール送信、メッセージ送信、決済、デプロイ人の承認が必要リトライ禁止。失敗はデッドレターキューへ承認者、承認時刻、リクエスト全文
破壊的削除、権限変更、本番設定の変更ツール一覧から外すことを検討リトライ禁止該当なし(原則として露出しない)

いくつかの原則があります。

権限はエージェントではなくツールに付けます。「このエージェントは管理者」と決めると、ツールが一つ追加されるたびに権限の表面が静かに広がります。ツールごとに必要最小限の権限の資格情報を別々に発行するほうがましです。

資格情報はモデルのコンテキストに入ってはいけません。システムプロンプトやツール引数にAPIキーを入れる構成はいまだによく見ますが、その値は会話履歴に永久に残り、ログや要約にも載ります。キーは呼び出し境界の外側で注入し、モデルにはプレースホルダーだけを見せます。

承認はツール呼び出し単位でなければなりません。「今回のセッションではメール送信を許可」のようなセッション単位の承認は楽ですが、承認した一通と承認していない十通を区別できません。承認リクエストには実際に出ていく内容がそのまま含まれていなければなりません — 宛先、件名、本文。要約された説明を見て承認するのは承認ではなく儀式です。

サービスアカウントで検索する構成は権限昇格です。社内ナレッジベースやファイルシステムにつなぐエージェントで特にそうです。問い合わせた人の身元をツール呼び出しまで伝播させ、認可はリソースにもっとも近い地点で強制しなければなりません。

自信満々の誤答とエスカレーション経路

もっとも扱いにくい失敗タイプはエラーではありません。エージェントがタスクを完了したと報告するのに、実際には間違っていたり、半分しかやっていなかったり、頼んでいないことをやっていたりする場合です。エラーにはアラートを張れますが、これには張れません — すべてのシグナルが成功を指しているからです。

三つが揃って必要です。

第一に、主張と証拠を切り離します。プロンプトのレベルで「完了を報告する前に、各主張を今回の実行のツール結果と突き合わせ、証拠を示せるものだけを報告せよ」という指示は実際に効きます。検証されていない項目は明示的に未検証だと言わせます。ここから有用な観測指標が一つ出てきます — 読み取りツールを一度も呼ばずに完了を報告した実行は、ほぼ常に疑わしいです。

第二に、終了条件をコードで確認します。エージェントの自己報告ではなく、外部から確認できる判定基準を置きます。テストが通ったか、チケットのステータスが変わったか、ファイルが存在するか。これができないタスクなら、そもそも自律実行の対象ではない可能性が高いです。

第三に、エスカレーションを正常終了の一種にします。多くの実装が「人に渡す」を失敗経路として扱いますが、そうするとモデルは渡さない方向に圧力を受けます。エスカレーションは成功と同じくらい正当な終了状態でなければならず、渡すときには次のものが一緒に行かなければなりません。

escalation = {
  reason:        「権限不足」 | 「矛盾する情報」 | 「予算超過」 | 「確信不足」 | 「ポリシー上、承認が必要」,
  what_was_done: すでに完了した副作用の一覧(外部リソースIDを含む),
  what_remains:  残っている作業,
  blocking_fact: 詰まった具体的な地点,
  run_id:        引き継げるように,
}

what_was_doneが肝心です。人が引き継ぐときにまず知らなければならないのは「何がすでに実行されたか」です。これがないと人は最初からやり直し、副作用が二度起こります。先ほど実行台帳を置けと言った理由がここでも回収されます。

エスカレーションの条件は明示的に書きます。些細な判断(変数名、同等な二つのアプローチのどちらを選ぶか)は自分で決めて記録だけ残させ、スコープの変更や取り消せない動作については必ず尋ねさせます。この区別を与えないと、エージェントは二つの極端のどちらかに行きます — 何も尋ねないか、すべてを尋ねるかです。

何にアラートを張るか

ダッシュボードは多いのにアラートがない、という状態はよくあります。実際にページを鳴らすべきものと週次レビューで見れば足りるものを分けると、こうなります。

即時アラート

  • 取り消せない等級のツールの実行回数がしきい値を超えたとき。絶対件数で張ります。比率ではなく。
  • 承認なしで実行された、承認必須のツール。0であるべき値なので、1件でアラートです。
  • deduped比率の急増。リトライ暴走やループ異常の先行指標です。
  • エスカレーションキューの滞留時間。人が見ていなければエスカレーションはただの取りこぼしです。
  • 実行あたりコストの上位パーセンタイルの急上昇。平均ではなくp95とp99を見ます。暴走する実行は少数で、平均に埋もれます。

トレンドとして見るもの

  • 終了理由の分布。step_limitbudgetの比重が増えているなら、タスクが難しくなったかプロンプトが劣化しています。
  • 実行あたりのツール呼び出し数の分布。裾が長くなったらループが迷っています。
  • 読み取りツールなしで完了を報告した実行の比率。
  • リプレイフィクスチャで回帰テストを回したときの合格率。

アラートを張ってはいけないものもあります。個別のツール呼び出しの失敗はアラート対象ではありません。エージェントがツールの失敗を見て別の経路を探すのは正常な動作です。失敗そのものではなく、失敗のあとに回復できなかった実行にアラートを張ります。

おわりに — リトライ可能性はプロンプトではなくツール層の性質だ

エージェント運用で繰り返し確認することになるのは、問題の大半がモデル側ではなくその下の層にあるという事実です。モデルが同じツールを二度呼ぶこと自体はバグではありません。二度呼んだときにリソースが二つできることがバグです。

  • 副作用のあるすべてのツールに決定的なキーと実行台帳を付けます。サードパーティが冪等性キーを受け取らないなら、リード・ビフォア・ライトと自然キーの埋め込みで代替します。フレームワークがこれを代わりにやってはくれません。
  • 上限は二種類です。モデルが知らないハードカットは切れた成果物を作り、モデルに知らせた予算は要約された成果物を作ります。両方張りつつ、役割を区別します。
  • 可観測性はあとから付け足せません。実行単位のトレースとツール単位のスパン、そして失敗した実行のリプレイフィクスチャを最初から残します。重複排除が働いた回数も指標です。
  • 権限はエージェントではなくツールに付け、承認は呼び出し単位で実際の内容を見せて取ります
  • エスカレーションを正常終了にします。渡すときにすでに実行された副作用の一覧が一緒に行かなければ、人は最初からやり直しながら同じ副作用をもう一度起こします。

そして最後に、上のGeekNewsの投稿の筆者が自ら付けた但し書きを改めて強調しておきたいです。あの数字はベンチマークのトレースから出たものであり、本番でこの失敗がどれくらいの頻度で起きるのかは誰も数えていないためにわかっていません。重複実行は精度ベンチマークでは単なる誤答として埋もれ、本番ではユーザーがドキュメント二つを見て一つを消しながら静かに消えていきます。数えない失敗は、存在しない失敗のように見えます。