プロンプトを少し直しただけのつもりだったのに、朝起きてみると顧客サポートエージェントの誤回答率が 3 倍に跳ね上がっていたことがあります。評価セット(Eval)では問題なかったはずなのに、本番のロングテールなリクエストでモデルが詰まっていたのです。私もこの「一晩で精度が落ちる」事故を何度か経験しました。その度に思うのは、従来の CI/CD はコードのデプロイを前提に設計されていて、エージェントの挙動変化には向いていないということです。
Claude Agent SDK で構築したエージェントに対して「プログレッシブデリバリー」を適用する本番パターンを順を追って整理していきます。カナリア配信・Feature Flag・SLO ベースの自動ロールバックを組み合わせ、「祈りながらデプロイ」から脱却できる設計を一緒に作っていきます。内容はいずれも私が実際に複数プロダクトで運用している構成を整理したもので、当日中に最初のステップを踏み出せるよう書いています。読み終わる頃には、自分のプロダクトのどこから手を付けるべきか、具体的な像が見えているはずです。
なぜエージェントに従来の CI/CD では足りないのか
Web アプリケーションのリリースでは、Blue/Green やカナリアを組めば十分なことが多いものです。しかしエージェントの場合、次のような特性が加わります。
- 出力が確率的: 同じ入力でも異なる応答が返るため、A/B 比較は平均値だけでは評価できません。分布の端(最悪ケース)まで見ないと判断を誤ります
- 失敗モードが多次元的: レイテンシや 5xx だけでなく、事実誤認・ツール誤選択・トーン逸脱など、本番でしか観測できない品質指標があります
- 評価セットが本番分布を覆い切れない: 評価は「代表ケース」に偏り、ロングテールで事故が起きやすいのです。「評価は 95% 合格していたのに本番では炎上した」は、かなりの頻度で起きる事故です
- 外部状態との結合が強い: ツール呼び出しでチケットを作ったりメールを送ったりすると、ロールバックできない副作用が積み重なります
つまりエージェントのリリースは、コードの正しさではなく「本番トラフィックに対して十分な品質が出るか」を段階的に検証する必要があるのです。これがプログレッシブデリバリーを採用する最大の理由になります。もう一つ重要なのは、切り戻しコストを最小化するという観点です。エージェントは機能追加ではなく「挙動を書き換える」リリースが多く、問題発生時に数分以内で元の挙動に戻せる仕組みがないと、事業側からの信頼を失ってしまいます。
全体アーキテクチャ
今回構築するパイプラインは、以下の要素で構成します。
- Feature Flag サービス(LaunchDarkly / Unleash / 自作 KV ストア)— 「どの呼び出しを新バージョンへ振り分けるか」を決定します
- ルーティング層(Hono / Express など)— Feature Flag の判定結果に従って旧バージョンと新バージョンへトラフィックを分割します
- Agent SDK 実装(
@anthropic-ai/agent-sdk)— 新旧のシステムプロンプト・ツール定義・モデルを version 単位で管理します
- 観測層(OpenTelemetry + 任意のバックエンド)— リクエストごとに
version タグ付きメトリクスを送出します
- SLO エンジン(自作 or Prometheus Alertmanager)— 閾値違反を検知したら Feature Flag をロールバックします
- 昇格コントローラ(Cron / Workers Cron Triggers)— 一定時間 SLO を満たしたら自動で次段階に昇格させます
重要なのは、新バージョンを「コード」ではなく「設定」として切り替えられる状態にしておくことです。リビルドやデプロイを挟むと、ロールバックの即応性が失われてしまいます。数秒で切り戻せるかどうかで、夜間のオンコール対応の質が劇的に変わります。
この設計でもう一つ意識すべきは、観測と制御のループを閉じるという点です。観測だけあっても手動で切り戻す限り、真夜中の事故は防げません。制御だけあっても観測が甘ければ、誤って良いバージョンをロールバックしてしまいます。プログレッシブデリバリーは「観測 → 判断 → 制御」を全自動で回す仕組みです。
Step 0: リリース前にやるべきオフライン評価
カナリアに流す前に、最低限の品質ゲートは通しておきます。評価セットはカバレッジが完璧でなくてもよく、「過去に炎上した代表ケース」を網羅することが最重要です。
// eval/offline.ts — CI で実行する
import { runAgent } from "../agent/versions";
import Anthropic from "@anthropic-ai/sdk";
import testset from "./fixtures/regression.json"; // 過去のインシデント由来のケース集
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
async function runEval(version: "v2026.04.15" | "v2026.04.22") {
const results: Array<{ id: string; ok: boolean; reason?: string }> = [];
for (const tc of testset) {
const out = await runAgent(version, tc.input, client);
const ok = tc.must_contain.every((s) => out.text.includes(s));
results.push({ id: tc.id, ok, reason: ok ? undefined : "missing_required_phrase" });
}
const pass = results.filter((r) => r.ok).length / results.length;
if (pass < 0.9) throw new Error(`Eval pass rate ${(pass * 100).toFixed(1)}% < 90%`);
return { pass, results };
}
意図: この評価は「新バージョンが過去の地雷を踏み抜かないこと」を最低限確認するためのものです。合格率を追いかけ始めると評価セットに過剰適合するので、「前回通っていたケースを落とさない」という回帰観点で十分機能します。合格率ではなく「前回比で落ちたケースがあるか」をビルドの失敗条件にすると、数字遊びを避けられます。
Step 1: バージョンを値として扱うエージェント実装
Claude Agent SDK の呼び出しを、バージョンを外から渡せる純粋関数として書き直します。これがすべての出発点です。
// agent/versions.ts
import type Anthropic from "@anthropic-ai/sdk";
import { agent } from "@anthropic-ai/agent-sdk";
// バージョンごとの設定をコードと分離して持つ
export const AGENT_VERSIONS = {
"v2026.04.15": {
model: "claude-sonnet-4-6" as const,
systemPrompt:
"あなたは公式ドキュメント内でのみ回答する顧客サポートエージェントです。未確定情報は『確認します』で返します。",
tools: ["search_docs", "create_ticket"],
},
"v2026.04.22": {
model: "claude-sonnet-4-6" as const,
systemPrompt:
"あなたは顧客サポートエージェントです。公式ドキュメントを優先しつつ、顧客との過去のやりとりも参照して共感的に応答します。未確定情報は必ず『確認します』で返します。",
tools: ["search_docs", "search_history", "create_ticket"],
},
} as const;
export type AgentVersion = keyof typeof AGENT_VERSIONS;
export async function runAgent(
version: AgentVersion,
input: string,
client: Anthropic,
) {
const cfg = AGENT_VERSIONS[version];
const session = agent({
client,
model: cfg.model,
system: cfg.systemPrompt,
allowed_tools: cfg.tools,
});
// エラーはそのまま投げて上位で観測する(握りつぶさない)
return await session.run({ input });
}
ポイント: バージョンを文字列キーにしておくと、Feature Flag の値としてそのまま渡せます。switch 文で新旧の分岐を書いた瞬間に、この仕組みは破綻します。
もう一つの原則: エラーは握りつぶさずに上位へ投げてください。エラーを握りつぶすと SLO が「見かけ上」健全になり、カナリアが昇格し続けてしまいます。エラーは Span / メトリクスとして観測層に必ず記録されるように配線しましょう。
Step 2: Feature Flag によるカナリア配信
リクエストごとにバージョンを決定する層を作ります。ここでは自作の軽量 Feature Flag を使う例を示しますが、LaunchDarkly などのマネージド製品に置き換えられる構造にしてあります。
// flags/resolver.ts
import { createHash } from "crypto";
// KV に保存される設定例
// {
// "agent.customer_support": {
// "stable": "v2026.04.15",
// "canary": "v2026.04.22",
// "canary_percentage": 5, // 5% へ振り分け
// "canary_allowlist": ["tenant_a"] // 強制的に新バージョンに入れるテナント
// }
// }
export type FlagConfig = {
stable: string;
canary: string;
canary_percentage: number;
canary_allowlist: string[];
};
// ユーザーごとに一貫して同じバージョンが返るようにハッシュで決める
function bucketOf(userId: string, flagKey: string): number {
const h = createHash("sha256").update(`${flagKey}:${userId}`).digest();
// 先頭4バイトを整数化して 0-99 に落とし込む
return h.readUInt32BE(0) % 100;
}
export function resolveVersion(
flag: FlagConfig,
ctx: { userId: string; tenantId: string; flagKey: string },
): { version: string; channel: "stable" | "canary" } {
if (flag.canary_allowlist.includes(ctx.tenantId)) {
return { version: flag.canary, channel: "canary" };
}
const bucket = bucketOf(ctx.userId, ctx.flagKey);
if (bucket < flag.canary_percentage) {
return { version: flag.canary, channel: "canary" };
}
return { version: flag.stable, channel: "stable" };
}
なぜハッシュで決めるのか: ランダムで 5% を選ぶと、同じユーザーが時々新バージョン・時々旧バージョンに振り分けられ、体験が壊れます。ハッシュベースにすれば「同じユーザーは必ず同じバージョン」が保証され、比較もクリーンになります。トラブル対応で「このユーザーのどちらのバージョンを見ていたか」を即座に再現できるのも大きな利点です。
canary_percentage は 1% → 5% → 25% → 50% → 100% と段階的に上げていきます。各ステージで後述の SLO を満たしたら次へ進む運用です。
canary_allowlist は、社内のドッグフーディング用テナントやベータ協力顧客を強制的に新バージョンへ振り分けるために使います。社内で充分観測してから初めて一般ユーザーへ流し始められるので、安全性が一段上がります。
Step 3: ルーティング層と観測ポイントの埋め込み
Hono でルーティング層を書きます。ここで「version」をメトリクスに必ず含めることが肝心です。これがなければ本番で事故ったときにどのバージョンが原因か特定できません。
// routes/support.ts
import { Hono } from "hono";
import Anthropic from "@anthropic-ai/sdk";
import { trace, metrics } from "@opentelemetry/api";
import { runAgent, type AgentVersion } from "../agent/versions";
import { resolveVersion } from "../flags/resolver";
import { getFlag } from "../flags/store";
const app = new Hono();
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const meter = metrics.getMeter("agent.support");
// version タグ付きのメトリクス
const latency = meter.createHistogram("agent.latency", { unit: "ms" });
const errors = meter.createCounter("agent.errors");
const thumbsDown = meter.createCounter("agent.thumbs_down");
app.post("/chat", async (c) => {
const body = await c.req.json<{ userId: string; tenantId: string; input: string }>();
const flag = await getFlag("agent.customer_support");
const { version, channel } = resolveVersion(flag, {
userId: body.userId,
tenantId: body.tenantId,
flagKey: "agent.customer_support",
});
const span = trace.getTracer("agent").startSpan("agent.run", {
attributes: { "agent.version": version, "agent.channel": channel },
});
const start = performance.now();
try {
const result = await runAgent(version as AgentVersion, body.input, client);
latency.record(performance.now() - start, { version, channel, status: "ok" });
return c.json({ version, channel, result });
} catch (err) {
// 本番では canary の失敗を stable にフェイルオーバーする選択肢もあるが、
// SLO を汚染しないよう、canary の失敗は canary で計上する
errors.add(1, { version, channel, error_type: (err as Error).name });
latency.record(performance.now() - start, { version, channel, status: "error" });
throw err;
} finally {
span.end();
}
});
// フィードバックエンドポイント — thumbs_down を SLO の一部に使う
app.post("/feedback", async (c) => {
const { version, channel, rating } = await c.req.json();
if (rating === "down") thumbsDown.add(1, { version, channel });
return c.json({ ok: true });
});
export default app;
落とし穴: ユーザーのフィードバック(thumbs down)は、最もリアルな品質信号です。しかしラグが大きいため、即時の自動ロールバックには向きません。後述の SLO では「即時信号(エラー率・レイテンシ)」と「遅延信号(満足度)」を分けて扱います。
もう一つ: version をレスポンス JSON にも含めています。これは事故発生時の調査で「どのバージョンがこの応答を返したか」を後から追うためです。ユーザーに見せる必要がないなら debug フラグ付きの時だけ返す実装にしてもよいでしょう。
Step 4: SLO ベースの自動ロールバック
ここが多くのチームが実装を諦めるポイントですが、ここを自動化しない限り段階リリースの意味が半減します。次のような二段構えで判断します。
- 即時 SLO(60 秒ウィンドウ): エラー率・p95 レイテンシ — 違反したら即ロールバック
- 遅延 SLO(15 分ウィンドウ): 満足度(thumbs_down 比率)・ツール使用の正しさ — 違反したら段階の進行を止めてアラート
// slo/watchdog.ts — Cron で 60 秒ごとに走らせる想定
import { queryMetrics } from "./prometheus";
import { setFlag, getFlag } from "../flags/store";
import { notifySlack } from "./slack";
type Sample = { version: string; errors: number; total: number; p95_ms: number };
const IMMEDIATE_BUDGET = {
error_rate_max: 0.02, // 2%
p95_ms_max: 6000,
};
export async function checkAndRollback(flagKey: string) {
const flag = await getFlag(flagKey);
if (flag.canary_percentage === 0) return; // 既に停止済み
const stable: Sample = await queryMetrics(flag.stable, "60s");
const canary: Sample = await queryMetrics(flag.canary, "60s");
// サンプル数が少なすぎると誤判定するため、下限を設ける
if (canary.total < 50) return;
const canaryErrRate = canary.errors / canary.total;
const stableErrRate = stable.total > 0 ? stable.errors / stable.total : 0;
const tooManyErrors = canaryErrRate > IMMEDIATE_BUDGET.error_rate_max;
const tooSlow = canary.p95_ms > IMMEDIATE_BUDGET.p95_ms_max;
const regressedVsStable =
canaryErrRate > stableErrRate * 2 && canary.total >= 200;
if (tooManyErrors || tooSlow || regressedVsStable) {
await setFlag(flagKey, { ...flag, canary_percentage: 0 });
await notifySlack(
`🚨 ${flagKey} canary=${flag.canary} をロールバックしました: ` +
`errRate=${(canaryErrRate * 100).toFixed(2)}%, p95=${canary.p95_ms}ms`,
);
}
}
なぜ stable との比較を入れるのか: 単純な絶対閾値だけだと、外部 API の障害で両方悪化した場合にカナリアだけをロールバックして事態を悪化させる恐れがあります。stable との相対比較を入れることで「世界全体が悪いのか、カナリアだけが悪いのか」を切り分けられます。
regressedVsStable の条件は「エラー率が stable の 2 倍以上、かつサンプル数 200 以上」としています。この下限はビジネスの受容度によって調整してください。高クリティカルなサポートエージェントなら 1.5 倍、実験的なアシスタントなら 3 倍などが目安になります。閾値を一度決めたら config/slo.yaml に書き出してコードから分離するのがおすすめです。閾値を変えるたびにデプロイするのは運用の足かせになります。
Step 5: 自動昇格(Progressive Promotion)
SLO を満たし続けたカナリアを、自動で次の割合まで引き上げる仕組みも入れておくと運用が劇的に楽になります。ただし、昇格は即時ではなく遅延信号も確認した上で行う点が肝心です。
// slo/promoter.ts — 15 分ごとに走らせる
import { queryMetrics, querySatisfaction } from "./prometheus";
import { setFlag, getFlag } from "../flags/store";
import { notifySlack } from "./slack";
const STAGES = [1, 5, 25, 50, 100]; // %
export async function tryPromote(flagKey: string) {
const flag = await getFlag(flagKey);
if (flag.canary_percentage === 0 || flag.canary_percentage === 100) return;
const canary = await queryMetrics(flag.canary, "15m");
const sat = await querySatisfaction(flag.canary, "15m"); // thumbs_down 比率
// 満足度の悪化がないことを確認
if (sat.thumbs_down_rate > 0.05) {
await notifySlack(
`⏸ ${flagKey} 昇格を停止: thumbs_down=${sat.thumbs_down_rate.toFixed(3)}`,
);
return;
}
// 十分なサンプルを観測したか
if (canary.total < 500) return;
const idx = STAGES.indexOf(flag.canary_percentage);
const next = STAGES[idx + 1];
if (!next) return;
await setFlag(flagKey, { ...flag, canary_percentage: next });
await notifySlack(`✅ ${flagKey} を ${next}% へ昇格`);
}
100% に到達したら、次のリリースサイクルで stable を新バージョンに書き換えてカナリアをクリアします。これが完了したら、旧バージョンのプロンプト・ツール定義をコードから削除しても安全です。古いバージョンは恩を感じるほど長く残してはいけません。残しておくと依存関係が増え、次のリリースの足を引っ張ります。
Step 6: シャドウ実行で「破壊的変更」を安全に検証する
ツール呼び出しの挙動を変えたい・新しいモデルへ切り替えたいなど、出力比較が必要な場合は、シャドウ実行を挟むと安心です。ユーザーには stable の応答を返しつつ、裏で canary も走らせて差分だけ記録するパターンです。
// agent/shadow.ts
import { runAgent, type AgentVersion } from "./versions";
import type Anthropic from "@anthropic-ai/sdk";
import { recordDiff } from "./shadow_store";
export async function runWithShadow(
primary: AgentVersion,
shadow: AgentVersion,
input: string,
client: Anthropic,
) {
const [primaryResult, shadowResult] = await Promise.allSettled([
runAgent(primary, input, client),
runAgent(shadow, input, client),
]);
// シャドウ結果はユーザーに返さず、差分だけ保存する
if (
primaryResult.status === "fulfilled" &&
shadowResult.status === "fulfilled"
) {
await recordDiff({
input,
primary: primaryResult.value,
shadow: shadowResult.value,
});
}
if (primaryResult.status === "rejected") throw primaryResult.reason;
return primaryResult.value;
}
注意: シャドウ実行は API コストがほぼ倍になります。10% のリクエストにだけ適用するなど、サンプリングを前提にしてください。また、ツール呼び出しが副作用を伴う場合は、シャドウ側は dry_run: true を付けて外部への書き込みを抑制する設計が必須です。このあたりは anthropic-ai/agent-sdk の tool definition でドライラン用のフックを設けると綺麗に整理できます。
Step 7: ダッシュボードとチーム運用
自動化だけでは安心できないので、人間が見るダッシュボードと週次の振り返りもセットで用意します。私が必ず作っているのは次の 3 枚です。
- Release Status: 現在のカナリア状況(どの version が何% に入っているか、次の昇格まで何分か、直近の SLO 違反数)
- Quality Diff: stable と canary の比較(エラー率・p95・thumbs_down・ツール使用分布の差分)
- Cost Diff: バージョンごとの 1 リクエスト平均トークン数とドル換算コスト(モデル変更時にコストが跳ねないか監視)
週次ミーティングでは、このダッシュボードを起点に「なぜこの変更は失敗したか/成功したか」を 1 つだけ深掘りします。これを積み重ねていくと、リリースごとの評価セット・閾値・ステージ間隔がプロダクトに最適化されていきます。人間が介在するのは「数値を見て意思決定する」ところだけにとどめ、振り分けやロールバックは機械に任せる、という線引きが運用を持続可能にします。
マルチテナント環境での追加考慮点
B2B SaaS のようにテナントごとに契約条件が異なるプロダクトでは、テナント単位での制御が欠かせません。Feature Flag に tenant_overrides フィールドを足し、特定テナントだけ canary を遅らせる・あるいは先行投入するといった運用が可能になります。
// flags/resolver.ts に追加
if (flag.tenant_overrides?.[ctx.tenantId]) {
const override = flag.tenant_overrides[ctx.tenantId];
return { version: override.version, channel: override.channel };
}
たとえば、エンタープライズ契約で「新バージョンは事前通告の上で適用」という約束があるテナントは、一般公開が 100% になった後も tenant_overrides で旧バージョンに固定しておけます。契約の約束が技術的に守られる保証があると、営業チームの安心感が段違いに変わります。
また、テナントごとのエラー率を tenant_id タグ付きで集計しておくと、「特定テナントだけカナリアで詰まっている」ことが一瞬で見えます。この可観測性があるかどうかが、エスカレーション対応のスピードを数倍変えます。
よくある落とし穴
私自身が踏んだ罠を、先にお伝えしておきます。
- プロンプトキャッシュが刺さらなくなる: 新バージョンに差し替えるとシステムプロンプトが変わるため、プロンプトキャッシュのヒット率が一時的に落ちます。カナリア段階ではコスト・レイテンシが両方悪化するので、SLO 閾値を一時的にゆるめるか、キャッシュが暖まるまでの初期バーストを許容する設計が必要です
- ツールの副作用がロールバックできない: チケット作成・メール送信など外部状態を変えるツールを追加した場合、ロールバックしても副作用は残ります。新ツールにはフラグを別に切り、ドライラン(出力だけ確認して実行しない)を経由してから本番有効化するのが安全です
- 評価セットと本番分布の乖離: 評価では出ないロングテールのリクエストが本番では上位を占めることがあります。本番ログから匿名化サンプルを抽出して評価セットに追加する運用を、リリースサイクルに組み込みましょう
- カナリア割合が小さすぎて統計的に判定できない: 1% カナリアでは、低頻度バグを検出するのに数日かかります。リスクが低い変更(プロンプトの微修正)は 25% からスタートするなど、変更の種類に応じて初期割合を変えてよいでしょう
- Feature Flag の KV が単一障害点になる: Flag 取得に毎回 KV を叩くと、KV 障害でエージェントが止まります。必ずローカルキャッシュ(30 秒 TTL 程度)を挟み、fallback 値(最後に取得できた値)を用意してください
- バージョン ID が人間に読めない:
v1234 のようなハッシュだと事故調査が辛くなります。v2026.04.22-shorten-tone のように日付 + 変更要約を名前に入れるだけで、調査スピードが明確に上がります
実プロダクトへの組み込み例
実際に私が運用している顧客サポートエージェントでは、以下のステージングで回しています。
- 月・水・金: 新バージョンを 5% へ投入、金曜夜まで放置して SLO 経過観察
- 木: SLO 良好なら 25% へ昇格、不芳ならロールバック後に原因分析
- 週末: 50% で週末トラフィックを観察(日本以外の地域・営業時間外の挙動検証)
- 翌週月曜: 100% 昇格、stable 更新、旧コードの整理
このサイクルに落とし込むことで、「デプロイで緊張する」時間が劇的に減りました。ロールバックは自動、昇格はデータ駆動、人間は週1回の原因分析に集中できる、という構造です。最初の 1 つを自動化できれば、2 つ目以降は横展開のスピードで広がっていきます。
週末のトラフィック観察を入れているのは、平日と週末でユーザー層や問い合わせ内容の分布が大きく違うプロダクトが多いためです。金曜夜に 50% へ昇格させ、月曜朝に 100% にするのは、週末の違う分布で十分なサンプルを取るための工夫です。このあたりは読者のプロダクト特性に合わせてアレンジしてください。
さらに多言語プロダクトの場合、言語ごとに SLO を分けて監視することをおすすめします。全体では問題がなくても、英語では問題ないのに日本語だけ精度が落ちていた、というケースを何度か見てきました。version タグに加えて locale タグもメトリクスに含めておくと、この切り分けが一瞬で済みます。
次の一歩
まずは 1 つのエージェントの runAgent() をバージョン引数付き関数に書き換えるところから始めてください。Feature Flag の実装はすぐ後でも構いません。バージョンを「コードの中の switch」から「設定値」に引き剥がすことが、プログレッシブデリバリーの土台になります。30 分もかからず完了するはずです。
この土台さえできれば、その後のカナリア実装・SLO 監視・自動ロールバックは、少しずつ積み上げていくだけで機能していきます。一気に全部を作る必要はありません。「設定でバージョンを切り替えられる状態」まで辿り着いたその日から、本番への自信の持ち方が変わるはずです。
もし導入の壁として「組織の合意形成が難しい」と感じるなら、最初は自分一人で使う社内ツールから始めるのがおすすめです。社内ツールで「昨日までは当たり前だった挙動が今日は変わっているかもしれない」という不安感をチームに体験してもらえると、顧客向けプロダクトに広げる合意が一気に取りやすくなります。技術の話だけでなく、心理的な納得感を作ることも、この仕組みが定着するには大事なステップだと感じています。
エージェントの観測性やコストの深掘りが必要な方は、Claude API OpenTelemetry による AI 可観測性ガイド と Claude Agent SDK のプロダクション マルチエージェントシステム構築ガイド も合わせて読むと、この仕組みの全体像がより明確になります。