「AI エージェントを本番に出したいけれど、精度が心配で踏み切れない」— この相談を受けるのは、もう何度目か分かりません。ログを見せてもらうと、エンジニアはみなオフライン評価のスコアを出し尽くし、スタッフの手元で動かし、同僚に触ってもらい、それでも「本番トラフィックで壊れないか」の最後の一歩を越えられずにいます。
私自身、Claude Agent SDK を使って業務アシスタントを本番投入したとき、同じ壁にぶつかりました。評価データセットでは高い精度が出るのに、本番リクエストには評価では想定しなかったパターンが必ず混ざります。ユーザーに影響を与えずに「本番と同じ入力」を食わせて精度を測る手段が必要でした。その答えがシャドウモードです。
ここではClaude Agent SDK で構築したエージェントを、既存の処理に影響を与えないまま本番トラフィック上で並走させ、結果を記録・比較し、段階的に昇格させるまでの実装パターンを共有します。コード例はすべて、実際のプロダクトで運用できる粒度で書いています。
AI エージェントの本番投入で直面する「事前検証の壁」
オフライン評価は万能ではありません。評価データセットは過去のログや合成データから作られ、本番トラフィックの「裾野」を捉え切れないからです。特にエージェントのように複数ツールを呼び分ける構造では、入力の組み合わせ爆発が起き、評価データの10倍以上のパターンが本番で発生することも珍しくありません。
さらに評価指標そのものが曖昧になりがちです。「チャットの応答が正しい」とは何かを一意に決めるのは難しく、人手評価に頼れば時間とコストが跳ね上がります。私が関わった案件では、評価100件に対して2日の工数がかかり、リリース判断が常にボトルネック化していました。
ここで伝統的な A/B テストに逃げたくなりますが、エージェントの場合は初動のリスクが大きすぎます。たとえば「メール返信をエージェントが代行する」機能で1% のトラフィックに割り当てた結果、その1% のユーザーに見当違いの返信が届いたら、信頼回復は容易ではありません。リスクが顕在化してから切り戻す A/B テストは、この領域では遅すぎるのです。
必要なのは「本番と同じ入力を食わせるが、ユーザーには影響を与えない」第3の選択肢です。それがシャドウモード — 既存の処理を正とし、エージェントを並走させて結果のみ記録する構造です。
シャドウモードの設計思想
シャドウモードの核にある考え方は単純で、「判定はするが適用はしない」というものです。既存のワークフロー(ルールベースでもヒト処理でも)が本番の結果を決め、エージェントはそれと同じ入力を受け取って独自に判断し、判断結果だけをログに残します。
この構造には3つの利点があります。第一に、ユーザー体験に影響がゼロであること。エージェントが誤った判断をしても、本番の出力には反映されません。第二に、本番と同じ入力分布に触れられること。テストデータではなく、ユーザーから今この瞬間に届いているリクエストを使って精度を測れます。第三に、オフライン評価では得られない「実運用データに基づく改善ループ」を回せること。乖離したケースを次のプロンプト改善や fine-tune データに直接フィードバックできます。
一方で代償もあります。最大の代償はコストです。本番トラフィックの全量(または大きなサンプル)にエージェントを走らせるため、Claude API の利用料が倍増します。Claude Haiku 4.5 を使った軽量タスクでも、月1,000万リクエストのシステムで並走させれば、月間数十万円のオーダーになることがあります。これをどう抑えるかは後半の落とし穴セクションで扱います。
もう一つの代償は観測可能性の負荷です。エージェントの判断を記録すると、1リクエストあたり数 KB のログが発生します。既存のログ基盤が耐えられるか、ストレージコストが許容範囲か、事前の試算が必要です。
設計思想としてもう一つ強調しておきたいのが、「シャドウは評価基盤ではなく、運用基盤である」という点です。オフライン評価はモデルや学習の性能を測る目的で行われますが、シャドウは「この会社の本番運用でどう振る舞うか」を測る仕組みです。評価データセットには現れない祝日の問い合わせ波、顧客の口調の変化、季節性を持つ問い合わせカテゴリなど、運用特有の要因がすべて入ってきます。この違いを意識すると、シャドウで集めたデータを評価データセットに逆流させる改善サイクルが回り始めます。
私はこの設計を「既存プロセスへの無侵襲(non-invasive)な観測」と呼んでいます。本番の挙動を一切変えないまま、エージェントがいた場合どうなったかを仮想的に記録する — この性質が、本番投入前の最終検証として機能します。
最小実装 — 既存処理と並走させる
まずは動くコードから始めます。次のコードは、カスタマーサポートのチケット分類を例にした最小実装です。既存の分類(ここでは legacyClassify)は変えず、Claude Agent SDK をシャドウで走らせて結果を記録します。
// shadow-runner.ts
// 目的: 既存の分類関数 legacyClassify を正としつつ、
// Claude Agent SDK のエージェントを同じ入力で並走させ、
// 結果だけを記録する(本番出力には影響を与えない)
import Anthropic from "@anthropic-ai/sdk";
import { recordShadowResult } from "./shadow-logger";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
type Ticket = { id: string; subject: string; body: string };
type Category = "billing" | "technical" | "account" | "other";
export async function classifyWithShadow(
ticket: Ticket,
legacyClassify: (t: Ticket) => Promise<Category>,
): Promise<Category> {
// 1. 本番の結果は legacy を正とする(最優先・失敗させない)
const legacyPromise = legacyClassify(ticket);
// 2. シャドウは非同期に走らせ、本番のレイテンシには影響させない
// Promise.allSettled にせず、fire-and-forget で切り離す
runShadow(ticket, legacyPromise).catch((err) => {
// シャドウ側のエラーは本番に伝播させない
console.warn("[shadow] suppressed error:", err?.message);
});
return legacyPromise;
}
async function runShadow(ticket: Ticket, legacyPromise: Promise<Category>) {
const startedAt = Date.now();
try {
const response = await client.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 200,
system:
"あなたはチケット分類エージェントです。" +
"billing / technical / account / other のいずれか一語のみ返してください。",
messages: [
{
role: "user",
content: `件名: ${ticket.subject}\n本文: ${ticket.body}`,
},
],
});
const shadow = normalizeCategory(
response.content[0].type === "text" ? response.content[0].text : "",
);
const legacy = await legacyPromise;
await recordShadowResult({
ticketId: ticket.id,
legacy,
shadow,
match: legacy === shadow,
latencyMs: Date.now() - startedAt,
usage: response.usage, // input_tokens / output_tokens
});
} catch (err) {
// タイムアウト・429・5xx などを分類して記録する
await recordShadowResult({
ticketId: ticket.id,
legacy: await legacyPromise.catch(() => "other"),
shadow: null,
match: null,
latencyMs: Date.now() - startedAt,
error: normalizeError(err),
});
}
}
function normalizeCategory(raw: string): Category {
const lower = raw.trim().toLowerCase();
if (["billing", "technical", "account", "other"].includes(lower)) {
return lower as Category;
}
// 予想外の出力を other に丸めるのは安全策だが、集計側で「フォーマット違反」
// を別カウントするために、もとの文字列もログに残す設計が望ましい
return "other";
}
function normalizeError(err: unknown) {
if (err instanceof Error) {
return { name: err.name, message: err.message };
}
return { name: "Unknown", message: String(err) };
}
このコードで一番重要なのは fire-and-forget の判断です。await Promise.all([legacy, shadow]) としてしまうと、エージェント側の遅延(特に Extended Thinking を有効にした場合の数秒〜数十秒)がそのまま本番レイテンシに乗ってしまいます。本番レイテンシを1ミリ秒も犠牲にしない、というのがシャドウモードの絶対原則です。
期待する動作としては、本番の classifyWithShadow は従来通り legacyClassify の結果を同じ速度で返し、シャドウのログが数秒遅れてデータベースや観測基盤に流れ込むようになります。
記録すべきデータと比較ロジック
何を記録するかが、後の分析の質を決めます。私は最低限次の6項目を記録することを推奨しています。
- 入力のハッシュ(原文は PII の観点で避け、分析時に必要なら逆引きする設計)
- 既存処理(正)の結果
- エージェントの結果(フォーマット違反・エラーも含む)
- 一致・不一致のフラグ
- レイテンシとトークン消費(コスト試算用)
- エージェントのバージョン(プロンプトのハッシュまたはタグ)
特に6番が後で効きます。プロンプトを更新したのに古いログと混ざると、精度計算が狂います。私は prompt_hash: sha256(system + templateVersion) を必ずログに含めるようにしています。
比較ロジックは分類タスクのような離散値なら単純ですが、生成タスク(要約・返信など)では完全一致が使えません。この場合は「人間が判断すれば同じクラスに入るか」を近似する指標を複数用意します。
# shadow_compare.py
# 目的: 生成系エージェントの出力と既存処理の出力を比較する指標を計算する
# 完全一致ではなく、複数のシグナルを組み合わせて乖離を検知する
from dataclasses import dataclass
from typing import Optional
import difflib
@dataclass
class ComparisonResult:
text_similarity: float # 0.0-1.0
length_ratio: float # 長さの比率(1.0で同じ)
sentiment_match: Optional[bool] # 感情極性の一致(分析できた場合のみ)
structural_match: bool # 見出し・リスト構造の一致
flags: list[str] # 人間確認が必要と判断した理由
def compare_shadow_vs_legacy(shadow: str, legacy: str) -> ComparisonResult:
"""
シャドウ出力と既存出力を多角的に比較する。
単一スコアではなく、複数の軸で差分を取ることで
後のレビュー担当者が「どこが違うか」を絞り込みやすくなる。
"""
flags: list[str] = []
# 1. 表層の類似度(テキスト差分率)
text_sim = difflib.SequenceMatcher(None, shadow, legacy).ratio()
if text_sim < 0.3:
flags.append("very_different_text")
# 2. 長さの比率 — 極端に短い/長い応答は要注意
shadow_len = max(len(shadow), 1)
legacy_len = max(len(legacy), 1)
length_ratio = shadow_len / legacy_len
if length_ratio < 0.3 or length_ratio > 3.0:
flags.append("length_anomaly")
# 3. 構造の一致 — 見出しや箇条書きの数が合うか
structural_match = (
shadow.count("\n-") == legacy.count("\n-")
and shadow.count("\n#") == legacy.count("\n#")
)
if not structural_match:
flags.append("structural_diff")
# 4. 感情極性の一致 — 返信の「お詫び/案内」など方向が違うと致命的
sentiment_match = None
# 実装は外部ライブラリやモデル呼び出しに委ねる(この関数外で行う)
return ComparisonResult(
text_similarity=text_sim,
length_ratio=length_ratio,
sentiment_match=sentiment_match,
structural_match=structural_match,
flags=flags,
)
if __name__ == "__main__":
# 期待する出力例:
# ComparisonResult(text_similarity=0.72, length_ratio=1.1, ...)
r = compare_shadow_vs_legacy(
shadow="## ご案内\nご請求について、次の通り対応いたします。\n- 返金可能です",
legacy="## ご案内\nご請求について、以下の通り対応します。\n- 返金いたします",
)
print(r)
このコードのポイントは、単一のスコアに圧縮しないことです。「7割一致」という数字だけ見ると問題ないように見えても、長さが半分なら重要な情報が抜けている可能性があります。複数の軸で記録しておけば、後で「乖離はあるが方向性は合っている」ケースと「方向性ごと外している」ケースを分離できます。
よくある4つの落とし穴
私自身が踏んだ落とし穴、同業者から聞いた事例を合わせて、シャドウモード導入時に詰まりやすいポイントを整理します。
1. コスト爆発 — 全量並走は危険
最初に失敗するのがここです。本番の全トラフィックに対してエージェントを走らせると、単純に API コストが2倍以上になります。月1,000万リクエストのシステムで Claude Sonnet 4.6 を並走させて、月額200万円を超えた事例を見たこともあります。
解決策は、ランダムサンプリング率を設ける設計にすることです。環境変数や KV で SHADOW_SAMPLE_RATE=0.05(5%)のように制御し、確率的にシャドウを発火させます。5% でも、月50万件の判断データが集まれば統計的には十分です。
const sampleRate = Number(process.env.SHADOW_SAMPLE_RATE ?? "0.05");
if (Math.random() < sampleRate) {
runShadow(ticket, legacyPromise).catch(() => {});
}
私はこれに加えて、トラフィック種別ごとに率を変えることを強く推奨します。たとえば「新規ユーザー」「既存ユーザー」でカテゴリの分布が違うなら、両方から同程度サンプルを取るよう層別化します。
2. ログ汚染 — 既存の観測基盤にノイズが混じる
シャドウのエラーや失敗判定を既存の ERROR ログに流し込むと、本番のエラーダッシュボードが壊れます。運用チームが慌てて対応しようとするので、信頼関係まで壊れます。
シャドウのログは専用の名前空間(shadow.* など)かストリームに分離するのが正解です。Datadog なら専用サービス名を割り当て、OpenTelemetry ならリソース属性で environment: shadow を明示します。本番アラートがシャドウのエラーで発火しないよう、最初の設定でしっかり分けてください。
3. 非決定性の取り扱い
Claude は温度を 0 にしても完全決定的ではありません。同じ入力で1ヶ月前と今日で結果が違うこともあります。これを「精度が下がった」と誤認しないよう、比較のベースラインは常に「同時刻の既存処理の結果」とし、時系列での直接比較は避けます。
また、プロンプトを更新した日付は必ず記録し、精度グラフに注釈として重ねます。プロンプト変更直後の2〜3日は精度がぶれるのが普通なので、そこでロールバック判断を急がないことも大切です。
4. レイテンシの暗黙漏洩
fire-and-forget にしたつもりでも、エージェントの HTTP コネクション数がプロセス上限に達すると本番リクエストも待たされる、ということがあります。Node.js の Agent インスタンスを別にするか、別プロセス(別の小さな Worker)でシャドウを受けるのが安全です。
私はシャドウを「本番と同じサービス内で走らせない」ルールにしています。Cloudflare Workers + Queue のようにメッセージを投げて別ワーカーで処理する構成にすれば、本番サービスの CPU・メモリ・接続数を取り合うことがなくなります。
// producer.ts — 本番サービス内では投入のみ
await env.SHADOW_QUEUE.send({
ticket,
legacyResult,
enqueuedAt: Date.now(),
});
// consumer.ts — 別ワーカーで実処理
export default {
async queue(batch: MessageBatch, env: Env) {
for (const message of batch.messages) {
try {
await runShadow(message.body.ticket, message.body.legacyResult);
message.ack();
} catch (err) {
message.retry({ delaySeconds: 60 });
}
}
},
};
この分離だけで、本番側のレイテンシは完全にシャドウから切り離されます。運用開始から2〜3ヶ月経って本番トラフィックが倍になったときにも、本番性能が巻き込まれる心配がなくなります。
トラフィック昇格戦略 — シャドウ → カナリア → フル
シャドウモードで十分なデータが貯まったら、段階的に本番権限を移します。私が実際に使っている閾値はこうです。
- シャドウ段階(1〜4週間): 一致率(または人手レビュー合格率)が 92% 以上で2週間連続安定
- カナリア段階(1〜2週間): 1% → 5% → 10% とトラフィックを広げ、CSAT・クレーム率が悪化しないか監視
- フル展開: 10% 到達後、業務指標(解決時間・再問い合わせ率)が旧来比で同等以上
この数字は業態によって調整が必要です。B2C 大量トラフィックなら 92% でも十分ですが、金融・医療など誤りの代償が大きいドメインでは 98% を超えるまで昇格しません。正解は一つではなく、「失敗のコスト × 見込み数」から逆算して決めるものです。
カナリア段階での重要なルールは、シャドウを止めないことです。カナリアの1% に昇格したエージェントの判断は本番に適用されますが、残りの99% は引き続きシャドウで走らせ、同じ基準で一致率を測り続けます。こうすることで、カナリア昇格後の「サンプリングバイアス」に気づけます。
昇格判断は人手レビューで最終確定します。自動昇格は魅力的ですが、私は推奨しません。なぜなら、昇格の意思決定はエンジニアだけでなく、CS 部門・法務・経営陣の合意が必要な場合が多く、数値だけでは決めきれないからです。数値は「昇格会議を開く引き金」であって、「昇格そのもの」ではないと割り切った方がスムーズに進みます。
運用指標とアラート設計
シャドウモードは記録しただけでは価値がありません。継続的に可視化・アラート化して、変化に気づける状態を作る必要があります。私は次の4つを必ず計器盤に載せています。
第一に、時系列の一致率です。日次で一致率(または人手判定の合格率)を折れ線で表示し、プロンプト変更・モデル変更の日付を縦線で重ねます。グラフを見ただけで「何が原因で精度が動いたか」が推測できる状態を作ります。
第二に、カテゴリ別の乖離マップです。たとえば「billing の一致率は 98% だが technical は 78%」のように、カテゴリごとにブレがあることが普通です。弱いカテゴリを特定し、そこにプロンプト改善や few-shot を集中させます。
第三に、コスト対効果の追跡です。シャドウ段階ではコストが純粋な負債ですが、カナリア・フル展開後は「省けたオペレーター工数」と比較します。月額 30 万円のシャドウコストで、月 500 時間の工数削減が測定できれば、十分ペイしているか経営会議で判断できます。
第四に、フォーマット違反率です。エージェントが期待外の文字列を返した割合(最初のコード例で other に丸めていた部分)を別途集計します。これが 5% を超えたら、プロンプトの出力フォーマット指示を見直す合図です。
アラートは「一致率が3日連続で閾値を下回る」「フォーマット違反率が急増する」「コストが予算の 120% を超える」の3つを最低でも設定してください。単発の揺れでアラートを出すと狼少年になるので、必ず複数日連続か移動平均で判定します。
実例 — 1ヶ月動かして得た学び
理論だけでなく、実際に1ヶ月シャドウを回したときに見えたことを共有します。サンプルサイズは月間チケット約4万件、シャドウは 10% サンプリングで走らせました。
最初の週、一致率は 76% でした。期待より低く、「これはダメかも」という空気がチームに流れました。しかし、乖離ケースを 100 件ほど人手で精査したところ、実はエージェントの判断の方が適切なケースが約 35% 含まれていたのです。旧来のルールベース分類で other に落ちていたチケットの多くを、エージェントは technical や billing に正しく振り分けていました。この発見で、一致率そのものを追うのではなく「人手判定で見たときの正解率」を別途算出する運用に切り替えました。
2週目以降はプロンプトを調整しながら、週次で次のダッシュボードを眺めていました。
- 全体一致率(週次移動平均)
- カテゴリ別一致率(どのカテゴリが弱いか)
- 乖離サンプル 20 件の人手評価(毎週金曜)
- コスト推移(Haiku 4.5 換算で週次)
この「毎週金曜の 20 件レビュー」が最も価値を発揮しました。数字だけ見ていると気づけないパターン — たとえば「件名が極端に短いチケット」「多言語混在のチケット」など — が、人の目を通すことで浮き上がってきます。これをプロンプトの few-shot に追加することで、3週目には一致率が 88%、人手基準の正解率が 95% まで上がりました。
4週目、カナリア昇格の判断会議を開きました。数値は基準を満たしていましたが、法務と CS の両部門から「誤分類時のエスカレーション経路」に懸念が出て、結論は「もう2週間延長」でした。私はこれを「失敗」ではなく「健全な慎重さ」と捉えています。シャドウモードが提供するのは「議論の土台となる数値」であって、「議論を省略する根拠」ではないからです。
実装チェックリストと次のアクション
記事の締めに、自分のシステムで明日から試せる最小手順をまとめます。チェックリスト形式で並べますが、一つずつ確認しながら進めると、1週間程度でシャドウモードが動き始めます。
- 既存の判断処理(関数・エンドポイント)を特定し、入力と出力を型で明示する
- Claude Agent SDK でのエージェント実装を用意する(評価データで8割以上の精度が出ている前提)
fire-and-forget でシャドウを起動するラッパーを作る — 本番レイテンシに影響させないことを必ず確認する
- サンプリング率(初期は 5%)と、専用のログ名前空間を設定する
- 比較結果を保存するストレージ(BigQuery・ClickHouse・DB テーブル)を用意し、プロンプトバージョンも記録する
- 週次で一致率・コスト・乖離事例のトップ 10 を眺める習慣を作る
- 2〜4週間後、一致率が 92% を超えたら昇格会議を設定する
まずは「一致率を毎朝見る」習慣から始めてみてください。本番に影響しない形で、エージェントの本当の実力が日を追って見えてくる体験は、それ自体が開発体験を大きく変えます。
シャドウモードを実装した後の改善ループは、Claude API のコスト最適化を本番で回す実践パターン と、Claude Agent SDK の冪等性設計 を組み合わせると一気に安定します。冪等性の設計を先に入れておくと、シャドウが本番昇格したあとの障害回復が格段に楽になるのでおすすめです。
本記事のシャドウ設計を、エージェント全体のアーキテクチャ観点から位置づけ直すのに役立ちました。