평가 하네스 구축: LLM 성능을 체계적으로 측정하라
직접 만드는 LLM 평가 시스템 — 테스트셋 설계, 자동 채점(LLM-as-Judge), 사람 평가 워크플로우, 지표 집계, 회귀 감지까지 실전 평가 하네스 구축 가이드.
지난 글에서 파인튜닝 파이프라인을 처음부터 구축해봤다. 모델을 훈련했다면 이제 핵심 질문이 남는다. “이 모델이 이전보다 실제로 나아졌는가?” 대답을 감이 아닌 숫자로 내놓으려면 평가 하네스(Eval Harness) 가 필요하다. 체계적인 평가 시스템 없이는 프롬프트를 바꿀 때마다, 모델을 업데이트할 때마다 품질이 올랐는지 내려갔는지 알 방법이 없다. 이 글에서는 테스트셋 설계부터 자동 채점, 사람 평가 통합, 회귀 감지까지 실전에서 바로 쓸 수 있는 평가 하네스를 파이썬으로 처음부터 만들어본다.
왜 체계적인 평가가 필요한가
LLM 개발에서 가장 흔한 실수는 “마지막으로 테스트한 몇 가지 케이스를 직접 보니 좋아진 것 같다”는 방식으로 품질을 판단하는 것이다. 이 접근에는 세 가지 문제가 있다.
확증 편향: 개발자는 자신이 고친 케이스를 테스트하려는 경향이 있다. 고치지 않은 영역에서 조용히 회귀가 발생해도 모른다.
재현 불가능: 어떤 기준으로, 어떤 입력으로 테스트했는지 기록이 없으면 다음 버전과 비교할 수 없다.
커버리지 부족: 한두 명이 직접 보는 케이스 수는 매우 제한적이다. 엣지 케이스, 언어 다양성, 다양한 사용자 의도를 커버하기 어렵다.
체계적인 평가 하네스는 이 세 문제를 동시에 해결한다. 고정된 테스트셋, 자동 채점, 버전 간 비교를 통해 “이번 변경이 정말 나아졌는가”를 데이터로 증명한다.
테스트셋 설계
좋은 평가는 좋은 테스트셋에서 시작된다. 테스트셋 설계의 핵심 원칙은 세 가지다.
대표성: 실제 프로덕션 트래픽의 분포를 반영해야 한다. 사용자의 80%가 간단한 질문을 한다면 테스트셋도 그 비율을 유지해야 한다.
다양성: 쉬운 케이스만으로는 모델 간 차별화가 어렵다. 엣지 케이스, 모호한 입력, 적대적 프롬프트를 의도적으로 포함시킨다.
큐레이션: 실패 케이스에서 학습한다. 프로덕션에서 유저가 불만을 표한 사례, 이전 버전이 틀린 케이스를 황금 테스트셋에 추가한다.
# eval_dataset.py
import json
from dataclasses import dataclass, field
from typing import Optional
from pathlib import Path
@dataclass
class EvalCase:
case_id: str
input: str
expected_output: Optional[str] = None # 정답이 명확한 경우
reference_criteria: Optional[str] = None # LLM-as-Judge용 채점 기준
tags: list[str] = field(default_factory=list) # 카테고리 태그
difficulty: str = "medium" # easy / medium / hard
class EvalDataset:
def __init__(self, path: str):
self.cases: list[EvalCase] = []
self._load(path)
def _load(self, path: str):
with open(path) as f:
for line in f:
data = json.loads(line)
self.cases.append(EvalCase(**data))
def filter_by_tag(self, tag: str) -> list[EvalCase]:
return [c for c in self.cases if tag in c.tags]
def summary(self):
from collections import Counter
tags = Counter(t for c in self.cases for t in c.tags)
diffs = Counter(c.difficulty for c in self.cases)
print(f"Total: {len(self.cases)} cases")
print(f"By difficulty: {dict(diffs)}")
print(f"Top tags: {tags.most_common(5)}")
테스트 케이스는 JSONL 형식으로 저장한다. 한 줄이 하나의 케이스이므로 스트리밍 처리와 부분 로드가 쉽다.
// eval_cases.jsonl
{"case_id": "sum-001", "input": "다음 글을 3줄로 요약하라: ...", "expected_output": null, "reference_criteria": "핵심 내용 포함 여부, 3줄 준수, 한국어", "tags": ["summarization", "korean"], "difficulty": "medium"}
{"case_id": "cls-001", "input": "이 리뷰의 감성을 positive/negative/neutral로 분류하라: ...", "expected_output": "positive", "tags": ["classification"], "difficulty": "easy"}
{"case_id": "code-001", "input": "파이썬 퀵소트 구현", "expected_output": null, "reference_criteria": "정확한 정렬, 재귀 구조, 타입 힌트 포함", "tags": ["coding"], "difficulty": "hard"}
Eval Runner: 비동기 배치 추론
테스트 케이스가 100개든 10,000개든 효율적으로 처리하려면 비동기 배치 추론이 필요하다. 동기 방식으로 API를 순차 호출하면 케이스당 1~2초가 쌓여 전체 eval에 수 시간이 걸릴 수 있다.
# eval_runner.py
import asyncio
import time
from dataclasses import dataclass
from openai import AsyncOpenAI
from eval_dataset import EvalCase
@dataclass
class InferenceResult:
case_id: str
actual_output: str
latency_ms: float
model: str
error: str | None = None
class EvalRunner:
def __init__(self, model: str = "gpt-4o", concurrency: int = 8):
self.client = AsyncOpenAI()
self.model = model
self.semaphore = asyncio.Semaphore(concurrency) # 동시 요청 제한
async def _infer_one(self, case: EvalCase, system_prompt: str) -> InferenceResult:
async with self.semaphore:
start = time.monotonic()
try:
resp = await self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": case.input},
],
temperature=0, # 재현 가능성을 위해 0으로 고정
max_tokens=1024,
)
output = resp.choices[0].message.content
latency = (time.monotonic() - start) * 1000
return InferenceResult(
case_id=case.case_id,
actual_output=output,
latency_ms=round(latency, 1),
model=self.model,
)
except Exception as e:
latency = (time.monotonic() - start) * 1000
return InferenceResult(
case_id=case.case_id,
actual_output="",
latency_ms=round(latency, 1),
model=self.model,
error=str(e),
)
async def run(
self, cases: list[EvalCase], system_prompt: str
) -> list[InferenceResult]:
tasks = [self._infer_one(c, system_prompt) for c in cases]
results = await asyncio.gather(*tasks)
return list(results)
# 사용
async def main():
dataset = EvalDataset("eval_cases.jsonl")
runner = EvalRunner(model="gpt-4o", concurrency=8)
results = await runner.run(dataset.cases, system_prompt="당신은 전문 AI 어시스턴트입니다.")
print(f"완료: {len(results)}개, 오류: {sum(1 for r in results if r.error)}")
asyncio.run(main())
semaphore(concurrency=8)는 동시 API 요청 수를 8개로 제한한다. 이 값은 사용하는 API의 Rate Limit에 맞게 조정한다. GPT-4o는 Tier에 따라 다르지만 기본적으로 분당 500 RPM, 30,000 TPM을 제공한다.
메트릭 유형과 선택 기준
평가 메트릭은 태스크 특성과 비용에 따라 선택해야 한다.
1. Exact Match — 정형 출력 태스크
분류, 정보 추출, 예/아니오 답변처럼 정답이 명확한 경우에 사용한다. 빠르고 비용이 없으며 완전히 결정론적이다.
def exact_match_score(predicted: str, expected: str) -> float:
return 1.0 if predicted.strip().lower() == expected.strip().lower() else 0.0
def keyword_inclusion_score(predicted: str, keywords: list[str]) -> float:
"""모든 키워드 포함 여부 체크"""
hits = sum(1 for kw in keywords if kw.lower() in predicted.lower())
return hits / len(keywords) if keywords else 0.0
2. ROUGE / BERTScore — 텍스트 유사도
요약, 번역처럼 참조 출력이 있지만 정확한 매칭이 아닌 경우에 사용한다. ROUGE는 어휘 겹침(n-gram overlap)을, BERTScore는 의미적 유사도(코사인 유사도)를 측정한다.
from rouge_score import rouge_scorer
from bert_score import score as bert_score
def rouge_l_score(hypothesis: str, reference: str) -> float:
scorer = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=True)
result = scorer.score(reference, hypothesis)
return result["rougeL"].fmeasure
def bert_score_f1(hypotheses: list[str], references: list[str]) -> list[float]:
"""BERTScore F1 — 배치 처리 권장"""
P, R, F = bert_score(
hypotheses, references,
lang="ko", # 한국어 모델 사용
model_type="klue/roberta-large",
)
return F.tolist()
3. LLM-as-Judge — 복잡한 기준 자동화
창의성, 논리성, 지시 따르기처럼 규칙으로 정의하기 어려운 기준을 GPT-4나 Claude 같은 강력한 모델이 채점한다. 비용이 발생하지만 사람 평가에 가장 가까운 결과를 자동으로 얻을 수 있다.
# judge.py
import json
from openai import OpenAI
JUDGE_SYSTEM_PROMPT = """당신은 AI 출력 품질을 평가하는 전문 채점관입니다.
주어진 기준에 따라 1~5점으로 채점하고, 반드시 JSON으로만 응답하세요.
형식: {"score": <1-5>, "reasoning": "<간단한 이유>"}"""
JUDGE_TEMPLATE = """[태스크 설명]
{criteria}
[모델 입력]
{input}
[모델 출력]
{output}
위 출력을 1~5점으로 평가하세요. 5점: 완벽, 4점: 양호, 3점: 보통, 2점: 미흡, 1점: 부적절."""
class LLMJudge:
def __init__(self, judge_model: str = "gpt-4o"):
self.client = OpenAI()
self.judge_model = judge_model
def score(self, input_text: str, output: str, criteria: str) -> dict:
prompt = JUDGE_TEMPLATE.format(
criteria=criteria,
input=input_text,
output=output,
)
resp = self.client.chat.completions.create(
model=self.judge_model,
messages=[
{"role": "system", "content": JUDGE_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
temperature=0,
response_format={"type": "json_object"},
)
return json.loads(resp.choices[0].message.content)
# 사용
judge = LLMJudge(judge_model="gpt-4o")
result = judge.score(
input_text="한국 경제의 현황을 분석하라",
output="...<모델 출력>...",
criteria="사실 정확성, 논리적 구조, 객관성을 5점 척도로 평가",
)
print(result) # {"score": 4, "reasoning": "논리 구조는 명확하나 최신 데이터 미반영"}
LLM-as-Judge를 사용할 때 중요한 점은 채점 기준을 구체적으로 작성하는 것이다. “좋은지 평가하라”보다 “사실 정확성, 200자 이내 준수, 한국어 문체 일관성”처럼 측정 가능한 기준을 나열해야 채점 일관성이 높아진다.
사람 평가 워크플로우 통합
LLM-as-Judge는 편리하지만 모든 경우에 신뢰할 수 없다. 특히 새로운 도메인에서는 Judge 모델도 틀릴 수 있다. 사람 평가와 LLM 평가의 상관관계를 주기적으로 검증해야 한다.
# human_eval.py — 어노테이션 작업 생성기
import json
import random
from pathlib import Path
def create_annotation_batch(
inference_results: list[dict],
sample_size: int = 50,
output_path: str = "annotation_batch.jsonl"
):
"""
자동 채점 결과 중 샘플을 추출해 사람이 검토할 배치 생성.
LLM Judge 점수와 최종 사람 점수를 비교해 Judge 신뢰도 측정.
"""
sampled = random.sample(inference_results, min(sample_size, len(inference_results)))
with open(output_path, "w") as f:
for item in sampled:
annotation_item = {
"case_id": item["case_id"],
"input": item["input"],
"actual_output": item["actual_output"],
"llm_judge_score": item.get("judge_score"),
"human_score": None, # 어노테이터가 채울 필드
"human_notes": None,
}
f.write(json.dumps(annotation_item, ensure_ascii=False) + "\n")
print(f"{len(sampled)}개 어노테이션 배치 생성 → {output_path}")
def compute_judge_correlation(annotation_path: str) -> float:
"""사람 평가와 LLM Judge 점수의 상관계수 계산"""
from scipy.stats import pearsonr
human_scores, llm_scores = [], []
with open(annotation_path) as f:
for line in f:
item = json.loads(line)
if item["human_score"] is not None and item["llm_judge_score"] is not None:
human_scores.append(item["human_score"])
llm_scores.append(item["llm_judge_score"])
if len(human_scores) < 10:
print("⚠ 상관계수 계산에는 최소 10개 이상의 사람 평가가 필요합니다.")
return 0.0
corr, pvalue = pearsonr(human_scores, llm_scores)
print(f"Judge 상관계수: r={corr:.3f}, p={pvalue:.4f}")
print(f"판단: {'신뢰 가능' if corr > 0.7 else '추가 검토 필요'}")
return corr
경험상 LLM Judge와 사람 평가의 피어슨 상관계수가 0.7 이상이면 Judge를 자동화에 신뢰할 수 있다고 본다. 상관이 낮다면 채점 기준 프롬프트를 개선하거나 Judge 모델을 바꿔야 한다.
결과 집계와 지표 대시보드
모든 채점이 끝나면 결과를 집계해 버전 간 비교가 가능하도록 저장한다.
# aggregator.py
import json
import sqlite3
from datetime import datetime
from dataclasses import dataclass
@dataclass
class EvalRun:
run_id: str
model: str
prompt_version: str
timestamp: str
scores: dict # 메트릭별 집계 점수
case_results: list # 개별 케이스 결과
class ResultsDB:
def __init__(self, db_path: str = "eval_results.db"):
self.conn = sqlite3.connect(db_path)
self._init_schema()
def _init_schema(self):
self.conn.execute("""
CREATE TABLE IF NOT EXISTS eval_runs (
run_id TEXT PRIMARY KEY,
model TEXT,
prompt_version TEXT,
timestamp TEXT,
avg_score REAL,
pass_rate REAL,
avg_latency_ms REAL,
scores_json TEXT
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS case_results (
run_id TEXT,
case_id TEXT,
score REAL,
latency_ms REAL,
error TEXT,
FOREIGN KEY(run_id) REFERENCES eval_runs(run_id)
)
""")
self.conn.commit()
def save_run(self, run: EvalRun):
scores = run.scores
self.conn.execute(
"INSERT INTO eval_runs VALUES (?,?,?,?,?,?,?,?)",
(
run.run_id, run.model, run.prompt_version, run.timestamp,
scores.get("avg_score"), scores.get("pass_rate"),
scores.get("avg_latency_ms"), json.dumps(scores),
),
)
for case in run.case_results:
self.conn.execute(
"INSERT INTO case_results VALUES (?,?,?,?,?)",
(run.run_id, case["case_id"], case.get("score"), case.get("latency_ms"), case.get("error")),
)
self.conn.commit()
print(f"Run {run.run_id} 저장 완료 (avg_score={scores.get('avg_score'):.3f})")
def compare_versions(self, run_id_a: str, run_id_b: str):
"""두 eval run 비교"""
rows = self.conn.execute(
"SELECT run_id, prompt_version, avg_score, pass_rate FROM eval_runs WHERE run_id IN (?,?)",
(run_id_a, run_id_b),
).fetchall()
for row in rows:
print(f"run={row[0]} | version={row[1]} | score={row[2]:.3f} | pass_rate={row[3]:.3f}")
회귀 감지: 자동 경보 시스템
새 버전의 모델이나 프롬프트를 배포하기 전에 이전 버전 대비 성능이 하락했는지 자동으로 감지한다. CI/CD 파이프라인에 통합하면 배포 전 자동으로 eval이 실행되고, 회귀가 감지되면 배포를 차단할 수 있다.
# regression_check.py
from scipy.stats import wilcoxon
def detect_regression(
baseline_scores: list[float],
candidate_scores: list[float],
threshold: float = -0.05, # -5% 이상 하락 시 회귀
alpha: float = 0.05, # 통계적 유의수준
) -> dict:
"""
Wilcoxon Signed-Rank Test로 통계적으로 유의한 성능 하락인지 검증.
단순 평균 비교만으로는 노이즈에 취약하므로 비모수 검정을 사용한다.
"""
baseline_avg = sum(baseline_scores) / len(baseline_scores)
candidate_avg = sum(candidate_scores) / len(candidate_scores)
delta = candidate_avg - baseline_avg
# 통계 검정 (충분한 샘플이 있을 때)
is_significant = False
p_value = 1.0
if len(baseline_scores) >= 20:
try:
stat, p_value = wilcoxon(candidate_scores, baseline_scores, alternative="less")
is_significant = p_value < alpha
except ValueError:
pass # 두 분포가 동일하면 예외 발생 (점수 변화 없음)
is_regression = delta < threshold and is_significant
result = {
"baseline_avg": round(baseline_avg, 4),
"candidate_avg": round(candidate_avg, 4),
"delta": round(delta, 4),
"p_value": round(p_value, 4),
"is_significant": is_significant,
"is_regression": is_regression,
"verdict": "FAIL — 회귀 감지됨" if is_regression else "PASS",
}
print(f"회귀 검사: {result['verdict']} (delta={delta:+.4f}, p={p_value:.4f})")
return result
# CI/CD 통합 예시
if __name__ == "__main__":
import sys
baseline = [0.82, 0.74, 0.91, 0.68, 0.87] # 이전 버전 점수 목록
candidate = [0.71, 0.62, 0.80, 0.55, 0.73] # 신규 버전 점수 목록
result = detect_regression(baseline, candidate)
sys.exit(1 if result["is_regression"] else 0) # 회귀 시 exit code 1로 CI 실패
eval-driven 개발 마인드셋
평가 하네스를 만드는 것보다 중요한 것은 eval-driven 개발 습관이다. 코드를 바꾸기 전에 먼저 테스트 케이스를 추가하고, 변경 후에는 반드시 eval을 돌린다. 새로운 실패 케이스가 발견되면 즉시 테스트셋에 추가한다. 이 사이클을 반복하면 테스트셋 자체가 점점 정밀해지고, 모델의 실제 약점이 코드에 문서화된다.
실전 팁 몇 가지를 정리하면 다음과 같다.
작게 시작하라: 처음부터 완벽한 평가 시스템을 만들려 하지 말고, 20~50개의 골든 케이스와 Exact Match부터 시작한다. 필요에 따라 메트릭을 추가한다.
케이스를 꾸준히 추가하라: 프로덕션 오류 로그, 사용자 피드백, 버그 리포트에서 새 케이스를 정기적으로 추가한다. 테스트셋은 살아 있는 문서다.
Judge 신뢰도를 주기적으로 검증하라: 두 달마다 30~50개 케이스를 사람이 직접 채점해 LLM Judge와의 상관관계를 확인한다.
eval 속도를 지켜라: eval이 오래 걸리면 개발자가 실행을 게을리한다. 핵심 케이스 서브셋으로 구성한 “빠른 eval”(5분 이내)을 따로 만들어 커밋마다 실행하고, 전체 eval은 PR 때만 실행한다.
지난 글: 파인튜닝 파이프라인: QLoRA부터 Ollama 배포까지
다음 글: 프롬프트 반복 개발: 체계적인 이터레이션 워크플로우
읽어주셔서 감사합니다. 😊