AI Agent

LLM-as-Judge 완전 가이드 2편 — 편향 제거부터 Jury 패턴까지, 프로덕션에서 살아남는 법

cell-devlog 2026. 5. 22. 16:17
반응형

 

기본 개념은 알겠는데, 실제로 쓰면 점수가 이상합니다. 짧은 답변이 낮게 나오고, 순서만 바꿔도 결과가 뒤집힙니다. 이게 왜 일어나는지, 어떻게 막는지 파고들어 봅니다.


📌 핵심 요약
→ LLM Judge의 인간 동의율 85% — 하지만 편향이 있으면 그 85%가 틀린 방향으로 수렴
→ 주요 편향 4가지: Position / Verbosity / Self-preference / Preference Leakage
→ G-Eval: 루브릭 기반 채점, CoT로 신뢰도 10~15% 향상
→ DAG 방식: 비결정적 G-Eval의 한계를 구조화된 평가 그래프로 보완
→ LLM Jury: 여러 모델을 배심원단처럼 운용, 단일 Judge 편향 희석
→ Preference Leakage: Judge와 피평가 모델이 같은 계열이면 채점이 오염됨
→ 프로덕션 황금 공식: LLM Judge(속도) + 인간 리뷰(엣지케이스) 혼합
→ 실전 코드: G-Eval, Jury 패턴, 위치 편향 방어까지 전부 다룸

실전1 — 편향 4종 해부: 왜 점수가 틀리는가

LLM Judge가 낮은 점수를 주는데 실제로는 좋은 답변인 경우, 대부분 아래 4가지 편향 중 하나입니다.

# 편향 실험: 순서만 바꿔도 결과가 달라지는지 확인
import anthropic

client = anthropic.Anthropic()

JUDGE_PROMPT = """
다음 두 답변 중 더 나은 것을 고르세요.

질문: {question}

답변 A: {answer_a}
답변 B: {answer_b}

더 나은 답변은 A입니까 B입니까? 이유를 포함해 답하세요.
"""

question = "Python에서 리스트와 튜플의 차이는?"
good_answer = "리스트는 mutable(변경 가능), 튜플은 immutable(변경 불가)입니다. 리스트는 [], 튜플은 ()로 선언합니다."
bad_answer  = "둘 다 여러 값을 담는 자료구조인데 조금 다릅니다."

# 순서 1: good이 A
r1 = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=500,
    messages=[{"role": "user", "content": JUDGE_PROMPT.format(
        question=question, answer_a=good_answer, answer_b=bad_answer
    )}]
)

# 순서 2: good이 B (순서만 뒤집음)
r2 = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=500,
    messages=[{"role": "user", "content": JUDGE_PROMPT.format(
        question=question, answer_a=bad_answer, answer_b=good_answer
    )}]
)

print("순서1 결과:", r1.content[0].text[:100])
print("순서2 결과:", r2.content[0].text[:100])
# → 실제로 돌려보면 같은 모델이 다른 답을 고르는 경우가 10~15% 발생
→ [Position Bias] 첫 번째 위치의 답변을 더 선호하는 경향 — 순서만 바꿔도 판정 뒤집힘
→ [Verbosity Bias] 긴 답변을 무조건 더 좋다고 평가 — RLHF 학습 과정의 부산물
→ [Self-preference Bias] Claude가 Judge면 Claude스러운 답변에 높은 점수
   ∟ 자신의 출력 패턴과 낮은 perplexity = 더 친숙 = 더 높은 점수
→ [Preference Leakage] Judge와 피평가 모델이 같은 계열(같은 베이스모델, 파인튜닝 관계)이면
   ∟ 학습 데이터가 겹쳐 "아는 맛" 효과 발생 — ICLR 2026 채택 논문에서 공식 확인

실전2 — G-Eval: 루브릭 기반 채점으로 신뢰도 올리기

단순히 "어느 게 낫냐"고 묻는 대신, 평가 기준을 명시적으로 정의하고 CoT로 채점하는 방식입니다.

from anthropic import Anthropic

client = Anthropic()

G_EVAL_PROMPT = """당신은 AI 답변 품질 평가 전문가입니다.

[평가 기준]
1. 정확성 (Accuracy): 사실적으로 올바른가? (0~5)
2. 관련성 (Relevance): 질문에 직접 답하는가? (0~5)
3. 완결성 (Completeness): 중요한 내용이 빠지지 않았는가? (0~5)
4. 간결성 (Conciseness): 불필요한 내용이 없는가? (0~5)

[평가 방법]
각 기준마다 아래 순서로 평가하세요:
1) 해당 기준에서 좋은 점 나열
2) 부족한 점 나열
3) 점수 결정 및 이유 한 줄 요약

[입력]
질문: {question}
답변: {answer}

위 기준에 따라 단계적으로 평가하고, 마지막에 JSON으로 결과를 출력하세요:
{{"accuracy": N, "relevance": N, "completeness": N, "conciseness": N, "total": N, "reason": "..."}}"""

def g_eval(question: str, answer: str) -> dict:
    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1000,
        messages=[{
            "role": "user",
            "content": G_EVAL_PROMPT.format(question=question, answer=answer)
        }]
    )

    text = response.content[0].text

    # JSON 파싱
    import json, re
    match = re.search(r'\{.*\}', text, re.DOTALL)
    if match:
        return json.loads(match.group())
    return {}

result = g_eval(
    question="Python GIL이 무엇인가요?",
    answer="GIL(Global Interpreter Lock)은 CPython에서 한 번에 하나의 스레드만 Python 바이트코드를 실행하도록 하는 뮤텍스입니다. CPU-bound 작업에서 멀티스레딩 성능을 제한하지만, I/O-bound 작업에서는 큰 영향이 없습니다."
)
print(result)
# → {"accuracy": 5, "relevance": 5, "completeness": 4, "conciseness": 5, "total": 19, "reason": "..."}
→ CoT(단계적 사고) 강제로 신뢰도 10~15% 향상 — "왜"를 먼저 쓰게 하면 결론이 달라짐
→ 루브릭을 구체적으로 쓸수록 편향 감소 — "좋다" 대신 "정확한 기술 용어 사용 여부"
→ G-Eval의 한계: 비결정적 — 같은 입력에 다른 점수가 나올 수 있음
→ 해결: temperature=0 고정, 동일 입력 3회 실행 후 평균 사용 권장

실전3 — DAG 방식: 결정론적 평가가 필요할 때

G-Eval이 주관적 평가에 강하다면, DAG(Directed Acyclic Graph)는 명확한 기준이 있는 평가에 적합합니다.

# DAG 평가: 조건을 트리 구조로 순차 검증
from anthropic import Anthropic
import json

client = Anthropic()

def check_single_criterion(answer: str, criterion: str, description: str) -> dict:
    """단일 기준 하나만 체크 — 결정론적"""
    prompt = f"""다음 답변이 아래 기준을 충족하는지 판단하세요.

기준: {criterion}
설명: {description}

답변:
{answer}

반드시 JSON으로만 응답하세요:
{{"pass": true/false, "reason": "한 줄 이유"}}"""

    r = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=200,
        messages=[{"role": "user", "content": prompt}]
    )
    return json.loads(r.content[0].text)

def dag_evaluate(answer: str) -> dict:
    """
    DAG 평가 흐름:
    [사실 확인] → pass → [관련성 확인] → pass → [안전성 확인] → 최종 점수
                → fail → 즉시 0점 반환
    """
    results = {}

    # 노드 1: 사실 확인 (가장 중요, 실패시 즉시 종료)
    r1 = check_single_criterion(
        answer,
        "사실 정확성",
        "답변에 명백히 틀린 기술 정보가 없어야 함"
    )
    results["accuracy"] = r1
    if not r1["pass"]:
        return {"score": 0, "reason": "사실 오류 감지", "details": results}

    # 노드 2: 관련성 확인
    r2 = check_single_criterion(
        answer,
        "질문 관련성",
        "질문에서 벗어난 내용이 없어야 함"
    )
    results["relevance"] = r2
    if not r2["pass"]:
        return {"score": 3, "reason": "관련성 부족", "details": results}

    # 노드 3: 안전성 확인
    r3 = check_single_criterion(
        answer,
        "안전성",
        "유해하거나 부적절한 내용이 없어야 함"
    )
    results["safety"] = r3
    if not r3["pass"]:
        return {"score": 0, "reason": "안전성 위반", "details": results}

    return {"score": 10, "reason": "모든 기준 통과", "details": results}

result = dag_evaluate("Python의 GIL은 멀티스레딩을 완전히 불가능하게 만듭니다.")
print(json.dumps(result, ensure_ascii=False, indent=2))
# → score: 0 (사실 오류 — I/O 스레딩은 가능하므로)
→ DAG의 핵심: 한 번에 하나의 기준만 판단 → 결정론적, 디버깅 가능
→ 중요한 기준을 앞에 배치해 실패시 early exit → API 비용 절감
→ G-Eval vs DAG 선택 기준:
   ∙ 주관적 품질(창의성, 일관성) → G-Eval
   ∙ 명확한 패스/실패 기준(사실 오류, 포맷 검사) → DAG

실전4 — LLM Jury: 배심원단 패턴으로 편향 희석

단일 Judge의 편향을 줄이는 가장 강력한 방법은 여러 모델을 배심원단처럼 운용하는 것입니다.

import anthropic
from statistics import mean, stdev

# 여러 모델을 배심원단으로 구성
# (실제 프로덕션에서는 OpenAI, Gemini API도 함께 사용 권장)
JUDGES = [
    {"model": "claude-sonnet-4-20250514", "name": "Claude Sonnet"},
    # {"model": "gpt-4o", "name": "GPT-4o"},       # openai 클라이언트 필요
    # {"model": "gemini-2.0-flash", "name": "Gemini"}, # google 클라이언트 필요
]

SCORE_PROMPT = """다음 답변의 전반적인 품질을 0~10 사이 정수로만 답하세요.
평가 기준: 정확성, 명확성, 완결성

질문: {question}
답변: {answer}

점수(숫자만):"""

def jury_evaluate(question: str, answer: str, n_runs: int = 3) -> dict:
    """
    같은 모델을 여러 번 실행 + 위치 편향 방어(순서 교란) 포함
    실제로는 다른 모델들을 섞어 쓰는 것이 더 효과적
    """
    client = anthropic.Anthropic()
    all_scores = []

    for judge in JUDGES:
        scores_for_judge = []
        for _ in range(n_runs):
            try:
                r = client.messages.create(
                    model=judge["model"],
                    max_tokens=10,
                    messages=[{
                        "role": "user",
                        "content": SCORE_PROMPT.format(
                            question=question,
                            answer=answer
                        )
                    }]
                )
                score = int(r.content[0].text.strip())
                scores_for_judge.append(score)
            except:
                continue

        if scores_for_judge:
            all_scores.extend(scores_for_judge)
            print(f"[{judge['name']}] 점수들: {scores_for_judge} → 평균: {mean(scores_for_judge):.1f}")

    if len(all_scores) < 2:
        return {"final_score": all_scores[0] if all_scores else None}

    final = mean(all_scores)
    variance = stdev(all_scores)

    return {
        "final_score": round(final, 2),
        "variance": round(variance, 2),
        "all_scores": all_scores,
        # variance가 2 이상이면 판정이 불안정 → 인간 검토 플래그
        "needs_human_review": variance >= 2.0
    }

result = jury_evaluate(
    question="비동기 프로그래밍이란?",
    answer="비동기 프로그래밍은 작업 완료를 기다리지 않고 다음 작업을 실행하는 방식입니다. I/O 바운드 작업에서 성능을 크게 향상시킵니다."
)
print(result)
→ Jury 패턴의 핵심 원리: 다른 계열 모델이 같은 방향으로 편향될 확률은 낮음
→ variance(분산)가 높으면 판정 불안정 신호 → 인간 검토 트리거로 활용
→ 비용: 단일 Judge 대비 3~5배 → 중요한 평가에만 선택적 적용
→ Jury-on-Demand: 신뢰도 예측기로 "이 샘플은 Jury 필요" 판단 (2025년 연구)

실전5 — Preference Leakage 방어: 같은 계열 모델 금지

2026년 ICLR에서 공식 확인된 새로운 편향입니다. Judge와 피평가 모델이 같은 계열이면 채점이 오염됩니다.

# Preference Leakage 방어 체크리스트 구현

LEAKAGE_RISK_MAP = {
    # (평가 모델 계열, 피평가 모델 계열): 위험도
    ("claude", "claude"): "HIGH",    # 같은 모델 → 최고 위험
    ("gpt", "gpt"):       "HIGH",
    ("claude", "gpt"):    "LOW",     # 다른 계열 → 안전
    ("gpt", "claude"):    "LOW",
    ("claude", "gemini"): "LOW",
    ("gemini", "claude"): "LOW",
}

def check_leakage_risk(judge_model: str, target_model: str) -> str:
    """
    사용 예:
    judge_model  = "claude-sonnet-4-20250514"
    target_model = "claude-haiku-3-5"  → HIGH RISK
    """
    def get_family(model_name: str) -> str:
        model_lower = model_name.lower()
        if "claude"  in model_lower: return "claude"
        if "gpt"     in model_lower: return "gpt"
        if "gemini"  in model_lower: return "gemini"
        if "llama"   in model_lower: return "llama"
        if "qwen"    in model_lower: return "qwen"
        return "unknown"

    judge_family  = get_family(judge_model)
    target_family = get_family(target_model)

    risk = LEAKAGE_RISK_MAP.get((judge_family, target_family), "MEDIUM")

    print(f"Judge: {judge_family} | Target: {target_family} | Risk: {risk}")

    if risk == "HIGH":
        print("⚠️  같은 계열 모델 — Preference Leakage 위험. 다른 계열 Judge 사용 권장")
    return risk

# 실전 가이드라인
check_leakage_risk("claude-sonnet-4-20250514", "claude-haiku-4-5")
# → HIGH RISK

check_leakage_risk("claude-sonnet-4-20250514", "gpt-4o")
# → LOW RISK
→ Preference Leakage 3가지 유형:
   1) 동일 모델 (claude judge → claude output): 최고 위험
   2) 상속 관계 (파인튜닝 베이스 동일): 중간 위험
   3) 같은 패밀리 (claude sonnet → claude haiku): 중간 위험
→ 방어법: Judge는 반드시 다른 계열 모델 사용
→ 예산 없으면: 최소한 같은 모델의 다른 버전이라도 사용

실전6 — 프로덕션 Judge 파이프라인 전체 구조

import anthropic
import json
from enum import Enum

client = anthropic.Anthropic()

class ReviewLevel(Enum):
    AUTO_PASS   = "auto_pass"    # 자동 통과
    AUTO_FAIL   = "auto_fail"    # 자동 실패
    HUMAN_REVIEW = "human_review" # 인간 검토 필요

def production_judge_pipeline(question: str, answer: str) -> dict:
    """
    프로덕션 Judge 파이프라인
    1) 빠른 DAG 검사 (치명적 오류 필터)
    2) G-Eval 점수 (주관적 품질)
    3) 점수 기반 라우팅 (자동 처리 vs 인간 리뷰)
    """

    # Step 1: 치명적 오류 먼저 걸러내기 (DAG)
    safety_check = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=100,
        messages=[{"role": "user", "content": f"""
답변에 아래 중 하나라도 해당하면 "FAIL", 아니면 "PASS"만 출력하세요.
- 명백한 사실 오류
- 유해하거나 위험한 내용
- 질문과 전혀 무관한 내용

질문: {question}
답변: {answer}
"""}]
    )

    if "FAIL" in safety_check.content[0].text:
        return {
            "level": ReviewLevel.AUTO_FAIL.value,
            "score": 0,
            "reason": "안전성/사실성 검사 실패"
        }

    # Step 2: G-Eval 점수
    score_response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=300,
        messages=[{"role": "user", "content": f"""
답변 품질을 평가하세요. 반드시 JSON만 출력:

질문: {question}
답변: {answer}

평가 (각 항목 0~10):
{{"clarity": N, "completeness": N, "accuracy": N}}"""}]
    )

    try:
        scores = json.loads(score_response.content[0].text)
        total = sum(scores.values()) / len(scores)
    except:
        total = 5.0  # 파싱 실패시 중간값

    # Step 3: 점수 기반 라우팅
    if total >= 8.0:
        level = ReviewLevel.AUTO_PASS.value
    elif total <= 3.0:
        level = ReviewLevel.AUTO_FAIL.value
    else:
        level = ReviewLevel.HUMAN_REVIEW.value  # 중간 구간 → 인간 검토

    return {
        "level": level,
        "score": round(total, 2),
        "details": scores if 'scores' in dir() else {}
    }

result = production_judge_pipeline(
    "파이썬 async/await 사용법은?",
    "async def 함수에 await를 붙여 비동기 작업을 실행합니다. asyncio.run()으로 이벤트 루프를 시작합니다."
)
print(result)
→ 핵심 설계 원칙: LLM Judge는 속도, 인간은 정확도 — 역할 분리
→ 중간 구간(3~8점)만 인간 리뷰 → 비용 최소화하면서 품질 유지
→ IBM 연구자 말: "Judge로 판단력을 향상시켜라, 대체하지 말고"
→ RAND 연구(2026.03): 포맷 변경, 패러프레이징만으로도 Judge 일관성 붕괴 확인
   ∟ → 프로덕션에선 입력 정규화(공백, 마크다운 제거) 필수

✅ 이 글 핵심 정리
✅ 편향 4종 파악: Position / Verbosity / Self-preference / Preference Leakage
✅ G-Eval로 주관적 품질 채점, DAG로 결정론적 기준 검사
✅ Jury 패턴으로 단일 Judge 편향 희석 — variance로 불안정 감지
✅ Preference Leakage 방어: Judge ≠ 피평가 모델 계열
✅ 프로덕션 공식: DAG 필터 → G-Eval 점수 → 인간 리뷰 라우팅

❌ G-Eval 단독으론 결정론적 평가 불가 — DAG 병행 필수
❌ 같은 계열 모델로 Judge 쓰면 점수 오염
❌ Judge 완전 자동화는 현시점 무리 — 엣지케이스 인간 검토 필수
❌ temperature 미고정시 같은 입력에서 다른 점수 → 신뢰도 훼손

관련글

 

반응형