エージェントを更新するたびに胃が痛くなる、という方は多いのではないでしょうか。プロンプトを少し変えただけのつもりが、特定のユーザーで応答品質が大きく崩れていた。モデルを新しくしたら、コストが想定の3倍に跳ね上がっていた。私自身、Antigravity でマルチエージェントを運用するようになってから、こうした「リリース後にしか見えない問題」と何度も向き合ってきました。
A/Bテストやカナリーリリースは有効な手段ですが、エージェント特有の問題があります。出力が確率的で、しかもコストが結果に直接効いてくるため、「実ユーザーに本番トラフィックの5%を見せる」という従来型のカナリーは、想定外の挙動が出た瞬間に体験を壊してしまうのです。
なぜシャドウモードを選ぶのか — A/Bテストとカナリーリリースとの違い
エージェントの段階的リリースには三つの主要な戦略があります。それぞれ目的が異なるため、まず違いを整理しておきます。
カナリーリリースは「実ユーザーの一部に新バージョンを当てて、不具合があれば速やかに切り戻す」手法です。デプロイの失敗を早く検知することに優れていますが、新バージョンが返した応答はそのまま体験となります。チャットボットや創造的な応答を生成するエージェントの場合、品質劣化が即座にUXの劣化につながります。
A/Bテストは「どちらが良いか」を統計的に決めるための仕組みで、両者ともに本番品質であることが前提です。リリース前の検証にはオーバースペックですし、ユーザーに見せる以上、品質に大きな差があると倫理的に問題が生じる場面もあります。
シャドウモードは、新バージョンを本番と完全に並走させますが、応答はユーザーに返しません。ログだけを取り、出力の差分・コスト・レイテンシ・失敗率を比較します。「本番トラフィックの実態に新バージョンが耐えられるか」を、ユーザー体験を一切傷つけずに検証できる、という点で、リリース前の最終チェックとしてもっとも安全な選択肢になります。
私はこのアプローチを好みます。理由は単純で、エージェントの失敗は「実装バグ」よりも「想定外の入力に対する予測不能な応答」のほうが明確に多いからです。これは単体テストでは捕まえられず、本番トラフィックを浴びせてみないと分かりません。エージェント評価の基本的な枠組みはAntigravity エージェント評価本番フレームワークで扱っていますが、シャドウモードはその上に重ねる「実トラフィック検証層」と捉えると分かりやすいです。シャドウモードは、その「本番トラフィックを安全に浴びせる」ための唯一の現実解だと感じています。
シャドウモードの全体アーキテクチャ — リクエストミラーリングと比較ロジック
設計の要点は四つです。第一に、本番リクエストを受け取った瞬間に複製して新バージョンにも流すこと。第二に、新バージョンの応答はユーザーに返さず、サイドチャネルに記録すること。第三に、両者の出力を構造化したスコアで比較できるようにしておくこと。第四に、新バージョンが暴走した場合に自動で停止する仕組みを入れておくことです。
私の運用では、リクエストパス上では本番エージェントの結果を即座に返し、新バージョン側はバックグラウンドのキューに投入します。これによって、新バージョンのレイテンシやエラーが本番のSLAに干渉することを完全に防げます。エラー処理全般の設計パターンはエージェントのレジリエンス・エラーハンドリング本番設計で詳しく扱っています。Antigravity でエージェントを実装する際、この「本番経路は同期、シャドウ経路は非同期」という分離はほぼ必須だと思っています。
[User Request]
│
▼
[Production Agent v1] ──► [User] (同期・SLAを守る)
│
└──► [Queue] ──► [Shadow Agent v2] ──► [Comparator] ──► [Metrics Store]
(非同期・観測専用)
比較ロジックは、エージェントの種類によって設計が変わります。タスク完了型エージェント(コード生成・分類・抽出など)であれば、出力の構造的等価性をハッシュやスキーマ検証で見ます。会話型エージェントであれば、意味的類似度(埋め込みコサイン類似度)と、応答長・トーンなど補助指標を併用するのが現実的です。すべてを「LLM-as-a-Judge」で評価するのは精度が安定しないため、私は確定的な指標を主軸に置くようにしています。
実装1: リクエストをミラーリングする最小構成(Hono + Cloudflare Workers)
まずは、本番エージェントとシャドウエージェントへリクエストを並行で流す最小構成を作ります。Antigravity から Cloudflare Workers にデプロイする想定で、Hono を使います。本番経路のレイテンシを絶対に増やさないため、シャドウ呼び出しは ctx.waitUntil を使ってバックグラウンドに流します。
// src/worker/shadow-router.ts
import { Hono } from "hono";
type Bindings = {
AGENT_V1_URL: string;
AGENT_V2_URL: string;
SHADOW_QUEUE: Queue;
SHADOW_ENABLED: string; // "true" | "false"
};
const app = new Hono<{ Bindings: Bindings }>();
app.post("/api/agent/run", async (c) => {
const body = await c.req.json();
const requestId = crypto.randomUUID();
const startedAt = Date.now();
// 1. 本番エージェントを同期で呼ぶ — ここがクリティカルパス
let primaryRes: Response;
try {
primaryRes = await fetch(c.env.AGENT_V1_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...body, requestId, version: "v1" }),
});
} catch (err) {
// 本番が落ちた場合はそのまま 503。シャドウは無関係。
console.error("primary_failed", { requestId, err: String(err) });
return c.json({ error: "agent_unavailable" }, 503);
}
const primaryJson = await primaryRes.clone().json<unknown>();
// 2. シャドウは ctx.waitUntil でバックグラウンド投入(本番に絶対干渉させない)
if (c.env.SHADOW_ENABLED === "true") {
c.executionCtx.waitUntil(
enqueueShadow(c.env.SHADOW_QUEUE, {
requestId,
startedAt,
request: body,
primaryResponse: primaryJson,
}).catch((err) => {
// シャドウキュー投入失敗はログだけで握りつぶす
console.error("shadow_enqueue_failed", { requestId, err: String(err) });
}),
);
}
return new Response(primaryRes.body, {
status: primaryRes.status,
headers: primaryRes.headers,
});
});
async function enqueueShadow(queue: Queue, payload: unknown): Promise<void> {
await queue.send(payload, { contentType: "json" });
}
export default app;
ポイントは三点です。waitUntil によりシャドウ処理がレスポンスを遅らせないこと、シャドウキュー投入失敗時に握りつぶすことで本番経路への影響を完全に遮断していること、SHADOW_ENABLED 環境変数でシャドウ全体を瞬時に止められること、です。最後の点が後述するキルスイッチの第一段になります。
期待動作としては、本番レスポンスが従来通り 100〜400ms 程度で返り、シャドウ処理はキューに溜まって別ワーカーで非同期に消化されます。レスポンスヘッダや本文に「シャドウが動いた痕跡」が一切残らないことが重要で、ここを守らないとブラウザキャッシュやログに新バージョンの情報が混入してしまいます。
実装2: 出力の差分を計測する — 構造化された比較メトリクス
シャドウキューを処理するワーカー側で、新バージョンを実際に呼んで本番出力と比較します。比較ロジックを汎用化するために、私は次の四つの指標を必ず取得するようにしています。レスポンスのスキーマ妥当性、文字列ハッシュ一致、意味的類似度(埋め込みベース)、補助指標(長さ・トーン推定)の四つです。
// src/worker/shadow-consumer.ts
import { z } from "zod";
const AgentOutputSchema = z.object({
status: z.enum(["ok", "refused", "error"]),
message: z.string(),
citations: z.array(z.string()).optional(),
});
type AgentOutput = z.infer<typeof AgentOutputSchema>;
type Env = {
AGENT_V2_URL: string;
EMBED_URL: string;
METRICS_KV: KVNamespace;
ALERT_WEBHOOK: string;
};
export default {
async queue(batch: MessageBatch, env: Env): Promise<void> {
for (const msg of batch.messages) {
try {
await processOne(msg.body as ShadowJob, env);
msg.ack();
} catch (err) {
// リトライ予算を消費させる
msg.retry({ delaySeconds: 30 });
console.error("shadow_consumer_failed", { err: String(err) });
}
}
},
};
type ShadowJob = {
requestId: string;
startedAt: number;
request: unknown;
primaryResponse: unknown;
};
async function processOne(job: ShadowJob, env: Env): Promise<void> {
const t0 = Date.now();
// 1. シャドウ側で v2 エージェントを呼ぶ(タイムアウトとサーキットブレーカ込み)
const shadowRaw = await callWithTimeout(
env.AGENT_V2_URL,
job.request,
/* timeoutMs */ 8000,
);
const shadowLatencyMs = Date.now() - t0;
// 2. スキーマ検証 — 本番との互換性が壊れていないか
const primaryParsed = AgentOutputSchema.safeParse(job.primaryResponse);
const shadowParsed = AgentOutputSchema.safeParse(shadowRaw);
// 3. 構造化スコアを計算
const score = await computeScore({
primary: primaryParsed.success ? primaryParsed.data : null,
shadow: shadowParsed.success ? shadowParsed.data : null,
embedUrl: env.EMBED_URL,
});
// 4. メトリクスストアに書き込む(KVは月次集計用、別途 R2 や ClickHouse へ流すと尚良し)
await env.METRICS_KV.put(
`shadow:${job.requestId}`,
JSON.stringify({
...score,
shadowLatencyMs,
schemaValidShadow: shadowParsed.success,
schemaValidPrimary: primaryParsed.success,
ts: Date.now(),
}),
{ expirationTtl: 60 * 60 * 24 * 30 },
);
}
async function callWithTimeout(url: string, body: unknown, timeoutMs: number) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`shadow_http_${res.status}`);
return await res.json<unknown>();
} finally {
clearTimeout(timer);
}
}
async function computeScore(args: {
primary: AgentOutput | null;
shadow: AgentOutput | null;
embedUrl: string;
}): Promise<{ exactHash: boolean; cosine: number; lengthDiff: number }> {
const { primary, shadow, embedUrl } = args;
if (!primary || !shadow) {
return { exactHash: false, cosine: 0, lengthDiff: 0 };
}
const exactHash = primary.message === shadow.message;
const cosine = await embedSimilarity(embedUrl, primary.message, shadow.message);
const lengthDiff = Math.abs(primary.message.length - shadow.message.length);
return { exactHash, cosine, lengthDiff };
}
async function embedSimilarity(url: string, a: string, b: string): Promise<number> {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ texts: [a, b] }),
});
if (!res.ok) return 0;
const { vectors } = (await res.json()) as { vectors: number[][] };
return cosine(vectors[0], vectors[1]);
}
function cosine(a: number[], b: number[]): number {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9);
}
このコードでは、なぜタイムアウトを 8 秒に設定しているのかという点が重要です。本番エージェントの平均応答が 1〜2 秒の場合、シャドウは「本番より長くかかってもいいから観測したい」場面と、「本番に近いレイテンシで動くか確認したい」場面があります。私は最初は緩めの 8 秒で全件取得し、安定してきたら本番のp95に揃える形に絞り込みます。最初から厳しくしすぎると、新バージョンの遅延傾向を観測する前にタイムアウトで切り捨ててしまうため、判断材料を失います。
スキーマ検証を必ず通しているのも理由があります。LLM の出力フォーマットが微妙に変わって本番のパース処理を壊すケースは現場で頻発します。シャドウモードでこの「契約破壊」を先に検出できるだけでも、シャドウを入れる価値があります。
実装3: コスト・レイテンシ・失敗率の自律監視と自動シャットオフ
シャドウは便利な一方、放っておくとAPIコストが二重にかかり続けます。観測の基盤としてAntigravity OpenTelemetry 観測パイプラインを併用しておくと、シャドウ特有のメトリクスを既存の観測基盤に統合できて便利です。新バージョンが暴走するとコストが跳ねるリスクもあります。そこで、Cloudflare Cron Triggers で 1 分ごとに集計を回し、閾値を超えたら自動で SHADOW_ENABLED を false に切り替える仕組みを入れています。
// src/worker/shadow-watchdog.ts
type Env = {
METRICS_KV: KVNamespace;
CONFIG_KV: KVNamespace;
ALERT_WEBHOOK: string;
};
export default {
async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {
const samples = await collectRecentSamples(env.METRICS_KV, 1000);
if (samples.length < 50) return; // 最小サンプル数を確保するまで判定しない
const stats = summarize(samples);
// しきい値はサービス特性によって調整する
const tooSlow = stats.p95LatencyMs > 6000;
const tooDivergent = stats.meanCosine < 0.7;
const tooManyErrors = stats.errorRate > 0.1;
if (tooSlow || tooDivergent || tooManyErrors) {
await env.CONFIG_KV.put("SHADOW_ENABLED", "false");
await postAlert(env.ALERT_WEBHOOK, {
title: "🛑 Shadow auto-disabled",
reason: { tooSlow, tooDivergent, tooManyErrors },
stats,
});
console.warn("shadow_killed", stats);
}
},
};
type Sample = {
cosine: number;
shadowLatencyMs: number;
schemaValidShadow: boolean;
};
async function collectRecentSamples(kv: KVNamespace, limit: number): Promise<Sample[]> {
const list = await kv.list({ prefix: "shadow:", limit });
const out: Sample[] = [];
for (const k of list.keys) {
const v = await kv.get(k.name, "json");
if (v) out.push(v as Sample);
}
return out;
}
function summarize(samples: Sample[]) {
const lat = samples.map((s) => s.shadowLatencyMs).sort((a, b) => a - b);
const p95 = lat[Math.floor(lat.length * 0.95)] ?? 0;
const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / Math.max(xs.length, 1);
return {
p95LatencyMs: p95,
meanCosine: mean(samples.map((s) => s.cosine)),
errorRate: samples.filter((s) => !s.schemaValidShadow).length / samples.length,
n: samples.length,
};
}
async function postAlert(url: string, payload: unknown) {
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => undefined);
}
ここで「最小サンプル数 50 件未満なら判定しない」ガードを入れているのは、夜間など低トラフィック時間帯にノイズで誤判定されてシャドウが止まる事故を防ぐためです。私は最初これを入れず、深夜にメトリクスが3件しかない状態で meanCosine = 0.4 という偶然の値が出てシャドウが自動停止し、翌朝原因を追ったことがあります。「サンプル数の下限」は、ロールアウト判断系の自動化では必ず入れるべきガードレールだと思っています。
期待動作としては、meanCosine が 0.7 を下回ったり、p95 レイテンシが 6 秒を超えたり、スキーマ違反率が 10% を超えたりした場合に、即座にシャドウが停止して Slack/Discord に通知が飛びます。これによって、夜中に発生した問題で翌朝コストが跳ねていた、という事態を構造的に防げます。
段階的に本番へ昇格させる判断基準 — シャドウから10%、50%、100%へ
シャドウで十分な信頼性が確認できたら、いよいよ実トラフィックに段階的に昇格させます。私は次の四段階で昇格させるのを基本としています。シャドウ100% → 実トラフィック10% → 50% → 100%。各段階の昇格条件を、定性的でなく数値で固定しておくことが肝です。
シャドウ100% から実トラフィック10% への昇格条件として、私は以下を要求しています。最低 24 時間の連続稼働、サンプル数1万件以上、meanCosine 0.85 以上、スキーマ違反率 1% 未満、p95 レイテンシ本番比 1.3 倍以内、コスト本番比 1.5 倍以内。これらを満たさない限り、勘で昇格判断はしません。
10% から 50% への昇格条件はさらに厳しく、上記に加えてユーザー体験指標(CTRや会話継続率など、プロダクト固有のKPI)が本番と統計的に有意な差がないこと、を加えます。50% から 100% は、48時間以上の安定稼働とインシデントゼロを条件にします。
この段階分けで重要なのは、「上に進む基準」だけでなく「下に戻す基準」も決めておくことです。10% で運用中に meanCosine が 0.7 を割ったら自動でシャドウに戻す、というルールを実装しておけば、夜間の事故にも対応できます。私は基準を別ファイルにまとめて Git 管理し、変更時にレビューを通すようにしています。基準そのものがインフラの一部だと考えると分かりやすいです。
よくある落とし穴と回避策
シャドウモードを実装する際、現場で繰り返し遭遇する落とし穴があります。実体験から得た五つを挙げます。
**第一の落とし穴は副作用のあるシャドウ実行です。**新バージョンが本物の決済APIや本物のメール送信APIを叩いていたら、ユーザーには見えないところで二重請求が起きます。シャドウは必ずサンドボックス環境または冪等キーを差し替えた呼び出しに限定すること。私は runtime: "shadow" というフラグをエージェントの context に必ず入れて、ツール側で副作用を抑止する設計にしています。
**第二の落とし穴はコストの二重計上を予算で気づけないことです。**シャドウが動いている期間、APIコストが2倍に膨らみます。これを把握していないと、月末に予算超過で慌てます。私は新バージョンに専用のAPIキーを発行し、ダッシュボードでシャドウだけのコストが見えるようにしています。シャドウはあくまで一時的な実験であり、コスト計測も独立させるべきです。
第三の落とし穴は LLM-as-a-Judge への過度な依存です。「両方の出力を別の LLM に評価させればよい」と考えがちですが、評価モデル自体が確率的に揺れるため、判断が日によって変わってしまいます。確定的に計算できる指標(スキーマ妥当性、ハッシュ一致、長さ、埋め込みコサイン類似度)を主指標にし、LLM-as-a-Judge は補助に留めるのが現実的です。
**第四の落とし穴はプロダクション側のレイテンシ汚染です。**シャドウ呼び出しを await で同期的に書いてしまうと、本番のレスポンスが遅くなります。waitUntil やキュー経由の非同期化は、設計の根幹に置くべきです。コードレビューでは「シャドウの await 禁止」をルールにしておくと事故が減ります。
**第五の落とし穴はサンプル数下限がない判定ロジックです。**前述の通り、低トラフィック時間帯にノイズで誤判定されてシャドウが自動停止することがあります。判定する前に必ず最小サンプル数を確保し、サンプル数が足りない場合は判定そのものを保留する、というガードを必ず入れてください。
本番運用での実際の使い方 — 私が実装したパターン
最後に、私が個人開発のプロダクトで実際に使っているシャドウモード運用フローを紹介します。あくまで一例ですが、Antigravity でエージェントを更新する際の参考になれば嬉しいです。
新バージョンのエージェント実装は feature ブランチで開発し、PR をマージした時点で v2 ワーカーが自動デプロイされます。同時に CI が SHADOW_ENABLED=true を Cloudflare KV に書き込み、シャドウが自動的に動き始めます。最初の 24 時間はメトリクスのみを観測し、ダッシュボード(Grafana に流しています)で meanCosine と p95 を眺めます。
24 時間経過後、メトリクスが基準を満たしていれば、/api/agent/run のロジックで「リクエストの 10% は v2 を本番として返す」フェーズに入ります。ここから先は通常のカナリーリリースと同じ運用です。何かおかしい兆候が出れば、KV のフラグを書き換えるだけで瞬時に v1 に戻せます。
この仕組みを導入してから、私のプロダクトでは「リリース直後のロールバック」がほぼゼロになりました。問題はシャドウ段階で発見され、ユーザーに見える前に解決されるからです。リリースの精神的負荷が劇的に下がり、結果としてリリース頻度を上げられるようになりました。シャドウモードはエージェント開発のデプロイ慣行を変える力があると、私は感じています。
エージェントの本番運用は、Webアプリよりも一段難しい局面が多いです。出力の不確実性、コスト、レイテンシ、外部API依存、どれを取っても従来のソフトウェアより気を使います。しかし、こうした問題は「本番トラフィックで観測する」以外に解けないことが多いのもまた事実です。シャドウモードは、その観測を安全に行うための、もっとも実践的な答えのひとつだと思っています。
エージェント本番運用の周辺知識
まずは今日、自分のエージェントに runtime フラグを1つ追加してみてください。それだけで、後日シャドウモードを導入する準備の半分は終わったことになります。