import RelatedArticles from "@/components/RelatedArticles";
「夜中の 2 時に Slack 通知が鳴って、Antigravity Agent が同じツールを 200 回呼び出して止まらなくなっていた」— これは私が実際に体験した一夜の話です。半分寝ぼけながら Manager Surface を開き、どのトレースから見ればいいのか分からず 30 分溶かしました。Runbook さえあれば、5 分で原因特定まで辿り着けたはずでした。
AI Agent の本番運用は、従来の Web サービスとは別物の障害パターンを生み出します。CPU は元気でも「思考が壊れている」状態が起きるのです。ここでは私が複数のプロダクトで失敗を重ねながら整えてきた Antigravity Agent 専用の Runbook フレームワーク を、コード付きで丸ごとお渡しします。
個人開発から数十エージェントを束ねる本番環境まで、規模に応じてスケールする設計にしています。読み終える頃には、Slack に届いた最初のアラートから 5 分以内にトリアージを終え、ユーザー影響を最小化する具体的な手順が手元に揃っているはずです。
なぜ Antigravity Agent には専用 Runbook が必要なのか
通常の Web サービスの Runbook は「リクエスト数が急増した」「DB 接続が切れた」といった外形的な指標に紐づいています。一方、Antigravity Agent の障害は次のような特徴を持ちます。
- 「正常に動いているのに間違っている」状態が頻発する: ツール呼び出しは成功、API は 200 を返す、しかし出力が業務要件を満たしていない
- 失敗が伝播しにくい: マルチエージェント構成では Worker Agent が壊れていても Manager Agent が「問題ありません」と返してしまうケースがある
- コストが障害指標になる: トークン消費が急増した時点で、すでに数百ドル分の損害が出ている可能性がある
- 再現性が低い: 同じ入力で 2 回試して再現しないことが普通にある
つまり、HTTP ステータスや CPU 使用率だけを見る Runbook では網羅できません。Antigravity 固有の runId、traceId、agentSpanId を中心に据えた、AI Agent 専用の対応フローが必要になります。
私は当初、既存の SRE Runbook テンプレートをそのまま使っていましたが、トリアージ初動で「どのログを見ればいいのか」を毎回考えてしまい、夜中の対応で頭が回らない時に致命的でした。Agent 専用フレームワークに切り替えてから、初動時間が平均 18 分から 4 分に短縮されました。
Runbook の 4 階層モデル — 個人開発でも回せる軽量設計
私が辿り着いたのは、Runbook を 4 つの階層に分ける構造です。重いプロセスは続かないので、個人開発者が一人で回せる軽さを優先しています。
- L0: 検知 (Detection) — 何かがおかしいと最初に気づく層。アラート定義とトリガー条件を集約
- L1: トリアージ (Triage) — 5 分以内に「ユーザー影響あり/なし」「自動復旧可能/不可」を判定
- L2: 抑制 (Mitigation) — ユーザー影響を止めるための即時アクション。Kill switch・フォールバック・トラフィック遮断
- L3: 復旧と再発防止 (Recovery & Postmortem) — 根本原因の修正、Runbook 更新、再発防止策の組み込み
各階層には専用のチェックリストとコードスニペットを用意します。深夜に冷静に判断できる人間はいないので、Runbook が考える代わりに動いてくれる必要があります。
L0: 検知の設計 — 4 種類のアラートを使い分ける
まず Antigravity Agent で監視すべき指標を 4 つに整理します。1 つのダッシュボードに混ぜると見落とすので、必ず分けてください。
// monitoring/agent-alerts.ts
// Antigravity Agent 用のアラート定義(OpenTelemetry + PromQL ベース)
import { Counter, Gauge, Histogram } from "@opentelemetry/api";
// ① 動作系: そもそも Agent が動いているか
export const agentRunCount = new Counter({
name: "antigravity_agent_run_total",
help: "Total number of Agent runs by status",
labelNames: ["agent_id", "status"], // status: success | failure | timeout
});
// ② 品質系: 出力が業務要件を満たしているか
export const agentEvalScore = new Histogram({
name: "antigravity_agent_eval_score",
help: "Eval score (0-1) for Agent outputs",
labelNames: ["agent_id", "eval_type"],
buckets: [0.5, 0.7, 0.8, 0.9, 0.95],
});
// ③ コスト系: トークン消費が予算内か
export const agentTokenSpend = new Counter({
name: "antigravity_agent_token_spend_usd",
help: "Cumulative USD spend per Agent",
labelNames: ["agent_id", "model"],
});
// ④ ループ系: 同じツールを暴走呼び出ししていないか
export const agentToolCallStreak = new Gauge({
name: "antigravity_agent_tool_call_streak",
help: "Consecutive identical tool calls (potential loop)",
labelNames: ["agent_id", "tool_name"],
});私が実装で痛感したのは、コスト系アラートを最初に設計しないと取り返しがつかない ことです。一晩で $300 溶かした経験があります。agentTokenSpend のような累積カウンターを 1 時間あたり $X 超えたら即時通知する仕組みを、最初の 1 日目に組み込んでください。
PromQL でのアラートルール例も載せます。Cloudflare や Grafana Cloud に置く前提です。
# monitoring/alerts.yml
# Antigravity Agent の本番アラートルール
groups:
- name: antigravity_agent_alerts
interval: 30s
rules:
# ① 動作系: 失敗率が 5 分間で 20% 超
- alert: AgentFailureRateHigh
expr: |
(
sum(rate(antigravity_agent_run_total{status="failure"}[5m])) by (agent_id)
/ sum(rate(antigravity_agent_run_total[5m])) by (agent_id)
) > 0.2
for: 5m
labels:
severity: page
runbook: agent-failure-rate
annotations:
summary: "Agent {{ $labels.agent_id }} failure rate > 20%"
# ② コスト系: 1 時間で $20 超え(個人開発スケール)
- alert: AgentTokenSpendBurst
expr: |
increase(antigravity_agent_token_spend_usd[1h]) > 20
for: 5m
labels:
severity: page
runbook: agent-cost-burst
annotations:
summary: "Agent {{ $labels.agent_id }} burned ${{ $value }} in 1h"
# ③ ループ系: 同じツールを 30 回連続呼び出し
- alert: AgentToolLoopDetected
expr: antigravity_agent_tool_call_streak > 30
for: 1m
labels:
severity: page
runbook: agent-tool-loop
annotations:
summary: "Agent {{ $labels.agent_id }} stuck on {{ $labels.tool_name }}"
# ④ 品質系: Eval スコアが過去 1 時間で 0.7 を下回る
- alert: AgentQualityDegraded
expr: |
histogram_quantile(0.5, sum(rate(antigravity_agent_eval_score_bucket[1h])) by (le, agent_id)) < 0.7
for: 15m
labels:
severity: ticket
runbook: agent-quality-drop
annotations:
summary: "Agent {{ $labels.agent_id }} median eval score < 0.7"severity: page はオンコールに即時通知、severity: ticket は翌営業日対応で十分なものに分けています。全部 page にすると本当に必要な時に反応できなくなるので、痛い目を見ながら線引きしてきました。
L1: トリアージの 5 分間プロトコル
アラートを受け取ったら、まず 5 分以内に次の 3 つを答える ことを目標にします。
- ユーザー影響は出ているか?(出ていれば即 L2 へ)
- 自動復旧の余地はあるか?(あればまず再試行)
- 横展開リスクはあるか?(他の Agent や下流サービスへの影響)
このフローを Runbook に落とし込んだ Markdown テンプレートが以下です。Slack の Workflow Builder や Notion にそのまま貼って使ってください。
## トリアージ・チェックリスト(5 分以内に完了)
- [ ] **alert ID** を取得 (例: `AgentFailureRateHigh-2026-04-28-02-15`)
- [ ] Manager Surface で該当 `agent_id` の `runId` を上から 3 件開く
- [ ] エラーメッセージのパターンを判定:
- [ ] `ToolTimeout` → L2-A: タイムアウト緩和フロー
- [ ] `RateLimitExceeded` → L2-B: バックオフ延長フロー
- [ ] `MaxIterationsReached` → L2-C: ループ検出フロー
- [ ] `EvalScoreDrop` → L2-D: モデルロールバックフロー
- [ ] その他 → L2-E: Kill switch 発動を検討
- [ ] ユーザー影響を `cmd+K` で `is_user_facing` フィールド検索:
- 「あり」: 60 秒以内に L2 を発動
- 「なし」: 15 分内で L3 へ移行
- [ ] 横展開リスクの確認:
- [ ] 同じ tool を使う他の Agent の失敗率 (PromQL: `rate(antigravity_agent_run_total{status="failure", tool_name="<name>"}[5m])`)
- [ ] 下流サービス (DB, 外部 API) の応答時間
- [ ] **#incidents-agent** チャネルに `[L1完了] agent_id=xxx pattern=ToolTimeout user_impact=あり` を投稿このチェックリストは、私が実際に 5 分タイマーを使ってリハーサルしながら磨いてきたものです。「Manager Surface で runId を上から 3 件開く」 のように、具体的な GUI 操作を書くのがコツです。「ログを確認する」だけだと、夜中に頭が回らない時に固まります。
L2: 抑制パターン集 — Kill Switch を 1 行で打てるように
ユーザー影響が出ている場合、根本原因の調査より先に 「血を止める」 ことが優先です。Antigravity Agent には次の 5 種類の Mitigation パターンを用意しておきます。
// runbook/mitigations.ts
// 本番障害の即時抑制ライブラリ
import { ConfigStore } from "./config-store";
interface MitigationContext {
agentId: string;
reason: string;
operator: string; // 実行者の名前
durationMinutes?: number;
}
export class AgentMitigator {
constructor(private config: ConfigStore) {}
// パターン A: Kill Switch — Agent を完全停止
async killSwitch(ctx: MitigationContext): Promise<void> {
await this.config.set(`agent:${ctx.agentId}:enabled`, false, {
ttlMinutes: ctx.durationMinutes ?? 60,
audit: { reason: ctx.reason, operator: ctx.operator },
});
console.log(`[KILL] ${ctx.agentId} stopped for ${ctx.durationMinutes ?? 60}min`);
// 通知も忘れずに
await this.notifySlack(`🚨 ${ctx.agentId} を ${ctx.durationMinutes ?? 60}分間停止しました (${ctx.operator}: ${ctx.reason})`);
}
// パターン B: フォールバック — 単純な決定論的処理に切り替え
async fallbackToDeterministic(ctx: MitigationContext): Promise<void> {
await this.config.set(`agent:${ctx.agentId}:mode`, "fallback", {
ttlMinutes: ctx.durationMinutes ?? 30,
audit: { reason: ctx.reason, operator: ctx.operator },
});
await this.notifySlack(`🔄 ${ctx.agentId} をフォールバックモードに切替`);
}
// パターン C: モデルダウングレード — 安定版に戻す
async downgradeModel(ctx: MitigationContext, fallbackModel: string): Promise<void> {
await this.config.set(`agent:${ctx.agentId}:model`, fallbackModel, {
ttlMinutes: ctx.durationMinutes ?? 1440,
audit: { reason: ctx.reason, operator: ctx.operator },
});
await this.notifySlack(`⬇️ ${ctx.agentId} を ${fallbackModel} にダウングレード`);
}
// パターン D: トラフィック制限 — 同時実行数を絞る
async throttle(ctx: MitigationContext, maxConcurrent: number): Promise<void> {
await this.config.set(`agent:${ctx.agentId}:max_concurrent`, maxConcurrent, {
ttlMinutes: ctx.durationMinutes ?? 60,
audit: { reason: ctx.reason, operator: ctx.operator },
});
await this.notifySlack(`🐌 ${ctx.agentId} の並列度を ${maxConcurrent} に絞りました`);
}
// パターン E: ループ強制脱出 — 進行中のループを中断
async breakLoop(ctx: MitigationContext, runId: string): Promise<void> {
await this.config.set(`run:${runId}:cancel`, true, {
ttlMinutes: 5,
audit: { reason: ctx.reason, operator: ctx.operator },
});
await this.notifySlack(`✂️ runId=${runId} のループを強制終了`);
}
private async notifySlack(message: string): Promise<void> {
const webhookUrl = process.env.SLACK_INCIDENT_WEBHOOK_URL;
if (!webhookUrl) {
console.warn("SLACK_INCIDENT_WEBHOOK_URL not set, skipping notification");
return;
}
try {
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message, channel: "#incidents-agent" }),
});
} catch (e) {
console.error("Slack notify failed:", e);
// 通知失敗で抑制処理を止めないこと(ここが重要)
}
}
}
// 使い方の例: Kill Switch を 1 行で打つ
// const mitigator = new AgentMitigator(configStore);
// await mitigator.killSwitch({ agentId: "code-reviewer", reason: "暴走検知", operator: "masaki" });ここで意識しているのは、Slack 通知の失敗で抑制処理を止めないこと です。本番障害中に Slack まで落ちている可能性は普通にあります。try/catch でくるんで、抑制自体は完遂させてください。
それから TTL 必須 にしているのも重要なポイントです。手動で enabled = false にして、そのまま忘れて翌週まで止めっぱなし、という事故を防ぎます。最大 24 時間で自動復活させて、必要なら更新する設計にしましょう。
L3: 復旧 — Trace ID から原因を辿る実装
血が止まったら、根本原因を特定して恒久対策を打ちます。Antigravity Agent の場合、traceId を起点に「思考の足跡」を再現できることが強力な武器になります。
// runbook/postmortem-data.ts
// 障害ポストモーテム用のデータ収集スクリプト
// 使い方: node postmortem-data.ts <traceId>
import { AntigravityClient } from "@antigravity/sdk";
import { writeFileSync } from "node:fs";
interface PostmortemBundle {
traceId: string;
agentId: string;
startedAt: string;
endedAt: string;
totalTokens: number;
totalUsd: number;
toolCalls: Array<{
spanId: string;
toolName: string;
durationMs: number;
success: boolean;
inputHash: string;
outputHash: string;
}>;
modelMessages: Array<{
role: string;
contentSummary: string; // 最初の 200 文字
tokens: number;
}>;
evalScores: Array<{ evalType: string; score: number }>;
}
async function collectBundle(traceId: string): Promise<PostmortemBundle> {
const client = new AntigravityClient({
apiKey: process.env.ANTIGRAVITY_API_KEY!,
});
try {
const trace = await client.traces.get(traceId);
const spans = await client.traces.spans(traceId);
const evals = await client.traces.evals(traceId);
return {
traceId,
agentId: trace.agentId,
startedAt: trace.startedAt,
endedAt: trace.endedAt ?? new Date().toISOString(),
totalTokens: trace.totalTokens,
totalUsd: trace.totalUsd,
toolCalls: spans
.filter((s) => s.type === "tool_call")
.map((s) => ({
spanId: s.id,
toolName: s.attributes.tool_name,
durationMs: s.durationMs,
success: s.status === "ok",
inputHash: s.attributes.input_hash,
outputHash: s.attributes.output_hash,
})),
modelMessages: spans
.filter((s) => s.type === "model_message")
.map((s) => ({
role: s.attributes.role,
contentSummary: (s.attributes.content ?? "").slice(0, 200),
tokens: s.attributes.tokens ?? 0,
})),
evalScores: evals.map((e) => ({ evalType: e.type, score: e.score })),
};
} catch (e) {
console.error(`Failed to collect bundle for ${traceId}:`, e);
throw new Error(`Trace ${traceId} not found or API error`);
}
}
// CLI エントリポイント
const traceId = process.argv[2];
if (!traceId) {
console.error("Usage: node postmortem-data.ts <traceId>");
process.exit(1);
}
collectBundle(traceId)
.then((bundle) => {
const filename = `postmortem-${traceId.slice(0, 8)}-${Date.now()}.json`;
writeFileSync(filename, JSON.stringify(bundle, null, 2));
console.log(`✅ Saved bundle: ${filename}`);
console.log(` Tool calls: ${bundle.toolCalls.length}`);
console.log(` Token cost: $${bundle.totalUsd.toFixed(2)}`);
console.log(` Failures: ${bundle.toolCalls.filter((c) => !c.success).length}`);
})
.catch((e) => {
console.error("❌ Bundle collection failed:", e.message);
process.exit(1);
});このスクリプトを npx tsx postmortem-data.ts <traceId> で叩けば、ポストモーテム用のデータが 1 ファイルにまとまります。私はこれを Notion のポストモーテムページに添付して、後から見返せるようにしています。
実行結果の例はこんな感じです。
✅ Saved bundle: postmortem-abc12345-1714356123456.json
Tool calls: 247
Token cost: $18.42
Failures: 12Tool calls: 247 がすでに異常値だと一目で分かります。健全な実行は通常 5〜30 件に収まるからです。