본문 바로가기

AI Agent

LLM-as-Judge 완전 가이드 — AI로 AI 출력을 자동 평가하는 법

반응형

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

 

반응형