Antigravity エージェントが途中で落ちたとき、Retry ボタンを押し続けて同じ場所で詰まる、という経験をされた方は多いのではないでしょうか。個人プロジェクトであれば「もう一回回す」で済むかもしれません。しかし、クライアント案件の本番環境や、自分の稼働プロダクトでエージェントを自動実行しているとき、この素朴な Retry は静かに牙を剥きます。同じ失敗が 20 回続けば、トークン代は 20 倍です。副作用のある処理(メール送信、決済、データ書き込み)を Retry してしまえば、被害は金額だけでは終わりません。
私もアーティスト活動と並行してアプリを運用する中で、夜中にエージェントジョブが暴走し、朝起きたら想定の 5 倍の API コストが発生していたことがあります。その経験から学んだのは、Retry は「機能」ではなく「設計対象」だということです。以下では、冪等性・バックオフ・コスト上限・失敗トリアージという 4 本柱に分けて、実装コードと手元で測った数字を添えながら組み立てていきます。読み終わる頃には、自分のプロジェクトに今日から差し込める再実行フローが手元にできているはずです。
Retry が「運試し」から「設計」に変わる瞬間
多くの開発者にとって、最初の Retry は UI ボタンです。Antigravity の Manager Surface や CLI で失敗したタスクに Retry を押す。これは開発中のインタラクティブな使い方としては正しいのですが、「誰かが見ていない時間に動くエージェント」に対しては不十分です。
本番品質の Retry に求められる条件は、次の 4 つに集約できます。
- 冪等性(Idempotency): 同じ入力で何回実行しても、副作用は 1 回だけ発生する
- 指数バックオフとジッター: 外部依存の過負荷を悪化させない再試行間隔
- コスト上限(Cost Ceiling): トークン・API 呼び出し回数・金額の上限を事前に定義する
- 失敗トリアージ: 再試行すべきエラー、即座に止めるべきエラー、人間にエスカレートするエラーを分類する
この 4 つを揃えていないエージェントを自動化フローに乗せるのは、ブレーキの効かない車で高速道路に入るようなものです。逆に、この 4 つさえ設計できていれば、夜中に Antigravity のスケジュールタスクを回しても、朝の珈琲を落ち着いて飲めます。以降、それぞれを順に組み立てていきましょう。
冪等性(Idempotency)を設計する — 同じ入力でも副作用を2度起こさない
Retry 設計で最も誤解されやすいのがここです。「ただ関数を呼び直すだけ」と思うかもしれませんが、エージェントは外部の世界に対して副作用を起こします。ファイル書き込み、API 呼び出し、決済、メール、DB 更新。これらを 2 度走らせないために、エージェントが扱うすべての副作用アクションに「このアクションはすでに実行済みか?」を判定できる仕組みを持たせます。
実装パターンはシンプルです。アクションごとに一意の Idempotency Key を生成し、実行前にキーで重複チェックを行います。TypeScript での最小実装例を示します。
// src/retry/idempotency.ts
// エージェントが起こす副作用を、冪等キーで守るためのラッパー。
// 実行前にキーをチェックし、完了したキーは結果付きで 24 時間保存する。
import { createHash } from "node:crypto";
type ExecutionResult<T> = {
status: "completed" | "failed";
data?: T;
error?: string;
completedAt: string;
};
export class IdempotencyGuard {
// 本番では Redis / KV を使うこと。ここでは学習目的でメモリ実装。
private store = new Map<string, ExecutionResult<unknown>>();
private makeKey(action: string, payload: unknown): string {
const body = JSON.stringify({ action, payload });
return createHash("sha256").update(body).digest("hex").slice(0, 32);
}
async run<T>(
action: string,
payload: unknown,
execute: () => Promise<T>,
): Promise<T> {
const key = this.makeKey(action, payload);
const cached = this.store.get(key);
if (cached?.status === "completed") {
// 2 回目以降は保存済みの結果をそのまま返す。副作用は実行しない。
return cached.data as T;
}
try {
const data = await execute();
this.store.set(key, {
status: "completed",
data,
completedAt: new Date().toISOString(),
});
return data;
} catch (err) {
// 失敗は記録するが、キーには結び付けない。
// 次回は入力が同じでも再実行される。
this.store.set(key, {
status: "failed",
error: err instanceof Error ? err.message : String(err),
completedAt: new Date().toISOString(),
});
throw err;
}
}
}
// 使い方
const guard = new IdempotencyGuard();
await guard.run("send_welcome_email", { userId: "u_123" }, async () => {
await mailer.send({ to: "user@example.com", template: "welcome" });
return { queued: true };
});
// → 1 回目はメール送信、2 回目以降は送信せずに { queued: true } を返す
期待する出力としては、同じ action + payload の組み合わせで 2 回目以降を呼び出しても、メール送信が走らず即座に { queued: true } が返ることを必ずテストで確認してください。これが Retry 設計の土台です。
なぜキーを「アクション名+入力のハッシュ」にするか
タスク ID や UUID を Idempotency Key に使ってしまうと、エージェントが「同じ意図の処理」を別 ID で何度も投げてしまった時に、すべて別物として扱われます。アクション名と入力の本質的な内容からハッシュを作ることで、「同じ仕事を同じ条件でやろうとしている」ことを検知できます。これは決済 SDK(Stripe の Idempotency Keys)と同じ設計思想です。
本番ストレージでの注意点
メモリストアは再起動で消えるので、本番では必ず外部ストレージを使います。Cloudflare Workers であれば KV、Firebase を使っているなら Firestore の単一ドキュメント + TTL、Redis なら SET key value NX EX 86400 で一発です。TTL は 24〜72 時間が現実的で、これより短いと再実行時に二重送信、長すぎるとストレージが肥大化します。
指数バックオフとジッターで「嵐」を避ける
失敗したら即座に再試行、というパターンは、障害時に外部サービスへの攻撃になります。特に LLM API は障害時にレートリミットで絞ってくることが多く、即 Retry は傷口を広げるだけです。ここで使うのが、指数バックオフ(Exponential Backoff)と、それに重ねるジッター(Jitter)です。
指数バックオフは「待ち時間を指数関数的に伸ばす」パターンで、base * 2^attempt のように計算します。ジッターはさらに乱数を加えて、同時刻に復帰した多数のクライアントが一斉に再試行するのを防ぎます。これは AWS が推奨する Exponential Backoff and Jitter というよく知られた設計パターンです。
// src/retry/backoff.ts
// 指数バックオフ + ジッター + 最大試行回数 + 最大待ち時間の組み合わせ。
// これを「再試行してよい」と判断したエラーにだけ適用する。
export type RetryOptions = {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
shouldRetry: (err: unknown, attempt: number) => boolean;
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
opts: RetryOptions,
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === opts.maxAttempts || !opts.shouldRetry(err, attempt)) {
throw err;
}
// full jitter: 0 〜 base*2^attempt の範囲でランダム化
const expo = Math.min(opts.baseDelayMs * 2 ** attempt, opts.maxDelayMs);
const delay = Math.floor(Math.random() * expo);
opts.onRetry?.(err, attempt, delay);
await sleep(delay);
}
}
throw lastError;
}
// 使い方
await retryWithBackoff(
() => fetch("https://api.example.com/data").then((r) => r.json()),
{
maxAttempts: 5,
baseDelayMs: 500,
maxDelayMs: 30_000,
shouldRetry: (err) => {
// 後述する「失敗トリアージ」でここを埋める
return isTransientError(err);
},
onRetry: (err, attempt, delay) => {
console.log(`retry attempt=${attempt} delay=${delay}ms error=${String(err)}`);
},
},
);
期待する動作は、成功したらその時点で return、失敗しても shouldRetry が true を返す間は指数バックオフで待って再試行、maxAttempts に達するか shouldRetry が false になったら例外を投げる、というものです。onRetry はログ・メトリクスを仕込む場所として必ず使ってください。ここがブラックボックスだと、後で事故原因を追えなくなります。
「full jitter」を使う理由
ジッターには「equal jitter(半分固定+半分乱数)」や「decorrelated jitter(前回値と現在値の間で乱数)」などのバリエーションがあります。個人開発の Antigravity エージェントで最も無難なのは full jitter(0 からその時点の最大値の間で一様分布)です。実装がシンプルで、同時復帰の集中を強く抑えてくれます。複雑なバリエーションを持ち込む前に、まず full jitter で測定することをおすすめします。
バックオフ戦略の違いを 40 並列のシミュレーションで測る
「full jitter が良い」と書きましたが、どれくらい良いのかは実際に測らないと納得しにくいものです。私も最初は「なんとなく乱数を入れておけば安全だろう」という程度の理解で運用していました。そこで、レート制限のある API に複数のエージェントが同時に殺到する状況を仮想時計で再現し、4 つの戦略を比べてみました。
条件は次の通りです。ワーカー 40 並列、トークンバケットは容量 8・250ms ごとに 1 個補充、baseDelayMs は 500ms、上限 30 秒、maxAttempts は 6。同じ条件でシード違いの 200 回を平均しています。
// retry-sim.mjs — 実時間を待たずに仮想時計でバックオフ戦略を比較する
// node retry-sim.mjs で実行できます(依存パッケージなし)
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function simulate(strategy, seed) {
const rnd = mulberry32(seed);
const WORKERS = 40, CAPACITY = 8, REFILL_MS = 250;
const BASE = 500, MAX_DELAY = 30_000, MAX_ATTEMPTS = 6;
let tokens = CAPACITY, lastRefill = 0;
// イベントキュー: 各ワーカーが「いつ叩きに行くか」を仮想時刻で持つ
const queue = Array.from({ length: WORKERS }, (_, id) => ({ t: 0, id, attempt: 0 }));
let attempts = 0, ok = 0, fail = 0;
const completedAt = [];
while (queue.length) {
queue.sort((a, b) => a.t - b.t);
const e = queue.shift();
const refill = Math.floor((e.t - lastRefill) / REFILL_MS);
if (refill > 0) {
tokens = Math.min(CAPACITY, tokens + refill);
lastRefill += refill * REFILL_MS;
}
e.attempt++; attempts++;
if (tokens > 0) { tokens--; ok++; completedAt.push(e.t); continue; }
if (e.attempt >= MAX_ATTEMPTS) { fail++; continue; }
const exp = Math.min(MAX_DELAY, BASE * 2 ** (e.attempt - 1));
const delay =
strategy === "fixed" ? BASE
: strategy === "exponential" ? exp
: strategy === "equal-jitter" ? exp / 2 + rnd() * (exp / 2)
: rnd() * exp; // full-jitter
queue.push({ t: e.t + delay, id: e.id, attempt: e.attempt });
}
completedAt.sort((a, b) => a - b);
return {
attempts, ok, fail,
median: completedAt[Math.floor(completedAt.length / 2)],
p95: completedAt[Math.floor(completedAt.length * 0.95)],
};
}
for (const s of ["fixed", "exponential", "equal-jitter", "full-jitter"]) {
let A = 0, O = 0, F = 0, M = 0, P = 0;
const RUNS = 200;
for (let r = 0; r < RUNS; r++) {
const x = simulate(s, 1000 + r);
A += x.attempts; O += x.ok; F += x.fail; M += x.median; P += x.p95;
}
console.log(
`${s.padEnd(14)} 試行=${(A / RUNS).toFixed(1)} 完了率=${((100 * O) / (O + F)).toFixed(1)}% ` +
`中央値=${(M / RUNS / 1000).toFixed(2)}s p95=${(P / RUNS / 1000).toFixed(2)}s`,
);
}
手元で走らせた結果がこちらです。中央値と p95 は「完了したタスクだけ」を対象にした所要時間です。
| 戦略 | 平均試行回数 | 完了率 | 完了時間の中央値 | 完了時間の p95 |
| fixed(500ms 固定) | 180.0 | 45.0% | 0.50s | 2.50s |
| exponential(ジッターなし) | 156.0 | 95.0% | 3.50s | 15.50s |
| equal jitter | 155.0 | 100.0% | 3.98s | 12.83s |
| full jitter | 165.2 | 95.5% | 3.08s | 10.15s |
この表で目を引くのは、fixed の完了率 45% です。中央値 0.50 秒という数字だけを見れば最速に見えますが、これは「早々に通れた運の良い半分」の記録でしかありません。残りの半分は 6 回の試行を 500ms 間隔で使い切り、一度もトークンにありつけないまま脱落しています。ダッシュボードの平均レイテンシだけを眺めていると、この脱落が見えません。
ジッターなしの指数バックオフは完了率こそ 95% まで戻りますが、p95 が 15.50 秒まで伸びます。全ワーカーが同じ時刻に目を覚ますため、復帰のたびに同じ混雑を作り出しているからです。equal jitter は完了率を 100% にする代わりに中央値がやや後ろに寄り、full jitter は p95 を 10.15 秒と最も短く抑えます。
私が個人開発の既定値として full jitter を選んでいるのは、この p95 の差が体感に直結するからです。夜間バッチのように「全部が終わればいい」処理なら equal jitter の取りこぼしのなさが効きますし、対話的なタスクを裏で走らせるなら full jitter の裾の短さが効きます。どちらが正解というより、自分のタスクが「完了率」と「最悪ケースの待ち時間」のどちらを優先するのかを、数字を見て決めるのが大切だと考えています。
このスクリプトは仮想時計で動くので、200 回のシミュレーションが 1 秒足らずで終わります。WORKERS と REFILL_MS を自分が叩いている API のレート制限に合わせて書き換えれば、そのまま自分の環境の見立てとして使えます。本番に投入する前に手元で数字を見ておくと、maxAttempts を 6 にするか 8 にするかといった判断が、勘ではなく根拠に変わります。
コスト上限(Cost Ceiling)を仕掛ける — 無限ループが口座を溶かす前に
夜中にエージェントが暴走して朝までトークンを燃やし続ける、という事故は、実際に起きます。私自身、初期の頃に円安も相まって想定の 5 倍の請求が来たことがあり、そこから本気でコスト上限を設計するようになりました。Antigravity のエージェントは外部 LLM API を呼ぶので、Retry の回数ではなく「どれだけトークンを消費したか」を上限として持つのが現実的です。
設計方針は次の 3 点です。
- タスク単位で「トークン予算」を事前に決める
- Retry のたびに消費トークンを加算し、予算超過で即停止する
- 超過時はアラート + チェックポイントに戻して人間の判断を待つ
// src/retry/budget.ts
// タスク単位のコスト上限をエージェント実行ループに組み込む。
// LLM レスポンスから usage を取り出し、合計が超えたら BudgetExceeded を投げる。
export class BudgetExceeded extends Error {
constructor(
public readonly used: number,
public readonly budget: number,
) {
super(`budget exceeded: used=${used} budget=${budget}`);
}
}
export class TokenBudget {
private used = 0;
constructor(
private readonly budget: number,
private readonly onWarn?: (used: number, budget: number) => void,
) {}
track(usage: { prompt_tokens: number; completion_tokens: number }) {
this.used += usage.prompt_tokens + usage.completion_tokens;
if (this.used > this.budget) {
throw new BudgetExceeded(this.used, this.budget);
}
// 80% 超えで警告(Slack 通知・メトリクス発火などに繋げる)
if (this.used > this.budget * 0.8) {
this.onWarn?.(this.used, this.budget);
}
}
remaining(): number {
return Math.max(0, this.budget - this.used);
}
}
// 使い方(エージェント実行ループの内側)
const budget = new TokenBudget(200_000, (used, total) => {
console.warn(`budget at 80%: ${used}/${total}`);
});
for (const step of plannedSteps) {
try {
const response = await callLLM(step.prompt);
budget.track(response.usage);
applyResult(response);
} catch (err) {
if (err instanceof BudgetExceeded) {
await notifyHuman("budget exceeded, human review required", { err });
await saveCheckpoint(step);
throw err; // ループ全体を止める
}
throw err;
}
}
期待する動作は、トークン消費が予算の 80% を超えた時点で警告が飛び、100% を超えた時点で例外を投げて以降の処理を即停止、最後のチェックポイントと共にアラートが届く、というものです。これを入れておけば、最悪でも「予算の 1 倍分」の事故で済みます。
予算の決め方
初めて設計する場合、次のガイドが実用的です。
- 短いタスク(1 ファイル編集、ドキュメント更新): 10,000 〜 30,000 トークン
- 中規模タスク(複数ファイル横断のリファクタリング): 50,000 〜 150,000 トークン
- 大きいタスク(新機能開発、マイグレーション): 300,000 〜 500,000 トークン
これを超えた時点で「そのタスクはエージェントに任せる粒度ではない」と疑ってください。Planning Mode で分割するか、人間が手を動かすかに切り替える判断軸になります。この粒度分割の考え方は、Antigravity の Planning Mode を大規模プロジェクトで活かす戦略ガイド にまとめましたので、あわせて参考にしてください。
失敗トリアージ — どの失敗は再試行するか
Retry が暴発する最大の原因は、「すべての失敗を同じように扱ってしまう」ことです。実際にはエラーは次の 3 種類に分かれます。
- 一時的エラー(transient): ネットワーク瞬断、LLM のレートリミット、タイムアウト → 再試行でほぼ回復
- 構造的エラー(permanent): 認証失敗、権限不足、入力バリデーション、未知のモデル名 → 何回試しても同じ結果
- 曖昧なエラー(ambiguous): 500 エラー、JSON パース失敗、部分的な結果 → 一度だけ再試行して、駄目なら人間にエスカレート
この 3 分類を実装に落とすと、「再試行していい」という判断が明確になります。
// src/retry/triage.ts
// エラーを transient / permanent / ambiguous に分類するトリアージ関数。
// このロジックは実運用で必ず育てる。新種エラーが来るたびに分類を追加する。
export type ErrorClass = "transient" | "permanent" | "ambiguous";
export function classifyError(err: unknown): ErrorClass {
if (!(err instanceof Error)) return "ambiguous";
const msg = err.message.toLowerCase();
// --- 認証・権限・入力エラーは再試行しない ---
if (/unauthorized|forbidden|invalid_api_key|authentication/.test(msg)) {
return "permanent";
}
if (/validation|invalid request|bad request|schema/.test(msg)) {
return "permanent";
}
if (/model_not_found|unknown model/.test(msg)) {
return "permanent";
}
// --- 一時的なネットワーク・レートリミットは再試行する ---
if (/rate.?limit|too many requests|429/.test(msg)) {
return "transient";
}
if (/timeout|econnreset|etimedout|network/.test(msg)) {
return "transient";
}
if (/503|service unavailable|overloaded/.test(msg)) {
return "transient";
}
// --- 曖昧なものは一度だけ再試行する ---
if (/500|internal server|json|parse/.test(msg)) {
return "ambiguous";
}
return "ambiguous";
}
// 使い方: retry ループの shouldRetry に組み込む
export function shouldRetryFromTriage(err: unknown, attempt: number): boolean {
const klass = classifyError(err);
if (klass === "permanent") return false;
if (klass === "transient") return attempt < 5;
if (klass === "ambiguous") return attempt < 2; // 一度だけ
return false;
}
このトリアージは「最初から完璧」ではなく、運用しながら育てるものです。新しいエラーが出るたびに、「これは transient / permanent / ambiguous のどれか?」を決め、正規表現に 1 行追加します。これを続けていくと、自分のプロジェクトに最適化されたトリアージが手元に残ります。
チェックポイントと Retry を組み合わせて巨大タスクを守る
トリアージと予算を備えても、100 ステップのエージェントタスクが 95 ステップ目で落ちたとき、最初からやり直すのは時間もお金も無駄です。ここで役立つのがチェックポイント(途中状態の保存)です。Antigravity は Manager Surface で自動的にチェックポイントを取りますが、スクリプトから呼び出す自作エージェントでは明示的に設計する必要があります。
設計のポイントは 3 つです。
- ステップ単位で「完了済みステップの ID」と「次に実行するステップの入力」を保存する
- Retry 時は最後の成功点から再開する(最初からやり直さない)
- 副作用のあるステップは冪等性ガード(前述)で守る
// src/retry/checkpoint.ts
// チェックポイントと Retry を組み合わせた「再開可能なエージェント」の最小実装。
// 保存先は JSON ファイル / KV / Firestore など、耐久性のあるストレージを使うこと。
import fs from "node:fs/promises";
type Step = { id: string; run: () => Promise<void> };
type Checkpoint = {
completed: string[];
updatedAt: string;
};
export async function runResumable(
steps: Step[],
checkpointPath: string,
): Promise<void> {
let cp: Checkpoint;
try {
cp = JSON.parse(await fs.readFile(checkpointPath, "utf-8"));
} catch {
cp = { completed: [], updatedAt: new Date().toISOString() };
}
for (const step of steps) {
if (cp.completed.includes(step.id)) {
console.log(`skip already-completed step: ${step.id}`);
continue;
}
try {
await step.run();
cp.completed.push(step.id);
cp.updatedAt = new Date().toISOString();
await fs.writeFile(checkpointPath, JSON.stringify(cp, null, 2));
} catch (err) {
// 失敗時もチェックポイントを保存し、次回はここから再開できるようにする
await fs.writeFile(checkpointPath, JSON.stringify(cp, null, 2));
throw err;
}
}
}
期待する動作は、途中で落ちた次の実行で、すでに完了したステップを全てスキップして失敗地点から再開すること。大きなエージェントタスクほどこの設計の恩恵は大きく、再実行コストがほぼゼロになります。より踏み込んだ設計については、Antigravity のチェックポイントとロールバックを使いこなす完全ガイド も参考になさってください。
本番での観測 — ログ・メトリクス・アラート
Retry は「見えないところで回るもの」なので、観測性を欠かせません。最低限必要な 4 つを挙げます。
- 構造化ログ: 試行回数、待ち時間、エラーのクラス、消費トークン
- メトリクス: retry.count / retry.success / retry.fail / retry.budget.exceeded の 4 本
- アラート: 予算超過、連続失敗、permanent エラー発生
- 分散トレース: Antigravity のスパンと外部 API 呼び出しを紐づける
観測ツールは好みでよく、Langfuse や Helicone、あるいは自前の OpenTelemetry 収集でも構いません。私は個人開発では Cloudflare の Workers Analytics Engine + Slack Webhook を使って、最低限のメトリクスだけ飛ばしています。重要なのは「ダッシュボードを作り込むこと」ではなく、「Retry が暴発したら 5 分以内に気づける」ことです。
Antigravity のエージェントログに自作ロガーを差し込む具体的な方法は、Antigravity × Langfuse でエージェントの可観測性を高める運用ガイド にまとめましたので、観測基盤を整えるときの足がかりとしてお使いください。
チーム運用 — Retry ポリシーを明文化してレビューする
個人開発でも、ましてチーム開発ならなおさら、Retry の判断は「なんとなく」で回してはいけません。プロジェクトルートに RETRY_POLICY.md のようなファイルを置き、次の 5 項目を明文化しておくことを強くおすすめします。
- タスク種別ごとの
maxAttempts
- 待ち時間の
baseDelayMs / maxDelayMs / ジッター戦略
- タスク種別ごとのトークン予算(短/中/大)
- エラー分類の原則(どのメッセージは permanent か)
- 予算超過・permanent 検出時の通知先と人間の対応手順
このファイルはコードよりも先に書き、レビューしてから実装するのが順序として正しいです。実装だけあってポリシーがない状態は、「信号の色が決まっていない交差点」と同じで、事故の温床になります。エージェント固有の話ではありませんが、「暗黙のルールを明示に変える」思考は普遍的です。
4 本柱を 1 つのループに束ねる
ここまでの部品はそれぞれ独立して説明してきましたが、実際のプロジェクトでは 1 つのループに同居します。断片のままだと接続部分の判断が抜け落ちやすいので、全体を 1 画面に収まる形で組み上げておきます。
// src/agents/resilient-loop.ts
// 冪等性・バックオフ・コスト上限・失敗トリアージ・チェックポイントを 1 本のループに束ねる
import { IdempotencyGuard } from "../retry/idempotency";
import { retryWithBackoff } from "../retry/backoff";
import { TokenBudget } from "../retry/budget";
import { classifyError, shouldRetryFromTriage } from "../retry/triage";
import { runResumable } from "../retry/checkpoint";
const guard = new IdempotencyGuard();
const budget = new TokenBudget(200_000, (used, total) => {
notifySlack(`トークン予算 80% 到達: ${used}/${total}`);
});
type TaskStep = { id: string; action: string; payload: unknown };
async function runStep(step: TaskStep) {
return retryWithBackoff(
// 副作用は必ず guard の内側に置く。バックオフより内側であることが重要
() =>
guard.run(step.action, step.payload, async () => {
const response = await callLLM({ action: step.action, payload: step.payload });
budget.track(response.usage);
return response;
}),
{
maxAttempts: 5,
baseDelayMs: 500,
maxDelayMs: 30_000,
shouldRetry: (err, attempt) => {
if (classifyError(err) === "permanent") return false; // 認証・権限は即降参
if (String(err).includes("budget exceeded")) return false; // 予算超過も再試行しない
return shouldRetryFromTriage(err, attempt);
},
onRetry: (err, attempt, delayMs) => {
logger.warn({ step: step.id, attempt, delayMs, err: String(err) }, "retry");
},
},
);
}
export async function runTask(steps: TaskStep[]) {
const resumable = steps.map((s) => ({ id: s.id, run: async () => { await runStep(s); } }));
await runResumable(resumable, `.agent/checkpoint-${Date.now()}.json`);
}
このコードで一番注意していただきたいのは、guard.run がバックオフの内側にあるという入れ子の順序です。逆にしてしまうと、「1 回目で外部 API には届いたがレスポンス受信中に切れた」ケースで、2 回目の試行が冪等性チェックを通らずに副作用を二重に起こします。層の順番は、外から順に「チェックポイント → バックオフ → 冪等性ガード → 実処理」。この 4 層の順序さえ守れば、残りは設定値の調整です。
4 つの失敗シナリオでの挙動
このループが実際の失敗にどう反応するかを、想像しやすい形で並べておきます。
| 起きたこと | ループのふるまい | 人間がすること |
| 429 レート制限が途中で返る | トリアージが transient と判定 → ジッター付きで待機 → 次の試行で通過。予算は消費分だけ加算 | 何もしない(ログにだけ残る) |
| API キーが失効した | permanent と判定して即座に例外。再試行 0 回、副作用 0 回 | キーを差し替えて再開 |
| モデルが微妙に壊れた JSON を返し続ける | ambiguous として 1 回だけ再試行し、それでも駄目なら降参。予算は上限にまったく届かない | プロンプトかスキーマを見直す |
| 巨大なプロンプトが誤って混入 | 予算上限に到達 → チェックポイントを書き出して停止 → 通知が飛ぶ | チェックポイントから途中再開 |
どのシナリオでも、結果が「予測できる範囲に収まっている」ことが要点です。派手な自己修復は起きません。起きないことこそが、夜中に回しておける状態の中身だと私は考えています。
よくある失敗パターンと対処
最後に、本番運用で実際にぶつかりやすい失敗を 3 つ挙げます。
1. 冪等性ガードをつけ忘れて、Retry でメールが二重送信された
もっとも多い事故です。send_email / create_payment / post_to_slack のような副作用の強いアクションは、必ず Idempotency Key で守ります。新しい副作用アクションを足すときは、テストコードにも「2 回呼んでも 1 回しか実行されない」ケースを書いておくことが予防策です。
2. permanent エラーを Retry し続けて、予算を使い果たした
認証エラーや権限不足は、何回試しても同じ結果です。ここを transient 扱いしてしまうと、エージェントは「無限ループ + 課金」を静かに続けます。classifyError のテストに unauthorized / forbidden / invalid_api_key を含めておき、CI で shouldRetry === false になることを自動確認しましょう。
3. 予算超過後もアラートが飛ばず、翌朝に気づいた
onWarn の通知先がダミーのままだった、あるいは Slack Webhook の URL が失効していた、という地味なパターンです。通知自体の死活監視を、週に 1 度でもよいので走らせてください。テスト用の「必ず警告が飛ぶダミータスク」を 1 つ常設しておくのが、最小労力で最大の効果がある運用策です。
ここまで、Retry を機能ではなく設計対象として捉え直し、4 本柱(冪等性・バックオフ・コスト上限・失敗トリアージ)を実装コード付きで組み立ててきました。記事を読んでいただいた上で、今日すぐに始めていただきたい一歩はシンプルです。自分のエージェントスクリプトの中から「副作用を起こすアクション」を 1 つだけ選び、そこに IdempotencyGuard を差し込んでみてください。たったそれだけで、「Retry で同じメールが二重に飛ぶ」という、最も痛い事故の確率が大きく下がります。そこから少しずつ、バックオフ、予算、トリアージと広げていけば、1 〜 2 週間でエージェントの信頼性は別物になります。
Antigravity は、適切に設計すれば夜中に回しておける相棒です。手綱は私たちの側で握る必要がありますが、一度きちんと設計してしまえば、後は静かに働いてくれます。