반응형
프롬프트를 바꿨더니 기존에 잘 되던 케이스가 깨졌습니다. 얼마나 깨졌는지 모릅니다. 테스트가 없기 때문입니다. 코드 개발에서 TDD가 해결한 문제를 AI 에이전트 개발에서 Eval-Driven Development(EDD)가 해결합니다. 차이는 하나입니다. 코드는 pass/fail이고, AI 에이전트는 확률적입니다.
[핵심 요약]
→ EDD: eval을 코드 작성 전에 먼저 정의 — 프롬프트·파이프라인·모델 선택 이전
→ TDD와 차이: pass/fail → 점수 기반 임계값 (예: 80% 이상이면 통과)
→ 핵심 루프: eval 정의 → 실패 확인 → 프롬프트 수정 → 통과 → CI 게이트
→ 비결정론 대응: 동일 입력 × 5~10회 실행 → 평균 점수로 판단
→ Evaluator 3가지: 규칙 기반(빠름·정확) / LLM-as-Judge(유연) / 인간 검토(최종)
→ 프로덕션 실패 → 즉시 Eval 케이스 등록 → 회귀 방지 자동화
→ 도구: pytest + 커스텀 스코어 / Braintrust / RAGAS / DeepEval
→ Hacker News 트렌딩: "Eval이 없는 AI 개발은 테스트 없는 코드 배포와 같다"
TDD vs EDD — 무엇이 다른가
[전통 TDD]
1. 실패하는 테스트 작성 (Red)
2. 테스트 통과하는 코드 작성 (Green)
3. 리팩토링 (Refactor)
4. pass / fail — 이진 판단
[Eval-Driven Development]
1. 실패하는 Eval 정의 (Red)
→ "좋은 응답이란 무엇인가" 임계값 먼저 정의
2. Eval 통과하는 프롬프트·파이프라인 작성 (Green)
3. 리팩토링 (Refactor)
4. 0~1 점수 → 임계값 판단 (예: 0.8 이상이면 통과)
[핵심 차이]
TDD: expect(result).toBe("정확한 문자열")
EDD: expect(score(result)).toBeGreaterThan(0.8)
왜 이진 판단이 안 되나:
→ "파이썬을 한 줄로 설명해줘"에 대한 정답은 수백 가지
→ 모두 올바르지만 모두 다름
→ 비결정론: 같은 프롬프트, 같은 모델 → 매번 다른 응답
→ 따라서 "정확히 일치"가 아니라 "기준 이상의 품질"을 테스트
실전 1 — Eval 먼저 작성하기
# 전통적 방식 (❌ — Eval 나중에)
# 1. 프롬프트 작성
# 2. 몇 번 테스트
# 3. "좋아 보이네" → 배포
# 4. 프로덕션 실패 → 원인 불명
# EDD 방식 (✅ — Eval 먼저)
# 1. "좋은 응답이란 무엇인가" 정의
# 2. Eval 작성 → 초기엔 전부 실패
# 3. Eval 통과할 때까지 프롬프트 수정
# 4. CI에서 자동 실행
import anthropic
from dataclasses import dataclass
from typing import Callable
import re
client = anthropic.Anthropic()
@dataclass
class EvalCase:
"""단일 Eval 케이스"""
name: str
input: dict
scorers: list[Callable] # 여러 스코어 함수 조합
pass_threshold: float = 0.8 # 이 점수 이상이면 통과
@dataclass
class EvalResult:
case_name: str
output: str
scores: dict[str, float]
avg_score: float
passed: bool
details: str = ""
def run_eval(
agent_fn: Callable,
cases: list[EvalCase],
n_runs: int = 3 # 비결정론 대응: 여러 번 실행
) -> list[EvalResult]:
"""
Eval 실행 — 비결정론적 에이전트를 n_runs번 실행해서 평균
"""
results = []
for case in cases:
all_run_scores = []
for run in range(n_runs):
output = agent_fn(case.input)
# 모든 스코어 함수 실행
run_scores = {}
for scorer in case.scorers:
score_name = scorer.__name__
run_scores[score_name] = scorer(output, case.input)
all_run_scores.append(run_scores)
# n_runs 평균
avg_scores = {}
for score_name in all_run_scores[0].keys():
avg_scores[score_name] = sum(
r[score_name] for r in all_run_scores
) / n_runs
avg_score = sum(avg_scores.values()) / len(avg_scores)
passed = avg_score >= case.pass_threshold
results.append(EvalResult(
case_name=case.name,
output=all_run_scores[-1].get("last_output", ""),
scores=avg_scores,
avg_score=avg_score,
passed=passed,
))
# 결과 출력
status = "✅ PASS" if passed else "❌ FAIL"
print(f"{status} [{case.name}] avg={avg_score:.2f}")
for name, score in avg_scores.items():
print(f" {name}: {score:.2f}")
return results
실전 2 — 3가지 Evaluator 구현
Evaluator 1: 규칙 기반 (빠름·정확·무료)
# 결정론적 판단 가능한 케이스에 사용
# 비용 없음, 즉시 실행, 완전 재현 가능
def score_has_code_block(output: str, input: dict) -> float:
"""코드 블록 포함 여부 — 코딩 질문에 필수"""
has_code = "```" in output
return 1.0 if has_code else 0.0
def score_response_length(output: str, input: dict) -> float:
"""응답 길이 적절성"""
words = len(output.split())
if 50 <= words <= 500:
return 1.0
elif words < 20 or words > 1000:
return 0.0
else:
return 0.5
def score_no_hallucination_markers(output: str, input: dict) -> float:
"""할루시네이션 위험 표현 감지"""
danger_phrases = [
"제가 알기로는", "아마도", "확실하지 않지만",
"기억이 맞다면", "~인 것 같습니다",
"I think", "I believe", "I'm not sure"
]
found = sum(1 for p in danger_phrases if p.lower() in output.lower())
return max(0.0, 1.0 - found * 0.2)
def score_mentions_required_terms(required_terms: list):
"""필수 용어 포함 여부 팩토리"""
def scorer(output: str, input: dict) -> float:
output_lower = output.lower()
found = sum(1 for term in required_terms
if term.lower() in output_lower)
return found / len(required_terms)
scorer.__name__ = f"mentions_{required_terms[0]}"
return scorer
def score_valid_json_output(output: str, input: dict) -> float:
"""JSON 출력 유효성"""
import json
# 코드 블록에서 JSON 추출
json_match = re.search(r'```(?:json)?\n(.*?)\n```', output, re.DOTALL)
if json_match:
try:
json.loads(json_match.group(1))
return 1.0
except json.JSONDecodeError:
return 0.0
# 직접 JSON 파싱 시도
try:
json.loads(output.strip())
return 1.0
except json.JSONDecodeError:
return 0.0
def score_korean_response(output: str, input: dict) -> float:
"""한국어로 응답했는지 확인"""
korean_chars = sum(1 for c in output if '\uAC00' <= c <= '\uD7A3')
total_chars = len(output.replace(" ", ""))
if total_chars == 0:
return 0.0
ratio = korean_chars / total_chars
return 1.0 if ratio > 0.3 else 0.0
Evaluator 2: LLM-as-Judge (유연·비용 있음)
def llm_judge_factory(
criteria: str,
scale: str = "1-5",
judge_model: str = "claude-haiku-4-5" # 저렴한 모델로
):
"""
LLM이 다른 LLM의 출력을 평가
주관적 품질, 자연스러움, 유용성 등에 사용
"""
def judge(output: str, input: dict) -> float:
user_input = input.get("question", input.get("task", str(input)))
response = client.messages.create(
model=judge_model,
max_tokens=200,
system=f"""당신은 AI 응답 품질 평가자입니다.
다음 기준으로 {scale}점 척도로 평가하세요:
{criteria}
반드시 다음 형식으로만 응답하세요:
점수: X
이유: (한 줄)""",
messages=[{
"role": "user",
"content": f"사용자 입력: {user_input}\n\nAI 응답: {output}"
}]
)
judge_output = response.content[0].text
# 점수 추출
score_match = re.search(r'점수:\s*(\d+(?:\.\d+)?)', judge_output)
if score_match:
raw_score = float(score_match.group(1))
# 스케일 정규화 (1-5 → 0-1)
if scale == "1-5":
return (raw_score - 1) / 4
elif scale == "0-10":
return raw_score / 10
else:
return raw_score
return 0.5 # 파싱 실패 시 중간값
judge.__name__ = f"llm_judge_{criteria[:20].replace(' ', '_')}"
return judge
# 미리 정의된 Judge 함수들
score_helpfulness = llm_judge_factory(
criteria="응답이 사용자 질문에 실질적으로 도움이 되는가",
scale="1-5"
)
score_accuracy = llm_judge_factory(
criteria="응답이 사실적으로 정확한가. 잘못된 정보나 추측이 없는가",
scale="1-5"
)
score_clarity = llm_judge_factory(
criteria="응답이 명확하고 이해하기 쉬운가. 불필요한 복잡성이 없는가",
scale="1-5"
)
score_code_quality = llm_judge_factory(
criteria="코드가 실행 가능하고, 타입 힌트와 에러 처리가 포함됐으며, 가독성이 좋은가",
scale="1-5"
)
Evaluator 3: 참조 기반 (정답이 있을 때)
from difflib import SequenceMatcher
def score_semantic_similarity(expected: str):
"""
예상 정답과 의미적 유사도 측정
LLM으로 비교 (정확한 문자열 일치 대신)
"""
def scorer(output: str, input: dict) -> float:
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=100,
messages=[{
"role": "user",
"content": f"""두 응답의 의미적 유사도를 0.0~1.0으로 평가해줘.
기준 응답: {expected}
평가 응답: {output}
숫자만 출력 (예: 0.85)"""
}]
)
try:
return float(response.content[0].text.strip())
except ValueError:
return 0.5
scorer.__name__ = "semantic_similarity"
return scorer
def score_factual_subset(expected_facts: list[str]):
"""
예상 사실 목록이 응답에 포함됐는지 확인
"""
def scorer(output: str, input: dict) -> float:
output_lower = output.lower()
found = 0
for fact in expected_facts:
# 완전 일치 or 의미적 포함 (간단 버전)
fact_words = fact.lower().split()
if all(w in output_lower for w in fact_words):
found += 1
return found / len(expected_facts) if expected_facts else 1.0
scorer.__name__ = "factual_subset"
return scorer
실전 3 — 에이전트 Eval 케이스 작성
# 실제 에이전트를 위한 Eval 케이스 작성 예시
# ── 코딩 에이전트 Eval ────────────────────────────────
def my_coding_agent(input: dict) -> str:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system="당신은 Python 전문가입니다. 한국어로 답변하고 실행 가능한 코드를 제공합니다.",
messages=[{"role": "user", "content": input["question"]}]
)
return response.content[0].text
coding_eval_cases = [
EvalCase(
name="타입힌트_포함_여부",
input={"question": "두 수를 더하는 함수 작성해줘"},
scorers=[
score_has_code_block,
score_korean_response,
score_mentions_required_terms(["def", "return"]),
score_code_quality,
],
pass_threshold=0.75
),
EvalCase(
name="에러처리_포함_여부",
input={"question": "파일 읽는 함수 작성해줘. 파일 없을 때 처리도 해줘"},
scorers=[
score_has_code_block,
score_mentions_required_terms(["try", "except", "FileNotFoundError"]),
score_code_quality,
],
pass_threshold=0.80
),
EvalCase(
name="할루시네이션_방지",
input={"question": "Python 3.15의 새로운 기능을 설명해줘"},
# 존재하지 않는 버전 → 모른다고 해야 함
scorers=[
lambda out, inp: 1.0 if any(
p in out for p in ["모르", "확인할 수 없", "해당 버전", "존재하지"]
) else 0.0,
score_no_hallucination_markers,
],
pass_threshold=0.7
),
]
# ── RAG 에이전트 Eval ─────────────────────────────────
def my_rag_agent(input: dict) -> str:
# 문서 검색 + LLM 답변
docs = retrieve_relevant_docs(input["question"])
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{
"role": "user",
"content": f"문서: {docs}\n\n질문: {input['question']}"
}]
)
return response.content[0].text
rag_eval_cases = [
EvalCase(
name="정확한_인용",
input={
"question": "환불 정책이 어떻게 되나요?",
"expected_facts": ["30일 이내", "영업일 5일", "카드 결제"]
},
scorers=[
score_factual_subset(["30일 이내", "영업일 5일", "카드 결제"]),
score_no_hallucination_markers,
score_helpfulness,
],
pass_threshold=0.75
),
]
# ── Eval 실행 ─────────────────────────────────────────
print("=== 코딩 에이전트 Eval ===")
coding_results = run_eval(my_coding_agent, coding_eval_cases, n_runs=3)
passed = sum(1 for r in coding_results if r.passed)
total = len(coding_results)
print(f"\n결과: {passed}/{total} 통과 ({passed/total*100:.0f}%)")
# CI에서: 통과율 80% 미만이면 배포 차단
if passed / total < 0.8:
raise SystemExit(f"Eval 실패: {passed}/{total} 통과")
실전 4 — CI/CD 자동화
# conftest.py — pytest 통합
import pytest
import anthropic
client = anthropic.Anthropic()
# pytest 픽스처로 에이전트 함수 제공
@pytest.fixture
def coding_agent():
def agent(input: dict) -> str:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system="Python 전문가. 한국어, 타입 힌트 필수.",
messages=[{"role": "user", "content": input["question"]}]
)
return response.content[0].text
return agent
# test_agent_evals.py
import pytest
@pytest.mark.parametrize("question,min_score", [
("두 수를 더하는 함수", 0.8),
("파일 읽는 함수, 에러처리 포함", 0.75),
("리스트 정렬하는 함수", 0.8),
])
def test_code_generation_quality(coding_agent, question, min_score):
"""코드 생성 품질 Eval — 프롬프트 변경 후 회귀 방지"""
output = coding_agent({"question": question})
# 여러 스코어 계산
scores = {
"has_code": score_has_code_block(output, {}),
"korean": score_korean_response(output, {}),
"no_hallucination": score_no_hallucination_markers(output, {}),
}
avg = sum(scores.values()) / len(scores)
print(f"\n[{question[:30]}] avg={avg:.2f}")
for name, score in scores.items():
print(f" {name}: {score:.2f}")
assert avg >= min_score, (
f"품질 기준 미달: {avg:.2f} < {min_score}\n"
f"스코어: {scores}\n"
f"출력: {output[:200]}"
)
@pytest.mark.parametrize("question,forbidden_claim", [
("Python 3.15 기능 설명", "새로운 기능"), # 존재하지 않는 버전
("GPT-9 성능", "GPT-9"), # 존재하지 않는 모델
])
def test_hallucination_prevention(coding_agent, question, forbidden_claim):
"""할루시네이션 방지 Eval"""
output = coding_agent({"question": question})
# 존재하지 않는 것에 대해 확신하는 주장 금지
has_uncertainty = any(
phrase in output
for phrase in ["모릅니다", "확인할 수 없", "존재하지", "알 수 없"]
)
assert has_uncertainty, (
f"할루시네이션 감지: '{forbidden_claim}' 관련 허위 주장\n"
f"출력: {output[:300]}"
)
# .github/workflows/eval.yml — GitHub Actions 자동화
name: Agent Eval CI
on:
pull_request:
paths:
- 'prompts/**' # 프롬프트 변경 시
- 'agents/**' # 에이전트 코드 변경 시
- 'tests/evals/**'
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python 설정
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: 의존성 설치
run: pip install pytest anthropic braintrust
- name: Eval 실행
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
pytest tests/evals/ -v \
--tb=short \
--timeout=120 \
-x # 첫 번째 실패 시 중단
- name: Braintrust 결과 업로드 (선택)
if: always()
env:
BRAINTRUST_API_KEY: ${{ secrets.BRAINTRUST_API_KEY }}
run: python scripts/upload_eval_results.py
실전 5 — 프로덕션 실패 → Eval 케이스 자동 등록
# 프로덕션 실패를 즉시 Eval 케이스로 변환
# "이 버그는 다시 나타나지 않는다" 보장
import json
from pathlib import Path
from datetime import datetime
EVAL_CASES_FILE = Path("tests/evals/regression_cases.json")
def register_production_failure(
user_input: dict,
bad_output: str,
failure_reason: str,
expected_behavior: str,
min_score: float = 0.8
):
"""
프로덕션에서 실패한 케이스를 Eval 데이터셋에 자동 등록
→ 같은 실패 절대 재발 방지
"""
new_case = {
"name": f"regression_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
"registered_at": datetime.now().isoformat(),
"input": user_input,
"bad_output_example": bad_output[:500],
"failure_reason": failure_reason,
"expected_behavior": expected_behavior,
"min_score": min_score,
"tags": ["regression", "production_failure"]
}
# 파일에 추가
cases = []
if EVAL_CASES_FILE.exists():
cases = json.loads(EVAL_CASES_FILE.read_text())
cases.append(new_case)
EVAL_CASES_FILE.write_text(json.dumps(cases, ensure_ascii=False, indent=2))
print(f"✅ Eval 케이스 등록: {new_case['name']}")
print(f" 실패 이유: {failure_reason}")
print(f" 기대 동작: {expected_behavior}")
return new_case
# 프로덕션 모니터링과 연동
def on_production_failure(session_id: str, error_type: str, context: dict):
"""Langfuse·AgentOps 실패 알림 → 자동 Eval 등록"""
register_production_failure(
user_input=context.get("user_input", {}),
bad_output=context.get("agent_output", ""),
failure_reason=f"{error_type}: {context.get('error_message', '')}",
expected_behavior=infer_expected_behavior(context),
min_score=0.8
)
def load_regression_cases() -> list[EvalCase]:
"""저장된 회귀 케이스 로드 → pytest에서 자동 실행"""
if not EVAL_CASES_FILE.exists():
return []
cases = json.loads(EVAL_CASES_FILE.read_text())
return [
EvalCase(
name=case["name"],
input=case["input"],
scorers=[
score_helpfulness,
score_no_hallucination_markers,
],
pass_threshold=case.get("min_score", 0.8)
)
for case in cases
if "regression" in case.get("tags", [])
]
Eval 설계 원칙
[좋은 Eval의 조건]
1. 비결정론을 수용:
→ pass/fail 아닌 임계값 (0.8 이상이면 통과)
→ 동일 입력 3~5회 실행 후 평균
→ "항상 완벽"이 아닌 "충분히 좋음" 기준
2. 구체적인 실패 케이스:
→ "잘 답변하는지" (X)
→ "타입 힌트가 포함됐는지" (O)
→ "존재하지 않는 API를 언급하지 않는지" (O)
3. 비용 최적화:
→ 규칙 기반: 빠름·무료 → 먼저 실행
→ LLM-as-Judge: 저렴한 모델 (Haiku, Flash) 사용
→ 비싼 Judge는 규칙 기반 통과한 케이스에만
4. 테스트 케이스도 테스트:
→ 의도적 나쁜 출력으로 Eval이 제대로 실패하는지 확인
→ Eval 자체가 잘못되면 모든 게 의미없음
5. 프로덕션 실패 → 즉시 Eval 등록:
→ 실패 5분 내 Eval 케이스 생성
→ 다음 PR에서 자동 검증
→ "이 버그는 절대 다시 안 나온다" 보장
마무리
✅ EDD 도입 순서
1. 현재 에이전트의 "좋은 응답" 기준 3가지 정의
2. 기준별 Evaluator 함수 작성 (규칙 기반부터)
3. 현재 프롬프트로 Eval 실행 → 베이스라인 측정
4. CI에 Eval 추가 (PR마다 자동 실행)
5. 프로덕션 실패 → 즉시 Eval 케이스 등록 습관화
✅ 당장 시작하는 법
→ 코드 5줄: score_has_code_block + score_korean_response
→ pytest에 파라미터화된 Eval 테스트 하나 추가
→ CI 추가 → 다음 프롬프트 변경부터 자동 검증
❌ 흔한 실수
→ Eval을 배포 후에 추가 — 이미 늦음, 베이스라인 없음
→ LLM-as-Judge만 사용 — 느리고 비쌈, 규칙 기반 먼저
→ 임계값 없이 "개선됐다" 판단 — 주관적 회귀 방지 불가
→ 한 번 실행으로 pass/fail — 비결정론 무시, 3회 이상 필수
관련 글
반응형
'AI Agent' 카테고리의 다른 글
| AI 에이전트 품질 관리 전략 — 프로덕션 킬러 1위가 품질인 이유 (0) | 2026.05.21 |
|---|---|
| LLM 에이전트 Capacity Engineering — 프로덕션 오류의 1/3이 rate limit인 이유 (0) | 2026.05.21 |
| AI 에이전트 메모리 관리 실전 — 세션 간 상태 유지, 컨텍스트 압축, 레포 재탐색 방지 (0) | 2026.05.21 |
| AI 에이전트 디버깅 실전 — Langfuse·AgentOps·Braintrust 언제 뭘 쓰나 (0) | 2026.05.21 |
| AI 에이전트 프로덕션 비용 폭탄 — 왜 LLM 청구서가 예상의 10배 나오나 (0) | 2026.05.21 |