0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

本番前に壊れを検知する — CIに組み込む回帰テストと評価ゲート

0
Posted at

前回は評価セットを組んで LLM-as-judge を回す話を書いた(LLMの出力を「なんとなく良い」で終わらせない — 評価セットとLLM-as-judge)。ただ、評価スクリプトを手元で回すだけでは、実運用では遅かれ早かれ事故が起きる。

プロンプトを一行いじった。judge のモデルを 4o から 4o-mini に落とした。retriever の top_k を 5 から 8 に増やした。そのどれもが「なんとなく動いてる」まま main にマージされ、数日後に「金曜の夕方から回答が短くなってる気がする」とチャットに流れてくる。筆者も一度やった。プロンプトの末尾に「簡潔に」の一語を足しただけで、金額の桁を落とすようになったことがある。

この記事のスコープ

今回は評価セットを CI に載せて、PR ごとに回して、閾値を割ったら赤にする、という素朴な仕組みを書く。派手ではない。ただし、これが無い状態で LLM 機能をプロダクションに置くのは、type check なしで TypeScript を書くのに近い。

CIパイプラインのダッシュボードが映るモニターと開発者の作業机

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 &gt; /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 システムの設計と運用を担当している。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?