LLM 평가(Evaluation) 구축
프롬프트 하나 고칠 때마다 "좋아진 건지 나빠진 건지" 감으로 판단하던 걸 끝내는 실전 가이드
LLM 기능을 한 번이라도 프로덕션에 올려본 사람은 같은 패턴을 겪는다. 프롬프트를 고치고, 몇 개 직접 돌려보고, "좋아진 것 같다" 하고 배포한다. 그러다 다른 입력에서 조용히 망가진다. 모델 버전을 올렸더니 멀쩡하던 케이스가 깨진다. 출력 포맷을 바꿨더니 다운스트림 파서가 터진다. 문제는 코드가 아니라 회귀를 감지할 수단이 없다는 것이다.
평가(eval)는 LLM 애플리케이션의 테스트 스위트다. 일반 소프트웨어에서 pytest가 하는 역할을, 비결정적이고 정답이 애매한 자연어 출력에 대해 해내야 한다. 차이는 두 가지다. (1) 출력이 매번 달라서 정확한 문자열 비교가 안 되는 경우가 많고, (2) "정답"이 단일하지 않아서 사람이 채점하거나 다른 LLM에게 채점을 맡겨야 한다. 이 가이드는 그 두 문제를 실제로 돌아가는 코드로 풀어나간다.
전체 그림은 이렇게 구성된다:
| 계층 | 무엇 | 비유 |
|---|---|---|
| 데이터셋 | 입력 + 기대 결과(또는 채점 기준)를 모은 고정 케이스 | 테스트 픽스처 |
| 태스크 러너 | 데이터셋 각 케이스를 시스템에 통과시켜 출력 수집 | 테스트 실행기 |
| 스코어러(채점기) | 출력을 점수로 변환 (정확매칭 / 코드 검증 / LLM-as-judge) | assertion |
| 회귀 게이트 | 점수를 베이스라인과 비교, 임계 미달 시 CI 실패 | pytest exit code |
이 가이드는 특정 평가 프레임워크에 종속되지 않게 "원리 → 직접 구현" 순으로 가되, 실무에서 바로 쓸 수 있는 라이브러리(promptfoo, OpenAI Evals, deepeval, Braintrust)도 어디에 끼우는지 짚는다. LLM-as-judge 예시는 Anthropic Claude API(모델 ID claude-opus-4-8 등)와 구조화 출력을 기준으로 실제 동작하는 코드를 쓴다.
1. 왜 "몇 개 돌려보기"로는 안 되는가 — eval의 ROI
LLM 기능을 손으로 검증하는 비용은 선형이 아니라 기하급수적으로 누적된다. 프롬프트를 한 줄 고칠 때마다 "이게 기존 케이스를 깨뜨리지 않았나"를 확인하려면, 머릿속에 있는 대표 케이스 5~10개를 매번 다시 돌려봐야 한다. 변경이 잦아질수록 이 반복 검증은 사실상 안 하게 되고, 회귀는 사용자가 먼저 발견한다.
eval을 한 번 구축해두면 이 비용 구조가 뒤집힌다:
- 변경 비용이 상수로 고정된다. 프롬프트를 고치고
eval한 번 돌리면 50~500개 케이스가 자동 채점된다. 사람이 다시 볼 필요가 없다. - 회귀가 배포 전에 잡힌다. CI에 게이트를 걸면 점수가 베이스라인보다 떨어질 때 머지를 막을 수 있다.
- 모델 업그레이드가 데이터 기반 결정이 된다. "
claude-opus-4-7에서claude-opus-4-8로 올리면 우리 태스크에서 정확도가 어떻게 변하나"를 추측이 아니라 숫자로 답한다. - 프롬프트 실험이 가능해진다. 변형 3개를 만들어 같은 데이터셋에 돌리고 점수로 고른다.
언제 eval을 만들 가치가 있나 — 만드는 데도 비용이 든다. 판단 기준:
| eval을 만들어라 | eval을 미뤄도 된다 |
|---|---|
| 같은 LLM 호출을 반복·여러 입력에 적용 (분류, 추출, 요약, RAG) | 일회성 스크립트, 데모 |
| 프롬프트/모델을 자주 고칠 예정 | 다시 안 건드릴 코드 |
| 출력 품질이 사용자에게 직접 노출 | 내부용, 사람이 매번 검수 |
| 실패 비용이 큼 (결제, 의료, 법률 문구) | 실패해도 가볍게 복구 |
핵심 원칙: 작게 시작하라. 완벽한 500개 데이터셋을 기다리지 말고, 실제로 망가졌던 케이스 10~20개로 시작한다. 버그가 터질 때마다 그 입력을 데이터셋에 추가하면, eval은 "한 번 겪은 실수를 두 번 겪지 않게 하는" 회귀 방지 자산으로 자연히 성장한다. 이건 일반 소프트웨어의 회귀 테스트와 정확히 같은 철학이다.
2. 평가 데이터셋 설계 — 케이스, 스키마, 출처
데이터셋이 eval의 전부다. 채점기가 아무리 정교해도 데이터셋이 현실을 반영 못 하면 점수는 거짓말을 한다.
한 케이스의 최소 스키마:
{"id": "refund-001", "input": {"message": "주문 취소했는데 환불이 안 왔어요. 3일 됐어요"}, "expected": {"intent": "refund_status", "priority": "high"}, "tags": ["refund", "korean"]}
{"id": "refund-002", "input": {"message": "배송이 너무 느린데 언제 와요?"}, "expected": {"intent": "shipping_inquiry", "priority": "medium"}, "tags": ["shipping"]}
필드별 역할:
| 필드 | 필수 | 용도 |
|---|---|---|
id | ✅ | 케이스 추적 — 어떤 케이스가 깨졌는지 식별 |
input | ✅ | 시스템에 넣을 입력 (전체를 담아라, 가공된 일부 X) |
expected | 경우에 따라 | 정답/기대값. LLM-as-judge에서는 채점 기준(rubric)으로 대체 가능 |
tags | 권장 | 슬라이스 분석용 (언어별, 카테고리별 점수 분리) |
metadata | 선택 | 출처, 난이도, 추가된 날짜 |
JSONL을 쓰는 이유: 한 줄 한 케이스라 git diff가 깔끔하고, 스트리밍으로 읽을 수 있고, 케이스 추가가 append-only다. CSV는 자연어에 콤마·줄바꿈·따옴표가 섞여 깨지기 쉽다 — JSON 문자열은 어떤 문자든 안전하게 담는다.
데이터셋을 어디서 채우나 (우선순위 순):
- 실제 프로덕션 실패 케이스 — 가장 가치 높음. 사용자가 망가뜨린 입력을 그대로. 로그·Sentry·CS 티켓에서 수집.
- 엣지 케이스 직접 작성 — 빈 입력, 초장문, 다국어 혼용, 욕설, 프롬프트 인젝션 시도, 의도적으로 모호한 입력.
- 대표 "행복 경로" — 흔한 정상 입력. 베이스라인이 흔들리지 않는지 확인.
- 합성 데이터 — LLM으로 변형 생성. 양은 채우지만 현실성이 떨어지니 1~3을 보강하는 용도로만.
흔한 함정:
- 데이터셋이 너무 쉬움 — 행복 경로만 모으면 점수 95%가 나오지만 실제 실패를 못 잡는다. 실패하는 케이스를 일부러 넣어야 회귀 감지력이 생긴다.
expected를 현재 출력에서 복사 — 지금 모델이 뱉은 걸 정답으로 박으면, 그건 "현재 동작 고정" 테스트지 "정답" 테스트가 아니다. 정답은 사람이 판단한 ground truth여야 한다.- 데이터셋 누수 — 프롬프트 튜닝에 쓴 예시를 그대로 eval에 넣으면 과적합을 측정한다. 튜닝용(dev)과 평가용(test) 셋을 분리하라.
3. 채점기(스코어러)의 3계층 — 결정적 → 코드검증 → LLM-as-judge
채점기는 출력을 점수로 바꾼다. 항상 가장 싸고 가장 결정적인 채점기를 먼저 시도하고, 그것으로 안 될 때만 위 계층으로 올라가라. LLM-as-judge를 모든 것에 쓰는 건 비싸고 느리고 노이즈가 많다.
| 계층 | 방법 | 비용 | 적합한 태스크 |
|---|---|---|---|
| 1. 결정적 매칭 | exact match, enum 일치, 정규식, 숫자 허용오차 | ~0 | 분류 라벨, 추출 필드, 구조화 출력 |
| 2. 코드 검증 | JSON 파싱, 스키마 검증, 단위 변환, SQL 실행, 코드 테스트 | 낮음 | 코드 생성, SQL, 구조화 데이터, 함수 호출 |
| 3. LLM-as-judge | 다른 LLM이 rubric 기준으로 채점 | 높음 | 요약 품질, 톤, 사실성, "도움이 되는가" |
1계층 — 결정적 매칭 예시 (분류):
def score_classification(output: dict, expected: dict) -> dict:
intent_match = output.get("intent") == expected["intent"]
priority_match = output.get("priority") == expected["priority"]
return {
"intent_correct": float(intent_match),
"priority_correct": float(priority_match),
"both_correct": float(intent_match and priority_match),
}
2계층 — 코드 검증 예시 (SQL 생성): 생성된 SQL을 "정답과 글자가 같은가"로 채점하면 안 된다. SELECT a, b 와 SELECT b, a는 다르지만 결과는 같을 수 있다. 실행 결과로 채점하라:
import sqlite3
def score_sql(generated_sql: str, expected_sql: str, db_path: str) -> dict:
con = sqlite3.connect(db_path)
try:
got = con.execute(generated_sql).fetchall()
want = con.execute(expected_sql).fetchall()
# 순서 무관 비교 (ORDER BY가 명시 안 됐다면)
return {"exec_match": float(sorted(got) == sorted(want))}
except sqlite3.Error as e:
return {"exec_match": 0.0, "error": str(e)}
finally:
con.close()
2계층 — 코드 생성 예시: 생성된 함수를 실제 테스트로 검증. (샌드박스에서 실행할 것 — 신뢰할 수 없는 코드를 그대로 exec 하지 말 것.)
def score_code(generated_code: str, test_cases: list[tuple]) -> dict:
ns = {}
try:
exec(generated_code, ns) # 실전: 격리된 샌드박스에서 실행
fn = ns["solution"]
passed = sum(1 for args, want in test_cases if fn(*args) == want)
return {"pass_rate": passed / len(test_cases)}
except Exception as e:
return {"pass_rate": 0.0, "error": type(e).__name__}
핵심 판단: 출력을 코드로 검증할 방법이 있으면 LLM-as-judge를 쓰지 마라. "이 추출된 이메일이 유효한 형식인가"는 정규식이 LLM보다 빠르고 정확하고 공짜다. LLM-as-judge는 코드로 검증 불가능한 주관적 품질에만 남겨둔다.
4. LLM-as-judge — 언제 쓰고 어떻게 신뢰하나
요약의 품질, 답변의 톤, RAG 응답이 "실제로 질문에 답했는가" — 이런 건 정규식으로 못 잡는다. 다른 LLM에게 채점을 맡기는 게 LLM-as-judge다. 강력하지만 함정이 많다. 먼저 "언제 쓰는가"와 "왜 노이즈가 생기는가"를 이해해야 한다.
LLM-as-judge가 적합한 곳:
- 정답이 여러 개인 생성 태스크 (요약, 답변, 재작성)
- 코드로 정의하기 어려운 품질 차원 (자연스러움, 정중함, 간결함)
- 사실성/근거성 — 답변이 제공된 컨텍스트에서 뒷받침되는가 (RAG hallucination 검출)
LLM-as-judge를 쓰지 말아야 할 곳:
- 결정적으로 채점 가능한 것 (위 1·2계층) — 비용·노이즈만 늘린다
- 정밀한 수치 비교, 정확한 매칭
- 정답이 명확한 분류 — judge가 오히려 틀린다
판정자가 틀리는 알려진 편향 (반드시 인지):
| 편향 | 증상 | 완화 |
|---|---|---|
| 위치 편향 | A/B 비교 시 먼저 제시된 답을 선호 | 순서 무작위화 또는 양방향 평가 후 평균 |
| 장황함 편향 | 더 긴 답을 더 좋다고 판단 | rubric에 "길이로 판단하지 말 것" 명시 |
| 자기 선호 | 같은 모델 계열이 만든 답을 선호 | 생성 모델과 판정 모델을 분리 |
| 관대함 | 거의 모든 것을 통과시킴 (점수 분산 낮음) | rubric에 구체적 감점 기준, 낮은 점수 예시 제공 |
| 포맷 민감 | 마크다운/구조가 있으면 후하게 | 내용만 보도록 명시 |
가장 중요한 메타 규칙: 판정자를 검증하라. LLM-as-judge는 또 하나의 LLM 시스템이고, 그 자체로 eval이 필요하다. 사람이 채점한 케이스 30~50개를 만들고, judge의 점수가 사람과 얼마나 일치하는지(agreement rate)를 측정하라. judge가 사람과 70%도 안 맞으면, judge 프롬프트부터 고쳐야지 그 점수를 신뢰하면 안 된다. 이걸 건너뛰면 "신뢰할 수 없는 측정 도구로 신뢰할 수 없는 시스템을 측정"하는 셈이다.
판정 모델은 강한 걸 써라. 채점은 생성보다 어려운 추론 태스크인 경우가 많다. 판정에는 claude-opus-4-8 같은 상위 모델을 쓰고, 비용이 문제면 1차 필터만 빠른 모델로 거른 뒤 애매한 케이스만 상위 모델로 재판정하는 2단 구조를 쓴다.
5. LLM-as-judge 구현 — 구조화 출력으로 안정적인 채점
judge의 출력은 반드시 구조화해야 한다. "좋음/나쁨"을 자유 텍스트로 받으면 파싱이 깨지고 집계가 불가능하다. Claude API의 구조화 출력(output_config.format)으로 스키마를 강제하면 매번 같은 형태의 JSON이 보장된다.
rubric 기반 점수 + 근거를 한 번에 받는 judge:
import anthropic
client = anthropic.Anthropic()
JUDGE_SCHEMA = {
"type": "object",
"properties": {
"reasoning": {"type": "string", "description": "점수를 매긴 구체적 근거"},
"faithfulness": {"type": "integer", "description": "컨텍스트로 뒷받침되는 정도 1-5"},
"relevance": {"type": "integer", "description": "질문에 답하는 정도 1-5"},
"pass": {"type": "boolean", "description": "두 점수 모두 4 이상이면 true"},
},
"required": ["reasoning", "faithfulness", "relevance", "pass"],
"additionalProperties": False,
}
JUDGE_SYSTEM = """당신은 엄격한 RAG 답변 채점자다.
주어진 컨텍스트, 질문, 답변을 보고 두 차원을 1-5로 채점한다.
- faithfulness: 답변의 모든 주장이 컨텍스트에서 뒷받침되는가. 컨텍스트에 없는 내용을 지어내면 1-2.
- relevance: 답변이 실제로 질문에 답하는가.
채점 규칙: 답변의 길이나 마크다운 서식으로 판단하지 말 것. 내용만 본다.
확신이 없으면 낮은 점수를 준다. 대부분의 답변은 5점이 아니다."""
def judge_rag(question: str, context: str, answer: str) -> dict:
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
system=JUDGE_SYSTEM,
output_config={"format": {"type": "json_schema", "schema": JUDGE_SCHEMA}},
messages=[{
"role": "user",
"content": (
f"<context>\n{context}\n</context>\n\n"
f"<question>\n{question}\n</question>\n\n"
f"<answer>\n{answer}\n</answer>"
),
}],
)
import json
text = next(b.text for b in resp.content if b.type == "text")
return json.loads(text)
구현 포인트:
reasoning을 점수보다 먼저 스키마에 두라. 모델이 근거를 먼저 쓰면 점수의 질이 올라간다(일종의 chain-of-thought). 점수만 받으면 즉흥적인 숫자가 나온다.- 5점 척도 + 명시적 임계 — 이진 pass/fail만 받으면 정보가 손실된다. 점수를 받아 두면 나중에 임계를 조정할 수 있다.
pass는 점수에서 파생. - rubric에 감점 기준과 "대부분은 만점이 아니다"를 명시 — 관대함 편향을 누른다.
additionalProperties: False— 스키마에 없는 필드를 막아 파싱 안정성 확보.
참고: judge 호출에도 적절한 max_tokens(여기선 reasoning이 있으니 1024 정도)를 주고, 분류처럼 라벨만 뽑는다면 더 작게. client.messages.parse()(Python SDK)를 쓰면 Pydantic 모델로 검증된 객체를 바로 받을 수 있어 json.loads를 생략할 수 있다.
6. 태스크 러너 — 데이터셋을 시스템에 통과시키기
러너는 데이터셋의 각 케이스를 "평가 대상 시스템"에 넣고 출력을 모은다. 평가 대상은 단일 LLM 호출일 수도, 전체 RAG 파이프라인일 수도, 에이전트 루프일 수도 있다. 러너가 호출하는 함수는 프로덕션에서 실제로 쓰는 그 함수여야 한다 — eval용으로 따로 만든 함수는 실제 동작을 측정하지 못한다.
기본 러너 구조:
import json
from dataclasses import dataclass, field
@dataclass
class EvalCase:
id: str
input: dict
expected: dict | None = None
tags: list[str] = field(default_factory=list)
@dataclass
class EvalResult:
case_id: str
output: dict
scores: dict
tags: list[str]
error: str | None = None
def load_dataset(path: str) -> list[EvalCase]:
cases = []
with open(path) as f:
for line in f:
line = line.strip()
if line:
cases.append(EvalCase(**json.loads(line)))
return cases
def run_eval(cases, task_fn, score_fn) -> list[EvalResult]:
results = []
for case in cases:
try:
output = task_fn(case.input) # 실제 시스템 호출
scores = score_fn(output, case.expected) # 채점
results.append(EvalResult(case.id, output, scores, case.tags))
except Exception as e:
# 실패도 결과다 — 0점 처리하고 계속 (한 케이스 때문에 전체 중단 X)
results.append(EvalResult(case.id, {}, {"error": 1.0}, case.tags, str(e)))
return results
프로덕션 시스템이 비용·지연이 크다면 병렬화가 필수다. 50개 케이스를 직렬로 LLM 호출하면 분 단위가 걸린다. ThreadPoolExecutor로 동시 실행:
from concurrent.futures import ThreadPoolExecutor
def run_eval_parallel(cases, task_fn, score_fn, workers=8):
def process(case):
try:
output = task_fn(case.input)
return EvalResult(case.id, output, score_fn(output, case.expected), case.tags)
except Exception as e:
return EvalResult(case.id, {}, {"error": 1.0}, case.tags, str(e))
with ThreadPoolExecutor(max_workers=workers) as ex:
return list(ex.map(process, cases))
비용 최적화 — Batches API: 지연이 중요하지 않은 대규모 eval(수백~수천 케이스)이라면 Claude의 Message Batches API를 쓰면 **토큰 비용이 표준의 50%**다. 대부분 1시간 내 완료(최대 24시간). 야간 회귀 스위트, 데이터셋 합성, judge 일괄 채점에 적합하다. CI에서 즉시 결과가 필요한 PR 게이트에는 부적합(동기 호출 사용).
함정:
- 예외를 삼키고 0점 처리하되, 에러를 기록하라. 한 케이스가 터졌다고 전체 eval이 죽으면 안 된다. 동시에 "왜 0점인지"(파싱 실패 vs 진짜 오답)를 구분할 수 있게
error필드를 남긴다. - 재현성 — judge 호출 등 비결정성이 있으면 점수가 실행마다 흔들린다. 같은 입력을 여러 번 채점해 평균 내거나, 데이터셋을 충분히 크게 해서 노이즈를 평균화한다.
7. 점수 집계와 슬라이스 분석 — 평균 하나로 끝내지 마라
eval을 돌리면 케이스별 점수가 나온다. 이걸 단일 평균으로 뭉개면 어디가 망가졌는지를 잃는다. 집계는 항상 (1) 전체 요약과 (2) 슬라이스별 분해를 함께 내야 한다.
기본 집계:
from collections import defaultdict
def aggregate(results, metric: str) -> dict:
scores = [r.scores.get(metric, 0.0) for r in results]
n = len(scores)
return {
"metric": metric,
"n": n,
"mean": sum(scores) / n if n else 0.0,
"pass_count": sum(1 for s in scores if s >= 1.0),
"fail_ids": [r.case_id for r in results if r.scores.get(metric, 0.0) < 1.0],
}
def aggregate_by_tag(results, metric: str) -> dict:
by_tag = defaultdict(list)
for r in results:
for tag in r.tags:
by_tag[tag].append(r.scores.get(metric, 0.0))
return {tag: sum(s) / len(s) for tag, s in by_tag.items()}
슬라이스 분석이 잡아내는 것: 전체 평균이 88%여도, 태그별로 보면 korean 0.95 / english 0.91 / refund 0.62 일 수 있다. 평균만 봤다면 환불 처리가 망가진 걸 못 본다. 태그를 미리 잘 붙여두는 것이 회수율을 좌우한다.
results = run_eval_parallel(cases, classify, score_classification)
print(aggregate(results, "both_correct"))
# {'metric': 'both_correct', 'n': 120, 'mean': 0.88, 'pass_count': 106,
# 'fail_ids': ['refund-007', 'refund-012', ...]}
print(aggregate_by_tag(results, "both_correct"))
# {'refund': 0.62, 'shipping': 0.94, 'korean': 0.95, ...}
메트릭 선택 — 분류라면 정확도만 보지 마라. 데이터가 불균형하면(예: 95%가 정상, 5%가 사기) "전부 정상"이라고 답해도 정확도 95%다. 클래스별 precision/recall, 혼동행렬을 봐야 한다:
| 태스크 | 봐야 할 메트릭 |
|---|---|
| 분류 (균형) | accuracy |
| 분류 (불균형) | per-class precision/recall, F1, 혼동행렬 |
| 추출 | 필드별 정확도, 완전일치율 |
| 생성 (judge) | rubric 차원별 평균, pass rate, 점수 분포 |
| RAG | faithfulness, relevance, 검색 hit rate 분리 |
실패 케이스 ID를 반드시 출력하라. 점수만 보면 "88%"에서 멈추지만, fail_ids가 있으면 바로 그 케이스를 열어 원인을 본다. eval의 가치는 점수가 아니라 무엇이 왜 틀렸는지를 빠르게 찾게 해주는 것이다.
8. 회귀 게이트 — CI에서 머지를 막기
eval의 마지막 조각은 자동화다. 사람이 기억해서 돌리는 eval은 안 돌아간다. CI에 게이트를 걸어 점수가 베이스라인보다 떨어지면 PR을 막는다. 이것이 "한 번 고친 버그가 다시 안 터지게" 하는 기계적 장치다.
베이스라인 저장 + 비교:
import json, sys
def save_baseline(results, path="eval_baseline.json"):
summary = {m: aggregate(results, m)["mean"] for m in ["both_correct"]}
with open(path, "w") as f:
json.dump(summary, f, indent=2)
def check_regression(results, baseline_path="eval_baseline.json", tolerance=0.02):
with open(baseline_path) as f:
baseline = json.load(f)
failed = False
for metric, base_score in baseline.items():
current = aggregate(results, metric)["mean"]
delta = current - base_score
status = "OK" if delta >= -tolerance else "REGRESSION"
print(f"{metric}: {current:.3f} (baseline {base_score:.3f}, Δ{delta:+.3f}) {status}")
if delta < -tolerance:
failed = True
return not failed
if __name__ == "__main__":
results = run_eval_parallel(load_dataset("dataset.jsonl"), classify, score_classification)
if not check_regression(results):
sys.exit(1) # CI 실패 → 머지 차단
tolerance(허용 하락폭)가 중요하다. LLM 출력에는 노이즈가 있어서 코드를 안 바꿔도 점수가 ±1~2% 흔들릴 수 있다(특히 judge 기반). tolerance를 0으로 두면 무관한 변경에도 CI가 빨개진다. tolerance를 너무 크게 두면 진짜 회귀를 놓친다. judge 기반 메트릭은 노이즈가 크니 데이터셋을 키우고 tolerance를 약간 넉넉히, 결정적 메트릭은 tolerance를 거의 0으로.
GitHub Actions 예시:
name: eval
on: [pull_request]
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install anthropic
- run: python run_eval.py
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
운영 팁:
- 베이스라인은 의도적으로 갱신한다. 정당하게 점수를 올렸으면 새 점수로 베이스라인을 커밋한다(
save_baseline). 베이스라인 변경이 git diff에 남아 리뷰 가능해진다. - PR 코멘트로 점수 표를 남겨라. "refund 0.62 → 0.81" 같은 변화를 리뷰어가 바로 본다.
- 비용 관리 — PR마다 LLM eval을 돌리면 API 비용이 쌓인다. 빠른 결정적 eval은 매 PR, 비싼 judge 기반 full eval은 main 머지 시 또는 야간 배치(Batches API, 50% 비용)로 분리하는 2단 전략이 흔하다.
9. RAG·에이전트 평가 — 파이프라인을 단계별로 쪼개라
RAG나 멀티스텝 에이전트는 "최종 답이 좋은가"만 보면 어느 단계에서 망가졌는지를 못 잡는다. 검색이 틀렸는지, 검색은 맞았는데 생성이 망쳤는지, 도구 호출이 잘못됐는지를 구분해야 고칠 수 있다. 파이프라인 평가는 단계별로 메트릭을 분리하는 게 핵심이다.
RAG의 분리된 메트릭:
| 단계 | 메트릭 | 채점 방법 |
|---|---|---|
| 검색(retrieval) | hit rate / recall@k — 정답 문서가 top-k에 있나 | 결정적 (문서 ID 비교) |
| 검색 정밀도 | precision@k — 가져온 문서 중 관련 비율 | 결정적 또는 judge |
| 생성 — 근거성 | faithfulness — 답이 검색된 컨텍스트로 뒷받침되나 | LLM-as-judge |
| 생성 — 관련성 | relevance — 답이 질문에 답하나 | LLM-as-judge |
def score_rag(case, pipeline_output) -> dict:
retrieved_ids = pipeline_output["retrieved_doc_ids"]
gold_ids = set(case.expected["relevant_doc_ids"])
# 검색은 결정적으로 채점
hit = float(bool(gold_ids & set(retrieved_ids[:5]))) # top-5에 정답 있나
recall = len(gold_ids & set(retrieved_ids)) / len(gold_ids)
# 생성은 judge로 채점
judged = judge_rag(
question=case.input["question"],
context=pipeline_output["context"],
answer=pipeline_output["answer"],
)
return {
"retrieval_hit": hit,
"retrieval_recall": recall,
"faithfulness": judged["faithfulness"] / 5.0,
"relevance": judged["relevance"] / 5.0,
}
이렇게 하면 "검색 hit는 0.94인데 faithfulness가 0.6" → 검색은 멀쩡, 생성이 컨텍스트를 무시하고 지어낸다는 진단이 나온다. 반대로 "검색 hit 0.5" → 생성 프롬프트를 아무리 고쳐도 소용없고 검색을 고쳐야 한다.
에이전트(도구 사용) 평가의 추가 차원:
- 궤적(trajectory) 채점 — 올바른 도구를 올바른 순서로 호출했나. 최종 답만 보면 운 좋게 맞은 경우를 못 거른다.
- 도구 호출 정확도 — 호출한 도구의 인자가 맞나 (결정적 채점 가능).
- 종료 조건 — 무한 루프 없이 적절히 끝났나, 불필요한 호출은 없었나.
함정 — judge에게 컨텍스트를 안 주는 것: faithfulness를 채점할 때 judge에게 검색된 컨텍스트를 반드시 함께 줘야 한다. 컨텍스트 없이 "이 답이 사실인가"를 물으면 judge가 자기 사전지식으로 판단해서, 정작 RAG가 컨텍스트를 무시한 hallucination을 못 잡는다. faithfulness는 "세상의 진실"이 아니라 "제공된 컨텍스트와의 일치"를 측정하는 것임을 잊지 마라.
10. 기성 프레임워크를 어디에 끼우나 — promptfoo, deepeval, OpenAI Evals, Braintrust
위에서 직접 구현한 4계층(데이터셋·러너·채점기·게이트)을 라이브러리가 대신 제공한다. 원리를 이해했으니 이제 어느 도구가 어느 조각을 맡는지 보면 선택이 쉽다. 직접 만들 가치는 "채점 로직이 우리 도메인에 특수할 때"이고, 그 외 배관(병렬화·집계·리포트·CI 통합)은 라이브러리에 맡기는 게 효율적이다.
| 도구 | 강점 | 적합 |
|---|---|---|
| promptfoo | YAML 선언형, 프롬프트 A/B 비교·매트릭스, 로컬 웹 뷰어 | 프롬프트/모델 빠른 비교, CI 게이트 |
| deepeval | pytest 스타일, RAG 메트릭(faithfulness 등) 내장 | 파이썬 테스트 워크플로에 통합 |
| OpenAI Evals | 레지스트리 기반, 표준 벤치마크 | 정형화된 대규모 eval |
| Braintrust | 호스팅 대시보드, 실험 추적·diff, 협업 | 팀 단위 실험 추적, 시각화 |
promptfoo 선언형 예시 — 프롬프트 변형 2개 × 모델 2개를 같은 케이스에 매트릭스로 돌리고 비교:
# promptfooconfig.yaml
providers:
- anthropic:messages:claude-opus-4-8
- anthropic:messages:claude-sonnet-4-6
prompts:
- "다음 문의를 분류하세요: {{message}}"
- file://prompts/classify_v2.txt
tests:
- vars: { message: "환불이 안 왔어요" }
assert:
- type: contains-json
- type: javascript
value: "JSON.parse(output).intent === 'refund_status'"
- vars: { message: "배송 언제 와요" }
assert:
- type: llm-rubric
value: "intent를 shipping_inquiry로 올바르게 분류했는가"
assert 타입이 정확히 앞서 본 채점 3계층에 대응한다 — contains-json/javascript(코드 검증), llm-rubric(LLM-as-judge). promptfoo eval이 매트릭스를 돌리고 exit code로 CI 게이트가 된다.
도구를 쓰든 직접 짜든 변하지 않는 원칙들 (이 가이드의 요약):
- 실패 케이스로 데이터셋을 키워라 — 버그 터질 때마다 추가, 회귀 테스트로 영속화.
- 가장 싸고 결정적인 채점기를 먼저 — LLM-as-judge는 코드로 못 푸는 것만.
- judge를 검증하라 — 사람 라벨과의 일치율을 재기 전엔 judge 점수를 믿지 마라.
- 평균 하나로 끝내지 마라 — 슬라이스별 분해 + 실패 ID 출력.
- CI 게이트 + tolerance — 노이즈는 흡수하되 진짜 회귀는 막는다.
- dev/test 분리 — 튜닝에 쓴 케이스로 평가하면 과적합을 측정한다.
- 파이프라인은 단계별로 — RAG/에이전트는 어느 단계가 깨졌는지 분리 측정.
eval은 한 번에 완성되지 않는다. 10개 케이스와 결정적 채점기로 시작해서, 실패를 만날 때마다 데이터셋과 채점기를 키워라. 6개월 뒤엔 프롬프트를 자신 있게 고치고, 모델 업그레이드를 숫자로 결정하고, 회귀를 사용자보다 먼저 잡는 시스템을 갖게 된다.