AIエージェントの並行実行をどう制御する?同じ対象への更新競合を防ぐ
二つのAIエージェントが同じ問い合わせをほぼ同時に処理すると、どちらも「未対応」と読んで別々の返信を送り、最後に書いた方だけが担当者として残ることがある。各処理が一度しか実行されなくても起きるため、冪等性だけでは防げない。必要なのは、読み取った状態が書き込み時にも最新かを確かめ、競合した操作を止める仕組みである。
例えば、エージェントAとBが同じ注文の版 7 を読む。Aが配送先を更新して版を 8 にしたあと、Bが版 7 を前提に注文全体を保存すると、Aの変更が消える。この現象はlost updateと呼ばれる。RFC 9110は、状態を変えるPUTやDELETEに If-Match を使い、複数の利用者が並行して操作するときの意図しない上書きを防げるとしている。
冪等性と並行実行制御は守る対象が違う
冪等性は、同じ依頼を再試行しても二重送信や二重登録を起こさないための仕組みである。並行実行制御は、異なる依頼や異なる実行主体が同じ状態を同時に変えたとき、片方の変更を黙って消さないための仕組みである。
一つの操作には両方が必要になる場合がある。例えば「注文123の配送先を変更する」という依頼では、操作IDで重複実行を防ぎ、注文の版番号で古い状態からの上書きを防ぐ。操作IDが同じかどうかと、対象の状態が同じかどうかは別の判定だ。
版番号を条件にして更新する
競合が少ない対象では、楽観的並行実行制御が扱いやすい。レコードに版番号を持たせ、更新時に「自分が読んだ版のままなら書く」という条件を付ける。Amazon DynamoDBの公式資料も、項目の版番号を更新ごとに増やし、最後に読んだ版と一致する場合だけ更新する方法を説明している。条件が外れた処理は ConditionalCheckFailedException になり、別の処理が先に変更したと分かる。
考え方だけを標準Pythonで再現すると次のようになる。データベース製品の性能を測るコードではなく、二つの処理が同じ版を読んだ場合の説明用の最小例である。
order = {"status": "waiting", "version": 7}
def update_status(expected_version, new_status): if order["version"] != expected_version: return False order["status"] = new_status order["version"] += 1 return True
agent_a_version = order["version"]agent_b_version = order["version"]
print(update_status(agent_a_version, "approved"))print(update_status(agent_b_version, "cancelled"))print(order)出力は次のようになる。
TrueFalse{'status': 'approved', 'version': 8}Aの更新だけが成功し、古い版 7 を前提にしたBの更新は拒否される。実際のデータベースでは、版の比較と更新を同じ原子的な操作として実行する必要がある。Python側で読み直してから無条件に保存すると、その間に別の更新が入り、同じ競合が戻ってくる。
HTTP APIではETagとIf-Matchを使える
対象をHTTP APIで操作する場合、版番号をETagとして返し、更新要求の If-Match へ入れる方法がある。現在のETagと一致しなければ更新しない。RFC 9110では条件が偽のとき、サーバーは要求された操作を実行せず、412 Precondition Failed を返せる。
エージェントの道具は、単に update_order(order_id, fields) とするより、update_order(order_id, expected_version, fields) のように期待する版を入力へ含める方が安全である。結果も成功か失敗だけでは足りない。少なくとも競合、権限不足、入力不正、外部障害を分け、競合時には最新の版や再取得方法を返す。
競合したら自動で上書きしない
競合後に最新状態を読み直し、同じ更新をそのまま再試行すればよいとは限らない。注文がすでに発送済みなら配送先変更を再適用できない。問い合わせが別の担当者によって解決済みなら、古い回答案を送らない方がよい。状態が変わると、操作の意味や許可条件も変わる。
再取得後に機械的な再計算が安全な操作だけを自動再試行する。加算、空いている枠への予約、最新情報を基にした下書き更新でも、上限や業務条件を再評価する。送信、購入、削除、公開のように外部へ影響する操作は、競合内容を人へ示して承認を取り直す選択肢が必要になる。
ロックは必要な範囲と時間を小さくする
競合が頻繁で、同時に一つだけ進めなければならない処理では、悲観的なロックやキューによる直列化が候補になる。ただし、エージェントがLLMの応答や外部APIを待つ数十秒から数分の間、対象をロックし続けると待ち時間と障害の影響が大きくなる。
ロックを使うなら、LLMが計画を考える全時間ではなく、共有状態を確定する短い区間へ絞る。期限、所有者、更新用トークンを持たせ、期限切れの古い実行が新しい所有者のロックを解除しないようにする。ロックサービスが落ちたときに危険な更新を無条件で続ける設計も避ける。
Microsoft Azure Architecture Centerは、悲観的制御は競合を防げる一方で性能と可用性を下げうるため、可能なら楽観的制御を使い、調整そのものを減らすことを勧めている。対象を顧客IDや注文IDごとに分ければ、無関係な処理まで一列に並べずに済む。
エージェントの判断と更新を一つの処理だと思わない
LLMが状態を読んで判断し、道具を呼ぶまでには時間が空く。そのため「判断時には正しかった」は更新の安全性を保証しない。道具側で更新直前に版、現在状態、権限、上限を検査する。プロンプトに「最新状態を確認すること」と書くだけでは、原子的な条件付き更新の代わりにならない。
実行記録には、対象ID、読み取った版、更新時の版、操作ID、競合結果、再試行回数を残す。評価では二つ以上の実行を意図的に重ね、片方の変更が黙って消えないこと、競合後に不可逆操作を自動実行しないこと、ロック期限切れ後の古い実行が書き込めないことを確認する。
AIエージェントの並行実行は、モデルを一つずつ動かせば解決する問題ではない。共有する対象の単位を決め、版を条件に更新し、競合を通常の結果として扱う。これにより、複数の処理を動かしても「最後に書いたものが正しい」という危険な前提から離れられる。