Split View: LLM 평가를 감으로 하지 않는 법 — 표본 크기, 심사자 편향, CI 회귀 테스트
LLM 평가를 감으로 하지 않는 법 — 표본 크기, 심사자 편향, CI 회귀 테스트
들어가며 — "좋아진 것 같아요"의 비용
프롬프트를 고치고 예시 몇 개를 돌려 본 뒤 "확실히 나아졌네요"라고 말한 적이 있으실 겁니다. 저도 있습니다. 문제는 그다음입니다.
그 변경은 어떤 입력에서는 나아지고 어떤 입력에서는 나빠졌을 텐데, 확인한 것은 눈에 띈 몇 개뿐입니다. 나빠진 쪽은 다음 릴리스에서도 조용히 남습니다. 이 과정을 스무 번 반복하면 각 단계에서 "좋아진 것 같았던" 시스템이 처음보다 나빠져 있을 수 있고, 더 나쁜 건 그 사실을 확인할 방법이 없다는 것입니다. 변경마다의 기준선이 매번 다른 기억이었기 때문입니다.
평가 체계는 모델 품질을 올리기 위한 장치가 아닙니다. 되돌아갈 지점을 남기기 위한 장치입니다. 이 글은 그 체계를 비용이 낮은 층부터 쌓는 방법과, 거기서 나온 숫자를 어떻게 해석해야 하는지를 다룹니다.
평가의 네 층위
전부 다 할 필요는 없지만, 어떤 층이 무엇을 잡는지는 알아야 합니다.
| 층위 | 잡아내는 것 | 예시당 비용 | 신뢰도 | 실행 빈도 |
|---|---|---|---|---|
| 결정적 어서션 | 형식 위반, 금칙어, 스키마 오류, 지연 초과 | 사실상 0 | 매우 높음 | 커밋마다 |
| 골든 데이터셋 | 정답이 정해진 작업의 정확도 회귀 | 낮음 (API 비용) | 높음 | PR마다 |
| LLM 심사자 | 정답이 여럿인 작업의 상대 품질 | 중간 | 중간 (검증 필요) | 야간, 릴리스 전 |
| 사람 평가 | 위 어느 것도 못 잡는 미묘한 품질 | 매우 높음 | 최고 (기준) | 분기, 큰 변경 시 |
순서가 중요합니다. 많은 팀이 LLM 심사자부터 만듭니다. 화려하고 논문에도 자주 나오기 때문입니다. 그런데 실제로 사고를 막는 것은 첫 번째 줄인 경우가 압도적으로 많습니다.
값싼 어서션이 먼저입니다
정답을 정의할 수 없는 작업에도, 절대 일어나면 안 되는 일은 정의할 수 있습니다. 이건 통계가 아니라 단위 테스트입니다.
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 심사자의 편향과 완화
정답이 하나가 아닌 작업, 즉 요약이나 상담 응답 같은 것은 정확도로 잴 수 없습니다. 여기서 심사자 모델을 씁니다. 다만 알려진 편향 세 가지를 처리하지 않으면 그 점수는 신뢰할 수 없습니다.
위치 편향입니다. 두 답을 나란히 보여 주고 고르라고 하면 먼저 제시된 쪽이 유리해집니다. 완화는 간단합니다. 순서를 바꿔 두 번 평가하고, 두 번 모두 이긴 경우만 승리로 셉니다.
길이 선호입니다. 심사자는 길고 자세한 답에 높은 점수를 주는 경향이 있습니다. 문제는 길이가 실제 품질과 상관이 있기도 해서 무조건 보정하면 안 된다는 점입니다. 실무적인 방법은 두 후보의 출력 길이를 함께 기록해 두고, 승률과 길이 차이의 상관을 확인하는 것입니다. 상관이 크면 그 승률은 품질이 아니라 길이를 재고 있을 가능성이 높습니다.
자기 선호입니다. 심사자가 자기 자신 또는 같은 계열 모델의 출력에 높은 점수를 주는 경향이 보고되어 왔습니다. 후보 중 하나가 심사자와 같은 계열이면 다른 계열의 심사자를 함께 써서 결과가 뒤집히는지 확인하십시오.
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개, 그리고 새 버전과 옛 버전을 같은 예시로 돌려 비교하는 스크립트 하나. 여기서 나온 차이가 신뢰구간 안이면 "모르겠다"고 말하시면 됩니다. 그 말을 할 수 있게 되는 것이 감으로 하는 평가와의 진짜 차이입니다.
How to Stop Evaluating LLMs on Vibes — Sample Size, Judge Bias, and CI Regression Tests
Introduction — the cost of "it seems better"
You have probably edited a prompt, run a few examples, and said "that is definitely better now." So have I. The problem is what comes next.
That change made some inputs better and some worse, but the only ones you checked were the few that caught your eye. The ones that got worse quietly survive into the next release. Repeat this twenty times and a system that "seemed better" at every step may be worse than where it started, and worse still, you have no way to confirm that. The baseline for each change was a different memory each time.
An evaluation system is not a device for raising model quality. It is a device for leaving behind a point you can return to. This post covers how to build that system starting from the cheapest layer, and how to interpret the numbers that come out of it.
The four layers of evaluation
You do not have to do all of them, but you do have to know which layer catches what.
| Layer | What it catches | Cost per example | Reliability | Run frequency |
|---|---|---|---|---|
| Deterministic assertions | Format violations, banned words, schema errors, latency overruns | Effectively 0 | Very high | Every commit |
| Golden dataset | Accuracy regression on tasks with a fixed answer | Low (API cost) | High | Every PR |
| LLM-as-judge | Relative quality on tasks with many valid answers | Medium | Medium (needs validation) | Nightly, pre-release |
| Human evaluation | Subtle quality none of the above catches | Very high | Highest (the reference) | Quarterly, on big changes |
The order matters. Many teams build the LLM-as-judge layer first. It is flashy and it shows up in papers often. Yet what actually prevents incidents is, overwhelmingly often, the first row.
Cheap assertions come first
Even for tasks where you cannot define a correct answer, you can define what must never happen. This is not statistics, it is unit testing.
import re, json
def assert_output_contract(resp, ctx):
"""things that must always hold regardless of model quality"""
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]
The fifth line earns its keep especially well. A link the model produced that was not in the context is almost certainly a hallucination, and one line of set arithmetic catches it. The same approach lets you check whether a cited number actually appears in the context, and whether a citation index points at a chunk that exists. These are all things you can verify without knowing the correct answer.
This layer does not raise accuracy. What it does is effectively eliminate the "we had an outage because the format broke" class of incident. A substantial share of real incidents falls into that class, which is why the return on investment here is the best of any layer.
The golden dataset — composition over size
To measure accuracy you need inputs with answers attached. Two mistakes are common here.
First, collecting only easy examples. An evaluation set filled with cases that already work starts at 0.95 accuracy and does not move for any change. As a measuring instrument, it is dead. An evaluation set only produces signal when it deliberately includes the inputs that failed, the boundary conditions and the ambiguous inputs.
Second, building it once and never updating it. Over time the system memorizes the evaluation set, because you kept editing the prompt to fit those examples. Unless you periodically pull new examples from production logs, the evaluation set slowly turns into a training set.
The loop that grows the set from logs looks like this.
def harvest_candidates(logs, judge, sample_rate=0.02):
"""automatically pick out requests worth adding to the evaluation set"""
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 # the ground-truth labels are attached by a human
Do not remove that last random sample branch. Fill the set with failure signals only and it becomes biased toward the hard end, and you stop catching regressions that appear on normal requests.
LLM-as-judge bias and mitigation
Tasks with more than one right answer — summarization, support replies and the like — cannot be measured by accuracy. This is where a judge model comes in. But unless you handle three known biases, its scores cannot be trusted.
Position bias. Show two answers side by side and ask which is better, and the one presented first is favored. Mitigation is simple: evaluate twice with the order swapped and count a win only when the same side wins both times.
Length preference. Judges tend to give higher scores to long, detailed answers. The catch is that length does correlate with real quality sometimes, so you must not correct for it unconditionally. The practical approach is to record the output lengths of both candidates and check the correlation between win rate and length difference. A strong correlation means the win rate is likely measuring length rather than quality.
Self-preference. Judges have been reported to give higher scores to their own output or that of models in the same family. If one of the candidates is from the same family as the judge, run a judge from a different family too and check whether the result flips.
def judge_pairwise(judge, question, answer_a, answer_b, rubric):
"""evaluate twice with the order swapped and count a win only when they agree"""
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 first
r2 = ask(answer_b, answer_a) # B first
if r1 == "1" and r2 == "2": return "A"
if r1 == "2" and r2 == "1": return "B"
return "무승부" # if swapping the order flips it, no verdict
Here, "the share of cases where swapping the order flipped the result" is itself a useful metric. A high value means either the real gap between the two candidates is small or the judge cannot discriminate on this task, and in either case you must not make a deployment decision on that judge's scores.
And the most important step is still left: validating the judge. Take around 100 human-labeled examples and measure how well the judge's verdicts agree with the humans. If agreement is low, that judge is just automated vibes. Skipping this validation and putting judge scores on a dashboard is the most common self-deception in this field.
Twenty examples tell you nothing
You got 17 out of 20 right. That is 85 percent. How much should you believe that number?
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
The true value behind 85 percent observed on 20 examples could be 64 percent or it could be 95 percent. Every change that moves within that interval is indistinguishable from noise. If you measured two versions on 20 examples each and got 80 percent and 85 percent, that result is entirely compatible with "no difference."
So how many do you need?
Z_A, Z_B = 1.96, 0.8416 # significance level 5%, power 80%
def n_unpaired(p1, p2):
"""number of examples per group when measuring the two versions on different examples"""
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):
"""number of examples needed when comparing the two versions on the same examples.
win_ratio: share of the disagreeing examples that the new version wins"""
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개 필요)
The number of examples needed to reach the same conclusion dropped from 1806 to 235. The reason is simple. In an unpaired comparison, "variance in example difficulty" and "difference between versions" are mixed together, whereas comparing the two versions on the same examples cancels the difficulty term. If you cannot afford a large evaluation set, at minimum run both versions on the same examples. That one change alone shrinks the minimum detectable effect size by several times.
There are caveats worth stating honestly. The calculation above assumes the examples are independent of each other. Ten questions derived from the same document are not independent, and in that case the effective sample size is smaller than ten. This formula also handles the case where a result is either correct or incorrect; a scored scale such as 1 to 5 uses different math. And comparing ten prompt candidates against the same evaluation set raises the probability that one of them looks good by chance. When you compare many times, the bar has to be raised accordingly.
Putting it in CI, and the problem that temperature 0 is not reproducible
Evaluation that only runs by hand eventually stops running. It has to go into the pipeline.
What you run into there is nondeterminism. Setting the temperature to 0 feels like it should give the same output for the same input, but in practice it does not. The batch composition changes the order in which floating-point values accumulate, and that changes which kernel path is selected. When the top two tokens have nearly equal probability, that minute difference makes a different token win, and from there on you get a completely different sentence. If you are on a provider API, unannounced server-side changes get added on top.
So a CI test that compares output strings verbatim will inevitably break. Structure it like this instead.
def ci_gate(baseline, candidate, n, threshold=0.03):
"""decide by a per-metric drop threshold. Do not fail on a single-example mismatch."""
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
It fails only when the baseline sits above the candidate's confidence interval. Without that, noise keeps the pipeline red, and a few weeks later nobody looks at the red light anymore. Training people to ignore an alarm is worse than having no alarm.
The execution tiers have to be split too. The tier that runs on every PR should finish within minutes using assertions and a small golden set, while the full evaluation with a judge runs nightly or before a release. Skip this split and evaluation gets slow enough that someone turns it off.
Last comes online evaluation. However good the offline evaluation set is, it differs from the real traffic distribution. Roll out in stages and watch a guardrail metric that must never get worse alongside the metric you are trying to improve. Latency doubling or the abstention rate spiking while chasing accuracy really does happen often. And when the failures found there flow back into the golden set, the loop closes.
Closing — if the baseline is a memory, regressions are invisible
If you take one thing from this post, let it be this. The purpose of an evaluation system is not to get good scores, it is to freeze a comparable baseline in code. When the baseline lives in a person's memory, regressions are by definition unobservable.
So the minimum configuration you can start today is about this much: ten assertions that must always hold, a 50-example golden set filled with inputs that failed, and one script that runs the new version and the old version on the same examples and compares them. When the difference that comes out is inside the confidence interval, you get to say "I do not know." Being able to say that is the real difference from evaluating on vibes.