- Published on
LLM 평가를 감으로 하지 않는 법 — 표본 크기, 심사자 편향, CI 회귀 테스트
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — "좋아진 것 같아요"의 비용
프롬프트를 고치고 예시 몇 개를 돌려 본 뒤 "확실히 나아졌네요"라고 말한 적이 있으실 겁니다. 저도 있습니다. 문제는 그다음입니다.
그 변경은 어떤 입력에서는 나아지고 어떤 입력에서는 나빠졌을 텐데, 확인한 것은 눈에 띈 몇 개뿐입니다. 나빠진 쪽은 다음 릴리스에서도 조용히 남습니다. 이 과정을 스무 번 반복하면 각 단계에서 "좋아진 것 같았던" 시스템이 처음보다 나빠져 있을 수 있고, 더 나쁜 건 그 사실을 확인할 방법이 없다는 것입니다. 변경마다의 기준선이 매번 다른 기억이었기 때문입니다.
평가 체계는 모델 품질을 올리기 위한 장치가 아닙니다. 되돌아갈 지점을 남기기 위한 장치입니다. 이 글은 그 체계를 비용이 낮은 층부터 쌓는 방법과, 거기서 나온 숫자를 어떻게 해석해야 하는지를 다룹니다.
평가의 네 층위
전부 다 할 필요는 없지만, 어떤 층이 무엇을 잡는지는 알아야 합니다.
| 층위 | 잡아내는 것 | 예시당 비용 | 신뢰도 | 실행 빈도 |
|---|---|---|---|---|
| 결정적 어서션 | 형식 위반, 금칙어, 스키마 오류, 지연 초과 | 사실상 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개, 그리고 새 버전과 옛 버전을 같은 예시로 돌려 비교하는 스크립트 하나. 여기서 나온 차이가 신뢰구간 안이면 "모르겠다"고 말하시면 됩니다. 그 말을 할 수 있게 되는 것이 감으로 하는 평가와의 진짜 차이입니다.