Antigravity で AI エージェントを半年ほど本番運用してきて、いちばん痛い目に遭ったのが「プロンプトを微調整したら、なぜか特定の顧客のユースケースで応答品質が壊れていた」という事件でした。修正したのは1文字だけ、コード差分は2行。しかし、その変更が本番でどんな影響を及ぼしたのかを遡る術がなく、結局3日かけて手動で調査することになりました。
この経験から学んだのは、プロンプトは「設定ファイル」でも「マジック文字列」でもなく、コードと同じく厳密にバージョン管理・テスト・観測すべき第一級の資産 だということです。ここではその信念に基づいて私が Antigravity 上で実装してきたプロンプト管理基盤を、設計思想から動くコードまで公開します。一度この仕組みを整えると、プロンプトの改善サイクルが劇的に速く、かつ安全になります。
なぜプロンプトを「コード」として管理する必要があるのか
プロンプトを文字列として扱うアプローチは、プロトタイプ段階では明確に早く動きます。しかし本番運用に入ると、以下の4つの痛みが必ず顔を出します。
第一に、再現性の欠如 です。「先週は正しく動いていたはずなのに、今週おかしい」という状況で、どのプロンプトがどのレスポンスを生んだのかを追跡できなければ、原因の特定は不可能です。プロンプトは毎日のように微修正されるものですから、Git コミットだけでは不十分で、レスポンスごとに「このバージョン」というトレーサビリティが必要になります。
第二に、比較検証の不在 です。新しいプロンプトが旧プロンプトよりも本当に良いのかは、ユーザー体感だけでは判断できません。私はかつて「この表現の方が自然だ」と確信して差し替えたプロンプトが、実は回答の正確性を10%も落としていたことに、本番投入後2週間気づけなかったことがあります。A/B テストの仕組みがなければ、改善しているつもりで退化することが頻繁に起こります。
第三に、コスト観測の難しさ です。プロンプトの長さ・few-shot 例の数・出力形式は直接 API コストに跳ね返ります。プロンプトが育っていくにつれ月額コストが3倍になっていた、というのはよくある話ですが、それを早期に発見するには「プロンプトバージョンごとのトークン消費量」を記録しておく必要があります。
第四に、安全なロールバックの困難さ です。何か問題が起きたとき、「とりあえず昨日のバージョンに戻したい」と思うのは自然な欲求です。しかし、プロンプトがアプリケーションコードに埋め込まれていると、ロールバックのためだけに新しいデプロイを走らせる必要があります。プロンプトを別のストアに分離していれば、数秒でロールバック可能です。
本記事で作るのは、この4つの痛みを一気に解消する最小構成の管理基盤です。派手な機能は最小限に抑え、1ヶ月以内に自分のプロジェクトに組み込めるサイズ感 に留めています。
プロンプト管理基盤のアーキテクチャ — 4層の責務分離
設計の核は「責務の分離」です。以下の4層に分けると、あとから拡張しやすく、テストも書きやすくなります。
ストア層 (Store) : プロンプトのバージョンと、各バージョンのメタデータを保持します。YAML ファイル or データベース。
ルーター層 (Router) : リクエストごとに「どのバージョンを使うか」を決定します。重み付きルーティングで A/B テストを実現します。
実行層 (Executor) : 選択されたプロンプトを実際に LLM に送り、応答とメトリクス(トークン数・レイテンシ)を返す。
評価層 (Evaluator) : 応答の品質スコアを算出し、ログストアに永続化します。バッチまたはリアルタイム評価。
この分離が重要なのは、それぞれの層を独立して差し替えられる ようになるからです。たとえばストアを YAML から Postgres に移行しても、ルーター以降は一切変更する必要がありません。評価層のアルゴリズムを「ルールベース」から「LLM-as-a-Judge」に変えても、ストアと実行層は無傷です。Antigravity のエージェントから各層を呼び出す際も、インターフェース定義が明確なら迷いません。
この設計思想の背景には、私自身が一度失敗しているという事情もあります。最初は「全部ひとつのファイルで十分」と思って単一モジュールで書き始めたのですが、3ヶ月後には5種類の評価指標と2種類のストアバックエンドを抱え、巨大な泥団子になっていました。Antigravity で AIエージェントを本番投入する前に必ずやるべき評価フレームワーク構築ガイドで触れた教訓ですが、評価ロジックは独立したモジュールに切り出すのが正解です。
プロンプトのバージョニング: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 LLMOps:本番環境でAIモデルを監視・運用するための実践ガイドもあわせてお読みください。この管理基盤は 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
- 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が通らなくなり、緩すぎると退化が素通りします。
私は半年この運用を回してきて、「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に通知するアラートを設定しているだけで、真夜中の障害対応がほぼゼロになりました。
ストレージバックエンドの選び方 — 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 × マルチエージェント アーキテクチャ実践ガイド で詳しく書いたので、本格的に構築するときは併せて読んでみてください。本記事のプロンプト管理基盤は、そのアーキテクチャの「プロンプト・アズ・データ」層として素直に収まります。
全体を振り返って:プロンプト管理は「習慣化」が最重要
今日できる最初の一歩は、現在アプリケーションコードに埋まっているプロンプトをひとつだけ、prompts/ ディレクトリの YAML に切り出してみること です。それだけで、Git diff でプロンプトの変更履歴が追えるようになり、レビューコメントも書けるようになります。最初のプロンプトを切り出す作業に30分、PR テンプレートを整えるのに1時間、A/B ルーターを入れるのに半日。この投資は、プロジェクトが半年続くなら、必ず元が取れます。
本番で AI 機能を磨き続けるには、プロンプトを「職人の感覚で調整する個別の芸事」から、「チームで安全に改善し続ける開発プロセス」に引き上げる必要があります。そのための基盤は、意外と小さく始められます。完璧を目指さず、まずは YAML 一枚と簡単なバリデーションから始めてみてください。そこから積み上げていくだけで、半年後には「プロンプト変更が怖くないチーム」になっているはずです。