LLM을 평가자로 활용하는 LLM-as-Judge 기법의 원리, 프롬프트 설계, 편향 완화 전략을 체계적으로 다룹니다.
코드 기반 메트릭(ROUGE, BLEU, Exact Match 등)은 계산이 빠르고 재현 가능하지만, 언어의 미묘한 품질 차이를 포착하지 못합니다. "사실적으로 정확한가", "논리적으로 일관되는가", "사용자의 의도를 정확히 파악했는가"와 같은 판단은 의미 이해를 필요로 합니다.
인간 평가는 이러한 판단에 가장 정확하지만, 비용이 높고 속도가 느리며 확장이 어렵습니다. LLM-as-Judge는 강력한 LLM을 평가자로 활용하여, 인간 평가에 가까운 품질 판단을 자동화하는 접근법입니다.
메트릭 유형별 비교:
코드 기반 메트릭 : 빠름, 저비용, 재현 가능, 의미 파악 제한
LLM-as-Judge : 중간 속도, 중간 비용, 의미 파악 가능, 편향 존재
인간 평가 : 느림, 고비용, 가장 정확, 확장 어려움가장 기본적인 LLM-as-Judge 패턴은 응답에 숫자 점수를 부여하는 것입니다.
def score_response(
question: str,
answer: str,
criterion: str,
scale: tuple = (1, 5),
model: str = "claude-sonnet-4-20250514"
) -> dict:
"""단일 기준으로 응답에 점수를 부여합니다."""
prompt = """당신은 LLM 응답 품질 평가 전문가입니다.
주어진 기준에 따라 응답을 평가하고 점수를 매기세요.
## 질문
{question}
## 응답
{answer}
## 평가 기준
{criterion}
## 점수 척도
{min_score}: 매우 부족
{mid_score}: 보통
{max_score}: 매우 우수
반드시 다음 형식으로만 응답하세요:
점수: [숫자]
근거: [평가 근거를 2-3문장으로 작성]""".format(
question=question,
answer=answer,
criterion=criterion,
min_score=scale[0],
mid_score=(scale[0] + scale[1]) // 2,
max_score=scale[1]
)
response = call_llm(model, prompt)
return parse_score_response(response)실무에서는 하나의 응답을 여러 기준으로 동시에 평가합니다.
def multi_criteria_judge(
question: str,
answer: str,
criteria: dict,
model: str = "claude-sonnet-4-20250514"
) -> dict:
"""여러 기준으로 동시에 평가합니다."""
criteria_text = ""
for name, description in criteria.items():
criteria_text += "- " + name + ": " + description + "\n"
prompt = """당신은 LLM 응답 품질 평가 전문가입니다.
다음 응답을 여러 기준에 따라 각각 1-5점으로 평가하세요.
## 질문
{question}
## 응답
{answer}
## 평가 기준
{criteria}
각 기준에 대해 다음 형식으로 응답하세요:
[기준명]: [점수]/5 - [한 줄 근거]""".format(
question=question,
answer=answer,
criteria=criteria_text
)
response = call_llm(model, prompt)
return parse_multi_criteria(response)
# 사용 예시
criteria = {
"정확성": "사실적으로 정확한 정보를 제공하는가",
"완전성": "질문의 모든 측면에 답변하는가",
"명확성": "이해하기 쉽게 설명하는가",
"간결성": "불필요한 내용 없이 핵심을 전달하는가",
}
result = multi_criteria_judge(
question="파이썬의 GIL이란 무엇인가요?",
answer="GIL은 Global Interpreter Lock으로...",
criteria=criteria
)절대 점수 대신 두 응답을 비교하여 어떤 것이 더 나은지 판단하는 패턴입니다. 인간에게도 절대 점수보다 상대 비교가 더 일관된 결과를 보입니다.
def pairwise_judge(
question: str,
answer_a: str,
answer_b: str,
criterion: str,
model: str = "claude-sonnet-4-20250514"
) -> dict:
"""두 응답을 비교하여 더 나은 것을 선택합니다."""
prompt = """당신은 LLM 응답 품질 평가 전문가입니다.
같은 질문에 대한 두 응답을 비교하여, 주어진 기준에서 더 나은 응답을 선택하세요.
## 질문
{question}
## 응답 A
{answer_a}
## 응답 B
{answer_b}
## 평가 기준
{criterion}
반드시 다음 형식으로만 응답하세요:
선택: [A 또는 B 또는 동점]
근거: [선택 근거를 3-4문장으로 작성]""".format(
question=question,
answer_a=answer_a,
answer_b=answer_b,
criterion=criterion
)
response = call_llm(model, prompt)
return parse_pairwise_result(response)쌍대 비교에서는 위치 편향(Position Bias)이 발생합니다. LLM은 첫 번째(또는 두 번째) 위치의 응답을 선호하는 경향이 있습니다. 이를 완화하려면 A와 B의 순서를 바꿔서 두 번 평가하고, 결과가 일치하는지 확인해야 합니다.
async def pairwise_judge_debiased(
question: str,
answer_a: str,
answer_b: str,
criterion: str,
model: str = "claude-sonnet-4-20250514"
) -> dict:
"""위치 편향을 완화한 쌍대 비교를 수행합니다."""
# 순서 1: A가 먼저
result_1 = pairwise_judge(
question, answer_a, answer_b, criterion, model
)
# 순서 2: B가 먼저
result_2 = pairwise_judge(
question, answer_b, answer_a, criterion, model
)
# 결과 일관성 확인
choice_1 = result_1["choice"] # "A" 또는 "B"
choice_2 = result_2["choice"] # 순서가 바뀌었으므로 반전
if choice_2 == "A":
choice_2_normalized = "B"
elif choice_2 == "B":
choice_2_normalized = "A"
else:
choice_2_normalized = "동점"
if choice_1 == choice_2_normalized:
return {
"choice": choice_1,
"confidence": "high",
"consistent": True,
}
else:
return {
"choice": "동점",
"confidence": "low",
"consistent": False,
"note": "위치에 따라 결과가 달라짐",
}세밀한 평가를 위해, 각 점수 수준에 대한 상세 기준(루브릭)을 제공합니다.
rubric_template = """당신은 LLM 응답 품질 평가 전문가입니다.
아래 루브릭에 따라 응답을 평가하세요.
## 질문
{question}
## 응답
{answer}
## 평가 루브릭: {dimension}
5점 - 탁월함
{level_5}
4점 - 우수함
{level_4}
3점 - 보통
{level_3}
2점 - 미흡함
{level_2}
1점 - 매우 부족함
{level_1}
응답 형식:
점수: [1-5]
해당 수준: [루브릭에서 가장 부합하는 수준의 설명을 인용]
추가 근거: [평가 근거]"""
# 사실 정확성 루브릭 예시
factual_accuracy_rubric = {
"dimension": "사실 정확성",
"level_5": "모든 주장이 사실적으로 정확하며, 출처가 명확하고 최신 정보를 반영함",
"level_4": "거의 모든 주장이 정확하며, 사소한 부정확성이 1개 이하",
"level_3": "핵심 주장은 정확하나, 부가적 정보에서 2-3개의 부정확성이 있음",
"level_2": "핵심 주장에 부정확성이 있거나, 중요한 정보가 누락됨",
"level_1": "주요 주장이 사실과 다르거나, 심각한 오류가 포함됨",
}LLM 평가자도 체계적인 편향을 가지며, 이를 인지하고 완화해야 합니다.
| 편향 유형 | 설명 | 완화 전략 |
|---|---|---|
| 위치 편향 (Position Bias) | 특정 순서의 응답을 선호 | 순서 교체 후 이중 평가 |
| 장문 편향 (Verbosity Bias) | 긴 응답에 높은 점수 부여 | 루브릭에 간결성 기준 명시 |
| 자기 선호 편향 (Self-Enhancement) | 자신과 같은 모델의 출력 선호 | 다른 모델을 평가자로 사용 |
| 관대함 편향 (Leniency Bias) | 전반적으로 높은 점수 부여 | 구체적 루브릭으로 앵커링 |
| 일관성 편향 (Consistency Bias) | 한 번 높은 점수를 주면 계속 높게 | 각 평가를 독립 세션으로 실행 |
def measure_position_bias(
test_cases: list,
judge_model: str,
num_trials: int = 100
) -> dict:
"""위치 편향의 정도를 정량적으로 측정합니다."""
first_wins = 0
second_wins = 0
ties = 0
for case in test_cases[:num_trials]:
# 동일한 응답을 A, B 위치에 배치
result = pairwise_judge(
question=case["question"],
answer_a=case["answer"],
answer_b=case["answer"], # 동일 응답
criterion="전반적 품질",
model=judge_model
)
if result["choice"] == "A":
first_wins += 1
elif result["choice"] == "B":
second_wins += 1
else:
ties += 1
total = first_wins + second_wins + ties
return {
"first_position_rate": round(first_wins / total, 3),
"second_position_rate": round(second_wins / total, 3),
"tie_rate": round(ties / total, 3),
"bias_detected": abs(first_wins - second_wins) / total > 0.1,
}def cross_model_judge(
question: str,
answer: str,
criteria: dict
) -> dict:
"""여러 모델로 교차 평가하여 편향을 줄입니다."""
judge_models = [
"claude-sonnet-4-20250514",
"gpt-4o",
"gemini-1.5-pro",
]
all_scores = {}
for model in judge_models:
scores = multi_criteria_judge(
question, answer, criteria, model=model
)
all_scores[model] = scores
# 모델별 점수를 집계
aggregated = {}
for criterion in criteria:
model_scores = [
all_scores[m].get(criterion, {}).get("score", 0)
for m in judge_models
]
aggregated[criterion] = {
"mean": round(sum(model_scores) / len(model_scores), 2),
"scores_by_model": dict(zip(judge_models, model_scores)),
"agreement": max(model_scores) - min(model_scores) <= 1,
}
return aggregated교차 모델 평가는 비용이 3배 증가하므로 모든 평가에 적용하기는 어렵습니다. 핵심 평가 데이터셋이나 분쟁이 있는 케이스에 선별적으로 적용하는 것이 실용적입니다.
LLM 평가자의 응답 형식이 일관되지 않으면 결과 파싱이 실패합니다. 구조화된 출력을 강제하여 안정성을 높입니다.
import json
def structured_judge(
question: str,
answer: str,
criteria: list,
model: str = "claude-sonnet-4-20250514"
) -> dict:
"""JSON 형식의 구조화된 평가 결과를 생성합니다."""
criteria_list = "\n".join(
"- " + c["name"] + ": " + c["description"]
for c in criteria
)
prompt = """당신은 LLM 응답 품질 평가 전문가입니다.
주어진 기준에 따라 응답을 평가하고, 반드시 유효한 JSON으로만 응답하세요.
## 질문
{question}
## 응답
{answer}
## 평가 기준
{criteria}
응답 JSON 구조:
- scores: 각 기준별 1-5 점수
- reasoning: 각 기준별 평가 근거
- overall: 종합 점수 (1-5)
- summary: 종합 평가 요약 (1-2문장)""".format(
question=question,
answer=answer,
criteria=criteria_list
)
response = call_llm(model, prompt, response_format="json")
try:
return json.loads(response)
except json.JSONDecodeError:
# 파싱 실패 시 재시도
return retry_with_json_repair(response)LLM 평가자의 신뢰도를 검증하기 위해, 인간 평가와의 일치도를 측정합니다.
def calibrate_judge(
human_labels: list,
judge_labels: list
) -> dict:
"""인간 평가와 LLM 평가의 일치도를 측정합니다."""
from sklearn.metrics import cohen_kappa_score
import numpy as np
# 정확 일치율
exact_match = sum(
1 for h, j in zip(human_labels, judge_labels) if h == j
) / len(human_labels)
# Cohen's Kappa (우연 일치를 보정한 일치도)
kappa = cohen_kappa_score(human_labels, judge_labels)
# 상관계수
correlation = float(np.corrcoef(human_labels, judge_labels)[0, 1])
# 평균 절대 오차
mae = float(np.mean(np.abs(
np.array(human_labels) - np.array(judge_labels)
)))
return {
"exact_match": round(exact_match, 3),
"cohen_kappa": round(kappa, 3),
"correlation": round(correlation, 3),
"mean_absolute_error": round(mae, 3),
"interpretation": interpret_kappa(kappa),
}
def interpret_kappa(kappa: float) -> str:
"""Cohen's Kappa 값을 해석합니다."""
if kappa > 0.8:
return "거의 완전 일치 (Almost Perfect)"
elif kappa > 0.6:
return "상당한 일치 (Substantial)"
elif kappa > 0.4:
return "보통 일치 (Moderate)"
elif kappa > 0.2:
return "약한 일치 (Fair)"
else:
return "미미한 일치 (Slight)"class LLMJudge:
"""프로덕션 수준의 LLM 평가자 시스템입니다."""
def __init__(
self,
model: str = "claude-sonnet-4-20250514",
criteria: list = None,
rubrics: dict = None,
debias: bool = True
):
self.model = model
self.criteria = criteria or self._default_criteria()
self.rubrics = rubrics or {}
self.debias = debias
def _default_criteria(self) -> list:
return [
{"name": "정확성", "description": "사실적으로 정확한 정보를 제공하는가"},
{"name": "관련성", "description": "질문에 직접적으로 답변하는가"},
{"name": "완전성", "description": "질문의 모든 측면을 다루는가"},
{"name": "명확성", "description": "이해하기 쉽게 설명하는가"},
]
async def evaluate(self, question: str, answer: str) -> dict:
"""응답을 종합적으로 평가합니다."""
result = structured_judge(
question=question,
answer=answer,
criteria=self.criteria,
model=self.model
)
return result
async def compare(
self, question: str, answer_a: str, answer_b: str
) -> dict:
"""두 응답을 비교 평가합니다."""
if self.debias:
return await pairwise_judge_debiased(
question, answer_a, answer_b,
criterion="전반적 품질",
model=self.model
)
return pairwise_judge(
question, answer_a, answer_b,
criterion="전반적 품질",
model=self.model
)
async def batch_evaluate(self, cases: list, concurrency: int = 5) -> list:
"""여러 케이스를 병렬로 평가합니다."""
import asyncio
semaphore = asyncio.Semaphore(concurrency)
async def bounded_eval(case):
async with semaphore:
return await self.evaluate(
question=case["question"],
answer=case["answer"]
)
tasks = [bounded_eval(case) for case in cases]
return await asyncio.gather(*tasks)LLM-as-Judge는 코드 기반 메트릭이 포착하지 못하는 의미적 품질을 자동으로 평가하는 강력한 도구입니다. 점수 매기기, 쌍대 비교, 루브릭 기반 평가 등 다양한 패턴을 활용할 수 있으며, 위치 편향, 장문 편향, 자기 선호 편향 등 알려진 편향을 인지하고 완화 전략을 적용해야 합니다.
중요한 것은 LLM-as-Judge가 인간 평가를 완전히 대체하는 것이 아니라, 인간 평가의 확장 수단이라는 점입니다. 다음 장에서는 LLM-as-Judge와 상호 보완적으로 활용되는 인간 평가와 어노테이션 설계를 다룹니다.
이 글이 도움이 되셨나요?