LLM-as-Judge 已经是 LLM 应用语义评测的事实标准,但把它当测试断言用之前,得先回答一个问题:judge 给的分可信吗。我们踩过的坑是:judge 打分和人工评估的相关性一开始只有 0.4 左右,拿它直接卡发布,拦错了好版本,也放过了坏版本。问题不在 judge 模型不够强,在评测协议本身有偏差------自夸、位置、长度、尺度漂移,四个偏差叠在一起,分数就成了带噪声的猜测。修协议比换模型便宜得多。落地做法是双通道打分(rubric 绝对分 + 成对比较)、一致性统计兜底、人工抽检校准相关性、回归 diff 设门禁,技术栈只有 pytest + 异步 OpenAI 兼容客户端 + JSONL 存结果,没引任何重平台。
judge 的四个偏差,只能被设计掉
偏差不会因为模型变强而消失,只能靠评测协议规避:
| 偏差 | 现象 | 规避手段 |
|---|---|---|
| 自夸偏差 | 用被测模型自己打分,分数系统性偏高 0.3~0.5 | judge 与被测模型分开,用第三方模型 |
| 位置偏差 | 成对比较时 A 位置总占便宜 | AB/BA 交换顺序各评一次,只取两次一致的结论 |
| 长度偏差 | 输出越长分越高,废话多反而得高分 | rubric 显式加入"简洁性"维度,按长度分层抽检 |
| 尺度漂移 | 换 judge 模型/版本后绝对分整体平移 | 固定锚定样本集,只比同批次内的相对差 |
识别方式很简单:把 judge 结果和人工标注放在一起散点图上看,偏差大的 case 集中在"长答案高分组"或"自家模型组",一眼就能看出来。
打分协议:绝对分管纵向,成对比较管横向
两类问题要分开回答。纵向:同一个 query 在 prompt v1/v2 下质量是涨是跌------用 rubric 绝对分(1~5)看趋势。横向:新版本到底比不比 baseline 好------用成对比较(A/B 盲评)做决策。两者结论冲突时以成对比较为准,因为人的判断本质是相对的,rubric 绝对分跨批次不可比。
import asyncio
import json
import re
from openai import AsyncOpenAI
client = AsyncOpenAI()
RUBRIC = """你是一名严格的评测员。按以下标准给【回答】打分(1-5):
5:事实正确、完全覆盖问题、无冗余
4:事实正确、覆盖主要部分、略有遗漏或冗余
3:事实基本正确,但遗漏关键点或含轻微错误
2:存在明显事实错误或答非所问
1:完全不可用
只输出 JSON:{"score": int, "reason": "一句话理由"}"""
async def score_once(question, answer, model="gpt-4o-mini"):
resp = await client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role": "system", "content": RUBRIC},
{"role": "user", "content": f"问题:{question}\n回答:{answer}"},
],
)
text = resp.choices[0].message.content
# 容错:模型偶尔把 JSON 包在 markdown 里,正则兜底
try:
return json.loads(text)["score"]
except Exception:
m = re.search(r'"score"\s*:\s*(\d)', text)
return int(m.group(1)) if m else None
async def score_with_consistency(question, answer, n=3):
"""同一 case 采 n 次:返回众数分 + 离散度,用于一致性过滤"""
scores = [s for s in await asyncio.gather(
*[score_once(question, answer) for _ in range(n)]) if s]
if not scores:
return None, 99
mode = max(set(scores), key=scores.count)
agree = scores.count(mode) / len(scores)
return mode, 1 - agree # 离散度 > 1/3 的 case 标记进人工复核
跑全量时用 asyncio.Semaphore(10) 限并发,同一 prompt 前缀命中 cache 后,单条成本基本可以忽略。
一致性先于准确性:三个数字定生死
准确性(和人工的相关性)是每周校准一次的事,一致性是每次跑批都要看的事:
-
自洽性:同一 case 采 3 次,离散度超过 1/3(三票不统一)就进人工复核队列,不参与统计。judge 自己都拿不准的 case,分数没有意义。
-
相关性:每周抽 50 条做人工标注,算 Cohen's kappa。kappa 掉到 0.6 以下说明 rubric 失效或 judge 模型该换,先修协议再谈门禁。
-
回归 diff:门禁不设绝对分阈值,设相对差阈值------新版本全量分数相对 baseline 的 Δmean 和成对胜率:
tests/test_prompt_regression.py
def test_new_prompt_not_worse_than_baseline():
new = load_results("latest.jsonl")
base = load_results("baseline.jsonl") # 上次发布时的结果快照
delta = new["score"].mean() - base["score"].mean()
win_rate = pairwise_win_rate(new, base) # 成对比较中新的胜出比例
assert delta >= -0.3, f"平均分下降 {delta:.2f}"
assert win_rate >= 0.45, f"成对胜率过低 {win_rate:.2%}"
用 Δ 不用绝对分的原因:judge 模型一升级,绝对分整体从 3.8 平移成 4.1 是常有的事,不代表质量变好;而同批次内新旧对比不受尺度漂移影响,baseline 快照存 JSONL 就是为了这个。
踩坑记录
- 用被测模型当 judge:自评比第三方模型平均高 0.3~0.5 分,等于没测。换成第三方模型后 kappa 从 0.4x 提到 0.6x,这是单次改动收益最大的一次。
- 小模型 judge 对中文长文 rubric 遵循差:7B 级模型经常漏掉"简洁性"维度,只盯事实维度。评测这类任务至少用旗舰模型的降配版。
- temperature=0 不是银弹:解码路径仍有随机性,长答案尤其明显。必须多次采样取众数,顺便拿到了自洽性信号。
- 评测集要分级:冒烟 50 条(commit 级,只跑新增样例)、回归 500 条(nightly)、全量 2000 条(周级)。全量每次跑成本从几十元降到个位数,频率和成本才匹配得上。
- 记录元数据:每条结果存 judge 模型版本、prompt hash、温度。没有元数据的分数无法复盘,模型一换历史结果全部作废。
- JSON 解析必须容错:模型偶尔把 JSON 包在 ```markdown 代码块里,正则兜底 + 失败重试一次,否则跑批会莫名中断。
收尾
Judge 评测的本质是用协议换置信度:偏差靠设计规避、趋势靠回归对比、置信度靠一致性统计。先修协议再换模型,是投入产出比最高的路径。进阶方向有三个:一是可验证信号优先------检索命中、引用可溯、工具执行成功这类能程序化断言的事,别交给 judge,judge 只判主观质量;二是把 judge 任务收敛成偏好数据,微调一个专属小 judge,延迟和成本降一个量级;三是人工抽检从固定比例改成自适应,一致性差的 case 自动提高抽检率。