LLM 기반 기능을 개발하다 보면 “프롬프트를 바꿨는데 좋아진 건지 나빠진 건지 모르겠다”는 순간을 반드시 만납니다. 사람이 매번 눈으로 몇 개 돌려보는 방식은 재현성이 없고 회귀를 잡지 못합니다. 필요한 것은 자동화된 평가 파이프라인, 즉 오프라인 벤치마크와 LLM-as-judge를 결합한 구조입니다.
이 글에서는 골든 데이터셋 구성, 결정론적 자동 채점, 그리고 정답이 하나로 떨어지지 않는 주관적 태스크를 LLM 심판으로 채점하는 방법을 실제 코드와 함께 정리합니다. LLM-as-judge의 알려진 편향과 그 완화책까지 다뤄, 실무에 바로 얹을 수 있는 형태로 구성합니다.
두 축으로 나눠 생각하기: 오프라인 벤치 vs LLM-judge
평가는 성격에 따라 방식이 갈립니다. 정답이 명확한 태스크(추출, 분류, 형식 준수, 코드 실행 결과)는 결정론적으로 채점할 수 있고 빠르고 저렴합니다. 반면 정답이 열려 있는 태스크(요약 품질, 어조, 유용성)는 규칙으로 재기 어려워 LLM 심판이 유용합니다.
- 오프라인 벤치(programmatic): 정확 일치, 정규식, JSON 스키마 검증, 단위 테스트 실행 — 결정론적, CI에 적합
- LLM-as-judge: 기준(rubric) 기반 점수, 두 응답 비교(pairwise) — 유연하지만 비결정적이고 비용 발생
둘은 배타적이지 않습니다. 먼저 결정론적 검사로 명백한 실패(형식 위반, 필수 필드 누락)를 걸러내고, 통과한 것만 LLM 심판으로 품질을 매기는 2단 게이트가 비용과 신뢰도 면에서 가장 실용적입니다.
골든 데이터셋: 평가의 뿌리
어떤 평가든 출발점은 대표성 있는 케이스 모음입니다. 실제 트래픽에서 다양한 난이도·엣지 케이스를 샘플링하고, 각 케이스에 기대 결과나 채점 기준을 명시합니다. JSONL로 관리하면 버전 관리와 확장이 쉽습니다.
{"id": "sum-001", "input": "다음 회의록을 3줄로 요약: ...", "type": "summary", "must_include": ["예산", "일정"], "max_lines": 3}
{"id": "cls-014", "input": "이 문의를 분류: 환불 요청 ...", "type": "classify", "expected": "refund"}
{"id": "ext-032", "input": "영수증에서 총액 추출: ...", "type": "extract", "expected": {"total": 48500}}
데이터셋은 고정되어야 회귀를 잡을 수 있습니다. 프롬프트나 모델을 바꿔도 같은 셋으로 돌려 점수를 비교합니다. 실제 운영 중 발견된 실패 사례는 반드시 데이터셋에 회귀 케이스로 추가해, 같은 실수가 재발하지 않도록 합니다.
결정론적 자동 채점 구현
정답이 명확한 케이스는 코드로 채점합니다. 타입별로 채점 함수를 분기하면 됩니다. 아래는 분류·추출·형식 검증을 포함한 최소 예시입니다.
import json, re
def score_classify(pred, item):
return 1.0 if pred.strip().lower() == item["expected"] else 0.0
def score_extract(pred, item):
try:
obj = json.loads(pred)
except json.JSONDecodeError:
return 0.0
return 1.0 if obj == item["expected"] else 0.0
def score_summary(pred, item):
lines = [l for l in pred.splitlines() if l.strip()]
if len(lines) > item["max_lines"]:
return 0.0
hits = sum(1 for kw in item["must_include"] if kw in pred)
return hits / len(item["must_include"])
SCORERS = {"classify": score_classify, "extract": score_extract, "summary": score_summary}
이런 채점기는 빠르고 비용이 없으며 완전히 재현 가능합니다. 코드 생성 태스크라면 생성된 코드를 샌드박스에서 실행해 테스트 통과 여부로 채점하는 것이 가장 신뢰도 높은 오프라인 지표입니다.
LLM-as-judge: 기준을 명시한 채점
주관적 품질은 심판 모델에게 구체적 rubric과 출력 형식을 강제해 점수를 받습니다. 모호한 “좋아?” 대신 항목별 기준을 주고, 파싱 가능한 구조화 출력을 요구하는 것이 핵심입니다. Anthropic 파이썬 SDK로 Claude를 심판으로 쓰는 예시입니다.
from anthropic import Anthropic
client = Anthropic()
JUDGE_PROMPT = """당신은 요약 품질 평가자입니다. 아래 기준으로 채점하세요.
- 정확성(0-5): 원문 사실과 모순이 없는가
- 완결성(0-5): 핵심 논점을 빠짐없이 담았는가
- 간결성(0-5): 불필요한 반복이 없는가
원문:
{source}
요약:
{summary}
반드시 아래 JSON만 출력:
{{"accuracy": int, "completeness": int, "conciseness": int, "reason": "짧은 근거"}}"""
def judge(source, summary):
msg = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=512,
temperature=0, # 채점은 결정성을 위해 0
messages=[{"role": "user",
"content": JUDGE_PROMPT.format(source=source, summary=summary)}],
)
return json.loads(msg.content[0].text)
temperature=0으로 채점 변동을 줄이고, 절대 점수보다 pairwise 비교(A와 B 중 어느 쪽이 나은가)가 더 안정적인 신호를 줍니다. 여러 후보 프롬프트를 서로 겨루게 하면 순위가 뚜렷해집니다.
심판의 편향과 완화책
LLM 심판은 편리하지만 알려진 편향이 있습니다. 맹신하면 잘못된 방향으로 최적화됩니다.
- 위치 편향: 먼저(또는 나중에) 제시된 응답을 선호. 완화 → A/B 순서를 바꿔 두 번 평가하고 결과가 뒤집히면 무승부 처리
- 장황함 편향: 더 긴 답을 더 좋다고 판단하는 경향. 완화 → rubric에 “길이가 아닌 내용으로 평가” 명시
- 자기 선호: 심판 모델이 자기 계열 출력을 선호. 완화 → 생성 모델과 다른 심판 모델 사용
# 위치 편향 완화: 순서를 뒤집어 두 번 평가
def robust_pairwise(judge_fn, a, b):
r1 = judge_fn(a, b) # A 먼저
r2 = judge_fn(b, a) # B 먼저
if r1 == "first" and r2 == "second":
return "A"
if r1 == "second" and r2 == "first":
return "B"
return "tie" # 순서 뒤집혔을 때 결과가 흔들리면 무승부
더 중요한 것은 심판 자체를 검증하는 것입니다. 사람이 라벨링한 소수의 케이스에 심판을 돌려, 심판-사람 일치율을 측정하세요. 일치율이 낮으면 rubric을 다듬어야지, 그 심판 점수를 지표로 삼으면 안 됩니다.
CI에 얹어 회귀 방어하기
평가를 파이프라인화하면 프롬프트·모델 변경이 품질을 떨어뜨릴 때 자동으로 잡힙니다. 전체 데이터셋을 돌려 집계 점수를 계산하고, 기준선(baseline) 대비 하락 시 실패시킵니다.
def run_eval(dataset, generate_fn):
scores = []
for item in dataset:
pred = generate_fn(item["input"])
s = SCORERS[item["type"]](pred, item) if item["type"] in SCORERS \
else judge_to_score(item, pred)
scores.append(s)
return sum(scores) / len(scores)
avg = run_eval(load_jsonl("golden.jsonl"), generate)
BASELINE = 0.82
print(f"eval score: {avg:.3f} (baseline {BASELINE})")
assert avg >= BASELINE - 0.02, "품질 회귀 감지 — 병합 차단"
비용이 걱정된다면 결정론적 채점은 매 PR마다, 비싼 LLM-judge는 nightly나 릴리스 전에만 돌리는 식으로 빈도를 나누는 것이 현실적입니다.
마무리
LLM 평가 자동화의 목표는 “감으로 좋아진 것 같다”를 “데이터로 0.82에서 0.87로 올랐다”로 바꾸는 것입니다. 정답이 명확한 태스크는 결정론적 채점으로 빠르고 저렴하게 게이트를 세우고, 주관적 품질은 명시적 rubric과 pairwise 비교로 LLM 심판을 활용하되, 위치·장황함·자기 선호 편향을 순서 반전과 별도 심판 모델로 완화하세요. 무엇보다 심판 자체를 사람 라벨과 대조해 신뢰도를 확인하고, 골든 데이터셋에 실패 사례를 계속 축적해 CI에서 회귀를 자동으로 막는 구조를 갖추는 것이 장기적으로 가장 큰 차이를 만듭니다.