プロンプトの構造を JSON Schema で定義する
レジストリのエントリは次のスキーマで定義します。Anthropic API の messages.create に渡す前提の構造です。
// src/prompts/types.ts
import { z } from "zod" ;
export const PromptVariantSchema = z. object ({
id: z. string (). regex ( / ^ v \d + (_ [a-z0-9-] + ) ?$ / ), // 例: v3, v3_a, v3_b
weight: z. number (). int (). min ( 0 ). max ( 100 ), // A/Bテストの配分 (合計100)
status: z. enum ([ "draft" , "canary" , "stable" , "deprecated" ]),
systemPrompt: z. string (). min ( 1 ),
messages: z. array (z. object ({
role: z. enum ([ "user" , "assistant" ]),
content: z. string (),
})),
model: z. enum ([ "claude-opus-4-6" , "claude-sonnet-4-6" , "claude-haiku-4-5-20251001" ]),
maxTokens: z. number (). int (). positive (). max ( 64000 ),
temperature: z. number (). min ( 0 ). max ( 1 ). default ( 0.7 ),
inputSchema: z. record (z. any ()). default ({}),
// ⚠️ 内側に .default() を置いて外側を .default({}) にしてはいけません(後述)
slo: z. object ({
p95LatencyMs: z. number (). positive (),
minSuccessRate: z. number (). min ( 0 ). max ( 1 ),
maxCostPerCallUsd: z. number (). positive (),
}). default ({ p95LatencyMs: 8000 , minSuccessRate: 0.97 , maxCostPerCallUsd: 0.05 }),
createdAt: z. string (). datetime (),
createdBy: z. string (),
});
export const PromptEntrySchema = z. object ({
promptId: z. string (). regex ( / ^ [a-z][a-z0-9-] *$ / ),
description: z. string (),
variants: z. array (PromptVariantSchema). min ( 1 ),
});
export type PromptEntry = z . infer < typeof PromptEntrySchema>;
export type PromptVariant = z . infer < typeof PromptVariantSchema>;
ポイントは weight と status を分離している点 です。weight で配分を、status でリリース段階を独立に制御できます。canary の variant は weight: 5 程度から始め、メトリクスが SLO を満たし続けたら stable に昇格させ、旧 stable を deprecated に下げる運用です。
もうひとつ、4 つの status のうち 実際に配信してよいのは canary と stable の 2 つだけ である、という約束を最初に決めておいてください。この約束をコードの一箇所にまとめておかないと、draft のまま置いた書きかけの variant が本番に出ます。実際に出しました。その顛末は記事の後半に書きます。
// src/prompts/types.ts (末尾に追記)
export const SERVABLE_STATUSES = [ "canary" , "stable" ] as const ;
export const isServable = ( v : PromptVariant ) =>
( SERVABLE_STATUSES as readonly string []). includes (v.status);
レジストリの実装
レジストリは「単一ファイルに全部詰める派」と「prompt ごとに 1 ファイル派」がありますが、私は後者を強く推します。差分レビューしやすく、レビュアーの負荷が低くなるからです。
// prompts/article-summarizer.json
{
"promptId" : "article-summarizer" ,
"description" : "ブログ記事の冒頭を 3 行に要約する" ,
"variants" : [
{
"id" : "v2" ,
"weight" : 95 ,
"status" : "stable" ,
"model" : "claude-haiku-4-5-20251001" ,
"maxTokens" : 256 ,
"temperature" : 0.3 ,
"systemPrompt" : "あなたは熟練の編集者です。読者が記事を開くか判断できる、3 行の要約を作成してください。" ,
"messages" : [
{ "role" : "user" , "content" : "次の記事を 3 行で要約してください: \n\n {{ article }}" }
],
"inputSchema" : { "article" : "string" },
"slo" : { "p95LatencyMs" : 4000 , "minSuccessRate" : 0.98 , "maxCostPerCallUsd" : 0.005 },
"createdAt" : "2026-03-12T09:00:00Z" ,
"createdBy" : "masaki@dolice.design"
},
{
"id" : "v3_canary" ,
"weight" : 5 ,
"status" : "canary" ,
"model" : "claude-haiku-4-5-20251001" ,
"maxTokens" : 256 ,
"temperature" : 0.2 ,
"systemPrompt" : "あなたは熟練の編集者です。読者の興味を引く、最初の 1 行で結論を述べる 3 行要約を作成してください。" ,
"messages" : [
{ "role" : "user" , "content" : "次の記事を 3 行で要約してください。1 行目に結論を必ず置いてください: \n\n {{ article }}" }
],
"inputSchema" : { "article" : "string" },
"slo" : { "p95LatencyMs" : 4000 , "minSuccessRate" : 0.98 , "maxCostPerCallUsd" : 0.005 },
"createdAt" : "2026-04-25T09:00:00Z" ,
"createdBy" : "masaki@dolice.design"
}
]
}
これを TypeScript 側で読み込んで、ビルド時に静的に検証します。
// src/prompts/registry.ts
import fs from "node:fs" ;
import path from "node:path" ;
import { PromptEntrySchema, isServable, type PromptEntry } from "./types" ;
const PROMPTS_DIR = path. join (process. cwd (), "prompts" );
export function loadRegistry () : Map < string , PromptEntry > {
const registry = new Map < string , PromptEntry >();
const files = fs. readdirSync ( PROMPTS_DIR ). filter (( f ) => f. endsWith ( ".json" ));
for ( const file of files) {
const raw = JSON . parse (fs. readFileSync (path. join ( PROMPTS_DIR , file), "utf8" ));
const parsed = PromptEntrySchema. parse (raw);
// 配信対象 (canary / stable) だけを 100 に合わせる。draft を数に入れてはいけない
const totalWeight = parsed.variants
. filter (isServable)
. reduce (( acc , v ) => acc + v.weight, 0 );
if (totalWeight !== 100 ) {
throw new Error ( `[${ parsed . promptId }] 配信対象 weight の合計が 100 ではありません (${ totalWeight })` );
}
const strays = parsed.variants. filter (( v ) => ! isServable (v) && v.weight !== 0 );
if (strays. length > 0 ) {
throw new Error (
`[${ parsed . promptId }] 非配信 variant に weight が残っています: ${ strays . map (( v ) => v . id ). join ( ", " ) }` ,
);
}
registry. set (parsed.promptId, parsed);
}
return registry;
}
weight 合計が 100 にならないとビルドが落ちる、という不変条件を最初から入れておくのが大事です。これを忘れると、A/B テストでサイレントに「どの variant も選ばれない 5%」が発生し、デバッグに数日溶かします。
合計の対象を status !== "deprecated" ではなく isServable にしている点も、地味ですが効きます。前者だと draft の weight が 100 の一部として認められてしまい、「合計は 100 なのでビルドは通るが、書きかけの variant が本番に出ている」という最悪の組み合わせが成立します。
variant の決定ロジック — sticky なハッシュ分岐
A/B テストで一番やってはいけないのは「リクエストごとに乱数で振る」ことです。同じユーザーが同じ会話の中で variant が切り替わると、応答の一貫性が崩れて UX が壊滅します。userId などの安定した識別子を使い、ハッシュで振る sticky な分岐にします。
// src/prompts/selector.ts
import crypto from "node:crypto" ;
import { isServable, type PromptEntry, type PromptVariant } from "./types" ;
function stableHash ( input : string ) : number {
const hash = crypto. createHash ( "sha256" ). update (input). digest ();
const num = hash. readUInt32BE ( 0 );
return num % 100 ;
}
export function selectVariant (
entry : PromptEntry ,
identityKey : string ,
options ?: { forceVariantId ?: string ; disabled ?: ReadonlySet < string > },
) : PromptVariant {
if (options?.forceVariantId) {
const forced = entry.variants. find (( v ) => v.id === options.forceVariantId);
if (forced) return forced;
}
// 配信対象は canary / stable のみ。さらに Guardian が落としたものを除外する
const candidates = entry.variants. filter (
( v ) => isServable (v) && ! options?.disabled?. has ( `${ entry . promptId }:${ v . id }` ),
);
if (candidates. length === 0 ) {
throw new Error ( `[${ entry . promptId }] 配信可能な variant がありません` );
}
// 除外が起きると合計が 100 未満になるため、残った合計へ射影し直す
const totalWeight = candidates. reduce (( acc , v ) => acc + v.weight, 0 );
const scaled = ( stableHash ( `${ entry . promptId }::${ identityKey }` ) / 100 ) * totalWeight;
let cumulative = 0 ;
for ( const v of candidates) {
cumulative += v.weight;
if (scaled < cumulative) return v;
}
return candidates[candidates. length - 1 ] ! ;
}
identityKey は userId 単位だけでなく、${userId}:${experimentName} のように実験を切ると衝突が減る という経験則があります。同じユーザーに対して別の prompt の A/B が同じ振り方をしてしまうのは、実験設計上のノイズになるからです。
disabled を後付けの引数ではなく 選定ロジックの内側 に置いているのが要点です。Guardian が緊急停止のフラグを立てても、Selector がそれを見ていなければ配信は 1 件も減りません。ここは実際に踏み抜いた箇所なので、後半で測定結果とあわせて詳しく書きます。
レンダリングと API 呼び出し
テンプレート変数の埋め込みは Mustache でも自前正規表現でも構いませんが、未代入変数を エラーにする ことだけ徹底します。{{ article }} のままユーザーに届くと致命的です。
// src/prompts/renderer.ts
export function render ( template : string , vars : Record < string , string >) : string {
return template. replace ( / \{\{ \s * ( [a-zA-Z_][a-zA-Z0-9_] * ) \s * \}\} / g , ( _ , key ) => {
if ( ! (key in vars)) {
throw new Error ( `未代入のテンプレート変数: ${ key }` );
}
return vars[key] ! ;
});
}
API 呼び出しは Anthropic SDK をそのまま使います。重要なのは どの variantId で呼んだかをレスポンスとセットで永続化する ことです。
// src/prompts/invoke.ts
import Anthropic from "@anthropic-ai/sdk" ;
import { loadRegistry } from "./registry" ;
import { selectVariant } from "./selector" ;
import { render } from "./renderer" ;
import { recordTelemetry } from "./telemetry" ;
const client = new Anthropic ();
const registry = loadRegistry ();
export async function invokePrompt ( params : {
promptId : string ;
identityKey : string ;
vars : Record < string , string >;
forceVariantId ?: string ;
}) {
const entry = registry. get (params.promptId);
if ( ! entry) throw new Error ( `Unknown promptId: ${ params . promptId }` );
const variant = selectVariant (entry, params.identityKey, {
forceVariantId: params.forceVariantId,
});
const renderedMessages = variant.messages. map (( m ) => ({
role: m.role,
content: render (m.content, params.vars),
}));
const startedAt = Date. now ();
let usage : Anthropic . Messages . Usage | undefined ;
let success = false ;
try {
const res = await client.messages. create ({
model: variant.model,
max_tokens: variant.maxTokens,
temperature: variant.temperature,
system: variant.systemPrompt,
messages: renderedMessages,
});
usage = res.usage;
success = true ;
return {
promptId: entry.promptId,
variantId: variant.id,
output: res.content,
usage,
};
} catch (err) {
success = false ;
throw err;
} finally {
await recordTelemetry ({
promptId: entry.promptId,
variantId: variant.id,
identityKey: params.identityKey,
latencyMs: Date. now () - startedAt,
success,
usage,
});
}
}
finally で recordTelemetry を呼ぶ理由は、エラー時こそ Guardian がロールバック判定で必要とするからです。「成功時だけログを残す」実装をすると、悪化検知が遅れて被害が拡大します。
Telemetry — Cloudflare Workers + D1 への構造化保存
私の運用では、Cloudflare Workers + D1 にメトリクスを書き出しています。ローカル DB を持たないエッジ環境でも、D1 なら集計クエリが SQL で書けて十分に分析に使えます。
// src/prompts/telemetry.ts
import type { Anthropic } from "@anthropic-ai/sdk" ;
interface TelemetryRecord {
promptId : string ;
variantId : string ;
identityKey : string ;
latencyMs : number ;
success : boolean ;
usage ?: Anthropic . Messages . Usage ;
feedbackScore ?: number ;
}
export async function recordTelemetry ( rec : TelemetryRecord ) {
const inputTokens = rec.usage?.input_tokens ?? 0 ;
const outputTokens = rec.usage?.output_tokens ?? 0 ;
const costUsd = estimateCostUsd (rec.promptId, rec.variantId, inputTokens, outputTokens);
// env.DB は wrangler.toml で bind した D1
// @ts-expect-error - グローバルから取得 (Workers 前提)
await env. DB . prepare (
`INSERT INTO prompt_invocations
(prompt_id, variant_id, identity_key, latency_ms, success, input_tokens, output_tokens, cost_usd, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
. bind (
rec.promptId,
rec.variantId,
rec.identityKey,
rec.latencyMs,
rec.success ? 1 : 0 ,
inputTokens,
outputTokens,
costUsd,
new Date (). toISOString (),
)
. run ();
}
function estimateCostUsd ( _promptId : string , _variantId : string , inputTokens : number , outputTokens : number ) {
// モデル別単価表をここに集約 (例: Haiku 4.5)
const inputUsdPerMTok = 0.25 ;
const outputUsdPerMTok = 1.25 ;
return (inputTokens * inputUsdPerMTok + outputTokens * outputUsdPerMTok) / 1_000_000 ;
}
スキーマは最小限で構いませんが、identity_key を必ず保存しておく のが後から効きます。「特定ユーザーで悪化していないか」「ある期間内のリピート率」など、A/B テストの本格的な分析に必要になるからです。
Guardian — SLO 逸脱で自動ロールバック
最後の決め手が、自動ロールバックです。canary variant のメトリクスを 15 分ごとに集計し、SLO を逸脱していたら配信フラグを切る cron を別途走らせます。
// src/prompts/guardian.ts
import { loadRegistry } from "./registry" ;
interface VariantStats {
count : number ;
successRate : number ;
p95LatencyMs : number ;
avgCostUsd : number ;
}
async function fetchStats ( promptId : string , variantId : string , sinceIso : string ) : Promise < VariantStats > {
// @ts-expect-error - グローバルから取得 (Workers 前提)
const result = await env. DB . prepare (
`SELECT
COUNT(*) AS count,
AVG(CASE WHEN success = 1 THEN 1.0 ELSE 0.0 END) AS success_rate,
AVG(cost_usd) AS avg_cost
FROM prompt_invocations
WHERE prompt_id = ? AND variant_id = ? AND created_at >= ?`
)
. bind (promptId, variantId, sinceIso)
. first <{ count : number ; success_rate : number ; avg_cost : number }>();
// p95 は近似 (D1 では PERCENTILE 関数が無いため、ORDER BY + OFFSET で代用)
// @ts-expect-error
const p95Row = await env. DB . prepare (
`SELECT latency_ms FROM prompt_invocations
WHERE prompt_id = ? AND variant_id = ? AND created_at >= ?
ORDER BY latency_ms ASC
LIMIT 1 OFFSET CAST((SELECT COUNT(*) FROM prompt_invocations
WHERE prompt_id = ? AND variant_id = ? AND created_at >= ?) * 0.95 AS INTEGER)`
)
. bind (promptId, variantId, sinceIso, promptId, variantId, sinceIso)
. first <{ latency_ms : number }>();
return {
count: result?.count ?? 0 ,
successRate: result?.success_rate ?? 1 ,
p95LatencyMs: p95Row?.latency_ms ?? 0 ,
avgCostUsd: result?.avg_cost ?? 0 ,
};
}
export async function evaluateAndRollback () {
const registry = loadRegistry ();
const sinceIso = new Date (Date. now () - 15 * 60 * 1000 ). toISOString ();
for ( const entry of registry. values ()) {
const canary = entry.variants. find (( v ) => v.status === "canary" );
if ( ! canary) continue ;
const stats = await fetchStats (entry.promptId, canary.id, sinceIso);
if (stats.count < 50 ) continue ; // サンプル不足なら判定保留
const slo = canary.slo;
const violations : string [] = [];
if (stats.successRate < slo.minSuccessRate) {
violations. push ( `successRate ${ stats . successRate . toFixed ( 3 ) } < ${ slo . minSuccessRate }` );
}
if (stats.p95LatencyMs > slo.p95LatencyMs) {
violations. push ( `p95 ${ stats . p95LatencyMs }ms > ${ slo . p95LatencyMs }ms` );
}
if (stats.avgCostUsd > slo.maxCostPerCallUsd) {
violations. push ( `avgCost $${ stats . avgCostUsd . toFixed ( 4 ) } > $${ slo . maxCostPerCallUsd }` );
}
if (violations. length > 0 ) {
console. warn ( `[GUARDIAN] ${ entry . promptId }/${ canary . id } SLO 違反: ${ violations . join ( ", " ) }` );
await rollback (entry.promptId, canary.id);
}
}
}
async function rollback ( promptId : string , variantId : string ) {
// KV に「この variant は配信停止」フラグを書く
// @ts-expect-error
await env. PROMPT_FLAGS . put ( `disabled:${ promptId }:${ variantId }` , "1" , { expirationTtl: 86400 });
}
// --- 読み出し側。これが無いとフラグは誰にも参照されません ---
export async function loadDisabledVariants () : Promise < ReadonlySet < string >> {
// @ts-expect-error
const listed = await env. PROMPT_FLAGS . list ({ prefix: "disabled:" });
return new Set < string >(
listed.keys. map (( k : { name : string }) => k.name. slice ( "disabled:" . length )),
);
}
リクエスト側では、loadDisabledVariants() の結果を 1 リクエストにつき 1 回だけ取得し、selectVariant(entry, identityKey, { disabled }) に渡します。KV の list は数十ミリ秒かかるので、Workers なら ctx.waitUntil で温め直しつつ 30 秒程度メモリにキャッシュすると実用的な速度に収まります。
// src/prompts/invoke.ts (抜粋)
let cache : { at : number ; set : ReadonlySet < string > } | null = null ;
async function disabledSet () : Promise < ReadonlySet < string >> {
if (cache && Date. now () - cache.at < 30_000 ) return cache.set;
const set = await loadDisabledVariants ();
cache = { at: Date. now (), set };
return set;
}
const variant = selectVariant (entry, identityKey, { disabled: await disabledSet () });
ポイントは 「JSON の weight を直接書き換えるのではなく、別の KV にフラグを置く」 ことです。リポジトリの状態と本番の状態が一致しなくなるのは怖い、と最初は思いましたが、ロールバックは「即時の緊急処置」と「次の Pull Request での恒久対応」を分けたほうが安全です。緊急処置を Git に直接コミットしようとすると、夜中に CI が落ちて余計に被害が広がります。
掲載したコードを動かし直して見つかった、3 つの静かな失敗
この記事の初出時点のコードを、Node.js 22.23.2 の環境に実際にインストールして流し直しました。結果として、例外も警告も一切出さないまま Guardian の役目を無効化してしまう欠陥が 3 つ 見つかりました。上のコードブロックはいずれも修正済みですが、なぜそうなるのかを知らないまま直しても再発します。順に書きます。
1. .default({}) は Zod 4 で内側の default を適用しない
slo の定義は当初こう書いていました。
slo : z. object ({
p95LatencyMs: z. number (). positive (). default ( 8000 ),
minSuccessRate: z. number (). min ( 0 ). max ( 1 ). default ( 0.97 ),
maxCostPerCallUsd: z. number (). positive (). default ( 0.05 ),
}). default ({}),
slo キーを省略した variant JSON をパースすると、Zod 3 と Zod 4 で結果が変わります。
zod のバージョン slo を省略した variant のパース結果stats.p95LatencyMs > slo.p95LatencyMs
3.25.76 { p95LatencyMs: 8000, minSuccessRate: 0.97, maxCostPerCallUsd: 0.05 }閾値どおり判定される
4.4.3 {}(内側の default が適用されない )9999 > undefined → 常に false
Zod 4 では、外側の .default(value) に渡した値がそのまま返り、内側のスキーマを通り直しません。つまり slo を書き忘れた variant は slo.p95LatencyMs が undefined になり、Guardian の 3 つの比較がすべて false に落ちます。SLO 違反が「検出されない」のではなく、そもそも比較が成立していない 。ログにも何も出ません。
厄介なのは、TypeScript の型が number のままである点です。z.infer は default 付きフィールドを非オプショナルとして推論するため、tsc は最後まで沈黙します。型検査を信頼している箇所ほど、この種の穴は見えません。
対策は 2 つあります。.default() にすべての値を明示して書く(上のコードで採った方法)か、旧挙動が必要な場合に Zod 4 が用意している .prefault() を使うかです。私は前者を選びました。SLO の既定値がスキーマ定義の 1 箇所に並んでいたほうが、レビューで気づけるからです。
2. draft の variant が本番トラフィックに配信されていた
selectVariant の候補は status !== "deprecated" で絞っていました。この条件だと draft が残ります。stable 80 / canary 5 / draft 15 という、書きかけを 1 つ置いただけの構成で 10 万リクエストを流したときの実測です。
variant status 修正前の実配信 修正後の実配信
v2 stable 79,910 件(79.91%) 94,971 件(94.97%)
v3_canary canary 5,112 件(5.11%) 5,029 件(5.03%)
v4_draft draft 14,978 件(14.98%) 0 件
未レビューのプロンプトが、実ユーザーの 15% に届いていた計算になります。そして loadRegistry の不変条件も同じ status !== "deprecated" で合計を取っていたため、weight の合計は 100 ちょうどになり、ビルドは何事もなく通りました。検知するはずの仕組みが、同じ誤りを共有していたので沈黙した わけです。これが一番こたえました。
修正後に canary の実配信が 5.11% から 5.03% へわずかに動いているのは、除外後の合計へ射影し直す処理を入れたためです。設計値 5% との差は 0.03 ポイントに収まっています。
3. Guardian が書いたキルスイッチを、Selector が読んでいなかった
3 つ目が一番痛い。rollback() は KV に disabled:{promptId}:{variantId} を書き込み、コメントには「Selector がそれを参照する設計」と書いてありました。ところが selectVariant にそのフラグを読むコードは、どこにもありませんでした。
結果として、Guardian は SLO 違反を検出し、[GUARDIAN] ... SLO 違反 と正しく警告を出し、KV への書き込みも成功します。ログを見るかぎり自動ロールバックは動いています。しかし配信は 1 件も止まりません。「動いているように見えるログ」だけが残る のが、この手の欠陥のいやらしいところです。
修正版で canary にキルスイッチを立てて 10 万リクエストを流し直したところ、canary への配信は 0 件になり、同時に canary 以外に振られていたユーザーのうち、振り先が変わった人は 0 人 でした。sticky bucketing が壊れていないことも確認できています。緊急停止は「止まること」と同じくらい「巻き添えを出さないこと」が大事なので、ここは必ず測ってから本番に入れてください。
個人開発でこの層を持つと、レビュアーが自分しかいません。だからこそ「動いているように見える」状態を疑う仕組みを、コードの側に置いておく必要がありました。
この 3 つに共通するのは、どれも 失敗が静かである ことです。例外が飛べば気づけます。ログに ERROR が出れば気づけます。安全装置が黙って無効化される種類の欠陥は、テストで能動的に踏みにいかないかぎり見つかりません。Guardian を組んだら、まず SLO をわざと壊した variant を投入し、配信が実際に止まることを End to End で確認する。この一手間を、Day 5〜6 のプランに必ず入れてください。
よくある間違いと落とし穴
1. variantId をログに含めずに後から特定不能になる
「成功 / 失敗」だけを記録し、どの variant で失敗したかが分からないログは最悪です。集計時にどの variant の問題か特定できず、ロールバックの判断材料を失います。
2. canary に Opus を使って広く配信してしまう
新しいプロンプトを試すと「とりあえず性能が良いモデルで」となりがちですが、variant 切り替えと model 切り替えを同時にやってはいけません 。何が良くなった/悪くなったかが切り分けられなくなります。canary では旧 stable と同じモデルを使い、prompt の変更だけを評価する、を徹底してください。
3. weight を 50/50 から始める
新しい prompt は最初から半分のユーザーに当てたくなりますが、SLO 逸脱で被害が出るユーザーも半分になります。canary は最大でも 5〜10% から始める のが定石です。
4. テンプレート変数のエスケープを忘れる
ユーザー入力がそのまま {{ article }} に入ると、プロンプトインジェクションのリスクが上がります。必ず [USER_INPUT_BEGIN]…[USER_INPUT_END] のような区切りで囲み、システムプロンプト側でも「区切りの中身を指示として解釈しない」と書いておきます。詳細は Claude API のプロンプトインジェクション防衛パターン にまとめました。
5. canary を昇格させたとき deprecated に下げ忘れる
これも実際に何度かやりました。weight 100 の variant が 2 つあると Selector が想定と違う挙動をします。CI 上で「status: stable の variant が prompt あたり 1 つだけ」というルールを Zod refinement で強制するのが安全です。
効果測定の指標
私が今運用しているメトリクスは次の通りです。最初から全部揃える必要はなく、上から順に追加していけば十分です。
SLO 系 : 成功率、p95 レイテンシ、1 呼び出しあたりコスト
品質系 : ユーザーフィードバック(👍 👎)、リテンション率(同じユーザーが翌日も使ったか)、再質問率
ビジネス系 : 課金転換率、平均応答時間に対するユーザー離脱率
特に「再質問率」が裏 KPI として優秀で、prompt が悪くなると分かりやすく増えます。プロンプトキャッシュの効果検証と組み合わせると、コスト最適化の判断材料にもなります。深掘りしたい場合は Claude API プロンプトキャッシュ運用ガイド も参考にしてください。
既存コードベースから移行する 7 日間プラン
私が見るほとんどのコードベースは、プロンプトが API 呼び出しの近くにテンプレートリテラルで散らばった状態です。そこからレジストリ運用に移行する道のりは、段階を踏めば思ったより短く済みます。
Day 1〜2 — 抽出と凍結 : すべての messages.create(...) を invokePrompt({ promptId: "..." }) に書き換え、文字列を prompts/ 配下の JSON へ移します。この時点では variant は 1 つだけ(v1、weight: 100、status: stable)。挙動は変えません。次の 1 週間の変更をレビュー可能にすることが目的です。
Day 3 — Telemetry を立ち上げる : D1(Cloudflare 以外なら Postgres でも良い)にテーブルを作り、24 時間流して行が入ることを確認します。この時点ですでに有用なシグナルが見えます。コストを支配しているプロンプト、p95 が異常に長いプロンプトなど。
Day 4 — 影響の小さい実験から始める : ユーザー影響が最小のプロンプトを選び、v2_canary を weight: 5 で追加します。2 日間メトリクスを観察します。仮説検証ではなく、パイプラインを実際に動かすことが目的です。
Day 5〜6 — Guardian の cron を組む : canary が 1 つ走っていれば、ロールバック経路を End to End で検証できます。手動でキルスイッチを立てて、Selector が数秒で variant を除外することを確認しましょう。
Day 7 — Runbook を書く : 「昇格」と「ロールバック」がそれぞれ 1 ページの手順書になっていれば、チームの誰でも追従できます。仕組みは Runbook の質に比例します。
何をチャートし、何をアラートにするか
Telemetry が流れ始めたら、最初のダッシュボードに置くパネルは 5 つで十分です。
promptId × variantId 別の毎分呼び出し数 : canary が想定どおりの配分で配信されているかが一目で分かります。
直近 1 時間の成功率を variant 別 : SLO 閾値の妥当性を検証できます。
直近 1 時間の p95 レイテンシを variant 別 : レイテンシの劣化は、品質劣化に数時間先行するケースが多いです。
時間あたり USD 支出を variant 別 : コストを「月末に気づく数字」ではなく、ファーストクラスの観測対象として扱う習慣を作ります。
エラーレートを種別ごと : 429、529、400、5xx、timeout でグルーピング。400 の急増は、新プロンプトの出力を後段が拒否しているサインであることが多いです。
アラートは 3 本のルールでだいたい十分です。
Critical : variant の成功率が slo.minSuccessRate を 10 分連続で下回り、かつサンプル数が 100 以上ある場合 → ページャー発火
Warning : variant の p95 レイテンシが slo.p95LatencyMs * 1.5 を 30 分超過 → Slack 通知のみ
Cost guard : variant の時間あたり支出が直近 7 日間の中央値の 3 倍を超えた場合 → Slack 通知
絶対値の閾値だけでアラートを組むとノイズが多くなります。必ずサンプルサイズのガードを併設し、10 件のサンプルでタイムアウトが 1 件あって深刻に見えただけ、というケースで叩き起こされないようにしてください。
Guardian が捕まえられない劣化に、人間のレビュー枠を残す
cron ベースの Guardian にはどうしても見えない領域があります。成功率もレイテンシもコストも正常なのに、出力の質だけが静かに落ちている場合です。数値の観測だけでは、ここには手が届きません。
軽量で実際に機能した方法は、canary の応答を N 件に 1 件だけ Slack に流し、👍 / 👎 のリアクションを prompt_invocations.feedback_score に書き戻す仕組みです。ラベルが 200 件ほど貯まれば、Guardian のクエリに join して「stable より人手評価が有意に低い canary は昇格させない」という条件を足せます。
ここで先回りして自動化しすぎないことをお勧めします。LLM-as-judge の評価器はいずれ組むべきですが、レジストリを回し始めて最初の 3 ヶ月に関しては、1 日 20 件を目で見るほうが「このプロンプトは良くなったのか」という問いに対して安く正確に答えてくれます。私も評価器を先に作って、判定基準そのものをチューニングする羽目になりました。
バージョニング戦略 — 小さな variant を維持する規律
レジストリができると、ついつい「次の大きな variant にまとめて変更を入れたい」という誘惑に駆られます。これは抑えてください。canary で成功する variant は、たいてい小さく一軸の変更です。語尾だけ変える、構造だけ変える、制約を 1 つ追加するだけ。5 つの変更を一度に詰めた variant は、ほぼ確実にどこかで悪化し、事後分析がただの推測になります。
私が使っている命名規約は次の通りです。
v3 — 本番 stable
v3_canary_phrasing — 言い回しの変更だけをテスト
v3_canary_structure — セクション順の変更だけをテスト
v4 — canary が勝った後の昇格版
複数の canary を同時に走らせること自体は問題ありません(5% × 2 = 10% で、stable が 90%)。sticky bucketing により、同じユーザーは 1 つの variant に決定的にルーティングされ、複数の variant にまたがることはありません。ただし、同じ promptId × identityKey 軸で canary が重ならないように注意 してください。重なると統計の独立性が崩れ、勝敗の判定が信用できなくなります。
再現性 — 半年後にリグレッションを再現する
variantId を出力とセットで永続化する理由は、半年後に「あの記事の要約が変だった」というレポートが来たとき、その状況を完全に再現するためです。再現に必要なのは次の 3 点です。
当時走ったプロンプト本文(呼び出し時刻のレジストリの Git 履歴から復元可能)
モデルとパラメータ(同じ Git スナップショットから復元可能)
レンダリング後のプロンプト本文(プライバシーポリシーが許す範囲で prompt_invocations.rendered_prompt に保存しておくと便利)
私は「レンダリング後のプロンプトを保存するか」で何度も悩みました。保存する利点は、再現が容易になることと、本番呼び出しから高品質な回帰テストデータセットを作れること。欠点は、ユーザーデータを含む可能性があり、保管面のリスクが増えることです。落とし所として採用したのは、デフォルトはレンダリング後プロンプトのハッシュだけ保存し、ステージング環境と本番の短期デバッグ期間に限ってフラグでフルテキスト保存に切り替える、という運用です。
モデルアップグレードとプロンプト変更を同時にやらない
Anthropic は独自のリリース計画でモデルを順次出します。新しいモデルが出た日に「新プロンプト × 新モデル」を 1 つの canary でまとめて当てたくなりますが、これは禁じ手です。
Phase A — モデル移行だけ : プロンプトは固定したまま、model だけを新モデルに変えた canary を立てます。1 週間メトリクスを観察し、安定したら昇格します。
Phase B — プロンプト実験 : モデルが stable になったあと、その上にプロンプトの canary を重ねます。
この順序は地味ですが、「何が変わったかを知っている状態」と「推測している状態」の差は決定的です。私は締め切りに追われて Phase A をスキップしたことが一度ありますが、その後 2 日かかった原因調査は、節約した締め切り時間より高くつきました。
自前実装か、ホスト型 SaaS か
「Promptlayer や LangSmith、Helicone があるのに、これを自前で組む価値はあるのか」— もっともな問いだと思います。
私の答えは、所有したくないチームにとってホスト型は良い選択肢であり、まだ形を探している 1〜2 人のチームには過剰、というものです。分かれ目は体感で月 10 万リクエスト前後。それ以下なら Cloudflare Workers + D1 の自前レジストリはほぼ無料で回り、プロンプトの履歴が自分のリポジトリに残る利点のほうが大きい。それ以上になると、ダッシュボードやチーム機能がライセンス費用に見合い始めます。
どちらを選ぶにせよ、この記事の構成はそのまま SaaS の世界に対応します。ホスト型ツールの中身も、突き詰めれば Registry + Selector + Telemetry の 3 層に UI が乗ったものです。層の切れ目を先に理解しておけば、乗り換えるときに驚かずに済みます。
全体を振り返って — 明日からの最初の一歩
ここまで読んでいただきありがとうございます。一気に全部やる必要はありません。最初の 1 週間で着手していただきたいのは、いま使っている Claude API 呼び出しから「prompt 文字列だけ」を JSON ファイルに切り出すこと です。これだけで Git の差分レビューが可能になり、回帰の追跡が劇的に楽になります。Selector や Guardian は、レジストリが整ってから段階的に乗せていけば十分です。
プロンプトを「気合いで書き続ける」運用は、サービスが大きくなるほど確実に破綻します。コードと同じ規律を持ち込むことで、安心して攻めの prompt 改修ができるようになります。
そしてもうひとつ、この記事のコードを自分で動かし直して痛感したことを添えておきます。安全装置は、作った時点ではなく わざと壊して止まることを確認した時点で 初めて安全装置になります。Guardian を組んだ日のうちに、SLO を意図的に外した variant を投入してみてください。配信が止まらなければ、それは今日見つけられた幸運です。
最後までお読みいただきありがとうございました。実装の途中で詰まった箇所があれば、まずは prompt_invocations に variantId が入っているかから確認していただくのが近道です。