プロンプトを直したら、別の入力で静かに壊れていた
ある分類タスクのプロンプトを「もう少し丁寧に分類させよう」と一行だけ書き換えて本番に出したことがあります。手元で試した数件は確かに良くなっていました。ところが数日後、特定のカテゴリだけ精度が落ちているという報告が届きました。自分が確認した入力では改善していたのに、確認していなかった入力で静かに悪化していたのです。
私は 2014 年から個人でアプリを開発し、累計 5,000 万ダウンロードほどのユーザーに届けてきました。コードであれば、変更が既存の挙動を壊していないかをテストが守ってくれます。ところが Claude を組み込んだ機能では、同じプロンプトでも入力次第で出力が変わるため、従来のアサーション型テストがそのままでは効きません。数件試して良さそうだから出す、という運用は、入力分布の一部しか見ていない賭けでした。
そこで作ったのが、プロンプトやモデルを変えるたびに品質の回帰を機械的に検出する eval ハーネスです。手を動かして作ることが一つの信心だと宮大工だった祖父から教わったのですが、評価基盤こそ地味に手を動かして整えるほど後で効いてきます。実装コードと、私の運用で見えた数値を共有します。
なぜ単体テストではなく eval が要るのか
Claude の出力は決定的ではありません。temperature を 0 にしても、入力がわずかに違えば出力は変わりますし、モデルのマイナー更新でも挙動は動きます。つまり守りたいのは「特定の入力に対する特定の文字列」ではなく、「入力分布全体に対する品質の水準」です。
ここが従来テストとの決定的な違いです。単体テストは一点を固定しますが、eval は分布を測ります。守るべき指標は次の二つに集約できます。第一に、代表的な入力群に対する平均品質スコア。第二に、その分散、とりわけ最低スコア帯の悪化です。冒頭の事故は平均だけ見ていたら見逃していました。特定カテゴリという部分集合での悪化は、層別のスコアを見て初めて表に出ます。
ゴールデンデータセットを最小構成で作る
eval の土台はゴールデンデータセットです。完璧な期待出力を用意する必要はありません。むしろ「合格条件(ルーブリック)」を入力ごとに添えるほうが現実的です。私は最初の一週間で 40 件だけ作り、その後事故やクレームが出るたびに 1 件ずつ足して、半年で 200 件まで育てました。
# golden.jsonl — 1行1ケース。expected は文字列一致ではなくルーブリックで持つ
# {"id": "cat-012", "category": "返金", "input": "...", "rubric": ["返金カテゴリに分類している", "理由を1文で述べている", "個人情報を出力に含めない"]}
import json
from pathlib import Path
def load_golden(path: str) -> list[dict]:
cases = []
for line in Path(path).read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
cases.append(json.loads(line))
return cases
ポイントは、ルーブリックを「客観的に YES/NO で判定できる項目」に分解することです。「丁寧であること」のような曖昧な基準は判定がぶれます。「理由を 1 文で述べている」「指定外のカテゴリ名を使っていない」のように、人間が見ても判定が割れない粒度まで落とすのが、後述する判定一致率を上げる最大のコツでした。
被験プロンプトを実行する層
評価対象(system プロンプト + ユーザー入力の組み立て)は、本番と同じコードパスを通すべきです。eval 用に別実装を書くと、評価では通るのに本番で壊れるという最悪の乖離が起きます。本番の関数をそのまま import して回します。
import anthropic
client = anthropic.Anthropic()
CANDIDATE_MODEL = "claude-sonnet-4-6"
def run_candidate(system_prompt: str, user_input: str) -> str:
resp = client.messages.create(
model=CANDIDATE_MODEL,
max_tokens=1024,
temperature=0, # 評価の再現性のため固定
system=system_prompt,
messages=[{"role": "user", "content": user_input}],
)
return resp.content[0].text
LLM-as-judge のブレをルーブリックで抑える
採点を人手でやると 200 件で心が折れます。Claude 自身を judge に使うのが現実的ですが、judge は放っておくと甘くも辛くもぶれます。私が最初に組んだ judge は人手評価との一致率が 68% しかなく、判定を信用できませんでした。
一致率を 91% まで引き上げた要因は三つです。第一に、自由記述で点数を出させるのをやめ、ルーブリック項目ごとに true/false を返させる構造化出力にしたこと。第二に、judge に必ず「判定理由」を各項目に書かせ、理由→判定の順で出力させたこと(先に結論を出させると後付けで甘くなります)。第三に、judge のモデルを候補より上位の Opus に固定し、候補モデルと judge を別系統にしたことです。
JUDGE_MODEL = "claude-opus-4-6"
def judge(user_input: str, output: str, rubric: list[str]) -> dict:
rubric_lines = "\n".join(f"{i+1}. {r}" for i, r in enumerate(rubric))
judge_system = (
"あなたは厳密な採点者です。各ルーブリック項目について、"
"まず判定理由を1文書き、その後に true/false を決めてください。"
"迷ったら false にしてください。出力は指定 JSON のみ。"
)
judge_user = (
f"# 入力\n{user_input}\n\n# 被験モデルの出力\n{output}\n\n"
f"# ルーブリック\n{rubric_lines}\n\n"
'# 出力形式\n{"items":[{"reason":"...","pass":true}], "notes":"..."}'
)
resp = client.messages.create(
model=JUDGE_MODEL,
max_tokens=1024,
temperature=0,
system=judge_system,
messages=[{"role": "user", "content": judge_user}],
)
data = json.loads(resp.content[0].text)
items = data["items"]
passed = sum(1 for it in items if it["pass"])
return {"score": passed / len(items), "items": items, "notes": data.get("notes", "")}
judge を上位モデルにする判断には賛否があると思います。コストは上がりますが、評価がぶれると eval 全体が無意味になるため、私はここはケチらないほうが実用的だと考えています。
ベースラインと比較して回帰を判定する
スコアを出しただけでは「良いのか悪いのか」が分かりません。直前に合格していたコミットのスコアを baseline.json として保存しておき、候補との差分で判定します。ここで平均だけでなく、層別の最低スコアと最悪ケースの増加を見るのが冒頭の事故への対策です。
import statistics
def evaluate(system_prompt: str, cases: list[dict]) -> dict:
per_case, per_category = [], {}
for c in cases:
out = run_candidate(system_prompt, c["input"])
r = judge(c["input"], out, c["rubric"])
per_case.append({"id": c["id"], "category": c.get("category", "-"), "score": r["score"]})
per_category.setdefault(c.get("category", "-"), []).append(r["score"])
scores = [p["score"] for p in per_case]
return {
"mean": round(statistics.mean(scores), 4),
"p10": round(sorted(scores)[max(0, len(scores) // 10 - 1)], 4),
"fails": [p for p in per_case if p["score"] < 0.6],
"by_category": {k: round(statistics.mean(v), 4) for k, v in per_category.items()},
}
def detect_regression(baseline: dict, candidate: dict) -> list[str]:
alerts = []
if candidate["mean"] < baseline["mean"] - 0.02: # 平均が2pt以上低下
alerts.append(f"平均スコア低下: {baseline['mean']} -> {candidate['mean']}")
if candidate["p10"] < baseline["p10"] - 0.05: # 下位10%が悪化
alerts.append(f"下位帯の悪化: p10 {baseline['p10']} -> {candidate['p10']}")
for cat, sc in candidate["by_category"].items():
base = baseline["by_category"].get(cat)
if base is not None and sc < base - 0.05: # 特定カテゴリの劣化
alerts.append(f"カテゴリ「{cat}」劣化: {base} -> {sc}")
return alerts
閾値の -0.02 や -0.05 は固定の正解があるわけではありません。私は最初は緩め(平均 -0.05)から始め、judge の一致率が上がって測定が安定してから締めました。測定がぶれている段階で閾値を厳しくすると、本物でない回帰アラートで疲弊します。ここは段階的に締めるのが運用上の正解でした。
CI に組み込んで push 前に止める
最後に、これを CI のステップにして回帰時に exit code を返します。プロンプトファイルやモデル指定を変更した PR でだけ走らせれば、コストも頻度も抑えられます。
import sys
def main():
cases = load_golden("eval/golden.jsonl")
system_prompt = Path("prompts/classify.txt").read_text(encoding="utf-8")
candidate = evaluate(system_prompt, cases)
baseline = json.loads(Path("eval/baseline.json").read_text(encoding="utf-8"))
alerts = detect_regression(baseline, candidate)
print(json.dumps(candidate, ensure_ascii=False, indent=2))
if alerts:
print("回帰を検出しました:", file=sys.stderr)
for a in alerts:
print(" - " + a, file=sys.stderr)
sys.exit(1)
Path("eval/candidate.json").write_text(json.dumps(candidate, ensure_ascii=False))
print("回帰なし。baseline 更新候補を candidate.json に出力しました。")
if __name__ == "__main__":
main()
合格して人がレビューしたら、candidate.json を baseline.json に昇格させます。baseline を自動更新にしないのは、わずかな劣化が積み重なって基準ごと下がる「ゆで蛙」を防ぐためです。基準の更新には必ず人の判断を一段挟む、というのが守るべき一線だと考えています。
運用でハマりやすい落とし穴と回避策
導入してしばらくの間、私自身がいくつかの落とし穴にはまりました。同じところでつまずかないよう、頻度の高い順に対処法を添えて挙げます。
1. judge の出力が JSON として壊れてパースエラーになる
最も多いトラブルが、judge の応答が JSON として壊れて json.loads で例外が出るケースです。理由文を長く書かせると max_tokens に達して途中で切れるのが主因でした。回避策は三つで、max_tokens を十分に確保すること、messages の末尾に {"role": "assistant", "content": "{"} を prefill して先頭を固定すること、そしてパース失敗時に一度だけ再試行することです。本番では、それでも壊れた場合はそのケースを「judge 失敗」として記録し、平均から除外せずスコア 0 扱いにします。握りつぶすと回帰を見逃します。
2. ゴールデンセットが本番の入力分布とずれていく
作った当初は代表的だったゴールデンセットも、ユーザーの使い方が変わると少しずつ本番分布とずれます。私の場合は、月に一度だけ本番ログから無作為に 10 件を抽出し、ルーブリックを付けてセットに加えるようにしています。古いケースを消す必要はありません。足していくだけで分布の追従は十分でした。
3. 閾値を最初から厳しくしてアラート疲れを起こす
測定がぶれている段階で -0.01 のような厳しい閾値を置くと、本物でない回帰アラートが頻発して誰も見なくなります。これは典型的な失敗です。私のお勧めは、judge の一致率が 85% を超えるまでは閾値を緩め(平均 -0.05)に保ち、安定してから締めることです。
導入の手順としては、次の順番を強く推奨します。
- まず本番で実際に起きた失敗を 40 件集め、ルーブリック付きでゴールデンセットにする
- judge を一つ作り、20 件ほどを人手採点と突き合わせて一致率を測る
- 一致率が 85% を超えたら baseline を固定し、プロンプト変更 PR の CI に組み込む
個人的には、この三段階を飛ばさずに進めることが、結局いちばん早く安定した eval にたどり着く道だと感じています。
運用で見えた数値と判断材料
私の運用では、200 件のゴールデンセットを Sonnet で実行し Opus で採点する 1 回の eval が、所要 4〜6 分、コストはおよそ 0.5〜0.8 ドルで収まっています。PR 単位で走らせるなら無視できる額です。導入してからは、冒頭のような「特定カテゴリだけ静かに悪化」を push 前に 3 回止められました。いずれも手元の数件確認では見抜けなかったものです。
judge のコストが気になる場合は、全件を Opus で採点せず、候補が false を出した項目だけ Opus で再判定する二段構えも有効でした。ただし最初から二段にすると複雑性が増えるので、まずは素直に全件採点から始め、コストが問題になってから足すことをおすすめします。
1997 年、16 歳でインターネットにつながったとき、世界中の知らない人が書いたコードが自分の画面で動く驚きに夢中になりました。あの頃から変わらないのは、作ったものが意図どおり動き続けてくれることへの喜びです。Claude を組み込んだ機能も同じで、変更のたびに品質を測る土台があるだけで、安心して手を入れられるようになります。
まずは 40 件のゴールデンセットと、この judge を一つ作るところから始めてみてください。完璧な評価基盤を一度に作る必要はありません。事故が起きるたびに 1 件足していけば、半年後にはあなたの本番品質を静かに守ってくれる資産になっています。