ある金曜の夜、Sonnet 4.5 から 4.6 に切り替えた直後、私のサポートチャットボットの「ご注文番号を教えてください」という確認文が、なぜか 3 割の会話で「お問い合わせ番号」に変わっていました。プロンプトは 1 文字も変えていません。バグレポートが来てから気づくまで丸 2 日。これが「プロンプトが静かに壊れる」典型的なパターンです。
ユニットテストでは検知できませんでした。出力のフォーマットは正しく、JSON もパースできていました。ただ、ビジネス上重要な「注文番号」という単語だけがすり替わっていたのです。
ここではこうした静かな品質劣化を CI で捕まえるための Golden Dataset 方式の評価パイプラインを、コード付きで丸ごと組み立てます。私が 4 つのサイトで運用している実装をベースに、月 50 ドル以下に収めるコスト設計まで含めて共有します。
なぜ「ユニットテスト」では LLM の品質を守れないのか
従来のユニットテストは「入力 X に対し出力 Y が返ること」を保証する仕組みです。LLM はそもそも非決定的に動くので、この前提が成り立ちません。
私が実際に経験した「静かに壊れる」3 つの瞬間を挙げます。
第一に、モデルバージョンを上げたとき。最新モデルは平均性能こそ向上しますが、特定タスクでは前のモデルに劣ることがあります。例えば「短く答える」指示への忠実さが微妙に下がる、といった具合です。
第二に、プロンプトの 1 文を「分かりやすく」書き換えたとき。「丁寧に答えてください」を「親切に答えてください」に変えただけで、敬語のレベルが変わることがあります。
第三に、Tool Use や RAG の周辺コードを変えたとき。プロンプトは触っていなくても、検索結果の順序やトリミング方法が変わると、最終出力の質がじわっと落ちます。
これらに共通するのは「テストは緑のまま、ユーザー体験だけが落ちる」という点です。Golden Dataset 方式は、これを定量的に検知するための仕組みです。
Golden Dataset の最小構成 — 50 件で始める
「Golden Dataset」と聞くと数千件規模を想像するかもしれませんが、実用上は 30〜100 件で十分です。私は新規プロジェクトなら 50 件から始めます。
ポイントは、プロンプトが 本番で受け取る入力の分布を縮約することです。サポートチャットなら「商品到着の遅延」「返金依頼」「使い方質問」など、過去 1 ヶ月のログから頻出する 10 カテゴリを選び、各カテゴリ 5 件をサンプリングします。
データ構造は JSONL がおすすめです。Git 管理しやすく、追記も容易です。
{"id": "support_001", "category": "shipping_delay", "input": "注文した商品がまだ届きません。", "expected_intent": "shipping_inquiry", "expected_keywords": ["注文番号", "配送状況", "確認"], "must_not_include": ["返金", "キャンセル"]}
{"id": "support_002", "category": "refund", "input": "サイズが合わないので返品したいです。", "expected_intent": "refund_request", "expected_keywords": ["返品ポリシー", "返金", "手続き"], "must_not_include": []}
{"id": "support_003", "category": "usage", "input": "アプリにログインできません。", "expected_intent": "technical_support", "expected_keywords": ["パスワード", "リセット", "アカウント"], "must_not_include": ["返金"]}expected_keywords と must_not_include を併用するのがコツです。前者は「これらの単語のうち N 個以上含むこと」、後者は「これらは絶対に含めないこと」を表します。完全一致ではなくキーワードベースにすることで、表現の揺れを許容しつつ、意味の劣化は捕まえられます。
ルールベース評価 — まずは決定的なチェックから
Golden Dataset を使った評価は、ルールベース → LLM-as-a-Judge の二段構えにします。コストの安いルールベースで弾けるものは、LLM 評価を回さない設計です。
# evaluator/rule_based.py
import json
from typing import TypedDict
class GoldenItem(TypedDict):
id: str
category: str
input: str
expected_keywords: list[str]
must_not_include: list[str]
class RuleResult(TypedDict):
id: str
keyword_score: float # 0.0〜1.0
blocklist_passed: bool
overall_passed: bool
failure_reasons: list[str]
def evaluate_rule(item: GoldenItem, output: str, min_keyword_ratio: float = 0.5) -> RuleResult:
"""ルールベース評価: 期待キーワード比率 + ブロックリスト判定。"""
output_lower = output.lower()
# ① 期待キーワードのうち何割含まれているか
matched = [kw for kw in item["expected_keywords"] if kw.lower() in output_lower]
keyword_score = len(matched) / max(len(item["expected_keywords"]), 1)
# ② ブロックリストは1つでも含めばアウト
blocklist_violations = [kw for kw in item["must_not_include"] if kw.lower() in output_lower]
blocklist_passed = len(blocklist_violations) == 0
failure_reasons = []
if keyword_score < min_keyword_ratio:
missing = set(item["expected_keywords"]) - set(matched)
failure_reasons.append(f"keyword_ratio {keyword_score:.2f} < {min_keyword_ratio} (missing: {missing})")
if not blocklist_passed:
failure_reasons.append(f"blocklist_violation: {blocklist_violations}")
return {
"id": item["id"],
"keyword_score": keyword_score,
"blocklist_passed": blocklist_passed,
"overall_passed": keyword_score >= min_keyword_ratio and blocklist_passed,
"failure_reasons": failure_reasons,
}
# 期待出力例:
# {'id': 'support_001', 'keyword_score': 1.0, 'blocklist_passed': True, 'overall_passed': True, 'failure_reasons': []}このコードのポイントは min_keyword_ratio=0.5 のしきい値です。100% 一致を求めるとプロンプトの自然な揺れまで失敗扱いになります。私は経験上 0.5〜0.6 が実用的だと感じています。
LLM-as-a-Judge — 揺れる出力を安定して評価する
ルールベースで弾けないのが「丁寧さ」「事実性」「文脈の維持」のような質的評価です。ここで Claude を Judge として使います。
ただし、LLM-as-a-Judge には重大な落とし穴があります。Judge モデル自身もバージョンが変わると評価が揺れるのです。私は最初これを軽視して、Judge を Sonnet 4.6 から Sonnet 4.6 に「アップデート」したら(同じバージョンに見えるが minor revision が違っていた)、評価スコアが 0.78 → 0.71 に落ちたことがあります。
対策は 2 つです。(1) Judge モデルとバージョンを評価ログに必ず記録します。(2) Judge と人手評価の合意度を Cohen's Kappa で定期測定する。
# evaluator/llm_judge.py
import os
import anthropic
from typing import TypedDict
JUDGE_MODEL = "claude-sonnet-4-6" # 評価ログに必ず記録すること
class JudgeResult(TypedDict):
id: str
politeness_score: int # 1〜5
factual_score: int # 1〜5
relevance_score: int # 1〜5
judge_reasoning: str
judge_model: str
JUDGE_SYSTEM = """あなたはカスタマーサポート応答の評価者です。
3 つの軸で 1〜5 点で評価し、JSON のみを返してください。
- politeness: 1=失礼 / 5=非常に丁寧
- factual: 1=事実誤認あり / 5=事実誤認なし
- relevance: 1=的外れ / 5=ユーザーの意図を完全に捉えている
評価は厳格に。3 点を「平均的」の基準点とし、迷ったら下方に振ってください。"""
JUDGE_USER_TEMPLATE = """ユーザー入力:
{user_input}
評価対象の応答:
{model_output}
JSON 形式で {{"politeness": N, "factual": N, "relevance": N, "reasoning": "..."}} のみを返してください。"""
def judge(item_id: str, user_input: str, model_output: str) -> JudgeResult:
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
try:
msg = client.messages.create(
model=JUDGE_MODEL,
max_tokens=400,
system=JUDGE_SYSTEM,
messages=[{
"role": "user",
"content": JUDGE_USER_TEMPLATE.format(
user_input=user_input, model_output=model_output
)
}],
)
text = msg.content[0].text.strip()
# ```json ... ``` ブロックの除去
if text.startswith("```"):
text = text.split("```")[1].lstrip("json").strip()
import json
parsed = json.loads(text)
return {
"id": item_id,
"politeness_score": int(parsed["politeness"]),
"factual_score": int(parsed["factual"]),
"relevance_score": int(parsed["relevance"]),
"judge_reasoning": parsed.get("reasoning", ""),
"judge_model": JUDGE_MODEL,
}
except (anthropic.APIError, json.JSONDecodeError, KeyError) as e:
# Judge 自体が失敗したら N/A 扱い(テスト全体は失敗にしない)
return {
"id": item_id, "politeness_score": -1, "factual_score": -1,
"relevance_score": -1, "judge_reasoning": f"judge_error: {e}",
"judge_model": JUDGE_MODEL,
}
# 期待出力例:
# {'id': 'support_001', 'politeness_score': 5, 'factual_score': 5, 'relevance_score': 4, 'judge_reasoning': '丁寧で事実誤認なし。注文番号確認の手順も明確。', 'judge_model': 'claude-sonnet-4-6'}なぜ judge_error を「テスト全体の失敗」にしないのか。Judge の API 失敗は評価対象モデルの品質と無関係です。これを混同すると、Anthropic 側の一時的な障害でビルドが落ちます。-1 をマーカーにして、サマリで「N/A 件数」として別カウントするのが正解です。
Cohen's Kappa で Judge の信頼度を担保する
LLM-as-a-Judge の評価が「人間の評価とどれくらい一致するか」を定量化する指標が Cohen's Kappa です。月に 1 回、Golden Dataset の 20 件をランダムサンプリングして、Judge と人間(自分)の評価を比較します。
# evaluator/kappa.py
from sklearn.metrics import cohen_kappa_score
from typing import Sequence
def compute_kappa(judge_scores: Sequence[int], human_scores: Sequence[int]) -> dict:
"""politeness などの順序尺度に対する一致度を測る。"""
# 1〜5 の順序尺度なので weights="quadratic" を使う
kappa = cohen_kappa_score(human_scores, judge_scores, weights="quadratic")
diffs = [abs(j - h) for j, h in zip(judge_scores, human_scores)]
return {
"kappa": kappa,
"mean_abs_diff": sum(diffs) / len(diffs),
"exact_agreement_rate": sum(1 for d in diffs if d == 0) / len(diffs),
"n_samples": len(diffs),
}
# 期待出力例:
# {'kappa': 0.72, 'mean_abs_diff': 0.4, 'exact_agreement_rate': 0.65, 'n_samples': 20}
# kappa >= 0.6 を「実用ライン」、>= 0.8 を「強い一致」と判断する判断基準: kappa が 0.6 を切ったら Judge プロンプトを見直すサインです。私の経験では、評価軸を曖昧にしている(「全体的な品質」のような単一スコアにしている)と kappa が下がります。politeness factual relevance のように分解すると安定します。
評価コストを月 50 ドル以下に抑える設計
「Golden Dataset を CI で毎回回したらコストが大変では」という心配が必ず出ます。私の実装ではこう抑えています。
第一に、評価をフルに回すのは main へのマージ時のみにしています。PR ビルドではルールベースのみ走らせ、LLM-as-a-Judge は main マージ時にバッチで実行します。
第二に、Judge には Haiku 4.5 を使うことも検討してください。私は精度比較を実施した上で、サポートチャット評価では Sonnet 4.6 と Haiku 4.5 の Kappa 差が 0.05 以内だったので Haiku に切り替えました。コストは約 1/5 です。
第三に、プロンプトキャッシュを必ず使うことです。Judge の system プロンプトは固定なので、キャッシュ効率は 90% 超になります。
具体的な月額試算は以下の通りです。
- Golden Dataset 50 件 × 評価軸 3 = 150 評価コール / 1 ラン
- main マージ 1 日 5 回 × 30 日 = 150 ラン
- 合計 22,500 評価コール / 月
- Haiku 4.5 (input $0.80 / output $4.00 per 1M, キャッシュ後実効 input $0.10) × 1 評価あたり ~600 input + 80 output トークン
- 月額: ($0.10 × 0.6 + $4.00 × 0.08) / 1000 × 22500 ≒ 約 8 ドル
これに、月 1 回の Cohen's Kappa 用 20 件評価 × Sonnet 4.6 を加えても、月 15 ドル以内に収まります。
CI への組み込み — GitHub Actions の最小構成
# .github/workflows/prompt-eval.yml
name: Prompt Regression Eval
on:
push:
branches: [main]
pull_request:
paths:
- 'prompts/**'
- 'evaluator/**'
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- run: pip install -r evaluator/requirements.txt
# PR は安価なルールベースのみ(高速・無料)
- name: Rule-based eval (PR)
if: github.event_name == 'pull_request'
run: python evaluator/run.py --mode rule_only --golden golden_dataset.jsonl
# main マージ時はフル評価(LLM-as-a-Judge 含む)
- name: Full eval (main)
if: github.event_name == 'push'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: python evaluator/run.py --mode full --golden golden_dataset.jsonl --output eval_results.json
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: eval-results
path: eval_results.json
# 失敗時に Slack 通知(任意)
- name: Notify on regression
if: failure() && github.event_name == 'push'
run: |
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"⚠️ Prompt eval regression detected on main. See: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \
${{ secrets.SLACK_WEBHOOK_URL }}timeout-minutes: 15 を必ず設定してください。Judge API のハングや無限リトライで CI が止まると、開発フローを大きく妨げます。
よくある間違い・落とし穴 5 つ
実運用に持ち込んで初めて気づく落とし穴を、私が実際にハマった順に紹介します。
(1) Golden Dataset を 1 度作って終わりにしてしまう。本番の入力分布は刻々と変わります。月 1 回、本番ログから 5〜10 件を追加し、半年以上前の古いケースは見直すサイクルを入れてください。
(2) しきい値を「初期スコア = 合格ライン」にしてしまう。最初の評価で総合スコア 0.85 が出たから「0.85 を下回ったら失敗」とするのは危険です。Judge の揺れだけで 0.83 になることがあり、全レポを赤くします。初期スコア × 0.95 を初期しきい値にし、3 ヶ月運用して経験的に調整するのが安全です。
(3) Judge プロンプトに「1〜5 点」と書いて 4.7 が返ってくる。Claude は柔軟に解釈するので、整数指定でも小数を返すことがあります。int(parsed["politeness"]) で明示変換するか、Tool Use で構造化出力を強制してください。
(4) 失敗時のレポートが「平均 0.78」だけ。これでは何が悪化したか分かりません。個別ケース ID 単位で前回スコアと diff を出す仕組みを必ず入れてください。eval_results.json を S3 に履歴保存しておけば、previous_run.json と当日結果の差分を出せます。
(5) Judge の評価ログを保存しない。後から「なぜ 3 点だったのか」を遡れないと、Judge プロンプトの改善ができません。judge_reasoning フィールドは必ず保存してください。月数百 KB です。
月次の評価レビュー会 — 仕組みを腐らせない運用
最後に、最も重要なのは「仕組みを作って終わりにしない」ことです。私は月初に 30 分、自分一人でも以下のレビューを必ずします。
評価結果のトレンド(ダッシュボードかスプレッドシート)を見ながら、(a) スコアが下がっているケースの原因を仮説立て、(b) Cohen's Kappa が 0.6 を切っていないか、(c) Golden Dataset に追加すべき新しい本番ケースはないか、(d) Judge プロンプトを直すべきか、をチェックします。
このレビューを欠かすと、Golden Dataset は 3 ヶ月で陳腐化します。私の運用ログでは、レビュー月とそうでない月で「実本番でのユーザー指摘件数」が約 2 倍違っていました。仕組みは育てるものです。
評価設計をさらに体系的に
次の一歩
まず Golden Dataset を 30 件作るところから始めてください。完璧なデータセットを目指すと永遠に始まりません。本番ログから「ユーザーがリトライしている会話」と「サポートが介入した会話」を 15 件ずつ拾えば、それだけで実用的な出発点になります。明日の朝 30 分でできる作業です。
そしてルールベース評価だけでも CI に入れてみてください。LLM-as-a-Judge は、ルールベースが回り始めてからで十分です。「いきなり全部入り」を狙うと挫折します。
関連記事として Claude API の LLM 評価パイプライン構築ガイド と Claude API のテスト戦略 — Unit / Integration / E2E で AI 品質を守る、さらに Claude API プロンプトレジストリと A/B テストの本番運用ガイド も合わせて読むと、評価・バージョニング・テストの三位一体が見えてきます。