AI Agent

Eval-Driven Development 완전 가이드 — AI 에이전트를 TDD처럼 개발하는 법

cell-devlog 2026. 5. 21. 10:25
반응형

프롬프트를 바꿨더니 기존에 잘 되던 케이스가 깨졌습니다. 얼마나 깨졌는지 모릅니다. 테스트가 없기 때문입니다. 코드 개발에서 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회 이상 필수

관련 글

 

반응형