ストアのレビュー返信の下書きをエージェントに任せはじめた頃のことです。ダッシュボードは何週間も緑のままなのに、朝に下書きを開くと、手直しが必要な日が続いていました。
HTTP の 200 は返っていて、エラー率もゼロに近いのです。それでも「使える下書き」の割合は、画面のどこにも出ていません。胃のあたりが重くなるあの感覚を、今でも覚えています。
最初にお伝えしたいのは、確率的に動くエージェントでも、SRE の古典的な道具である SLI / SLO / エラーバジェットで本番運用は組み立てられるということです。ただし、従来の Web サービスと同じ形で SLI を定義すると、緑の画面だけが残ります。ここから先では、定義の仕方、計装コード、そして私が数式を確かめていて気づいた「設定したのに鳴らないアラート」の落とし穴まで、順に書き残します。
評価の土台は AI モデル評価・テストフレームワーク と、計装側は LangFuse によるエージェント可観測性 とあわせて読んでいただくと、計装から意思決定までの流れが辿りやすくなります。
従来の SLI がなぜ AI エージェントで通じないのか
Google の SRE Book に出てくる SLI の典型は「HTTP リクエストの成功率」や「レイテンシが一定値以下の割合」です。ところがエージェントに「このコードベースをリファクタリングしてください」と頼んだとき、200 OK で返ってきた応答が成功かどうかは、差分を読むまで分かりません。
私が最初に書きかけたのは、is_success = response.status == 200 という雑な SLI でした。この定義だと、意味の通らない差分を返したエージェントも「成功」に数えられます。メトリクスは完璧、でも使う側の手間は減らない——この乖離こそが、最初の落とし穴です。
もう一つ、起こりやすい例を挙げます。完了の定義を「エージェントが submit_result ツールを呼んだこと」にすると、エージェントが空の引数で submit_result を呼ぶだけで SLI が満たされてしまいます。エージェントが悪いわけではありません。測りやすいものを目標にすると、最適化はその人工物に向かうのです。
エージェントの SLI を決めるときの線引きは、次の 3 つです。
- 決定論的な成功条件と、確率的な品質評価を分ける——どちらか片方では足りません
- 内部 API のステータスではなく、ユーザーが感じる価値(タスクが終わったか)で測ります
- 全件を LLM-as-a-Judge に通すとコストがかさむため、サンプリング評価を前提にします
SLI の定義を後から変えると、アラートも運用手順も合意事項も、その上に積んだものをすべて組み直すことになります。コードを書く前に、定義だけで 1 週間かけても惜しくありません。
SLI を 3 層で定義する
私が組んでいるエージェントの SLI は 3 層です。それぞれが、信頼性の別の側面を測ります。
第 1 層だけでは足りませんが、ここが壊れていると次の層の議論に進めないため、まず押さえます。第 2 層は LLM を使わずにコードで判定できるので、全件をリアルタイムで計装します。第 3 層は費用がかさむため、別ジョブで回します。
この切り分けを飛ばして、いきなり全部を LLM-as-a-Judge で測ろうとすると、コストが膨らむうえに、評価の揺らぎで SLO が安定しません。私はこの順番を取り違えて、手戻りを経験しました。
SLI 計装の実装 — 第 1・第 2 層
まずは決定論的に測れる部分です。Antigravity エージェントを呼び出すサーバーサイドの TypeScript を想定し、OpenTelemetry でメトリクスを出して Prometheus で集計します。
// agent-sli.ts
// 目的: エージェントの SLI を計装する
// ポイント: 第1層(インフラ)と第2層(タスク完了)を 1 箇所で扱う
import { metrics, trace } from "@opentelemetry/api";
const meter = metrics.getMeter("antigravity-agent", "1.0.0");
// 第1層: インフラ SLI
const requestCounter = meter.createCounter("agent_requests_total", {
description: "エージェントへのリクエスト総数",
});
const requestDuration = meter.createHistogram("agent_request_duration_ms", {
description: "エージェントリクエストの処理時間",
unit: "ms",
});
// 第2層: タスク完了 SLI
const taskCompletionCounter = meter.createCounter("agent_task_completions_total", {
description: "タスク完了判定の結果",
});
export interface SliContext {
agentId: string;
taskType: string;
}
export interface TaskVerification {
completed: boolean;
reason?: string;
}
export async function instrumentAgentRun<T>(
ctx: SliContext,
run: () => Promise<T>,
verify: (result: T) => Promise<TaskVerification>,
): Promise<T> {
const span = trace.getTracer("agent").startSpan(`agent.${ctx.taskType}`);
const start = Date.now();
try {
const result = await run();
// 第1層: インフラとしては成功
requestCounter.add(1, { outcome: "ok", task_type: ctx.taskType });
requestDuration.record(Date.now() - start, { task_type: ctx.taskType });
// 第2層: タスクとして成立しているかを検証
const verification = await verify(result);
taskCompletionCounter.add(1, {
outcome: verification.completed ? "completed" : "incomplete",
reason: verification.reason ?? "none",
task_type: ctx.taskType,
});
span.setAttributes({
"agent.task_completed": verification.completed,
"agent.verification_reason": verification.reason ?? "",
});
return result;
} catch (err) {
requestCounter.add(1, { outcome: "error", task_type: ctx.taskType });
taskCompletionCounter.add(1, {
outcome: "error",
reason: err instanceof Error ? err.name : "unknown",
task_type: ctx.taskType,
});
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
}
// コード生成タスクの検証例。型チェックは呼び出し側から注入する
export async function verifyCodeGenerationResult(
result: { files: Array<{ path: string; content: string }> },
deps: { typecheck: (files: Array<{ path: string; content: string }>) => Promise<boolean> },
): Promise<TaskVerification> {
if (!Array.isArray(result.files) || result.files.length === 0) {
return { completed: false, reason: "empty_output" };
}
if (result.files.some((f) => f.content.trim() === "")) {
return { completed: false, reason: "empty_file" };
}
const tsFiles = result.files.filter((f) => f.path.endsWith(".ts"));
if (tsFiles.length > 0 && !(await deps.typecheck(tsFiles))) {
return { completed: false, reason: "typecheck_failed" };
}
return { completed: true };
}
typecheck の中身は、一時ディレクトリに書き出して tsc --noEmit を走らせるだけで構いません。以前の私は「export の文字列が含まれているか」で済ませかけましたが、それでは空に近い出力を通してしまいます。完了の判定は、少し厳しすぎるくらいで始めるほうが、あとで緩められます。
タスク完了率は、Prometheus で次のように引けます。
# タスク完了 SLI(直近 28 日)
sum(rate(agent_task_completions_total{outcome="completed"}[28d]))
/
sum(rate(agent_task_completions_total[28d]))
verify 関数はタスクタイプごとに用意します。「コード生成」「ドキュメント生成」「調査レポート」では「完了」の意味が違うので、プラグインのように差し替えられる形にしておくと、横展開が楽になります。
SLI 計装の実装 — 第 3 層(LLM-as-a-Judge)
品質 SLI は別ジョブで処理します。コスト、レイテンシ、信頼性のどれを見ても、リアルタイム経路から切り離すほうが安全だからです。
// quality-judge.ts
// 目的: サンプリングした出力を LLM-as-a-Judge で品質評価する
// ポイント: Judge が落ちても本番は落ちない
import { GoogleGenAI } from "@google/genai";
const judge = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const JUDGE_PROMPT = `
あなたは AI エージェントの出力品質を評価する評価者です。
次の評価対象について、以下の 3 軸を 1〜5 で採点し JSON で返してください。
- correctness: タスク要求を正しく満たしているか
- safety: セキュリティ上の問題はないか(ハードコードされた秘密情報、SQL インジェクション等)
- style: プロジェクトのコーディング規約に沿っているか
評価対象のタスク: {task}
ユーザー入力: {input}
エージェント出力: {output}
返却形式: {"correctness": 1-5, "safety": 1-5, "style": 1-5, "reason": "..."}
`;
interface QualityScore {
correctness: number;
safety: number;
style: number;
reason: string;
}
export async function evaluateQuality(
task: string,
input: string,
output: string,
): Promise<QualityScore | null> {
// 置換は関数形式で行う。入力に "$&" などが含まれても壊れない
const prompt = JUDGE_PROMPT
.replace("{task}", () => task)
.replace("{input}", () => input.slice(0, 4000))
.replace("{output}", () => output.slice(0, 8000));
try {
const response = await judge.models.generateContent({
model: "gemini-2.5-flash",
contents: prompt,
config: {
responseMimeType: "application/json",
temperature: 0.1, // 評価は低温度で安定させる
},
});
const text = response.text;
if (!text) {
console.warn("judge returned empty response");
return null;
}
const parsed = JSON.parse(text) as QualityScore;
const scores = [parsed.correctness, parsed.safety, parsed.style];
if (!scores.every((v) => typeof v === "number" && v >= 1 && v <= 5)) {
console.warn("judge returned invalid scores", parsed);
return null;
}
return parsed;
} catch (err) {
// Judge の失敗は本番エージェントの失敗ではない。ログだけ残して続行する
console.error("judge failed:", err);
return null;
}
}
サンプリングは、単純な乱数だけでは足りません。珍しいタスクタイプの評価がいつまでも溜まらないからです。
// sampler.ts
// 目的: タスクタイプごとに「最低 N 件」を保証する層化サンプリング
const evaluatedCount = new Map<string, number>();
const MIN_PER_TYPE_PER_DAY = 5;
const BASE_RATE = 0.05;
export function shouldSample(taskType: string): boolean {
const done = evaluatedCount.get(taskType) ?? 0;
const pick = done < MIN_PER_TYPE_PER_DAY || Math.random() < BASE_RATE;
if (pick) evaluatedCount.set(taskType, done + 1);
return pick;
}
// 日次ジョブの先頭でカウンタをリセットする
export function resetDailyCounters(): void {
evaluatedCount.clear();
}
「Judge の結果を信じてよいのか」と聞かれることがあります。答えは「信じきらない」です。Judge は大量の出力を粗く篩い分けるための道具で、最終的な品質の判断は人の目で補正します。週に 1 時間でも、Judge が低スコアをつけた出力を人が読み、採点の一致度を測ってください。この一致度そのものを、メタ SLI として追跡しておくと安心です。
SLO をどう決めるか — 数値に感情を入れない
SLI がそろったら、次は SLO です。ここで「とりあえず 99.9%」と書くのは避けます。ビジネスが許容できる失敗の上限から、逆算して決めます。
コード生成エージェントの例として、次のような置き方を挙げます。あくまで考え方を示すための例で、そのまま採用していただく数値ではありません。
第 2 層に 99.9% を置かない理由ははっきりしています。曖昧な要件の依頼は、そもそも人が書いても間違えます。達成できない目標は、運用する側を疲れさせるだけです。
最初の SLO は、現状の実測値より少し緩めに置くのが現実的です。計装して 1〜2 週間の実測値を眺めてから、「この数字なら守れる」という線を引いてください。
エラーバジェットを自動判定するワークフロー
SLO が決まると、その裏返しとして エラーバジェット が決まります。月間 92% の SLO なら、許容される失敗は 8% です。
バジェットの価値は、残量に応じて意思決定を切り替えられる点にあります。残量が潤沢なら新機能を出し、尽きかけたらリスクのある変更を止めます。その切り替えを自動化したのが次のコードです。
// error-budget-gate.ts
// 目的: エラーバジェットの残量を見て、デプロイや実験の可否を判定する
// ポイント: 判断を数値に委ね、感情論でデプロイ可否を議論しない
export interface ErrorBudgetStatus {
consumedPercentage: number; // 使った割合 (0 以上。100 超は超過)
remainingPercentage: number; // 0 未満にはしない
recommendation: "proceed" | "proceed_with_caution" | "freeze";
reason: string;
}
const QUERY = `
sum(rate(agent_task_completions_total{outcome="completed"}[28d]))
/
sum(rate(agent_task_completions_total[28d]))
`;
export async function checkErrorBudget(
promUrl: string,
sloTarget: number, // 例: 0.92
): Promise<ErrorBudgetStatus> {
const res = await fetch(
`${promUrl}/api/v1/query?query=${encodeURIComponent(QUERY)}`,
{ signal: AbortSignal.timeout(5000) },
);
if (!res.ok) {
// Prometheus 障害時の扱いは運用方針そのもの。チームによっては freeze にします
return {
consumedPercentage: 0,
remainingPercentage: 100,
recommendation: "proceed_with_caution",
reason: `prometheus query failed: ${res.status}`,
};
}
const data = (await res.json()) as {
data: { result: Array<{ value: [number, string] }> };
};
// 結果が空(計装直後・無トラフィック)や NaN のときに 0% と誤読しない
const raw = data.data.result[0]?.value[1];
const completionRate = raw === undefined ? NaN : parseFloat(raw);
if (Number.isNaN(completionRate)) {
return {
consumedPercentage: 0,
remainingPercentage: 100,
recommendation: "proceed_with_caution",
reason: "no data in window — cannot evaluate budget",
};
}
const allowedFailure = 1 - sloTarget; // 例: 0.08
const actualFailure = 1 - completionRate;
const consumed = (actualFailure / allowedFailure) * 100;
const remaining = Math.max(0, 100 - consumed);
if (consumed < 50) {
return { consumedPercentage: consumed, remainingPercentage: remaining,
recommendation: "proceed", reason: "budget has plenty of headroom" };
}
if (consumed < 90) {
return { consumedPercentage: consumed, remainingPercentage: remaining,
recommendation: "proceed_with_caution",
reason: "budget is being consumed — risky changes require review" };
}
return { consumedPercentage: consumed, remainingPercentage: remaining,
recommendation: "freeze",
reason: "error budget nearly exhausted — freeze non-critical deploys" };
}
呼び出し側は別ファイルに分けます。ESM で動かせる tsx などを前提にした例です。
// check-budget-cli.ts
import { checkErrorBudget } from "./error-budget-gate";
const status = await checkErrorBudget(
process.env.PROM_URL ?? "http://localhost:9090",
0.92,
);
console.log(JSON.stringify(status, null, 2));
if (status.recommendation === "freeze") {
console.error("Error budget exhausted. Deploy blocked.");
process.exit(1);
}
GitHub Actions から呼び出し、freeze が返ったらデプロイを止める形にすると、運用の温度感が変わります。夜中に目を覚ました人が「止めてください」と訴えなくても、仕組みが先に止めてくれるのです。
Prometheus が落ちているときに proceed_with_caution を返す設計は、迷いどころです。私は短時間の障害でエージェント全体が止まる損失のほうが大きいと判断しました。ただし、ここは本当にプロジェクトごとに違います。判断の理由は ADR として残しておく——半年後の自分が、その線引きを疑えるようにしておくためです。
バーンレートアラート — 鳴らすべきときにだけ鳴らす
アラート設計は、エージェント SRE でいちばん差が出るところです。失敗が起きた瞬間に鳴るアラートは、オンコールを疲れさせます。Google の SRE Workbook が示す マルチウィンドウ・マルチバーンレート の考え方を、エージェントに当てはめます。
バーンレートとは、エラーバジェットを「許容ペースの何倍の速さで」使っているかを表す値です。バーンレート 1 なら、ちょうど期間いっぱいでバジェットを使い切ります。
ここで、私が数式を確かめていて気づいた落とし穴があります。SRE Workbook の標準例(1 時間窓でバーンレート 14.4、6 時間窓で 6)は、SLO が 99.9% 前後の世界を前提にしています。SLO が 92% のエージェントにそのまま持ち込むと、こうなります。
許容失敗率が 8% なら、失敗率は最大でも 100% までしかありません。つまり発火できるバーンレートの上限は 1 ÷ 0.08 = 12.5 で、14.4 倍のアラートは、設定しても一度も鳴りません。ダッシュボードが緑のまま、アラートだけが沈黙している——いちばん気づきにくい故障です。
この計算は、手元の Node で数行確かめられます。SLO を変えるたびに、先にこの表を作り直してください。
// burn-table.mjs — node burn-table.mjs 0.92
const slo = Number(process.argv[2] ?? 0.92);
const budget = 1 - slo;
const monthHours = 720;
console.log(`max burn rate = ${(1 / budget).toFixed(2)}`);
for (const [name, burn, windowH] of [["page", 6, 1], ["ticket", 3, 6], ["slow", 1, 72]]) {
const failure = burn * budget;
const used = ((burn * windowH) / monthHours) * 100;
console.log(`${name}: burn=${burn} window=${windowH}h failure>${(failure * 100).toFixed(1)}% budget=${used.toFixed(2)}%`
+ (failure >= 1 ? " ← 到達不能" : ""));
}
SLO 0.92 で実行すると、上の表と同じ値(上限 12.50、page は 48.0%、ticket は 24.0%、slow は 8.0%)が出ます。
次に、アラートそのものです。短い窓と長い窓の両方が閾値を超えたときだけ鳴らします。長い窓は「本当に消費が続いているか」、短い窓は「いまも続いているか」を確かめる役です。まず、窓ごとの失敗率を記録ルールにしておきます。
# agent-slo-rules.yml(抜粋。1h・6h・30m・3d も同じ形で定義します)
groups:
- name: agent-slo-recording
rules:
- record: agent:task_failure_ratio:rate5m
expr: |
1 - (
sum(rate(agent_task_completions_total{outcome="completed"}[5m]))
/
sum(rate(agent_task_completions_total[5m]))
)
- record: agent:task_failure_ratio:rate1h
expr: |
1 - (
sum(rate(agent_task_completions_total{outcome="completed"}[1h]))
/
sum(rate(agent_task_completions_total[1h]))
)
- name: agent-slo-alerts
rules:
- alert: AgentErrorBudgetBurnPage
expr: |
agent:task_failure_ratio:rate1h > (6 * 0.08)
and
agent:task_failure_ratio:rate5m > (6 * 0.08)
and
sum(rate(agent_task_completions_total[1h])) > 0.01
for: 2m
labels:
severity: page
annotations:
summary: "このペースだと約 5 日でバジェットを使い切ります(SLO 92%)"
末尾の > 0.01 は、リクエストが少ない時間帯の誤報を避けるための足切りです。1 時間に 36 件ほどしか処理しないエージェントでは、5 分窓の失敗率は 1 件の失敗で大きく振れます。流量の少ないエージェントほど、この足切りと窓の長さの調整が効いてきます。
もう一つ。ここまで 30 日換算でバジェットを考えていますが、SLO の窓を 28 日にした場合は、消費割合の換算も 28 日に合わせて計算し直してください。窓の長さとバーンレートの対応がずれると、アラートが約束と噛み合わなくなります。
最初の 1 か月は、閾値をあえて甘めに置くのがお勧めです(page の 6 を 4 に下げるなど)。発火頻度を見ながら締めていけば、アラート疲れを避けられます。鳴らない週が続いても、慌てて閾値を下げないでください。鳴らないアラートが正常なのです。
インシデント対応 — エージェントが詰まったときの 3 パターン
SLO を決めても、実際に何が起きたとき何をするかを決めていないと、対応は属人的になります。エージェントに固有の 3 つのパターンを、ランブックの叩き台として整理します。
パターン 1: 失敗率の急増(1 時間で 10% から 50% など)
まず疑うのは、上流の LLM プロバイダー(Gemini API など)の障害です。ステータスページと、自分のレイテンシ指標の異常(急な増加、あるいは不自然な減少)を確認します。次に疑うのが、直前に入れたプロンプトやツール定義の変更です。プロンプトは脆く、1 行で挙動が変わります。コードと同じく Git で管理し、ロールバック手順をランブックに書いておいてください。
パターン 2: 品質がじわじわ下がっていきます(バジェットが少しずつ減る)
最も対応が難しい型です。インフラも第 2 層も正常なのに、Judge のスコアだけが 4.2、4.0、3.8 と下がっていきます。多くは入力の分布が、プロンプトを設計した頃から離れていくデータドリフトです。入力のトークン数の分布や、扱うライブラリのバージョンを時系列で記録しておけば、「いつから」変わったかを特定できます。
パターン 3: 特定のタスクタイプだけが失敗する
全体の SLI は健全なのに、一部の種別だけが悪い型です。ラベルでタスクタイプを分けておけば、同じメトリクスからサブセットの SLO を切り出せます。ラベルをケチらないことが、あとで効いてきます。
評価データセットを一級の成果物として扱う
品質 SLI の信頼度は、評価データセットで決まります。入力例と「良い出力が満たすべき特徴」のチェックリスト(正解文そのものではなく)を組にして持ちます。コード生成なら、「React を import している」「空配列を扱っている」「命名規約に沿っている」といった具合です。
このデータセットは Git で管理し、大きな変更ごとにタグを付けてください。Judge のプロンプトからも、チェックリストの項目を参照できる形にします。得られるものは三つあります。先月の評価を今月のエージェントに再実行して比較できること、品質が落ちたときに差分で原因を辿れること、そして非エンジニアも項目を読んで質問できることです。
月に一度ほど、実運用の入力から一部を(同意とプライバシーの確認を済ませたうえで)入れ替えます。全部ではなく、古い 3 分の 1 ほどにとどめてください。変わらない例が残っているから、後退に気づけるのです。評価に入る入力には、個人情報や秘密情報が混じり得ます。ログと同じ分類と伏せ字処理を通し、公開リポジトリには置かないでください。
あわせて、フレークな評価をゲートにしないための考え方を 評価ゲートの揺れを記録したノート にまとめています。
組織で SLO を合意する/ひとりで運用するとき
チームで運用する場合、SLO は「サービス品質のコミットメントライン」として共有します。「完了率が 92% を下回ったら、新機能を止めて品質改善に集中する」と事前に決めておけば、品質と売上の押し付け合いになりません。開発側には、バジェットを「予算」として見せます。「今月は 60% 使っているので、リスクの高いプロンプト変更は来月に回しましょう」と言えるようになります。
私のように個人でアプリやサイトを運用している場合は、合意する相手が自分です。それでも、SLO を README に書き、「この数値を下回ったら新機能を止める」と先に文章にしておくと効きます。疲れた夜の自分は、たいてい甘い判断をするからです。
ダッシュボードは 2 枚に分けます。1 枚で現状が分かる俯瞰ビューと、時系列で掘れる詳細ビューです。1 枚で両方を満たそうとすると、どちらにも使いにくくなります。
ラベル設計
第 2 層に付けるラベルは、運用が長くなるほど効いてきます。
user_id のような高カーディナリティなラベルは付けません。ユーザー単位の追跡は構造化ログとトレースの役割です。メトリクスは集計用、ログとトレースは個別追跡用と分けておくと、長く保てます。新しいラベルを足すときは、「このラベルでどんなクエリを書くか」を 2〜3 個先に想像してください。書けないなら、まだ要りません。
SLO を後回しにしてよい場面
すべてのエージェントに、この仕組みが要るわけではありません。1 日の処理が 100 件に届かないうちは、割合ベースの SLO は統計的なノイズになります。構造化ログと人の目で足ります。まだ何をするエージェントかが固まっていない試作段階も、同じです。SLO が前提を固めてしまいます。
すでにプロダクト全体の SLO(たとえば「90% のセッションが成功で終わる」)があり、エージェントの挙動がそこに含まれているなら、別建ての SLO は要らないかもしれません。ただ、お金に触れる処理、セキュリティに関わる処理、無人で動く処理は別です。SLO の手間は、それがない週の損失よりはるかに小さいので、初日から計装してください。
よくある落とし穴
私が踏み抜いた、あるいは踏み抜きかけた落とし穴です。
落とし穴 1: SLO を関係者に見せないこと 運用側だけが SLO を知っていると、「ちょっと速度を優先したい」という要望がバジェットを食い潰します。誰でも今月の残量を見られる場所に公開します。
落とし穴 2: 評価データセットのバージョンを残さないこと Judge のスコアが下がったとき、データセットが変わったのかエージェントが後退したのか、区別できなくなります。Git のコミット ID をメトリクスに付けてください。
落とし穴 3: SLI で直接アラートを鳴らすこと 失敗率 5% で即アラートにすると、疲弊します。鳴らす条件は、バジェットの燃焼速度です。しかもその閾値は、SLO から逆算して到達可能かを確かめたものでなければなりません。
落とし穴 4: 人のレビューを後回しにすること Judge は Judge の好みに偏ります。週 1 時間の人の目がないと、Judge が壊れていても気づけません。この工数は SRE のコストとして計上します。
落とし穴 5: 複数のエージェントを 1 つの SLO で束ねること 一方の劣化が、他方の余裕に隠れます。エージェントごとに SLO とダッシュボードを持たせてください。
運用に落とし込む最初の一歩
ここまで読んで「自分でも始めたい」と感じていただけたなら、全部を一度に作らないでください。SRE は成熟度に合わせて、段階的に入れるものです。
計装する対象は、まず第 2 層(タスク完了率)だけ、期間は 1 週間。 第 1 層は既存の監視で足りていることが多く、第 3 層は費用と手間がかかります。1 週間後に出てくる完了率を眺めて、「思ったより高い」「思ったより低い」と感じたところが、SLO の議論の出発点です。そして SLO を決めたら、burn-table.mjs に値を入れて、設定したアラートが本当に発火できるかを一度だけ確かめてみてください。
私もこの順番で組み直しました。いまも、鳴らないアラートほど疑うようにしています。