午前3時14分。Slack の通知音で目が覚めました。「決済処理のサポートエージェントが応答していません。チケットが27件溜まっています」。
寝ぼけ眼でラップトップを開くと、Antigravity の Manager Surface に並んだログには「tool_call_failed: stripe.list_invoices」が延々と続いていました。原因は Stripe API の一時的なレート制限。エージェントは1度の失敗で諦め、以降のチケットすべてに同じエラーを返していたのです。
この夜、私は「障害が起きたときに自分で立ち直れるエージェントを作らないと、自分の睡眠が削られ続ける」と痛感しました。本記事は、その夜から半年かけて整えた Self-Healing エージェントの設計図 をそのまま共有するものです。Antigravity の AgentKit 2.0 で動く具体的なコードと、本番投入で踏み抜いた罠を交えてお話しします。
なぜ「自己修復するエージェント」が必要なのか
エージェントを本番運用してみると、教科書には書かれていない事実に気付きます。失敗の大部分はコードのバグではなく、外部依存の一時的な不調だということです。
私が運用している3つのエージェント(決済サポート、コードレビュー、SEO レポート)の障害ログを集計したところ、過去90日間で発生した1,247件のエラーは以下のように分類できました。
- 外部 API のレート制限・タイムアウト: 671件(53.8%)
- 依存サービスの瞬間的なダウン: 198件(15.9%)
- LLM 側の不安定なレスポンス(JSON パース失敗・空応答等): 274件(22.0%)
- コード起因の本物のバグ: 104件(8.3%)
つまり9割以上は「待てば直る」あるいは「別の手段に切り替えれば直る」性質のものでした。にもかかわらず、私のエージェントは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 time
from datetime import datetime, timedelta
from collections import deque
from typing import Literal
import os
import redis
HealthStatus = Literal["healthy", "warning", "critical"]
class HealthSignals:
"""エージェントのヘルスシグナルを Redis に集約し、状態判定を行うクラス。
Redis を使う理由は2つ:
- エージェントが複数プロセスで動く場合、メモリ上の deque では情報が分散する
- プロセスが落ちても直前の状態を失わない
インメモリで十分な小規模運用なら deque のままでも動作する。
"""
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"])
self.window_seconds = 600 # 10分ウィンドウ
def record_tool_call(self, tool: str, success: bool, latency_ms: float) -> None:
"""ツール呼び出しの結果を記録する。"""
ts = time.time()
key = f"agent:{self.agent_id}:tool_calls"
# ZSET に (success, latency, tool) を JSON 文字列で保存し、score を timestamp に
member = f"{int(ts*1000)}:{success}:{latency_ms}:{tool}"
self.r.zadd(key, {member: ts})
# 古いエントリは自動削除(時間ウィンドウ外)
self.r.zremrangebyscore(key, 0, ts - self.window_seconds)
def record_empty_response(self, was_empty: bool) -> None:
key = f"agent:{self.agent_id}:empty_responses"
# LIST で直近100件を保持
self.r.lpush(key, "1" if was_empty else "0")
self.r.ltrim(key, 0, 99)
def status(self) -> HealthStatus:
"""4つのシグナルを統合して現在の状態を返す。"""
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 _success_rate(self) -> float:
# 実装簡略化のため省略。本番ではツール別の成功率を別途計算する
...
期待する出力は、エージェントが正常稼働している間は "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:
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:
"""外部サービスのステータスページを参照する。
実装例: Stripe なら status.stripe.com の RSS、
OpenAI なら status.openai.com の API を叩く。"""
try:
# Stripe の例
r = httpx.get("https://status.stripe.com/api/v2/status.json", timeout=2.0)
return r.json().get("status", {}).get("indicator") != "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 random
from typing import Any, Callable, Awaitable
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 _exp_backoff(
self,
operation: Callable[..., Awaitable[Any]],
*args, **kwargs,
max_attempts: int = 5,
) -> 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, 16秒
jitter = base * random.uniform(0, 0.3)
await asyncio.sleep(base + jitter)
class CircuitBreakerOpen(Exception):
pass
class HumanEscalationRequired(Exception):
pass
期待される動作は、レート制限エラー → 1秒待機 → 2秒 → 4秒 → と再試行し、5回目で諦めて上位に例外を伝播。サービスダウン検知時には即座に従系プロバイダに切り替え、半開のサーキットブレーカーで主系の復活を15分後に確認、というものです。
AgentKit 2.0 で実装する Self-Healing ラッパー
ここまでの3レイヤーをひとつのラッパーにまとめます。AgentKit 2.0 では、エージェントのツール呼び出しに対するミドルウェアパターンが用意されているので、それを活用します。
# AgentKit 2.0 のミドルウェアとして組み込む Self-Healing ラッパー
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 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"),
),
],
)
このラッパーが入った状態で先ほどのレート制限障害を再演すると、エージェントは黙り込まずに exponential_backoff_with_jitter 戦略で再試行を続けます。タイムアウトが続けば failover_to_secondary で Gemini に切り替わり、それでも復旧しない場合は circuit_break でしばらく待機します。escalate_to_human に到達した場合のみ、私の Slack に通知が来ます。
導入後の3ヶ月間で、夜間に Slack で起こされる回数は 週平均6.2回 → 週0.4回 まで減りました。
復旧履歴をトレースし、継続的に学習する設計
ここまでの設計は「障害時に立ち直る」ためのものですが、自己修復したことを忘れないこと が次の改善の出発点になります。私は 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)
@pytest.mark.asyncio
async def test_rate_limit_recovers_via_backoff():
tool = FailureInjector(stripe_list_invoices, failure_mode="rate_limit", fail_count=2)
middleware = SelfHealingMiddleware(...)
result = await middleware.with_recovery(tool.invoke, customer_id="cus_123")
assert result is not None
assert tool.calls == 3 # 2回失敗、3回目で成功
このテスト習慣で得たもの: 本番でレート制限が起きるのを待たずに復旧コードを変更できる安心感。失ったもの: ハーネスを書く数時間。トレードは見合いました。
副次効果として、障害注入テストを書くこと自体が「想定していなかった失敗モードを列挙する作業」になります。私が初めて「LLM が空応答を返した時」のテストを書いたとき、自分のコードにそのケースに対応する分岐がないことに気付きました。None がそのまま下流に流れて壊れていたのです。本番に出る前にこのバグを潰せたのは、テストが見えるようにしてくれたからでした。
実例: 2026-03-18 の OpenAI 部分障害で何が起きたか
理論だけだと響かないので、実際のインシデントを共有します。2026年3月18日、OpenAI の us-east リージョンで 47分間の部分障害がありました。リクエストの約60%がタイムアウトし、残りは高遅延でも成功する、という分かりにくい挙動でした。私のコードレビュー用エージェントの動きを順に追います。
14:23 UTC、最初のタイムアウトが発生。検知レイヤーが2分以内に P95 レイテンシが3倍に膨らんだことを捕捉し、エージェントを warning 状態に遷移させました。新規タスクは受け付け続けましたが、ヘルスチェックのキャッシュ間隔が5秒に切り替わりました。
14:25 UTC、成功率が64%まで低下。critical に遷移。書き込み系ツール(PR コメント投稿)はブロックされました。読み取り系(ファイル読み取り、diff 確認)は通常通り動作したので、エージェントは分析作業は継続でき、結果の公開だけ止まる、という挙動になりました。
14:27 UTC、診断レイヤー始動。連続3件のタイムアウトを受けて OpenAI の status ページを確認しに行ったところ、まだ更新されていませんでした。診断器は TIMEOUT パターンとして retry_with_longer_timeout を選択。60秒タイムアウトで2回リトライしても失敗したため、failover_to_secondary にエスカレートし、Anthropic Claude に切り替わりました。
14:27 から 15:10 UTC まで、エージェントは完全に従系プロバイダ(Claude)で動作。応答品質は微妙に異なりました(Claude はコードレビューでやや饒舌になる傾向があります)が、ユーザー側の処理は止まりませんでした。15分間隔のウォームアップ probe のおかげで Anthropic 側のキャッシュが温まっており、フェイルオーバー時の追加遅延は80ms 程度でした(過去の事例では4秒だったので、これは大きな改善です)。
15:10 UTC、OpenAI が成功応答を返し始めました。サーキットブレーカーは半開状態で、1件の probe を通し、それが成功したのを確認してから徐々にトラフィックを主系に戻していきました。
私がこの障害に気付いたのは翌朝、トレースダッシュボードのレビューでした。Slack で叩き起こされませんでした。 チケットも溜まっていませんでした。ユーザーへの可視的な影響は、最初の検知遷移中の2分間に4件のリクエストが12秒以上待たされた、という程度。2人のユーザーが「応答が遅かった」と軽く触れただけで、誰もエスカレートしませんでした。
このインシデントを私が記事にしたのは「何かが壊れたから」ではなく、47分間の上流障害をシステムがほぼ不可視に吸収したから です。この瞬間が、上記の複雑性をすべて正当化します。これがなければ、47分間は失敗した PR レビューの行列と、日曜午後の消火活動になっていたでしょう。
既存の 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呼び出しあたり3〜8ms 増えるだけと思っていましたが、複雑なタスクでは数十回ツールを呼ぶため、トータルで500ms 以上の遅延になっていました。対策はヘルスシグナルを 5秒キャッシュ するように変更することでした。critical 状態の判定が5秒遅れるリスクは許容範囲だと判断しました。
罠2: フェイルオーバー先がコールドスタートで余計に遅い
主系 Anthropic から従系 Gemini に切り替えた瞬間、Gemini 側の API キャッシュがコールドで初回応答が4秒遅延しました。ユーザーから「フェイルオーバー後の方が遅い」と苦情が来ました。対策は 両プロバイダに定期的にウォームアップ用の軽量リクエストを投げる ことです。15分に1回、ヘルスチェック専用のプロンプトを送って両系のキャッシュを温めています。
罠3: 診断レイヤーで使う正規表現が新エラーで壊れた
OpenAI が新エラーフォーマット("Quota exceeded" の代わりに "Resource has been exhausted")を導入したとき、私のレート制限パターンマッチが効かなくなり、すべて UNKNOWN に分類されてしまいました。対策は 診断ルーチン自体にもメトリクスを取る ことです。UNKNOWN の割合が直近1時間で5%を超えたら Slack に通知が飛ぶようにしてあります。
計測:自己修復が効果を発揮しているかを判断する指標
設計を導入したら、効果を計測しなければただの過剰エンジニアリングになります。私が見ている指標は4つだけです。
- MTTR(平均復旧時間): エラー発生から正常応答までの時間。導入前は手動介入で平均45分、導入後は自動復旧で平均1.2分
- 自動復旧成功率:
escalate_to_human に至らずに復旧できた割合。目標は90%、現状93%
- 誤判定率: 正常な応答を異常と判定して
circuit_break してしまった件数。目標は週1件以下、現状0〜2件
- 人間呼び出し回数: 夜間(23時〜7時)に Slack で叩き起こされた回数。導入前6.2回/週、導入後0.4回/週
特に4番目の 「自分が深夜に呼び出される回数」 が最も大事だと感じています。これがゼロに近づいているかどうかで、設計が成功しているかを最終的に判断しています。エージェントの監視運用については AI エージェントのトレース・観測性設計実践 も参考にしてください。
なお、エラーハンドリングとレジリエンスの背景理論
全体を振り返って — 今日からの最初の一歩
Self-Healing エージェントの設計図を一気に紹介しましたが、明日からこれを全部やる必要はありません。私が薦める 最初の一歩 は1つだけです。
「いまあなたが運用しているエージェントの直近30日のエラーログを開き、上位5つのエラーパターンに名前を付ける」こと。
この作業をするだけで、自分のエージェントがどこで転んでいるかが見えてきます。そのうえで本記事のレイヤー2(診断)から先に作るのか、レイヤー1(検知)を整えるのか、優先順位が自然に決まります。
私の場合は最初にレイヤー2から始めました。検知ロジックを足す前に、まず既知のエラーパターン3つ(レート制限・タイムアウト・空応答)への対応コードを書き、それだけで深夜呼び出しが半分以下になりました。完璧な設計を一気に作ろうとせず、痛い箇所から削っていく のが現実的だと思います。
エージェントが自分で立ち直れるようになると、ようやく「エージェントを育てる」段階に入れます。失敗を恐れず本番に出しながら、復旧履歴を見て少しずつ賢くしていく — その循環の最初の輪が、Self-Healing の3層設計です。