はじめに:なぜLLMアプリは「同じことを2回実行」してしまうのか

LLMエージェントの書き込み処理で「同じレコードが2件登録された」「同じトランザクションが二重に実行された」という事象に遭遇したエンジニアは多いだろう。多くの場合、この問題の原因はモデルの推論品質やプロンプトの設計に求められがちだが、実際にはそうではない。エージェントの再試行による重複実行は、LLMを載せた分散システムの問題である(cyclopt_dimitrisk氏の解説記事)。

問題の核心は「タイムアウトの意味」に対する認識のズレにある。HTTPリクエストがタイムアウトしたとき、多くの開発者は「アクションが発生しなかった」と解釈する。しかし実際には、タイムアウトは単に応答が得られなかったことを示すだけで、バックエンドでは処理が正常に完了している可能性が高い(同記事)。つまり、クライアント側から見た「失敗」は、サーバー側では「成功」になっているケースが頻発する。

この認識のズレが放置されると、エージェントはタイムアウトを検知した時点で再試行を発行し、すでに完了している処理を2回実行してしまう。結果として、重複書き込みやデータ不整合という致命的なバグが生まれる。モデルが「同じことを2回指示した」わけではなく、インフラ層の不確実性を吸収する仕組みが欠落しているのだ。

本記事では、この問題を分散システムの基礎理論(冪等性・永続ログ・状態管理)に立ち返って解決する設計指針と実装パターンを提示する。

前提知識:LLMアプリにおける「不確実性」の3つの層

LLMアプリの信頼性設計を議論する前に、どの層で不確実性が生じるのかを整理しておく必要がある。従来のWebサービスでは、HTTPステータスコードやエラーレスポンスによって「成功/失敗」の境界が比較的明確だった。しかしLLMを統合したエージェントでは、この境界が曖昧になる要因が3層にわたって存在する。

第1層:ネットワークのタイムアウト

最も古典的だが、LLMアプリでは特に顕著になる層である。LLMの推論には数秒から数十秒のレイテンシが発生し、外部API(MCPサーバーやサードパーティのAPI)を呼び出す場合も同様にネットワーク往復が発生する。クライアント側でタイムアウトがトリガーされたとき、サーバー側では処理が進行中または完了している可能性がある。この状態を「half-open state」と呼ぶが、クライアントの視点からは「失敗」に見えるため、再試行が発行されやすい。

第2層:モデル出力の確率的性質

LLMの出力は温度パラメータやサンプリング戦略によって確率的に生成される。同じプロンプトに対して2回実行すると、ツール呼び出しの引数が微妙に異なる場合がある。この層の不確実性は、冪等性キーの設計に直接影響する。後述するが、モデルが生成したトークンやIDをキーにすると、再試行ごとに値が変わるため重複排除が機能しなくなる。

第3層:ビジネスロジックの実行状態

ツール呼び出しが成功したとしても、その処理がビジネスレベルで「完了」したとは限らない。例えば、注文を作成するAPIがHTTP 200を返したとしても、在庫の確保が部分的に失敗していた場合、ビジネス的には「失敗」である。この層の不確実性は、単なるネットワーク問題ではなく、アプリケーションの整合性チェックの設計に依存する。

これら3層が重なることで、LLMエージェントにおける「再試行すべきか否か」の判断が、従来のWebサービスよりもはるかに複雑になる。特に第1層と第3層が同時に問題になると、クライアント側からは「何が起きたのか」が完全に不透明になる。この不透明さをどう扱うかが、信頼性設計の核心である。

根本解決①:冪等性キーの設計と生成タイミング

再試行による重複実行を防ぐ最も基本的な手法は、冪等性(idempotency)の保証である。冪等性キーは、同じ意図を持つアクションに対して一意の識別子を与えることで、サーバー側が「このリクエストは以前処理済みである」と判定できるようにする仕組みだ。

生成タイミング:アクション実行前のアプリケーション層で

冪等性キーの生成タイミングは、アクションの最初の実行前にアプリケーション層で決定されなければならない。つまり、LLMがツール呼び出しを生成した時点で、その呼び出しの意図を反映したキーを生成し、再試行が発生した際に同じキーを再利用する(cyclopt_dimitrisk氏の解説記事)。サーバー側がキーを生成する設計では、クライアントが再試行を判断する時点でキーが手元にないため、このアプローチは機能しない。

具体的な実装では、エージェントがツール呼び出しを決定した直後に、その呼び出しの引数からキーを導出する。例えば、注文作成ツールを呼び出す場合、{customer_id}:{item_id}:{date} のような形式でキーを生成し、それをHTTPヘッダーやリクエストボディに付与して送信する。

キーの設計:トークンではなくビジネス引数を使用する

ここで重要な設計判断が1つある。LLMが生成したトークンやセッションIDをキーに使うべきか、それともビジネス文脈を反映した引数を使うべきかである。結論から言えば、後者である。LLMからの呼び出しにおいて、冪等性キーはトークンではなく引数(スラッグや日付など)であるべきである(worklore.devの検証記事)。

その理由は単純だ。LLMの出力は確率的であり、同じ意図のツール呼び出しを2回生成しても、生成されるトークンやIDは異なる場合がある。もしキーにモデル生成のトークンを使えば、再試行のたびに新しいキーが生成され、サーバー側はそれを「新しいリクエスト」として扱ってしまう。結果として、冪等性が保証されず、重複実行が防げなくなる。

一方、ビジネス引数(例:注文番号、スラッグ、日付、内容のハッシュ)は、同じ意図を持つ呼び出しであれば再試行しても同じ値になる。これにより、サーバー側が「このキーは以前処理済み」と判定し、2回目の実行をスキップできる。

キーの生成ソース特性リスク
モデル生成トークン(セッションID等)再試行ごとに値が変わる可能性が高い冪等性が保証されない。重複実行が起きる
ビジネス引数(スラッグ・日付・内容ハッシュ)同じ意図なら再試行でも同一値引数の設計が不適切だとキー衝突が起きうる

ただし、ビジネス引数ベースのキーにも注意点がある。引数が十分に一意でなければ、本来は別々のアクションなのに同じキーが生成され、正当なリクエストが誤ってスキップされてしまう(キー衝突)。例えば、日付だけでキーを生成すると、同じ日中に複数の異なる注文が同じキーになり、後続の注文が破棄されてしまう。キーの粒度は「同じ意図の再試行」を識別しつつ「別の意図」を区別できるレベルに設計する必要がある。

根本解決②:永続的な意図ログ(Intent Log)の実装

冪等性キーで重複排除の「仕組み」を設計したとしても、そのキー自体がメモリ上にのみ存在すれば意味をなさない。LLMエージェントの実装では、LangChainのメモリやコンテキストウィンドウに意図の記録を保持するケースが少なくない。しかし、これらの保存先はプロセスのクラッシュや再起動時に消失する可能性がある(Dimitrisk氏の解説記事)。つまり、エージェントがタイムアウト後に再起動したとき、「今何をしようとしていたのか」「すでに実行済みか否か」を判断する手がかりが失われていることになる。

永続ログの書き込みタイミング

永続的な意図ログ(Intent Log)の設計で最も重要なのは、書き込みのタイミングである。ログはアクションを実行する「前に」ディスクやデータベースに書き込まれなければならない(Dimitrisk氏の解説記事)。実行後に記録する実装は、実行と記録の間にクラッシュが起きればログ自体が欠落し、復旧時に「何も起きていない」状態として扱われてしまう。これは二重実行を引き起こす典型パターンだ。

具体的には、以下の4段階のライフサイクルをログに記録する。

  1. intent_created:冪等性キーとアクションの引数を記録。この時点で「実行予定」であることが永続化される。
  2. dispatched:外部APIやツールへの呼び出しを発行したことを記録。
  3. confirmed:応答を受信し、成功または失敗が確定したことを記録。
  4. settled:最終状態(成功・失敗・破棄)を確定し、ログをアーカイブ。

プロセスが再起動した際、intent_createdやdispatchedの状態で止まっているエントリを発見すれば、その意図が「実行中だが結果不明」であることを把握できる。この情報がなければ、エージェントは新しいリクエストとして再実行し、重複が起きる。

復旧時の判断ロジック

再起動後の復旧処理では、ログの状態に応じて分岐する。intent_created(まだ呼び出し未発行)の場合は安全に再実行できる。dispatched(呼び出し済みだが応答未取得)の場合は、後述するunknown状態の扱いと同様に、まず相手側が処理済みかを確認してから判断する。confirmed以降の状態では再実行は不要であり、結果の保存処理のみを補完する。

flowchart TD
    A["プロセス起動"] --> B["Intent Logスキャン"]
    B --> C{"状態は?"}
    C -->|"intent_created"| D["安全に再実行"]
    C -->|"dispatched"| E["相手側にステータス確認"]
    C -->|"confirmed / settled"| F["結果保存のみ補完"]
    E --> G{"処理済みか?"}
    G -->|"Yes"| F
    G -->|"No / 不明"| H["unknownとして保持"]
    D --> I["dispatchedに更新"]

この設計の核心は、「実行する前に記録し、記録から復旧する」という一方向性にある。メモリに依存する実装ではこの一方向性が保証されず、クラッシュのたびに状態がリセットされる。永続ログを介すことで、プロセスの生死に関わらず意図の履歴が保全され、アトミックな復旧が可能になる。

状態遷移管理:「unknown」状態の扱いと整合性チェック

外部APIへの呼び出しは、成功・失敗の2値で片付かない。ネットワークの断絶やタイムアウトにより、「送信はしたのに結果が分からない」という第3の状態が発生する。この状態をunknownと呼ぶが、ここでの設計判断が再試行バグの有無を分ける。

なぜ盲目的なリトライが危険か

多くの実装では、タイムアウト発生時に「失敗」とみなして自動的にリトライする。しかし、タイムアウトはアクションが発生しなかったことを意味せず、単に応答が得られなかったことを示すだけである(Dimitrisk氏の解説記事)。バックエンドでは処理が完了している可能性が高く、その状態で再実行すれば二重書き込みが起きる。

したがって、unknown状態では自動再試行をブロックし、明示的な整合性チェック(フェッチ処理やステータス確認)をトリガーすべきである(Dimitrisk氏の解説記事)。このガード節があることで、ネットワーク断による一時的な不明状態と、実際のビジネスロジックの重複実行を区別できる。

状態遷移の設計

外部呼び出しの結果を表す状態を以下のように定義する。

stateDiagram-v2
    [*] --> pending
    pending --> success : 応答受信(2xx)
    pending --> fail : 明確なエラー(4xx/5xx)
    pending --> unknown : タイムアウト/接続断
    unknown --> success : 整合性チェックで処理済確認
    unknown --> fail : 整合性チェックで未処理確認
    unknown --> unknown : チェック不能(再ポーリング)
    success --> [*]
    fail --> [*]

この遷移図から設計上の制約が読み取れる。pendingからfailへの遷移は、サーバーが明確なエラーコードを返した場合にのみ許可される。4xx(クライアントエラー)や5xx(サーバーエラー)は「処理が完了し、結果が確定した」ことを意味するため、安全に再実行できる。一方、タイムアウトや接続断はfailではなくunknownに遷移する。ここをfailとして扱ってしまうと、自動リトライが二重実行を引き起こす。

整合性チェックの実装パターン

unknown状態から回復するための整合性チェックには、いくつかのパターンがある。最も単純なのは、冪等性キーを付与したGETリクエストで結果を確認する手法である。サーバーが冪等性キーでレスポンスをキャッシュしていれば、200 OKで元の結果を返す。この場合、unknownからsuccessへ遷移し、再実行は不要である。

サーバー側にステータス確認エンドポイントがない場合は、ビジネスロジックの副作用を確認する。例えば、注文の作成が目的であれば、冪等性キーに対応するレコードがデータベースに存在するかをクエリする。存在すれば処理済みと判定し、存在しなければ未処理と判断して再実行する。この確認処理自体も冪等性キーを付与して実行し、確認処理の再試行で問題が起きないよう設計する。

チェックが一定回数失敗した場合(サーバーが完全にダウンしている等)は、unknown状態を保持したままアラートを出し、人手による介入を促す。自動でfailに降格させると、サーバー復旧後に「未処理」と誤認されて二重実行が起きる可能性がある。

実装パターン:オフライン対応とマルチデバイス環境での冪等性

前述の冪等性キーと永続ログの設計は、単一プロセスが単一サーバーとやり取りする前提で成立する。しかし、モバイルアプリやエッジデバイスでは、ネットワークが途切れた状態で操作が蓄積され、再接続後にまとめて同期されるシナリオが生まれる。この環境では、冪等性の設計がより複雑になる。

オフライン操作の同期

オフライン対応のアプリでは、ユーザーがネットワークなしで操作を行う。その操作はローカルにキューイングされ、接続回復時にサーバーへ送信される。このとき、サーバー側は各操作に一意なop_id(操作ID)とイベントタイムスタンプを付与して受け取り、同じトランザクション内で結果を保存する必要がある(worklore.devの検証記事)。

op_idはクライアント側で生成され、冪等性キーの役割を担う。同じ操作を2回送信しても、サーバーはop_idが重複していることを検知して2回目をスキップする。イベントタイムスタンプは、操作が「いつ」発生したかを記録し、後述する衝突解決の基準となる。

// オフライン操作の同期処理(擬似コード)
// 各操作は op_id + timestamp を持つ
function syncOperation(op: { op_id: string; timestamp: number; action: string; payload: any }) {
  // 冪等性チェック:同じ op_id が処理済みか
  const existing = db.find("operations", { op_id: op.op_id });
  if (existing) {
    return existing.result; // 2回目以降はキャッシュを返す
  }

  // 同一トランザクション内で操作実行と結果保存を行う
  db.transaction(() => {
    const result = executeAction(op.action, op.payload);
    db.insert("operations", {
      op_id: op.op_id,
      timestamp: op.timestamp,
      result: result,
      status: "settled"
    });
  });
}

ここで重要なのは、操作の実行と結果の保存を同じトランザクションにまとめる点である。実行は成功したが保存前にクラッシュした場合、op_idが未登録のままとなり、再接続時に再実行されて二重書き込みが起きる。トランザクション内で原子性を持たせることで、このギャップをなくす。

マルチデバイスでの衝突解決

複数のデバイスが同じレコードを更新する場合、同期の到着順序に依存しないマージルールを定義する必要がある(worklore.devの検証記事)。例えば、2台のデバイスが同じタスクを「完了」にマークした場合、どちらが先にサーバーに到着しても同じ結果になるよう設計する。

マージルール動作適用例
最早の終了時刻優先timestampが最も早い方を採用タスクの完了・キャンセル
最終更新者勝(Last-Writer-Wins)到着順に関わらず最新timestampを採用テキストフィールドの編集
オペレーションベースのマージ操作の差分を適用して統合リストへの要素追加・削除

到着順序に依存する実装(「先に届いた方を採用」など)は、ネットワークの遅延や再接続のタイミングで結果が変わる非決定的な挙動を生む。同じ操作列を同じ順序で適用しても、到着順が変われば最終状態が異なる。これはデバッグが極めて困難であり、ユーザーには「同じ操作なのに結果が違う」という不整合として見える。

決定論的なマージルール(最早の終了時刻優先など)を採用すれば、どの順序で同期が到着しても同じ最終状態に収束する。この「収束性」が、オフライン対応の分散システムにおける整合性の根拠となる。実装上は、各操作にタイムスタンプを付与し、サーバー側でタイムスタンプの比較のみで勝敗を判定するようにする。到着時刻ではなく、操作が発生した時刻を基準とすることで、ネットワークの非対称性への耐性も得られる。

実例から学ぶ:MCPサーバーにおける再試行バグの現実

理論的な説明だけでは、再試行バグの深刻さが伝わりにくいかもしれない。ここで、実際にMCP(Model Context Protocol)サーバーを運用した際の計測結果を紹介する。ある開発者が、自前のMCPサーバーが提供する書き込みツール31件すべてに対して、同じリクエストを2回送信するテストを実施した。その結果、8件で重複レコードの作成、あるいは誤ったレコードへの書き込みが確認された(worklore.devの検証記事)。

31件中8件、つまり約26%の書き込みツールが再試行で問題を起こした。この数値が示すのは、MCPサーバーのツール実装に冪等性制御が組み込まれていない場合、再試行が「稀に発生するエッジケース」ではなく「ほぼ確実に発生する障害」であるという事実である。LLMエージェントはタイムアウトやネットワーク揺らぎを理由にリトライを行うため、この26%は本番環境でも同等の割合でバグを生む。

「誤ったレコードにヒット」とは何か

「重複」は比較的直感的だが、「誤ったレコードにヒット」という現象はより厄介である。これは、再試行リクエストが本来対象とするレコードではなく、別のレコードに対して書き込みを行うケースを指す。例えば、注文IDの参照がタイムアウトした後にリトライされた際、参照クエリが新しいレコードを返却し、そのレコードに対して更新が適用されてしまう。データが壊れるだけでなく、ユーザーには「自分が操作したはずのデータが変わっていない」という不自然な体験として現れる。

この現象は、リトライが「同じリクエストの再送」ではなく「新しいリクエストとして再実行」される場合に生じる。つまり、リトライロジックが冪等性キーを再利用せず、毎回新しいコンテキストでリクエストを組み立てている場合である。前節で述べた「ビジネス引数ベースのキー」が欠如している典型例である。

小規模なエージェントであっても、書き込み処理を含む限りこの確率でバグが発生しうる。MCPサーバーのツールは「LLMが呼ぶ関数」であり、その呼び出し元が確率的なモデルである以上、同じ入力が同じタイミングで2回届く可能性を設計前提に置く必要がある。これは「LLMの精度を上げる」ことで解決できる問題ではなく、サーバー側の実装で防がなければならない分散システムの課題である。

設計判断のトレードオフ:複雑さ vs 信頼性

冪等性キーの生成と永続ログの実装は、コードの複雑さが増加する。リクエストの受付処理にキー生成のロジックを追加し、DBにログレコードを書き込み、リトライ時にキーの照合を行う処理を挟む。この追加処理がもたらすレイテンシは、通常数ミリ秒程度だが、DBの負荷やログの保持期間によっては体感されるレベルに達する場合もある。

では、このコストをどこまで許容すべきか。判断の基準は「エージェントが書き込みを伴うか」である。読み取り専用のエージェント(検索・要約・分類など)では、冪等性制御が不要な場合が大半である。同じクエリを2回送っても結果は同じであり、副作用がないため重複の概念自体が成立しない。一方、書き込みを伴うエージェント(注文作成、メール送信、ファイル更新など)では、冪等性制御は「nice to have」ではなく「must have」である。

実装要素追加コスト得られる保証不要なケース
冪等性キーの生成・照合キー設計+DB照合(数ms)重複書き込みの排除読み取り専用エージェント
永続意図ログ実行前DB書き込み+復旧ロジッククラッシュ後の安全なリトライ判定プロセス再起動がない環境
unknown状態のガード節状態管理+確認クエリの追加二重実行の防止外部呼び出しがないローカル処理
決定論的マージルールタイムスタンプ付与+比較ロジックマルチデバイス同期の収束性単一デバイス・単一セッション

トレードオフを調整する際の考慮点は3つある。第一に、ログの保持期間である。冪等性キーの照合が有効である期間は、リトライが実際に発生しうる時間窓と一致すればよい。例えば、HTTPタイムアウトが30秒で、最大リトライが3回である場合、キーの照合が有効である必要なのは数分程度である。7日間保持するより、1時間後に削除する方がストレージコストを抑えられる。

第二に、整合性チェックの頻度である。unknown状態の解決をリアルタイムで行う場合、確認クエリがリトライのレイテンシに直結する。一方、バッチ処理で1分ごとにunknown状態のレコードを走査して解決する方式なら、リトライの応答時間は短く保てるが、ユーザーには「処理中」の状態が1分程度長く見える。どちらが適切かは、ユーザー体験の許容範囲とバックエンドの負荷で決める。

第三に、実装の段階的導入である。いきなり全ツールに冪等性制御を適用するのではなく、書き込み頻度が高く・影響が大きいツール(例:注文作成、決済処理)から優先的に実装し、読み書きの比率が低いツールは後回しにする。これにより、信頼性の向上と開発コストのバランスを段階的に取れる。

運用・デバッグ:再試行ログの可視化と監視

冪等性キーと永続ログが導入されれば、再試行の挙動が「黒箱」でなくなる。具体的には、以下のような情報をログに記録することで、障害発生時の切り分けが大幅に簡略化される。

  • 冪等性キーの照合結果:キーが既存レコードと一致した(=重複排除された)場合、元の処理のステータスを参照して即座に結果を返す。この「重複排除」イベント自体をログに記録する。
  • unknown状態の遷移:外部呼び出しがunknownに遷移した時刻、整合性チェックがトリガーされた時刻、最終的にsuccess/failに確定した時刻を時系列で記録する。
  • リトライのトリガー要因:タイムアウト、ネットワークエラー、HTTP 5xxなど、リトライが発生した理由を記録する。

監視メトリクスの設計

運用時に常時監視すべき指標は、以下の2つを軸にするとよい。

一つ目は重複排除率である。全書き込みリクエストのうち、冪等性キーの照合で重複として排除された割合である。この値が0%であれば「再試行が起きていない」か「冪等性制御が機能していない」のどちらかであり、後者の場合は深刻な問題である可能性がある。逆に、正常な運用では数%程度が期待される。この値が突然上昇した場合は、ネットワーク障害やタイムアウト閾値の不適切さが原因である可能性が高い。

二つ目はunknown状態からの復旧率である。unknown状態に遷移した処理のうち、整合性チェックによってsuccess/failに確定できた割合である。この値が低い場合は、確認クエリが対象システムに届いていない(ネットワーク断が継続している)か、確認クエリ自体が冪等でない(確認のたびに副作用を生む)可能性がある。後者は設計上のバグであり、確認クエリが読み取り専用であることを保証する必要がある。

デバッグ時の切り分けフロー

「LLMの出力が不自然だった」という報告を受けた際の切り分けは、以下の順序で行うと効率的である。まず、該当リクエストの冪等性キーでログを検索し、同一キーで複数回の処理が記録されていないか確認する。複数回ある場合はインフラ側のリトライが原因であり、LLMの出力品質とは無関係である。次に、unknown状態に遷移した記録があるか確認し、整合性チェックの結果がどのようだったかを見る。最後に、LLMが実際に送信したツール呼び出しのパラメータをログから抽出し、意図した操作と一致しているか確認する。

この切り分けが可能になる前提が、永続ログに「LLMが生成した意図」と「実際に実行されたアクション」を両方記録していることである。コンテキストウィンドウ内の履歴だけでは、プロセスが再起動した後に参照できなくなるため、DBやファイルシステムに永続化されたログがデバッグの信頼できる情報源となる。

まとめ:AIエンジニアリングとしての分散システムの重要性

LLMエージェントの再試行バグは、モデルの推論品質の問題ではなく、LLMを載せた分散システムの問題である(Dimitrisk氏のDEV記事)。この認識の転換が、信頼性設計の出発点となる。

本記事で扱った設計指針を整理すると、以下の4点に集約される。第一に、タイムアウトは「失敗」ではなく「応答未取得」であり、バックエンドでは処理が完了している可能性を常に前提にすること。第二に、冪等性キーはビジネス文脈を反映した引数から生成し、再試行ごとに再利用すること。第三に、意図ログはコンテキストウィンドウではなく、クラッシュに耐える永続ストレージに実行前に書き込むこと。第四に、unknown状態では自動再試行をブロックし、明示的な整合性チェックで状態を確定させること。

これらは、マイクロサービスやメッセージキューの設計で確立された分散システムの基礎理論である。LLMエージェントの文脈では、モデルの確率的な出力が「同じ入力が同じタイミングで2回届く」確率を高めるため、これらの技術がより重要になる。しかし、技術自体は新規なものではなく、既存の知見をLLM統合の文脈に適用するものである。

AIエンジニアリングという言葉が「モデルのファインチューニング」や「プロンプトの最適化」に限定されがちな中、その下支えとなるインフラ設計の品質が、最終的なユーザー体験に影響を与える。書き込みを伴うエージェントを本番に載せる前に、再試行ロジックを分散システムの視点から一度見直すことが、信頼性向上のための有効なアプローチである。

関連記事

参考

本記事は海外の技術トレンド「Your agent's retry bug isn't an AI problem. It's a distributed-systems problem with an LLM on top.」(DEV)で話題のテーマをもとに、両儀システムソリューションズが独自に解説したものです。