LLM 응답 품질을 사람이 일일이 평가하고 있습니까. 1000개 응답을 사람이 보면 3일이 걸립니다. LLM-as-Judge는 같은 작업을 3분에 끝냅니다.
[핵심 요약]
→ 정체: LLM이 다른 LLM의 응답을 자동으로 평가하는 패턴
→ 용도: 응답 품질 평가, A/B 테스트, 회귀 테스트, 프로덕션 모니터링
→ 패턴: 단일 평가, 쌍 비교, 참조 기반, 루브릭 기반
→ 도구: Claude API + 구조화 출력, LangSmith, Ragas
→ 신뢰도: 사람 평가와 80~90% 일치 (단, 편향 있음)
→ 비용: 평가당 $0.001~0.01 수준
→ 주의: 자기 편향, 위치 편향 → 설계로 보완 필요
LLM-as-Judge가 왜 필요한가
AI 서비스 응답 품질 평가 방법 비교:
1. 사람 평가:
→ 정확도: 가장 높음
→ 속도: 1000개 = 3~5일
→ 비용: 높음 (시간 = 돈)
→ 스케일: 불가능 (프로덕션 모니터링)
2. 규칙 기반 (정규식, 키워드):
→ 정확도: 낮음 (문맥 파악 불가)
→ 속도: 빠름
→ 비용: 거의 없음
→ 스케일: 가능
3. LLM-as-Judge:
→ 정확도: 높음 (사람과 80~90% 일치)
→ 속도: 1000개 = 3~10분
→ 비용: 낮음 ($1~10)
→ 스케일: 가능 (프로덕션 실시간 모니터링)
[언제 LLM-as-Judge를 쓰나]
→ 새 프롬프트 배포 전 품질 검증
→ 모델 업그레이드 후 회귀 테스트
→ A/B 테스트 자동화
→ 프로덕션 응답 품질 실시간 모니터링
→ RAG 청크 품질 평가
→ 파인튜닝 데이터셋 품질 필터링
실전 1 — 기본 단일 평가 패턴
import anthropic
import json
from dataclasses import dataclass
client = anthropic.Anthropic()
@dataclass
class JudgeResult:
score: float # 0.0 ~ 1.0
passed: bool
reason: str
details: dict
def judge_response(
task: str,
response: str,
criteria: list[str],
threshold: float = 0.7,
reference: str = None # 정답 예시 (선택)
) -> JudgeResult:
"""
단일 응답 품질 평가
Args:
task: 에이전트에게 주어진 작업
response: 평가할 응답
criteria: 평가 기준 목록
threshold: 합격 기준 점수
reference: 참조 정답 (있으면 더 정확한 평가)
"""
criteria_text = "\n".join(f"- {c}" for c in criteria)
reference_text = f"\n[참조 정답]\n{reference}" if reference else ""
prompt = f"""다음 AI 응답을 평가해주세요.
[작업]
{task}
[AI 응답]
{response}
{reference_text}
[평가 기준]
{criteria_text}
각 기준을 0~1 점수로 평가하고 종합 점수를 계산하세요.
반드시 아래 JSON 형식으로만 응답하세요:
{{
"criteria_scores": {{
"기준명": 점수
}},
"average_score": 종합평균,
"passed": true/false,
"reason": "평가 이유 2~3문장",
"improvement": "개선 방향 제안"
}}"""
response_obj = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=500,
messages=[{"role": "user", "content": prompt}]
)
data = json.loads(response_obj.content[0].text)
return JudgeResult(
score=data["average_score"],
passed=data["average_score"] >= threshold,
reason=data["reason"],
details=data
)
# 사용 예시
result = judge_response(
task="파이썬에서 리스트 중복 제거하는 법 알려줘",
response="set()을 쓰면 됩니다. list(set(my_list))로 변환하세요.",
criteria=[
"정확한 방법을 제시했는가",
"코드 예시를 포함했는가",
"순서 유지 이슈를 언급했는가",
"대안적 방법을 제시했는가"
],
threshold=0.7
)
print(f"점수: {result.score:.2f}")
print(f"합격: {result.passed}")
print(f"이유: {result.reason}")
print(f"개선: {result.details['improvement']}")
실전 2 — 쌍 비교 패턴 (A/B 테스트)
두 응답 중 어느 것이 더 나은지 직접 비교합니다.
from enum import Enum
class Winner(Enum):
A = "A"
B = "B"
TIE = "TIE"
def pairwise_judge(
task: str,
response_a: str,
response_b: str,
criteria: list[str],
randomize: bool = True # 위치 편향 방지
) -> tuple[Winner, str]:
"""
두 응답 쌍 비교 평가
위치 편향: LLM은 앞에 나온 응답을 선호하는 경향
randomize=True로 A/B 순서를 무작위로 바꿔서 편향 완화
"""
import random
if randomize and random.random() > 0.5:
# 순서 뒤집기
first, second = response_b, response_a
flipped = True
else:
first, second = response_a, response_b
flipped = False
criteria_text = "\n".join(f"- {c}" for c in criteria)
prompt = f"""다음 두 AI 응답 중 더 나은 것을 선택하세요.
[작업]
{task}
[응답 1]
{first}
[응답 2]
{second}
[평가 기준]
{criteria_text}
반드시 아래 JSON 형식으로만 응답:
{{
"winner": "1" 또는 "2" 또는 "TIE",
"reason": "선택 이유 2~3문장",
"scores": {{
"response_1": 0.85,
"response_2": 0.72
}}
}}"""
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=400,
messages=[{"role": "user", "content": prompt}]
)
data = json.loads(response.content[0].text)
raw_winner = data["winner"]
# 순서 뒤집힌 경우 결과 보정
if raw_winner == "TIE":
winner = Winner.TIE
elif flipped:
winner = Winner.A if raw_winner == "2" else Winner.B
else:
winner = Winner.A if raw_winner == "1" else Winner.B
return winner, data["reason"]
# A/B 테스트 통계 수집
def run_ab_test(
test_cases: list[dict],
response_a_fn,
response_b_fn,
criteria: list[str],
rounds: int = 3 # 같은 케이스 여러 번 평가 (신뢰도)
) -> dict:
"""여러 케이스로 A/B 테스트 실행"""
wins = {Winner.A: 0, Winner.B: 0, Winner.TIE: 0}
for case in test_cases:
task = case["input"]
response_a = response_a_fn(task)
response_b = response_b_fn(task)
# 같은 케이스를 여러 번 평가 (변동성 줄이기)
case_wins = {Winner.A: 0, Winner.B: 0, Winner.TIE: 0}
for _ in range(rounds):
winner, _ = pairwise_judge(
task, response_a, response_b, criteria
)
case_wins[winner] += 1
# 과반수 결과 채택
final = max(case_wins, key=case_wins.get)
wins[final] += 1
total = len(test_cases)
return {
"A wins": f"{wins[Winner.A]}/{total} ({wins[Winner.A]/total*100:.1f}%)",
"B wins": f"{wins[Winner.B]}/{total} ({wins[Winner.B]/total*100:.1f}%)",
"Ties": f"{wins[Winner.TIE]}/{total} ({wins[Winner.TIE]/total*100:.1f}%)",
"winner": "A" if wins[Winner.A] > wins[Winner.B] else "B"
}
실전 3 — 루브릭 기반 평가
각 차원을 독립적으로 채점하는 루브릭을 정의합니다.
from dataclasses import dataclass, field
@dataclass
class RubricCriterion:
name: str
weight: float # 가중치 합계 = 1.0
description: str
scale: dict # 점수별 기준 설명
# 코딩 에이전트 루브릭 정의
CODING_RUBRIC = [
RubricCriterion(
name="correctness",
weight=0.40,
description="코드가 올바르게 작동하는가",
scale={
1.0: "완벽히 작동, 엣지케이스 처리",
0.7: "대부분 작동, 일부 엣지케이스 누락",
0.4: "기본 동작하지만 버그 있음",
0.0: "작동 안 함"
}
),
RubricCriterion(
name="readability",
weight=0.25,
description="코드가 읽기 쉬운가",
scale={
1.0: "명확한 변수명, 적절한 주석, 일관된 스타일",
0.7: "대체로 읽기 쉬움",
0.4: "이해는 가능하지만 개선 필요",
0.0: "매우 읽기 어려움"
}
),
RubricCriterion(
name="efficiency",
weight=0.20,
description="시간/공간 복잡도가 적절한가",
scale={
1.0: "최적 알고리즘 사용",
0.7: "합리적인 효율성",
0.4: "비효율적이지만 동작",
0.0: "매우 비효율적"
}
),
RubricCriterion(
name="explanation",
weight=0.15,
description="설명이 충분한가",
scale={
1.0: "명확한 설명 + 예시 포함",
0.7: "충분한 설명",
0.4: "부족한 설명",
0.0: "설명 없음"
}
),
]
def rubric_judge(
task: str,
response: str,
rubric: list[RubricCriterion]
) -> dict:
"""루브릭 기반 상세 평가"""
rubric_text = ""
for criterion in rubric:
rubric_text += f"\n## {criterion.name} (가중치: {criterion.weight})\n"
rubric_text += f"설명: {criterion.description}\n"
rubric_text += "채점 기준:\n"
for score, desc in criterion.scale.items():
rubric_text += f" {score}: {desc}\n"
prompt = f"""다음 응답을 루브릭으로 평가하세요.
[작업]
{task}
[응답]
{response}
[루브릭]
{rubric_text}
각 기준을 루브릭에 따라 정확히 채점하세요.
JSON으로만 응답:
{{
"scores": {{
"correctness": 0.7,
"readability": 0.85,
"efficiency": 0.6,
"explanation": 0.9
}},
"weighted_average": 0.76,
"feedback": {{
"correctness": "구체적 피드백",
"readability": "구체적 피드백",
"efficiency": "구체적 피드백",
"explanation": "구체적 피드백"
}}
}}"""
response_obj = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=600,
messages=[{"role": "user", "content": prompt}]
)
data = json.loads(response_obj.content[0].text)
# 가중 평균 직접 계산 (검증)
calculated_avg = sum(
data["scores"][c.name] * c.weight
for c in rubric
if c.name in data["scores"]
)
return {
"scores": data["scores"],
"weighted_average": calculated_avg,
"feedback": data["feedback"],
"passed": calculated_avg >= 0.7
}
실전 4 — 프로덕션 모니터링 파이프라인
import asyncio
from datetime import datetime
import random
class ProductionJudge:
"""프로덕션 응답 품질 실시간 모니터링"""
def __init__(
self,
sample_rate: float = 0.1, # 10% 샘플링
alert_threshold: float = 0.6, # 이 이하면 알림
criteria: list[str] = None
):
self.sample_rate = sample_rate
self.alert_threshold = alert_threshold
self.criteria = criteria or [
"질문에 정확히 답변했는가",
"한국어로 응답했는가",
"응답이 충분히 상세한가",
"안전하고 적절한 내용인가"
]
self.metrics = {
"total_evaluated": 0,
"total_passed": 0,
"avg_score": 0.0,
"low_quality": []
}
async def monitor(
self,
user_input: str,
agent_response: str,
session_id: str
) -> None:
"""
비동기로 응답 품질 모니터링
메인 응답 처리를 블로킹하지 않음
"""
# 샘플링 (전체 평가는 비용이 큼)
if random.random() > self.sample_rate:
return
result = judge_response(
task=user_input,
response=agent_response,
criteria=self.criteria,
threshold=self.alert_threshold
)
# 메트릭 업데이트
n = self.metrics["total_evaluated"]
self.metrics["avg_score"] = (
self.metrics["avg_score"] * n + result.score
) / (n + 1)
self.metrics["total_evaluated"] += 1
if result.passed:
self.metrics["total_passed"] += 1
else:
# 낮은 품질 응답 기록
self.metrics["low_quality"].append({
"session_id": session_id,
"score": result.score,
"reason": result.reason,
"timestamp": datetime.now().isoformat()
})
await self._alert(session_id, result)
async def _alert(self, session_id: str, result: JudgeResult):
"""품질 이슈 알림 (Slack, 이메일 등)"""
print(f"⚠️ 품질 이슈 감지: {session_id}")
print(f" 점수: {result.score:.2f}")
print(f" 이유: {result.reason}")
# 실제로는 Slack MCP나 이메일 발송
def print_report(self):
m = self.metrics
pass_rate = (m["total_passed"] / m["total_evaluated"] * 100
if m["total_evaluated"] > 0 else 0)
print(f"""
=== 품질 모니터링 보고서 ===
평가된 응답: {m['total_evaluated']}
평균 점수: {m['avg_score']:.3f}
합격률: {pass_rate:.1f}%
낮은 품질: {len(m['low_quality'])}건
""")
# 실제 서비스에서 사용
judge = ProductionJudge(sample_rate=0.1)
async def handle_request(user_input: str) -> str:
# 메인 에이전트 응답 생성
response = await agent.run(user_input)
# 비동기로 품질 평가 (응답 지연 없음)
asyncio.create_task(
judge.monitor(
user_input=user_input,
agent_response=response,
session_id=str(uuid.uuid4())
)
)
return response
실전 5 — LLM-as-Judge 편향 대처법
[알려진 편향 종류]
1. 자기 편향 (Self-bias):
→ Claude로 Claude 응답 평가 → Claude 편향 높게 평가
→ 해결: 평가 모델과 테스트 모델을 다르게
2. 위치 편향 (Position bias):
→ 쌍 비교 시 첫 번째 응답 선호
→ 해결: randomize=True로 순서 무작위화
3. 길이 편향 (Verbosity bias):
→ 긴 응답을 더 좋다고 평가
→ 해결: 평가 기준에 "간결성" 명시적 포함
4. 형식 편향 (Format bias):
→ 마크다운, 목록 형식 선호
→ 해결: 평가 기준에서 형식 가중치 낮추기
5. 아첨 편향 (Sycophancy):
→ 평가 요청자의 의견에 동의하는 경향
→ 해결: 평가 프롬프트에 의견 포함하지 않기
# 편향 완화 평가 설계
def unbiased_judge(task: str, response: str, criteria: list[str]) -> float:
"""편향 최소화 평가"""
# 1. 자기 편향: 다른 모델로 평가
# → GPT-5.5나 Gemini로 Claude 응답 평가
eval_client = openai.OpenAI() # Claude 대신 GPT 사용
# 2. 길이 편향: 명시적 기준 포함
criteria_with_conciseness = criteria + [
"불필요한 내용 없이 간결한가 (길수록 좋은 게 아님)"
]
# 3. 아첨 편향: 선입견 없는 프롬프트
prompt = f"""[평가 지침]
- 객관적으로 평가하세요
- 응답 길이와 형식은 품질과 무관합니다
- 짧아도 정확하면 높은 점수, 길어도 부정확하면 낮은 점수
[작업] {task}
[응답] {response}
[기준] {chr(10).join(f'- {c}' for c in criteria_with_conciseness)}
JSON: {{"score": 0.8, "reason": "이유"}}"""
# 4. 위치 편향: 단일 평가에서는 해당 없음
response_obj = eval_client.chat.completions.create(
model="gpt-5.5",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"}
)
data = json.loads(response_obj.choices[0].message.content)
return data["score"]
Ragas로 RAG 품질 평가 (보너스)
# pip install ragas
from ragas import evaluate
from ragas.metrics import (
faithfulness, # 생성된 답변이 컨텍스트에 충실한가
answer_relevancy, # 답변이 질문과 관련 있는가
context_precision, # 검색된 컨텍스트가 정확한가
context_recall # 필요한 컨텍스트를 모두 검색했는가
)
from datasets import Dataset
# 평가 데이터셋 구성
eval_data = {
"question": ["RAG가 뭔가요?", "벡터 DB 선택 기준은?"],
"answer": [rag_answer_1, rag_answer_2],
"contexts": [[chunk1, chunk2], [chunk3, chunk4]],
"ground_truth": ["정답1", "정답2"] # 선택적
}
dataset = Dataset.from_dict(eval_data)
# 자동 평가
result = evaluate(
dataset=dataset,
metrics=[
faithfulness,
answer_relevancy,
context_precision,
context_recall
]
)
print(result)
# faithfulness: 0.92
# answer_relevancy: 0.88
# context_precision: 0.79
# context_recall: 0.85
마무리
✅ LLM-as-Judge 써야 할 때
→ 대량 응답 품질 평가가 필요할 때 (100개+)
→ 새 프롬프트/모델 배포 전 자동 검증
→ 프로덕션 실시간 품질 모니터링
→ A/B 테스트 결과 자동 채점
→ RAG 파이프라인 품질 지표 추적
→ 파인튜닝 데이터셋 품질 필터링
❌ 사람 평가가 나은 경우
→ 안전, 윤리, 법률 관련 민감한 평가
→ 매우 전문적인 도메인 (의료, 법률)
→ 10개 이하 소량 평가 (비용 대비 효과 낮음)
→ 최종 배포 전 최종 검토 (사람 확인 병행 권장)
[빠른 시작 체크리스트]
→ 평가 기준 3~5개 명확히 정의 (모호하면 평가 불일치)
→ 임계값 설정 (0.7이 일반적 시작점)
→ 골든셋 20개 만들어 calibration
→ 편향 완화: 평가 모델 ≠ 테스트 모델
→ 10% 샘플링으로 프로덕션 모니터링 시작
관련 글:
https://cell-devlog.tistory.com/108
LLM 사설 평가셋 50개 만들고 모델 비교하기 — 벤치마크를 믿지 마세요
48시간마다 새 모델이 나와요. 모두 "SWE-bench 1위", "GPQA 최고점"을 주장해요.근데 그게 내 서비스에서도 최고일까요.공개 벤치마크의 현실:MMLU: 상위 모델 88% 이상 → 이미 포화SWE-bench: 에이전트 코
cell-devlog.tistory.com
https://cell-devlog.tistory.com/26
AI 에이전트 성능을 어떻게 측정하나 — Evals와 평가 방법론 완전 정리
AI 에이전트를 만들고 나면 이런 질문이 생겨요."이 에이전트가 잘 동작하는 건지 어떻게 알지? 그냥 써보는 것 말고 제대로 측정하는 방법이 있나?"일반 소프트웨어는 테스트가 간단해요. 같은
cell-devlog.tistory.com
https://cell-devlog.tistory.com/148
Google Stitch vs Claude Design — AI 디자인 툴 2파전, 뭘 써야 하나
Figma 주가가 11% 빠졌습니다. 3월에 Stitch, 4월에 Claude Design. 한 달 새 AI 디자인 툴 2개가 연속 출시됐습니다.[핵심 요약]→ Google Stitch: 3월 19일 대규모 업데이트, 무료→ Claude Design: 4월 17일 출시, Opus
cell-devlog.tistory.com
'AI Agent' 카테고리의 다른 글
| 임베딩 모델 완전 가이드 — text-embedding 선택과 RAG 적용 (0) | 2026.05.04 |
|---|---|
| Claude Code 디버깅 완전 가이드 — 에이전트가 실패할 때 추적하는 법 (0) | 2026.04.30 |
| AI 에이전트 롤백 전략 완전 가이드 — 에이전트가 망쳤을 때 복구하는 법 (0) | 2026.04.28 |
| AI 에이전트 상태 관리 완전 가이드 — 장기 실행 에이전트에서 상태를 잃지 않는 법 (0) | 2026.04.28 |
| AI 에이전트 테스트 전략 완전 가이드 — 단위 테스트부터 통합 테스트, E2E까지 (0) | 2026.04.28 |