왜 오프라인 벤치가 필요한가
프롬프트 한 줄, 모델 버전 하나, temperature 값 하나만 바꿔도 출력 품질은 달라진다. 문제는 이 변화를 "느낌"으로 판단하는 순간 회귀(regression)를 놓친다는 점이다. 어제 잘 되던 요약이 오늘 배포에서 핵심 문장을 빠뜨려도, 정량 지표가 없으면 사용자 클레임이 들어와서야 알게 된다. 오프라인 벤치는 배포 전에 고정된 데이터셋으로 품질을 수치화해 이 회귀를 CI에서 잡는 장치다.
온라인 A/B는 통계적으로 신뢰도가 높지만 느리고 비싸며, 이미 나쁜 버전을 사용자에게 노출한다는 한계가 있다. 오프라인 벤치는 트래픽 없이, 커밋마다, 수 분 내에 돌아간다. 둘은 대체재가 아니라 순서다.
데이터셋을 먼저 고정한다
평가의 신뢰도는 데이터셋의 안정성에서 나온다. 케이스는 버전 관리되는 JSONL로 두고, 함부로 수정하지 않는다. 실패 사례가 발견되면 삭제가 아니라 추가한다.
{"id": "sum-001", "input": "3분기 매출은 전년比 12% 증가...", "reference": "3분기 매출 12% 증가", "tags": ["summary", "finance"]}
{"id": "sum-002", "input": "환불 정책은 구매 후 14일 이내...", "reference": "14일 이내 환불 가능", "tags": ["summary", "policy"]}
태그를 붙여두면 "정책 카테고리만 점수가 떨어졌다" 같은 국소 회귀를 분리해 볼 수 있다. 케이스 수는 처음부터 많을 필요 없다. 실제 장애를 재현하는 30~50개가 무작위 수백 개보다 낫다.
채점 방식: 규칙과 LLM-as-judge를 나눠 쓴다
모든 걸 LLM으로 채점하면 비용과 변동성이 커진다. 결정적으로 판정 가능한 것은 코드로 채점하고, 주관적 품질만 판정 모델에 맡긴다.
| 채점 방식 | 적합한 대상 | 비용/변동성 |
|---|---|---|
| 정규식·정확 일치 | 포맷, 필수 키워드, JSON 스키마 | 낮음 / 없음 |
| 임베딩 유사도 | 의미 보존, 요약 일치 | 중간 / 낮음 |
| LLM-as-judge | 유용성, 톤, 근거 충실도 | 높음 / 중간 |
파이프라인 구현
실행부는 단순하게 유지한다. 케이스를 읽고, 모델을 호출하고, 채점기를 통과시키고, 집계한다. 판정 모델은 근거를 강제로 함께 뱉게 해 디버깅을 쉽게 한다.
import json, statistics
def judge(pred, ref, client):
prompt = (f"기준 답: {ref}\n생성 답: {pred}\n"
"생성 답이 기준의 핵심 사실을 보존하면 1, 누락/왜곡이면 0. "
'JSON으로 {"score":0|1,"reason":"..."} 만 출력.')
r = client.messages.create(
model="claude-sonnet-4-6", max_tokens=200,
temperature=0, # 판정은 반드시 0
messages=[{"role": "user", "content": prompt}])
return json.loads(r.content[0].text)
def run(cases, generate, client):
results = []
for c in cases:
pred = generate(c["input"])
v = judge(pred, c["reference"], client)
results.append({"id": c["id"], **v, "tags": c["tags"]})
score = statistics.mean(r["score"] for r in results)
return score, results
판정 호출의 temperature=0은 타협 대상이 아니다. 채점기 자체가 흔들리면 회귀 신호와 노이즈를 구분할 수 없다.
CI에 임계값 게이트를 건다
점수를 로그로만 남기면 아무도 안 본다. 기준선(baseline) 대비 하락 폭이 허용치를 넘으면 빌드를 실패시킨다.
eval:
script:
- python -m bench.run --cases data/cases.jsonl --out score.json
- |
python - <<'PY'
import json
cur = json.load(open("score.json"))["score"]
base = json.load(open("baseline.json"))["score"]
if cur < base - 0.03: # 3%p 이상 하락 시 차단
raise SystemExit(f"regression: {base:.3f} -> {cur:.3f}")
PY
절대 점수가 아니라 기준선 대비 델타로 판단하는 게 핵심이다. 데이터셋이 어려워 절대 점수가 0.7이어도, 그 값이 유지되면 통과여야 한다.
주의점
- 판정 모델 편향: 채점에 쓴 모델과 생성 모델이 같으면 자기 출력을 후하게 준다. 판정은 다른 모델 또는 최소한 다른 버전으로 둔다.
- 데이터셋 오염: 프롬프트 튜닝을 벤치 케이스에 맞춰 하면 과적합된다. 검증용 케이스를 일부 홀드아웃으로 분리한다.
- 비결정성: 생성 온도가 0이 아니면 실행마다 점수가 흔들린다. 케이스당 여러 번 샘플링해 평균 내거나, 벤치 전용으로 온도를 낮춘다.
- 비용 폭주: 케이스×샘플수×판정 호출이 곱으로 늘어난다. 규칙 채점으로 거를 수 있는 건 먼저 걸러 판정 호출을 줄인다.
오프라인 벤치는 완벽한 품질 보증이 아니라 "명백한 후퇴를 배포 전에 막는" 안전망이다. 이 선을 넘지 않게만 잡아둬도 릴리스 사고의 상당수는 사라진다.