왜 오프라인 벤치가 필요한가

프롬프트 한 줄, 모델 버전 하나, 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이 아니면 실행마다 점수가 흔들린다. 케이스당 여러 번 샘플링해 평균 내거나, 벤치 전용으로 온도를 낮춘다.
  • 비용 폭주: 케이스×샘플수×판정 호출이 곱으로 늘어난다. 규칙 채점으로 거를 수 있는 건 먼저 걸러 판정 호출을 줄인다.

오프라인 벤치는 완벽한 품질 보증이 아니라 "명백한 후퇴를 배포 전에 막는" 안전망이다. 이 선을 넘지 않게만 잡아둬도 릴리스 사고의 상당수는 사라진다.