ようやく腹を据えて「自動復旧」を設計に組み込んだ夜
個人開発でアプリやサービスを一人で運用していると、いちばん神経を使うのは「深夜に何かが壊れたとき」の対応です。課金まわりの検証、外部 API の呼び出し、自動応答 — どれも止まると翌朝の収益と評判に直撃します。
Claude API でエージェントを組み始めた頃、私もご多分に漏れず「うまく動いたとき」のデモコードばかり書いていました。レート制限、ツールタイムアウト、モデルの幻覚 — 「起きる前提」で設計していなかったのです。
実運用に入ると、AI エージェントの障害は通常のサーバ障害と質が異なる、ということに気づかされます。429 が返ってきた一つの事象に対して、対応すべき選択肢は「再試行」「モデル変更」「コンテキスト圧縮」「人間にエスカレーション」の4種類に増えます。サーキットブレーカーを噛ませるだけでは足りないのです。
ここで取り上げる「自己修復型(Self-Healing)」エージェントは、こうした AI 特有の障害をエージェント自身が分類し、最適な復旧戦略を選んで実行する仕組みです。Dolice Labs の Claude Lab・Gemini Lab・Antigravity Lab・Rork Lab を 4 サイト並行で運用している経験から、机上の理論ではなく「夜 3 時に呼び出されないために」必要なパターンを、TypeScript の動くコードで順番にまとめていきます。
公式ドキュメントには書かれていない自己修復実装の6つの落とし穴
本題に入る前に、私自身が Claude API を本番で運用して気づいた、SDK のリファレンスには明示されていない落とし穴を6つ共有します。これを先に押さえておかないと、後半のパターンを実装しても本番でハマります。
1. 429 だけでなく 529 (overloaded) も返ってくる
Anthropic.APIError を分類する際、レート制限(429)以外に「リージョン全体の過負荷」を示す 529 が返ってくることがあります。529 には retry-after ヘッダがほぼ付かないので、429 と同じバックオフロジックでは対応できません。実測では、529 は数分続いた後に自然回復するケースが多いので、私は「指数バックオフ最大 60 秒 → 60 秒以上経っても回復しなければモデルを切り替え」というロジックにしています。
2. ツールタイムアウト時は空文字列ではなくエラー文字列を返す
AbortController でツール実行をタイムアウトさせた場合、Claude 側はその tool call が完了したものとして次のメッセージを待ちます。ここで tool_result に空文字列を入れると、後続の parsing で例外が出るケースに何度か遭遇しました。私は必ず "ERROR: tool timed out after 30s, please proceed without this data" のような英語の説明文を入れています。Claude は英語のエラー文字列の方が正確に解釈してくれる傾向があります。
3. ストリーミング切断時の復元には tool_use_id の保持が必須
stream: true で stop_reason: tool_use の途中で接続が切れた場合、再接続時には同じ tool_use_id を持つ tool_result を含めてリクエストを再構成しないと、Claude は文脈を見失います。私は tool_use_id をローカルの SQLite にスナップショット保存し、ストリーム再接続時にリプレイする実装に落ち着きました。
4. Supervisor を Sonnet にすると意外と高くつく
最初は「障害監視も賢いモデルがいい」と考えて Supervisor を Sonnet で組みました。が、実測で 1 障害あたり約 3,000 token = 約 ¥1.5 のコストがかかります。日次 1,000 障害(小規模なエージェントでも本番ではこの程度起きる)なら ¥1,500/日 = 月 ¥45,000。Haiku で Supervisor を書き直したら、品質はほぼ変わらず コストは約 1/4 になりました。Supervisor は「分類するだけ」のタスクなので、Haiku で十分です。
5. コンテキスト圧縮の summary が幻覚を起こす
コンテキストが長くなったとき、別の Claude 呼び出しに summary を作らせるアプローチは便利ですが、その summary 自体が幻覚を起こします。具体的には、過去のツール実行結果を要約するときに「成功」と「失敗」を取り違える事故が起きました。対策として、私は temperature: 0 + system プロンプトに「ファクトのみ要約し、推測を加えてはならありません。不明な点は『不明』と明記せよ」と縛りを入れています。これで幻覚は劇的に減りました。
6. Cloudflare Workers ではエージェントループは設計しにくい
Dolice Labs の各サイトは Cloudflare Workers + OpenNext で動かしていますが、Workers の CPU time limit(無料 50ms、有料でも 30 秒)に長時間のエージェントループは収まりません。Self-Healing のリトライを 3 回回すと、それだけで普通に超えます。エージェントの実行基盤は Lambda・Cloud Run・自前 VM の方が向いています。Workers 上ではエージェントの「結果保存」と「軽量な状態遷移」だけ担当させ、本体は別レイヤに置くのが私の運用解です。
エラーの7分類と最適なリカバリ戦略
本番環境で発生するエラーは、大きく7つのカテゴリに分類できます。それぞれに最適なリカバリ戦略が異なるため、まずこの分類を理解する点が肝心です。
1. 一時的なAPIエラー(Transient API Errors)
症状 : 429 Rate Limit、500 Internal Server Error、503 Service Unavailable
戦略 : 指数バックオフ付きリトライ(最大3回)
特徴 : 時間が解決する問題。リトライで高確率で回復する
2. コンテキスト超過エラー(Context Overflow)
症状 : トークン制限超過、コンテキストウィンドウの枯渇
戦略 : コンテキスト圧縮 → サマリー生成 → 重要情報のみ保持してリトライ
特徴 : 単純なリトライでは解決しません。コンテキストの再構築が必要
3. ツール呼び出し失敗(Tool Execution Failure)
症状 : 外部API タイムアウト、認証切れ、レスポンス形式の変更
戦略 : 代替ツール → フォールバック値 → 部分的な結果で続行
特徴 : ツールごとにフォールバック戦略を定義しておく
4. 構造化出力の不正(Malformed Output)
症状 : JSON パースエラー、スキーマ不一致、必須フィールドの欠落
戦略 : 出力修正プロンプト → スキーマヒント付きリトライ → 段階的スキーマ簡略化
特徴 : プロンプトの改善で再発防止が可能
5. 推論エラー(Reasoning Errors)
症状 : ハルシネーション、論理的矛盾、事実誤認
戦略 : 自己検証ループ → 外部検証 → Extended Thinking による再推論
特徴 : 検出が最も困難。バリデーションロジックの設計が鍵
6. 無限ループ・デッドロック(Infinite Loop / Deadlock)
症状 : 同じツールの繰り返し呼び出し、進捗なしの反復、トークン消費の異常増加
戦略 : ループ検出 → 状態リセット → 別アプローチへの切り替え
特徴 : Supervisor パターンによる外部監視が効果的
7. カスケード障害(Cascade Failure)
症状 : 1つの障害が連鎖的にシステム全体に波及
戦略 : サーキットブレーカー → 部分的シャットダウン → グレースフルデグラデーション
特徴 : 障害の伝播を早期に遮断することが最優先
自己修復エージェントの基本アーキテクチャ
自己修復型エージェントは、通常のエージェントループに「検出 → 診断 → 修復 → 検証」の4フェーズを組み込んだ構造です。
// self-healing-agent.ts — 自己修復型エージェントの基本構造
import Anthropic from "@anthropic-ai/sdk" ;
// エラーカテゴリの定義
type ErrorCategory =
| "transient_api"
| "context_overflow"
| "tool_failure"
| "malformed_output"
| "reasoning_error"
| "infinite_loop"
| "cascade_failure" ;
interface DiagnosisResult {
category : ErrorCategory ;
severity : "low" | "medium" | "high" | "critical" ;
rootCause : string ;
suggestedStrategy : string ;
retryable : boolean ;
}
interface HealingResult {
success : boolean ;
strategy : string ;
attemptsUsed : number ;
degraded : boolean ; // グレースフルデグラデーション適用フラグ
}
class SelfHealingAgent {
private client : Anthropic ;
private maxRetries = 3 ;
private loopDetector : LoopDetector ;
private circuitBreaker : CircuitBreaker ;
private metrics : MetricsCollector ;
constructor () {
this .client = new Anthropic ();
this .loopDetector = new LoopDetector ();
this .circuitBreaker = new CircuitBreaker ();
this .metrics = new MetricsCollector ();
}
// メインのエージェントループ(自己修復機能付き)
async run ( task : string ) : Promise < string > {
let context : Anthropic . MessageParam [] = [];
let result = "" ;
for ( let step = 0 ; step < 20 ; step ++ ) {
try {
// ループ検出チェック
if ( this .loopDetector. isLooping (context)) {
await this . healFromLoop (context);
}
// サーキットブレーカーチェック
if ( this .circuitBreaker. isOpen ()) {
return this . gracefulDegrade (task, context);
}
const response = await this . callWithHealing (context, task);
// stop_reason による分岐
if (response.stop_reason === "end_turn" ) {
result = this . extractText (response);
break ;
}
// ツール呼び出しの自己修復付き実行
if (response.stop_reason === "tool_use" ) {
const toolResults = await this . executeToolsWithHealing (
response.content
);
context. push (
{ role: "assistant" , content: response.content },
{ role: "user" , content: toolResults }
);
}
} catch (error) {
// 自己修復フロー: 検出 → 診断 → 修復 → 検証
const diagnosis = await this . diagnose (error);
this .metrics. recordError (diagnosis);
const healing = await this . heal (diagnosis, context, task);
if ( ! healing.success) {
// 修復失敗時はグレースフルデグラデーション
return this . gracefulDegrade (task, context);
}
}
}
return result;
}
// 以下、各メソッドの実装は後述
// ...
}
指数バックオフ付きリトライの実装
一時的なAPIエラーに対する最も基本的なリカバリ戦略です。単純なリトライではなく、ジッター(ランダム揺らぎ)を加えた指数バックオフで、サーバー側の負荷を分散します。
// retry-with-backoff.ts — ジッター付き指数バックオフ
interface RetryConfig {
maxRetries : number ;
baseDelay : number ; // ミリ秒
maxDelay : number ; // ミリ秒
jitterFactor : number ; // 0〜1
}
const DEFAULT_RETRY_CONFIG : RetryConfig = {
maxRetries: 3 ,
baseDelay: 1000 ,
maxDelay: 30000 ,
jitterFactor: 0.5 ,
};
async function callWithRetry < T >(
fn : () => Promise < T >,
config : RetryConfig = DEFAULT_RETRY_CONFIG
) : Promise < T > {
let lastError : Error | null = null ;
for ( let attempt = 0 ; attempt <= config.maxRetries; attempt ++ ) {
try {
return await fn ();
} catch ( error : any ) {
lastError = error;
// リトライ不可能なエラーは即座にスロー
if ( ! isRetryable (error)) throw error;
if (attempt < config.maxRetries) {
const delay = calculateDelay (attempt, config);
console. log (
`[Retry ${ attempt + 1 }/${ config . maxRetries }] ` +
`Waiting ${ delay }ms before retry...`
);
await sleep (delay);
}
}
}
throw lastError;
}
function calculateDelay ( attempt : number , config : RetryConfig ) : number {
// 指数バックオフ: baseDelay * 2^attempt
const exponentialDelay = config.baseDelay * Math. pow ( 2 , attempt);
const cappedDelay = Math. min (exponentialDelay, config.maxDelay);
// ジッターを追加(サーバー負荷の分散)
const jitter = cappedDelay * config.jitterFactor * Math. random ();
return cappedDelay + jitter;
}
function isRetryable ( error : any ) : boolean {
// 429 Rate Limit と 5xx は一時的エラーとしてリトライ可能
if (error.status === 429 ) return true ;
if (error.status >= 500 && error.status < 600 ) return true ;
// ネットワークエラーもリトライ可能
if (error.code === "ECONNRESET" || error.code === "ETIMEDOUT" ) return true ;
// Anthropic の overloaded_error もリトライ可能
if (error.error?.type === "overloaded_error" ) return true ;
return false ;
}
function sleep ( ms : number ) : Promise < void > {
return new Promise (( resolve ) => setTimeout (resolve, ms));
}
重要ポイント : Anthropic API が返す retry-after ヘッダーがある場合は、そちらを優先してください。429 エラーのレスポンスヘッダーに含まれる値が、サーバー側が推奨する待機時間です。
コンテキスト圧縮による自動リカバリ
長時間動作するエージェントでは、会話履歴が蓄積してトークン制限に達することがあります。自己修復型エージェントは、コンテキストを自動的に圧縮して処理を継続します。
// context-compression.ts — コンテキスト自動圧縮
class ContextCompressor {
private client : Anthropic ;
private maxContextTokens : number ;
constructor ( client : Anthropic , maxTokens = 150000 ) {
this .client = client;
this .maxContextTokens = maxTokens;
}
async compressIfNeeded (
messages : Anthropic . MessageParam []
) : Promise < Anthropic . MessageParam []> {
const estimatedTokens = this . estimateTokens (messages);
if (estimatedTokens < this .maxContextTokens * 0.8 ) {
return messages; // 80%未満なら圧縮不要
}
console. log (
`[ContextCompressor] Token estimate: ${ estimatedTokens } ` +
`(limit: ${ this . maxContextTokens }). Compressing...`
);
// 戦略1: 古い会話のサマリー化
const summarized = await this . summarizeOldMessages (messages);
// 戦略2: ツール呼び出し結果の要約
const trimmed = this . trimToolResults (summarized);
const newEstimate = this . estimateTokens (trimmed);
console. log (
`[ContextCompressor] Compressed: ${ estimatedTokens } → ${ newEstimate } tokens`
);
return trimmed;
}
private async summarizeOldMessages (
messages : Anthropic . MessageParam []
) : Promise < Anthropic . MessageParam []> {
if (messages. length <= 6 ) return messages;
// 最新6メッセージは保持、それ以前をサマリー化
const oldMessages = messages. slice ( 0 , - 6 );
const recentMessages = messages. slice ( - 6 );
const summaryResponse = await this .client.messages. create ({
model: "claude-sonnet-4-6" , // 圧縮にはコスト効率の良いモデル
max_tokens: 1024 ,
system: "会話履歴を簡潔に要約してください。重要な決定事項、" +
"実行済みのアクション、未解決の問題点を中心にまとめてください。" ,
messages: [
{
role: "user" ,
content: `以下の会話履歴を要約してください: \n\n ` +
JSON . stringify (oldMessages, null , 2 ),
},
],
});
const summaryText =
summaryResponse.content[ 0 ].type === "text"
? summaryResponse.content[ 0 ].text
: "" ;
return [
{
role: "user" ,
content: `[Previous conversation summary] \n ${ summaryText }` ,
},
{ role: "assistant" , content: "理解しました。要約を踏まえて作業を続行します。" },
... recentMessages,
];
}
private trimToolResults (
messages : Anthropic . MessageParam []
) : Anthropic . MessageParam [] {
return messages. map (( msg ) => {
if (msg.role !== "user" || ! Array. isArray (msg.content)) return msg;
const trimmedContent = msg.content. map (( block : any ) => {
if (block.type !== "tool_result" ) return block;
// ツール結果が長すぎる場合は先頭1000文字に切り詰め
if (
typeof block.content === "string" &&
block.content. length > 1000
) {
return {
... block,
content:
block.content. slice ( 0 , 1000 ) +
" \n ... [truncated for context management]" ,
};
}
return block;
});
return { ... msg, content: trimmedContent };
});
}
private estimateTokens ( messages : Anthropic . MessageParam []) : number {
// 簡易推定: 日本語は1文字≈1.5トークン、英語は1ワード≈1.3トークン
const text = JSON . stringify (messages);
return Math. ceil (text. length * 0.4 );
}
}
Supervisor パターンによるマルチエージェント自己修復
単一エージェントの自己修復には限界があります。エージェント自身が無限ループに陥っている場合、自分では気づけません。Supervisor パターンは、別のエージェント(Supervisor)が Worker エージェントを監視し、異常を検出して介入する設計です。
// supervisor-pattern.ts — Supervisor による外部監視と自動介入
interface AgentState {
taskId : string ;
startTime : number ;
stepCount : number ;
tokenUsage : number ;
lastToolCalls : string []; // 直近のツール呼び出し履歴
progressIndicators : string []; // 進捗の兆候
}
class SupervisorAgent {
private client : Anthropic ;
private thresholds = {
maxSteps: 30 ,
maxDuration: 300000 , // 5分
maxTokens: 500000 ,
loopDetectionWindow: 5 , // 直近5回のツール呼び出しを監視
};
constructor ( client : Anthropic ) {
this .client = client;
}
// Worker エージェントの状態を評価
async evaluate ( state : AgentState ) : Promise < SupervisorDecision > {
// 1. 定量的チェック(高速)
const quantCheck = this . quantitativeCheck (state);
if (quantCheck.action !== "continue" ) return quantCheck;
// 2. ループ検出(パターンマッチング)
const loopCheck = this . detectLoop (state.lastToolCalls);
if (loopCheck.action !== "continue" ) return loopCheck;
// 3. 定性的チェック(Claude による進捗評価)
if (state.stepCount > 10 && state.stepCount % 5 === 0 ) {
return await this . qualitativeCheck (state);
}
return { action: "continue" };
}
private quantitativeCheck ( state : AgentState ) : SupervisorDecision {
const elapsed = Date. now () - state.startTime;
if (state.stepCount > this .thresholds.maxSteps) {
return {
action: "intervene" ,
reason: `Step count exceeded: ${ state . stepCount }/${ this . thresholds . maxSteps }` ,
strategy: "reset_with_summary" ,
};
}
if (elapsed > this .thresholds.maxDuration) {
return {
action: "intervene" ,
reason: `Duration exceeded: ${ elapsed }ms/${ this . thresholds . maxDuration }ms` ,
strategy: "force_completion" ,
};
}
if (state.tokenUsage > this .thresholds.maxTokens) {
return {
action: "intervene" ,
reason: `Token usage exceeded: ${ state . tokenUsage }` ,
strategy: "compress_and_continue" ,
};
}
return { action: "continue" };
}
private detectLoop ( toolCalls : string []) : SupervisorDecision {
const window = toolCalls. slice ( - this .thresholds.loopDetectionWindow);
// 同じツールが連続して呼ばれていないかチェック
if (window. length >= 3 ) {
const unique = new Set (window);
if (unique.size === 1 ) {
return {
action: "intervene" ,
reason: `Loop detected: "${ window [ 0 ] }" called ${ window . length } times consecutively` ,
strategy: "break_loop" ,
};
}
}
// A → B → A → B のような振動パターンの検出
if (window. length >= 4 ) {
const pattern = window. slice ( 0 , 2 ). join ( "," );
const repeated = window. slice ( 2 , 4 ). join ( "," );
if (pattern === repeated) {
return {
action: "intervene" ,
reason: `Oscillation detected: ${ pattern }` ,
strategy: "alternative_approach" ,
};
}
}
return { action: "continue" };
}
private async qualitativeCheck (
state : AgentState
) : Promise < SupervisorDecision > {
// Claude に進捗を評価させる
const response = await this .client.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 512 ,
system:
"あなたはAIエージェントの進捗を監視するSupervisorです。" +
"以下の情報から、エージェントが正常に進捗しているか判断してください。" +
"JSON形式で回答: { \" healthy \" : boolean, \" reason \" : string}" ,
messages: [
{
role: "user" ,
content:
`Steps: ${ state . stepCount } \n ` +
`Recent tools: ${ state . lastToolCalls . slice ( - 10 ). join ( ", " ) } \n ` +
`Progress indicators: ${ state . progressIndicators . join ( "; " ) }` ,
},
],
});
const text =
response.content[ 0 ].type === "text" ? response.content[ 0 ].text : "{}" ;
try {
const evaluation = JSON . parse (text);
if ( ! evaluation.healthy) {
return {
action: "intervene" ,
reason: evaluation.reason,
strategy: "guided_correction" ,
};
}
} catch {
// JSON パース失敗は無視して続行
}
return { action: "continue" };
}
}
interface SupervisorDecision {
action : "continue" | "intervene" | "abort" ;
reason ?: string ;
strategy ?: string ;
}
ツール呼び出しのフォールバックチェーン
外部ツールの呼び出しが失敗した場合、代替手段を自動的に試行するフォールバックチェーンを実装します。
// tool-fallback-chain.ts — ツールのフォールバックチェーン
interface ToolFallbackConfig {
primary : string ;
fallbacks : string [];
defaultValue ?: any ;
timeout : number ;
}
class ResilientToolExecutor {
private fallbackChains : Map < string , ToolFallbackConfig > = new Map ();
private circuitBreakers : Map < string , CircuitBreaker > = new Map ();
registerChain ( toolName : string , config : ToolFallbackConfig ) {
this .fallbackChains. set (toolName, config);
this .circuitBreakers. set (toolName, new CircuitBreaker ({
failureThreshold: 3 ,
resetTimeout: 60000 , // 1分後にリセット
}));
}
async execute (
toolName : string ,
input : any
) : Promise <{ result : any ; usedFallback : boolean ; toolUsed : string }> {
const chain = this .fallbackChains. get (toolName);
if ( ! chain) {
// フォールバック未定義のツールは直接実行
return {
result: await this . executeTool (toolName, input),
usedFallback: false ,
toolUsed: toolName,
};
}
// プライマリツールの実行を試行
const breaker = this .circuitBreakers. get (toolName) ! ;
if ( ! breaker. isOpen ()) {
try {
const result = await Promise . race ([
this . executeTool (chain.primary, input),
this . timeoutPromise (chain.timeout),
]);
breaker. recordSuccess ();
return { result, usedFallback: false , toolUsed: chain.primary };
} catch (error) {
breaker. recordFailure ();
console. warn (
`[Fallback] Primary tool "${ chain . primary }" failed:` ,
error
);
}
}
// フォールバックチェーンを順に試行
for ( const fallback of chain.fallbacks) {
try {
const result = await Promise . race ([
this . executeTool (fallback, input),
this . timeoutPromise (chain.timeout),
]);
return { result, usedFallback: true , toolUsed: fallback };
} catch (error) {
console. warn ( `[Fallback] "${ fallback }" also failed:` , error);
}
}
// 全て失敗した場合はデフォルト値を返す
if (chain.defaultValue !== undefined ) {
console. warn (
`[Fallback] All tools failed. Using default value for "${ toolName }"`
);
return {
result: chain.defaultValue,
usedFallback: true ,
toolUsed: "default" ,
};
}
throw new Error (
`All fallbacks exhausted for tool "${ toolName }"`
);
}
private async executeTool ( name : string , input : any ) : Promise < any > {
// 実際のツール実行ロジック(プロジェクトに合わせて実装)
throw new Error ( "Not implemented" );
}
private timeoutPromise ( ms : number ) : Promise < never > {
return new Promise (( _ , reject ) =>
setTimeout (() => reject ( new Error ( "Tool execution timeout" )), ms)
);
}
}
// サーキットブレーカーの実装
class CircuitBreaker {
private failures = 0 ;
private lastFailure = 0 ;
private state : "closed" | "open" | "half-open" = "closed" ;
private config : { failureThreshold : number ; resetTimeout : number };
constructor ( config : { failureThreshold : number ; resetTimeout : number }) {
this .config = config;
}
isOpen () : boolean {
if ( this .state === "open" ) {
// リセットタイムアウト経過後は half-open に遷移
if (Date. now () - this .lastFailure > this .config.resetTimeout) {
this .state = "half-open" ;
return false ;
}
return true ;
}
return false ;
}
recordSuccess () {
this .failures = 0 ;
this .state = "closed" ;
}
recordFailure () {
this .failures ++ ;
this .lastFailure = Date. now ();
if ( this .failures >= this .config.failureThreshold) {
this .state = "open" ;
}
}
}
構造化出力の自動修復
Claude の出力が期待するスキーマと一致しない場合、自動的に修復を試みる仕組みです。
// output-healing.ts — 構造化出力の自動修復
import { z } from "zod" ;
class OutputHealer {
private client : Anthropic ;
constructor ( client : Anthropic ) {
this .client = client;
}
async parseWithHealing < T >(
rawOutput : string ,
schema : z . ZodSchema < T >,
maxAttempts = 3
) : Promise < T > {
// 1. そのままパースを試行
try {
const parsed = JSON . parse (rawOutput);
return schema. parse (parsed);
} catch (firstError) {
console. log ( "[OutputHealer] Initial parse failed. Attempting repair..." );
}
// 2. JSON の構文修復を試行
const cleaned = this . cleanJsonString (rawOutput);
try {
const parsed = JSON . parse (cleaned);
return schema. parse (parsed);
} catch {
// 続行
}
// 3. Claude に修復を依頼
for ( let attempt = 1 ; attempt <= maxAttempts; attempt ++ ) {
try {
const repaired = await this . repairWithClaude (
rawOutput,
schema,
attempt
);
return schema. parse ( JSON . parse (repaired));
} catch (error) {
if (attempt === maxAttempts) {
throw new Error (
`Output healing failed after ${ maxAttempts } attempts: ${ error }`
);
}
}
}
throw new Error ( "Unreachable" );
}
private cleanJsonString ( raw : string ) : string {
// よくある JSON エラーパターンの自動修正
let cleaned = raw
. replace ( /```json \n ? / g , "" ) // マークダウンコードブロックの除去
. replace ( /``` \n ? / g , "" )
. replace ( /, \s * }/ g , "}" ) // 末尾カンマの除去
. replace ( /, \s * ]/ g , "]" )
. replace ( /'/ g , '"' ) // シングルクォートの置換
. replace ( / \n / g , " \\ n" ) // 改行のエスケープ
. trim ();
// JSON 部分のみ抽出
const jsonMatch = cleaned. match ( / \{ [\s\S] * \} / );
if (jsonMatch) cleaned = jsonMatch[ 0 ];
return cleaned;
}
private async repairWithClaude < T >(
brokenOutput : string ,
schema : z . ZodSchema < T >,
attempt : number
) : Promise < string > {
// Zod スキーマを人間が読める形に変換
const schemaDescription = JSON . stringify (
(schema as any )._def,
null ,
2
);
const response = await this .client.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 4096 ,
system:
"あなたは壊れた JSON 出力を修復するエキスパートです。" +
"与えられたスキーマに完全に準拠する有効な JSON のみを返してください。" +
"説明は不要です。JSON のみ出力してください。" ,
messages: [
{
role: "user" ,
content:
`修復対象の出力: \n ${ brokenOutput } \n\n ` +
`期待するスキーマ: \n ${ schemaDescription } \n\n ` +
`修復試行: ${ attempt }回目` ,
},
],
});
const text =
response.content[ 0 ].type === "text" ? response.content[ 0 ].text : "" ;
return this . cleanJsonString (text);
}
}
可観測性パイプラインの構築
自己修復型エージェントは、修復のたびに何が起きたかを記録し、パターンを分析できるようにする必要があります。
// observability.ts — 自己修復の可観測性パイプライン
interface HealingEvent {
timestamp : number ;
taskId : string ;
errorCategory : ErrorCategory ;
severity : string ;
strategy : string ;
success : boolean ;
duration : number ; // 修復にかかった時間(ms)
tokensUsed : number ; // 修復に消費したトークン数
degraded : boolean ; // グレースフルデグラデーション適用
}
class MetricsCollector {
private events : HealingEvent [] = [];
private alertThresholds = {
failureRate: 0.3 , // 30%以上の修復失敗でアラート
healingFrequency: 10 , // 10分間に10回以上の修復でアラート
cascadeWindow: 60000 , // 1分間に同じカテゴリの障害が3回でカスケード警告
};
record ( event : HealingEvent ) {
this .events. push (event);
// リアルタイムアラートチェック
this . checkAlerts ();
// 構造化ログ出力(JSON Lines 形式)
console. log ( JSON . stringify ({
type: "healing_event" ,
... event,
// 期待する出力: {"type":"healing_event","timestamp":1711878000000,...}
}));
}
// ダッシュボード用のサマリー統計
getSummary ( windowMs = 3600000 ) : HealingSummary {
const cutoff = Date. now () - windowMs;
const recent = this .events. filter (( e ) => e.timestamp > cutoff);
const total = recent. length ;
const successes = recent. filter (( e ) => e.success). length ;
const failures = total - successes;
// カテゴリ別の分布
const byCategory = recent. reduce (
( acc , e ) => {
acc[e.errorCategory] = (acc[e.errorCategory] || 0 ) + 1 ;
return acc;
},
{} as Record < string , number >
);
return {
total,
successRate: total > 0 ? successes / total : 1 ,
failureRate: total > 0 ? failures / total : 0 ,
avgHealingDuration:
total > 0
? recent. reduce (( sum , e ) => sum + e.duration, 0 ) / total
: 0 ,
totalTokensUsed: recent. reduce (( sum , e ) => sum + e.tokensUsed, 0 ),
byCategory,
degradedCount: recent. filter (( e ) => e.degraded). length ,
};
}
private checkAlerts () {
const summary = this . getSummary ( 600000 ); // 10分間のウィンドウ
if (summary.failureRate > this .alertThresholds.failureRate) {
this . sendAlert (
"HIGH" ,
`修復失敗率が ${ ( summary . failureRate * 100 ). toFixed ( 1 ) }% に上昇`
);
}
if (summary.total > this .alertThresholds.healingFrequency) {
this . sendAlert (
"MEDIUM" ,
`10分間に ${ summary . total } 回の自己修復が発生`
);
}
}
private sendAlert ( level : string , message : string ) {
// 実際のアラート送信(Slack, PagerDuty 等)
console. error ( `[ALERT:${ level }] ${ message }` );
}
}
interface HealingSummary {
total : number ;
successRate : number ;
failureRate : number ;
avgHealingDuration : number ;
totalTokensUsed : number ;
byCategory : Record < string , number >;
degradedCount : number ;
}
グレースフルデグラデーションの設計
すべての修復が失敗した場合でも、システムは「壊れた」ではなく「機能を制限して動作中」の状態を目指します。これがグレースフルデグラデーションです。
// graceful-degradation.ts — 段階的な機能縮退
class GracefulDegradation {
private client : Anthropic ;
constructor ( client : Anthropic ) {
this .client = client;
}
async degrade (
task : string ,
context : Anthropic . MessageParam [],
failedCapabilities : string []
) : Promise <{ result : string ; level : DegradationLevel }> {
// レベル1: ツールなしで回答を試みる
if (failedCapabilities. includes ( "tools" )) {
try {
const response = await this .client.messages. create ({
model: "claude-opus-4-6" ,
max_tokens: 4096 ,
system:
"外部ツールが一時的に利用できない状況です。" +
"あなたの知識のみで可能な限り正確に回答してください。" +
"ツールが必要な部分は、その旨を明記してください。" ,
messages: [
... context,
{
role: "user" ,
content: `[System: ツール利用不可のため、知識ベースのみで回答] ${ task }` ,
},
],
});
return {
result: this . extractText (response) +
" \n\n --- \n ⚠️ 一部の外部データ取得に失敗したため、" +
"AI の知識に基づく回答です。最新情報は別途ご確認ください。" ,
level: "partial" ,
};
} catch {
// レベル2へ
}
}
// レベル2: より軽量なモデルにフォールバック
try {
const response = await this .client.messages. create ({
model: "claude-haiku-4-5" ,
max_tokens: 1024 ,
messages: [
{
role: "user" ,
content: `簡潔に回答してください: ${ task }` ,
},
],
});
return {
result: this . extractText (response) +
" \n\n --- \n ⚠️ システムの一時的な制限により、簡易回答を提供しています。" ,
level: "minimal" ,
};
} catch {
// レベル3へ
}
// レベル3: キャッシュされた応答 or エラーメッセージ
return {
result:
"申し訳ございません。現在システムに一時的な問題が発生しています。" +
"しばらく時間をおいてから再度お試しください。" +
"問題が継続する場合は、サポートまでお問い合わせください。" ,
level: "unavailable" ,
};
}
private extractText ( response : Anthropic . Message ) : string {
return response.content
. filter (( b ) : b is Anthropic . TextBlock => b.type === "text" )
. map (( b ) => b.text)
. join ( "" );
}
}
type DegradationLevel = "full" | "partial" | "minimal" | "unavailable" ;
実装時によく出てくる2つの判断
ここでは、私自身が4サイト並行運用で何度も自問した、コストと信頼性のトレードオフに関する2つの問いを共有します。
コストが跳ね上がる前に、何を見ておくべきか
リトライ回数の上限を設定するのは大前提として、私は本番で必ず次の3つのメトリクスを Datadog(もしくは Cloudflare Analytics Engine)に流しています。1分あたりの healing 回数、healing あたりの平均 token 消費、healing 起因の累計コスト。日次の予算が普段の 1.5 倍を超えたら Slack に通知する、というシンプルなアラートが結局いちばん効きます。本記事の MetricsCollector の出力をそのまま流せます。
Supervisor 自体が落ちたら、何が動くべきか
「監視者を誰が監視するか」の古典的問題です。私は Supervisor は Haiku で軽量に保ち、Cloudflare の Cron Trigger(あるいは Lambda の EventBridge)で 5 分おきに「Supervisor が応答するか」だけを問い合わせるヘルスチェック用ワーカーを別途置いています。Supervisor 障害時は Worker が「独立モード(タイムアウト 10 秒のみのリトライ)」で動き続け、結果を Pending キューに積みます。復旧後に Supervisor がキューを再処理する設計で、24 時間ダウンしてもデータは失わない運用にしています。
暴走ループを自分で止める—トークンと反復回数の予算ガード
ここまでの仕組みは「壊れたものを直す」方向の自己修復でした。もうひとつ忘れてはならないのが、「直そうとして止まらなくなる」事態への備えです。
エラーの7分類で挙げた「進捗なしの反復」は、リトライやコンテキスト圧縮を重ねるほど起きやすくなります。再試行が再試行を呼び、気づくとオーケストレーターが同じ判断を回し続けてトークンを際限なく消費している。深夜に起きるのは、たいていこのパターンです。
対策はシンプルです。エージェントに「使ってよい予算」を最初に渡し、超えたら自分で打ち切らせます。反復回数の上限とトークン予算を二段構えにしておくと、片方をすり抜けてももう片方で止まります。
from dataclasses import dataclass
@dataclass
class AgentBudget :
max_input_tokens: int
max_output_tokens: int
max_iterations: int = 20
used_input: int = 0
used_output: int = 0
iterations: int = 0
def record (self, usage):
self .used_input += usage.input_tokens
self .used_output += usage.output_tokens
self .iterations += 1
def exhausted (self) -> tuple[ bool , str ]:
if self .iterations >= self .max_iterations:
return True , f "反復上限 { self .max_iterations } 回に到達"
if self .used_input >= self .max_input_tokens:
return True , f "入力トークン上限 { self .max_input_tokens :, } に到達"
if self .used_output >= self .max_output_tokens:
return True , f "出力トークン上限 { self .max_output_tokens :, } に到達"
return False , ""
def nearly_done (self) -> bool :
in_ratio = self .used_input / self .max_input_tokens
out_ratio = self .used_output / self .max_output_tokens
return max (in_ratio, out_ratio) > 0.8
def run_with_budget (client, user_request: str , tools, budget: AgentBudget) -> str :
messages = [{ "role" : "user" , "content" : user_request}]
while True :
stop, reason = budget.exhausted()
if stop:
# 握りつぶさず、なぜ止まったかと途中結果を呼び出し側へ返す
return f "[予算到達: { reason } ] 直近の状態: { messages[ - 1 ][ 'content' ] } "
# 残り2割を切ったらモデルに「畳みにかかれ」と伝える
hint = " \n [残予算わずか。新しい分岐を増やさず結論をまとめてください]" if budget.nearly_done() else ""
response = client.messages.create(
model = "claude-opus-4-6" ,
max_tokens = min ( 4096 , budget.max_output_tokens - budget.used_output),
tools = tools,
system = "あなたはタスク管理の担当です。" + hint,
messages = messages,
)
budget.record(response.usage)
if response.stop_reason == "end_turn" :
return response.content[ 0 ].text
messages.append({ "role" : "assistant" , "content" : response.content})
# ツール結果を messages に積む処理は前掲のループと同じです
ポイントは、予算到達時に例外で静かに死なせず、reason と途中結果を返して「なぜ止まったか」を呼び出し側に残すことです。可観測性パイプラインの MetricsCollector に reason をそのまま流せば、どのエージェントが予算を食い潰しているかが翌朝のダッシュボードで一目で分かります。
私自身、個人開発で複数のサイトとアプリのバックエンドを一人で回しているので、夜間の自動処理が暴走して朝に請求が跳ねる事態がいちばん怖いです。反復回数の上限を渡すようにしてから、少なくとも「気づいたら大きな請求」だけは避けられるようになりました。
全体を振り返って
自己修復型AIエージェントの構築は、本番環境で安定した AI サービスを提供するための必須スキルです。本記事で解説した7つのエラー分類、Supervisor パターン、フォールバックチェーン、グレースフルデグラデーションを組み合わせることで、人間の介入なしに24時間安定稼働するエージェントシステムを実現できます。
さらに深く学びたい方には、ストリーミング処理と組み合わせたリアルタイムエージェントの構築について Claude API ストリーミング・ツール使用本番運用 を、エージェントループの基本設計については Claude SDK Tool Runner エージェントループ完全ガイド をご覧ください。