プロンプトのバージョニング:YAML ベースのストア実装
最初に実装するのはストア層です。小〜中規模のプロジェクトなら YAML ファイルで十分で、Git 管理するだけで履歴・PR レビュー・ロールバックがすべて無料で手に入ります。
prompts/summarize_article.yaml を例に、以下のようなスキーマを採用します。
# prompts/summarize_article.yaml
# このファイルがプロンプトの「ソース・オブ・トゥルース」です
id : summarize_article
versions :
- version : v1
status : stable # stable / canary / deprecated
weight : 0.8 # ルーター層での選択確率
created_at : 2026-03-15
author : masaki
template : |
あなたはプロの編集者です。以下の記事を3文で要約してください。
記事: {article}
要約:
model : gemini-2.5-pro
max_tokens : 256
temperature : 0.3
- version : v2
status : canary
weight : 0.2
created_at : 2026-04-20
author : masaki
description : "『プロの編集者』を『熟練のライター』に変更し、箇条書き出力を禁止するガードを追加"
template : |
あなたは熟練のライターです。以下の記事を3文で要約してください。
出力は必ず通常の文章で行い、箇条書きや見出しは使わないでください。
記事: {article}
要約:
model : gemini-2.5-pro
max_tokens : 256
temperature : 0.3
このスキーマの要点は3つあります。status フィールドで「本番で使ってよいか」を明示すること、weight でルーターが各バージョンを選ぶ確率を制御すること、description で「なぜこの変更を入れたか」を必ず残すこと。最後の点は地味ですが、半年後の自分に「なぜ v3 を作ったんだっけ?」と悩まされないためのセーフティネットとして決定的に重要です。
次に、Python で読み込み側を実装します。型安全性のために Pydantic を使い、Antigravity のエディタ補完も効くようにします。
# src/prompt_store.py
# プロンプトストアの読み込みと検証を担当するモジュール
from __future__ import annotations
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field, field_validator
class PromptVersion ( BaseModel ):
"""単一プロンプトバージョンの定義"""
version: str
status: Literal[ "stable" , "canary" , "deprecated" ]
weight: float = Field( ge = 0.0 , le = 1.0 ) # 0〜1 の確率
created_at: str
author: str
template: str
model: str
max_tokens: int = Field( gt = 0 )
temperature: float = Field( ge = 0.0 , le = 2.0 )
description: str | None = None
class PromptDefinition ( BaseModel ):
"""プロンプト(複数バージョンを持つ論理単位)"""
id : str
versions: list[PromptVersion]
@field_validator ( "versions" )
@ classmethod
def weights_must_sum_to_one (cls, versions: list[PromptVersion]) -> list[PromptVersion]:
# deprecated を除いた合計が 1.0 でないとルーターが破綻する
active = [v for v in versions if v.status != "deprecated" ]
total = sum (v.weight for v in active)
if abs (total - 1.0 ) > 1e-6 :
raise ValueError (
f "active weight sum must be 1.0, got { total :.4f } . "
f "Check prompt id= { versions[ 0 ].version if versions else 'unknown' } "
)
return versions
class PromptStore :
"""YAMLディレクトリ全体をインメモリにロードするストア"""
def __init__ (self, root: Path) -> None :
self .root = root
self ._prompts: dict[ str , PromptDefinition] = {}
self ._load()
def _load (self) -> None :
for yaml_file in self .root.glob( "*.yaml" ):
try :
data = yaml.safe_load(yaml_file.read_text( encoding = "utf-8" ))
prompt = PromptDefinition.model_validate(data)
self ._prompts[prompt.id] = prompt
except Exception as e:
# 1つの YAML が壊れていても全体を止めず、警告を出す
# 本番では監視に通知するフックを入れる
print ( f "[prompt_store] failed to load { yaml_file.name } : { e } " )
def get (self, prompt_id: str ) -> PromptDefinition:
if prompt_id not in self ._prompts:
raise KeyError ( f "prompt not found: { prompt_id } " )
return self ._prompts[prompt_id]
def list_ids (self) -> list[ str ]:
return sorted ( self ._prompts.keys())
# 動作確認
if __name__ == "__main__" :
store = PromptStore(Path( "prompts" ))
print ( "loaded:" , store.list_ids())
p = store.get( "summarize_article" )
print ( f "versions: { [v.version for v in p.versions] } " )
# 期待する出力:
# loaded: ['summarize_article']
# versions: ['v1', 'v2']
ここで重要なのは weights_must_sum_to_one のバリデーションです。気軽に weight: 0.5 と書いてしまうと、全バージョンの合計が1.0でなくなり、ルーターの確率計算が壊れます。起動時に失敗させる のが一番優しいので、Pydantic のフィールドバリデータで厳密にチェックしています。
A/B テストランナーの実装 — 重み付きルーティングと統計収集
次に、weight を使って実リクエストを振り分けるルーターを実装します。ポイントは「同じユーザーには同じバージョンを返す」ことで、学習データとしての一貫性を保つことです。
# src/prompt_router.py
# リクエストごとにプロンプトバージョンを選択するルーター
from __future__ import annotations
import hashlib
import random
from dataclasses import dataclass
from src.prompt_store import PromptStore, PromptVersion
@dataclass
class RoutingResult :
version: PromptVersion
routing_key: str # ログに記録して追跡する
class PromptRouter :
def __init__ (self, store: PromptStore) -> None :
self .store = store
def select (self, prompt_id: str , user_id: str | None = None ) -> RoutingResult:
"""
user_id が与えられた場合は「スティッキー」ルーティング。
同じユーザーには常に同じバージョンを返す(学習データの一貫性確保)。
"""
prompt = self .store.get(prompt_id)
active = [v for v in prompt.versions if v.status != "deprecated" ]
if not active:
raise RuntimeError ( f "no active versions for { prompt_id } " )
# スティッキールーティング: user_id のハッシュで 0.0〜1.0 を生成
if user_id:
h = hashlib.sha256( f " { prompt_id } : { user_id } " .encode()).hexdigest()
r = int (h[: 8 ], 16 ) / 0x FFFFFFFF
else :
# user_id がない場合(バッチ処理等)はランダム
r = random.random()
cumulative = 0.0
for v in active:
cumulative += v.weight
if r < cumulative:
return RoutingResult( version = v, routing_key = f " { prompt_id } : { v.version } " )
# 浮動小数点の丸め誤差で最後まで到達する場合のフォールバック
return RoutingResult( version = active[ - 1 ], routing_key = f " { prompt_id } : { active[ - 1 ].version } " )
# 動作確認: 10,000 ユーザーで分布を検証
if __name__ == "__main__" :
from pathlib import Path
from collections import Counter
store = PromptStore(Path( "prompts" ))
router = PromptRouter(store)
counts = Counter()
for i in range ( 10000 ):
result = router.select( "summarize_article" , user_id = f "user_ { i } " )
counts[result.version.version] += 1
print (counts)
# 期待する出力(おおむね):
# Counter({'v1': 8000, 'v2': 2000}) (誤差±1%以内)
スティッキールーティングが重要な理由は2つあります。ひとつは上で述べた学習データの一貫性ですが、もうひとつはユーザー体験の一貫性 です。同じユーザーが同じ質問をしたのに毎回違う応答スタイルで返ってきたら、単純に使いにくいですよね。ハッシュベースのルーティングは、この2つを同時に解決します。
プロンプト評価パイプライン:自動品質スコアリング
A/B テストで振り分けができるようになったら、次は「どちらのバージョンが良いか」を判断する評価層です。評価アプローチは3つあります。
ルールベース評価は、出力の形式・禁止ワード・文字数といった客観的な基準をチェックするシンプルな方式です。速くて安いので、CI で毎回走らせます。LLM-as-a-Judge は、別の強いモデル(例:Gemini 2.5 Pro)に「この応答の質を10点満点で評価して」と頼む方式です。主観的な品質を数値化できますが、評価コストがかかります。ヒューマン評価は最も信頼できますが、スループットが低く、サンプリングで使います。
私の経験では、3層を組み合わせる のが最もコスパが良いです。全リクエストにルールベースを、10%のサンプリングで LLM-as-a-Judge を、週に一度ヒューマン評価でキャリブレーションする、という構成です。以下は LLM-as-a-Judge の最小実装です。
# src/evaluator.py
# 応答品質を LLM で 0〜10 点に評価するモジュール
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any
import google.generativeai as genai
genai.configure( api_key = os.environ[ "GOOGLE_API_KEY" ]) # 環境変数必須
JUDGE_PROMPT = """ \
あなたはAI応答の品質を評価する厳格な審査員です。
以下の「入力」に対して、「AIの応答」を0〜10点で評価してください。
評価基準:
- 関連性 (query との一致度): 0-3点
- 事実性 (捏造のなさ): 0-4点
- 明瞭性 (読みやすさ): 0-3点
合計点と、各基準のスコア、簡単な理由を JSON で返してください。
他の文字は一切出力せず、JSONオブジェクトのみを返してください。
入力: {query}
AIの応答: {response}
出力形式:
{{ "total": 8, "relevance": 3, "factuality": 3, "clarity": 2, "reason": "..." }}
"""
@dataclass
class EvalResult :
total: float
relevance: float
factuality: float
clarity: float
reason: str
def evaluate_response (query: str , response: str , judge_model: str = "gemini-2.5-pro" ) -> EvalResult:
"""LLM-as-a-Judge で応答品質を評価する"""
model = genai.GenerativeModel(judge_model)
prompt = JUDGE_PROMPT .format( query = query, response = response)
try :
result = model.generate_content(prompt, generation_config = { "temperature" : 0.0 })
# マークダウンの ``` が混入することがあるのでストリップ
text = result.text.strip().removeprefix( "```json" ).removeprefix( "```" ).removesuffix( "```" ).strip()
data: dict[ str , Any] = json.loads(text)
return EvalResult(
total = float (data[ "total" ]),
relevance = float (data[ "relevance" ]),
factuality = float (data[ "factuality" ]),
clarity = float (data[ "clarity" ]),
reason = data.get( "reason" , "" ),
)
except json.JSONDecodeError as e:
# LLMが指示に反してJSON以外を返すことがある。再試行ではなくフォールバックで続行
return EvalResult( total = 0.0 , relevance = 0.0 , factuality = 0.0 , clarity = 0.0 ,
reason = f "judge output parse error: { e } " )
except Exception as e:
# API エラー等はスコアなしで返し、呼び出し側で分岐できるようにする
return EvalResult( total =- 1.0 , relevance =- 1.0 , factuality =- 1.0 , clarity =- 1.0 ,
reason = f "judge error: { type (e). __name__ } : { e } " )
# 動作確認
if __name__ == "__main__" :
r = evaluate_response(
query = "今日の東京の天気は?" ,
response = "今日の東京は晴れ時々曇りで、最高気温は22度です。"
)
print ( f "total= { r.total } , reason= { r.reason } " )
# 期待する出力例:
# total=8.0, reason="関連性は高いが、天気情報の出典が不明で事実性を減点"
temperature=0.0 で呼び出しているのは、評価の再現性を確保するためです。評価モデル自身がブレていたら、A/B テストの比較が意味をなくしてしまいます。また、total=-1.0 をエラー表現として返しているのは、0.0 と「評価失敗」を呼び出し側で区別できるようにするためです。
この3つのコードを組み合わせることで、「どのユーザーに」「どのバージョンが」「どんな品質スコアで」応答したかを、すべてログに蓄積できます。蓄積したログは BigQuery や Postgres に流し込み、週次で「v1 と v2 の平均スコア差は統計的に有意か」という問いに答えられるようになります。
より広い観測基盤とのつなぎ込みについては、Antigravity × OpenTelemetry で実装するAIオブザーバビリティパイプライン で詳しく書いたので、本番導入時はあわせて参照してください。
Antigravity エディタから本番のプロンプトを安全に編集する
基盤が整ったら、次は運用です。プロンプト編集を誰もが安全に行える体験を作らないと、せっかくの仕組みが宝の持ち腐れになります。私が Antigravity で実践している運用フローを紹介します。
第一に、プロンプト専用のブランチ命名規則 を決めます。prompt/{prompt-id}/{short-description} という規則にして、PR タイトルにも自動でプロンプト ID が入るようにします。Antigravity の AI エージェントに「プロンプトの変更なので、yaml の diff を日本語で要約してコミットメッセージを生成して」と指示すると、レビュー時に「なぜこの変更をしたか」が明確になります。
第二に、PR テンプレートに評価結果を貼る ことを義務付けます。CI で v1 と新バージョンを50件ずつ同じ入力で走らせ、品質スコアを比較するスクリプトを走らせます。結果を PR に自動コメントさせ、「新バージョンが平均0.3点以上劣化している場合はマージをブロック」というルールを GitHub Actions で強制します。この一手間で、「感覚では改善したつもりが、実は退化していた」事故を防げます。
第三に、canary ステータスの活用 です。新バージョンを投入するときは必ず weight: 0.1 の canary として本番投入し、48時間のメトリクスを確認してから stable に昇格させます。問題が起きても影響は10%以下に抑えられ、ロールバックも weight: 0.0 に変えるだけで即時反映されます。
このフローを回すときに Antigravity が光るのは、エディタと評価パイプラインが同じワークスペースに同居していること です。プロンプトを書き換え、AI エージェントに「同じ入力で v1 と比較して、差が大きいケースを教えて」と頼むと、10分程度で手応えが掴めます。従来のように「IDE でプロンプト編集 → ターミナルでテスト実行 → スプレッドシートでスコア比較」と3つのツールを行き来する必要がなくなり、思考の流れが切れません。
本番運用のより体系的な観点については、Antigravity エージェントの SRE を始める — SLO とエラーバジェットの設計 もあわせてお読みください。この管理基盤は LLMOps 全体の一部として位置づけられます。
本番運用での落とし穴とリカバリ戦略
ここからは、実際に数ヶ月運用して遭遇した落とし穴と、その対策を共有します。運用初期に知っておくと、同じ失敗を避けられます。
落とし穴1:weight の合計が1.0 にならないまま本番デプロイされる 。これは気を抜くと頻発します。「一時的に v2 を 100% にしたいから v1 を 0.0 に」と書いたあと、v1 の weight を元に戻し忘れる、というパターンです。対策は、YAML のバリデーションを CI で走らせ、合計が1.0でなければビルドを失敗させることです。上のコードの weights_must_sum_to_one を CLI として独立させ、pre-commit にも登録すると二重に守れます。
落とし穴2:プロンプトのテンプレート変数と渡すキーが噛み合わない 。{article} を {content} に改名したのに、呼び出し側のコードが古いままだった、という典型例です。対策は、プロンプト YAML から必要な変数名を抽出し、渡されるキーと一致しているかを実行時にチェックすることです。Python なら string.Formatter().parse() で変数名を抜き出し、set(template_vars) <= set(provided.keys()) を検証します。
落とし穴3:LLM-as-a-Judge のコストが想定を超える 。全リクエストに評価を走らせると、評価コストが本番コストと同等になります。対策はサンプリング率の明示的な設計です。私は「新バージョン投入直後は50%、安定したら5%」というスライディング設計を採用しています。コストは約10分の1に収まりますが、検出力はほぼ落ちません。
落とし穴4:ロールバックが「weight を戻すだけで本当にいいのか」問題 。スティッキールーティングしている場合、ロールバック後も同じユーザーには壊れたバージョンが返り続けます。これを防ぐには、ハッシュの種にタイムスタンプを混ぜるなど、「強制再振り分け」の仕組みを用意しておきます。私はforce_reshuffle_after: 2026-04-20T10:00 という設定を各バージョンに持たせ、この時刻以降のリクエストは新しいハッシュで再振り分けされる実装にしています。
落とし穴5:プロンプトと評価基準のミスマッチ 。プロンプトを「箇条書きで答えて」に変えたのに、評価基準が「通常の文章で流暢か」のままだと、スコアが落ちて当然です。プロンプト変更時には評価基準のレビューも同時にする、というルールが必要です。PR テンプレートに「評価基準に変更が必要ですか?」というチェックボックスを入れておくだけで、この行き違いはかなり減らせます。
Executor層の実装 — 選ばれたプロンプトを実際に実行する
ここまでのStore・Router・Evaluatorをつなぐのが Executor 層です。この層の役割は、ルーターが選んだバージョンで実際に LLM を呼び、トークン消費・レイテンシ・コストを記録し、Evaluatorに渡す応答オブジェクトを返すことです。見落としがちですが、ここに可観測性の核が入ります。
# src/executor.py
# 選ばれたプロンプトを実行し、メトリクスを記録するレイヤー
from __future__ import annotations
import time
import json
from dataclasses import dataclass, asdict
from pathlib import Path
import google.generativeai as genai
from src.prompt_router import RoutingResult
@dataclass
class ExecutionRecord :
"""1リクエストあたりの観測レコード"""
prompt_id: str
version: str
user_id: str | None
query: str
response: str
input_tokens: int
output_tokens: int
latency_ms: float
error: str | None = None
def _estimate_cost_jpy (input_tokens: int , output_tokens: int , model: str ) -> float :
"""モデルごとの1Mトークン単価を掛けて日本円に換算(概算)"""
# 本番では単価テーブルをYAMLに外出しするのがおすすめ
table = {
"gemini-2.5-pro" : ( 0.375 , 1.125 ), # (input/1M, output/1M) USD
"gemini-2.5-flash" : ( 0.075 , 0.225 ),
}
ipm, opm = table.get(model, ( 0.3 , 1.0 ))
usd = (input_tokens / 1_000_000 ) * ipm + (output_tokens / 1_000_000 ) * opm
return round (usd * 155 , 4 ) # 概算USD/JPY = 155
class PromptExecutor :
def __init__ (self, log_path: Path = Path( "logs/prompt_runs.jsonl" )) -> None :
self .log_path = log_path
self .log_path.parent.mkdir( parents = True , exist_ok = True )
def run (self, routing: RoutingResult, variables: dict[ str , str ],
user_id: str | None = None ) -> ExecutionRecord:
v = routing.version
# テンプレート変数の欠落チェック(落とし穴2の対策)
try :
prompt_text = v.template.format( ** variables)
except KeyError as e:
raise ValueError (
f "missing template variable { e } for prompt { routing.routing_key } "
) from e
model = genai.GenerativeModel(v.model)
start = time.perf_counter()
error: str | None = None
response_text = ""
input_tokens = output_tokens = 0
try :
response = model.generate_content(
prompt_text,
generation_config = {
"temperature" : v.temperature,
"max_output_tokens" : v.max_tokens,
},
)
response_text = response.text
# google-generativeai の usage_metadata から取得
um = getattr (response, "usage_metadata" , None )
if um:
input_tokens = getattr (um, "prompt_token_count" , 0 )
output_tokens = getattr (um, "candidates_token_count" , 0 )
except Exception as e:
error = f " { type (e). __name__ } : { e } "
latency_ms = (time.perf_counter() - start) * 1000.0
record = ExecutionRecord(
prompt_id = routing.routing_key.split( ":" )[ 0 ],
version = v.version,
user_id = user_id,
query = variables.get( "article" , "" )[: 100 ], # 先頭100文字のみ記録
response = response_text,
input_tokens = input_tokens,
output_tokens = output_tokens,
latency_ms = latency_ms,
error = error,
)
self ._persist(record)
return record
def _persist (self, rec: ExecutionRecord) -> None :
# JSONL形式でファイルに追記。本番ではBigQuery/Postgresに送る
with self .log_path.open( "a" , encoding = "utf-8" ) as f:
f.write(json.dumps(asdict(rec), ensure_ascii = False ) + " \n " )
# 動作確認(API key 必須)
if __name__ == "__main__" :
import os
from pathlib import Path
from src.prompt_store import PromptStore
from src.prompt_router import PromptRouter
genai.configure( api_key = os.environ[ "GOOGLE_API_KEY" ])
store = PromptStore(Path( "prompts" ))
router = PromptRouter(store)
executor = PromptExecutor()
routing = router.select( "summarize_article" , user_id = "user_42" )
record = executor.run(routing, { "article" : "昨日、東京で桜が満開を迎えました。" }, user_id = "user_42" )
cost = _estimate_cost_jpy(record.input_tokens, record.output_tokens, "gemini-2.5-pro" )
print ( f "version= { record.version } , latency= { record.latency_ms :.0f } ms, cost=¥ { cost } " )
# 期待する出力例:
# version=v1, latency=820ms, cost=¥0.12
重要なのは、エラーをスタックトレースで握りつぶさずExecutionRecord.errorに必ず記録する ことです。分析時に「この1時間で失敗率が跳ねている」と気づくには、エラーも同じログに並んでいる必要があります。また、queryフィールドに先頭100文字だけ記録しているのは、PIIの流出を避けつつ傾向分析はできるようにする折衷案です。要件に応じてハッシュ化するかマスキングしてください。
CI/CD と統合する — 毎PRで品質差分を自動レポート
手動で評価を回すのは最初の数回だけで、すぐに面倒になります。GitHub Actions に組み込むと、PR を出すだけで評価が自動実行され、結果がコメントされます。以下は実際に私が使っているワークフローの簡略版です。
# .github/workflows/prompt-eval.yml
name : Prompt Evaluation
on :
pull_request :
paths :
- "prompts/**.yaml"
jobs :
evaluate :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
with :
fetch-depth : 0
- name : Setup Python
uses : actions/setup-python@v5
with :
python-version : "3.12"
- name : Install deps
run : pip install pydantic pyyaml google-generativeai
- name : Run comparative evaluation
env :
GOOGLE_API_KEY : ${{ secrets.GOOGLE_API_KEY }}
run : |
python scripts/compare_versions.py \
--prompt-id summarize_article \
--baseline-ref origin/main \
--candidate-ref HEAD \
--sample-size 50 \
--output eval_report.md \
--json-output eval_report.json
- name : Comment on PR
uses : marocchino/sticky-pull-request-comment@v2
with :
path : eval_report.md
- name : Fail if quality regressed
run : |
SCORE_DIFF=$(jq '.diff' eval_report.json)
if (( $(echo "$SCORE_DIFF < -0.3" | bc -l) )); then
echo "Quality regressed by $SCORE_DIFF — blocking merge"
exit 1
fi
paths フィルターで prompts/ 配下の変更だけをトリガーにすることで、関係ない PR で余計な API 料金を払わずに済みます。しきい値 -0.3 は経験的に、「明らかに退化している」を表す値です。プロジェクトの特性に応じて調整してください。厳しすぎると改善PRが通らなくなり、緩すぎると退化が素通りします。
ワークフローが呼んでいる scripts/compare_versions.py は、記事の初出時に載せそびれていました。ここに置いておきます。やっていることは単純で、baseline と candidate の2つの Git ref からそれぞれのプロンプト YAML を取り出し、同じ入力セットで走らせて、スコア差を Markdown と JSON の両方に書き出すだけです。JSON を出すのは、最後のステップの jq がそれを読むためです。
# scripts/compare_versions.py
import argparse, json, subprocess, statistics as st
from pathlib import Path
import yaml
from src.executor import PromptExecutor # 前節の Executor
from src.evaluator import evaluate_response # 前節の Evaluator
def load_prompt_at_ref (ref: str , prompt_id: str ) -> dict :
"""指定した Git ref 時点の prompts/{id}.yaml を取り出す(作業ツリーは汚さない)"""
path = f "prompts/ { prompt_id } .yaml"
blob = subprocess.run(
[ "git" , "show" , f " { ref } : { path } " ],
capture_output = True , text = True , check = True ,
).stdout
return yaml.safe_load(blob)
def stable_version (defn: dict ) -> dict :
"""weight が最大のものを、そのバージョン群の代表として選ぶ"""
active = [v for v in defn[ "versions" ] if v[ "status" ] != "deprecated" ]
if not active:
raise SystemExit ( f "no active version in { defn[ 'id' ] } " )
return max (active, key =lambda v: v.get( "weight" , 0.0 ))
def run_side (version: dict , samples: list[ dict ]) -> list[ float ]:
executor = PromptExecutor( template = version[ "template" ], model = version[ "model" ])
scores = []
for s in samples:
resp = executor.run(s[ "inputs" ])
scores.append(evaluate_response(resp, expected = s.get( "expected" )).total)
return scores
def main () -> None :
p = argparse.ArgumentParser()
p.add_argument( "--prompt-id" , required = True )
p.add_argument( "--baseline-ref" , default = "origin/main" )
p.add_argument( "--candidate-ref" , default = "HEAD" )
p.add_argument( "--sample-size" , type = int , default = 50 )
p.add_argument( "--fixtures" , default = "eval/fixtures.jsonl" )
p.add_argument( "--output" , required = True )
p.add_argument( "--json-output" , required = True )
args = p.parse_args()
samples = [json.loads(l) for l in Path(args.fixtures).read_text().splitlines() if l.strip()]
samples = samples[: args.sample_size]
base = stable_version(load_prompt_at_ref(args.baseline_ref, args.prompt_id))
cand = stable_version(load_prompt_at_ref(args.candidate_ref, args.prompt_id))
if base[ "template" ] == cand[ "template" ] and base[ "model" ] == cand[ "model" ]:
Path(args.output).write_text( "プロンプト本体に差分がないため評価をスキップしました。 \n " )
Path(args.json_output).write_text(json.dumps({ "diff" : 0.0 , "skipped" : True }))
return
base_scores = run_side(base, samples)
cand_scores = run_side(cand, samples)
diff = st.mean(cand_scores) - st.mean(base_scores)
# 悪化した順に並べ、レビューで目を通すべきケースを上に出す
worst = sorted (
({ "i" : i, "delta" : c - b} for i, (b, c) in enumerate ( zip (base_scores, cand_scores))),
key =lambda d: d[ "delta" ],
)[: 5 ]
md = [
f "### プロンプト評価: ` { args.prompt_id } `" ,
"" ,
f "- baseline ` { base[ 'version' ] } ` 平均 ** { st.mean(base_scores) :.3f } **(n= { len (base_scores) } )" ,
f "- candidate ` { cand[ 'version' ] } ` 平均 ** { st.mean(cand_scores) :.3f } **(n= { len (cand_scores) } )" ,
f "- 差分 ** { diff :+.3f } **" ,
"" ,
"悪化幅の大きいケース: " + ", " .join( f "# { w[ 'i' ] } ( { w[ 'delta' ] :+.2f } )" for w in worst),
]
Path(args.output).write_text( " \n " .join(md) + " \n " )
Path(args.json_output).write_text(json.dumps({
"prompt_id" : args.prompt_id,
"baseline" : base[ "version" ], "candidate" : cand[ "version" ],
"n" : len (samples), "diff" : round (diff, 4 ), "skipped" : False ,
}))
if __name__ == "__main__" :
main()
このスクリプトで意識的にそうしている点が3つあります。第一に、git show でプロンプトを取り出していることです。ブランチを checkout し直す実装にすると、CI の作業ツリーが動いてしまい、他のステップと干渉します。第二に、テンプレートとモデルが同一なら評価をスキップして 0.0 を返すことです。コメントの修正だけの PR で API 料金を払う必要はありません。第三に、悪化幅が大きいケースの番号を Markdown に出していることです。平均値だけを見せられても、レビュアーは「で、何が起きたのか」を確認できません。番号があれば、その入力だけを手元で再実行できます。
なお、--json-output を後から足したのは、jq '.diff' eval_report.json が動かずに CI が落ちたからです。Markdown だけを出力していて、しきい値判定のステップが読むファイルが存在しませんでした。しきい値でマージをブロックする仕組みは、ブロックが効いていること自体を一度確かめておかないと、静かに無効なまま何ヶ月も過ぎてしまいます。
私は半年この運用を回してきて、「PRを出したら機械的に品質レポートがつく」という体験が、チーム全体のプロンプト品質観を変えたと感じています。数値が目の前にあると、自然と「なぜこの変更でスコアが下がったのか」を考え始めます。数値がないと、主観の押し付け合いにしかなりません。
実運用への移行プレイブック:既存プロジェクトへの導入手順
ここからは、既に稼働中のプロジェクトにこの基盤を「既存コードを壊さずに」導入する現実的な手順を紹介します。理想論ではなく、私が実プロジェクトで使った順序です。
第一段階は 「書き込み側だけ切り出す」 です。まず全プロンプトを prompts/ 配下のYAMLに書き出し、それと並行してアプリケーションコード側には今まで通りの文字列を残しておきます。この時点ではアプリケーションは YAML を読んでいないので、挙動は変わりません。目的は「プロンプトをGit管理に乗せる」ことと、レビュー習慣を作ることです。1〜2週間運用すると、「プロンプトを触るにはPRが必要」という感覚がチームに馴染みます。
第二段階は 「1つのプロンプトだけStoreから読み込む」 です。重要度の低いプロンプト(社内デバッグ用など)を1つ選び、アプリケーションコード側を prompt_store.get("xxx") に差し替えます。この段階でバグを踏んだら影響は限定的なので、実装上の問題を早期に炙り出せます。私は最初の移行で status フィールドの誤記と、weight 合計が1.0でない問題の両方を踏み、CI バリデーションの必要性を痛感しました。
第三段階は 「残りのプロンプトを段階的に移行」 です。1週間に2〜3プロンプトのペースで十分です。一気に全部移すと、問題が起きたときの切り分けが難しくなります。この段階でA/B ルーターも導入し、新しいバージョンは必ずcanaryから始めるルールを確立します。
第四段階は 「評価パイプラインの本番統合」 です。ここで初めてBigQueryやPostgresにログを流し込み、週次のレポートを自動化します。ダッシュボード整備は地味で後回しにしがちですが、これがあるかないかで「なんとなく運用」と「数値で運用」の分水嶺になります。
ダッシュボード用SQL — 日々の運用で本当に使うクエリ
最後に、評価ログを貯めた後に実際に使うクエリを3つ紹介します。毎朝これを眺めるだけで、プロンプト運用の健康状態がわかります。
-- Query 1: バージョンごとの直近24時間の平均品質スコアとレイテンシ
SELECT
prompt_id,
version ,
COUNT ( * ) AS requests,
AVG (eval_total) AS avg_score,
AVG (latency_ms) AS avg_latency_ms,
SUM (input_tokens + output_tokens) AS total_tokens
FROM prompt_runs
WHERE ts >= CURRENT_TIMESTAMP () - INTERVAL 24 HOUR
AND error IS NULL
GROUP BY prompt_id, version
ORDER BY prompt_id, avg_score DESC ;
-- Query 2: A/Bテストの統計的有意性チェック(Welch's t-test の簡易版)
-- canary版が本当にstable版より良いかを数値で判定する
WITH stats AS (
SELECT
version ,
AVG (eval_total) AS mean,
STDDEV(eval_total) AS stddev,
COUNT ( * ) AS n
FROM prompt_runs
WHERE prompt_id = 'summarize_article'
AND ts >= CURRENT_TIMESTAMP () - INTERVAL 7 DAY
AND error IS NULL
GROUP BY version
)
SELECT
a . version AS version_a, a . mean AS mean_a,
b . version AS version_b, b . mean AS mean_b,
( a . mean - b . mean ) / SQRT (( a . stddev * a . stddev ) / a . n + ( b . stddev * b . stddev ) / b . n ) AS t_stat
FROM stats a JOIN stats b ON a . version < b . version ;
-- t_stat の絶対値が約2を超えたら95%水準で有意差ありの目安
-- Query 3: エラー発生の時系列 — 新バージョンデプロイ後に失敗率が跳ねていないか
SELECT
DATE_TRUNC(ts, HOUR ) AS hour ,
version ,
COUNT ( * ) AS total,
SUM ( CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END ) AS errors,
ROUND ( SUM ( CASE WHEN error IS NOT NULL THEN 1 . 0 ELSE 0 . 0 END ) * 100 / COUNT ( * ), 2 ) AS error_rate_pct
FROM prompt_runs
WHERE ts >= CURRENT_TIMESTAMP () - INTERVAL 48 HOUR
GROUP BY hour , version
ORDER BY hour DESC , version ;
これら3つのクエリを Looker Studio や Metabase でダッシュボード化し、朝一でチェックする習慣をつけると、ほぼすべての品質問題は「本番に波及する前」に気付けます。私は特に Query 3 の効果を実感していて、canary 投入後のエラー率が stable 版の2倍を超えた瞬間にSlackに通知するアラートを設定しているだけで、真夜中の障害対応がほぼゼロになりました。
毎朝ダッシュボードで勝敗を決めていた頃の話 — のぞき見の誤検出を測ってみました
Query 2 を書いた当初、私は「t の絶対値が2を超えたら candidate の勝ち」と決めて、毎朝ダッシュボードでその値を眺めていました。canary 投入から3日目に t が 2.1 まで伸びたのを見て stable に昇格させた、ということも何度かあります。
その運用を続けていたある時期、昇格させたはずの新バージョンのスコアが、数週間後には元のバージョンとほとんど変わらない水準に落ち着く、という現象が続けて起きました。プロンプトが悪かったのか、それとも判定の仕方が悪かったのか。切り分けるために、実際に数字を出してみることにしました。
条件は自分の運用に寄せています。評価に回るのは各バージョン1日25件(canary 10% にサンプリングを掛けた実測に近い数)、観測期間は14日、スコアの標準偏差は 0.90。この設定で、2つのバージョンに本当は差がない場合と、差がある場合をそれぞれ2万回ずつ生成し、「毎日 t を見て2を超えた時点で判定する」運用と「14日目に1回だけ判定する」運用を比べました。
# eval/peeking_sim.py — 手元で回せます(numpy のみ)
import numpy as np
TRIALS , PER_DAY , DAYS , SD , BASE = 20000 , 25 , 14 , 0.90 , 3.80
def simulate (lift: float , seed: int , threshold: float = 2.0 ):
rng = np.random.default_rng(seed)
a_all = np.clip(rng.normal( BASE , SD , ( TRIALS , DAYS * PER_DAY )), 0 , 5 )
b_all = np.clip(rng.normal( BASE + lift, SD , ( TRIALS , DAYS * PER_DAY )), 0 , 5 )
flagged = np.zeros( TRIALS , dtype = bool )
first_day = np.full( TRIALS , - 1 )
final = None
for day in range ( 1 , DAYS + 1 ):
n = day * PER_DAY
a, b = a_all[:, :n], b_all[:, :n]
se = np.sqrt(a.var( 1 , ddof = 1 ) / n + b.var( 1 , ddof = 1 ) / n)
t = (b.mean( 1 ) - a.mean( 1 )) / se # Welch の t(Query 2 と同じ式)
hit = np.abs(t) > threshold
first_day[hit & ~ flagged] = day
flagged |= hit
if day == DAYS :
final = hit
return flagged.mean(), final.mean(), first_day[first_day > 0 ].mean()
for lift in ( 0.00 , 0.10 , 0.20 , 0.40 ):
peek, fixed, day = simulate(lift, seed = 424242 + int (lift * 100 ))
print ( f "lift= { lift :.2f } 毎日判定= { peek :.3f } 14日固定= { fixed :.3f } 初回フラグ平均日= { day :.1f } " )
出た数字がこちらです。lift は candidate 側に乗せた本当のスコア差で、0.00 は「2つのバージョンに差がない」状態を意味します。
本当の差 毎日 t を見て判定 14日目に1回だけ判定 初回フラグの平均日
0.00(差なし) 20.9% 4.3% 4.7日目
+0.10 47.1% 28.9% 6.1日目
+0.20 88.0% 80.8% 5.5日目
+0.40 100.0% 100.0% 2.5日目
差がまったくないのに、毎朝のぞく運用では 20.9% が「有意差あり」と判定されました。14日目に1回だけ見る運用の 4.3% と比べると、5倍近い開きです。5回に1回、存在しない改善を信じて昇格させていた計算になります。しかも初回のフラグが立つのは平均4.7日目で、体感としてはむしろ「早めに手応えが出た、筋の良い変更」に見えてしまいます。
昇格させた新バージョンのスコアが後から元の水準に戻っていたのは、プロンプトが劣化したのではなく、最初から差がなかった、というだけのことでした。
素直に思いつく対策は、しきい値を上げることです。同じシミュレーションで 3.0 にしてみました。
判定しきい値 差なしの誤検出(毎日判定) +0.20 を検出できる割合(毎日判定)
|t| > 2.0 20.9% 88.0%
|t| > 3.0 2.0% 54.2%
誤検出は 2.0% まで下がりましたが、本物の +0.20 を見つけられる割合が 88.0% から 54.2% へ落ちました。しきい値を上げる操作は、誤検出と見逃しを交換しているだけで、判定そのものが良くなったわけではありません。半分の改善を捨てるのなら、プロンプトを磨く意味が薄れます。
結局のところ、問題はしきい値ではなく「何回見たか」でした。毎日見れば14回の判定機会があり、そのどれか1回でも当たれば勝ちにしてしまえば、当たる確率は上がります。ですから私が採用したのは、先に期間を決めてしまう というやり方です。検出したい差の大きさから必要な件数を逆算し、その日数が過ぎるまで「勝ったかどうか」は見ない、と決めました。
検出したい差 必要な件数(各バージョン) 1日25件なら
+0.10 1,271件 51日
+0.15 565件 23日
+0.20 318件 13日
+0.30 142件 6日
標準偏差 0.90、有意水準5%、検出力80% で計算した目安です。個人開発で回している以上、評価に回せる件数は増やしようがありません。その制約が「なんとなく足りない」から「+0.10 は51日かかる」という数字に変わったのが、この表を作って一番良かった点でした。この表を見て、私はプロンプト改善の目標を +0.20 前後に置き、13日を1サイクルとして固定しました。それ未満の差は「今の観測量では見えないもの」として、追わないと決めています。
+0.10 を確かめたければ51日かかります。51日待てないなら、選択肢は2つです。サンプリング率を上げて1日あたりの件数を増やすか、そもそも +0.10 を狙う変更をやめて、もっと大きく効く変更に取り組むか。私は後者を選ぶことが多くなりました。この表を作ってから、「文言を少し整える」類の PR を出す回数が明確に減っています。
ひとつ補足しておきます。期間を固定するのは「勝敗の判定」だけです。この記事の前半で canary を48時間見てから昇格させる、と書きましたが、あの48時間は壊れていないことの確認 であって、良くなったことの確認ではありません。Query 3 のエラー率と Query 1 のレイテンシは毎朝見ます。壊れていないかを監視する作業と、どちらが良いかを決める作業は、同じダッシュボードに並んでいても目的の異なる別の作業だということです。
数字を出してみるまで、私はこの2つを同じ画面で同時にやっていました。誰かに指摘されたわけではなく、後から結果がおかしいと気づいて調べただけです。自分の判定基準そのものを一度測ってみると、思っていたのと違う顔が見えることがあります。
ストレージバックエンドの選び方 — YAMLを卒業する潮時
YAMLから始めたのは意図的な選択です。10%の複雑さで90%の価値が手に入るからです。しかしプロジェクトが育つにつれ、YAMLでは苦しくなる瞬間がきます。その潮時の見極め方をお伝えします。
YAML + Git が正解なのは、プロンプト数がおおむね50以下で、プロンプトを触るエンジニアが数名、そして非エンジニア(プロダクト・コンテンツ・ローカライゼーション)がリアルタイムで編集する必要がない場合です。キラー機能は「すべての変更がPR経由で行われる」こと、つまり全ての変更が必ずレビューされる点です。
次の3つが同時に起き始めたら、YAMLの限界が近いサインです。第一に、非エンジニアがプロンプトを編集したいケースが増え、Gitの使い方を全員に教えるコストが見合わなくなります。第二に、プロンプトをランタイムで変更する必要が出てくる(マルチテナントSaaSでのテナント別カスタマイズなど)。第三に、バージョンごとの評価メタデータ(承認者・承認日時・過去スコアなど)を直接持たせたくなり、YAMLが肥大化して読みにくくなります。
この時点で私が推奨するのは Postgresへの移行 です。ただし重要なのは、Pydanticスキーマは維持すること 。JSONB列に同じ PromptDefinition 形状を保存すれば、PromptStore の実装を差し替えるだけで済み、ルーター以降のパイプラインは一切変更不要です。ここで4層分離が効いてきます。
データベース移行時によくある罠を一つ挙げると、PRワークフローを捨ててしまう ことです。凝ったWeb UIで誰でも直接保存できるようにすると、半年後には200バージョンが並び、どれが現役かわからなくなります。対策はシンプルで、データベースを「ステージング」として扱うこと。statusをcanaryからstableに昇格させる操作だけは承認が必要なフローにし、承認テーブル(approvals)を同じDB内に持たせます。
評価モデルの選定 — コストと精度のバランス
先ほどのLLM-as-a-JudgeではGemini 2.5 Proを使いました。これは意図的な選択ですが、コスト面で無視できない選択です。私の判断基準を共有しておきます。年間数千ドル単位の差になり得ます。
評価モデルに求められる性質は3つ。スコアの一貫性(同じ入力を2回評価してもほぼ同じ結果になる)、キャリブレーション(人間の評価と少なくともゆるく相関する)、ドメインカバレッジ(医療要約を採点するなら、汎用モデルより少なくとも医療文書に慣れたモデルの方が安心)。
実運用で使ってきた3構成を紹介します。Gemini 2.5 Pro を judge にするのが本番トラフィック向けのデフォルトです。精度が高く、私の実験では人間評価との相関が0.8を超えています。Gemini 2.5 Flash は約5分の1のコストで、相関は0.7程度。CIでの軽量チェックには十分ですが、リリースブロッキング判断には弱い。ファインチューンした小型モデルは終着点で、数十万件のラベル付きデータがあれば相関0.9超も狙えます。私自身はそこまでのボリュームがないので試せていませんが、規模のある組織なら選択肢に入ります。
現場で採用している実用ルールは、本番サンプリングの10%だけProを使って統計分析に、CI実行ではFlashを使う、そして100件程度の人手採点済みキャリブレーションセットを維持してjudgeのプロンプト自体を変更した時だけ走らせる 、というものです。このキャリブレーションセットの維持コストは小さいですが、judgeのドリフトを即座に検知してくれます。既知の良質な応答を突然低く評価し始めたら、何かが変わったサインです。
Antigravityのエージェント基盤との組み合わせ方
Antigravity上でマルチエージェントシステムを構築しているなら、このプロンプト管理基盤はさらに価値を増します。各エージェントは通常3〜10個の独立したプロンプトを使います(プランニング用・ツール呼び出し用・要約用など)。これらを感覚で管理しようとすると、すぐに収拾がつかなくなります。それぞれを個別バージョン管理された資産として扱うだけで、システム全体の理解しやすさが別物になります。
私が採用している具体的なパターンは、各エージェントが使うプロンプトIDのマニフェストを宣言すること です。エージェント起動時に、ランタイムが「すべてのプロンプトIDがストアに存在し、各IDに非deprecatedなバージョンが少なくとも1つある」ことを確認します。これだけで、ユーザーに影響が及ぶ前にデプロイミスを検出できます。評価層と組み合わせれば、エージェントごとに品質を独立して追跡できます。「プランナーのプロンプトは退化したが、サマライザーは問題ない」という粒度の情報は、マルチエージェントの故障原因を切り分けるときに不可欠です。
マルチエージェント設計とプロンプトの組み合わせ方については、AGENTS.md × マルチエージェント アーキテクチャ実践ガイド で詳しく書いたので、本格的に構築するときは併せて読んでみてください。本記事のプロンプト管理基盤は、そのアーキテクチャの「プロンプト・アズ・データ」層として素直に収まります。
基盤を根づかせるために — 数字を共有する習慣
仕組みを作ったあとに残るのは、それを使い続けられるかという問題です。私は、きれいに整った基盤が誰にも使われないまま放置される様子も、YAML 一枚で回しながら毎週改善を出し続けるチームも、どちらも見てきました。差がついたのはツールの出来ではなく、プロンプトを真面目に扱う価値があるという感覚が共有されているかどうかでした。
私が効いたと感じている工夫は3つあります。第一に、品質スコアの改善を機能リリースと同じ扱いで共有すること です。誰かの PR が平均スコアを 0.4 上げたら、その事実を朝の共有で言葉にします。プロンプトを触る作業が「片手間の調整」ではないという合図になります。
第二に、数字をエンジニア以外にも見える場所に置くこと です。週次のスコア推移をプロダクト側やサポート側にも共有していると、質問の質が変わってきます。「なんとなく回答が固い」ではなく「先週からこの指標が下がっているが何かしたか」と聞かれるようになると、対話がずっと速くなります。
第三に、失敗する実験があることを前提にすること です。A/B テストの価値は、良し悪しが分かる点にあります。すべての実験が勝つのなら、試している変更の幅が狭すぎるのだと考えるようにしています。前節で書いた通り、勝ったかどうかを決めるには期間が要りますから、負けを引く覚悟がないと期間を待てません。
この基盤を入れて何が変わったかを一言でまとめるなら、会話の中身です。「この言い回しの方が良い気がする」から「品質は上がったのか」へ。ここに書いたどの実装パターンよりも、その変化のほうが効いたと感じています。
全体を振り返って:プロンプト管理は「習慣化」が最重要
今日できる最初の一歩は、現在アプリケーションコードに埋まっているプロンプトをひとつだけ、prompts/ ディレクトリの YAML に切り出してみること です。それだけで、Git diff でプロンプトの変更履歴が追えるようになり、レビューコメントも書けるようになります。最初のプロンプトを切り出す作業に30分、PR テンプレートを整えるのに1時間、A/B ルーターを入れるのに半日。この投資は、プロジェクトが半年続くなら、必ず元が取れます。
本番で AI 機能を磨き続けるには、プロンプトを「職人の感覚で調整する個別の芸事」から、「チームで安全に改善し続ける開発プロセス」に引き上げる必要があります。そのための基盤は、意外と小さく始められます。完璧を目指さず、まずは YAML 一枚と簡単なバリデーションから始めてみてください。そこから積み上げていくだけで、半年後には「プロンプト変更が怖くないチーム」になっているはずです。