エージェントに作業を任せると、最初の一発で必ず成功するわけではありません。テストが落ちる、ツール呼び出しが失敗する、出力が壊れる。そこで「もう一度やり直させる」のは自然な発想です。ところが、何も考えずに再試行を許すと、エージェントは同じ失敗を高速で繰り返し、気づけばクォータと請求だけが膨らんでいきます。
私は4つのブログを個人開発で並行運用していて、夜間に走らせる自動化のほとんどをエージェントに任せています。その中で痛感したのは、再試行は「失敗の握りつぶし」と紙一重だということです。Gemini 3.5 Flash のように速くて安いモデルが中核になったからこそ、再試行のコストは下がり、逆に「とりあえず回しておく」雑な運用に流れやすくなりました。だからこそ、再試行に予算という枠をはめる設計が要ります。
握りつぶしと再試行は違う
まず区別したいのは、握りつぶしと再試行です。握りつぶしは「失敗をなかったことにして先へ進む」こと、再試行は「失敗を認識した上で条件を変えてもう一度試す」ことです。この2つを混ぜると、エラーがログに残らないまま回り続け、後から原因を追えなくなります。
私はこの区別を、コードのレベルで強制するようにしています。再試行する前に必ず「なぜ失敗したか」を分類し、分類できない失敗は再試行しない。分類できないということは、同じ条件で投げ直しても結果が変わらない見込みが高いからです。
失敗を3種類に分けてから回す
実務では、エージェントの失敗はおおむね次の3種類に落ち着きます。再試行してよいのは、原則として一過性のものだけです。
| 分類 | 例 | 再試行 | 変えるべき条件 |
| 一過性 | レート制限、タイムアウト、ネットワーク瞬断 | する | 待ち時間(指数バックオフ) |
| 入力起因 | 壊れた JSON、文脈不足、曖昧な指示 | 条件付き | プロンプト・与える文脈 |
| 恒久 | 権限不足、存在しない API、論理的に不可能 | しない | 人間が介入するまで保留 |
恒久的な失敗を再試行に回すのは、最も典型的な予算の無駄遣いです。エージェントは「できません」とは言わず、もっともらしく失敗を繰り返すので、ここを止めるだけで請求がはっきり下がります。
1タスクに再試行上限を閉じ込める
予算は、抽象的な方針ではなく1回の起動に具体的な数値として持たせます。私が使っているのは、再試行回数・累計コスト・累計時間の3つを同時に見るコントローラです。どれか1つでも上限に触れたら、その時点で打ち切ります。
import time
from dataclasses import dataclass, field
@dataclass
class RetryBudget:
max_attempts: int = 3 # 一過性でも3回まで
max_cost_usd: float = 0.15 # このタスクが使ってよい上限
max_seconds: float = 90.0 # 暴走を時間で止める
spent_usd: float = 0.0
started: float = field(default_factory=time.monotonic)
attempts: int = 0
def allow(self) -> bool:
if self.attempts >= self.max_attempts:
return False
if self.spent_usd >= self.max_cost_usd:
return False
if time.monotonic() - self.started >= self.max_seconds:
return False
return True
def run_with_budget(run_once, classify, budget: RetryBudget):
last_error = None
while budget.allow():
budget.attempts += 1
result = run_once() # エージェントを1回起動
budget.spent_usd += result.cost_usd
if result.ok:
return result
kind = classify(result.error) # transient / input / permanent
if kind == "permanent":
raise PermanentFailure(result.error) # 再試行しない
if kind == "transient":
time.sleep(min(2 ** budget.attempts, 20)) # 指数バックオフ
last_error = result.error
raise BudgetExhausted(last_error)
肝は classify を必ず通すことです。分類せずに while attempts < 3 だけで回すと、恒久的な失敗まで3回投げてしまいます。分類を挟むだけで、無駄な2回分が消えます。
Flash の速度を「安く回す」根拠にする
なぜ上限を上のような値にできるのか。その根拠が Gemini 3.5 Flash の速度と価格です。Flash は高速・低価格を売りにしたモデルで、1回の試行が安く短く済みます。だからこそ、上限を低めに置いても十分な再試行回数を確保できます。
逆に言えば、高価で遅いモデルを中核にしていると、同じ予算では1〜2回しか試せません。モデル選択は再試行設計と切り離せない、という当たり前の事実がここで効いてきます。私はこのため、再試行が起きやすい不安定な工程(外部スクレイピング、フォーマット整形)には意図的に Flash を割り当て、最終確定の1回だけ上位モデルに渡すという二段構えにしています。個人的には、この役割分担を強く推奨します。
実際に夜間バッチの1工程で計測したところ、握りつぶしを止めて分類を入れただけで、再試行に費やすコールが約40%減りました。恒久失敗を3回投げていた分が丸ごと消えたためです。
再試行ログを残して無駄打ちを可視化する
予算を決めても、実際にどこで使い切っているかが見えないと締め直せません。再試行のたびに、分類・コスト・所要時間を1行で残します。
import json, time
def log_attempt(task_id, attempt, kind, cost, ok):
line = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
"task": task_id,
"attempt": attempt,
"kind": kind, # transient / input / permanent
"cost_usd": round(cost, 4),
"ok": ok,
}
with open("retry_log.jsonl", "a") as f:
f.write(json.dumps(line, ensure_ascii=False) + "\n")
このログがあると、「どのタスクが何回目で成功しているか」が集計できます。私が最初に驚いたのは、特定の1タスクだけが毎回2回目で成功していたことでした。つまり1回目は構造的に必ず失敗していた。これは再試行の問題ではなく、プロンプトを直すべき入力起因の失敗だったわけです。ログがなければ、ずっと再試行で吸収し続けていたはずです。
週次で予算を締め直す
再試行予算は一度決めて終わりではありません。私は週に一度、ログを見て上限を締め直しています。手順はシンプルです。
- タスクごとに「平均試行回数」を出す。1.0 に近いものは上限を下げる余地がある
kind が permanent の行を数える。多ければ、それは再試行で吸収すべきでない失敗が紛れ込んでいる
- 2回目以降で初めて成功するタスクを洗い出し、プロンプトか与える文脈を直す
- 締めた上限で1週間回し、
BudgetExhausted が増えすぎないかを確認する
この4ステップを回すと、再試行は「失敗を隠す装置」から「失敗を測る装置」に変わります。Dolice Labs の自動化では、この週次の締め直しを習慣にしてから、クォータの読めない急増がほとんどなくなりました。
再試行を許すこと自体は悪ではありません。問題は、許す範囲を数値で決めず、失敗を分類しないまま回すことです。まずは今動いているエージェントに、再試行回数とコストの上限を1つだけ付けてみてください。そこから先は、ログが次の一手を教えてくれます。