午前3時14分。Slack の通知音で目が覚めました。「決済処理のサポートエージェントが応答していません。チケットが27件溜まっています」。
寝ぼけ眼でラップトップを開くと、Antigravity の Manager Surface に並んだログには「tool_call_failed: stripe.list_invoices」が延々と続いていました。原因は Stripe API の一時的なレート制限。エージェントは1度の失敗で諦め、以降のチケットすべてに同じエラーを返していたのです。
この夜、私は「障害が起きたときに自分で立ち直れるエージェントを作らないと、自分の睡眠が削られ続ける」と痛感しました。本記事は、その夜から半年かけて整えた Self-Healing エージェントの設計図 をそのまま共有するものです。Antigravity の AgentKit 2.0 で動く具体的なコードと、本番投入で踏み抜いた罠を交えてお話しします。
なぜ「自己修復するエージェント」が必要なのか
エージェントを本番運用してみると、教科書には書かれていない事実に気付きます。失敗の大部分はコードのバグではなく、外部依存の一時的な不調 だということです。
決済サポート、コードレビュー、SEO レポートの3つのエージェントを動かしながら、障害ログを次の四つに仕分けてみました。正確な件数は記録しておりませんので割合は載せませんが、仕分けた結果の傾向ははっきりしていました。
大半は上の三行、つまり「待てば直る」か「別の手段に切り替えれば直る」ものでした。にもかかわらず、私のエージェントは1度失敗すると黙り込んでいたのです。ご自身のログも、まず四つに仕分けてみてください。リトライで救える失敗と救えない失敗の線引きが、それだけで見えてきます。
ここで重要なのは「リトライを足せば解決する」と短絡しないことです。素朴なリトライは雪崩を引き起こします。 Stripe API がレート制限を返している最中に5回リトライすれば、ペナルティが伸びるだけ。本物のバグに対してリトライすれば、同じエラーログが100倍に膨らむだけです。
必要なのは「何が起きているかを自分で診断し、状況に応じた復旧戦略を選べるエージェント 」です。これを私は Self-Healing エージェントと呼んでいます。
Self-Healing 設計の3層モデル — 検知・診断・復旧の責任分離
私が辿り着いた設計は、責任を3つのレイヤーに分けるものです。1つの巨大な try-except で全部を抱え込むのではなく、それぞれのレイヤーが明確な仕事を持ちます。
レイヤー1: 検知(Detection) — 「いま正常か、異常か」を判定します。ヘルスシグナル収集とアラート発火を担当
レイヤー2: 診断(Diagnosis) — 「どんな種類の異常か」を分類します。エラーの種類と影響範囲を判定し、復旧戦略を選びます
レイヤー3: 復旧(Recovery) — 「具体的にどう立ち直るか」を実行します。リトライ、フォールバック、デグレード、サーキットブレーカーなど
この分離が効くのは、各レイヤーを独立してテスト・改善できるからです。たとえば検知ロジックを変えるだけで「サイレント障害(エラーログが出ていないのに結果が間違っている状態)」を捕まえられるようになります。診断レイヤーを足すだけで、新しいエラーパターンに対する戦略を後から追加できます。
なぜこの分離が重要なのかというと、復旧コードは本番中に最も触れたくないコードだから です。深夜に「とりあえずリトライ回数を増やそう」とコードを書き換えると、別の障害を呼び込みます。検知と診断を別レイヤーに切り出しておけば、復旧ロジックを安定させたまま、新しい障害パターンを上のレイヤーで吸収できます。
レイヤー1: ヘルスシグナルの設計 — 何を計測し、何を異常と判定するか
検知レイヤーで最初に決めるべきは「何を計測するか」です。私が現在採用しているシグナルは4種類あります。
ツール呼び出しの成功率(直近10分) — 80%を下回ったら警告、50%を下回ったら異常
応答時間の P95(直近10分) — 通常時の3倍を超えたら警告
空応答率(直近100件) — LLM が空文字や null を返す割合。5%を超えたら警告
タスク完了率(直近1時間) — エージェントが「完了」をマークしたタスクの割合。70%を下回ったら異常
特に重要なのが3番目と4番目です。従来のサーバー監視は「エラーが出ていない=正常」と判断しますが、エージェント運用ではエラーを返さず黙り込む障害が頻発します。 タスク完了率を見ていなければ気付けません。
以下が AgentKit 2.0 の中で動くヘルスシグナル収集器の実装です。コピー&ペーストで動くように、依存と環境変数も明記しました。
# requirements: antigravity-agentkit>=2.0.0, redis>=5.0.0
# 環境変数: REDIS_URL(例: redis://localhost:6379/0)
import os
import time
import uuid
from typing import Literal
import redis
HealthStatus = Literal[ "healthy" , "warning" , "critical" ]
class HealthSignals :
"""エージェントのヘルスシグナルを Redis に集約し、状態判定を行うクラス。
Redis を使う理由は2つ:
- エージェントが複数プロセスで動く場合、メモリ上の deque では情報が分散する
- プロセスが落ちても直前の状態を失わない
インメモリで十分な小規模運用なら、同じインターフェースで list に置き換えられる。
"""
def __init__ (self, agent_id: str , redis_url: str | None = None ):
self .agent_id = agent_id
self .r = redis.from_url(redis_url or os.environ[ "REDIS_URL" ], decode_responses = True )
self .window_seconds = 600 # 10分ウィンドウ
self .k_calls = f "agent: { agent_id } :tool_calls"
self .k_empty = f "agent: { agent_id } :empty_responses"
self .k_base = f "agent: { agent_id } :baseline_p95_ms"
def record_tool_call (self, tool: str , success: bool , latency_ms: float ) -> None :
ts = time.time()
member = f " { uuid.uuid4().hex } : { int (success) } : { latency_ms :.1f } : { tool } " # uuid を付けて、同一ミリ秒の呼び出しが ZSET 上で潰れないようにする
self .r.zadd( self .k_calls, {member: ts})
self .r.zremrangebyscore( self .k_calls, 0 , ts - self .window_seconds)
def record_empty_response (self, was_empty: bool ) -> None :
self .r.lpush( self .k_empty, "1" if was_empty else "0" )
self .r.ltrim( self .k_empty, 0 , 99 )
def set_baseline_p95 (self, ms: float ) -> None :
self .r.set( self .k_base, ms)
def status (self) -> HealthStatus:
success_rate = self ._success_rate()
p95 = self ._p95_latency()
empty_rate = self ._empty_rate()
if success_rate < 0.5 or empty_rate > 0.20 :
return "critical"
if success_rate < 0.8 or p95 > self ._baseline_p95() * 3 or empty_rate > 0.05 :
return "warning"
return "healthy"
def _window (self) -> list[tuple[ bool , float ]]:
now = time.time()
rows = self .r.zrangebyscore( self .k_calls, now - self .window_seconds, now)
out = []
for m in rows:
_, ok, lat, _tool = m.split( ":" , 3 ) # ツール名に ':' が入っていても壊れないよう、先頭3区切りだけ分割する
out.append((ok == "1" , float (lat)))
return out
def _success_rate (self) -> float :
rows = self ._window()
if not rows:
return 1.0 # データがないうちは異常扱いにしない
return sum ( 1 for ok, _ in rows if ok) / len (rows)
def _p95_latency (self) -> float :
lats = sorted (lat for _, lat in self ._window())
if not lats:
return 0.0
return lats[ min ( len (lats) - 1 , int ( len (lats) * 0.95 ))]
def _baseline_p95 (self) -> float :
v = self .r.get( self .k_base)
return float (v) if v else 2000.0 # 基準が未登録の間の仮値(ms)。実測の P95 を set_baseline_p95() で登録する
def _empty_rate (self) -> float :
vals = self .r.lrange( self .k_empty, 0 , 99 )
if not vals:
return 0.0
return sum ( 1 for v in vals if v == "1" ) / len (vals)
期待する出力は、エージェントが正常稼働している間は "healthy"、外部 API がレート制限を返し始めた瞬間に "warning" へ、複数のツール呼び出しが連続失敗すると "critical" へ遷移する、という3段階の状態です。
このヘルスシグナルを どのタイミングで読むか がポイントです。私はエージェントの「タスク開始時」と「ツール呼び出し前」の2箇所で読むようにしました。タスク開始時に critical なら、そもそもタスクを受け付けず読み取り専用モードに退避します。
レイヤー2: 自己診断ステップ — 失敗パターンに対応する診断ルーチン
検知レイヤーが「異常がある」と知らせたら、次は 何が起きているかを分類する 仕事です。これを LLM に丸投げするのは私のお勧めしない選択肢です。理由は3つあります。
LLM 自体が不調なときに診断を頼むのは、筋が通りません
障害のときこそコストを抑えたいのに、推論コストが膨らみます
同じ障害に毎回違う診断が返ってくると、改善の手がかりが残りません
代わりに、私は 決定論的な診断ルーチン を用意しています。エラーメッセージのパターンマッチと、外部サービスのステータスチェックの組み合わせです。
# 診断レイヤー: エラーパターンと外部サービス状態から復旧戦略を決定する
import re
import httpx
from dataclasses import dataclass
from enum import Enum
class FailurePattern ( Enum ):
RATE_LIMIT = "rate_limit"
SERVICE_DOWN = "service_down"
TIMEOUT = "timeout"
EMPTY_LLM_RESPONSE = "empty_llm"
JSON_PARSE_ERROR = "json_parse"
UNKNOWN = "unknown"
@dataclass
class Diagnosis :
pattern: FailurePattern
confidence: float # 0.0〜1.0
recovery_strategy: str
cooldown_seconds: int
# パターンマッチ用の正規表現と外部サービス検知をまとめた診断器
class Diagnoser :
# ツール名 → 依存先ステータスページの JSON API の URL(Atlassian Statuspage 形式 {"status": {"indicator": "none"}} を想定)
STATUS_API : dict[ str , str ] = {}
RATE_LIMIT_PATTERNS = [
re.compile( r "rate . ? limit" , re.I),
re.compile( r "too many requests" , re.I),
re.compile( r "429" , re.I),
]
TIMEOUT_PATTERNS = [
re.compile( r "timeout" , re.I),
re.compile( r "deadline exceeded" , re.I),
]
def diagnose (self, error_message: str , tool_name: str ) -> Diagnosis:
# まずパターンマッチで素早く分類
if any (p.search(error_message) for p in self . RATE_LIMIT_PATTERNS ):
return Diagnosis(
pattern = FailurePattern. RATE_LIMIT ,
confidence = 0.95 ,
recovery_strategy = "exponential_backoff_with_jitter" ,
cooldown_seconds = 60 ,
)
if any (p.search(error_message) for p in self . TIMEOUT_PATTERNS ):
# タイムアウトは外部サービスのステータスを確認する
if self ._service_is_degraded(tool_name):
return Diagnosis(
pattern = FailurePattern. SERVICE_DOWN ,
confidence = 0.85 ,
recovery_strategy = "failover_to_secondary" ,
cooldown_seconds = 300 ,
)
return Diagnosis(
pattern = FailurePattern. TIMEOUT ,
confidence = 0.70 ,
recovery_strategy = "retry_with_longer_timeout" ,
cooldown_seconds = 10 ,
)
# 該当パターンなしは UNKNOWN として扱い、LLM 診断にエスカレーション
return Diagnosis(
pattern = FailurePattern. UNKNOWN ,
confidence = 0.0 ,
recovery_strategy = "escalate_to_human" ,
cooldown_seconds = 0 ,
)
def _service_is_degraded (self, tool_name: str ) -> bool :
"""依存先のステータス API を参照する。URL は STATUS_API に登録したものだけを見る。
ステータスページの形式は依存先ごとに違うため、登録前に実際の JSON を確認すること。"""
url = self . STATUS_API .get(tool_name)
if not url:
return False # 参照先が未登録なら判断材料なし
try :
r = httpx.get(url, timeout = 2.0 )
return r.json().get( "status" , {}).get( "indicator" , "none" ) != "none"
except Exception :
return False # 確認できないときは保守的に「正常」扱い(ステータスページ自体も落ちうる)
診断結果として返される recovery_strategy 文字列が、次のレイヤー3への入力になります。
ここで「なぜ正規表現とステータスページの組み合わせなのか 」という疑問が出ると思います。LLM に分類させた方が柔軟ではないか、と。私の答えは「障害時にコストと不確実性を増やしたくないから」です。診断は 数百ミリ秒・数銭以内・100%同じ判定 で終わるべきで、これは LLM が苦手な領域です。LLM に頼るのは UNKNOWN にエスカレートする最後の段階だけにしました。
レイヤー3: 復旧戦略 — 段階的フォールバックの実装
復旧レイヤーは、診断で決まった戦略文字列を実行する部分です。私が実装しているのは6つの戦略です。
exponential_backoff_with_jitter — レート制限向け。1秒、2秒、4秒、8秒と倍にしながら、毎回 0〜30% ほどランダムに揺らします
failover_to_secondary — プロバイダを切り替えます。主系が Anthropic Claude なら従系を Gemini にする、といった形です
retry_with_longer_timeout — 一時的な遅延向け。タイムアウト値を倍にして3回まで再試行
degrade_to_readonly — 書き込みを止め、読み取り専用で受け答えを続けます
circuit_break — 一定時間はまったく呼びません。15分後に半開にして、1回だけ試します
escalate_to_human — Slack に通知し、エージェントは黙って待ちます
実装の核は「どの戦略も冪等で副作用がないこと 」です。エージェントが Stripe API でユーザーの請求書を取得しようとしている最中に復旧コードが走るとします。再試行時に二重請求が起きてはいけません。私はすべての書き込み系ツール呼び出しに idempotency key を必須にしました。
# 復旧戦略を実行する Recovery Executor
import asyncio
import os
import random
from typing import Any, Callable, Awaitable
import httpx
class RecoveryExecutor :
def __init__ (self, primary_provider: str = "anthropic" ):
self .primary = primary_provider
self .secondary = "gemini" if primary_provider == "anthropic" else "anthropic"
async def execute (
self,
strategy: str ,
operation: Callable[ ... , Awaitable[Any]],
* args, ** kwargs
) -> Any:
"""復旧戦略に従って operation を再実行する。
operation は冪等性を保証していること(idempotency_key 必須)。"""
if strategy == "exponential_backoff_with_jitter" :
return await self ._exp_backoff(operation, * args, ** kwargs)
if strategy == "failover_to_secondary" :
kwargs[ "provider" ] = self .secondary
return await operation( * args, ** kwargs)
if strategy == "retry_with_longer_timeout" :
kwargs[ "timeout" ] = kwargs.get( "timeout" , 30 ) * 2
return await operation( * args, ** kwargs)
if strategy == "degrade_to_readonly" :
kwargs[ "mode" ] = "readonly"
return await operation( * args, ** kwargs)
if strategy == "circuit_break" :
raise CircuitBreakerOpen( "サーキットブレーカー作動中。15分後に再試行します" )
if strategy == "escalate_to_human" :
await self ._notify_slack(operation. __name__ , args, kwargs)
raise HumanEscalationRequired( "人間の判断が必要です" )
raise ValueError ( f "未知の復旧戦略: { strategy } " )
async def _notify_slack (self, name: str , args: tuple , kwargs: dict ) -> None :
"""Slack の Incoming Webhook に人間への引き継ぎを通知する。URL は環境変数 SLACK_WEBHOOK_URL。"""
url = os.environ.get( "SLACK_WEBHOOK_URL" )
if not url:
return
safe = {k: v for k, v in kwargs.items() if k not in ( "api_key" , "token" )} # 秘匿値は載せない
async with httpx.AsyncClient( timeout = 5.0 ) as client:
await client.post(url, json = { "text" : f "[self-healing] { name } を人間に引き継ぎます: { safe } " })
async def _exp_backoff (
self,
operation: Callable[ ... , Awaitable[Any]],
* args,
max_attempts: int = 5 ,
** kwargs,
) -> Any:
for attempt in range (max_attempts):
try :
return await operation( * args, ** kwargs)
except Exception as e:
if attempt == max_attempts - 1 :
raise
base = 2 ** attempt # 1, 2, 4, 8秒(最後の試行のあとは待たずに送出する)
jitter = base * random.uniform( 0 , 0.3 )
await asyncio.sleep(base + jitter)
class CircuitBreakerOpen ( Exception ):
pass
class HumanEscalationRequired ( Exception ):
pass
class HealthCheckFailed ( Exception ):
pass
期待される動作は、レート制限エラー → 1秒待機 → 2秒 → 4秒 → と再試行し、5回目で諦めて上位に例外を伝播。サービスダウン検知時には即座に従系プロバイダに切り替え、半開のサーキットブレーカーで主系の復活を15分後に確認、というものです。
書き込みを伴うときの再試行の線引き — タイムアウトは「失敗」ではありません
最初の設計では、タイムアウトを単なる失敗として扱っておりました。ところがタイムアウトが教えてくれるのは「結果が返ってこなかった」ことだけで、相手側で処理が済んでいるかどうかは分からないのです。読み取りなら再実行で済みますが、書き込みの再実行は二重実行と隣り合わせでした。
タイムアウトは失敗ではなく、結果が分からない状態です。 この前提に立つと、戦略ごとに「書き込みにも使ってよいか」が決まります。
Stripe のように冪等キーを受け付ける API なら、Idempotency-Key ヘッダーに「タスク ID + ステップ番号」から作った文字列を載せておくと、同じ書き込みを再送しても一度しか実行されません。キーを毎回ランダムに作ってしまうと、この保護はまったく働かないので、再試行のたびに同じ値を使い回す設計にしてください。
もう一つ、待ち時間は自分で決め打ちしない方が安全です。429 や 503 には Retry-After ヘッダーが付くことがあり、秒数または HTTP 日付で「いつまで待ってほしいか」を教えてくれます(RFC 9110)。バックオフの待ち時間と見比べて、長い方 を採ります。
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
def retry_after_seconds (headers: dict[ str , str ], default: float ) -> float :
"""Retry-After(秒数 or HTTP 日付)を秒に直す。無い・読めないときは default。"""
raw = headers.get( "Retry-After" )
if not raw:
return default
if raw.isdigit():
return float (raw)
try :
when = parsedate_to_datetime(raw)
return max ( 0.0 , (when - datetime.now(timezone.utc)).total_seconds())
except ( TypeError , ValueError ):
return default
# _exp_backoff の中での使い方(例外から headers を取り出せる HTTP クライアントを前提)
# wait = max(base + jitter, retry_after_seconds(headers, 0.0))
# if wait > 120: # 上限を超える待ちは、待たずに人間へ引き継ぐ
# raise HumanEscalationRequired("Retry-After が長すぎます")
上限の120秒は私が置いた目安です。ご自身のタスクが何秒まで待てるのかを先に決めておけば、長い Retry-After を受け取ったときに迷わずエスカレーションへ進めます。
AgentKit 2.0 で実装する Self-Healing ラッパー
ここまでの3レイヤーをひとつのラッパーにまとめます。ツール呼び出しの前後に差し込むミドルウェアの形で書きます。
# AgentKit 2.0 のミドルウェアとして組み込む Self-Healing ラッパー
from typing import Any
from antigravity.agentkit import Agent, ToolMiddleware, ToolCallContext
class SelfHealingMiddleware ( ToolMiddleware ):
def __init__ (
self,
health_signals: HealthSignals,
diagnoser: Diagnoser,
executor: RecoveryExecutor,
):
self .health = health_signals
self .diag = diagnoser
self .exec = executor
async def before_tool_call (self, ctx: ToolCallContext) -> None :
"""ツール呼び出し前にヘルスチェックを実行する。"""
status = self .health.status()
if status == "critical" :
# 緊急時は読み取り専用ツールのみ許可
if not ctx.tool.is_read_only:
raise HealthCheckFailed(
f "エージェントが critical 状態です。書き込みツールは利用できません: { ctx.tool.name } "
)
async def after_tool_call (self, ctx: ToolCallContext, result: Any) -> None :
"""成功も記録する。失敗しか記録しないと、成功率の分母が偏って常に低く見える。"""
self .health.record_tool_call( tool = ctx.tool.name, success = True , latency_ms = ctx.elapsed_ms)
self .health.record_empty_response(result in ( "" , None ))
async def on_tool_error (self, ctx: ToolCallContext, error: Exception ) -> Any:
"""ツール呼び出しが失敗したら診断と復旧を実行する。"""
# 失敗を記録
self .health.record_tool_call(
tool = ctx.tool.name,
success = False ,
latency_ms = ctx.elapsed_ms,
)
# 診断
diagnosis = self .diag.diagnose( str (error), ctx.tool.name)
# 信頼度が低い診断は再投げ(人間への通知は escalate_to_human が行う)
if diagnosis.confidence < 0.5 :
raise error
# 復旧戦略を実行
return await self .exec.execute(
diagnosis.recovery_strategy,
ctx.tool.invoke,
** ctx.tool_args,
)
# エージェント生成時にミドルウェアを差し込む
agent = Agent(
name = "payment-support-agent" ,
model = "claude-sonnet-4-5" ,
tools = [stripe_tools, slack_tools],
middleware = [
SelfHealingMiddleware(
health_signals = HealthSignals( agent_id = "payment-support" ),
diagnoser = Diagnoser(),
executor = RecoveryExecutor( primary_provider = "anthropic" ),
),
],
)
なお ToolMiddleware や ToolCallContext のメソッド名・属性名は、設計の骨格を示すスケッチです。お使いの AgentKit のバージョンで実際に提供されているフックの名前を確認したうえで、差し替えてお使いください。
このラッパーが入った状態で先ほどのレート制限障害を再演すると、エージェントは黙り込まずに exponential_backoff_with_jitter 戦略で再試行を続けます。タイムアウトが続けば failover_to_secondary で Gemini に切り替わり、それでも復旧しない場合は circuit_break でしばらく待機します。escalate_to_human に到達した場合のみ、私の Slack に通知が来ます。
このラッパーを入れてから、夜中に Slack で起こされる回数ははっきりと減りました。
復旧履歴をトレースし、継続的に学習する設計
ここまでの設計は「障害時に立ち直る」ためのものですが、自己修復したことを忘れないこと が次の改善の出発点になります。私は AgentKit 2.0 のトレース機能を使って、すべての復旧イベントを構造化ログとして記録しています。
いつ(タイムスタンプ)
どのツールが
どんなエラーで失敗し
どんな診断結果が出て
どの戦略で復旧したか
復旧に何秒かかったか
最終的に成功したか失敗したか
この記録を週次でレビューすると、診断ルーチンの抜け穴が見えてきます。「UNKNOWN 診断で escalate_to_human に流れた回数が多いツールは、新しいパターンマッチを追加する候補」という形で改善の優先順位が決まります。
私は最近、復旧履歴を Looker Studio に流して、ツール別の障害頻度・復旧成功率・平均復旧時間を可視化するダッシュボードを作りました。エージェントを継続的に育てる感覚は、SRE で言うエラーバジェットの運用に近いものがあります。エージェント運用の SLO 設計については Antigravity AI エージェントの SRE 設計と SLO 運用ガイド で詳しく触れています。
復旧コストの罠 — 自己修復は無料ではない
Self-Healing は無償ではありません。リトライごとにトークン課金が発生し、フェイルオーバーは別プロバイダ(往々にして高価)への切り替えで、不要なサーキットブレーカーはユーザーリクエストを失います。「とにかくリトライ回数を増やせばいい」という発想は、このパターンを採用したチームが最初に踏む地雷です。
私は信頼性メトリクスと並行して3つのコスト指標を追跡しています。
インシデント単価 : 1回の障害復旧に消費したトークン費用。急増した場合は戦略が攻撃的すぎるサイン
無駄なリトライ件数 : リトライで成功したものの、本物のバグが原因だったケース。これがゼロに近づかないときは、リトライがバグを隠しています
フェイルオーバー差額 : 主系より従系の方が高い場合、その差額。月次で見て高すぎるなら、主系を直す方が合理的
実用的な歯止めは「1タスクあたりの復旧予算 」を設けることです。たとえば元タスクの想定コストの4倍に達したら諦めて人間にエスカレーションする、というルールにしておきます。これがないと、不安定な依存サービス1つで月末の請求書が大変なことになります。
# 予算上限つきの実行器 — 自己修復が「自己破産」にならないようにする
class BudgetedExecutor ( RecoveryExecutor ):
def __init__ (self, primary_provider: str , max_recovery_cost_usd: float = 0.10 ):
super (). __init__ (primary_provider)
self .max_cost = max_recovery_cost_usd
async def execute (self, strategy, operation, * args, ** kwargs):
spent = kwargs.pop( "_recovery_spent_usd" , 0.0 )
if spent >= self .max_cost:
await self ._notify_slack(
f "復旧予算超過: $ { spent :.4f } / $ { self .max_cost } " ,
args, kwargs,
)
raise HumanEscalationRequired( "復旧予算を使い切りました" )
return await super ().execute(strategy, operation, * args, ** kwargs)
「自己修復」と「自己破産」を分ける線は、まさにこのコスト上限です。
自己修復ロジックを本番を壊さずにテストする
このパターンで一番気持ち悪いのが「テストの仕方」です。Stripe のレート制限をサンドボックスで再現するのは難しく、「本番で発生するまで待つ」のは間違ったフィードバックループです。
私はミドルウェア境界で 障害注入器 を使い、ステージングだけで失敗を発生させてテストしています。診断パスを一通り歩く統合テストとセットで運用しています。
# 復旧パスをテストするための障害注入ハーネス
import pytest
import asyncio
class FailureInjector :
# 任意のツールをラップし、テスト時に特定の失敗モードを注入する
def __init__ (self, tool, failure_mode = None , fail_count = 1 ):
self .tool = tool
self .failure_mode = failure_mode
self .fail_count = fail_count
self .calls = 0
async def invoke (self, * args, ** kwargs):
self .calls += 1
if self .failure_mode and self .calls <= self .fail_count:
if self .failure_mode == "rate_limit" :
raise Exception ( "429: rate limit exceeded" )
if self .failure_mode == "timeout" :
raise asyncio.TimeoutError( "deadline exceeded" )
if self .failure_mode == "empty" :
return ""
return await self .tool.invoke( * args, ** kwargs)
class FakeInvoiceTool :
async def invoke (self, * args, ** kwargs):
return { "ok" : True }
@pytest.mark.asyncio
async def test_rate_limit_recovers_via_backoff (monkeypatch):
async def no_sleep (_seconds): # 待ち時間をテストから消す
return None
monkeypatch.setattr(asyncio, "sleep" , no_sleep)
tool = FailureInjector(FakeInvoiceTool(), failure_mode = "rate_limit" , fail_count = 2 )
executor = RecoveryExecutor()
result = await executor.execute( "exponential_backoff_with_jitter" , tool.invoke, customer_id = "cus_123" )
assert result is not None
assert tool.calls == 3 # 2回失敗、3回目で成功
このテスト習慣で得たもの: 本番でレート制限が起きるのを待たずに復旧コードを変更できる安心感。失ったもの: ハーネスを書く数時間。トレードは見合いました。
副次効果として、障害注入テストを書くこと自体が「想定していなかった失敗モードを列挙する作業」になります 。私が初めて「LLM が空応答を返した時」のテストを書いたとき、自分のコードにそのケースに対応する分岐がないことに気付きました。None がそのまま下流に流れて壊れていたのです。本番に出る前にこのバグを潰せたのは、テストが見えるようにしてくれたからでした。
想定シナリオ: 上流の部分障害を、設計上どう吸収するか
実際の障害記録を載せたいところですが、ここでは数字や日付を創作しないために、障害注入で再現できる想定シナリオ としてお話しします。リクエストの6割ほどがタイムアウトし、残りは遅くても成功する——止まりきらないぶん、いちばん判断に迷う部分障害を、コードレビュー用エージェントに当てた場合です。
ここで見ていただきたいのは、ステータスページがまだ「正常」と言っている時間帯です。ステータスページの更新は、実際の障害より遅れることがあります。だからこそ、診断を外部の申告だけに頼らず、手元のタイムアウトの連続という事実から切り替えを判断できるようにしてあります。
全面停止よりも、中途半端に動き続ける障害のほうが、人間の目に触れるまで長くかかります。 この想定シナリオの経過時間は、ぜひご自身の環境の障害注入で測り直して、手順書に実測値を書き残してください。
既存の Observability スタックへの組み込み
多くのチームには既に Datadog、New Relic、Honeycomb、Grafana のいずれかが導入されているはずです。これらを置き換えるのではなく、拡張する形で組み込みます。復旧トレースは、チームが既に見ている画面に流し込めるからこそ価値が出ます。
私が復旧イベントごとに発行する主なスパンは以下の4つです。
agent.tool_call — ツール呼び出しごとのトップレベルスパン
agent.tool_call.error — 失敗時の子スパン(生エラー・ツールメタデータを含む)
agent.diagnosis — 子スパン(パターン・信頼度・選択された戦略を含む)
agent.recovery — 復旧実行の子スパン(試行回数・総待機時間・最終結果を含む)
OpenTelemetry 互換のフォーマットで出力すると、運用エンジニアは「復旧戦略別のインシデントフィルタ」「サービスごとの escalate_to_human 件数」「上流プロバイダ障害との相関」といったクエリが書けるようになります。私は Grafana に3つのパネルを並べたダッシュボードを置いています: 「ツール別インシデント数(時系列)」「呼び出された復旧戦略の積み上げ」「MTTR の分布」。何かおかしいときは、まずここに兆候が出ます。
特に Honeycomb は tool_name や recovery_strategy のような高カーディナリティの属性が活きるので相性が良いです。「直近24時間の recovery_strategy:circuit_break だけ抽出」が1クリックで終わります。メトリクスのみのシステムでは、事前に集約軸を決めておく必要があり、後から欲しくなった切り口は基本的に取れません。
何から監視を始めるか — 4つの全部を一気に入れない
新しく始めるなら、初日に4つのヘルスシグナル全部を有効化しないでください。エージェントの目的に最も近い1つから始めます。
コンテンツ生成エージェント(レポート・要約・コード生成): 空応答率 から始めます。品質の劣化はここに隠れます
ワークフロー駆動エージェント(マルチステップ・長時間ジョブ): タスク完了率 から始めます。サイレントな停滞はここに隠れます
外部 API 連携エージェント(Stripe・Slack・GitHub): ツール呼び出し成功率 から始めます。レート制限と障害はここに隠れます
同期対話エージェント: P95 レイテンシ から始めます。ユーザー体験はここに隠れます
1つを選び、丁寧に計装し、2週間運用してから次を足します。一度に4つ全部を入れると、どれもチューニング不足になります。本記事で挙げた閾値(80%・50%・5%・70%)は私のワークロードでの値であり、あなたのワークロードでは別の値が正解です。固定する前にまず分布を観察してください。
復旧戦略にも同じ原則が当てはまります。exponential_backoff_with_jitter を最初に実装します(過半数のインシデントをカバーします)。failover_to_secondary は2番目、従系プロバイダ統合が動いてから。circuit_break は最後 — 連続失敗のトレースが実データとして取れてからにします。データなしでクールダウン時間を決めると、過敏に発火するか、ぜんぜん発火しないかのどちらかになります。
自己修復を「入れない」べきとき
逆説的ですが、Self-Healing が必要ない場面についても触れておきます。最悪のケースは、必要のないエージェントにこれを全部かぶせて運用負荷だけ増やすことです。
以下の場合、この設計は不要です。
エージェントがオンデマンドで動き、人間が結果を見ているとき。Antigravity を対話的にコード編集に使っているなら、人間が復旧ループそのもの
エージェントの失敗モードが「無応答」ではなく「悪い回答」のとき。Self-Healing は品質問題には効きません — それは評価(eval)の問題
タスクをゼロからやり直すコストが安いとき。最悪「全部やり直し」で済むなら、途中復旧より単純なリトライの方が良い
本番運用が30日未満で、何が壊れるか実データがないとき。最大の失敗は、想像上の障害に対して事前に Self-Healing を作ること 。実際の障害を30日観察してから着手しましょう
Self-Healing は保守負担です。診断レイヤーはエラーフォーマットがドリフトすると更新が必要。復旧コードはプロバイダ追加のたびにケアが要ります。24/7 の信頼性が必要ないなら、もっと素朴な設計の方が勝ちます。
「自己修復が必要」の最も明確なシグナルは、業務時間外に誰かが叩き起こされていること です。
本番投入時の落とし穴 — 私が踏み抜いた3つの罠
設計図が美しくても、本番で罠を踏みます。私が3ヶ月間で踏んだものを共有します。
罠1: ヘルスチェックがエージェント自身を遅くしていた
最初の実装では、すべてのツール呼び出し前に Redis に問い合わせていました。1呼び出しあたりは数ミリ秒の増加だと見ていましたが、複雑なタスクでは数十回ツールを呼ぶため、積み上がると体感できる遅延になっていました。対策はヘルスシグナルを 5秒キャッシュ するように変更することでした。critical 状態の判定が5秒遅れるリスクは許容範囲だと判断しました。
罠2: フェイルオーバー先がコールドスタートで余計に遅い
主系 Anthropic から従系 Gemini に切り替えた瞬間、Gemini 側は数時間呼ばれておらず、接続の張り直しなどで初回応答が目に見えて遅くなりました。「フェイルオーバー後のほうが遅い」という指摘を受けて気づいた点です。対策は 両プロバイダに定期的にウォームアップ用の軽量リクエストを投げる ことです。15分に1回、ヘルスチェック専用の短いプロンプトを送り、従系の接続を温めた状態に保っています。
罠3: 診断レイヤーの正規表現が、エラー文言の変更で効かなくなった
依存先がエラー文言を変えると、パターンマッチは黙って外れます。たとえば「Quota exceeded」が「Resource has been exhausted」に変わった場合、私のレート制限パターンでは拾えず、すべて UNKNOWN に分類されてしまいます。対策は 診断ルーチン自体にもメトリクスを取る ことです。UNKNOWN の割合が直近1時間で5%を超えたら Slack に通知が飛ぶようにしてあります。
計測:自己修復が効果を発揮しているかを判断する指標
設計を導入したら、効果を計測しなければただの過剰エンジニアリングになります。私が見ている指標は4つだけです。
MTTR(平均復旧時間) : エラー発生から正常応答までの時間。手動介入だった頃と自動復旧後を、トレースから同じ定義で比べます
自動復旧成功率 : escalate_to_human に至らずに復旧できた割合。まずは90%を目安に置きます
誤判定率 : 正常な応答を異常と判定して circuit_break してしまった件数。週1件以下を目安にします
人間呼び出し回数 : 夜間(23時〜7時)に Slack で叩き起こされた回数。導入前後で並べて見ます
特に4番目の 「自分が深夜に呼び出される回数」 が最も大事だと感じています。これがゼロに近づいているかどうかで、設計が成功しているかを最終的に判断しています。エージェントの監視運用については AI エージェントのトレース・観測性設計実践 も参考にしてください。
なお、診断レイヤーのパターンを書くときは、依存先の公式ドキュメントにあるレート制限の説明とエラーコード一覧を手元に置いておくと迷いません。
全体を振り返って — 今日からの最初の一歩
Self-Healing エージェントの設計図を一気に紹介しましたが、明日からこれを全部やる必要はありません。私が薦める 最初の一歩 は1つだけです。
「いまあなたが運用しているエージェントの直近30日のエラーログを開き、上位5つのエラーパターンに名前を付ける 」こと。
この作業をするだけで、自分のエージェントがどこで転んでいるかが見えてきます。そのうえで本記事のレイヤー2(診断)から先に作るのか、レイヤー1(検知)を整えるのか、優先順位が自然に決まります。
私の場合は最初にレイヤー2から始めました。検知ロジックを足す前に、まず既知のエラーパターン3つ(レート制限・タイムアウト・空応答)への対応コードを書き、それだけで深夜呼び出しがはっきり減りました。完璧な設計を一気に作ろうとせず、痛い箇所から削っていく のが現実的だと思います。
エージェントが自分で立ち直れるようになると、ようやく「エージェントを育てる」段階に入れます。失敗を恐れず本番に出しながら、復旧履歴を見て少しずつ賢くしていく — その循環の最初の輪が、Self-Healing の3層設計です。