プロンプトを少し書き換えて本番に投入し、なんとなく良くなった気がする — この曖昧さに付き合い続けていると、ある日突然、ユーザーから「最近精度落ちてませんか?」というフィードバックが届きます。私自身、Antigravity でマルチエージェントを本番運用し始めた頃、この「気がする」改善で何度か痛い目に遭いました。
プロンプトはコードと同じくらい慎重に扱うべき資産です。バージョン管理し、A/Bテストで効果を測り、統計的に有意な差が出てから昇格させる — このサイクルを Antigravity 環境で動かす方法を、レジストリ設計・トラフィック分配・メトリクス収集・統計判定・ロールバックまで含めて、実装コード付きでまとめました。
特定のフレームワークに依存しない最小実装から始めて、AgentKit 2.0 や Langfuse との接続例まで進みます。読み終えるころには、自分のプロダクトに今日から組み込めるはずです。
なぜプロンプトをコードと同じ扱いにすべきなのか
プロンプトをエージェントの実装ファイル内に直書きしている状態は、設定値をハードコードしている状態と本質的に同じです。変更履歴が追えず、過去のバージョンに戻すには Git の履歴を遡るしかなく、誰がいつ何を変えたかが PR の文脈に埋もれます。
問題はそれだけではありません。プロンプトの変更は、コードと違ってユニットテストでは捉えきれない挙動の変化を引き起こします。ある問い合わせには改善するが、別の問い合わせでは悪化する — このトレードオフを「実際の本番トラフィック」で測れる仕組みがないと、改善は常にギャンブルになります。
私が行き着いた整理は次の3つです。プロンプトは別ファイルに切り出し、明示的なバージョンを持たせ、本番に出る前に必ず A/B テストを通す。これだけで、改善の質と心理的安全性が大きく変わります。
プロンプトレジストリの最小設計
最初に、プロンプトを「コードから切り離して保存する場所」を作ります。データベースを使う必要はなく、まずはリポジトリ内の YAML で十分です。
# prompts/code_review/v1.yaml
id: code_review
version: 1
created_at: "2026-04-15T10:00:00Z"
author: "masaki"
status: champion # champion | candidate | retired
description: "PRに対するコードレビューを行う基本プロンプト"
template: |
あなたは経験豊富なソフトウェアエンジニアです。
以下の差分をレビューし、{{review_criteria}} の観点でコメントしてください。
## 差分
{{diff}}
## 出力形式
- 重大度(critical / warning / info)
- 該当ファイル
- 該当行番号
- 指摘内容
- 修正案
variables:
- name: review_criteria
required: true
- name: diff
required: true
evaluation:
test_set: "datasets/code_review_v1.jsonl"
metrics: ["accuracy", "false_positive_rate", "latency_ms", "cost_jpy"]ポイントは「テンプレート」と「メタデータ」と「評価設定」を一つのファイルに同居させることです。プロンプトを読み込むコードは次のようになります。
// src/lib/prompt-registry.ts
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { parse } from "yaml";
export type PromptStatus = "champion" | "candidate" | "retired";
export interface PromptDefinition {
id: string;
version: number;
created_at: string;
author: string;
status: PromptStatus;
description: string;
template: string;
variables: Array<{ name: string; required: boolean }>;
evaluation: {
test_set: string;
metrics: string[];
};
}
const PROMPTS_DIR = join(process.cwd(), "prompts");
export class PromptRegistry {
private cache = new Map<string, PromptDefinition[]>();
/** 指定IDの全バージョンを返す(versionの降順) */
load(id: string): PromptDefinition[] {
if (this.cache.has(id)) return this.cache.get(id)!;
const dir = join(PROMPTS_DIR, id);
const files = readdirSync(dir).filter((f) => f.endsWith(".yaml"));
const versions = files.map((file) => {
const raw = readFileSync(join(dir, file), "utf-8");
return parse(raw) as PromptDefinition;
});
versions.sort((a, b) => b.version - a.version);
this.cache.set(id, versions);
return versions;
}
/** 現在のチャンピオンを返す */
champion(id: string): PromptDefinition {
const champion = this.load(id).find((p) => p.status === "champion");
if (!champion) throw new Error(`No champion prompt for id=${id}`);
return champion;
}
/** A/B 候補を返す */
candidate(id: string): PromptDefinition | null {
return this.load(id).find((p) => p.status === "candidate") ?? null;
}
}
export const promptRegistry = new PromptRegistry();このレジストリは「レコードは追記のみ・既存レコードは編集禁止」という原則で運用します。バージョン2を作りたければ v2.yaml を新規追加し、v1.yaml の status は retired に書き換えるだけ。Git 履歴で誰がいつ昇格させたかも自動的に追えます。
期待する出力(promptRegistry.champion("code_review").template を呼んだとき):
あなたは経験豊富なソフトウェアエンジニアです。
以下の差分をレビューし、{{review_criteria}} の観点でコメントしてください。
(中略)
A/Bテストの分配ロジック
候補プロンプトが用意できたら、本番トラフィックの一部だけをそちらに流します。私は最初は「10%」から始めることを勧めています。問題が起きたときの影響範囲を抑えつつ、統計的な差を検出できる最小ラインです。
// src/lib/prompt-router.ts
import { createHash } from "node:crypto";
import { promptRegistry, PromptDefinition } from "./prompt-registry";
export interface RoutingResult {
prompt: PromptDefinition;
variant: "champion" | "candidate";
experimentId: string | null;
}
interface RouteOptions {
/** A/Bテストの「実験ID」。同じユーザー・同じ実験では常に同じ振り分けにする */
subjectId: string;
/** 候補へのトラフィック割合(0〜1) */
candidateShare?: number;
}
export function routePrompt(
promptId: string,
options: RouteOptions
): RoutingResult {
const champion = promptRegistry.champion(promptId);
const candidate = promptRegistry.candidate(promptId);
if (!candidate) {
return { prompt: champion, variant: "champion", experimentId: null };
}
const share = options.candidateShare ?? 0.1;
const experimentId = `${promptId}_v${champion.version}_vs_v${candidate.version}`;
const bucket = stableBucket(experimentId, options.subjectId);
if (bucket < share) {
return { prompt: candidate, variant: "candidate", experimentId };
}
return { prompt: champion, variant: "champion", experimentId };
}
/** 同じユーザーは常に同じバケットに入るよう、ハッシュベースで決める */
function stableBucket(experimentId: string, subjectId: string): number {
const hash = createHash("sha256")
.update(`${experimentId}:${subjectId}`)
.digest();
// 上位4バイトを 0〜1 の値に変換
const intVal = hash.readUInt32BE(0);
return intVal / 0xffffffff;
}ハッシュベースの分配にしている理由は明確で、同じユーザーが同じ実験中に異なるバージョンを引き当てると、体験の一貫性が崩れるうえ、ユーザーごとの効果分析が不可能になります。ランダム関数で振り分けてはいけません。
呼び出し側は次のようにシンプルに書けます。
import { routePrompt } from "./prompt-router";
const { prompt, variant, experimentId } = routePrompt("code_review", {
subjectId: user.id,
candidateShare: 0.1, // 10%だけ候補に流す
});
const filledTemplate = renderTemplate(prompt.template, {
review_criteria: "セキュリティ、パフォーマンス、可読性",
diff: pullRequestDiff,
});
const startedAt = Date.now();
const response = await callGemini(filledTemplate);
const latencyMs = Date.now() - startedAt;
// メトリクスを必ず記録(次節)
await recordExperimentResult({
experimentId,
variant,
promptVersion: prompt.version,
subjectId: user.id,
latencyMs,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
// 後で評価できるよう、入力と出力も保存
input: { diff: pullRequestDiff, criteria: "セキュリティ、パフォーマンス、可読性" },
output: response.text,
});評価メトリクスの設計 — 数字に落とせないと判定できない
A/Bテストで一番難しいのは「どちらが良いか」を数字で表現することです。プロンプトの良し悪しは多次元の指標で測るしかなく、私は次の4軸を必ず取ります。
- 品質メトリクス(accuracy / quality_score): 正解データセットがあるならそれとの一致率、ないなら別のジャッジLLMによる0〜10点評価
- コストメトリクス(cost_jpy): 入出力トークン数 × 単価で算出した1リクエストあたりの円コスト
- レイテンシメトリクス(latency_ms): ユーザー体験に直結する応答時間
- ユーザーフィードバック(user_thumbs_up_rate): 👍/👎ボタンや「修正案を採用したか」のシグナル
これらを実験ID単位で集計するテーブル設計の例です。
CREATE TABLE prompt_experiment_results (
id BIGSERIAL PRIMARY KEY,
experiment_id TEXT NOT NULL,
variant TEXT NOT NULL CHECK (variant IN ('champion', 'candidate')),
prompt_version INT NOT NULL,
subject_id TEXT NOT NULL,
latency_ms INT NOT NULL,
input_tokens INT NOT NULL,
output_tokens INT NOT NULL,
cost_jpy NUMERIC(10, 4) NOT NULL,
quality_score NUMERIC(4, 2), -- 後追い評価で埋める
user_feedback SMALLINT, -- 1 = 👍, -1 = 👎, NULL = 未取得
input_payload JSONB NOT NULL,
output_payload TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_experiment_variant ON prompt_experiment_results(experiment_id, variant);
CREATE INDEX idx_subject ON prompt_experiment_results(subject_id);入力と出力をそのまま保存している理由は、後から別の評価軸で再採点できるようにするためです。最初の実験では「品質スコア」を取り忘れていても、データさえ残っていれば翌週に評価ジョブを走らせて埋められます。これはプロンプト運用で何度も助けられた設計判断です。
統計判定 — 「平均が良い」だけで昇格させない
候補のほうが平均スコアが高くても、そのまま昇格させてはいけません。サンプル数が少ないと、たまたま良いリクエストばかり当たっただけかもしれないからです。私は2標本t検定(Welchのt検定)で有意差を確認しています。
// src/lib/experiment-stats.ts
export interface SampleStats {
n: number;
mean: number;
variance: number;
}
export function summarize(values: number[]): SampleStats {
const n = values.length;
if (n < 2) return { n, mean: values[0] ?? 0, variance: 0 };
const mean = values.reduce((s, v) => s + v, 0) / n;
const variance = values.reduce((s, v) => s + (v - mean) ** 2, 0) / (n - 1);
return { n, mean, variance };
}
/** Welch's t-test で p値(両側)を返す。p < 0.05 で有意差ありと判定 */
export function welchTTest(
a: SampleStats,
b: SampleStats
): { tStatistic: number; degreesOfFreedom: number; pValue: number } {
const seA = a.variance / a.n;
const seB = b.variance / b.n;
const tStatistic = (a.mean - b.mean) / Math.sqrt(seA + seB);
const degreesOfFreedom =
(seA + seB) ** 2 /
((seA ** 2) / (a.n - 1) + (seB ** 2) / (b.n - 1));
const pValue = 2 * (1 - studentTCDF(Math.abs(tStatistic), degreesOfFreedom));
return { tStatistic, degreesOfFreedom, pValue };
}
/** Student's t 分布の累積分布関数(数値積分による近似) */
function studentTCDF(t: number, df: number): number {
// Cornish-Fisher 展開を避け、ベータ関数の不完全積分で計算
const x = df / (df + t * t);
const a = df / 2;
const b = 0.5;
const ibeta = incompleteBeta(x, a, b);
return 1 - 0.5 * ibeta;
}
function incompleteBeta(x: number, a: number, b: number): number {
// 連分数展開(Lentz法)。実運用では mathjs や jstat の使用も検討
if (x <= 0) return 0;
if (x >= 1) return 1;
const lbeta =
logGamma(a) + logGamma(b) - logGamma(a + b);
const front =
Math.exp(Math.log(x) * a + Math.log(1 - x) * b - lbeta) / a;
let cf = 1;
let prev = 0;
for (let i = 0; i < 200; i++) {
const m = i / 2;
const numerator =
i % 2 === 0
? (m * (b - m) * x) / ((a + 2 * m - 1) * (a + 2 * m))
: (-(a + m) * (a + b + m) * x) /
((a + 2 * m) * (a + 2 * m + 1));
cf = 1 + numerator / cf;
if (Math.abs(cf - prev) < 1e-12) break;
prev = cf;
}
return front * (cf - 1);
}
function logGamma(z: number): number {
// Lanczos 近似
const g = 7;
const c = [
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
771.32342877765313, -176.61502916214059, 12.507343278686905,
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
];
if (z < 0.5)
return Math.log(Math.PI / Math.sin(Math.PI * z)) - logGamma(1 - z);
z -= 1;
let x = c[0];
for (let i = 1; i < g + 2; i++) x += c[i] / (z + i);
const t = z + g + 0.5;
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
}実運用では jstat や simple-statistics のような検定済みライブラリを使うことを勧めます。上記は仕組みを理解してもらうためのリファレンス実装です。判定ロジック側はこう書けます。
import { summarize, welchTTest } from "./experiment-stats";
interface PromotionDecision {
shouldPromote: boolean;
reason: string;
championStats: SampleStats;
candidateStats: SampleStats;
pValue: number;
}
export async function decidePromotion(
experimentId: string,
metric: "quality_score" | "user_feedback",
options: { minSamplesPerVariant: number; pValueThreshold: number }
): Promise<PromotionDecision> {
const championValues = await fetchMetric(experimentId, "champion", metric);
const candidateValues = await fetchMetric(experimentId, "candidate", metric);
const championStats = summarize(championValues);
const candidateStats = summarize(candidateValues);
// ガード1: サンプル数が足りない
if (
championStats.n < options.minSamplesPerVariant ||
candidateStats.n < options.minSamplesPerVariant
) {
return {
shouldPromote: false,
reason: `サンプル数不足(最低 ${options.minSamplesPerVariant} 件必要)`,
championStats,
candidateStats,
pValue: NaN,
};
}
// ガード2: 平均が悪化している
if (candidateStats.mean <= championStats.mean) {
return {
shouldPromote: false,
reason: `候補の平均が劣る or 同等(candidate=${candidateStats.mean.toFixed(3)}, champion=${championStats.mean.toFixed(3)})`,
championStats,
candidateStats,
pValue: NaN,
};
}
// ガード3: 統計的有意差なし
const { pValue } = welchTTest(championStats, candidateStats);
if (pValue >= options.pValueThreshold) {
return {
shouldPromote: false,
reason: `有意差なし(p=${pValue.toFixed(4)} ≥ ${options.pValueThreshold})`,
championStats,
candidateStats,
pValue,
};
}
return {
shouldPromote: true,
reason: `有意な改善あり(p=${pValue.toFixed(4)}, +${(candidateStats.mean - championStats.mean).toFixed(3)})`,
championStats,
candidateStats,
pValue,
};
}「平均が悪化している」のガードを必ず最初に置くのは、p値だけ見ると「有意に悪化している」候補も「有意差あり」と判定されてしまうからです。トレードオフ指標(コスト・レイテンシ)も見たい場合は、この関数を multi-objective に拡張するか、複数指標で個別に走らせて手動で総合判定します。
Antigravity Agent Manager との接続
ここまで作ったレジストリ・ルーター・統計判定を、実際に Antigravity の Manager Surface 上で動かします。Manager は複数のサブエージェントをオーケストレーションするので、サブエージェントごとに別々のプロンプトIDを持たせ、それぞれ独立に A/B テストを回せます。
// src/agents/code_reviewer.ts
import { defineAgent } from "@google/agentkit";
import { routePrompt } from "../lib/prompt-router";
import { recordExperimentResult } from "../lib/experiment-store";
import { renderTemplate } from "../lib/template";
export const codeReviewer = defineAgent({
name: "code_reviewer",
description: "PR差分にレビューコメントを付ける",
inputSchema: {
type: "object",
properties: {
diff: { type: "string" },
review_criteria: { type: "string" },
subject_id: { type: "string" },
},
required: ["diff", "review_criteria", "subject_id"],
},
async run(input, ctx) {
const { prompt, variant, experimentId } = routePrompt("code_review", {
subjectId: input.subject_id,
candidateShare: 0.1,
});
const filled = renderTemplate(prompt.template, {
review_criteria: input.review_criteria,
diff: input.diff,
});
const startedAt = Date.now();
let response;
try {
response = await ctx.gemini.generate({ prompt: filled });
} catch (error) {
// 候補プロンプトが原因でエラーが多発するなら、自動で champion にフォールバック
if (variant === "candidate") {
ctx.logger.warn("candidate failed, falling back to champion", {
experimentId,
error,
});
const championPrompt = await routePrompt("code_review", {
subjectId: input.subject_id,
candidateShare: 0,
});
response = await ctx.gemini.generate({
prompt: renderTemplate(championPrompt.prompt.template, {
review_criteria: input.review_criteria,
diff: input.diff,
}),
});
} else {
throw error;
}
}
const latencyMs = Date.now() - startedAt;
await recordExperimentResult({
experimentId,
variant,
promptVersion: prompt.version,
subjectId: input.subject_id,
latencyMs,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
input: input,
output: response.text,
});
return response.text;
},
});Antigravity Manager 側の設定では、このサブエージェントをオーケストレーションする親エージェントが、結果のメタデータ(variant, experimentId)を Manager のトレースに含められるようにしておくと、Antigravity の Inspector 画面でA/Bの内訳がそのまま追えます。Langfuse を併用しているなら、recordExperimentResult 内でトレースタグに variant を付けると、ダッシュボード上で variant 別のレイテンシ・コスト分布を一発で確認できます。
実装の詳細やオブザーバビリティ設計については、過去記事のAIエージェントのオブザーバビリティをLangfuseで構築する完全ガイドと組み合わせると、運用画面まで一貫して整います。
チャンピオン昇格とロールバックの自動化
統計判定が通ったら、候補を新しいチャンピオンに昇格させます。ここも手動でやらず、CI から実行するスクリプトにしておくと、人為的なミスが減ります。
// scripts/promote-prompt.ts
import { writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { stringify, parse } from "yaml";
import { decidePromotion } from "../src/lib/experiment-stats";
const PROMPT_ID = process.argv[2];
const EXPERIMENT_ID = process.argv[3];
if (!PROMPT_ID || !EXPERIMENT_ID) {
console.error("Usage: tsx scripts/promote-prompt.ts <promptId> <experimentId>");
process.exit(1);
}
const decision = await decidePromotion(EXPERIMENT_ID, "quality_score", {
minSamplesPerVariant: 200,
pValueThreshold: 0.05,
});
console.log(JSON.stringify(decision, null, 2));
if (!decision.shouldPromote) {
console.error(`昇格中止: ${decision.reason}`);
process.exit(2);
}
// champion を retired に、candidate を champion に書き換える
const dir = join("prompts", PROMPT_ID);
const files = readdirSync(dir).sort();
for (const file of files) {
const path = join(dir, file);
const def = parse(readFileSync(path, "utf-8"));
if (def.status === "champion") def.status = "retired";
else if (def.status === "candidate") def.status = "champion";
writeFileSync(path, stringify(def));
}
console.log("✅ 昇格完了。コミットして PR を作成してください。");このスクリプトは「PRを作るところまで」を担当し、最終的なマージは人間がレビューします。完全自動マージにしないのは、評価データセットそのものに偏りがあって誤って昇格してしまうリスクが残るためです。私の現場では、昇格判定の根拠(p値・サンプル数・平均差)をPR本文に貼り付けるテンプレートにしていて、レビュアーは「数字を見て」承認します。
逆方向のロールバックも同じ仕組みで動きます。新チャンピオンに昇格してから1週間、本番メトリクスが事前に決めた閾値(例: ユーザーフィードバック率が3%以上低下)を下回ったら、自動で旧バージョンを champion に戻すスクリプトを cron で回しています。
よくある落とし穴と回避策
ここからは、私自身が踏んだ・チームメンバーが踏んだ落とし穴を挙げます。最初の3つは特に多いので、設計時に必ず確認してください。
落とし穴1: 候補プロンプトを分配する前に、評価データセットだけでテストして満足してしまう オフライン評価で良いスコアが出ても、本番のリクエスト分布と評価データセットの分布が違えば結果は変わります。オフラインはあくまで「致命的に劣化していないことの確認」用と割り切り、最終判定は必ず本番A/Bで行ってください。
落とし穴2: 同じユーザーに毎回ランダムでバージョンを振り分けてしまう
本記事の stableBucket 関数のようにハッシュベースで固定しないと、同じユーザーがある日は champion、次の日は candidate に当たり、体験が一貫しません。さらに「ユーザー単位の効果」を分析できなくなり、相関が出ても解釈不能になります。
落とし穴3: メトリクスの単位を揃えていない 品質スコアは0〜1、レイテンシはms、コストは円 — 単位がバラバラだと、後で「総合スコア」を作ろうとしたときに重み付けで悩みます。最初から「全メトリクスは0〜1に正規化済み」というルールにしておくと、後段の判定ロジックが単純になります。
落とし穴4: 候補プロンプトのトークン消費量が champion の数倍になっている 品質は上がっているがコストが3倍になった候補を「品質改善!」として昇格させると、月末の請求書で青ざめます。コストメトリクスを必ず取り、判定時には「品質改善 / コスト増分」の比率で総合判断してください。私は「品質改善が10%以上 かつ コスト増加が30%以下」を昇格条件にしています。
落とし穴5: 評価ジャッジ用のプロンプトもバージョン管理する LLMジャッジで品質スコアを付ける場合、ジャッジLLM自体のプロンプトを更新したら、過去のスコアと新しいスコアは比較できません。ジャッジプロンプトもこの仕組みでバージョン管理し、昇格時には「過去スコアの再評価が必要」と明示する運用にします。
本番運用の実例 — 30日サイクルで何が変わるか
実装した仕組みを使って、私が運用しているコードレビューエージェントは次のようなサイクルで動いています。
- 月初: 過去30日のメトリクスを集計し、改善対象のプロンプトIDを2〜3個ピック
- 第1週: 候補プロンプトを起草、オフライン評価で致命的劣化がないことを確認、
status: candidateで追加 - 第2〜3週: 10%トラフィックで本番A/Bテスト。最低500件のサンプルが集まるまで継続
- 第4週:
decidePromotionで判定。昇格 or 棄却を決め、PRを作成 - 翌月初: 昇格後1週間のメトリクスを再確認し、ロールバック条件に該当しないかチェック
このサイクルを3周ほど回すと、改善が「印象」ではなく「数字」で語れるようになり、チームミーティングでも「次にどのプロンプトを触るか」の議論が一気に建設的になりました。
設計面でさらに深掘りしたい場合は、関連するAntigravityでのプロンプトエンジニアリング上級ガイドとAIエージェント評価フレームワークの本番設計を読むと、本記事で扱った「メトリクス設計」と「評価データセット作り」の補完情報が得られます。
書籍で体系的に
まずは1つのプロンプトで仕組みを動かしてみる
ここまで読んだ皆さんへの具体的な次の一歩は、自分のプロダクトの中で「最も使われているプロンプト」を1つだけ選び、本記事の prompts/ ディレクトリ構成と prompt-registry.ts だけをまず実装することです。A/Bルーターも統計判定も後回しで構いません。
切り出すだけでも、変更履歴が追えるようになり、ロールバックが簡単になり、チームで議論する土台ができます。そこから2週間後に候補プロンプトを書いて、本記事のルーターを差し込めば、A/Bテストが動き始めます。完璧な仕組みを最初から作ろうとせず、1プロンプトずつ、1メトリクスずつ、運用と一緒に育てていく方針が結局いちばん早いです。