반응형
1편에서 개념을 잡고, 2편에서 편향을 잡았다면, 3편은 실제 운영입니다. Judge를 실제 개발 파이프라인에 연결하지 않으면, 그건 그냥 일회성 스크립트입니다.
📌 핵심 요약
→ 2026년 현재 대부분의 팀은 Eval Level 0~1 — 수동 테스트가 전부
→ 목표: PR 올릴 때마다 Judge가 자동 실행되는 Level 3 파이프라인 구현
→ 에이전트 평가는 최종 결과 + 궤적(Trajectory) 함께 평가해야 함
→ 단계별 비용 최적화: 결정론적 체크 → 경량 Judge → 프론티어 Judge → 인간
→ Self-refinement 루프: Judge 피드백 → 에이전트 재시도 → 재평가 자동화
→ CoT 평가 함정: 에이전트가 CoT로 Judge를 속이는 현상 실증됨 (2026)
→ 프로덕션 모니터링: 실서비스 트래픽이 곧 다음 eval dataset
→ GitHub Actions 완성 코드 포함
실전1 — 에이전트 평가의 핵심: 결과가 아니라 궤적을 봐야 한다
단순 LLM 평가와 에이전트 평가의 결정적 차이는 여기서 납니다.
# 잘못된 에이전트 평가: 최종 결과만 본다
def naive_agent_eval(final_answer: str) -> float:
# "답이 맞으면 OK" — 에이전트가 어떻게 거기 도달했는지 모름
...
# 올바른 에이전트 평가: 궤적(Trajectory) 전체를 본다
def trajectory_eval(trajectory: list[dict]) -> dict:
"""
trajectory 구조 예시:
[
{"step": 1, "thought": "...", "tool": "web_search", "input": "...", "output": "..."},
{"step": 2, "thought": "...", "tool": "calculator", "input": "...", "output": "..."},
{"step": 3, "thought": "...", "action": "final_answer", "output": "42"},
]
"""
...
import anthropic, json
client = anthropic.Anthropic()
TRAJECTORY_JUDGE_PROMPT = """당신은 AI 에이전트의 실행 과정을 평가하는 전문가입니다.
[평가 대상]
목표: {goal}
실행 궤적:
{trajectory_str}
[평가 기준]
1. 도구 선택 적절성 (0~5): 각 단계에서 올바른 도구를 사용했는가?
2. 추론-행동 일관성 (0~5): thought와 실제 action이 일치하는가?
3. 불필요한 단계 여부 (0~5): 중복·우회 단계가 없는가?
4. 오류 복구 능력 (0~5): 실패 시 적절히 방향을 전환했는가?
5. 최종 답변 품질 (0~5): 목표를 달성했는가?
단계별 사고 후 반드시 JSON으로만 출력:
{{"tool_selection": N, "reasoning_consistency": N, "efficiency": N,
"error_recovery": N, "final_quality": N, "total": N, "weakness": "가장 취약한 부분"}}"""
def evaluate_trajectory(goal: str, trajectory: list[dict]) -> dict:
trajectory_str = ""
for step in trajectory:
trajectory_str += f"\n[Step {step['step']}]\n"
trajectory_str += f" 생각: {step.get('thought', '-')}\n"
trajectory_str += f" 도구: {step.get('tool', step.get('action', '-'))}\n"
trajectory_str += f" 입력: {step.get('input', '-')}\n"
trajectory_str += f" 결과: {step.get('output', '-')}\n"
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=800,
messages=[{
"role": "user",
"content": TRAJECTORY_JUDGE_PROMPT.format(
goal=goal,
trajectory_str=trajectory_str
)
}]
)
import re
text = response.content[0].text
match = re.search(r'\{.*\}', text, re.DOTALL)
return json.loads(match.group()) if match else {}
# 테스트
sample_trajectory = [
{"step": 1, "thought": "날씨 정보를 먼저 검색해야 한다", "tool": "web_search",
"input": "서울 오늘 날씨", "output": "맑음, 22도"},
{"step": 2, "thought": "검색 결과가 충분하다, 답변할 수 있다", "tool": "web_search",
"input": "서울 오늘 날씨", "output": "맑음, 22도"}, # ← 중복 단계
{"step": 3, "action": "final_answer", "output": "오늘 서울은 맑고 22도입니다"},
]
result = evaluate_trajectory("오늘 서울 날씨 알려줘", sample_trajectory)
print(json.dumps(result, ensure_ascii=False, indent=2))
# → efficiency 낮게 나옴 (중복 단계 감지)
→ 2025 arXiv 연구: 에이전트 실패의 17%는 단계 반복, 14%는 추론-행동 불일치
→ 최종 결과만 보면 이 두 가지 실패를 아예 탐지 못함
→ 궤적 평가는 단일 Judge로 한 번에 평가하지 말고 단계별로 나눠 평가할 것
실전2 — 비용 최적화: 4단계 레이어드 평가
모든 출력에 GPT-4/Claude를 갖다 대면 비용이 터집니다. 중요도에 따라 레이어를 나눕니다.
from anthropic import Anthropic
import json, re
client = Anthropic()
class LayeredEvalPipeline:
"""
[Layer 1] 결정론적 체크 → 비용 거의 0, 100% 적용
[Layer 2] 경량 휴리스틱 → 매우 저렴, 80% 적용
[Layer 3] LLM Judge → 중간 비용, 30% 적용
[Layer 4] 인간 리뷰 → 최고 비용, 5% 적용
"""
# ── Layer 1: 결정론적 체크 ─────────────────────────────
def layer1_deterministic(self, output: str, schema: dict = None) -> dict:
checks = {}
# JSON 형식 검사
if schema and schema.get("require_json"):
try:
json.loads(output)
checks["json_valid"] = True
except:
checks["json_valid"] = False
# 최소 길이 검사
min_len = schema.get("min_length", 10) if schema else 10
checks["length_ok"] = len(output.strip()) >= min_len
# 금지어 검사
banned = schema.get("banned_words", []) if schema else []
checks["no_banned_words"] = not any(w in output.lower() for w in banned)
passed = all(checks.values())
return {"layer": 1, "passed": passed, "details": checks}
# ── Layer 2: 휴리스틱 체크 ─────────────────────────────
def layer2_heuristic(self, output: str, question: str) -> dict:
checks = {}
# 질문 핵심 키워드 포함 여부
question_words = set(question.lower().split())
output_words = set(output.lower().split())
overlap = len(question_words & output_words) / max(len(question_words), 1)
checks["keyword_overlap"] = overlap >= 0.2
# 과도한 길이 (Verbosity Bias 방어)
checks["not_too_verbose"] = len(output) <= 2000
# 반복 문장 감지
sentences = [s.strip() for s in output.split('.') if s.strip()]
checks["no_repetition"] = len(sentences) == len(set(sentences))
passed = sum(checks.values()) >= 2 # 3개 중 2개 이상 통과
return {"layer": 2, "passed": passed, "details": checks}
# ── Layer 3: LLM Judge ─────────────────────────────────
def layer3_llm_judge(self, output: str, question: str) -> dict:
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=200,
messages=[{"role": "user", "content": f"""
다음 답변의 품질을 평가하세요. JSON만 출력:
질문: {question}
답변: {output}
{{"score": 0~10, "pass": true/false, "reason": "한 줄"}}
"""}]
)
text = response.content[0].text
match = re.search(r'\{.*\}', text, re.DOTALL)
result = json.loads(match.group()) if match else {"score": 5, "pass": True}
result["layer"] = 3
return result
# ── 통합 실행 ──────────────────────────────────────────
def run(self, question: str, output: str, schema: dict = None) -> dict:
import random
# Layer 1: 항상 실행
r1 = self.layer1_deterministic(output, schema)
if not r1["passed"]:
return {"final": "FAIL", "layer_stopped": 1, "details": r1}
# Layer 2: 항상 실행
r2 = self.layer2_heuristic(output, question)
if not r2["passed"]:
return {"final": "FAIL", "layer_stopped": 2, "details": r2}
# Layer 3: 30% 확률로 샘플링 (비용 제어)
if random.random() < 0.30:
r3 = self.layer3_llm_judge(output, question)
if not r3["pass"]:
return {"final": "HUMAN_REVIEW", "layer_stopped": 3, "details": r3}
if r3["score"] < 4:
return {"final": "FAIL", "layer_stopped": 3, "details": r3}
return {"final": "PASS", "layer_stopped": None}
# 실행
pipeline = LayeredEvalPipeline()
result = pipeline.run(
question="비동기 프로그래밍이란?",
output="async/await를 사용해 I/O 대기 없이 여러 작업을 동시에 처리하는 방식입니다.",
schema={"min_length": 20, "banned_words": ["모름", "잘 모르겠"]}
)
print(result)
→ Layer 1(결정론적): 비용 ≈ 0, 포맷 오류·금지어·빈 응답 필터링
→ Layer 2(휴리스틱): 비용 ≈ 0, 키워드 겹침·길이·반복 감지
→ Layer 3(LLM Judge): 30% 샘플링 → 비용 70% 절감하면서 품질 신호 확보
→ 전체 비용: 단일 LLM Judge 대비 약 1/5 수준
실전3 — Self-refinement 루프: Judge 피드백으로 에이전트 재시도
Judge 점수가 낮으면 피드백을 주고, 에이전트가 스스로 재답변하도록 자동화합니다.
import anthropic
client = anthropic.Anthropic()
def agent_with_self_refinement(question: str, max_retries: int = 2) -> dict:
"""
Judge 피드백 기반 Self-refinement 루프
실패 시 구체적 피드백을 담아 재시도 — 최대 max_retries회
"""
history = []
attempt = 0
while attempt <= max_retries:
# Step 1: 에이전트 답변 생성
messages = [{"role": "user", "content": question}]
# 이전 시도가 있으면 피드백 컨텍스트 추가
if history:
last = history[-1]
feedback_msg = (
f"이전 답변: {last['answer']}\n"
f"Judge 피드백: {last['feedback']}\n"
f"위 피드백을 반영해 더 나은 답변을 작성하세요."
)
messages = [
{"role": "user", "content": question},
{"role": "assistant", "content": last['answer']},
{"role": "user", "content": feedback_msg}
]
answer_resp = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=500,
messages=messages
)
answer = answer_resp.content[0].text
# Step 2: Judge가 답변 평가
judge_resp = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=300,
system="당신은 엄격한 기술 답변 평가자입니다.",
messages=[{"role": "user", "content": f"""
질문: {question}
답변: {answer}
평가 기준: 정확성, 완결성, 예시 포함 여부
JSON만 출력:
{{"score": 0~10, "pass": true/false,
"feedback": "구체적으로 부족한 부분과 개선 방향"}}
"""}]
)
import re, json
text = judge_resp.content[0].text
match = re.search(r'\{.*\}', text, re.DOTALL)
judge_result = json.loads(match.group()) if match else {"score": 5, "pass": True}
history.append({
"attempt": attempt + 1,
"answer": answer,
"score": judge_result.get("score", 0),
"feedback": judge_result.get("feedback", "")
})
# Judge 통과 → 루프 종료
if judge_result.get("pass"):
return {
"final_answer": answer,
"attempts": attempt + 1,
"final_score": judge_result.get("score"),
"history": history
}
attempt += 1
# 최대 재시도 초과 → 마지막 답변 반환
return {
"final_answer": history[-1]["answer"],
"attempts": attempt,
"final_score": history[-1]["score"],
"note": "최대 재시도 초과 — 인간 검토 권장",
"history": history
}
result = agent_with_self_refinement("Python 데코레이터의 실전 활용법을 예시와 함께 설명해줘")
print(f"시도 횟수: {result['attempts']}, 최종 점수: {result['final_score']}")
→ Self-refinement 핵심: "틀렸어" 가 아니라 "왜 틀렸고 뭘 고쳐야 해" 를 피드백으로
→ 재시도 상한선 필수 — 무한 루프 방지 (보통 2~3회가 최적)
→ 주의: CoT 평가 함정 — 에이전트가 CoT를 그럴듯하게 써서 Judge를 속이는 현상 (ICLR 2026 확인)
∟ 방어: CoT 내용이 아니라 실제 tool call 결과를 기준으로 평가
→ 점수 개선 없으면 조기 종료 → 추가 API 비용 낭비 방지
실전4 — GitHub Actions CI/CD 통합: PR마다 Judge 자동 실행
# .github/workflows/llm-eval.yml
name: LLM Judge Eval
on:
pull_request:
paths:
- 'prompts/**' # 프롬프트 파일 변경 시 트리거
- 'src/agents/**' # 에이전트 코드 변경 시 트리거
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python 설정
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: 의존성 설치
run: pip install anthropic pytest
- name: LLM Judge Eval 실행
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: python eval/run_eval.py --threshold 7.0
- name: 결과 PR 코멘트로 업로드
uses: actions/github-script@v7
if: always()
with:
script: |
const fs = require('fs');
const result = JSON.parse(fs.readFileSync('eval/result.json', 'utf8'));
const icon = result.passed ? '✅' : '❌';
const body = `## ${icon} LLM Judge 결과\n` +
`- 평균 점수: **${result.avg_score}/10**\n` +
`- 통과율: **${result.pass_rate}%**\n` +
`- 실패 케이스: ${result.failed_cases.join(', ') || '없음'}\n\n` +
(result.passed ? '품질 기준 통과 ✅' : '⚠️ 품질 기준 미달 — 머지 전 검토 필요');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
# eval/run_eval.py
import anthropic, json, argparse, sys
from pathlib import Path
client = anthropic.Anthropic()
# eval 데이터셋: 질문-기대답변 쌍
EVAL_DATASET = [
{
"id": "basic_async",
"question": "Python async/await를 언제 써야 하나요?",
"expected_keywords": ["I/O", "비동기", "이벤트 루프"],
"min_score": 7
},
{
"id": "rag_failure",
"question": "RAG 시스템에서 hallucination을 줄이는 방법은?",
"expected_keywords": ["검색", "컨텍스트", "청킹"],
"min_score": 7
},
# 실제 운영에서는 200개 이상 권장
]
def run_eval(threshold: float) -> dict:
results = []
for case in EVAL_DATASET:
# 에이전트/LLM 실행
answer_resp = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=300,
messages=[{"role": "user", "content": case["question"]}]
)
answer = answer_resp.content[0].text
# Judge 평가
keywords_found = sum(1 for kw in case["expected_keywords"] if kw in answer)
keyword_score = (keywords_found / len(case["expected_keywords"])) * 10
judge_resp = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=100,
messages=[{"role": "user", "content": f"""
질문: {case['question']}
답변: {answer}
점수(0~10, 숫자만):"""}]
)
try:
llm_score = float(judge_resp.content[0].text.strip())
except:
llm_score = 5.0
# 종합 점수: 키워드 40% + LLM Judge 60%
final_score = keyword_score * 0.4 + llm_score * 0.6
passed = final_score >= case["min_score"]
results.append({
"id": case["id"],
"score": round(final_score, 2),
"passed": passed
})
avg_score = sum(r["score"] for r in results) / len(results)
pass_rate = sum(1 for r in results if r["passed"]) / len(results) * 100
failed_cases = [r["id"] for r in results if not r["passed"]]
overall_passed = avg_score >= threshold
output = {
"avg_score": round(avg_score, 2),
"pass_rate": round(pass_rate, 1),
"failed_cases": failed_cases,
"passed": overall_passed,
"details": results
}
Path("eval/result.json").write_text(json.dumps(output, ensure_ascii=False))
print(json.dumps(output, ensure_ascii=False, indent=2))
return output
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--threshold", type=float, default=7.0)
args = parser.parse_args()
result = run_eval(args.threshold)
sys.exit(0 if result["passed"] else 1) # CI 통과/실패 신호
→ PR 올릴 때마다 Judge 자동 실행 → 프롬프트 변경 품질 회귀 즉시 감지
→ sys.exit(1) 로 CI 실패 신호 → 기준 미달이면 머지 불가 설정 가능
→ eval dataset은 최소 50개 실패 케이스부터 시작, 버그 발견 시마다 추가
→ 2026 트렌드: eval 결과가 PR 코멘트로 자동 게시 → 팀 전체가 품질 인지
실전5 — 프로덕션 트래픽 → eval dataset 자동 수집
실서비스 로그가 가장 현실적인 eval dataset입니다.
import anthropic, json
from datetime import datetime
from pathlib import Path
client = anthropic.Anthropic()
class ProductionEvalCollector:
"""
프로덕션 트래픽에서 eval dataset 자동 수집
낮은 점수 / 사용자 thumbs-down → 자동으로 eval case에 추가
"""
def __init__(self, dataset_path: str = "eval/production_cases.jsonl"):
self.dataset_path = Path(dataset_path)
self.dataset_path.parent.mkdir(exist_ok=True)
def log_interaction(self, question: str, answer: str,
user_feedback: str = None):
"""실서비스 응답마다 호출 — 10% 샘플링"""
import random
if random.random() > 0.10: # 10%만 샘플링
return
# 자동 Judge 점수
judge_resp = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=50,
messages=[{"role": "user", "content":
f"질문: {question}\n답변: {answer}\n\n점수(0~10, 숫자만):"}]
)
try:
score = float(judge_resp.content[0].text.strip())
except:
score = 5.0
case = {
"timestamp": datetime.now().isoformat(),
"question": question,
"answer": answer,
"auto_score": score,
"user_feedback": user_feedback, # "thumbs_up" / "thumbs_down" / None
# 낮은 점수 or 부정 피드백이면 인간 검토 플래그
"needs_review": score < 5.0 or user_feedback == "thumbs_down"
}
# JSONL 형식으로 누적 저장
with open(self.dataset_path, "a", encoding="utf-8") as f:
f.write(json.dumps(case, ensure_ascii=False) + "\n")
def export_regression_cases(self, min_cases: int = 50) -> list:
"""needs_review=True인 케이스만 뽑아 다음 eval dataset으로"""
cases = []
if not self.dataset_path.exists():
return cases
with open(self.dataset_path, encoding="utf-8") as f:
for line in f:
case = json.loads(line)
if case.get("needs_review"):
cases.append(case)
print(f"수집된 회귀 케이스: {len(cases)}개")
return cases[:min_cases]
collector = ProductionEvalCollector()
# 실서비스 응답 로깅 예시
collector.log_interaction(
question="Redis와 Memcached 차이는?",
answer="Redis는 다양한 자료구조 지원, Memcached는 단순 키-값만 지원합니다.",
user_feedback="thumbs_up"
)
regression_cases = collector.export_regression_cases()
print(f"다음 eval에 추가할 케이스: {len(regression_cases)}개")
→ 핵심 원칙: "실패 케이스가 발견될 때마다 dataset에 추가" → eval이 점점 강해짐
→ 사용자 thumbs-down은 Judge보다 신뢰도 높은 신호 — 우선 수집
→ 10% 샘플링으로 API 비용 제어하면서 데이터 품질 유지
→ Level 4 팀의 eval dataset은 100% 프로덕션 실패 사례에서 자라남
✅ 시리즈 3편 핵심 정리
✅ 에이전트는 최종 결과가 아닌 궤적(Trajectory) 전체를 평가해야 함
✅ 4단계 레이어드 평가로 단일 LLM Judge 대비 비용 1/5로 절감
✅ Self-refinement 루프: Judge 피드백 → 재시도 → 재평가 자동화
✅ GitHub Actions 통합: PR마다 Judge 자동 실행, 기준 미달 머지 차단
✅ 프로덕션 트래픽 10% 샘플링 → 실패 케이스 자동 수집 → eval dataset 성장
❌ CoT만 보고 평가하면 에이전트가 Judge 속일 수 있음 — tool call 결과 기준 평가 필수
❌ eval dataset 없이 Judge 단독 운용은 반쪽짜리 — 최소 50개 케이스부터
❌ Self-refinement 재시도 상한 없으면 비용 폭발 — 2~3회 제한 필수
❌ 2026년에도 대부분 팀은 Level 0~1 — 이 파이프라인 구축만으로 경쟁 우위
관련글
반응형
'AI Agent' 카테고리의 다른 글
| LLM as a Judge 완전정리 2편 — 판사는 어디서 거짓말하나: 7가지 편향 해부 (0) | 2026.05.26 |
|---|---|
| LLM as a Judge 완전정리 1편 — 왜 기존 평가 지표는 죽었고, 무엇이 그 자리를 차지했나 (0) | 2026.05.26 |
| LLM-as-Judge 완전 가이드 2편 — 편향 제거부터 Jury 패턴까지, 프로덕션에서 살아남는 법 (0) | 2026.05.22 |
| AI 에이전트 Durable Execution 실전 2편 — Human-in-the-Loop·멀티에이전트·Serverless (0) | 2026.05.21 |
| AI 에이전트 Durable Execution 실전 1편 — 에이전트가 죽어도 이어지는 워크플로우 설계 (0) | 2026.05.21 |