プロンプトは、ちょっとした一行修正から壊れる
個人開発で Gemini API を使い始めて一番怖いのは、モデルが落ちることでも、API キーの漏洩でもなく、「昨日までちゃんと動いていた処理が、今日の修正で静かに品質だけ下がる」瞬間だと私は思っています。
つい先日も、自分のアプリのレビュー要約プロンプトに「箇条書きで 5 点」と一行足しただけで、それまで自然に出ていた締めの一文が消え、事実と違う要約が混じるようになりました。テストらしいテストを書いていなかったので、気づいたのはリリース翌日、ユーザーから「最近の要約、なんか雑になった?」と問い合わせが来てからです。小さな修正だったので、Git の履歴を遡る時間より、そもそも「どのあたりが変わったのか」を特定する方に時間がかかりました。
この記事は、そういう事故を次からちゃんと止めたい人のためのガイドです。Pytest を使って Gemini API のプロンプトを回帰テストし、スナップショットと LLM-as-Judge の二段構えで「静かな劣化」を検知する仕組みを、実装コードとあわせて紹介していきます。対象は、Python で Gemini API を触り始めて数週間〜数ヶ月、CI で品質を守る段階に入りたい個人開発者の方を想定しています。チーム開発の方でも、最小構成の出発点として読んでいただけると思います。
この二段構えが大事な理由は、プロンプトが壊れるパターンに 2 種類あるからです。一つは「そもそも出力の形が変わった」タイプ。JSON を返すはずが自然文を返し始める、必須キーが抜ける、といった即死級の障害です。もう一つは「形は合っているけど内容が静かに下がった」タイプ。要約がユーザーの不満点を拾わなくなる、トーンが攻撃的になる、事実と違う情報を混ぜ始める、といった忍び寄る劣化です。前者は単純な型チェックで十分ですが、後者は人間の目や別の LLM の目で読まないと気づけません。
個人開発で一人で運用していると、特に後者に気づくのが遅れます。ユーザーからの問い合わせ経由で判明した時にはリリース後数日が経っていて、原因特定にさらに数日かかる、というサイクルに何度か嵌まりました。そこを短縮したい、というのがこの記事の一番の動機です。
プロンプト評価の総論として幅広くカバーしている Gemini API プロンプト評価・最適化パイプライン構築ガイド も併せて読んでいただけると、今日の話がその中のどの位置にあるか分かりやすくなるはずです。今日は「書いた後の変更を壊さないための継続的な仕組み」に絞って、深さを重視して進めていきます。
プロンプト回帰テストは、ユニットテストの拡張ではなく別物
「プロンプトもコードなんだから、ユニットテストと同じ発想で書けばいい」と最初は思いがちです。私もそう思っていました。ただ、実際にやってみると、従来のテストとは性質が違うと痛感します。
まず、出力が決定的ではありません。Temperature を 0 にしても、モデル側のバージョン更新や内部の丸め誤差で、厳密な一致はほぼ得られません。文字列 assertEqual は早々に破綻します。
次に、「正しい出力」は一意ではありません。要約記事の冒頭を「本稿は〜」で始めるか「今回は〜」で始めるかは、どちらも合格です。人間が書いても表記揺れは起きるので、そこをテストで締め付けるとほとんどの実行が赤になります。
そして、テストが落ちた時の原因がモデル側にもプロンプト側にもユーザー入力側にもあり得ます。Pytest の失敗メッセージだけ見ても、自分が直すべき箇所が分かりません。スタックトレースの代わりに、LLM の出力そのものを読み込むスキルが地味に要求されます。
ここから出てくる結論は、プロンプト回帰テストには「構造レベルの契約」と「意味レベルの合格基準」を分けて持つ必要がある、ということです。前者はスナップショットや JSON スキーマで縛り、後者は LLM-as-Judge で柔らかく見る。この二段構えが、私が数ヶ月運用してきた中で一番落ち着いた形になりました。
言い換えると、ユニットテスト的な「関数の入出力を固定する」発想から、契約テスト(Consumer-Driven Contract)的な「期待する形と許容範囲を合意する」発想に頭を切り替える必要があります。ここがまず、最初の山かもしれません。
Pytest をプロンプトテストランナーとして使う理由
専用ツール(promptfoo、Braintrust、LangSmith など)を使う選択肢もあり、私も状況に応じて併用しています。ただ、個人開発者として最初の一歩を踏み出すなら Pytest が明確に実用的だと感じています。
理由は三つあります。第一に、既存のテストインフラ(CI、pytest -k、並列実行、カバレッジ連携)がそのまま使えます。第二に、評価用コードも Python で書けるので、本番コードと評価コードの「語彙」が揃います。第三に、チームに新しいダッシュボードを導入する必要がなく、失敗がそのまま GitHub Actions のログに出ます。
個人開発の意思決定のしやすさで選ぶなら、まず Pytest から始めて、評価件数が増えてきたら専用ツールに移行する、という順番が現実的だと感じます。運用に乗せてから「ここが辛い」と分かった段階で専用ツールを検討しても、移行コストはそれほど高くありません。
実際のコスト感覚についても少し触れておきます。スナップショットテストは Flash 中心で書けるので、1 ケースあたり数円以下に収まります。Judge テストを Pro で走らせると 1 ケースあたり数十円の桁になります。10 ケース × 日次スケジュールなら、月額で数千円程度のコスト感です。個人開発の規模なら、本番のバグを 1 本見逃して出すより、この投資の方がずっと安いと私は判断しています。
最小構成の Pytest 環境
まずは依存を入れます。google-genai(新 SDK)、pytest、syrupy(スナップショット)、pydantic(スキーマ検証)の四つで始めれば十分です。余計な依存を足さないのも長期運用では大事です。
pip install google-genai pytest syrupy pydantic
プロジェクト構成はシンプルに始めるのが肝心です。テスト件数が 30 を超えてきてから分割を考えれば遅くありません。
app/
prompts/
review_summarizer.py
services/
gemini_client.py
tests/
prompts/
test_review_summarizer.py
__snapshots__/
テストディレクトリとプロンプトの置き場所は、1 対 1 で対応させておくと後から読みやすいです。私はファイル名を揃えて、プロンプト側と同じ名前のテストを並べるルールにしています。地味ですが、3 ヶ月後の自分が迷わないための配慮です。
実装編 1 — 構造を守るスナップショットテスト
まず守りたいのは「出力の骨格」です。JSON で返るはずの処理が突然自然文を返し始めたり、必須フィールドが抜けたりすると、下流のパーサーが一斉に落ちます。ここはスナップショットと Pydantic で締めます。
以下は、アプリのユーザーレビューを「総合スコア・良い点・気になる点・一言要約」の構造に落とす Gemini プロンプトを、Pydantic でスキーマ固定し、Pytest のスナップショットで形を記録する例です。
# tests/prompts/test_review_summarizer.py
from __future__ import annotations
import json
from typing import Any
import pytest
from pydantic import BaseModel, Field
from google import genai
from google.genai import types
class ReviewSummary ( BaseModel ):
"""レビュー要約の契約。壊れたら即アラート。"""
score: int = Field( ge = 1 , le = 5 )
positives: list[ str ] = Field( min_length = 1 , max_length = 5 )
concerns: list[ str ] = Field( max_length = 5 )
one_liner: str = Field( min_length = 5 , max_length = 80 )
SYSTEM_PROMPT = """ \
あなたはアプリレビューの要約担当です。
入力のレビュー群を読み、以下のフィールドを日本語で返してください。
- score: 1〜5 の総合スコア
- positives: ポジティブな点(1〜5件)
- concerns: 懸念点(0〜5件)
- one_liner: 80文字以内の一言要約
"""
def call_summarizer (reviews: list[ str ]) -> ReviewSummary:
"""本番と同じ入り口を通す(重要)。"""
client = genai.Client() # GEMINI_API_KEY を環境変数から読む
response = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = [ SYSTEM_PROMPT , " \n --- \n " .join(reviews)],
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = ReviewSummary,
temperature = 0.2 ,
),
)
# SDK の parsed を使うと Pydantic インスタンスが直接返る
return response.parsed # type: ignore[return-value]
@pytest.fixture
def sample_reviews () -> list[ str ]:
return [
"通知が多すぎて疲れる。でも検索は便利。" ,
"オフラインで開けないのが致命的。星2。" ,
"動作が軽くて気に入っている。広告が消せたら満点。" ,
]
def test_shape_is_stable (sample_reviews: list[ str ], snapshot: Any) -> None :
"""スキーマと主要キーの構造がリグレッションしていないか確認する。"""
result = call_summarizer(sample_reviews)
# 生のテキストではなく「形」だけを比較する
shape = {
"keys" : sorted (result.model_dump().keys()),
"positives_len_band" : _band( len (result.positives)),
"concerns_len_band" : _band( len (result.concerns)),
"one_liner_len_band" : _band( len (result.one_liner), step = 20 ),
}
assert shape == snapshot
def _band (n: int , step: int = 1 ) -> str :
"""厳密な数ではなく区間で比較することで、ゆらぎに強くする。"""
lo = (n // step) * step
return f " { lo } - { lo + step - 1 } "
ポイントは三つあります。まず、本番のプロンプト処理を直接呼ぶのでなく、call_summarizer のように薄い関数を挟むこと。これで将来モデルを切り替えても、テストは修正不要で残ります。次に、スナップショットは「生のテキスト」ではなく「形」を保存します。positives が毎回 3 件きっかりである必要はなく、1〜5 件の範囲で安定していればよいので、区間に丸めます。そして、Pydantic の制約(ge, le, min_length 等)で、そもそもスキーマ違反はテストを走らせる前に弾きます。スキーマ違反が起きた時点で例外が飛ぶので、赤の種類を「形すら壊れた」と「形は合っているが値が変」の 2 層に分けて読めるようになります。
初回実行時は pytest --snapshot-update でスナップショットを記録します。二回目以降は、スナップショットと差分があれば赤になります。これで「出力形式が静かに変わっていた」事故は止められます。
スナップショットの粒度は「後から見返したい粗さ」で決める
よくある失敗が、スナップショットに情報を入れすぎることです。出力 JSON をそのまま丸ごと保存すると、単語レベルのゆらぎで毎回赤になり、最終的にはチーム全員が --snapshot-update を機械的に走らせるようになって、テストの存在意義が消えます。
私の基準は「半年後にスナップショットの diff を見返した時、何を気にしていたか思い出せる粗さ」です。フィールドの有無、件数の区間、テキストの長さの区間。このあたりまで落とすと、本当に意味のある変化だけで赤が出るようになります。
実装編 2 — 意味を守る LLM-as-Judge テスト
形が守れても、中身が空っぽでは意味がありません。ここからは「出力の質が許容範囲に収まっているか」を、Gemini 2.5 Pro を判定役に使って評価します。
LLM-as-Judge は万能ではなく、後述する位置バイアスや寛大化バイアスを踏む癖があります。ただ、人手評価を毎回回すのは個人開発では現実的ではないので、判定プロンプト側の工夫でバイアスを抑え込みつつ使います。
# tests/prompts/test_review_summarizer_quality.py
from __future__ import annotations
from typing import Literal
import pytest
from pydantic import BaseModel, Field
from google import genai
from google.genai import types
from tests.prompts.test_review_summarizer import call_summarizer
class Rubric ( BaseModel ):
faithfulness: Literal[ "pass" , "warn" , "fail" ]
coverage: Literal[ "pass" , "warn" , "fail" ]
tone: Literal[ "pass" , "warn" , "fail" ]
reasoning: str = Field( max_length = 400 )
JUDGE_PROMPT = """ \
あなたはレビュー要約の評価者です。以下の観点を独立に採点してください。
採点は pass / warn / fail のいずれかで、甘くも辛くもしないでください。
- faithfulness: 要約内容がレビュー本文と矛盾していないか。新情報の捏造は fail。
- coverage: レビューの主要論点(良い点・懸念点)を抜け漏れなく含んでいるか。
- tone: アプリレビュー要約として自然で、過剰な断定や煽り表現がないか。
reasoning には、pass/warn/fail の判断理由を日本語で 2〜4 文で書いてください。
過度に褒めず、過度に貶さず、事実に即した観察を書いてください。
"""
def judge (reviews: list[ str ], summary_json: str ) -> Rubric:
client = genai.Client()
response = client.models.generate_content(
model = "gemini-2.5-pro" , # Judge には上位モデルを使うのが定石
contents = [
JUDGE_PROMPT ,
f "【原レビュー】 \n{ reviews }\n\n 【要約】 \n{ summary_json } " ,
],
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = Rubric,
temperature = 0.0 ,
),
)
return response.parsed # type: ignore[return-value]
@pytest.mark.parametrize (
"reviews" ,
[
[
"通知が多すぎて疲れる。でも検索は便利。" ,
"オフラインで開けないのが致命的。星2。" ,
"動作が軽くて気に入っている。広告が消せたら満点。" ,
],
[
"大型アップデート後に起動しなくなった。" ,
"新UIは好き。ただ課金プランが分かりにくい。" ,
],
],
ids = [ "mixed_feedback" , "post_update_issues" ],
)
def test_summary_quality (reviews: list[ str ]) -> None :
summary = call_summarizer(reviews)
r = judge(reviews, summary.model_dump_json( ensure_ascii = False ))
# fail は即赤、warn は1つまで許容(現実的な運用値)
failures = [k for k, v in r.model_dump().items() if v == "fail" ]
warns = [k for k, v in r.model_dump().items() if v == "warn" ]
assert not failures, f "品質 fail: { failures } / 判定理由: { r.reasoning } "
assert len (warns) <= 1 , f "warn 多発: { warns } / 判定理由: { r.reasoning } "
この実装で効いているのは、観点を 3 つに分け、独立に採点させている点です。「faithfulness・coverage・tone を一度にまとめて評価してください」と書くと、Judge はざっくりした総合評価に引っ張られて、個別の赤信号を見逃します。分けて採点させ、reasoning で根拠を短く書かせることで、テストが落ちた時に「どの観点がなぜダメだったか」が明確になります。
もう一つ重要なのが、warn を 1 件まで許容している点です。LLM-as-Judge は厳密な 0/1 判定には向かず、現実の運用では warn が 0 件ゼロは理想にすぎません。個人開発者の運用では、fail は即赤・warn は 1 件まで許容、という閾値がノイズと見逃しのバランスとして落ち着きやすいと感じています。
Judge の回答を信用するコツ
Judge が甘くなる時は、だいたい評価軸が曖昧か、お手本が与えられていない時です。JUDGE_PROMPT に「pass / warn / fail それぞれの具体例」を 1 つずつ足すと判定が締まります。私は最近、Judge プロンプトにも Pydantic スキーマをつけて、構造化された出力を強制しています。自然文で返させると、温度が 0 でも表現のゆらぎで reasoning が長文化しやすく、結果として処理が不安定になりがちです。
Judge のモデル選定も地味に効きます。評価対象が Flash で生成されているなら、Judge は Pro、というクロスモデル構成にしておくと、自己強化バイアス(自分の出力を甘く採点してしまう傾向)をある程度抑えられます。コストは上がりますが、Judge の呼び出し回数自体はテストケース数 × 1 なので、費用インパクトは現実的な範囲に収まります。
実装編 3 — ペアワイズ比較でプロンプト改善の方向を見極める
スナップショットと Judge だけでも「劣化してないか」は守れますが、「新しいプロンプトの方が本当に良いか」を決めるには、もう一枚別の仕組みがあると助かります。それがペアワイズ比較です。
# tests/prompts/test_pairwise.py
from __future__ import annotations
import random
from typing import Literal
from pydantic import BaseModel, Field
from google import genai
from google.genai import types
class PairwiseVerdict ( BaseModel ):
winner: Literal[ "A" , "B" , "tie" ]
reason: str = Field( max_length = 300 )
PAIRWISE_PROMPT = """ \
2 つの要約 A と B を、faithfulness・coverage・tone の観点で比較してください。
どちらが全体として優れているか A / B / tie で答え、reason に根拠を書いてください。
位置に引きずられないよう、A と B を入れ替えたつもりで二度考えてから判定してください。
"""
def pairwise (reviews: list[ str ], summary_a: str , summary_b: str ) -> PairwiseVerdict:
"""位置バイアスを軽減するため、順序をランダムに入れ替えて2回呼ぶ。"""
client = genai.Client()
def _ask (first: str , second: str ) -> PairwiseVerdict:
resp = client.models.generate_content(
model = "gemini-2.5-pro" ,
contents = [
PAIRWISE_PROMPT ,
f "【原レビュー】 \n{ reviews }\n\n 【A】 \n{ first }\n\n 【B】 \n{ second } " ,
],
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = PairwiseVerdict,
temperature = 0.0 ,
),
)
return resp.parsed # type: ignore[return-value]
# 正順と逆順の2回取り、片方が勝った側だけ採用
v1 = _ask(summary_a, summary_b)
v2 = _ask(summary_b, summary_a)
# v2 の winner は A/B が反転していることに注意
v2_flipped = PairwiseVerdict(
winner = { "A" : "B" , "B" : "A" , "tie" : "tie" }[v2.winner],
reason = v2.reason,
)
if v1.winner == v2_flipped.winner:
return v1
# 不一致なら tie として扱う(位置バイアスが疑わしい)
return PairwiseVerdict( winner = "tie" , reason = f "順序で判定が変わった: { v1.reason } / { v2_flipped.reason } " )
位置バイアスとは、前に置かれた選択肢を無意識に持ち上げる傾向のことです。私も最初は単純に 1 回だけ比較させていたのですが、A と B を入れ替えると勝者が変わるケースが体感で 2 割ほどありました。正順と逆順で同じ結果になった時だけ採用する、という素朴な仕組みで、ノイズがだいぶ減ります。
プロンプトを変更した時、この pairwise を旧バージョンと新バージョンの間で走らせると、「新しい方が勝率 X%」のような定量的な比較ができます。改善なのか好みなのか区別がつかずに迷走する時間を、かなり短縮できます。
CI に組み込んで、落ちたら止める
テストが書けたら、次は CI で毎回走らせます。私は GitHub Actions で、Pull Request のたびにプロンプトテストだけ分離したジョブを走らせています。
# .github/workflows/prompt-tests.yml
name : prompt-tests
on :
pull_request :
paths :
- "app/prompts/**"
- "tests/prompts/**"
schedule :
# 週1回の定点観測。モデル更新の影響を拾う。
- cron : "0 2 * * 1"
jobs :
pytest :
runs-on : ubuntu-latest
concurrency :
# 同じブランチでの多重実行を抑え、API 叩きすぎを防ぐ
group : prompt-tests-${{ github.ref }}
cancel-in-progress : true
steps :
- uses : actions/checkout@v4
- uses : actions/setup-python@v5
with :
python-version : "3.12"
- run : pip install -r requirements-dev.txt
- name : Run prompt tests
env :
GEMINI_API_KEY : ${{ secrets.GEMINI_API_KEY }}
run : pytest tests/prompts -q --snapshot-warn-unused
ポイントは三つです。まず paths フィルタでプロンプトかテスト自体の変更時だけ走らせます。毎プッシュで走らせるとコストが膨らみ、テストをスキップする動機が生まれてしまいます。次に concurrency で同一ブランチ内の多重実行をキャンセルします。Gemini の無料枠は意外と早く溶けるので、この一行は効きます。そして、schedule で週 1 回の定点観測を入れています。モデル側のアップデートで挙動が変わる「静かな劣化」は、開発者の変更がない週でも起きます。週明けに CI が赤くなっていれば、モデル側の影響だと当たりがつきます。
Judge を走らせるテストは高額になりがちなので、私は @pytest.mark.judge マーカーを付けて、PR では -m "not judge" でスキップし、日次スケジュールでのみ走らせる運用もよく使っています。スナップショットだけは毎回、Judge は日次、ペアワイズはプロンプト変更時のみ、という三層構造が個人開発のコスト・ノイズ感に一番合っています。
テストが落ちた時のデバッグ手順とノイズの付き合い方
プロンプトテストが落ちた時、焦って一気に直そうとするとさらに壊すので、決まった手順を用意しておくと落ち着きます。
まず、スナップショットが落ちているのか、Judge が落ちているのかを切り分けます。前者なら出力の「形」が変わっていて、後者なら出力の「中身」が変わっています。ここで直感で判断しない方がいいです。
形が変わっている場合、diff で何が変わったかを見ます。キーが増えた・減ったなら、モデルのスキーマ順守が弱くなっている可能性があるので、プロンプトに例を 1 件追加するか、スキーマを明示的に貼り直します。長さの区間(one_liner_len_band)が変わったなら、プロンプトに文字数制約を書き直します。単なるゆらぎなら、スナップショットを更新する判断もあります。
中身が変わっている場合、Judge の reasoning を読み込みます。ここで「良くなった方向のゆらぎ」なのか「悪くなった」のかを判別します。ありがちなのは、プロンプトの一部を変えた結果、別観点が持ち上がって従来強かった観点が弱くなっている、というパターンです。この場合、以前のプロンプトに戻すのではなく、両立する書き方を探す方が実は近道です。
赤が出た時の自分ルール
LLM のテストは、ソフトウェアのユニットテストより間違いなくノイズが多いです。それを前提に、赤が出た時にいきなり緊急対応しない姿勢を先に決めておきます。私の個人ルールは「赤が 1 回だけ出た時は翌日に再実行、2 回連続で出たら調査、3 回続いたら切り戻しを検討」です。これでだいぶメンタルが守られますし、チーム開発に広げる時も同じ基準で話せるので役立ちます。
見落としやすい落とし穴
プロンプトテストを数ヶ月運用してみて、実際にハマった落とし穴を共有します。書いておくと 3 ヶ月後の自分が絶対助かると思うので、少し長めに書きます。
一つ目は、固定の乱数シードに過度に期待しないこと です。Gemini API は seed パラメータを受け付けますが、モデルのバージョンが裏で更新されると同じシードでも挙動が変わります。「今日の赤は昨日の緑と同じ条件のはず」という仮定で長時間悩むと、けっこうな時間を失います。シードは「同一セッション内で再現性を上げる」ためのもので、「数週間後に比較できる基準」ではないと割り切ります。
二つ目は、Judge に自分自身を評価させない ことです。生成に使ったのと同じモデル・同じプロンプトで評価させると、自己強化バイアスが強く出て甘くなります。生成は Flash、Judge は Pro、のように分けるか、プロンプトの雛形自体を別に書くようにします。さらに言えば、Judge プロンプトは本番プロンプトより抽象度が高い日本語で書くのがコツです。本番プロンプトと似た語彙を使うと、Judge が生成側の言い回しに引きずられます。
三つ目は、評価データセットを固定しすぎない ことです。同じ入力ばかりでテストしていると、プロンプトがその入力に過適合していきます。私は月に一度、実運用のログから新しい入力を 2〜3 件抜き出して、parametrize のケースに足すようにしています。この更新を怠ると、テストは通るのに本番は悪化、という最悪のパターンが起きます。本番ログから追加する時は、個人情報や機微なデータをマスキングする工程を忘れないようにしてください。
四つ目は、コストとレイテンシを CI で監視しない ことです。スナップショットと Judge が緑でも、1 リクエスト辺りのトークンが 3 倍になっていることはよくあります。response.usage_metadata を記録して、トークン消費も「形」と同列でテストするくらいが、本番運用では安全です。コスト最適化の考え方は Gemini API コスト最適化ガイド も併せて参考にしてみてください。
五つ目は、テスト失敗時のアラート先を一つに絞ること です。GitHub の PR コメント、Slack 通知、メール、複数に飛ばしていると、結局どれも見ないようになります。私は Slack の個人 DM に寄せて、PR 本体の CI 赤は無視しないルールに変えてから、対応が格段に速くなりました。
ノイズとどう付き合うか — 判定ブレを前提にした運用
LLM-as-Judge を本格的に運用してみると、同じプロンプト・同じ入力でも数十回に 1 回は判定が揺れることに気付きます。決定論的なテストに慣れていると、「フレークは根絶すべきもの」という反射が出ますが、Judge のブレはプロンプト側のバグではなく、モデルそのものの性質に由来することがほとんどです。根絶ではなく、共存の設計が必要です。
私が落ち着いたルールは 3 つあります。1 つ目は、マージを 1 回の Judge 呼び出しで決めないこと。PR ゲートでは best-of-3 の多数決にして、コストは少し上がりますが 1 判定を疑い続ける時間のほうがはるかに高くつくと割り切ります。2 つ目は、「Judge が有効な JSON を返せなかった」失敗と「Judge が fail を出した」失敗を明確に分けること。前者はリトライして良いノイズですが、後者はリトライしてはいけない信号です。この 2 つを混ぜて扱うと、テストスイートがガチャ台のように見えてきて、誰も結果を信用しなくなります。
3 つ目は、「Judge ノイズが出やすい既知ケース」を小さなバケツに隔離することです。プロンプト側が安定しているのに月に 2 回以上判定が揺れるケースは、自動マージゲートからは外して人が目で見る対象にします。多くの場合、ルーブリックが曖昧か、期待出力自体が境界線上に位置しており、機械に委ねずに人が判断を持つほうが健全です。隔離しておくことで、他の安定したケースのシグナル対ノイズ比を守れます。
メンタルモデルとしては、Judge を「仕様書」ではなく「ノイズの乗ったセンサー」として扱うのが私の感覚に近いです。ノイズの乗ったセンサーでも冗長化と組み合わせれば十分使える信号になりますし、単独の値を過信しない前提で読めば不安は消えていきます。「Judge が間違っていたら?」という問いへの答えは、「時々間違うこと込みで回るように設計する」で済むのです。
評価データセットを「育てる」という発想
プロンプト回帰テストを続けていると、評価データセットの質がテスト全体のレベルをほぼ決めていることに気づきます。最初は開発時に書いた数件のケースで十分ですが、本番を数ヶ月運用すると「実際のユーザー入力はもっと多様で、想定から外れる」ことが繰り返し分かってきます。
本番ログから「しんどかったケース」を拾う
私のやり方は、月の終わりに本番ログから 3 種類のケースを抜き出して、テストケースに追加することです。一つ目は、Judge が warn を出したケース。完全な fail ではないが、次に同じ入力が来たら赤く出したい境界例として価値があります。二つ目は、ユーザーからフィードバックが来たケース。「要約がおかしい」と言われた入力は、そのまま回帰テストに移せます。三つ目は、レイテンシやトークン消費が平均の 2 倍を超えたケース。出力の質は悪くないのに、コスト面で問題になりそうな入力を早めに捕まえておけます。
個人情報や機微なテキストを含むログを扱う時は、pydantic のカスタムバリデータや簡単な正規表現で、メールアドレス・電話番号・クレジットカード番号・固有名詞の一部を置換してからテストに組み込みます。この工程を挟まないと、テストを書いた自分自身が後でログを見返せなくなりますし、リポジトリに情報を残すリスクも抱え込みます。
ケース名には「なぜそのケースなのか」を書く
@pytest.mark.parametrize の ids に case_01, case_02 のような無機質な名前を付けていると、テストが落ちた時に「これは何を守っていたケースだっけ」と思い出せません。私は ids に短い英字の要約を入れるようにしています。たとえば mixed_feedback、post_update_issues、very_short_input、long_review_with_emoji のように、ケースの特徴を名前に刻みます。
これは小さな工夫ですが、3 ヶ月後の自分がテストに戻ってきた時の理解速度が大きく変わります。テストのコードは、未来の自分に宛てたドキュメントだと思って扱うと、長期運用が楽になります。
データセットの「健康診断」を四半期に一度
四半期に一度、テストケース全体を眺めて、似たようなケースが増えすぎていないか・極端に古いモデル挙動を前提にしていないかを見直します。同じ入力に対してプロンプトを何度か改善していると、「ケース A に過適合したプロンプト」ができあがっていることがあります。不要になったケースを整理するのも、データセットを育てる作業の一部です。
追加で覚えておきたい運用上の注意
六つ目は、Judge のプロンプト自体も回帰テストの対象にする ことです。つい本番プロンプトだけに注意が向きますが、Judge プロンプトを弄った瞬間に、過去に合格していたケースが落ちるようになる事故が起きます。Judge プロンプトを変更する時は、既存のテストが全て同じ判定を出すことを確認してから反映するのが安全です。私はこの確認を judge_self_check.py というスクリプトにまとめて、Judge プロンプトの変更時だけ走らせています。
七つ目は、英語と日本語でテストを混在させないこと です。Gemini は多言語モデルなので英語と日本語を同時に扱えますが、Judge が評価軸の語彙を英語で理解するか日本語で理解するかで判定が微妙にブレます。日本語タスクは日本語の Judge プロンプト、英語タスクは英語の Judge プロンプト、と分けた方が、判定の一貫性が明らかに上がります。
八つ目は、失敗したテストの reasoning をログに残す ことです。Pytest の -v 出力だけだと、CI が流れて後から読み返せません。私は conftest.py に失敗時フックを仕込んで、Judge の reasoning を JSON ファイルに追記し、月末にまとめて読み返すようにしています。「どの観点で失敗が多いか」が見えると、次に直すべきプロンプトの優先順位が自然に決まります。
次の一歩
今夜のうちにできることは、一つだけで十分です。本番で一番怖い一本のプロンプトを選び、スナップショットテストだけ書いてみてください。Judge は後からで構いません。構造が守れているだけでも、「気づかないうちに壊れる」事故の 7 割くらいは止まります。
プロンプトをコードと同じくらい丁寧に扱えるようになると、改善のサイクルが目に見えて速くなります。遠回りに見えるテストが、結局は一番の近道でした。ここから先は、運用しながら自分のプロジェクトに合わせて少しずつ育てていくフェーズです。
プロンプト回帰テストの話自体は書かれていませんが、フィクスチャ設計・パラメトリックテスト・失敗時のノイズとの向き合い方など、本記事で触れた悩みの多くに補助線が引ける一冊です。