- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに — 「良くなった気がします」のコスト
プロンプトを直して例をいくつか回してみたあとに「確実に良くなりましたね」と言ったことがあると思います。私にもあります。問題はその次です。
その変更はある入力では良くなり、ある入力では悪くなったはずですが、確認したのは目に留まった数個だけです。悪くなったほうは次のリリースにも静かに残ります。この過程を二十回繰り返すと、各段階で「良くなった気がした」システムが最初より悪くなっている可能性があり、さらに悪いのは、その事実を確認する方法がないことです。変更ごとの基準線が毎回違う記憶だったからです。
評価体系はモデルの品質を上げるための装置ではありません。戻れる地点を残すための装置です。この記事ではその体系をコストの低い層から積む方法と、そこから出た数字をどう解釈すべきかを扱います。
評価の四層
全部やる必要はありませんが、どの層が何を捕まえるのかは知っておく必要があります。
| 層 | 捕まえるもの | 例あたりのコスト | 信頼度 | 実行頻度 |
|---|---|---|---|---|
| 決定的アサーション | 形式違反、禁止語、スキーマエラー、遅延超過 | 事実上0 | 非常に高い | コミットごと |
| ゴールデンデータセット | 正解が決まっている作業の精度回帰 | 低い(APIコスト) | 高い | PRごと |
| LLM-as-judge | 正解が複数ある作業の相対品質 | 中 | 中(検証が必要) | 夜間、リリース前 |
| 人手評価 | 上のどれも捕まえられない微妙な品質 | 非常に高い | 最高(基準) | 四半期、大きな変更時 |
順序が重要です。多くのチームはLLM-as-judgeから作ります。華やかで論文にもよく出てくるからです。ところが実際に事故を防ぐのは、圧倒的に一行目である場合が多いのです。
安いアサーションが先です
正解を定義できない作業でも、絶対に起きてはいけないことは定義できます。これは統計ではなく単体テストです。
import re, json
def assert_output_contract(resp, ctx):
"""モデルの品質と無関係に常に真でなければならないもの"""
checks = []
checks.append(("파싱 가능", is_valid_json(resp.text)))
checks.append(("스키마 준수", validate_schema(resp.text) is True))
checks.append(("잘리지 않음", resp.finish_reason != "length"))
checks.append(("금칙 표현 없음", not FORBIDDEN.search(resp.text)))
checks.append(("문맥 밖 URL 없음", set(urls(resp.text)) <= set(urls(ctx))))
checks.append(("지연 상한", resp.latency_ms < 4000))
checks.append(("출력 길이 상한", resp.output_tokens < 800))
return [name for name, ok in checks if not ok]
五行目が特に値打ちを出します。モデルが作り出したリンクは文脈になかったものならほぼ確実に幻覚であり、これは集合演算一行で捕まります。同じやり方で、引用された数字が文脈に実際に登場するか、引用番号が実在するチャンクを指しているかも確認できます。正解を知らなくても検証できるものです。
この層は精度を上げてはくれません。代わりに「形式が壊れて障害になった」種類の事故を事実上なくします。実際のインシデントの相当数がここに属するので、投資に対する回収が最も良いのです。
ゴールデンデータセット — サイズより構成
精度を測るには正解の付いた入力が必要です。ここで二つの間違いがよく起きます。
第一に、簡単な例ばかり集めてしまうことです。うまくいくケースで埋めた評価セットは最初から精度0.95で始まり、どんな変更にも動きません。測定道具としては死んだ状態です。評価セットは失敗した入力、境界条件、曖昧な入力を意図的に含めて初めて信号が生まれます。
第二に、作ったあとに更新しないことです。評価セットは時間が経つとシステムが覚えた状態になります。プロンプトをその例に合わせて直し続けたからです。プロダクションのログから定期的に新しい例を抜いて入れなければ、評価セットは徐々に訓練セットになります。
ログから評価セットを育てるループはこう書きます。
def harvest_candidates(logs, judge, sample_rate=0.02):
"""評価セットに入れる価値のあるリクエストを自動で選び出す"""
out = []
for r in logs:
reason = None
if r.user_feedback == "negative": reason = "사용자 부정 피드백"
elif r.retry_count > 0: reason = "재시도 발생"
elif r.assertion_failures: reason = "어서션 실패"
elif r.judge_score is not None and r.judge_score < 3:
reason = "심사자 저점"
elif random.random() < sample_rate: reason = "무작위 표본"
if reason:
out.append({"input": r.input, "output": r.output, "reason": reason})
return out # 正解ラベルは人が付ける
最後の無作為標本の分岐を抜いてはいけません。失敗の信号だけで埋めると評価セットが難しい方向にだけ偏り、通常のリクエストで生じた回帰を捕まえられなくなります。
LLM-as-judgeのバイアスと緩和
正解が一つではない作業、つまり要約や相談応答のようなものは精度で測れません。ここで審査者モデルを使います。ただし知られたバイアス三つを処理しなければ、その点数は信頼できません。
位置バイアスです。二つの答えを並べて見せて選ばせると、先に提示されたほうが有利になります。緩和は簡単です。順序を入れ替えて二回評価し、二回とも勝った場合だけ勝利として数えます。
長さ選好です。審査者は長く詳しい答えに高い点数を与える傾向があります。問題は、長さが実際の品質と相関していることもあるので無条件に補正してはいけないという点です。実務的な方法は、二つの候補の出力長を一緒に記録しておき、勝率と長さの差の相関を確認することです。相関が大きければ、その勝率は品質ではなく長さを測っている可能性が高いのです。
自己選好です。審査者が自分自身または同じ系列のモデルの出力に高い点数を与える傾向が報告されてきました。候補の一つが審査者と同じ系列なら、別の系列の審査者も一緒に使って結果がひっくり返るか確認してください。
def judge_pairwise(judge, question, answer_a, answer_b, rubric):
"""順序を入れ替えて二回評価し、一致したときだけ勝敗として認める"""
def ask(first, second):
prompt = (f"{rubric}\n\n질문: {question}\n\n"
f"[답변 1]\n{first}\n\n[답변 2]\n{second}\n\n"
"먼저 기준별 근거를 쓰고, 마지막 줄에 '승자: 1' 또는 '승자: 2' 또는 '승자: 무승부'.")
return parse_winner(judge(prompt))
r1 = ask(answer_a, answer_b) # Aが先
r2 = ask(answer_b, answer_a) # Bが先
if r1 == "1" and r2 == "2": return "A"
if r1 == "2" and r2 == "1": return "B"
return "무승부" # 順序によってひっくり返るなら判定不可
ここで「順序を入れ替えたら結果がひっくり返った比率」がそれ自体で有用な指標です。この値が高ければ、二つの候補の実際の差が小さいか審査者がこの作業を判別できていないかのどちらかであり、いずれにせよその審査者の点数でデプロイの判断を下してはいけません。
そして最も重要な段階が残っています。審査者を検証することです。人がラベリングした例100個ほどについて、審査者の判定が人とどれだけ一致するかを測ってください。一致率が低ければ、その審査者は自動化された勘にすぎません。この検証を飛ばしたまま審査者の点数をダッシュボードに載せることが、この分野で最もよくある自己欺瞞です。
20個では何も言えません
例20個のうち17個を当てました。85パーセントです。この数字をどれだけ信じるべきでしょうか。
import math
def wilson(successes, n, z=1.96):
if n == 0:
return (0.0, 1.0)
p = successes / n
denom = 1 + z * z / n
center = (p + z * z / (2 * n)) / denom
half = (z / denom) * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
return (max(0.0, center - half), min(1.0, center + half))
for n in (20, 60, 200, 1000):
lo, hi = wilson(round(0.85 * n), n)
print(f"n={n:4} 85% 관측 → 95% 신뢰구간 [{lo:.3f}, {hi:.3f}] 폭 {hi - lo:.3f}")
# n= 20 85% 관측 → 95% 신뢰구간 [0.640, 0.948] 폭 0.308
# n= 60 85% 관측 → 95% 신뢰구간 [0.739, 0.919] 폭 0.180
# n= 200 85% 관측 → 95% 신뢰구간 [0.794, 0.893] 폭 0.099
# n=1000 85% 관측 → 95% 신뢰구간 [0.827, 0.871] 폭 0.044
20個で観測した85パーセントの本当の値は64パーセントかもしれず、95パーセントかもしれません。この区間の中で動くすべての変化は雑音と区別できません。二つのバージョンをそれぞれ20個で測って80パーセントと85パーセントが出たなら、それは「差がない」と完全に両立する結果です。
では、いくつ必要なのでしょうか。
Z_A, Z_B = 1.96, 0.8416 # 有意水準5%、検出力80%
def n_unpaired(p1, p2):
"""互いに異なる例で二つのバージョンを測るとき、グループあたりに必要な例の数"""
num = (Z_A + Z_B) ** 2 * (p1 * (1 - p1) + p2 * (1 - p2))
return math.ceil(num / (p1 - p2) ** 2)
def n_paired(win_ratio, discordant_rate):
"""同じ例で二つのバージョンを突き合わせるときに必要な例の数。
win_ratio: 意見が割れた例のうち新バージョンが勝つ比率"""
num = (Z_A / 2 + Z_B * math.sqrt(win_ratio * (1 - win_ratio))) ** 2
need_discordant = math.ceil(num / (win_ratio - 0.5) ** 2)
return math.ceil(need_discordant / discordant_rate), need_discordant
print(f"짝짓지 않음: 그룹당 {n_unpaired(0.80, 0.85)}개 (총 {2 * n_unpaired(0.80, 0.85)}개)")
total, disc = n_paired(0.70, 0.20)
print(f"짝지은 비교: 총 {total}개 (의견 갈린 {disc}개 필요)")
# 짝짓지 않음: 그룹당 903개 (총 1806개)
# 짝지은 비교: 총 235개 (의견 갈린 47개 필요)
同じ結論を出すのに必要な例が1806個から235個に減りました。理由は単純です。ペアにしない比較では「例の難易度の分散」と「バージョン間の差」が混ざっているのに対し、同じ例で二つのバージョンを比較すると難易度の項が相殺されるからです。評価セットを大きくする余力がないなら、せめて二つのバージョンを同じ例で回してください。これ一つだけで測定可能な最小効果量が数倍小さくなります。
正直に付け加えるべき但し書きがあります。上の計算は例同士が独立であると仮定しています。同じ文書から派生した質問十個は独立ではなく、その場合の実効標本数は十ではなくそれより小さくなります。またこの式は結果が正解か不正解かの二つに一つである場合を扱います。1から5点の尺度のような点数型は別の計算を使います。そしてプロンプト候補十個を同じ評価セットで比較すると、そのうち一つが偶然良く見える確率が上がります。何度も比較するときは基準をその分だけ厳しく取る必要があります。
CIに入れる、そして温度0が再現されない問題
評価が人の手だけで回るなら結局回らなくなります。パイプラインに入れる必要があります。
ここでぶつかるのが非決定性です。温度を0にすれば同じ入力に同じ出力が出そうに思えますが、実際にはそうなりません。バッチの構成によって浮動小数点の累積順序が変わり、それに応じてカーネルが選ぶ経路も変わります。上位二つのトークンの確率がほぼ同じとき、この微細な差が別のトークンを選ばせ、その後はまったく違う文が出てきます。プロバイダのAPIをお使いなら、ここに予告のないサーバ側の変更が加わります。
ですからCIで出力文字列をそのまま比較するテストは必ず壊れます。代わりにこう構成します。
def ci_gate(baseline, candidate, n, threshold=0.03):
"""指標ごとの低下閾値で判定。単一の例の不一致では失敗させない。"""
failures = []
for metric in ("accuracy", "assertion_pass", "citation_valid"):
drop = baseline[metric] - candidate[metric]
lo, hi = wilson(round(candidate[metric] * n), n)
if drop > threshold and baseline[metric] > hi:
failures.append(f"{metric}: {baseline[metric]:.3f} → {candidate[metric]:.3f} "
f"(구간 상한 {hi:.3f})")
return failures
基準線が候補の信頼区間の上にあるときだけ失敗と判定します。こうしなければ雑音でパイプラインが赤くなり続け、数週間後には誰もその赤いランプを見なくなります。警報を無視させるようにすることは、警報がないことより悪いのです。
実行の階層も分ける必要があります。PRごとに回る層はアサーションと小規模なゴールデンセットで数分以内に終わるべきで、審査者を使う全体評価は夜間かリリース前に回します。この区別をしないと、評価が遅くて切られることになります。
最後はオンライン評価です。オフラインの評価セットがどれだけ良くても実際のトラフィック分布とは違います。デプロイは段階的に行い、改善したい指標の隣に絶対に悪くなってはいけないガードレール指標を一緒に見ます。精度を上げようとして遅延が二倍になったり棄権率が急騰したりすることは実際によく起きます。そしてここで出た失敗事例が再びゴールデンセットに入ることでループが閉じます。
おわりに — 基準線が記憶なら回帰は見えません
この記事から一つだけ持ち帰るとしたら、これであってほしいと思います。評価体系の目的は良い点数を得ることではなく、比較可能な基準線をコードで固定しておくことです。基準線が人の記憶の中にあれば回帰は定義上観測されません。
ですから今日始められる最小構成はこの程度です。常に真でなければならないアサーション十個、失敗した入力で埋めたゴールデンセット50個、そして新しいバージョンと古いバージョンを同じ例で回して比較するスクリプト一つ。ここで出た差が信頼区間の中なら「分かりません」と言えばよいのです。その言葉を言えるようになることが、勘でやる評価との本当の違いです。