AIエージェントはなぜ本番で壊れるのか
Antigravity で動くAIエージェントを開発環境でテストしているときは、すべてが順調に見えます。しかし本番にデプロイした瞬間、想像もしなかった壊れ方をします。LLM APIが429を返す。レスポンスが途中で途切れます。トークン上限に達してエージェントが中途半端な状態で停止します。
私がこの問題に本格的に向き合ったのは、マルチエージェントパイプラインを本番投入して3日目の深夜2時でした。Gemini APIのレートリミットに引っかかり、5つのエージェントが連鎖的に停止。復旧までに4時間かかっています。
このガイドでは、その経験から構築した耐障害性パターンをすべて公開します。サーキットブレーカー、リトライ戦略、モデルフォールバック、チェックポイント設計——本番環境でAIエージェントを安定稼働させるために必要な実装パターンを、動作するコードとともに解説します。
リトライ戦略の設計 — Exponential Backoff と Jitter の正しい実装
AIエージェントのAPI呼び出しは、従来のWebサービスとは異なるリトライ戦略が必要です。LLM APIは応答に数秒〜数十秒かかるため、単純なリトライでは待ち時間が爆発的に増加します。また、複数エージェントが同時にリトライすると「リトライストーム」を引き起こし、API側の負荷をさらに悪化させます。
なぜ固定間隔リトライではダメなのか
固定間隔(例: 毎回3秒待機)でリトライすると、障害発生時に全エージェントが同じタイミングで再リクエストを投げます。これがいわゆる「Thundering Herd(群集雪崩)」問題で、APIの復旧をかえって遅らせてしまいます。
Exponential Backoff with Jitter(指数バックオフ+ジッター)が標準解です。待ち時間を指数的に増やしつつ、ランダムなゆらぎを加えることで、リトライのタイミングを分散させます。
// Antigravity プロジェクトで使えるリトライユーティリティ
// エージェントのAPI呼び出しをラップして自動リトライする
interface RetryConfig {
maxRetries: number; // 最大リトライ回数
baseDelayMs: number; // 初回待機時間(ミリ秒)
maxDelayMs: number; // 待機時間の上限
jitterFactor: number; // ジッター係数(0〜1)
retryableErrors: number[]; // リトライ対象のHTTPステータス
}
const DEFAULT_CONFIG: RetryConfig = {
maxRetries: 5,
baseDelayMs: 1000,
maxDelayMs: 60000,
jitterFactor: 0.5,
retryableErrors: [429, 500, 502, 503, 504],
};
async function withRetry<T>(
operation: () => Promise<T>,
config: Partial<RetryConfig> = {}
): Promise<T> {
const cfg = { ...DEFAULT_CONFIG, ...config };
let lastError: Error | null = null;
for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) {
try {
return await operation();
} catch (error: unknown) {
lastError = error instanceof Error ? error : new Error(String(error));
// リトライ対象でないエラーは即座にスロー
const status = (error as { status?: number }).status;
if (status && !cfg.retryableErrors.includes(status)) {
throw error;
}
if (attempt === cfg.maxRetries) {
break; // 最大リトライ回数に達した
}
// Exponential Backoff + Full Jitter
const exponentialDelay = cfg.baseDelayMs * Math.pow(2, attempt);
const cappedDelay = Math.min(exponentialDelay, cfg.maxDelayMs);
const jitter = cappedDelay * cfg.jitterFactor * Math.random();
const finalDelay = cappedDelay - (cappedDelay * cfg.jitterFactor / 2) + jitter;
console.warn(
`[Retry ${attempt + 1}/${cfg.maxRetries}] ` +
`Waiting ${Math.round(finalDelay)}ms before retry. ` +
`Error: ${lastError.message}`
);
await new Promise(resolve => setTimeout(resolve, finalDelay));
}
}
throw new Error(
`Operation failed after ${cfg.maxRetries} retries. Last error: ${lastError?.message}`
);
}
// 使用例: Gemini API呼び出しにリトライを適用
const response = await withRetry(
() => fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': process.env.GEMINI_API_KEY ?? '',
},
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
}),
{ maxRetries: 3, baseDelayMs: 2000 }
);
// 期待出力: 429エラー時に2s→4s→8sの間隔でリトライし、成功すればレスポンスを返す
指数バックオフだけでは、集中は消えませんでした
上のコードには jitter フラグがあります。実装した当初、私はここを飾りだと思っていました。待ち時間が 2s → 4s → 8s と広がるのだから、それだけで十分に散るはずだ、と考えていたためです。
その思い込みを確かめたくて、手元で数えてみました。200 エージェントが同時に失敗し、それぞれ最大5回リトライする状況を置いて、リトライの到着時刻を 250ms 単位で数えるだけの短いスクリプトです。
// retry-arrival.mjs — リトライ到着時刻の集中度を数える
// 実行: node retry-arrival.mjs
const AGENTS = 200, MAX_RETRY = 5, BASE = 1000, CAP = 30000, BUCKET = 250;
function arrivals(kind) {
const out = [];
for (let a = 0; a < AGENTS; a++) {
let t = 0, prev = BASE;
for (let n = 0; n < MAX_RETRY; n++) {
let d;
if (kind === 'fixed') {
d = 3000; // 固定間隔
} else if (kind === 'exp') {
d = Math.min(CAP, BASE * 2 ** n); // 指数のみ(ジッターなし)
} else if (kind === 'full') {
d = Math.random() * Math.min(CAP, BASE * 2 ** n); // Full Jitter
} else {
d = Math.min(CAP, BASE + Math.random() * (prev * 3 - BASE)); // Decorrelated Jitter
prev = d;
}
t += d;
out.push(t);
}
}
return out;
}
function peak(list) {
const bucket = {};
for (const t of list) {
const k = Math.floor(t / BUCKET);
bucket[k] = (bucket[k] || 0) + 1;
}
return Math.max(...Object.values(bucket));
}
for (const kind of ['fixed', 'exp', 'full', 'decorr']) {
let sum = 0;
for (let i = 0; i < 20; i++) sum += peak(arrivals(kind));
console.log(kind.padEnd(7), '250msあたりの最大同時到着:', (sum / 20).toFixed(1));
}
20 回試行の平均です。
fixed 250msあたりの最大同時到着: 200.0
exp 250msあたりの最大同時到着: 200.0
full 250msあたりの最大同時到着: 77.0
decorr 250msあたりの最大同時到着: 36.0
ジッターを使う2行は乱数に依存するため、実行のたびに数個ぶん上下します(手元では full が 74〜78、decorr が 35〜38 の範囲でした)。fixed と exp は乱数を含まないため、何度走らせても同じ値になります。
exp の行が、私の思い込みでした。指数バックオフを入れても、ピークは固定間隔とまったく同じ 200 のままです。待ち時間が伸びても、全エージェントが同じ式から同じ値を出すのですから、揃って戻ってくるのは当然でした。
さらに、平均到着密度に対するピークの比を取ると、指数のみの構成は固定間隔より悪くなっていました。到着が数回の鋭いスパイクに圧縮されるためです。
| 戦略 | 250msあたりの最大同時到着 | 平均密度に対するピーク比 |
| 固定間隔(3秒) | 200 | 12.0 |
| 指数バックオフのみ | 200 | 24.8 |
| Full Jitter | 77 | 8.5 |
| Decorrelated Jitter | 36 | 12.9 |
Decorrelated Jitter は絶対量のピークが最も低い一方で、比の列では Full Jitter に劣ります。到着が長い時間へ広がるぶん平均密度が下がり、比の分母が小さくなるためです。API に届く瞬間最大負荷を下げたいのであれば絶対量の列を、監視のしきい値を設計したいのであれば比の列を見る、という使い分けになります。
散らしているのはジッターであって、指数の部分ではありませんでした。指数バックオフは「1エージェントが投げる総回数を抑える」ために効き、ジッターは「複数エージェントの到着を重ねない」ために効きます。効く相手が違う2つを、私は1つだと思っていたわけです。
jitter: false のまま動いているコードが手元にあるなら、そこだけは今日のうちに true にしておく価値があります。1行の変更で、障害時に API へ届く瞬間最大負荷が半分以下になります。
リトライと冪等性
ここで見落としがちな問題があります。リトライするということは、同じ操作を複数回実行する可能性があるということです。LLMへの問い合わせ自体は冪等ですが、エージェントが「ファイルに書き込む」「APIを呼ぶ」といった副作用を伴うアクションを実行する場合、リトライが重複実行を引き起こします。
対策は、アクション実行前にチェックポイントを記録し、リトライ時に「すでに実行済みか」を確認するパターンです。これについては後のセクションで詳しく解説します。
サーキットブレーカー — 連鎖障害を断ち切る
リトライだけでは、根本的にダウンしているサービスに対して無駄なリクエストを投げ続けることになります。サーキットブレーカーは、障害が一定回数続いたら自動的にリクエストを遮断し、サービスの復旧を待つパターンです。
3つの状態遷移
サーキットブレーカーには3つの状態があります。
Closed(閉): 正常運転。リクエストはそのまま通過します。失敗をカウントし、閾値に達したらOpenに遷移します。
Open(開): 遮断状態。リクエストを即座に拒否し、フォールバック処理を実行します。一定時間が経過したらHalf-Openに遷移します。
Half-Open(半開): 試験的に1件だけリクエストを通す。成功すればClosedに戻り、失敗すればOpenに戻る。
// AIエージェント用サーキットブレーカー実装
// モデルAPI・外部ツール呼び出しの障害を自動遮断する
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
interface CircuitBreakerConfig {
failureThreshold: number; // Open遷移の失敗回数閾値
resetTimeoutMs: number; // Open→Half-Open遷移までの待ち時間
halfOpenMaxAttempts: number; // Half-Openで試す最大リクエスト数
monitorWindowMs: number; // 失敗カウントのウィンドウ(古い失敗を忘れる)
}
class CircuitBreaker {
private state: CircuitState = 'CLOSED';
private failures: number[] = []; // タイムスタンプ配列
private lastOpenTime = 0;
private halfOpenAttempts = 0;
private consecutiveSuccesses = 0;
constructor(
private name: string,
private config: CircuitBreakerConfig = {
failureThreshold: 5,
resetTimeoutMs: 30000,
halfOpenMaxAttempts: 3,
monitorWindowMs: 60000,
}
) {}
async execute<T>(
operation: () => Promise<T>,
fallback?: () => Promise<T>
): Promise<T> {
// ウィンドウ外の古い失敗記録を除去
const now = Date.now();
this.failures = this.failures.filter(
t => now - t < this.config.monitorWindowMs
);
if (this.state === 'OPEN') {
if (now - this.lastOpenTime >= this.config.resetTimeoutMs) {
// タイムアウト経過 → Half-Openへ遷移
this.state = 'HALF_OPEN';
this.halfOpenAttempts = 0;
this.consecutiveSuccesses = 0;
console.info(`[CircuitBreaker:${this.name}] OPEN → HALF_OPEN`);
} else {
// まだOpen中 → フォールバック実行
console.warn(`[CircuitBreaker:${this.name}] Circuit OPEN — executing fallback`);
if (fallback) return fallback();
throw new Error(`Circuit breaker "${this.name}" is OPEN`);
}
}
if (this.state === 'HALF_OPEN' && this.halfOpenAttempts >= this.config.halfOpenMaxAttempts) {
// Half-Openの試行上限 → まだ不安定ならOpenに戻す
this.transitionToOpen();
if (fallback) return fallback();
throw new Error(`Circuit breaker "${this.name}" — half-open attempts exhausted`);
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
if (this.state === 'OPEN' && fallback) {
return fallback();
}
throw error;
}
}
private onSuccess(): void {
if (this.state === 'HALF_OPEN') {
this.consecutiveSuccesses++;
this.halfOpenAttempts++;
if (this.consecutiveSuccesses >= 2) {
// 連続成功 → Closedに復帰
this.state = 'CLOSED';
this.failures = [];
console.info(`[CircuitBreaker:${this.name}] HALF_OPEN → CLOSED (recovered)`);
}
}
}
private onFailure(): void {
this.failures.push(Date.now());
if (this.state === 'HALF_OPEN') {
this.transitionToOpen();
return;
}
if (this.failures.length >= this.config.failureThreshold) {
this.transitionToOpen();
}
}
private transitionToOpen(): void {
this.state = 'OPEN';
this.lastOpenTime = Date.now();
console.warn(
`[CircuitBreaker:${this.name}] → OPEN ` +
`(failures: ${this.failures.length}, timeout: ${this.config.resetTimeoutMs}ms)`
);
}
getState(): CircuitState {
return this.state;
}
}
// 使用例: Gemini API用とGemma 4ローカルモデル用を個別に管理
const geminiBreaker = new CircuitBreaker('gemini-api', {
failureThreshold: 3,
resetTimeoutMs: 30000,
halfOpenMaxAttempts: 2,
monitorWindowMs: 60000,
});
const gemmaBreaker = new CircuitBreaker('gemma-local', {
failureThreshold: 2, // ローカルモデルは障害が深刻な場合が多い
resetTimeoutMs: 10000, // 復旧が速いので短めに
halfOpenMaxAttempts: 1,
monitorWindowMs: 30000,
});
// 期待動作: geminiBreaker は60秒間に3回失敗するとOpen状態になり、
// 30秒後にHalf-Openで試行、2回連続成功でClosedに復帰する
なぜエージェントごとにサーキットブレーカーが必要なのか
Antigravity のマルチエージェント構成では、各エージェントが異なるAPIやツールに依存しています。1つのサーキットブレーカーで全体を管理すると、特定APIの障害が無関係なエージェントまで止めてしまいます。依存先ごとにサーキットブレーカーを分離するのが鉄則です。
モデルフォールバック — AIの「プランB」を用意する
LLM APIの障害で最も効果的な対策は、別のモデルにフォールバックすることです。Antigravity は複数のAIモデルを切り替えて使える環境なので、この利点を最大限に活かします。
フォールバックチェーンの設計
モデルフォールバックでは「品質」と「可用性」のトレードオフが発生します。最高品質のモデルが使えないとき、どこまで品質を妥協するかを事前に決めておく必要があります。
// モデルフォールバックチェーン
// 高品質モデルから順に試行し、利用可能なモデルで応答する
interface ModelConfig {
id: string;
endpoint: string;
apiKeyEnv: string;
maxTokens: number;
qualityTier: 'premium' | 'standard' | 'fallback';
circuitBreaker: CircuitBreaker;
}
interface FallbackResult<T> {
data: T;
modelUsed: string;
qualityTier: string;
wasFallback: boolean;
attemptedModels: string[];
}
class ModelFallbackChain {
private models: ModelConfig[];
constructor(models: ModelConfig[]) {
// qualityTier順にソート(premium → standard → fallback)
const tierOrder = { premium: 0, standard: 1, fallback: 2 };
this.models = [...models].sort(
(a, b) => tierOrder[a.qualityTier] - tierOrder[b.qualityTier]
);
}
async generate(prompt: string): Promise<FallbackResult<string>> {
const attemptedModels: string[] = [];
for (const model of this.models) {
attemptedModels.push(model.id);
try {
const result = await model.circuitBreaker.execute(
async () => {
const apiKey = process.env[model.apiKeyEnv];
if (!apiKey) throw new Error(`Missing API key: ${model.apiKeyEnv}`);
const response = await withRetry(
() => fetch(model.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: model.id,
messages: [{ role: 'user', content: prompt }],
max_tokens: model.maxTokens,
}),
}),
{ maxRetries: 2, baseDelayMs: 1000 }
);
if (!response.ok) {
const err = new Error(`API error: ${response.status}`);
(err as Error & { status: number }).status = response.status;
throw err;
}
const data = await response.json();
return data.choices?.[0]?.message?.content ?? data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
},
// サーキットブレーカーのフォールバックは次のモデルに委譲
undefined
);
return {
data: result,
modelUsed: model.id,
qualityTier: model.qualityTier,
wasFallback: attemptedModels.length > 1,
attemptedModels,
};
} catch (error) {
console.warn(
`[Fallback] ${model.id} failed (${model.qualityTier}): ${(error as Error).message}`
);
continue; // 次のモデルを試行
}
}
throw new Error(
`All models failed. Attempted: ${attemptedModels.join(' → ')}`
);
}
}
// 使用例: Gemini Pro → Gemma 4 ローカル → Gemini Flash の順にフォールバック
const chain = new ModelFallbackChain([
{
id: 'gemini-2.5-pro',
endpoint: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent',
apiKeyEnv: 'GEMINI_API_KEY',
maxTokens: 8192,
qualityTier: 'premium',
circuitBreaker: geminiBreaker,
},
{
id: 'gemma-4-local',
endpoint: 'http://localhost:11434/api/generate',
apiKeyEnv: 'OLLAMA_DUMMY_KEY', // ローカルはキー不要だがインターフェース統一のため
maxTokens: 4096,
qualityTier: 'standard',
circuitBreaker: gemmaBreaker,
},
{
id: 'gemini-2.0-flash',
endpoint: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
apiKeyEnv: 'GEMINI_API_KEY',
maxTokens: 4096,
qualityTier: 'fallback',
circuitBreaker: new CircuitBreaker('gemini-flash'),
},
]);
const result = await chain.generate('Explain the circuit breaker pattern');
console.log(`Model used: ${result.modelUsed} (${result.qualityTier})`);
console.log(`Was fallback: ${result.wasFallback}`);
// 期待出力(Gemini Pro が429エラーの場合):
// [Fallback] gemini-2.5-pro failed (premium): API error: 429
// Model used: gemma-4-local (standard)
// Was fallback: true
フォールバック時の品質低下を可視化する
フォールバックが発生していることをチームが認識できなければ、品質低下に気づかないまま運用が続きます。フォールバック発生時にはメトリクスを記録し、ダッシュボードに表示する点が肝心です。
チェックポイント設計 — 「どこまで終わったか」を記録する
AIエージェントが長時間かかるタスクを実行しているとき、途中で障害が起きると最初からやり直しになります。チェックポイントを導入すれば、最後に成功した地点から再開できます。
実装パターン: ステップベースのチェックポイント
// エージェントタスクのチェックポイント管理
// 各ステップの完了状態を永続化し、障害後の再開を可能にする
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
interface CheckpointData {
taskId: string;
currentStep: number;
totalSteps: number;
completedSteps: Record<string, {
result: unknown;
completedAt: string;
durationMs: number;
}>;
metadata: Record<string, unknown>;
lastUpdated: string;
}
class CheckpointManager {
private checkpointDir: string;
constructor(baseDir: string = '.antigravity/checkpoints') {
this.checkpointDir = baseDir;
}
private getPath(taskId: string): string {
return join(this.checkpointDir, `${taskId}.json`);
}
async load(taskId: string): Promise<CheckpointData | null> {
try {
const raw = await readFile(this.getPath(taskId), 'utf-8');
return JSON.parse(raw) as CheckpointData;
} catch {
return null; // チェックポイントなし → 最初から実行
}
}
async save(data: CheckpointData): Promise<void> {
await mkdir(this.checkpointDir, { recursive: true });
data.lastUpdated = new Date().toISOString();
await writeFile(this.getPath(data.taskId), JSON.stringify(data, null, 2));
}
async markStepComplete(
taskId: string,
stepName: string,
result: unknown,
durationMs: number
): Promise<void> {
const data = await this.load(taskId);
if (!data) throw new Error(`No checkpoint found for task: ${taskId}`);
data.completedSteps[stepName] = {
result,
completedAt: new Date().toISOString(),
durationMs,
};
data.currentStep++;
await this.save(data);
}
isStepCompleted(checkpoint: CheckpointData, stepName: string): boolean {
return stepName in checkpoint.completedSteps;
}
}
// 使用例: マルチステップのコード分析エージェント
async function runCodeAnalysisAgent(repoPath: string): Promise<void> {
const checkpoints = new CheckpointManager();
const taskId = `code-analysis-${repoPath.replace(/\//g, '-')}`;
// 既存チェックポイントがあれば途中から再開
let checkpoint = await checkpoints.load(taskId);
if (checkpoint) {
console.log(
`Resuming from step ${checkpoint.currentStep}/${checkpoint.totalSteps}`
);
} else {
checkpoint = {
taskId,
currentStep: 0,
totalSteps: 4,
completedSteps: {},
metadata: { repoPath },
lastUpdated: new Date().toISOString(),
};
await checkpoints.save(checkpoint);
}
const steps = [
{ name: 'scan-files', fn: () => scanRepository(repoPath) },
{ name: 'analyze-deps', fn: () => analyzeDependencies(repoPath) },
{ name: 'security-audit', fn: () => runSecurityAudit(repoPath) },
{ name: 'generate-report', fn: () => generateReport(repoPath) },
];
for (const step of steps) {
if (checkpoints.isStepCompleted(checkpoint, step.name)) {
console.log(`Skipping completed step: ${step.name}`);
continue;
}
const start = Date.now();
try {
const result = await step.fn();
await checkpoints.markStepComplete(
taskId, step.name, result, Date.now() - start
);
console.log(`✅ ${step.name} completed in ${Date.now() - start}ms`);
} catch (error) {
console.error(`❌ ${step.name} failed: ${(error as Error).message}`);
console.log('Checkpoint saved. Re-run to resume from this step.');
throw error; // 上位でリトライ判断
}
}
}
// ダミー関数(実際のプロジェクトでは具体的な実装に置き換える)
async function scanRepository(path: string) { return { files: 150 }; }
async function analyzeDependencies(path: string) { return { deps: 42 }; }
async function runSecurityAudit(path: string) { return { issues: 3 }; }
async function generateReport(path: string) { return { reportPath: '/tmp/report.md' }; }
// 期待出力(2回目の実行、step 1まで完了済みの場合):
// Resuming from step 1/4
// Skipping completed step: scan-files
// ✅ analyze-deps completed in 2341ms
// ✅ security-audit completed in 5102ms
// ✅ generate-report completed in 1203ms
チェックポイントの落とし穴
チェックポイントには「陳腐化」の問題があります。外部データが変化した後に古いチェックポイントから再開すると、前半のステップの結果と後半の処理で不整合が起きます。対策として、チェックポイントにTTL(有効期限)を設定し、一定時間を超えたチェックポイントは破棄して最初から実行し直すのが安全です。
グレースフルデグラデーション — 完全停止より「縮退運転」を選ぶ
エージェントが100%の機能を提供できない状況でも、ユーザーに何らかの価値を返す点が肝心です。これがグレースフルデグラデーション(縮退運転)の考え方です。
3段階のデグラデーション戦略
// グレースフルデグラデーション管理
// システムの状態に応じて機能レベルを自動調整する
type ServiceLevel = 'full' | 'degraded' | 'minimal';
interface DegradationPolicy {
level: ServiceLevel;
enabledFeatures: string[];
disabledFeatures: string[];
userMessage: string;
}
class GracefulDegradation {
private policies: Record<ServiceLevel, DegradationPolicy> = {
full: {
level: 'full',
enabledFeatures: ['ai-generation', 'code-analysis', 'multi-agent', 'streaming'],
disabledFeatures: [],
userMessage: '',
},
degraded: {
level: 'degraded',
enabledFeatures: ['ai-generation', 'code-analysis'],
disabledFeatures: ['multi-agent', 'streaming'],
userMessage: '一部機能を制限して運用中です。基本的なAI生成とコード分析は利用可能です。',
},
minimal: {
level: 'minimal',
enabledFeatures: ['code-analysis'],
disabledFeatures: ['ai-generation', 'multi-agent', 'streaming'],
userMessage: 'AI APIに接続できないため、ローカル解析のみ利用可能です。',
},
};
private currentLevel: ServiceLevel = 'full';
evaluate(circuitBreakers: Map<string, CircuitBreaker>): DegradationPolicy {
const openBreakers = Array.from(circuitBreakers.entries())
.filter(([_, cb]) => cb.getState() === 'OPEN')
.map(([name]) => name);
if (openBreakers.length === 0) {
this.currentLevel = 'full';
} else if (openBreakers.includes('gemini-api') && openBreakers.includes('gemma-local')) {
// 全モデルがダウン → minimal
this.currentLevel = 'minimal';
} else {
// 一部モデルがダウン → degraded
this.currentLevel = 'degraded';
}
const policy = this.policies[this.currentLevel];
if (this.currentLevel !== 'full') {
console.warn(
`[Degradation] Level: ${this.currentLevel} | ` +
`Open breakers: ${openBreakers.join(', ')} | ` +
`Disabled: ${policy.disabledFeatures.join(', ')}`
);
}
return policy;
}
isFeatureEnabled(feature: string): boolean {
return this.policies[this.currentLevel].enabledFeatures.includes(feature);
}
}
// 使用例
const degradation = new GracefulDegradation();
const breakers = new Map<string, CircuitBreaker>([
['gemini-api', geminiBreaker],
['gemma-local', gemmaBreaker],
]);
const policy = degradation.evaluate(breakers);
if (!degradation.isFeatureEnabled('multi-agent')) {
console.log('マルチエージェントは一時的に無効化されています');
// シングルエージェントモードにフォールバック
}
// 期待出力(geminiBreaker がOPENの場合):
// [Degradation] Level: degraded | Open breakers: gemini-api | Disabled: multi-agent, streaming
// マルチエージェントは一時的に無効化されています
よくある間違いと落とし穴
落とし穴1: リトライ回数を増やせば解決すると思っている
リトライ回数を10回、20回に増やしても、APIが根本的にダウンしていれば無意味です。むしろ、大量のリトライがAPIサーバーの復旧を妨げます。リトライは最大5回程度にとどめ、サーキットブレーカーと組み合わせるのが実用的です。
落とし穴2: エラーの種類を区別していない
すべてのエラーを同じリトライ戦略で処理するのは危険です。429(レートリミット)は待てば回復しますが、401(認証エラー)や400(不正リクエスト)はいくらリトライしても解決しません。エラーコードに応じて「リトライ可能」と「即座に失敗」を分けてください。
落とし穴3: フォールバックモデルの品質差を無視する
Gemini Pro からGemini Flashにフォールバックしたとき、出力品質が大きく変わる可能性があります。フォールバック時はプロンプトを調整する(例: より詳細な指示を追加する、出力フォーマットを厳密に指定する)ことで品質低下を軽減できます。
落とし穴4: チェックポイントのストレージを考慮していない
チェックポイントをファイルシステムに書くなら、ディスク容量とI/O性能を考慮する必要があります。大量のエージェントが同時にチェックポイントを書き込むと、I/Oがボトルネックになります。本番環境ではRedisやKVストアの利用を検討すべきです。
落とし穴5: テスト環境で障害をシミュレートしていない
耐障害性パターンを実装しても、実際に障害が起きたときに正しく動くかテストしなければ意味がありません。Chaos Engineering の考え方を取り入れ、意図的にAPIエラーやタイムアウトを注入するテストを用意してください。
// 障害注入テスト — サーキットブレーカーの動作を検証する
// Antigravity のテストランナーで実行可能
async function testCircuitBreakerTransition(): Promise<void> {
const breaker = new CircuitBreaker('test-breaker', {
failureThreshold: 3,
resetTimeoutMs: 1000,
halfOpenMaxAttempts: 2,
monitorWindowMs: 5000,
});
let callCount = 0;
const failingOperation = async () => {
callCount++;
throw new Error('Simulated API failure');
};
// 3回失敗でOpenに遷移するはず
for (let i = 0; i < 3; i++) {
try {
await breaker.execute(failingOperation);
} catch { /* expected */ }
}
console.assert(
breaker.getState() === 'OPEN',
`Expected OPEN, got ${breaker.getState()}`
);
console.log(`✅ Circuit opened after ${callCount} failures`);
// Open中はフォールバックが呼ばれる
const fallbackResult = await breaker.execute(
failingOperation,
async () => 'fallback-value'
);
console.assert(
fallbackResult === 'fallback-value',
'Fallback should be returned when circuit is OPEN'
);
console.log('✅ Fallback executed during OPEN state');
// 1秒後にHalf-Openに遷移
await new Promise(r => setTimeout(r, 1100));
console.assert(
breaker.getState() === 'OPEN', // まだ execute() を呼ぶまでは OPEN のまま
'Should still be OPEN until next execute()'
);
console.log('✅ All circuit breaker transitions verified');
}
testCircuitBreakerTransition();
// 期待出力:
// ✅ Circuit opened after 3 failures
// ✅ Fallback executed during OPEN state
// ✅ All circuit breaker transitions verified
監視とアラート — 障害を検知してから復旧までの時間を最小化する
耐障害性パターンを導入しても、障害の発生自体を見逃していては改善できません。エージェントの健全性を継続的に監視し、異常を早期に検知する仕組みが必要です。
メトリクス収集のポイント
AIエージェントの監視では、従来のWebサービスとは異なるメトリクスが重要になります。
// AIエージェント監視メトリクス収集
// OpenTelemetry互換のメトリクス記録
interface AgentMetrics {
// リクエストレベル
requestCount: number;
errorCount: number;
retryCount: number;
fallbackCount: number;
// レイテンシ
p50LatencyMs: number;
p95LatencyMs: number;
p99LatencyMs: number;
// トークン消費
inputTokensTotal: number;
outputTokensTotal: number;
estimatedCostUsd: number;
// サーキットブレーカー
circuitOpenEvents: number;
circuitRecoveryEvents: number;
meanTimeToRecoveryMs: number;
// チェックポイント
checkpointSaveCount: number;
checkpointResumeCount: number;
}
class MetricsCollector {
private latencies: number[] = [];
private metrics: AgentMetrics = {
requestCount: 0,
errorCount: 0,
retryCount: 0,
fallbackCount: 0,
p50LatencyMs: 0,
p95LatencyMs: 0,
p99LatencyMs: 0,
inputTokensTotal: 0,
outputTokensTotal: 0,
estimatedCostUsd: 0,
circuitOpenEvents: 0,
circuitRecoveryEvents: 0,
meanTimeToRecoveryMs: 0,
checkpointSaveCount: 0,
checkpointResumeCount: 0,
};
recordRequest(latencyMs: number, success: boolean): void {
this.metrics.requestCount++;
this.latencies.push(latencyMs);
if (!success) this.metrics.errorCount++;
this.updatePercentiles();
}
recordRetry(): void { this.metrics.retryCount++; }
recordFallback(): void { this.metrics.fallbackCount++; }
recordTokenUsage(input: number, output: number, costPerMillionTokens: number): void {
this.metrics.inputTokensTotal += input;
this.metrics.outputTokensTotal += output;
this.metrics.estimatedCostUsd +=
((input + output) / 1_000_000) * costPerMillionTokens;
}
private updatePercentiles(): void {
const sorted = [...this.latencies].sort((a, b) => a - b);
const len = sorted.length;
if (len === 0) return;
this.metrics.p50LatencyMs = sorted[Math.floor(len * 0.5)] ?? 0;
this.metrics.p95LatencyMs = sorted[Math.floor(len * 0.95)] ?? 0;
this.metrics.p99LatencyMs = sorted[Math.floor(len * 0.99)] ?? 0;
}
getSnapshot(): AgentMetrics {
return { ...this.metrics };
}
// アラート条件の評価
evaluateAlerts(): string[] {
const alerts: string[] = [];
const errorRate = this.metrics.requestCount > 0
? this.metrics.errorCount / this.metrics.requestCount
: 0;
if (errorRate > 0.1) {
alerts.push(`🔴 エラー率が ${(errorRate * 100).toFixed(1)}% に上昇(閾値: 10%)`);
}
if (this.metrics.p95LatencyMs > 30000) {
alerts.push(`🟡 P95レイテンシが ${this.metrics.p95LatencyMs}ms(閾値: 30,000ms)`);
}
if (this.metrics.fallbackCount > this.metrics.requestCount * 0.2) {
alerts.push(`🟠 フォールバック率が20%超 — プライマリモデルの障害を確認`);
}
return alerts;
}
}
// 期待動作: メトリクスを集計し、エラー率10%超・P95レイテンシ30秒超・
// フォールバック率20%超でアラートを発火する
全パターンを統合する — 本番対応エージェントラッパー
ここまで解説した個別パターンを統合し、Antigravityプロジェクトでそのまま使えるエージェントラッパーを構築します。
// 本番対応AIエージェントラッパー
// リトライ・サーキットブレーカー・フォールバック・チェックポイント・監視を統合
class ResilientAgent {
private fallbackChain: ModelFallbackChain;
private checkpoints: CheckpointManager;
private degradation: GracefulDegradation;
private metricsCollector: MetricsCollector;
private circuitBreakers: Map<string, CircuitBreaker>;
constructor(config: {
models: ModelConfig[];
checkpointDir?: string;
}) {
this.fallbackChain = new ModelFallbackChain(config.models);
this.checkpoints = new CheckpointManager(config.checkpointDir);
this.degradation = new GracefulDegradation();
this.metricsCollector = new MetricsCollector();
this.circuitBreakers = new Map(
config.models.map(m => [m.id, m.circuitBreaker])
);
}
async executeTask(taskId: string, steps: Array<{
name: string;
requiresAI: boolean;
execute: (aiGenerate: (prompt: string) => Promise<string>) => Promise<unknown>;
}>): Promise<{ success: boolean; results: Record<string, unknown> }> {
// デグラデーション状態を評価
const policy = this.degradation.evaluate(this.circuitBreakers);
if (policy.level !== 'full') {
console.warn(`[Agent] Running in ${policy.level} mode: ${policy.userMessage}`);
}
// チェックポイントから再開
let checkpoint = await this.checkpoints.load(taskId);
if (!checkpoint) {
checkpoint = {
taskId,
currentStep: 0,
totalSteps: steps.length,
completedSteps: {},
metadata: {},
lastUpdated: new Date().toISOString(),
};
await this.checkpoints.save(checkpoint);
}
const results: Record<string, unknown> = {};
for (const step of steps) {
if (this.checkpoints.isStepCompleted(checkpoint, step.name)) {
results[step.name] = checkpoint.completedSteps[step.name]?.result;
continue;
}
// AI必須のステップでAIが利用不可なら飛ばす
if (step.requiresAI && !this.degradation.isFeatureEnabled('ai-generation')) {
console.warn(`[Agent] Skipping AI step "${step.name}" — AI unavailable`);
continue;
}
const start = Date.now();
try {
const aiGenerate = async (prompt: string): Promise<string> => {
const result = await this.fallbackChain.generate(prompt);
if (result.wasFallback) this.metricsCollector.recordFallback();
this.metricsCollector.recordRequest(Date.now() - start, true);
return result.data;
};
const result = await step.execute(aiGenerate);
const duration = Date.now() - start;
await this.checkpoints.markStepComplete(taskId, step.name, result, duration);
results[step.name] = result;
console.log(`✅ [${step.name}] ${duration}ms`);
} catch (error) {
this.metricsCollector.recordRequest(Date.now() - start, false);
const alerts = this.metricsCollector.evaluateAlerts();
if (alerts.length > 0) {
console.error('[Alerts]', alerts.join('\n'));
}
throw error;
}
}
return { success: true, results };
}
}
// 使用例
const agent = new ResilientAgent({
models: [
/* 前述のModelConfig配列 */
],
checkpointDir: '.antigravity/checkpoints',
});
// このラッパーにより、個別パターンの実装を意識せずに
// 耐障害性のあるエージェントタスクを定義・実行できる
個人開発者の視点から(実体験メモ)
ここまでのパターンは、チーム開発を前提とした資料でよく見かけるものです。ただ、個人で運用していると優先順位がかなり変わりました。数字の決め方を中心に、手元の判断を書き残しておきます。
閾値は「トラフィック量」ではなく「復旧の速さ」から決めています
この記事に出てくる failureThreshold: 5 のような値は出発点であって、本番の値ではありません。当初の私は「リクエストが多いほど閾値を上げる」と考えていました。ところが個人開発の規模では、毎分数リクエストという状態が普通にあります。その状態で閾値を10にすると、ブレーカーが開く前に一日が終わってしまいます。
そこで、依存先ごとに resetTimeoutMs のほうから決めるようになりました。クラウドのLLM APIはレートリミットの窓が30〜60秒で戻ることが多いので、その範囲。Ollama で動かすローカルモデルは再起動が5〜10秒で済むので、もっと短く。failureThreshold は「その待ち時間を発生させてよい失敗回数」として、後から決めます。順番を逆にしてから、値を置くときの迷いが減りました。
フォールバックは、必ずしもコストを下げません
モデルフォールバックはコスト削減の文脈で語られがちですが、常にそうなるとは限らないと考えています。上位モデルから安価なモデルへ落ちる構成なら、フォールバックしている間はむしろ支払いが減ります。逆に、別ベンダーの高価なモデルを予備に置いた場合、障害が長引くほど請求が伸びていきます。
estimatedCostUsd をメトリクスに載せて予算アラートを設定しておくのは、このためです。ブレーカーの設定を誤ったまま高価な予備モデルへ流し続ける事故は、気づく場所が請求書になりやすい種類のものです。
サーバーレスでは、ファイルシステムのチェックポイントは成立しません
Cloudflare Workers や Vercel Edge Functions のような環境には、永続的なローカルストレージがありません。この記事の CheckpointManager をそのまま持っていくと、readFile / writeFile の時点で破綻します。
差し替え先は Cloudflare KV・Upstash Redis・DynamoDB あたりになります。CheckpointManager は読み書きの2箇所を入れ替えれば移せるように書いてありますが、私自身はこの差し替えを後から慌ててやりました。永続化の口を最初から1箇所に寄せておけばよかった、というのが正直な感想です。
次のアクション
この記事のコードをそのままプロジェクトへ持っていくこともできますが、まずは最も効果の高い1つから始めることをお勧めします。本番で起きる障害の多くは「リトライなしでAPIを呼んでいる」ことが原因です。withRetry 関数を追加し、LLM API呼び出しをすべてラップするところから始めてみてください。それだけで、深夜に叩き起こされる回数は目に見えて減ります。
サーキットブレーカーとフォールバックは、マルチエージェント構成に進んだタイミングで導入すれば十分です。エージェントが1つだけの段階では複雑さに見合いません。段階的に、必要になった時点で追加していくのが実用的なアプローチです。
AIエージェントの本番運用に関する設計パターンについては、Antigravityマルチエージェント設計の落とし穴と解決策や本番環境で動くAIエージェント設計 — オーケストレーションパターンと信頼性設計も併せてご覧いただければと思います。
耐障害性設計は、一度書いて終わるものではありませんでした。閾値もフォールバック先も、運用しながら実態に合わせて動かしていく類のものです。私もまだ調整の途中で、確信を持って置いた設定より、思い込みのまま残っている設定のほうが多いのが正直なところです。同じところを通られた方の工夫があれば、ぜひ伺ってみたいと思っています。お読みいただきありがとうございました。