AI Agent

AI 에이전트 품질 관리 전략 — 프로덕션 킬러 1위가 품질인 이유

cell-devlog 2026. 5. 21. 14:35
반응형

에이전트 만들었는데 데모에선 완벽하고 프로덕션에선 이상하게 돌아가는 경험, 다들 있으시죠?

핵심 요약
→ LangChain 2026: 프로덕션 에이전트 팀의 32%가 품질을 최대 장벽으로 꼽음
→ 63%의 에이전트가 복잡한 다단계 태스크에서 실패 — 데모는 통과해도
→ 에이전트 평가 ≠ 일반 LLM 평가 — 비결정성·도구 체인·궤적이 핵심
→ 3계층 평가: L1 결과(맞냐?) → L2 도구 호출(올바른 툴?) → L3 궤적(효율적?)
→ LLM-as-Judge: 인간 평가자와 85% 일치 — 인간끼리 일치율보다 높음
→ CLEAR 프레임워크: Cost·Latency·Effectiveness·Accuracy·Reliability 5축 평가
→ 황금 케이스 50~100개 수작업 → 프로덕션 트레이스 500개+ 확보 필수
→ CI/CD에 eval 통합 → 배포 전 품질 게이트 자동화

 


실전 1 — 왜 에이전트 품질 평가가 일반 LLM과 다른가

# ❌ 일반 LLM 평가 방식 — 에이전트엔 안 통함
def evaluate_llm(prompt: str, response: str) -> float:
    # 입력 → 출력 1:1 비교
    # 정답지와 비교하면 끝
    return similarity_score(response, expected_answer)

# 에이전트는 왜 이게 안 되나?

# 문제 1: 비결정성 체인
# 10~20개 LLM 호출을 연속으로 실행
# 3번째 호출의 미세한 차이 → 7번째 호출에서 완전히 다른 결과
# 같은 입력으로 100번 돌리면 100가지 다른 경로

# 문제 2: 과정 없이 결과만 봐선 안 됨
# 최종 답이 맞아도 → 중간에 잘못된 도구 5번 호출했을 수 있음
# 프로덕션에서는 결국 터짐

# 문제 3: 도구 호출 정확도 별도 평가 필요
# "검색 도구를 써야 할 때 계산 도구를 씀" → 결과는 우연히 맞음
# 이런 에이전트는 조금만 입력이 바뀌면 완전히 실패
개념 정리
→ L1 평가 (결과): 최종 답이 맞냐 — 가장 기본, 하지만 불충분
→ L2 평가 (도구): 올바른 도구를 올바른 순서로 호출했나
→ L3 평가 (궤적): 최적 경로로 태스크를 완료했나 (불필요한 단계 없이)
→ 2026 실무 기준: 세 계층 모두 평가해야 '프로덕션 안전' 에이전트

실전 2 — 3계층 평가 프레임워크 구현

from anthropic import Anthropic
from dataclasses import dataclass
from typing import Any

client = Anthropic()

@dataclass
class AgentTrace:
    """에이전트 실행 기록 — 평가의 기본 단위"""
    task_id: str
    user_input: str
    tool_calls: list[dict]      # 도구 호출 순서 및 파라미터
    final_output: str
    total_tokens: int
    latency_seconds: float
    step_count: int

@dataclass
class EvalResult:
    l1_outcome_score: float     # 0~1: 최종 결과 품질
    l2_tool_score: float        # 0~1: 도구 호출 정확도
    l3_trajectory_score: float  # 0~1: 경로 효율성
    passed: bool
    failure_reason: str | None

# ─────────────────────────────────────────
# L1: 결과 평가 — 최종 답이 맞냐
# ─────────────────────────────────────────
def evaluate_outcome(trace: AgentTrace, expected: str) -> float:
    """LLM-as-Judge로 최종 결과 품질 평가"""
    
    judge_prompt = f"""
태스크: {trace.user_input}
에이전트 출력: {trace.final_output}
기대 결과: {expected}

평가 기준:
1. 태스크를 완료했는가? (0~40점)
2. 출력이 정확한가? (0~40점)
3. 불필요한 내용 없이 간결한가? (0~20점)

점수를 0~100 사이 숫자 하나만 출력하세요. 설명 없이 숫자만.
"""
    
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=10,
        messages=[{"role": "user", "content": judge_prompt}]
    )
    
    try:
        score = float(response.content[0].text.strip()) / 100
        return min(max(score, 0.0), 1.0)
    except ValueError:
        return 0.0

# ─────────────────────────────────────────
# L2: 도구 호출 평가 — 올바른 툴을 썼냐
# ─────────────────────────────────────────
def evaluate_tool_calls(
    trace: AgentTrace,
    expected_tools: list[str]   # 이 태스크에서 써야 하는 도구 목록
) -> float:
    actual_tools = [call["name"] for call in trace.tool_calls]
    
    # 필수 도구 누락 체크
    missing = set(expected_tools) - set(actual_tools)
    
    # 불필요한 도구 호출 체크
    unexpected = set(actual_tools) - set(expected_tools)
    
    # 도구 호출 순서 체크 (순서가 중요한 경우)
    order_correct = actual_tools[:len(expected_tools)] == expected_tools
    
    # 점수 계산
    base_score = 1.0
    base_score -= len(missing) * 0.3       # 누락 도구당 -0.3
    base_score -= len(unexpected) * 0.1    # 불필요 도구당 -0.1
    if not order_correct:
        base_score -= 0.2                   # 순서 틀리면 -0.2
    
    return max(base_score, 0.0)

# ─────────────────────────────────────────
# L3: 궤적 평가 — 효율적으로 달성했냐
# ─────────────────────────────────────────
def evaluate_trajectory(
    trace: AgentTrace,
    optimal_steps: int          # 이 태스크의 최적 단계 수
) -> float:
    
    # 효율성: 실제 단계 수 vs 최적 단계 수
    efficiency = optimal_steps / max(trace.step_count, 1)
    efficiency_score = min(efficiency, 1.0)  # 최적보다 적게 쓰면 1.0
    
    # 토큰 효율성 (단계당 평균 토큰)
    tokens_per_step = trace.total_tokens / max(trace.step_count, 1)
    token_score = 1.0 if tokens_per_step < 2000 else max(0, 1 - (tokens_per_step - 2000) / 10000)
    
    # 응답 속도
    latency_score = 1.0 if trace.latency_seconds < 10 else max(0, 1 - (trace.latency_seconds - 10) / 50)
    
    return (efficiency_score * 0.5 + token_score * 0.3 + latency_score * 0.2)

# ─────────────────────────────────────────
# 통합 평가 실행
# ─────────────────────────────────────────
def run_full_eval(
    trace: AgentTrace,
    expected_output: str,
    expected_tools: list[str],
    optimal_steps: int
) -> EvalResult:
    
    l1 = evaluate_outcome(trace, expected_output)
    l2 = evaluate_tool_calls(trace, expected_tools)
    l3 = evaluate_trajectory(trace, optimal_steps)
    
    # 가중 합산 — L1 가장 중요
    weighted_score = l1 * 0.5 + l2 * 0.3 + l3 * 0.2
    passed = weighted_score >= 0.7  # 70% 이상이면 통과
    
    failure_reason = None
    if not passed:
        if l1 < 0.5:
            failure_reason = f"결과 품질 미달 (L1: {l1:.2f})"
        elif l2 < 0.5:
            failure_reason = f"잘못된 도구 호출 (L2: {l2:.2f})"
        else:
            failure_reason = f"비효율적 궤적 (L3: {l3:.2f})"
    
    return EvalResult(l1, l2, l3, passed, failure_reason)
개념 정리
→ T-Eval: 각 단계에서 다음 도구 호출이 기대값과 일치하는지 채점
→ Progress Rate: 실제 궤적 vs 기대 궤적 비교
→ "올바른 결과를 잘못된 방법으로 낸 에이전트" = 프로덕션 시한폭탄
→ 도구 호출 정확도는 결과 정확도와 별도로 반드시 측정

실전 3 — CLEAR 프레임워크: 5축 품질 대시보드

단일 점수로 에이전트 품질을 요약하면 놓치는 게 너무 많음. CLEAR 5축으로 입체 평가.

from dataclasses import dataclass
import statistics

@dataclass
class CLEARMetrics:
    """CLEAR: Cost / Latency / Effectiveness / Accuracy / Reliability"""
    
    # C — Cost: 쿼리당 비용
    cost_per_query: float           # $
    cost_vs_baseline: float         # baseline 대비 비율 (1.0 = 동일)
    
    # L — Latency: 응답 속도
    p50_latency: float              # 중앙값 응답시간 (초)
    p99_latency: float              # 99퍼센타일 (느린 케이스)
    
    # E — Effectiveness: 태스크 완료율
    task_completion_rate: float     # 0~1
    user_satisfaction_score: float  # 0~1 (human eval 또는 thumbs up/down)
    
    # A — Accuracy: 정확도
    l1_outcome_accuracy: float      # 결과 정확도
    l2_tool_accuracy: float         # 도구 정확도
    
    # R — Reliability: 안정성
    error_rate: float               # 실패율
    consistency_score: float        # 같은 입력 10회 실행 시 일관성

def compute_clear(traces: list[AgentTrace], eval_results: list[EvalResult]) -> CLEARMetrics:
    costs = [t.total_tokens * 0.000003 for t in traces]  # sonnet 기준
    latencies = [t.latency_seconds for t in traces]
    
    return CLEARMetrics(
        # Cost
        cost_per_query=statistics.mean(costs),
        cost_vs_baseline=statistics.mean(costs) / 0.01,  # 기준 비용 $0.01
        
        # Latency
        p50_latency=statistics.median(latencies),
        p99_latency=sorted(latencies)[int(len(latencies) * 0.99)],
        
        # Effectiveness
        task_completion_rate=sum(1 for r in eval_results if r.passed) / len(eval_results),
        user_satisfaction_score=statistics.mean([r.l1_outcome_score for r in eval_results]),
        
        # Accuracy
        l1_outcome_accuracy=statistics.mean([r.l1_outcome_score for r in eval_results]),
        l2_tool_accuracy=statistics.mean([r.l2_tool_score for r in eval_results]),
        
        # Reliability
        error_rate=sum(1 for r in eval_results if not r.passed) / len(eval_results),
        consistency_score=1 - statistics.stdev([r.l1_outcome_score for r in eval_results])
    )

def print_clear_report(metrics: CLEARMetrics):
    print("=" * 50)
    print("CLEAR 품질 대시보드")
    print("=" * 50)
    print(f"💰 Cost:        ${metrics.cost_per_query:.4f}/쿼리  (기준 대비 {metrics.cost_vs_baseline:.1f}x)")
    print(f"⚡ Latency:     P50={metrics.p50_latency:.1f}s  P99={metrics.p99_latency:.1f}s")
    print(f"✅ Effectiveness: 완료율 {metrics.task_completion_rate:.1%}  만족도 {metrics.user_satisfaction_score:.1%}")
    print(f"🎯 Accuracy:    결과 {metrics.l1_outcome_accuracy:.1%}  도구 {metrics.l2_tool_accuracy:.1%}")
    print(f"🛡 Reliability: 오류율 {metrics.error_rate:.1%}  일관성 {metrics.consistency_score:.1%}")
    
    # 자동 경보
    if metrics.task_completion_rate < 0.8:
        print(f"\n⚠️  경보: 완료율 {metrics.task_completion_rate:.1%} — 목표 80% 미달")
    if metrics.p99_latency > 30:
        print(f"\n⚠️  경보: P99 레이턴시 {metrics.p99_latency:.1f}s — 30초 초과")
    if metrics.error_rate > 0.05:
        print(f"\n⚠️  경보: 오류율 {metrics.error_rate:.1%} — 5% 초과")
개념 정리
→ 95% 정확도지만 비용이 10배 → 프로덕션 불가
→ CLEAR는 품질과 효율을 동시에 측정하는 균형 프레임워크
→ P99 레이턴시: 평균이 아닌 느린 케이스를 봐야 실제 사용자 경험
→ 일관성 점수: 표준편차 기반 — 낮을수록 결과가 예측 가능

실전 4 — CI/CD에 Eval 통합: 배포 전 품질 게이트

import subprocess
import json
import sys

# eval 데이터셋 — 황금 케이스 50~100개 수작업 + 프로덕션 트레이스 500개+
GOLDEN_DATASET = [
    {
        "task_id": "search_and_summarize_001",
        "user_input": "최신 Python 릴리즈 노트 요약해줘",
        "expected_tools": ["web_search", "text_summarizer"],
        "expected_output_keywords": ["Python", "버전", "새 기능"],
        "optimal_steps": 3
    },
    {
        "task_id": "code_review_001",
        "user_input": "이 Python 함수의 버그 찾아줘",
        "expected_tools": ["code_analyzer", "linter"],
        "expected_output_keywords": ["버그", "수정", "라인"],
        "optimal_steps": 2
    },
    # ... 나머지 케이스
]

def run_eval_suite(agent_version: str) -> dict:
    """CI/CD에서 호출하는 eval 스위트"""
    results = []
    
    for case in GOLDEN_DATASET:
        # 에이전트 실행 (실제 구현에서 에이전트 호출)
        trace = run_agent(case["user_input"])
        
        # 3계층 평가
        eval_result = run_full_eval(
            trace=trace,
            expected_output=" ".join(case["expected_output_keywords"]),
            expected_tools=case["expected_tools"],
            optimal_steps=case["optimal_steps"]
        )
        results.append(eval_result)
    
    # 집계
    pass_rate = sum(1 for r in results if r.passed) / len(results)
    avg_l1 = sum(r.l1_outcome_score for r in results) / len(results)
    avg_l2 = sum(r.l2_tool_score for r in results) / len(results)
    
    return {
        "version": agent_version,
        "pass_rate": pass_rate,
        "avg_outcome_score": avg_l1,
        "avg_tool_score": avg_l2,
        "failures": [r.failure_reason for r in results if not r.passed]
    }

def quality_gate(report: dict) -> bool:
    """배포 차단 조건 — 이 기준 미달 시 PR 머지 불가"""
    
    # ❌ 차단 조건
    if report["pass_rate"] < 0.80:
        print(f"❌ 배포 차단: 통과율 {report['pass_rate']:.1%} < 80%")
        return False
    
    if report["avg_outcome_score"] < 0.75:
        print(f"❌ 배포 차단: 결과 품질 {report['avg_outcome_score']:.1%} < 75%")
        return False
    
    if report["avg_tool_score"] < 0.70:
        print(f"❌ 배포 차단: 도구 정확도 {report['avg_tool_score']:.1%} < 70%")
        return False
    
    # ✅ 통과
    print(f"✅ 품질 게이트 통과: {report['pass_rate']:.1%}")
    return True

# GitHub Actions에서 실행
if __name__ == "__main__":
    agent_version = sys.argv[1]  # 버전 태그 받음
    
    print(f"🔍 Eval 시작: {agent_version}")
    report = run_eval_suite(agent_version)
    
    # 결과 저장
    with open("eval_report.json", "w") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    
    # 품질 게이트 — 실패 시 exit(1) → CI 파이프라인 중단
    if not quality_gate(report):
        sys.exit(1)
    
    print("🚀 배포 진행")
# .github/workflows/agent-eval.yml
name: Agent Quality Gate

on:
  pull_request:
    branches: [main]

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Run eval suite
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          pip install anthropic
          python eval/run_eval.py ${{ github.sha }}
      
      - name: Upload eval report
        uses: actions/upload-artifact@v4
        with:
          name: eval-report
          path: eval_report.json
        if: always()   # 실패해도 리포트는 업로드
개념 정리
→ 황금 케이스 50~100개: 수작업, 가장 중요한 시나리오 커버
→ 프로덕션 트레이스 500개+: 실제 실패 케이스 기반 → 생태학적 타당성 높음
→ 500개 확보 전까지는 집계 지표를 신뢰하지 말 것 (2026 업계 권고)
→ eval 비용: 판사 모델(sonnet) 10회/트레이스 × 1000트레이스 = 월 $30 수준
→ 비용이 LLM 청구서 10% 초과 시 → 소형 증류 판사 모델(Galileo Luna)로 교체

마무리

✅ 품질 관리 시스템 구축 후
→ 데모 통과 → 프로덕션 실패 패턴 사전 차단
→ L1·L2·L3 3계층으로 "맞는 답을 잘못된 방법으로 낸" 에이전트 탐지
→ CI/CD 품질 게이트 → 품질 회귀 자동 차단
→ CLEAR 대시보드 → 품질·비용·속도 동시 모니터링

❌ 품질 관리 없이 배포하면
→ 63%의 에이전트가 복잡한 태스크에서 실패
→ 프로덕션에서 터질 때까지 문제를 모름
→ 도구 호출 오류가 쌓여 어느 날 갑자기 대규모 장애
→ 32%가 품질 문제로 에이전트 프로덕션 포기

관련글

 

반응형