前回は評価セットを組んで LLM-as-judge を回す話を書いた(LLMの出力を「なんとなく良い」で終わらせない — 評価セットとLLM-as-judge)。ただ、評価スクリプトを手元で回すだけでは、実運用では遅かれ早かれ事故が起きる。
プロンプトを一行いじった。judge のモデルを 4o から 4o-mini に落とした。retriever の top_k を 5 から 8 に増やした。そのどれもが「なんとなく動いてる」まま main にマージされ、数日後に「金曜の夕方から回答が短くなってる気がする」とチャットに流れてくる。筆者も一度やった。プロンプトの末尾に「簡潔に」の一語を足しただけで、金額の桁を落とすようになったことがある。
この記事のスコープ
今回は評価セットを CI に載せて、PR ごとに回して、閾値を割ったら赤にする、という素朴な仕組みを書く。派手ではない。ただし、これが無い状態で LLM 機能をプロダクションに置くのは、type check なしで TypeScript を書くのに近い。

1. まず deterministic な assertion を分ける
LLM の評価と聞くと反射的に judge の話になるが、CI に最初に組み込むべきなのは judge じゃない。フォーマット準拠、キーワード有無、長さ、レイテンシ — このあたりの決定論的な check だ。これらは flake がゼロで、落ちたら 100% バグ。
逆に言うと、決定論的に書ける check を judge に投げているのは大抵設計ミスだ。「JSON として parse できるか」を LLM に判定させる必要はない。json.loads でよい。
from dataclasses import dataclass
from typing import Callable
import json, re, time
@dataclass
class Case:
id: str
input: dict
# deterministic checks: 返り値 (ok: bool, reason: str)
checks: list[Callable[[str], tuple[bool, str]]]
def check_json(s: str) -> tuple[bool, str]:
try:
json.loads(s)
return True, ""
except Exception as e:
return False, f"invalid json: {e}"
def check_has_keys(keys: list[str]) -> Callable[[str], tuple[bool, str]]:
def _f(s: str) -> tuple[bool, str]:
try:
obj = json.loads(s)
except Exception:
return False, "not json"
missing = [k for k in keys if k not in obj]
return (not missing), (f"missing: {missing}" if missing else "")
return _f
def check_max_chars(n: int) -> Callable[[str], tuple[bool, str]]:
def _f(s: str) -> tuple[bool, str]:
return (len(s) <= n), (f"too long: {len(s)} > {n}" if len(s) > n else "")
return _f
この手の check は評価セットの各ケースに 2〜4 個ぶら下がる。judge を呼ぶ前にここで落ちるものは、judge に流す価値がない。API コストの話でもあるし、単にノイズだ。
2. judge は agreement を測ってから信じる
前回書いた rubric ベースの judge を CI に載せるとき、いきなり閾値を切ってはいけない。手作業でラベルを付けた 20〜30 件のサンプルに対して、judge のスコアと人間のスコアがどのくらい一致するかを先に測る。
ここで agreement が Cohen's kappa で 0.4 を切っているようなら、その rubric は CI ゲートに使うにはまだ早い。文言を絞るか、5 段階を 3 段階に落とすか、few-shot を差し替えるかだ。筆者の経験だと、rubric に「〜も考慮する」が 3 つ以上並んでいると agreement は大抵崩れる。
from sklearn.metrics import cohen_kappa_score
def judge_calibration(judge_fn, human_labels: list[dict]) -> float:
# human_labels: [{"input": ..., "output": ..., "human_score": 1|0}, ...]
judge_scores = [judge_fn(x["input"], x["output"]) for x in human_labels]
human_scores = [x["human_score"] for x in human_labels]
kappa = cohen_kappa_score(human_scores, judge_scores)
return kappa
CI では起動時に一度これを回して、kappa < 0.4 なら judge を使わず
deterministic check のみで gate する — というフォールバックが安全
3. 閾値は「100%」にしない
ここは初回に必ずハマる場所だ。判定に LLM が絡む以上、同じ入力でも週によって 1〜2 件はスコアが揺れる。100% pass を要求すると、翌週には赤い CI が常態化する。無視される CI は無いのと同じだ。
実務的な出発点は pass rate 85% + 前回比の下降幅 5pt 以内、みたいな複合条件。数字自体は評価セットの成熟度によって動くが、「絶対 pass」と「絶対値」ではなく「割合」と「差分」で見るのがコツだ。
import statistics
def gate(results: list[bool], baseline_pass_rate: float,
min_pass_rate: float = 0.85, max_drop: float = 0.05) -> tuple[bool, str]:
pass_rate = sum(results) / len(results)
if pass_rate < min_pass_rate:
return False, f"pass rate {pass_rate:.2%} < {min_pass_rate:.0%}"
if baseline_pass_rate - pass_rate > max_drop:
return False, (
f"regression: {baseline_pass_rate:.2%} → {pass_rate:.2%} "
f"(-{(baseline_pass_rate - pass_rate)*100:.1f}pt)"
)
return True, f"ok: {pass_rate:.2%}"
baseline_pass_rate は main ブランチの最新 run の結果をどこかに保存しておいて引く。S3 の JSON でも、Postgres の 1 テーブルでも、GitHub Actions の cache でも何でもいい。凝った基盤より、更新が止まらない仕組みの方が大事。
4. GitHub Actions に載せる最小構成
PR に対して base と head の両方で同じ eval を回して、差分を PR コメントに貼る。これだけで「静かな劣化」の 8 割は捕まる。
name: llm-eval
on:
pull_request:
paths:
- "prompts/"
- "src/llm/"
- "eval/**"
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Cache LLM responses
uses: actions/cache@v4
with:
path: .eval-cache
key: eval-cache-${{ hashFiles('eval/dataset.jsonl') }}
- run: pip install -r requirements.txt
- name: Run eval on base
run: |
git checkout ${{ github.base_ref }}
python -m eval.run --out /tmp/base.json
- name: Run eval on head
run: |
git checkout ${{ github.head_ref }}
python -m eval.run --out /tmp/head.json
- name: Compare
id: diff
run: python -m eval.diff /tmp/base.json /tmp/head.json > /tmp/report.md
- name: Comment PR
uses: marocchino/sticky-pull-request-comment@v2
with:
path: /tmp/report.md
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
cache のキーを dataset のハッシュに紐付けているので、コードだけいじった PR では base 側の LLM 呼び出しがキャッシュヒットする。これがないと 1 PR で 30 分回ることになる。
5. diff の見せ方 — スコアの表より「どのケース」
PR コメントに pass rate だけ載せても、レビュアーは「ふーん」で終わる。悪くなったケースを 3 件、良くなったケースを 3 件、入出力ごと貼る。これが一番効く。
# eval/diff.py の抜粋
def render_diff(base: list[dict], head: list[dict]) -> str:
by_id = {b["id"]: (b, next(h for h in head if h["id"] == b["id"])) for b in base}
regressed = [(b, h) for b, h in by_id.values() if b["pass"] and not h["pass"]]
improved = [(b, h) for b, h in by_id.values() if not b["pass"] and h["pass"]]
lines = [f"## LLM eval diff", ""]
lines.append(f"- base pass rate: {sum(b['pass'] for b in base)/len(base):.1%}")
lines.append(f"- head pass rate: {sum(h['pass'] for h in head)/len(head):.1%}")
lines.append(f"- regressed: {len(regressed)} / improved: {len(improved)}")
lines.append("")
for b, h in regressed[:3]:
lines += [
f"### 🔴 {b['id']}",
f"**input**: `{b['input']}`",
f"**base output**: {b['output'][:200]}",
f"**head output**: {h['output'][:200]}",
f"**reason**: {h.get('reason','')}",
"",
]
# improved も同様
return "\n".join(lines)
スコアだけの表と、実際の入出力の並びを見せるのとでは、レビューの深さが全然違う。前者はマージされるが、後者はレビュアーが「あ、これ簡潔にって書いたせいで削れてる」と気づく。
6. フレークとの付き合い方
それでもフレークは残る。温度 0 にしても、モデル側の細かい確率差で 1 件だけ落ちることがある。
筆者がやっているのは、落ちた PR を retry で緑にする代わりに、同じケースを 3 回連続で回してすべて落ちた場合のみ regression と判定する、という多数決だ。@retry デコレータで包むのに近い発想だが、リトライ回数と「連続 fail」の判定を分けているのがポイント。単純リトライは flake を隠すだけで、regression 検出まで殺してしまう。
7. 何を CI に載せて、何を載せないか
すべての評価を CI に押し込むと、PR が回らない。筆者は 2 層に分けている。
PR ゲートには 30〜80 件程度の golden set と、format/keyword/length の deterministic check のみ。judge は最も重要な rubric を 1〜2 個。数分で終わる規模。
もう一段大きい dataset — 500〜1000 件、複数モデルのマトリクス、コスト集計含む — は nightly の schedule 実行で回して、Slack か PR コメントで通知だけする。ここに judge の重い rubric や、adversarial セットを置く。PR ごとに回すには重すぎるが、週に 1 回落ちてくれれば充分な種類のバグはこっちで拾える。
まとめ
「壊れを CI で捕まえる」を実現する材料は、deterministic check の分離、judge の calibration、差分と割合で切る閾値、キャッシュ + base/head 比較、そして 2 層の実行頻度 — このくらいだ。派手さは無いが、この 5 つが揃うと、プロンプトの一行変更を怖がらずにマージできるようになる。速度は自信から来る。
次回は本番稼働中の LLM をどう観測するかを書く予定だ。トレース、コスト、品質のドリフト検知 — CI で守った線を、稼働後にどう保つかの話になる。
筆者は 5years+ で LLM/AI システムの設計と運用を担当している。