独自ツールを足した直後のエージェントは、たいてい気持ちよく動きます。問題が出るのはその数日後、同じツールが想定より多く呼ばれ始めたときです。私自身、個人開発で使っている自動化エージェントに社内 API 連携ツールを一本追加したところ、最初の週は完璧で、翌週に「同じ請求が二重に作られている」という形で初めて綻びに気づきました。原因はツールの実装そのものではなく、エージェントがタイムアウトを失敗と解釈して、すでに成功している処理をもう一度呼んでいたことでした。
独自ツールの設計記事はスキーマや権限境界の話で終わりがちですが、運用で本当に効いてくるのは「同じツールが二度呼ばれても壊れない」という再実行耐性のほうです。ここでは、その観点から効いた実装と判断基準だけを残します。
二重副作用はどこから来るのか
エージェントがツールを二度呼ぶ経路は、思っているより多くあります。代表的なのは次のような場面です。
- ツールは実際には成功したのに、レスポンスがタイムアウトで返り、エージェントが「失敗した」と判断して再試行する。
- 長い作業の途中でコンテキストが圧縮され、実行済みの呼び出しをエージェントが忘れて、もう一度組み立てる。
- 並列エージェント構成で、複数のワーカーが同じタスクを拾ってしまう。
これらに共通するのは、ツール側から見ると「ほぼ同じ引数の呼び出しが短時間に二度来る」という現象として現れることです。本番運用で初めて表面化するこの種の事故は、エージェント側のプロンプトをいくら直しても根絶できません。防御はツール側の、しかも引数の側で打つのが確実です。
冪等キー — 同じ操作を二度実行しない
書き込み系・破壊系のツールには、冪等キーを必須にすることを強く推奨します。冪等キーは「この操作は一度きり実行されるべき」という単位を表す文字列で、呼び出し側(エージェント)が生成し、ツール側が保存します。同じキーで二度目の呼び出しが来たら、再実行せず初回の結果をそのまま返します。
import hashlib, json, time
class IdempotencyStore:
"""冪等キーと初回結果を TTL 付きで保持する。
本番では Redis などの共有ストアにする(並列ワーカー間で共有するため)。"""
def __init__(self, ttl_seconds: int = 86400):
self._store: dict[str, tuple[float, dict]] = {}
self._ttl = ttl_seconds
def get(self, key: str) -> dict | None:
entry = self._store.get(key)
if not entry:
return None
ts, result = entry
if time.time() - ts > self._ttl:
self._store.pop(key, None)
return None
return result
def put(self, key: str, result: dict) -> None:
self._store[key] = (time.time(), result)
def create_invoice(args: dict, idem: IdempotencyStore) -> dict:
key = args.get("idempotency_key")
if not key:
return {"ok": False, "error": {"code": "MISSING_IDEMPOTENCY_KEY",
"message": "書き込み操作には idempotency_key が必須です。", "retryable": False}}
cached = idem.get(key)
if cached is not None:
# 二度目以降は副作用を起こさず初回結果を返す
return {**cached, "replayed": True}
result = {"ok": True, "data": _do_create_invoice(args)}
idem.put(key, result)
return resultここで大事なのは、冪等キーを「呼び出し側が生成する」ことです。ツール側で引数のハッシュからキーを作る方法もありますが、それだと「意図的に二度作りたい正当な再呼び出し」と「事故の再実行」を区別できません。エージェントが操作の単位ごとに一意なキーを発行し、再試行のときは同じキーを使い回す、という約束にすると、両者がきれいに分かれます。
スキーマ側では、idempotency_key を書き込み系ツールの必須引数にしておきます。エージェントには「この操作は一度きり。再試行するなら同じキーを使ってください」という説明を description に添えます。
CREATE_INVOICE_SCHEMA = {
"name": "create_invoice",
"description": "請求を作成します。冪等です。再試行時は同じ idempotency_key を使ってください。",
"parameters": {
"type": "object",
"properties": {
"customer_id": {"type": "string"},
"amount_cents": {"type": "integer", "minimum": 1},
"idempotency_key": {
"type": "string",
"description": "この請求作成を一意に識別する文字列。再試行では同じ値を使う。",
},
},
"required": ["customer_id", "amount_cents", "idempotency_key"],
},
}TTL の長さは操作の性質で決めます。請求や送金のように「同じ操作を翌日にもう一度やったらまず事故」というものは 24 時間以上、逆に通知送信のように短時間の重複だけ防げばよいものは数分で十分です。私は破壊的なものほど長く取るようにしています。