月末の請求を開いて、指が止まりました。処理件数はほぼ横ばいなのに、金額だけが想定の約1.9倍。障害アラートは一度も鳴っていません。ログを追っても、どのリクエストも正常終了しています。
原因にたどり着くまで半日かかりました。並列に走らせていた3体のワーカーのうち、2体が同じタスクを別々に拾い、どちらも「自分の仕事だ」と判断して最後まで実行していたのです。結果は同じなので出力は正しく見える。けれど裏では、生成トークンの課金が静かに二重に積み上がっていました。
この記事は、その「沈黙する二重実行」を計測で捕まえ、冪等キーで止め、コストをタスク単位に帰属させるまでの運用記録です。Antigravity の Managed Agents のように、複数エージェントへタスク群を委譲する構成を前提にしています。特定の SDK に依存しない層として組んだので、Gemini API でも自作のワーカーでも移植できます。
なぜ重複は「エラーにならず」に起きるのか
二重実行が厄介なのは、例外を投げないことです。両方のワーカーが成功し、両方が正しい結果を返す。オーケストレーターは後着の結果を捨てるか上書きするだけで、二度走った事実はどこにも残りません。
根っこにあるのは、LLM が「自分の担当ではないこと」を明示的に断らない性質です。これは並列構成に特有の落とし穴で、プロンプトの手直しだけでは根本的な対処になりません。責任範囲をあいまいに書くと、複数のワーカーが同じ依頼を自分事として引き受けてしまいます。
| 症状 | 見えること | 見えないこと |
| 二重実行 | 出力は正しい・エラーなし | 同じタスクへの課金が2回発生 |
| 取りこぼし | 件数が合わない | どのワーカーも「担当外」と判断して放置 |
| ループ | レイテンシ増 | 境界の押し付け合いで往復が発生 |
まず効いたのは、ワーカーの責任範囲を「やること」ではなく「やらないこと」まで書き切ることでした。
# ❌ 境界があいまい(重複・取りこぼしの温床)
あなたはデータ処理エージェントです。必要に応じてレポートも作成してください。
# ✅ 担当外を明示し、越境時は差し戻す
あなたは「JSON→スキーマ変換」専任のワーカーです。
担当: 受け取った JSON を指定スキーマへ変換して返す。
担当外: 保存・送信・レポート生成・他ワーカーの再依頼。
担当外の入力が来たら本文を返さず {"status":"out_of_scope"} だけを返し、
オーケストレーターへ差し戻すこと。自分で肩代わりしないこと。
ただしプロンプトの明確化は「起きにくくする」対策にすぎません。ゆらぎのある相手が並列で動く以上、構造として二重実行を止める仕組みが要ります。
重複を「見える化」する実行台帳
止める前に、まず数えます。どのタスクが何回実行されたのかを記録しなければ、対策の効果も判定できません。
鍵になるのはタスクの同一性をどう定義するかです。私は入力の意味的な本体(正規化した引数)からフィンガープリントを作りました。タイムスタンプや実行 ID のような「毎回変わる値」を除いて正規化するのがポイントです。
import hashlib
import json
import time
def task_fingerprint(task_type: str, payload: dict) -> str:
"""実行のたびに変わる値を除いて、タスクの意味的な同一性を鍵にする。"""
volatile = {"request_id", "timestamp", "attempt", "trace_id"}
stable = {k: v for k, v in payload.items() if k not in volatile}
# キー順を固定して安定なハッシュにする
canonical = json.dumps(stable, sort_keys=True, ensure_ascii=False)
digest = hashlib.sha256(f"{task_type}:{canonical}".encode()).hexdigest()
return digest[:16]
class ExecutionLedger:
"""タスクごとの実行回数を記録する台帳。store は Redis 等の KV を想定。"""
def __init__(self, store):
self.store = store # get/incr/expire を持つ薄いラッパ
def record(self, fp: str, worker_id: str) -> int:
key = f"ledger:{fp}"
count = self.store.incr(key) # 実行回数を加算
self.store.expire(key, 60 * 60 * 24) # 24時間で自然に流す
self.store.sadd(f"{key}:workers", worker_id)
return count # 2 以上なら、そのタスクは重複実行されている
これを本番に差し込んで一週間ほど回すと、重複率が見えてきました。全タスクの約18%が2回以上実行されていて、そのほとんどが特定の2つのタスク種別に集中していました。数える前は「たまに起きる程度」だと思っていたので、この18%という数字は素直に驚きでした。
二重実行を止める:冪等キーの先取り予約
台帳は事後記録です。二重課金そのものを止めるには、実行の「前」に予約を取る必要があります。
パターンはシンプルで、次の3手順に分かれます。
- 各ワーカーは処理を始める前に、フィンガープリントをキーにした予約を「まだ誰も取っていなければ自分が取る」形で書き込む。
- 予約を取れたワーカーだけが実行し、結果を共有ストアへ保存する。
- 予約を取れなかったワーカーは既存の結果を待ち、タイムアウト時のみオーケストレーターへ判断を戻す。
KV の原子的な「存在しなければ書く」操作(SET NX)が、この先取り予約の土台になります。
import time
class IdempotencyGuard:
def __init__(self, store, ttl_seconds: int = 900):
self.store = store
self.ttl = ttl_seconds
def claim(self, fp: str, worker_id: str) -> bool:
"""予約を先取りできたら True。既に誰かが実行中/実行済みなら False。"""
key = f"claim:{fp}"
# NX: キーが無いときだけ書き込む(原子的)。これが競合の要。
acquired = self.store.set(key, worker_id, nx=True, ex=self.ttl)
return bool(acquired)
def save_result(self, fp: str, result: dict) -> None:
self.store.set(f"result:{fp}", json.dumps(result), ex=self.ttl)
def wait_result(self, fp: str, timeout: float = 30.0) -> dict | None:
"""予約を取れなかった側が、先行実行の結果を待つ。"""
deadline = time.time() + timeout
while time.time() < deadline:
raw = self.store.get(f"result:{fp}")
if raw is not None:
return json.loads(raw)
time.sleep(0.5)
return None # タイムアウト時はオーケストレーターへ判断を戻す
def run_task(worker_id, task_type, payload, guard, execute_fn):
fp = task_fingerprint(task_type, payload)
if guard.claim(fp, worker_id):
result = execute_fn(payload) # 実際の LLM 実行はここだけ
guard.save_result(fp, result)
return {"source": "executed", "result": result}
# 予約を取れなかった=別ワーカーが同じタスクを走らせている
shared = guard.wait_result(fp)
if shared is not None:
return {"source": "reused", "result": shared}
return {"source": "fallback", "result": execute_fn(payload)}
ここで大事なのは、claim が失敗したワーカーに黙って execute_fn を再実行させないことです。初期実装では「取れなかったら念のため自分でもやる」というフォールバックを無条件に入れていて、結局ほとんど重複が減りませんでした。先行結果を待つ経路を主にして、フォールバックは本当のタイムアウト時だけに絞る——この順序を守って初めて、重複率は約18%から0.4%まで落ちました。
TTL の設定にも注意が要ります。短すぎると先行実行が終わる前に予約が消えて二重実行に戻り、長すぎると失敗タスクの再実行が長時間ブロックされます。私はタスクの p95 実行時間の3倍を初期値にして、そこから調整しました。
コストをタスク単位・エージェント単位へ帰属させる
重複を止めたあとも、「次にどこが膨らむか」を先回りで見るために、コストを実行単位で記録し続けます。累積課金の性質を押さえておくと、異常の芽を早く摘めます。
マルチエージェントのコストが跳ねる主因は、会話履歴の累積です。ステップが進むほど、毎回の入力に過去の全履歴が乗ります。
def cumulative_input_tokens(steps: int, avg_per_step: int = 1000) -> int:
"""各ステップが前ステップまでの履歴を含むため、入力は級数的に増える。"""
# 1000*(1+2+...+steps) = 1000 * steps*(steps+1)/2
return avg_per_step * steps * (steps + 1) // 2
for s in (1, 5, 10, 20):
print(f"{s:2d}ステップ: 累積入力 {cumulative_input_tokens(s):>7,} tokens")
# 1ステップ: 累積入力 1,000 tokens
# 5ステップ: 累積入力 15,000 tokens
# 10ステップ: 累積入力 55,000 tokens
# 20ステップ: 累積入力 210,000 tokens
10ステップで55,000、20ステップで210,000。ステップ数を倍にすると入力はおよそ4倍です。この形を知っていれば、「ステップ数の上限」と「中間サマリーで履歴を畳む設計」がなぜ効くのかが腹落ちします。
実行ごとのコストは、フィンガープリント・ワーカー・タスク種別と一緒に記録しておきます。あとから「どのタスク種別が請求を押し上げているか」を集計できるようにするためです。
def log_cost(store, fp, worker_id, task_type, usage: dict):
record = {
"fp": fp,
"worker": worker_id,
"task_type": task_type,
"input_tokens": usage["input_tokens"],
"output_tokens": usage["output_tokens"],
"ts": time.time(),
}
store.rpush(f"cost:{task_type}", json.dumps(record))
def task_type_summary(store, task_type) -> dict:
rows = [json.loads(r) for r in store.lrange(f"cost:{task_type}", 0, -1)]
runs = len(rows)
in_tok = sum(r["input_tokens"] for r in rows)
out_tok = sum(r["output_tokens"] for r in rows)
return {
"task_type": task_type,
"runs": runs,
"avg_input": in_tok // runs if runs else 0,
"total_tokens": in_tok + out_tok,
}
従量課金を Stripe の Meter Events で請求している構成なら、この実行単位の記録がそのまま課金メーターとの突合材料になります。この帰属データがあると、請求異常が「件数の増加」なのか「1件あたりの肥大化」なのかを一目で切り分けられます。今回の1.9倍は前者、つまり件数(重複)側の問題でした。もしコストを実行単位で記録していなければ、私はモデルの単価改定や履歴の肥大化を疑って、見当違いの場所を何日も掘っていたはずです。
段階的に締める:しきい値とアラート
一度直しても、プロンプト変更や並列度の調整で重複は再び顔を出します。台帳を常設し、しきい値を超えたら気づける状態にしておきます。
| 指標 | 平常時の目安 | アラート条件 |
| 重複率(record が2以上の割合) | 1%未満 | 3%超が15分継続 |
| claim 失敗→fallback の比率 | 1%未満 | 5%超 |
| タスク種別ごとの平均入力トークン | ベースライン±20% | +50%超 |
いきなり全部を厳格にすると誤報で疲れます。私はまず重複率だけを監視対象にして、落ち着いてから残りを足しました。監視は「全部を一度に」ではなく「効く一つから」始めるほうが続きます。
まとめ:まず数えてから止める
沈黙する二重実行は、出力の正しさに隠れて請求だけを膨らませます。エラーが鳴らないぶん、気づく唯一の手がかりは計測です。
次の一歩として、個人開発で並列エージェントを本番運用しているなら、フィンガープリントによる実行台帳を一つだけ差し込んで、自分のシステムの重複率を測ってみてください。数字が出れば、冪等キーで止めるべきか、まだ許容範囲かを判断できます。私自身、測るまでは「たまに起きる程度」と高をくくっていました。数えて初めて、止める価値のある問題だと分かったのです。
実運用の参考になれば幸いです。お読みいただき、ありがとうございました。