「プロンプトを少し書き換えただけなのに、なぜか本番の応答品質が落ちた」— Claude API を業務で使い始めると、ほぼ全員がこの壁にぶつかります。私自身、4 サイト分の AI ブログ運用を Claude API で自動化していますが、最初の数ヶ月は「正解が分からないまま、エンジニアの直感でプロンプトをいじる」作業の繰り返しで、品質が安定しませんでした。
この記事は、プロンプトをアプリケーションコードと同じ粒度で「レジストリ」として管理し、Git で履歴を残し、本番投入前に A/B テストで検証し、悪化したら自動でロールバックする、という一連の運用パイプラインを TypeScript で組み上げる方法をまとめたものです。流行りの SaaS(Promptlayer、LangSmith、Helicone など)に依存せず、自前実装で完結できる構成にしています。Stripe の決済処理や Cloudflare Workers でのエッジ AI と相性がよい、軽量な設計を選びました。
なぜプロンプトを「データ」ではなく「コード」として扱うべきか
最初に避けたい設計から書きます。よく見かけるのが、Notion や Airtable にプロンプトを書き、それをアプリから fetch するパターンです。一見「非エンジニアも編集できて便利」に見えますが、プロダクション運用に入ると次の問題が出ます。
差分レビューができない : 誰がいつ何を変えたかが追えず、回帰の原因特定が極端に難しくなります。
テストとリリースが分離する : アプリのテストは通っても、プロンプト変更がデプロイ後にいきなり全ユーザーに届きます。
ロールバックが手動 : 旧バージョンを別タブで保存しておくような運用になり、深夜の障害対応で確実に事故ります。
私のスタンスは明確で、プロンプトは TypeScript / JSON のソースコードとしてリポジトリに置き、CI で型チェックとテストを通し、デプロイは Pull Request 経由で行うべきです。「非エンジニアが編集する」要件は、このあと出てくる Web UI を別レイヤーで提供すれば解決できます。
関連する設計思想は Claude API プロンプトエンジニアリング本番運用パターン に詳しく書きましたので、合わせて読むと土台が固まります。
アーキテクチャ全体像
これから作るシステムは次の 6 レイヤーで構成されます。
レイヤー1: Registry : プロンプト本文・バージョン・variant を保持する単一の真実
レイヤー2: Loader : アプリ起動時または lazy にレジストリから該当 version を読み込む
レイヤー3: Selector : A/B テストや段階的ロールアウトに応じて variant を決定する
レイヤー4: Renderer : テンプレート変数を埋め込んで最終 prompt を生成する
レイヤー5: Telemetry : 入力・出力・レイテンシ・トークン・評価スコアを構造化ログで保存する
レイヤー6: Guardian : メトリクスを監視し、悪化を検知したら自動で旧バージョンに切り戻す
このレイヤー分割が肝で、変更の影響範囲を物理的に閉じ込めるための境界線になっています。私はこの構造を意識せずに最初の実装をして、Selector と Renderer がぐちゃぐちゃに混ざり、A/B テストの結果がそもそも信用できないという失敗をしました。最初に「どの責務をどこに置くか」を決めておくと、後から効きます。
プロンプトの構造を 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 ({}),
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 ({}),
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 に下げる運用です。
レジストリの実装
レジストリは「単一ファイルに全部詰める派」と「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, 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);
const totalWeight = parsed.variants
. filter (( v ) => v.status !== "deprecated" )
. reduce (( acc , v ) => acc + v.weight, 0 );
if (totalWeight !== 100 ) {
throw new Error ( `[${ parsed . promptId }] weight の合計が 100 ではありません (${ totalWeight })` );
}
registry. set (parsed.promptId, parsed);
}
return registry;
}
weight 合計が 100 にならないとビルドが落ちる、という不変条件を最初から入れておくのが大事です。これを忘れると、A/B テストでサイレントに「どの variant も選ばれない 5%」が発生し、デバッグに数日溶かします。
variant の決定ロジック — sticky なハッシュ分岐
A/B テストで一番やってはいけないのは「リクエストごとに乱数で振る」ことです。同じユーザーが同じ会話の中で variant が切り替わると、応答の一貫性が崩れて UX が壊滅します。userId などの安定した識別子を使い、ハッシュで振る sticky な分岐にします。
// src/prompts/selector.ts
import crypto from "node:crypto" ;
import type { PromptEntry, 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 },
) : PromptVariant {
if (options?.forceVariantId) {
const forced = entry.variants. find (( v ) => v.id === options.forceVariantId);
if (forced) return forced;
}
const candidates = entry.variants. filter (( v ) => v.status !== "deprecated" );
if (candidates. length === 0 ) {
throw new Error ( `[${ entry . promptId }] 配信可能な variant がありません` );
}
const bucket = stableHash ( `${ entry . promptId }::${ identityKey }` );
let cumulative = 0 ;
for ( const v of candidates) {
cumulative += v.weight;
if (bucket < cumulative) return v;
}
return candidates[candidates. length - 1 ] ! ;
}
identityKey は userId 単位だけでなく、${userId}:${experimentName} のように実験を切ると衝突が減る という経験則があります。同じユーザーに対して別の prompt の A/B が同じ振り方をしてしまうのは、実験設計上のノイズになるからです。
レンダリングと 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 は配信停止」フラグを書き、Selector がそれを参照する設計
// @ts-expect-error
await env. PROMPT_FLAGS . put ( `disabled:${ promptId }:${ variantId }` , "1" , { expirationTtl: 86400 });
}
ポイントは 「JSON の weight を直接書き換えるのではなく、別の KV にフラグを置く」 ことです。リポジトリの状態と本番の状態が一致しなくなるのは怖い、と最初は思いましたが、ロールバックは「即時の緊急処置」と「次の Pull Request での恒久対応」を分けたほうが安全です。緊急処置を Git に直接コミットしようとすると、夜中に CI が落ちて余計に被害が広がります。
よくある間違いと落とし穴
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 件あって深刻に見えただけ、というケースで叩き起こされないようにしてください。
バージョニング戦略 — 小さな 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 日かかった原因調査は、節約した締め切り時間より高くつきました。
全体を振り返って — 明日からの最初の一歩
ここまで読んでいただきありがとうございます。一気に全部やる必要はありません。最初の 1 週間で着手していただきたいのは、いま使っている Claude API 呼び出しから「prompt 文字列だけ」を JSON ファイルに切り出すこと です。これだけで Git の差分レビューが可能になり、回帰の追跡が劇的に楽になります。Selector や Guardian は、レジストリが整ってから段階的に乗せていけば十分です。
プロンプトを「気合いで書き続ける」運用は、サービスが大きくなるほど確実に破綻します。コードと同じ規律を持ち込むことで、安心して攻めの prompt 改修ができるようになります。
より体系的に運用設計を