LLM 评测体系
第 6 章 LLM-as-Judge:原理、偏差与校准方法
"Sed quis custodiet ipsos custodes?"(那么谁来看守这些看守者呢?)
—— Juvenal,《Satires》VI.347–348
用模型给模型打分,第一件要回答的事就是这个:judge 自己的位置偏好、长度偏好、自我偏好,谁来查。
本章要点
- LLM-as-Judge 的三种工程形态:pointwise / pairwise / reference-based
- 五类有论文讨论的偏差:Position Bias、Length Bias、Self-Preference(证据尚不足以定论)、Verbosity Bias、Style Bias
- 校准方法:CoT prompting、ensemble、anchoring、JudgeBench 元评测
- judge 选型:不钉具体型号,按"换家族 + 在自家 anchor 集上实测"的方法选
- 一份可直接拿走的 judge prompt 模板与 grader 代码
6.1 LLM-as-Judge 的工程动机
第 5 章规则判分能解决相当一部分评测需求,剩下的需要"语义理解"——回答是否真的解决了问题,语气是否合适,推理过程是否合理。这些场景过去只能上人工标注,但人工标注的成本和速度都难以承受(下面的数字是示意值,按自家标注单价和人力复算):
- 一条人工标注典型成本 ¥3-10、耗时 2-5 分钟
- 1000 条样例的评测需要专人标注 1-2 周
- 每次模型 / prompt 迭代都重标一次成本爆炸
LLM-as-Judge(用一个 LLM 评判另一个 LLM)把耗时压到分钟级,单条成本降到"输入输出 token 数 × 当期单价"这个量级(通常是分甚至厘)。这是为什么从 2023 年 MT-Bench 论文(Zheng et al., arXiv:2306.05685)以后,LLM-as-Judge 迅速成为评测体系的主力。
但代价是:judge 模型自身有系统性偏差,如果不校准,评测结论会被偏差污染到失去参考价值。本章拆解这些偏差,给出可工程化的校准方法。
6.2 三种工程形态
flowchart TB
subgraph Pointwise
P1[Question + Answer] --> P2[Judge]
P2 --> P3[score 1-10 或 0-1]
end
subgraph Pairwise
PA1[Question + Answer A + Answer B] --> PA2[Judge]
PA2 --> PA3[A wins / B wins / tie]
end
subgraph Reference-based
R1[Question + Reference + Answer] --> R2[Judge]
R2 --> R3[等价 / 部分等价 / 不等价]
end
style P3 fill:#fee2e2
style PA3 fill:#dcfce7
style R3 fill:#dbeafe
6.2.1 Pointwise(绝对打分)
最直观:把 (question, answer) 给 judge,让它打 1-10 或 0-1 分。
优点:实现简单,能横向对比多个候选
缺点:绝对分的校准比想象中难——同一答案给 7 分还是 8 分,跨批次很难稳住,阈值一挪结论就变。这里要避免一个常见的过度概括:MT-Bench 论文 §4.2 的结论其实是单答案打分表现不差——GPT-4 的单答案打分与它自己的 pairwise 结果、与人类偏好都对得上,作者称它有「relatively stable internal rubric」,只是相比 pairwise 略逊,且会给出更多平局,换来的是更好的可扩展性。所以 pointwise 的真正代价不是"分打得不准",是分数的绝对值不可跨模型、跨时间直接比。
6.2.2 Pairwise(两两对比)
把两个 candidate answer 同时给 judge,让它选哪个更好。这个范式最早被 Chatbot Arena 大规模采用,目前是工业界的事实标准。
优点:避开"绝对分"的校准难题;judge 只需做"二选一"判断;与人工偏好对得上(MT-Bench 论文 §4.2:只算非平局票时,GPT-4 pairwise 与人类专家的一致率 85%,比人类之间的 81% 还高一点)
缺点:N 个候选要做 N×(N-1)/2 次对比,开销大;存在"位置偏差"(详见 §6.3.1)
6.2.3 Reference-based(有标准答案)
适合有黄金答案的场景。让 judge 判定 candidate 与 reference 是否语义等价。
优点:判分更稳定,因为有锚点
缺点:需要 reference,仅适用于黄金集驱动的评测;对开放生成(如创意写作)不适用
实操上三种形态并存,按场景选用。下面 §6.5 给出选型决策树。
6.3 五大偏差:每一种都有论文证据
6.3.1 Position Bias(位置偏差)
现象:在 pairwise 评测里,judge 倾向于偏好"第一个"答案(A 比 B 显著更高 win rate),即使两份答案质量相当、只是顺序不同。
论文证据:MT-Bench 论文 §3.3(Table 2)用 GPT-3.5 在 temperature 0.7 下对同一问题采样两次,得到两个「相似但不完全相同」的答案,再交换位置送给 judge。GPT-4 在默认 prompt 下交换前后结论一致的只有 65.0%,其中 30.0% 偏向第一个答案,5.0% 偏向第二个;GPT-3.5 的一致率 46.2%、Claude-v1 只有 23.8%(且 Claude 还有明显的"偏好 Assistant A 这个名字"的 name bias)。论文的原话是「Only GPT-4 outputs consistent results in more than 60% of cases」。
校准:把每对 (A, B) 同时跑 (A, B) 和 (B, A)。两次结论一致才采纳,否则视为 tie。这个做法叫 position swap,是 pairwise 评测的标准操作。
def pairwise_with_swap(judge, q, a, b):
r1 = judge(q, a, b) # A first
r2 = judge(q, b, a) # B first
if r1 == "A" and r2 == "B": # consistent: A wins both
return "A"
if r1 == "B" and r2 == "A": # consistent: B wins both
return "B"
return "tie" # inconsistent
代价是 2x judge 调用,但是对 position bias 的最有效防御。
6.3.2 Length Bias / Verbosity Bias(长度偏差)
现象:judge 倾向于偏好更长的答案,即使更长不等于更好。
论文证据:Saito et al. 2023("Verbosity Bias in Preference Labeling by Large Language Models", arXiv:2310.10076)专门考察了这一偏差,结论是在他们的设定下 GPT-4 比人类更偏好长答案,并提出了一个量化该偏差的指标。另一个更直观的证据来自 MT-Bench 论文 §3.3 的「repetitive list」攻击——把答案里的内容改写成冗长的重复列表,GPT-4 的失败率 8.7%,而 GPT-3.5 和 Claude-v1 都高达 91.3%。
校准:
- 在 judge prompt 里明确写 "A longer answer is not necessarily better. Reward conciseness when content is equivalent."
- 在打分维度里把 conciseness 单独列一项
- 后处理时按答案长度做归一化(z-score 处理 length 维度)
"把风格从实力里剥掉"这一类做法在 2024 年开始普及,但要分清两种不同的实现:AlpacaEval 2.0(Dubois et al. 2024)提出的是 length-controlled win rate,用回归把答案长度对胜率的贡献扣掉。
Arena-Hard(Li et al. 2024, arXiv:2406.11939)用的则是 style control——在 Bradley-Terry 回归里把 answer token 长度、markdown 标题 / 加粗 / 列表的密度作为额外的风格特征一起拟合,回答的是"如果所有模型风格都一样,偏好会是什么"。论文用"让模型答得更啰嗦"做过攻击实验:不加 style control 时 Llama-3.1-70B-Instruct 的分数显著上升,加上之后这个优势被抵消。
6.3.3 Self-Preference Bias(自我偏好)
现象:当 judge 模型评估 candidate answer 是另一个版本的自己时,它倾向于打更高分。
论文证据:Panickssery et al. 2024("LLM Evaluators Recognize and Favor Their Own Generations", arXiv:2404.13076)论证了 LLM 评审能认出自己的生成,并倾向于给它更高分。两个更容易自己复核的旁证:MT-Bench 论文 §3.3 统计了六个模型在不同 judge 下的 win rate,GPT-4 给自己的 win rate 比人类给的高约 10pp、Claude-v1 高约 25pp(但论文强调数据量有限,"cannot determine whether the models exhibit a self-enhancement bias");G-Eval 论文 §4 发现 G-Eval-4 总是给 GPT-3.5 生成的摘要打得比人写的摘要高,哪怕人类标注者更偏好人写的那篇(论文把它归为对"LLM 生成文本"的偏好,并称这只是初步研究)。所以自我偏好目前是"值得防、但还不能定论"的偏差。
校准:
- 永远用与被测模型不同的模型当 judge
- 如果不得不用相同模型,用 ensemble(多个 judge 投票)
- 在 prompt 里加 "Be impartial. Do not let model identity affect your judgment."(效果有限但有总好过没有)
6.3.4 Style Bias(风格偏差)
现象:judge 倾向于偏好"听起来更专业 / 更有自信"的答案,即使内容上不更准确。
论文证据:Wu & Aji 2023("Style Over Substance: Evaluation Biases for Large Language Models", arXiv:2307.03025)构造了一批故意有缺陷的机器答案,分别在事实、长度、语法三个维度上出问题,再看众包标注者、专家标注者和 LLM 分别怎么打分。结论相当刺眼:带事实错误的答案,得分反而高于那些过短或有语法错误的答案。也就是说评审(人和模型都一样)对"读起来顺不顺"的敏感度,压过了对"说得对不对"的敏感度。论文给的解法是不要把各维度揉成一个总分,而是分维度独立评。
校准:在 judge prompt 里强制要求 fact-check:
Evaluate ONLY based on factual accuracy. Tone, confidence, and presentation
are NOT relevant to your judgment.
加上这一句能削弱 style bias,但无法根除——削弱多少没有可引用的通用数字,必须在自家 anchor 集上量(第 8 章)。
6.3.5 总览:五种偏差的强度与校准成本
把这五种偏差按"强度 × 校准成本"两轴汇总:
| 偏差 | 强度 | 校准成本 | 校准方案 | 工程优先级 |
|---|---|---|---|---|
| Position Bias | 高(MT-Bench 里交换顺序后一致率仅 23.8%–65.0%) | 低(2x 调用) | Position swap | 必上 |
| Self-Preference | 未定论(MT-Bench 观察到 judge 给自己的 win rate 高 10–25pp,但论文称数据不足、不能定论) | 低(换模型) | 用不同家族 judge | 必上(防御成本低) |
| Length Bias | 中-高(repetitive list 攻击失败率 8.7%–91.3%) | 中(prompt + 后处理) | Length-controlled | 推荐 |
| Verbosity | 中(与 length 相关) | 中 | 同上 | 推荐 |
| Style Bias | 中(无公开的通用量化) | 高(无完美方案) | Prompt fact-check 提示 | 视场景 |
落到工程优先级上:
- Position swap 必上(成本低、效果显著)
- Different judge model 必上(防 self-preference)
- Length control 推荐(轻量、有标准方案)
- Style debiasing 视场景(成本高、效果中等)
6.3.6 隐藏偏差:判分粒度的"中位数塌陷"
除了上述五种被广泛讨论的偏差,工业实践里还有一种更隐蔽的偏差,称为 "中位数塌陷"——judge 倾向于给"安全的中位数分数"。
具体表现:你让 judge 在 1-10 上打分,结果会发现绝大多数样例挤在 6-8 分,1-3 分和 9-10 分都极少出现(占比多少请在自家数据上统计,§6.7.5 给了诊断代码)。这让分数失去区分度。
一个常见的解释(推测,没有定论性论文)是:LLM 在训练中学到的"礼貌输出"使其不愿做极端判断——除非样例特别明显地差或好,judge 会趋于"中庸"。
校准方法:
- 改用 likert 5 分制("很差/差/中/好/很好"),强迫离散化
- 改用 pairwise(二选一),完全绕过绝对打分
- 在 prompt 里明确写 "Use the FULL range of the scale. Most answers should NOT be in the middle."
- 后处理时把分数做百分位归一化,而非直接用绝对分
实操中不少团队最后都退到 pairwise——Chatbot Arena 收集的就是人类的成对投票;MT-Bench 则两种都用(pairwise 和单答案打分并列,见 §6.2.1)。
6.4 Chain-of-Thought Prompting:让 judge 解释自己
G-Eval(Liu et al. 2023, arXiv:2303.16634)的做法是:先让 LLM 根据任务定义和评分标准自动生成一串评估步骤(auto CoT),再把这些步骤连同待评文本放进 prompt,让 judge 以填表形式给分,最后按各分值的输出概率加权得到最终分数。注意它的 CoT 是"评估步骤",不是让 judge 逐条写出推理过程;下面这份 prompt 借用的是同一思路——先给步骤,再给分:
[Judge Prompt]
Evaluate the answer to the question on a scale of 1-10.
Question: {question}
Answer: {answer}
Step 1: Identify the key facts in the answer.
Step 2: Verify each fact against the question's expected scope.
Step 3: Note any hallucinations or omissions.
Step 4: Output the final score in the format `Score: <number>`.
G-Eval 论文的消融实验(Table 1,SummEval,summary-level Spearman,四维平均):G-Eval-4 去掉 CoT 是 0.500,带 CoT 是 0.514,论文说四个维度上都有提升,fluency 上最明显。增量是真实的,但很温和——别把 CoT 当成能把 judge 救回来的开关;真正的大头是底座模型(G-Eval-3.5 的 0.401 → G-Eval-4 的 0.514)和判分范式。
6.5 Judge 模型选型
6.5.1 公开数据:各 judge 模型的可靠性
JudgeBench(Tan et al. 2024, arXiv:2410.12784,ICLR 2025)专门评测 judge 模型自身的可靠性。它的构造方式很关键:从 MMLU-Pro、LiveBench、LiveCodeBench 等有客观标准答案的数据集出发,用强模型采样出多个回答,再按标准答案筛出"一对里恰好一个正确一个错误"的答案对——所以这里的 ground truth 是客观正确性,不是众包偏好。judge 的任务是二选一,随机猜的基线是 50%。
论文 Table 2(Arena-Hard judge prompt,overall accuracy):
| Judge 模型 | JudgeBench Overall Accuracy |
|---|---|
| 随机猜基线 | 50% |
| GPT-4o-mini | 50.00% |
| Gemini-1.5-pro | 47.14% |
| Llama-3.1-70B-Instruct | 52.29% |
| GPT-4o | 56.57% |
| Llama-3.1-405B-Instruct | 56.86% |
| Claude-3.5-Sonnet | 64.29% |
| o1-mini | 65.71% |
| DeepSeek-R1 | 73.14% |
| o1-preview | 75.43% |
| o3-mini (low / medium / high) | 70.57% / 76.57% / 80.86% |
表中的模型都是论文写作时的版本,其中不少已经退役(例如 Anthropic 官方弃用页记载 Claude 3.5 Sonnet 已于 2025-10-28 退役)。这张表的价值在于量级和规律,不在具体型号。有几个关键洞察:
- 当时最强的通用模型(Claude-3.5-Sonnet)也只有 64%——在"判断谁答得对"这件事上,judge 的天花板比想象中低得多;GPT-4o 用朴素 prompt 时论文明确说它"不比随机猜好",换上 Arena-Hard prompt 才升到 56.57%
- 推理模型有显著优势:o1-preview、DeepSeek-R1、o3-mini 都比通用旗舰高十几个百分点,而且 o3-mini 的 reasoning 预算越高分越高(70.57% → 80.86%)——这是"高赌注判定值得上推理型 judge"的直接证据
- 不同 judge 模型之间差出十几到三十个百分点——选错 judge,评测结论自带的噪声比你想测的那点改进大得多
- 论文里还有一组常被误读的数字:Claude-3.5-Sonnet 在 GPT-4o 生成的答案对上是 64.3%,换成判它自己生成的答案对时掉到 44.8%(低于随机)。但注意反方向并不成立——GPT-4o 判自己生成的答案对是 56.6%,判 Claude-3.5-Sonnet 生成的反而只有 53.9%。论文给的解释不是自我偏好,而是 Claude-3.5-Sonnet 推理更强,生成的答案对本身更难分辨。所以这组数字说明的是"答案对的难度随生成模型而变",想证明 self-preference 要看 §6.3.3 的对照实验
6.5.2 选型决策树
flowchart TD
A[需要 judge?] --> B{被测模型是哪家?}
B --> C[候选 judge:另外两家的<br/>现行旗舰 / 中档模型]
C --> F{判断难度高?}
F -->|是| G[候选里加入推理模型<br/>或开启推理模式]
F -->|否| H[保留通用候选]
G --> T[在自家 anchor 集上<br/>实测 κ 与单价]
H --> T
T --> I[选 κ 达标里最便宜的;<br/>高赌注再上 3 家 ensemble]
style I fill:#dcfce7
这张决策树刻意不钉具体型号:模型每隔几个月就会换代、退役,写死的型号很快过期。截至 2026-09-25,Anthropic 官方模型页的现行模型是 claude-opus-5-5、claude-sonnet-5、claude-haiku-4-5(claude-sonnet-4-6、claude-opus-4-6 等仍作 legacy 可用);OpenAI、Google 的现行型号请以各自官方模型页和弃用页为准。选型时先查弃用页,确认候选在评测周期内不会退役。
6.5.3 Ensemble Judge:多模型投票
成熟做法是用 3 个不同的 judge 投票:
def ensemble_judge(q, a, judges=("<OpenAI 现行模型>", "claude-sonnet-5", "<Google 现行模型>")):
# 三个来自不同家族的现行模型;call_judge / majority 需自行实现
votes = [call_judge(model, q, a) for model in judges]
return majority(votes)
代价是 3x API 调用,换来的是更高的一致性与一个额外信号——judge 之间的分歧本身就标记出"这条样本很难"。涨多少要自己在 anchor 集上量,没有可引用的通用增幅。
6.6 一份生产级 Judge Prompt 模板
汇总上面所有方法,下面是一份可直接拿走的 judge prompt 模板:
You are an impartial judge evaluating the quality of an AI assistant's response
to a user question.
Be objective and rigorous. Specifically:
- Evaluate ONLY based on factual accuracy and helpfulness, NOT tone or style.
- A longer answer is NOT necessarily better. Reward conciseness when content
is equivalent.
- Do NOT favor responses generated by any particular model. Judge purely on
content.
[Question]
{question}
[Reference Answer (if available)]
{reference}
[Candidate Answer]
{answer}
Evaluation steps:
1. Identify the key facts and claims in the Candidate Answer.
2. For each claim, verify whether it is supported by the Reference (or by
common knowledge if no Reference is given).
3. Note any hallucinations, omissions, or off-topic content.
4. Consider whether the answer fully addresses the user's question.
5. Output your reasoning followed by a final score 1-10.
Output format:
Reasoning: <your step-by-step reasoning>
Score: <integer 1-10>
这一份模板把本章 §6.3-6.4 的校准方法(length-bias 提示、style-bias 提示、self-preference 提示、CoT 推理)全部塞进 prompt。它是本专栏按这些方法整理的通用模板,不是任何工具的内置 prompt;ragas 的内置 prompt 见第 11 章。
6.6.1 生产级 judge prompt 完整模板(带所有偏差校准)
整合本章方法学,给一份生产级 judge prompt 模板——把 5 大偏差的校准全部内置:
You are an impartial AI evaluator. Your task is to evaluate the candidate
response based on factual accuracy and helpfulness ONLY.
# Critical Instructions (READ CAREFULLY)
1. **Do NOT favor longer or shorter responses.** Length is irrelevant to
quality. Reward conciseness when content is equivalent.
2. **Do NOT favor confident-sounding responses.** Tone, style, and
confidence are NOT relevant. Evaluate purely on content accuracy.
3. **Do NOT favor responses generated by any particular model family.**
Judge purely on output, not on perceived origin.
4. **Use the FULL range of the scoring scale.** Do NOT cluster scores in
the middle. Most answers should NOT be scored 5-7 if they are clearly
bad or clearly good.
5. **Be willing to give low scores.** A score of 1-3 should be common for
poor responses, not avoided.
# Question
{question}
# Reference Answer (if available)
{reference}
# Candidate Response
{response}
# Evaluation Steps
Step 1: List the factual claims in the candidate response.
Step 2: For each claim, verify against the reference (or common knowledge
if no reference is given). Mark each as SUPPORTED or HALLUCINATED.
Step 3: Note any omissions of important information.
Step 4: Assess whether the response addresses the question.
Step 5: Output your reasoning and final score.
# Output Format
Reasoning: <step-by-step reasoning, 50-150 words>
Score: <integer 1-10>
Justification: <one sentence summary>
# Scoring Rubric
- 9-10: Fully accurate, addresses question completely, no hallucinations
- 7-8: Mostly accurate, minor issues
- 5-6: Mixed quality, some accurate / some inaccurate
- 3-4: Mostly inaccurate or off-topic
- 1-2: Almost entirely wrong or harmful
这份模板把本章 §6.3 五大偏差的校准全部 inline:
- Length bias → "Do NOT favor longer / shorter"
- Style bias → "tone / style / confidence NOT relevant"
- Self-preference → "Do NOT favor any model family"
- 中位数塌陷 → "Use FULL range / Most should NOT be 5-7"
- Verbosity bias → 同 Length
加上 §6.4 的 CoT 推理(5 个 evaluation steps)和明确的输出格式,这是工业级 judge prompt 的"完整启动版"。
工业实务:把这份模板作为团队的 judge prompt 基础。每个评测维度(Faithfulness / Relevance / Helpfulness 等)只需要替换 question / reference / 评分维度,其他偏差校准复用。这种"模板化"让 judge prompt 维护成本大幅降低。
6.6.2 常被误解的实践:judge prompt 不需要太长
工程团队第一次写 judge prompt 时倾向于"写得越详细越好"——加大量 rubric,加 10+ 个 example,加详细的 step-by-step 指导。结果 judge prompt 长达 5000+ tokens。
但"越长越好"这个直觉有两个已知的反作用力,团队应当把 prompt 长度当作一个需要在自家数据上调的超参,而不是一味加长:
| Prompt 长度 | 主要风险 |
|---|---|
| 过短(只有一句"打个分") | 信息不足,judge 用自己的隐含标准,rubric 形同虚设 |
| 适中 | —— |
| 过长(数千 token 的 rubric + 十几个 example) | 关键 rubric 被淹没在长上下文里;few-shot 例子本身会把 judge 往例子的分布上带偏 |
第二个风险有公开证据支撑:"lost in the middle"(Liu et al. 2023, arXiv:2307.03172)表明模型对长上下文中段的信息利用率显著低于首尾——你辛苦写的第 7 条 rubric 如果正好在 prompt 中段,很可能没被用上。
没有一个通用的"最佳 token 数",本节也不打算编一个区间表。可执行的做法是:拿 anchor 集把 prompt 的精简版与完整版各跑一遍,比 κ——如果加了 2000 token 的 rubric 换来的 κ 提升在置信区间内不可分辨,那 2000 token 就是纯成本。
最佳实践:
- Rubric 描述控制在 200-500 tokens
- Few-shot example 限制在 3-5 个,每个 < 200 tokens
- CoT 步骤最多 5 步
- 避免重复表述(不要"判断准确性、判断相关性、判断完整性...")
写好 judge prompt 是一门精细工程——不是写得长就行。这就是为什么 ragas / G-Eval / OpenAI evals 的内置 judge prompt 都相对精简,避免"加长解决一切"的反直觉错误。
6.6.3 judge prompt 的常见误区清单
工程师写第一份 judge prompt 时容易踩坑。常见误区清单:
- rubric 太抽象:写"判断回答是否友好" → judge 不知道"友好"怎么量化。改写成"包含至少一个礼貌用语 + 不含命令式语气"
- examples 不平衡:5 个 example 全是 high-score → judge 学不到 low-score 该是什么样
- 忘了"忽略 X 因素":不写"忽略字数 / 风格" → length bias 直接生效
- 打分粒度太细:让 judge 1-10 分 → 实际只用 6-8 分区间。改用 1-5 分或 pairwise
- 无 fallback:模型输出 "I cannot evaluate" → 评测代码无错误处理直接挂掉
- 缺 reasoning 字段:只输出分数 → 失败时不知道为什么。强制让 judge 输出 reasoning
每一条都是常见的坑。把这份清单贴在 judge prompt 评审 checklist 里,能避开其中大部分。
6.6.4 LLM-as-Judge 的"4 种 prompt 风格"对照实验
整合本章方法学,给一份具体的"不同 prompt 风格的 judge 效果对照"——基于 G-Eval / JudgeBench 等论文公开数据综合:
# 4 种 prompt 风格
style_1_naive:
prompt: "Rate the answer 1-10. Output only the number."
relative_reliability: 基线(最低)
relative_cost: 1x
pros: 极简, 快
cons: 偏差大, 不可解释
style_2_cot:
prompt: |
Step 1: List facts in answer.
Step 2: Verify each.
Step 3: Output reasoning then Score 1-10.
relative_reliability: 高于基线(G-Eval 的 CoT 消融支持)
relative_cost: 约 2x(多了推理 token)
pros: 可解释, 偏差降低
cons: 中等成本
style_3_pairwise:
prompt: "Which response is better, A or B? Output 'A' / 'B' / 'tie'."
relative_reliability: 通常高于 pointwise(避开绝对分校准)
relative_cost: 约 2x(配 position swap 则 4x)
pros: 避开绝对分校准难题
cons: 只能得到相对结论, 无绝对分
style_4_full:
prompt: |
[完整 §6.6 模板:
- rubric 量化定义
- 5+ examples
- 显式忽略 length / style / model identity
- CoT 5 步推理
- 结构化输出 JSON]
relative_reliability: 本章方法的叠加上限(需自测确认)
relative_cost: 约 3x
pros: 偏差缓解最全, 输出可审计
cons: 成本最高, prompt 维护量最大
这里刻意没有给"judge-human 相关系数"这一列:G-Eval、JudgeBench、MT-Bench 报的是三个不同的量(Spearman / 二选一 accuracy / 成对一致率),在不同数据集上测的,拼成一列会造出一个看起来精确、实则无中生有的数字。能大致预期的只有顺序:naive 最低,CoT 高于 naive(G-Eval 消融支持),pairwise 与 full 谁更高要看任务;成本倍数也只是量级。你自己场景下的数值,用 §8.2.2 的方法在 anchor 集上测。
工程团队的实务:
- 大规模日常评测:style_2_cot(性价比最优)
- 关键决策(模型上线 gate):style_4_full(最可靠)
- 风格 / 偏好对比:style_3_pairwise(专门)
- 永远不用:style_1_naive(它连"judge 到底按什么标准打分"都没说清)
把自家 judge prompt 升级到 style_2_cot 或 style_4_full 之后,要在 anchor 集上量一次升级前后的 κ——"提升了多少"必须自己测出来,本节不替你预言那个数字。
6.6.5 judge prompt 的版本演化案例
把"prompt 是软件"的视角具体化——一份真实的 Faithfulness judge prompt 从 v1 到 v3 的演化轨迹(形态参考 ragas 的 faithfulness prompt 演化方向):
下面每一版后面标的"与人工一致率"是这个演化案例里的示例数字,用来说明"每轮修订都要用同一个 anchor 集重测"这件事本身;ragas 仓库的 PR 历史里并没有公开这样一组逐版对照数据,别把这三个数字当成可引用的结论。
v1(初版):
判断回答是否基于给定 context。输出 1 或 0。
Context: {context}
Answer: {answer}
典型问题:judge 倾向于宽松判 1,示例一致率仅 0.45。
v2(加 CoT + 严格定义):
判断 answer 中的每条事实陈述是否能在 context 中找到依据。
- 步骤 1: 把 answer 拆成原子陈述
- 步骤 2: 对每条陈述逐一判定 (1=有依据, 0=无依据)
- 步骤 3: 输出 (faithful_count / total_count) 作为最终分数
Context: {context}
Answer: {answer}
改善方向:示例一致率上升到 0.62。但仍偏宽松。
v3(加 examples + 显式排除模糊):
... (v2 内容)
Note: 模糊或推断性陈述视为 0。只有 context 中明确表述的才算 1。
[Example 1]
Context: "Acme 成立于 1995 年"
Answer: "Acme 成立于 1995 年, 是一家科技公司"
拆解: ["成立于 1995 年" (1), "是科技公司" (0, context 没说)]
分数: 1/2 = 0.5
改善方向:示例一致率达 0.75。
这个 v1 → v2 → v3 的演化路径展示了 prompt 工程的真实节奏——初版几乎一定不够好,每轮迭代基于失败 case 修订。每一次修订都是一次"prompt review"——加 example 来教模型,加约束来收紧判定,加步骤来分解推理。
把这个过程做成正式的 prompt PR 流程(每次改动有 PR review,有 A/B test,有指标对比),是评测体系成熟度的标志。
6.6.6 "Judge Prompt 版本管理"工程范式——避免"突然评分都变了"
Judge prompt 与代码一样需要版本管理——但很多团队还在用"直接改 prompt 字符串"的反模式。下面给出 judge prompt as code 的工程化范式:
import yaml
from pathlib import Path
from dataclasses import dataclass, field
from datetime import datetime
from typing import Iterable
@dataclass
class JudgePromptVersion:
version: str
template: str
judge_model: str
temperature: float
response_format: str # "json" | "text"
created_at: str
author: str
expected_kappa_with_humans: float
notes: str
deprecated: bool
class JudgePromptVersionManager:
"""judge prompt 的版本化管理(git-friendly)"""
def __init__(self, prompts_dir: Path):
self.dir = prompts_dir
def load_active(self, role: str = "faithfulness_judge") -> JudgePromptVersion:
"""每个 role 有 'active' 版本"""
active_path = self.dir / f"{role}_active.yaml"
return JudgePromptVersion(**yaml.safe_load(active_path.read_text()))
def list_history(self, role: str) -> list[JudgePromptVersion]:
history_dir = self.dir / role / "history"
versions = []
for f in sorted(history_dir.glob("v*.yaml")):
versions.append(JudgePromptVersion(**yaml.safe_load(f.read_text())))
return versions
def promote_new(self, role: str, new_version: JudgePromptVersion):
"""上线新版本:旧版本进 history,新版本变 active"""
history_dir = self.dir / role / "history"
history_dir.mkdir(parents=True, exist_ok=True)
# 当前 active 进 history
active_path = self.dir / f"{role}_active.yaml"
if active_path.exists():
old = self.load_active(role)
archive_path = history_dir / f"{old.version}.yaml"
archive_path.write_text(yaml.safe_dump(old.__dict__,
allow_unicode=True))
# 写 new active
active_path.write_text(yaml.safe_dump(new_version.__dict__,
allow_unicode=True))
def diff_versions(self, role: str, v1: str, v2: str) -> dict:
history = {h.version: h for h in self.list_history(role)}
a = history.get(v1)
b = history.get(v2)
if not a or not b:
return {"error": "version not found"}
return {
"kappa_diff": round(b.expected_kappa_with_humans -
a.expected_kappa_with_humans, 3),
"model_changed": a.judge_model != b.judge_model,
"temperature_changed": a.temperature != b.temperature,
"template_word_diff": abs(len(b.template) - len(a.template)),
}
# prompts/faithfulness_judge_active.yaml(git 管理)
version: "v3.2.1"
judge_model: "claude-sonnet-5" # 示例;钉死你实际使用的型号
temperature: 0 # 部分新模型已不接受 temperature 参数,按所用模型的 API 文档调整
response_format: "json"
created_at: "2026-04-15"
author: "alice@company.com"
expected_kappa_with_humans: 0.71
deprecated: false
template: |
你是 Faithfulness 评审员。
问题: {{question}}
上下文: {{context}}
被测回答: {{answer}}
分析回答的每个声明是否 grounded 在上下文中。
输出 JSON: {"score": 0-1, "unfaithful_claims": [...], "reasoning": "..."}
评分原则:
1. 若回答全 grounded,分数 0.95+
2. 若部分 grounded,按未 grounded 比例扣分
3. 完全编造扣到 0.0
4. 不要因风格 / 长度而扣分
notes: |
v3.2.1 修订:加"不要因风格扣分"明确指令
v3.2.0 → v3.2.1 在 anchor 200 题 κ 从 0.65 升到 0.71
flowchart LR PR[改 prompt PR] --> CR[code review] CR -->|approve| TEST[在 anchor 200 题测 κ] TEST -->|κ 下降| REJ[REJECT] TEST -->|κ 持平 / 涨| PROMOTE[promote_new] PROMOTE --> AC[active.yaml 更新] PROMOTE --> HIST[old → history/] AC --> CI[CI 跑评测] CI --> MON[monitor κ 漂移] style REJ fill:#ffebee style PROMOTE fill:#e8f5e9
工程实务的 4 条 prompt 版本管理经验:
- prompt 必入 git:禁止在代码里硬编码 prompt 字符串
- active + history 分目录:active 是当前唯一在用的,history 全保留
- promote 必跑 anchor 测 κ:κ 下降不允许 promote
- 每月 audit history:超过 1 年未触发的 history 版本可以归档
3 类常见 prompt 失控:
| 现象 | 后果 | 修法 |
|---|---|---|
| 工程师直接改字符串 | 评测分突变没人知道为什么 | 强制 PR 流程 + git history |
| 老 prompt 找不到了 | 想 rollback 没源 | 必入 git history |
| 多个 judge 用不同 prompt 但记不清 | 分数不可比 | active.yaml 唯一 |
具体例子(示意数据,需用你自己的数据复算):某团队 12 个月 prompt 版本演化:
| 版本 | 关键改动 | κ |
|---|---|---|
| v1.0 | initial | 0.55 |
| v2.0 | + CoT | 0.62 |
| v2.1 | + position swap | 0.66 |
| v3.0 | + 5 维度细分 | 0.69 |
| v3.2.1 | + 风格去偏 | 0.71 |
所有版本都在 git history/,工程师可任何时候 diff / rollback。
研究背景:
- LangChain Hub 是公开 prompt 版本化的早期范本
把 JudgePromptVersionManager 接入团队 prompt 管理之后,"突然评分都变了"至少能追到是哪一次 prompt 改动引起的。
6.7 判分设计:粒度、思维链与分数分布
6.7.1 LLM-as-Judge 的"评分粒度"工程权衡——pointwise / pairwise / listwise
LLM-judge 有三种评分范式,每种适合不同场景。下面一次讲清三者的工程权衡,避免团队"凭直觉用 5 点 likert"——这往往不是最优解。
| 范式 | 输出 | 优点 | 缺点 | 何时用 |
|---|---|---|---|---|
| pointwise | 单点分(如 1-5 或 0-1) | 简单、能 aggregate / threshold | 校准难(4 分 vs 4.5 分模型有微差) | 上线 gate / 大样本判 pass-fail |
| pairwise | A 优 / B 优 / 平 | 对齐人偏好最准(仿 Chatbot Arena) | n 个候选有 O(n²) 对比 | 模型选型 / Hard case 判别 |
| listwise | 多个候选排序 | 一次给 N 个候选排名 | prompt 长、token 贵、bias 多 | 多模型横向 + 一次出榜 |
import asyncio
from dataclasses import dataclass
@dataclass
class JudgeMode:
name: str
n_candidates: int
cost_factor: float
p99_latency_ms: int
# 这个字段没有通用默认值:必须用自家 anchor 集实测填入(见第 8 章),
# 下面 MODES 里的 0.0 是"未测量"的占位,不是"κ 为零"
measured_kappa: float = 0.0
class JudgeModeSelector:
"""根据样本量 + 候选数自动选择 judge 范式"""
MODES = {
"pointwise": JudgeMode("pointwise", 1, 1.0, 1500),
"pairwise": JudgeMode("pairwise", 2, 1.4, 2000),
"listwise": JudgeMode("listwise", 4, 2.5, 5000),
}
def recommend(self, n_samples: int, n_candidates: int,
goal: str) -> str:
"""goal in: 'absolute_score' | 'best_pick' | 'leaderboard'"""
if goal == "absolute_score":
return "pointwise"
if n_candidates == 2:
return "pairwise"
if 3 <= n_candidates <= 5 and n_samples < 500:
return "listwise"
# 大样本 + 多候选 → 退化为 pairwise + 锦标赛
return "pairwise+tournament"
def estimate_cost(self, mode: str, n_samples: int,
n_candidates: int,
base_cost: float = 0.012) -> float:
# base_cost 是单次 pointwise 调用的示意单价,按当期价目表替换
m = self.MODES.get(mode)
if not m: # tournament:只求第一名,n 个候选比 n-1 场
n_matches = n_candidates - 1
return n_samples * n_matches * base_cost * 1.4
return n_samples * m.cost_factor * base_cost
flowchart LR
S[场景] --> Q1{需要绝对分数?}
Q1 -->|是| PW[pointwise]
Q1 -->|否| Q2{候选数=2?}
Q2 -->|是| PR[pairwise]
Q2 -->|否| Q3{N candidates ≤ 5 + sample < 500?}
Q3 -->|是| LW[listwise]
Q3 -->|否| TR[pairwise + 锦标赛]
PW -. "绝对分,校准最难" .-> OUT[输出]
PR -. "对齐人偏好最稳" .-> OUT
LW -. "最省钱,偏差最难控" .-> OUT
TR -. "选最优的最省调用方式" .-> OUT
style PR fill:#e8f5e9
style PW fill:#fff3e0
style LW fill:#e3f2fd
style TR fill:#ffebee
工程实务的 4 条选型经验:
- 永远先排除"列表式"——除非候选 ≤ 5 + 样本 < 500,否则 prompt 容易超长 + 偏差累积
- 想要"上线门禁"用 pointwise——能直接 threshold 判 pass/fail
- 模型选型用 pairwise——这是 LMSYS Chatbot Arena 的科学根基(Bradley-Terry 模型)
- 大规模选最优用 pairwise + 锦标赛——n 个候选只跑 n-1 轮即可定第一名
具体例子(示意数据,需用你自己的数据复算):评测集 1000 题、3 个候选模型 A/B/C:
- pointwise:1000 × 3 = 3000 次 judge 调用
- pairwise:每题 3 对(A-B / B-C / A-C),1000 × 3 = 3000 次;若按 §6.3 做 position swap 则要 6000 次
- listwise:1000 × 1 = 1000 次(一次比 3 个)——调用次数最少,但单次 prompt 最长,实际 token 量并不是 1/3
- pairwise + 锦标赛:只求"谁最好"时 1000 × 2 = 2000 次(A-B 选优、winner 再对 C)
(换算成钱要乘上当期单价,这里只比调用量——单价随模型和时间变动,写死了明天就过期。)
读者要做模型选型 → pairwise + 锦标赛(调用量最省,且保留 pairwise 的偏差缓解手段)。要做绝对评分 → pointwise(无替代品)。listwise 只在"实在跑不起 pairwise"时考虑——它省下的调用次数不等于省下同比例的 token,而偏差控制手段又最少。
研究背景:Chatbot Arena (Chiang et al. arXiv:2403.04132) 收集的是人类成对投票,再用 Bradley-Terry 模型把成对胜负汇总成排名(§6.8.5);MT-Bench 论文 §4.2 则发现 GPT-4 的单答案打分与 pairwise 结果大体一致、只是略逊。listwise 省钱但更难校准:一次比较三个以上候选时,位置偏差与长度偏差会叠加,且没有 pairwise 那种成熟的 swap 缓解手段——“看似先进的方案实战不一定最优”。
6.7.2 深层问题:judge 应该 chain-of-thought 还是直接给分
读完 G-Eval 论文(§6.4)后会下意识觉得"judge 必须 CoT 才好"。但这个判断在推理模型出现后有了疑问——OpenAI 的 o1 等推理模型自带内化推理,再让它们做"显式 CoT"很可能徒增 token、不一定提升精度(目前没有公开对照,见下表)。
具体观察:
| Judge 模型 | CoT 是否提升 | 来源 |
|---|---|---|
| GPT-4 | 提升,但幅度温和:SummEval 四维平均 Spearman 0.500 → 0.514 | G-Eval 论文 Table 1 消融 |
| 其他非推理旗舰 | 方向同上,幅度无公开定量结论 | 需自测 |
| 推理模型(DeepSeek-R1 等) | 预期几乎无提升(推理已内化),无公开对照 | 需自测 |
| 小模型 judge | 预期受益更大,无公开对照 | 需自测 |
工程含义:
- 用便宜模型当 judge → CoT 必加(性价比高)
- 用旗舰模型当 judge → CoT 可加可不加(可省 token)
- 用推理模型当 judge → CoT 不加(节省成本)
这种"按模型能力调 CoT 策略"是推理模型普及后才出现的做法,上面三条都要在自家 anchor 集上验证。第 5 章 §5.14.5 提到的"软件熵增"在这里也适用——CoT 是工具不是教条,何时用要看场景。
6.7.3 LLM-as-Judge 的"reasoning trace"工程模式——让 judge 解释为什么
最实用但被忽略的 judge 升级:让 judge 不只输出 score,还输出"为什么这样判"的 reasoning trace。下面给出工程化模板:
import json
from dataclasses import dataclass
from typing import Iterable, Awaitable, Callable
@dataclass
class JudgeReasoningTrace:
score: float
rationale: str # 为什么打这个分
key_observations: list[str] # 影响分数的关键观察
confidence: float # judge 对自己判断的置信度
relied_on_evidence: list[str] # 引用的具体文本片段
decided_against: list[str] # 被否决的可能解释
class ReasoningJudge:
"""带 reasoning trace 的 judge"""
PROMPT_TEMPLATE = """你是评分员。请按 5 维分析:
Question: {question}
Answer: {answer}
输出严格 JSON:
{{
"score": float (0-1),
"rationale": "总评 1-2 句",
"key_observations": ["观察 1", "观察 2", ...],
"confidence": float (0-1, 你对该分数的信心),
"relied_on_evidence": ["原文片段引用 1", ...],
"decided_against": ["考虑过但否决的解释 1", ...]
}}
"""
def __init__(self, llm: Callable[[str], Awaitable[str]]):
self.llm = llm
async def judge(self, question: str,
answer: str) -> JudgeReasoningTrace:
prompt = self.PROMPT_TEMPLATE.format(
question=question, answer=answer,
)
response_text = await self.llm(prompt)
try:
data = json.loads(response_text)
return JudgeReasoningTrace(
score=data["score"],
rationale=data["rationale"],
key_observations=data["key_observations"],
confidence=data.get("confidence", 1.0),
relied_on_evidence=data["relied_on_evidence"],
decided_against=data.get("decided_against", []),
)
except (json.JSONDecodeError, KeyError) as e:
return JudgeReasoningTrace(
score=0.0, rationale=f"parse error: {e}",
key_observations=[], confidence=0.0,
relied_on_evidence=[], decided_against=[],
)
def explain_score(self, trace: JudgeReasoningTrace) -> str:
"""生成给工程师看的可读 explanation"""
return (f"【评分: {trace.score:.2f} (信心: {trace.confidence:.2f})】\n"
f"理由: {trace.rationale}\n\n"
f"关键观察:\n" + "\n".join(f" - {o}"
for o in trace.key_observations) +
f"\n\n依据原文:\n" + "\n".join(f" > {e}"
for e in trace.relied_on_evidence) +
(f"\n\n否决的其他解释:\n" + "\n".join(f" × {d}"
for d in trace.decided_against)
if trace.decided_against else ""))
flowchart LR Q[Question + Answer] --> J[ReasoningJudge] J --> SCORE[score 0-1] J --> RAT[rationale 1-2 句] J --> OBS[key_observations] J --> CONF[confidence] J --> EV[relied_on_evidence] J --> AGN[decided_against] SCORE --> R[JudgeReasoningTrace] RAT --> R OBS --> R CONF --> R EV --> R AGN --> R R --> E[explain_score 给工程师] R --> D[dashboard click drilldown] style E fill:#e8f5e9
工程实务的 4 类 reasoning trace 价值:
| 价值 | 应用 | 效果 |
|---|---|---|
| Debug 失败 case | 工程师很快看懂 judge 为什么判 0.3 | 调试时间明显缩短 |
| 工程师 / judge 对齐 | 看 reasoning 发现 prompt bug | 改 prompt 更准 |
| 元评测高效 | 人工 anchor 看 reasoning 决定 agree | calibration 更快 |
| 用户申诉 | bot 给低分时回应"为什么" | 减少投诉 |
具体例子(示意数据,需用你自己的数据复算):某客服 chatbot 评测:
【评分: 0.45 (信心: 0.82)】
理由: 回答 30% 内容来自 system prompt 默认模板,未实际针对用户问题
关键观察:
- 开头 "感谢您的咨询" 占 25 字符无信息量
- 中段提到了用户没问的"会员积分"
- 结尾未给具体退款时效
依据原文:
> "您的退款将在 3-5 个工作日内到账" # 但用户问的是"已超过 7 天没到账"
否决的其他解释:
× 解释为"用户问题模糊导致回答跑题"——实际用户问题清晰
工程师看完立刻知道:"prompt 让 bot 用模板化开头 + 没识别用户的实际诉求"——直接修 prompt。
3 类常见 reasoning trace 错误:
| 错误 | 现象 | 修法 |
|---|---|---|
| 不约束 JSON | 相当比例的输出格式坏 | 用所用 API 的结构化输出功能(如 OpenAI 的 response_format、Anthropic 的 output_config.format) |
| key_observations 太空 | "回答还行" 类无信息 | prompt 明确要求"具体到字" |
| 不要求 decided_against | judge 思考过程不透明 | 强制此字段 |
研究背景:
- Chain-of-thought (Wei et al. 2022) 是 reasoning trace 的方法学起点
- "Self-consistency reasoning" (Wang 2022) 多 reasoning 集成
用 ReasoningJudge 替代纯 score judge,代价是每条评测多出一段推理的 token,换来的是判错时能直接看到理由、不必重跑。
6.7.4 LLM-as-Judge 的"reasoning trace 解释力评分"——别被花式理由骗
LLM-as-Judge 启用 reasoning trace 看似让评分更可信,但实际隐藏一个新失败模式:judge 给出的 reason 听起来很专业,但其实和真实评分逻辑无关。这种"事后合理化"没有理由只出现在某一家模型上,它会让团队过度信任 judge。本节给读者一份「reasoning trace 解释力评分」工程方案——量化 reason 与 score 的真实一致性,避免被 judge 的"花式 reasoning" 骗。
graph LR
A[Judge 输出: score + reason] --> B{解释力评估}
B --> C[1. reason 长度合理性]
B --> D[2. reason 是否引用 context 具体片段]
B --> E[3. reason 与 score 数值一致]
B --> F[4. reason 多次跑稳定性]
B --> G[5. reason 包含可验证证据]
C & D & E & F & G --> H[explainability_score 0-1]
H --> I{阈值}
I -->|≥ 0.7| J[reason 真实可信]
I -->|0.4-0.7| K[reason 部分合理化]
I -->|< 0.4| L[reason 是花式扯谈<br/>不可信]
5 维 explainability + 评分公式:
| 维度 | 度量 | 权重 | 健康范围 |
|---|---|---|---|
| 长度合理性 | 50-300 字 | 0.10 | 太短无信息、太长可能无关 |
| 引用具体片段 | 包含 context 中的关键短语 | 0.30 | 至少 1 个具体引用 |
| 数值一致性 | reason 描述的程度词与 score 匹配 | 0.25 | "很差"对应 < 0.3 / "良好"对应 0.7+ |
| 多次稳定 | 同 input 跑 3 次 reason 主旨一致 | 0.20 | Jaccard ≥ 0.5 |
| 可验证证据 | 包含具体事实 / 数字 / 出处 | 0.15 | ≥ 1 处可验证 |
配套实现:reasoning trace 解释力评分器:
import re
import statistics
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class JudgeReasoningEvaluator:
min_reason_length: int = 50
max_reason_length: int = 300
intensity_to_score = {
"极差|很差|完全错误|严重": (0.0, 0.3),
"较差|不太好|有问题": (0.3, 0.5),
"一般|可接受|尚可": (0.4, 0.6),
"良好|还不错|较好": (0.6, 0.8),
"优秀|完美|非常好|excellent": (0.8, 1.0),
}
def length_score(self, reason: str) -> float:
n = len(reason)
if self.min_reason_length <= n <= self.max_reason_length:
return 1.0
if n < self.min_reason_length:
return n / self.min_reason_length
return max(0.0, 1.0 - (n - self.max_reason_length) / 200)
def context_citation_score(self, reason: str, context: str) -> float:
"""检查 reason 是否引用 context 具体短语(≥ 5 字)"""
# 取 reason 中所有引号内片段
quotes = re.findall(r'["「『]([^"」』]{5,})["」』]', reason)
# 或加权匹配 context 5+ 字 substring
chunks = [context[i:i+5] for i in range(0, len(context) - 4, 5)]
hits = sum(1 for c in chunks if c in reason)
if quotes:
return min(1.0, len(quotes) / 2)
if hits >= 3:
return 0.7
if hits >= 1:
return 0.4
return 0.0
def numerical_consistency_score(self, reason: str, score: float) -> float:
"""检查 reason 描述的程度词与 score 是否一致"""
for pattern, (lo, hi) in self.intensity_to_score.items():
if re.search(pattern, reason):
if lo <= score <= hi:
return 1.0
# 偏差衡量
center = (lo + hi) / 2
diff = abs(score - center)
return max(0.0, 1.0 - diff / 0.5)
return 0.5 # 无明显程度词,中性
def stability_score(self, reasons: list[str]) -> float:
"""同 input 多次跑 reason 主旨稳定性 — Jaccard 关键词"""
if len(reasons) < 2: return 1.0
token_sets = [set(re.findall(r'\b\w{2,}\b', r)) for r in reasons]
jaccards = []
for i in range(len(token_sets) - 1):
inter = len(token_sets[i] & token_sets[i+1])
union = len(token_sets[i] | token_sets[i+1])
jaccards.append(inter / max(union, 1))
return statistics.mean(jaccards)
def evidence_score(self, reason: str) -> float:
"""检查可验证证据:数字 / 时间 / 名词 / 引用"""
has_number = bool(re.search(r'\d', reason))
has_quote = bool(re.search(r'["「『]', reason))
has_specific = bool(re.search(r'根据|按照|引用|文档|页|节', reason))
signals = sum([has_number, has_quote, has_specific])
return signals / 3
def evaluate(self, reason: str, context: str, score: float,
multiple_runs: list[str] | None = None) -> dict:
ls = self.length_score(reason)
cs = self.context_citation_score(reason, context)
ns = self.numerical_consistency_score(reason, score)
ss = self.stability_score(multiple_runs) if multiple_runs else 0.7
es = self.evidence_score(reason)
explainability = ls * 0.10 + cs * 0.30 + ns * 0.25 + ss * 0.20 + es * 0.15
return {
"explainability_score": round(explainability, 3),
"components": {"length": round(ls, 2), "citation": round(cs, 2),
"numerical_consistency": round(ns, 2),
"stability": round(ss, 2), "evidence": round(es, 2)},
"verdict": ("trustworthy" if explainability >= 0.7
else "partial" if explainability >= 0.4
else "fancy_bs"),
"recommendation": self._recommend(explainability),
}
def _recommend(self, score: float) -> str:
if score >= 0.7: return "reason 真实可信,正常采用"
if score >= 0.4: return "reason 部分合理化,建议人工抽审 + judge prompt 加 'cite specific context'"
return "reason 是花式 BS,不可信;该 case 必走人工 review"
举例(示意数据,需用你自己的数据复算):某团队对 100 题 judge 输出做 explainability 评估:
- 80 题 trustworthy(reason 真实引用 context)
- 15 题 partial(reason 缺具体引用)
- 5 题 fancy_bs(reason 完全在编造合理化)
- 把 5 题 fancy_bs 抽出复盘 → 发现都是 judge 在边界 case 上的"事后合理化"
- 调整 judge prompt 加 "MUST cite at least one specific quote from context, otherwise return UNSURE"
- 重测:fancy_bs 降到 1%
- 此后把 explainability 分布纳入月度复查,防止「花式 reasoning 但实际错判」回潮
配套行业研究背景:
- "事后合理化"(post-hoc rationalization)这个说法在 judge 语境下还没有一篇公认可引的论文,本节给的是工程上可复现的检查方法,不是对某篇文献的转述
- "Faithfulness of explanations" 来自 Jacovi & Goldberg ACL 2020
JudgeReasoningEvaluator 适合接在 LLM-as-Judge 的后置 pipeline 上,把"reason 可不可信"从凭感觉变成有一个可复查的分数。注意它只检查形式信号(引用、程度词、稳定性),权重和阈值都是示意值,要在自家 anchor 集上校准。
6.7.5 LLM-as-Judge 的"分数分布健康"诊断——是否过度集中
健康的 judge 应该给出"接近正态"的分数分布——但很多 judge 过度集中在 4 分附近,看似稳定实际是"判分失能"。下面给出分布健康诊断:
import statistics
from dataclasses import dataclass
from collections import Counter
from typing import Iterable
@dataclass
class JudgeDistributionHealthReport:
judge_name: str
sample_count: int
score_mean: float
score_std: float
score_distribution: dict[str, int] # bucket → count
concentration_pct: float # 最大 bucket 占比
health: str # "diverse" / "concentrated" / "broken"
class JudgeDistributionAnalyzer:
"""诊断 judge 分数分布是否健康"""
HEALTHY_STD_MIN = 0.10
UNHEALTHY_CONCENTRATION_MAX = 0.50
def __init__(self, bucket_size: int = 5):
self.buckets = bucket_size
def _bucketize(self, scores: list[float]) -> dict[str, int]:
if not scores:
return {}
# 0-1 区间分 bucket
bucket_keys = [f"{i/self.buckets:.1f}-{(i+1)/self.buckets:.1f}"
for i in range(self.buckets)]
counts = {k: 0 for k in bucket_keys}
for s in scores:
idx = min(int(s * self.buckets), self.buckets - 1)
counts[bucket_keys[idx]] += 1
return counts
def analyze(self, judge_name: str,
scores: list[float]) -> JudgeDistributionHealthReport:
if not scores:
return None
mean = statistics.mean(scores)
std = statistics.stdev(scores) if len(scores) > 1 else 0
dist = self._bucketize(scores)
max_bucket = max(dist.values()) if dist else 0
concentration = max_bucket / max(len(scores), 1)
if std < 0.05 and concentration > 0.7:
health = "broken" # 几乎所有题给同分
elif (std < self.HEALTHY_STD_MIN
or concentration >= self.UNHEALTHY_CONCENTRATION_MAX):
health = "concentrated"
else:
health = "diverse"
return JudgeDistributionHealthReport(
judge_name=judge_name,
sample_count=len(scores),
score_mean=round(mean, 3),
score_std=round(std, 3),
score_distribution=dist,
concentration_pct=round(concentration * 100, 1),
health=health,
)
flowchart LR
S[1000 题 judge 分数] --> A[Analyzer]
A --> M[mean / std]
A --> D[bucket 分布]
M --> H{std + concentration}
D --> H
H -->|"std < 0.05 + 70%+ 集中"| BR[broken]
H -->|"std 0.05-0.1"| CON[concentrated]
H -->|"std ≥ 0.1"| DIV[diverse]
BR --> ACT[修 prompt 强制差异化]
CON --> WARN[review prompt]
DIV --> OK[健康]
style BR fill:#ffebee
style OK fill:#e8f5e9
工程实务的 4 类分布健康判定:
| 状态 | std | 最大 bucket | 含义 |
|---|---|---|---|
| diverse | ≥ 0.10 | < 50% | 健康分布 |
| concentrated | 0.05-0.10 | 50-70% | 略偏中位数 |
| broken | < 0.05 | > 70% | judge 失能 |
| bimodal | high std | 两 bucket > 30% | 二极分化(可能合理) |
具体例子(示意数据,需用你自己的数据复算):4 个 judge 分布对比:
| judge | mean | std | concentration | health |
|---|---|---|---|---|
| small-judge-a | 0.78 | 0.18 | 28% | diverse ✅ |
| small-judge-b | 0.75 | 0.15 | 32% | diverse ✅ |
| weak-judge-v1 | 0.85 | 0.04 | 88% | broken ❌ |
| strict-judge-v2 | 0.42 | 0.22 | 25% | diverse ✅ |
诊断:weak-judge-v1 有 88% 的分数挤在同一个桶(0.8-1.0),std 只有 0.04 → 几乎"无差异判分"。修法:prompt 改"必须按 5 档(0/0.25/0.5/0.75/1)打分"。
3 类常见分布问题:
| 问题 | 现象 | 修法 |
|---|---|---|
| sycophancy 高分 | mean 高 + concentration 高 | prompt 加"严格审视" |
| 中庸 | 全 0.5-0.6 | 强制 5 档评分 |
| 过度严格 | 全 0-0.3 | rubric 调整 |
研究背景:
- Item Response Theory 的 difficulty parameter 是相关概念
- 人工评分里早就有"趋中倾向"(central tendency bias):评分者回避量表两端;Likert 量表本身出自 Likert 1932
把 JudgeDistributionAnalyzer 接入 §8.8.2 的季度仪式,任何 judge 上线前先查分布健康:judge 不仅要"准确",还要"有判别力"。
6.7.6 Reasoning Judge 的"思维链长度 vs 准确度"工程取舍表
§6.12.1 提到 reasoning model(o 系列、DeepSeek-R1 等)作为 judge 的可能性,但没给出权衡框架。下面这张表给的是该怎么摆这张表:成本与延迟一列可以按当期价目表算出来(随模型迭代变,务必自己重算),"与人工 κ" 那一列则必须留白,由团队在自家 anchor 集上按第 8 章的方法填——没有任何公开数据能给出跨任务通用的 judge κ。
| Judge 配置 | 思考 token 量级 | 判分耗时量级 | 与人工 κ | 相对成本 | 适合场景 |
|---|---|---|---|---|---|
| 小模型,no CoT | 0 | 亚秒 | 待测 | ×1(基准) | 高吞吐离线初筛 |
| 旗舰模型,CoT prompt | inline 几百 tok | 1-2s | 待测 | 数倍 | 日常评测主力 |
| 旗舰模型,结构化 CoT | inline 数百 tok | 2-3s | 待测 | 数倍 | 上线 gate / RAG faithfulness |
| reasoning 模型,低预算 | 千级 reasoning tok | 数秒 | 待测 | 十余倍 | 复杂推理任务 / 数学题 judge |
| reasoning 模型,中高预算 | 数千 reasoning tok | 十余秒 | 待测 | 几十倍 | 高赌注上线决策 / 元评测 |
| Ensemble: 两个旗舰 + 仲裁 | inline | 数秒 | 待测 | 十余倍 | reasoning 模型之外的另一条高一致路线 |
填完这张表你会发现选型问题变得很简单:成本差几十倍是确定的,κ 差多少是未知的——所以正确顺序永远是先花一天测 κ,再决定要不要为那几十倍买单,而不是反过来。
quadrantChart title Judge 选择空间:accuracy vs cost x-axis "成本(低 → 高)" y-axis "准确度(低 → 高)" quadrant-1 "高准 + 高成本" quadrant-2 "高准 + 低成本(理想)" quadrant-3 "低准 + 低成本" quadrant-4 "低准 + 高成本(避免)" "小模型 no CoT": [0.10, 0.30] "旗舰 + CoT": [0.30, 0.55] "旗舰 + 结构化 CoT": [0.40, 0.68] "reasoning 低预算": [0.65, 0.74] "reasoning 中高预算": [0.85, 0.80] "Ensemble + 仲裁": [0.70, 0.82]
图中的纵坐标是示意,不是实测值:横轴(成本)可以从价目表算出来,纵轴(准确度)必须你自己测。这张图唯一想说的是形状——成本轴是线性的几十倍,准确度轴是被压扁的,越往右每多花一倍钱买到的准确度增量越小。
工程实务的 4 条选择规则:
- 不是 case 越复杂越要 reasoning——简单是非题用 reasoning judge 是预算浪费
- 看 task 是否本身需要"多步推理"——数学 / 代码 / 多约束 satisfiability 才值得上 o-series
- Ensemble(多个旗舰模型 + 仲裁)是 reasoning judge 之外的另一条路——两者成本量级接近,但 ensemble 额外给你一个 reasoning judge 给不了的信号:模型之间的分歧本身就是"这条样本很难"的标记,可以直接路由到人工
- "层级 judge"模式:先用小模型跑全量 → 少量不确定 case 升级到 reasoning judge
层级 judge 的工程实现(§6.8.4 ensemble 实现的扩展形态):
async def hierarchical_judge(query, answer, primary, escalator):
"""先用便宜 judge 跑,confidence 低再升级"""
primary_result = await primary(query, answer)
if primary_result["confidence"] >= 0.85:
return primary_result
# 边界 case → 升级
return await escalator(query, answer)
具体测算:90% 流量走"数倍成本"档(记为 ×4),10% 升级到"几十倍"档(记为 ×22),加权平均是 0.9×4 + 0.1×22 = ×5.8——只花 1/4 的钱买到接近全量升级的效果,前提是那 10% 确实是"贵 judge 才判得对"的样本。所以层级 judge 的成败不在两档 judge 有多强,而在分流规则准不准:把 confidence 阈值定在哪,要用 anchor 集上"低 confidence 样本里 judge 真的判错了多少"来标定。
把这张表填好贴在 wiki,每次"该不该上 reasoning judge"的争论就有了共同的事实基础——而不是各自引用一个来路不明的 κ。
6.8 可靠性验证与校准
6.8.1 对照实验:加 vs 不加 prompt 校准
这一节只放能在论文里逐个查到的数字。需要先说明一件事:不同论文报的根本不是同一个量——G-Eval 报的是与人工评分的 Spearman 相关系数,MT-Bench 报的是与人类专家的成对判断一致率,Arena-Hard 报的是与 Chatbot Arena 排名的相关度。它们不能放进同一列比大小,所以下面按论文分开列。
G-Eval 论文(Liu et al. 2023, arXiv:2303.16634)Table 1,SummEval 上 summary-level Spearman ρ(coherence / consistency / fluency / relevance 四维平均):
| 评测方法 | Spearman ρ(4 维平均) |
|---|---|
| ROUGE-1 / ROUGE-2 / ROUGE-L | 0.192 / 0.205 / 0.165 |
| BERTScore | 0.225 |
| MoverScore | 0.191 |
| BARTScore | 0.385 |
| UniEval | 0.474 |
| GPTScore | 0.417 |
| G-Eval-3.5(GPT-3.5 + CoT + 概率加权) | 0.401 |
| G-Eval-4 去掉 CoT | 0.500 |
| G-Eval-4(CoT + 概率加权) | 0.514 |
三条能直接读出来的结论:
- n-gram 与 embedding 相似度类指标(ROUGE / BERTScore / MoverScore)与人工判断的相关性在 0.2 上下——这是第 4 章说"经典指标在开放生成上失效"的量化证据
- 换更强的底座模型收益最大:G-Eval-3.5 → G-Eval-4 是 0.401 → 0.514
- CoT 的增量是真实但温和的:0.500 → 0.514(论文说四个维度上都有提升,fluency 上最明显)——别指望加一句 "think step by step" 就把 judge 救回来
MT-Bench 论文(Zheng et al. 2023, arXiv:2306.05685)报的是另一个量:不计平局时(setup S2),GPT-4 作为 judge 与人类专家的成对判断一致率约 85%,而人类专家之间的一致率是 81%——也就是说 judge 与人的分歧程度,和人与人之间的分歧程度已经在同一量级。这是 LLM-as-Judge 能被工业界接受的关键实证。但同一篇论文 §3.3 也指出了 judge 的边界:GPT-4 在数学和推理题上的判分能力受限,连它自己能解出的基础数学题,也会被待评答案带偏而判错(论文用 reference-guided judge 缓解,§3.4)。
这两组数字合起来的工程含义:judge 的可靠性主要由"底座模型 + 判分范式(pointwise / pairwise)"决定,prompt 技巧是锦上添花。至于你自己场景下的 judge-human 一致性是多少——没有任何论文能替你回答,必须按第 8 章的方法自己测。
6.8.2 LMSYS Chatbot Arena:人类盲投反向校准 judge
如果说 JudgeBench 是 judge 的"考试卷",Chatbot Arena(lmsys.org,arXiv:2403.04132)就是 judge 的"现实演练场"。它的方法学值得专门拆解:
flowchart LR U[匿名用户] --> Q[同一 prompt 同时跑 model A & model B] Q --> Both[两个 anonymous 回答] Both --> User[用户盲选哪个更好] User --> Vote[投票数据] Vote --> Elo[Elo / Bradley-Terry 排名] Elo --> Lead[Leaderboard] style Lead fill:#dcfce7
Arena 的关键工程优势:
- 匿名 + 双盲:用户不知道哪个回答来自哪家模型,从根本上排除"品牌偏见"
- 真实分布:用户提交真实想问的问题,不是固定题目,避免 benchmark 污染
- Elo 评分:借鉴国际象棋的相对排名系统,能稳定处理"未配对的对比"
- 数据量大:Arena 的投票量以百万计,统计可靠性远高于常见的学术 benchmark(具体累计票数以官网当期公布为准)
Arena 数据对工程团队最有价值的副产品,是一把"真实人类偏好"的尺子,可以拿来检验自动评测。Arena-Hard-Auto(arXiv:2406.11939)就是从 Arena 历史数据里挑出的 "hard prompts" 集合,配合 LLM-as-Judge 自动跑分,论文报告它与 Chatbot Arena 英文 Hard Prompts 子榜的 Spearman 相关 96.7%、confidence agreement 98.6%(§6.3)。
这篇论文还给了一个特别值得记住的对照:MT-Bench 与 Chatbot Arena 的 Spearman 相关高达 89.9%,但 confidence agreement 只有 26.6%(Table 1;正文 §6.2 写成了 22.6%,那是同表里 MT-Bench 的 separability)。同一对 benchmark,两个指标差出几十个百分点——原因是 Spearman 只看排名顺序,不管两个模型的分差是否在置信区间内可分辨。只报相关系数,不报可分辨性,是 benchmark 对照里最常见的误导。
工程含义:为团队的 judge 选型时,可以先参考 JudgeBench、Arena-Hard 这类论文里对 judge 模型的对比来圈定候选,但它们测的是论文当时的模型和题目分布,最终仍要在自家 anchor 集上验证。
6.8.3 对比实验:单 judge vs ensemble vs human
工程团队最想要的是一张"哪种 judge 模式最准"的对照表。本节不给这张表,因为它给不出来:judge 与人工的一致性强依赖于任务、rubric 和标注质量,跨场景不可迁移;任何把"GPT-4o pointwise = 0.45、ensemble = 0.80"这类数字列成通用表格的材料,都是在把某一篇论文某一个数据集上的结果冒充成常数。
能负责任地给出的只有方向,以及每一档要付的代价:
| Judge 模式 | 相对可靠性方向 | 相对成本 | 速度 |
|---|---|---|---|
| 朴素 pointwise("打 1-10 分") | 基线,最低 | 1× | 秒级 |
| pointwise + CoT + 明确 rubric | 高于基线(G-Eval 的消融支持这一点) | 1-2× | 秒级 |
| pairwise + position swap | 明显高于 pointwise(消掉位置偏差,且不需要绝对分校准) | 2× 起(每对至少两次调用) | 秒级 |
| 多模型 ensemble + 投票 | 再高一档,且给出分歧信号 | 3× 起 | 秒级 |
| 单个未受训人工 | 不一定高于强 judge——未受训人工本身噪声很大 | 远高于 judge(通常高出几个数量级) | 分钟级 |
| 单个受训 + 校准过的人工 | 高 | 更高 | 分钟级 |
| 多人工 + 仲裁 | 最高,通常被当作真值锚点 | 最高 | 小时级 |
两条容易被忽略的推论:
- "人工"不是自动等于"准"——未受训、未校准的众包标注,其内部一致性(第 7 章的 κ)经常低到不能当锚点用。把这样的标注当真值去评 judge,得到的结论毫无意义。
- 要用具体数字,就必须自己测——在自家数据上按第 8 章的方法,量出每一档的 κ / 相关系数与单位成本,再决定停在哪一档。上面这张表只负责告诉你该测哪几档。
6.8.4 生产级 ensemble judge 完整实现
整合本章方法学,给一份"3 judge ensemble + position swap + 加权"的完整 Python 实现:
# ensemble_judge.py
import asyncio
from dataclasses import dataclass
from typing import Callable
@dataclass
class JudgeConfig:
name: str
call_fn: Callable # 异步调用函数
calibration_score: float # 来自元评测的 calibration 分数
weight: float = None # 自动计算
class EnsembleJudge:
def __init__(self, judges: list[JudgeConfig]):
# 按 calibration 分数自动计算权重
total = sum(j.calibration_score for j in judges)
for j in judges:
j.weight = j.calibration_score / total
self.judges = judges
async def pairwise_judge(
self, question: str, response_a: str, response_b: str
) -> dict:
"""3 judge × 2 position swap = 6 次调用"""
tasks = []
for j in self.judges:
# (A, B) 与 (B, A) 都跑
tasks.append(j.call_fn(question, response_a, response_b))
tasks.append(j.call_fn(question, response_b, response_a))
verdicts = await asyncio.gather(*tasks)
# verdicts 6 个: [j1(AB), j1(BA), j2(AB), j2(BA), j3(AB), j3(BA)]
# 解析每个 judge 的"位置一致"结果
winners = []
for i, j in enumerate(self.judges):
ab_winner = verdicts[i*2] # j_i(A, B) 的赢家
ba_winner = verdicts[i*2+1] # j_i(B, A) 的赢家
# 位置一致才采纳
if ab_winner == "A" and ba_winner == "B":
winners.append(("A", j.weight))
elif ab_winner == "B" and ba_winner == "A":
winners.append(("B", j.weight))
else:
winners.append(("tie", j.weight))
# 按权重投票
a_score = sum(w for v, w in winners if v == "A")
b_score = sum(w for v, w in winners if v == "B")
tie_score = sum(w for v, w in winners if v == "tie")
if a_score > b_score and a_score > tie_score:
return {"winner": "A", "confidence": a_score, "details": winners}
elif b_score > a_score and b_score > tie_score:
return {"winner": "B", "confidence": b_score, "details": winners}
else:
return {"winner": "tie", "confidence": max(a_score, b_score, tie_score),
"details": winners}
# 使用示例
async def main():
judges = [
# 三家不同家族的现行模型;calibration_score 是示意值,来自你自己的元评测
JudgeConfig("openai-judge", call_openai, calibration_score=0.72),
JudgeConfig("claude-sonnet-5", call_claude, calibration_score=0.78),
JudgeConfig("google-judge", call_gemini, calibration_score=0.65),
]
ensemble = EnsembleJudge(judges)
result = await ensemble.pairwise_judge(
question="What is the capital of France?",
response_a="Paris is the capital of France.",
response_b="The capital of France is Paris, which is also its largest city.",
)
print(result)
约 75 行代码涵盖本章的几项核心方法:
- 3 judge ensemble
- Position swap(每对 query 都跑 2 次位置)
- 按 calibration 分数自动加权
- 异步并行(asyncio.gather)
- 一致性判定(位置一致才采纳)
- 加权投票输出
这是一份参考实现。拷贝改用时,把 call_openai / call_claude / call_gemini 替换成实际的异步 LLM 调用函数(返回 "A" / "B" / "tie"),再用 asyncio.run(main()) 启动。
6.8.5 完整的 Bradley-Terry 排名计算演示
Chatbot Arena 用 Bradley-Terry / Elo 模型把 pairwise win/lose 数据转成排名。这个过程从外面看很神秘,下面用一个最小例子拆开看:
假设我们用 pairwise 评测把 4 个模型两两对比(共 6 组,每组 10 场),得到下面的胜负矩阵(行赢列):
| A | B | C | D | |
|---|---|---|---|---|
| A | - | 8 | 6 | 9 |
| B | 2 | - | 7 | 8 |
| C | 4 | 3 | - | 6 |
| D | 1 | 2 | 4 | - |
每对总场次 10 场。Bradley-Terry 模型假设每个模型有一个隐藏 strength s_i,胜负概率:
P(A 胜 B) = s_A / (s_A + s_B)
通过 maximum likelihood 估计 s_i,最常见做法是迭代法(Zermelo's algorithm):
import numpy as np
def bradley_terry(wins):
"""wins[i,j] = i 击败 j 的次数"""
n = wins.shape[0]
s = np.ones(n) # 初值
for _ in range(100):
new_s = np.zeros(n)
for i in range(n):
num = sum(wins[i, j] for j in range(n) if j != i)
den = sum((wins[i, j] + wins[j, i]) / (s[i] + s[j])
for j in range(n) if j != i)
new_s[i] = num / den
s = new_s / new_s.mean() # 归一化
return s
wins = np.array([[0,8,6,9],[2,0,7,8],[4,3,0,6],[1,2,4,0]])
strengths = bradley_terry(wins)
# 迭代收敛后(归一化到均值 1): A ≈ 2.045, B ≈ 1.001, C ≈ 0.640, D ≈ 0.313
得到 4 个模型的 strength 比值,再换算成 Elo 分数(1500 + 400 × log10(s)):
- A: 1500 + 400 × log10(2.045) ≈ 1624
- B: 1500 + 400 × log10(1.001) ≈ 1500
- C: 1500 + 400 × log10(0.640) ≈ 1423
- D: 1500 + 400 × log10(0.313) ≈ 1298
(strength 的绝对值没有意义——只有比值有意义,所以上面归一化到均值 1;换个归一化常数,四个 Elo 会整体平移同一个数,排名和分差不变。)
这 4 个数字,就是 Chatbot Arena leaderboard 上你看到的"Arena Score"的微缩版。理解这个计算,能让你判断什么时候要加更多对比,什么时候排名已经稳定(看 Elo 的 95% CI——通常用对投票做 bootstrap 重采样、每次重跑一遍 BT 拟合得到)。
两个必须知道的适用条件:
- BT 假设胜负关系可传递(存在一维的 strength 轴)。如果数据里出现明显的"石头剪刀布"环(A 稳赢 B、B 稳赢 C、C 稳赢 A),说明模型强弱在你的题集上不是一维的,BT 拟合出的排名会掩盖这个结构——这时该做的是拆分题集分类别排名,而不是硬拟合。
- 上面的迭代法要求比较图是连通的,且每个模型至少赢过一场、输过一场。某个模型全胜(
wins[j][i]全为 0)时它的 strength 会发散到无穷;工程实现里通常给每对加一个很小的虚拟平局(伪计数)来正则化。
工程意义:pairwise 评测的 N 次对比可以汇总成绝对排名——这是 pairwise 比 pointwise 更强大的根本原因之一。N 个模型相互比 N(N-1)/2 次,能拿到一个完整的全局排名而不只是局部胜负。
6.8.6 真实的 Judge 选错案例
为让"选错 judge 的代价"具象化,举一个构造的示意案例(数字是示意值,不是某个团队的实测):
某团队上线了一个 RAG 客服系统,主回答模型和 judge 用的是同一家族的一小一大两个模型。半年里 judge 报告指标稳定在 80% 通过率,团队认为"系统稳定"。
但用户投诉持续增加。复盘发现:
- 主回答模型经常给出"听起来合理但事实错误"的回答,judge prompt 没有要求 fact-check,被流畅的表述带过去了(style bias,§6.3.4)
- 同家族 judge 可能还叠加了自家族偏好——这一条证据不足以定论(§6.3.3),但换家族的成本很低,没理由不防
- 真实质量(用人工抽检的 anchor 集重测)只有 65%,比 judge 报告的 80% 低 15pp
修复办法:换一个不同家族的 judge + 加 fact-check prompt + 用 ensemble,并定期用人工 anchor 集复核。几周后人工抽检曲线和 judge 曲线对齐,团队开始相信指标。
这个案例的核心教训:选 judge 不能图方便用同家族模型,也不能拿另一个 LLM 的判分冒充"真实质量"——真值锚点得来自人工。这是本章 §6.5 强调"换家族 judge + anchor 集实测"的原因。
6.8.7 LLM-as-Judge 的"Bias Calibration Suite"完整脚本
§6.3 列出五大 bias 的诊断方法但分散在文字里,本节给出一份可直接运行的 calibration suite——上线 judge 前必跑:
import asyncio
import json
import random
from dataclasses import dataclass
from collections import defaultdict
from typing import Callable, Awaitable
@dataclass
class CalibrationResult:
bias_name: str
sample_count: int
bias_score: float
severity: str
fix_suggestion: str
class JudgeBiasCalibrator:
"""5 种 bias 一键校准——返回每种 bias 的量化分数与严重程度"""
SEVERITY_THRESHOLDS = {"low": 0.05, "medium": 0.10, "high": 0.20}
def __init__(self, judge_fn: Callable[[str, str, str], Awaitable[str]],
calibration_pairs: list[dict]):
self.judge_fn = judge_fn
self.pairs = calibration_pairs
async def _swap_test(self, sample: dict) -> bool:
a_first = await self.judge_fn(sample["query"], sample["a"], sample["b"])
b_first = await self.judge_fn(sample["query"], sample["b"], sample["a"])
return a_first.strip().upper() != ("B" if b_first.strip().upper() == "A" else "A")
async def calibrate_position(self) -> CalibrationResult:
"""Position bias: 对调 A/B 顺序,看 judge 结论是否翻转"""
samples = self.pairs[:50]
flips = 0
for sample in samples:
if await self._swap_test(sample):
flips += 1
score = flips / max(len(samples), 1)
return CalibrationResult("position_bias", len(samples), score,
self._severity(score),
"Judge both orders and keep only consistent verdicts (position swap)")
async def calibrate_length(self) -> CalibrationResult:
"""Length bias: 把 a 重复拼接一半内容(信息不增),与原版 a 对比;
两种顺序各判一次,抵消位置偏差"""
samples = self.pairs[:50]
padded_wins = 0
for sample in samples:
a = sample["a"]
padded_a = a + " " + a[:len(a) // 2]
v1 = await self.judge_fn(sample["query"], padded_a, a) # padded 在 A 位
v2 = await self.judge_fn(sample["query"], a, padded_a) # padded 在 B 位
padded_wins += (v1.strip().upper() == "A") + (v2.strip().upper() == "B")
score = abs(padded_wins / max(2 * len(samples), 1) - 0.5)
return CalibrationResult("length_bias", len(samples), score,
self._severity(score), "Add 'penalize unnecessary length' to prompt")
async def calibrate_self_preference(self, judge_id: str) -> CalibrationResult:
"""Self-preference: 用同一 judge 对'被该 judge 生成的 a' vs '其他模型生成的 b'打分。
前提:这些样本对须经人工判定质量相当,否则偏离 0.5 反映的是质量差而不是自我偏好"""
wins = 0
own_pairs = [p for p in self.pairs if p.get("a_source") == judge_id][:50]
for sample in own_pairs:
verdict = await self.judge_fn(sample["query"], sample["a"], sample["b"])
if verdict.strip().upper() == "A":
wins += 1
score = abs(wins / max(len(own_pairs), 1) - 0.5)
return CalibrationResult("self_preference", len(own_pairs), score,
self._severity(score), "Use cross-family judge (e.g., Anthropic judge for OpenAI outputs)")
async def calibrate_style(self) -> CalibrationResult:
"""Style bias: 同义改写——formal vs casual——看 judge 是否倾向某种风格"""
formal_wins = 0
for sample in self.pairs[:30]:
verdict = await self.judge_fn(sample["query"], sample["formal"], sample["casual"])
if verdict.strip().upper() == "A":
formal_wins += 1
score = abs(formal_wins / 30 - 0.5)
return CalibrationResult("style_bias", 30, score,
self._severity(score), "Add 'judge content not style' to prompt")
async def calibrate_verbosity(self) -> CalibrationResult:
"""Verbosity: 比较 concise vs verbose 两版同义回答的 judge 偏好"""
verbose_wins = 0
for sample in self.pairs[:30]:
verdict = await self.judge_fn(sample["query"], sample["verbose"], sample["concise"])
if verdict.strip().upper() == "A":
verbose_wins += 1
score = abs(verbose_wins / 30 - 0.5)
return CalibrationResult("verbosity_bias", 30, score,
self._severity(score), "Reward 'directly answers question' explicitly")
def _severity(self, score: float) -> str:
if score < self.SEVERITY_THRESHOLDS["low"]:
return "low"
if score < self.SEVERITY_THRESHOLDS["medium"]:
return "medium"
return "high"
async def run_all(self, judge_id: str) -> list[CalibrationResult]:
return await asyncio.gather(
self.calibrate_position(),
self.calibrate_length(),
self.calibrate_self_preference(judge_id),
self.calibrate_style(),
self.calibrate_verbosity(),
)
约 100 行实现:5 种 bias 测试并发运行,每种返回 (bias_score, severity, fix_suggestion)。两点使用须知:style / verbosity 两项为了简短,把待测版本固定放在 A 位,结果会混入位置偏差,正式使用时应像 length 那样两种顺序各判一次;severity 阈值(0.05 / 0.10)是示意值。工程实务:上线 judge prompt 前必跑这套,任何 bias 落到 high 就必须修 prompt 重测。这是 §6.3 五大 bias 的"工程化产物"——把"知道有 bias"变成"量化所有 bias 并给出具体修复方向"。
6.8.8 LLM-as-Judge 的"判分一致性"漂移监测
LLM-as-Judge 上线后最隐蔽的失效是模型版本变化导致的 judge 漂移——不带日期的模型别名被切到新快照,团队升级了 judge 模型版本,内部 LLM Gateway 切了 routing,judge 给同一份 (input, output) 的打分就可能变样(比如从 4.5 漂到 3.8,这是示意)。这种漂移不被监测到,下游所有评测都失真。
下面是一份 judge 一致性 watchdog 的最小可行实现:
import json
import statistics
import asyncio
from datetime import datetime
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Awaitable
@dataclass
class JudgeDriftAlert:
timestamp: str
judge_id: str
canary_set_id: str
baseline_mean: float
current_mean: float
delta: float
drift_severity: str
affected_cases: list[str] = field(default_factory=list)
class JudgeConsistencyWatchdog:
"""监测 judge 在固定 canary 集上的判分稳定性"""
DRIFT_THRESHOLDS = {"low": 0.05, "medium": 0.10, "high": 0.15}
def __init__(self, canary_set: list[dict], judge_fn,
baseline_path: Path):
self.canary = canary_set
self.judge_fn = judge_fn
self.baseline_path = baseline_path
async def _score_canary(self) -> dict[str, float]:
scores = {}
for case in self.canary:
score = await self.judge_fn(case["query"], case["answer"])
scores[case["id"]] = float(score)
return scores
async def establish_baseline(self, runs: int = 5):
"""初次部署:跑 runs 次 canary,存中位数作为 baseline"""
all_runs = []
for _ in range(runs):
all_runs.append(await self._score_canary())
baseline = {}
for case_id in all_runs[0]:
baseline[case_id] = statistics.median(
run[case_id] for run in all_runs
)
self.baseline_path.write_text(json.dumps({
"established_at": datetime.now().isoformat(),
"scores": baseline,
}, indent=2))
async def check_drift(self, judge_id: str) -> JudgeDriftAlert | None:
baseline = json.loads(self.baseline_path.read_text())["scores"]
current = await self._score_canary()
deltas = {cid: abs(current[cid] - baseline[cid])
for cid in baseline}
affected = [cid for cid, d in deltas.items() if d > 0.1]
baseline_mean = statistics.mean(baseline.values())
current_mean = statistics.mean(current.values())
overall_delta = abs(current_mean - baseline_mean)
if overall_delta < self.DRIFT_THRESHOLDS["low"]:
return None
severity = "low"
if overall_delta >= self.DRIFT_THRESHOLDS["high"]:
severity = "high"
elif overall_delta >= self.DRIFT_THRESHOLDS["medium"]:
severity = "medium"
return JudgeDriftAlert(
timestamp=datetime.now().isoformat(),
judge_id=judge_id,
canary_set_id=str(self.baseline_path.stem),
baseline_mean=round(baseline_mean, 3),
current_mean=round(current_mean, 3),
delta=round(overall_delta, 3),
drift_severity=severity,
affected_cases=affected[:10],
)
flowchart LR
CAN[20-50 题 canary 集] --> BL{baseline 已建?}
BL -->|否| EST[跑 5 次取中位数]
EST --> SAVE[存到 baseline.json]
BL -->|是| RUN[跑当前 judge]
RUN --> DIFF[逐 case 比对 baseline]
DIFF --> AGG[聚合 mean delta]
AGG --> SEV{severity?}
SEV -->|< 0.05| OK[健康,无 alert]
SEV -->|0.05-0.10| L[low 通知]
SEV -->|0.10-0.15| M[medium 告警]
SEV -->|≥ 0.15| H[high 紧急 + 暂停 judge 输出]
style H fill:#ffebee
style OK fill:#e8f5e9
工程实务的 4 条部署规则:
- canary 集大小:20-50 题足够——必须包含 high/mid/low 三档 expected score
- 建立 baseline 的时机:judge prompt 冻结后立即建——5 次取中位数稳住随机性
- 跑 watchdog 的频率:每天一次(成本 = canary 题数 × 单题 token 数 × 当期单价,几十题的量级通常很低)
- 触发动作:
low→ Slack 通知 + 持续观察medium→ 自动跑完整 meta-eval(§8.8.2)high→ 自动暂停 judge 输出 + 告警 oncall
这里刻意不给"某模型半年里漂了几次、每次漂多少"的具体数字——公开可核实的只有一件事:各家的不带日期的模型别名(例如当年的 claude-3-5-sonnet-latest)会在某个时间点指向新的快照,旧快照也会按弃用计划退役,而快照切换不保证判分行为不变。工程上的防御有两条:能钉死带日期的快照就钉死,钉不死的就靠这套 watchdog 自己观测——否则下游所有评测分数都建立在不稳定的地基上。
6.9 judge 会被骗:hacking、泄漏与语言陷阱
6.9.1 LLM-as-Judge 的"反 hacking"——避免被评的模型学会欺骗 judge
LLM-judge 上线一段时间后会出现一个反直觉现象:被评测的模型分数越来越高,但用户反馈不变好甚至变差。这是 judge hacking——模型学会迎合 judge 的偏好而非真正改善质量。下面是工程上的诊断与防御方法。
flowchart LR ITER1[迭代 1: judge 偏好 verbose] --> P1[模型迭代向 verbose] P1 --> ITER2[迭代 2: judge 给高分] ITER2 --> P2[模型继续 verbose] P2 --> H1[人工 NPS 反而降] H1 --> DET[需要 hacking 检测] DET --> CHK1[人工 anchor κ 是否同步降?] DET --> CHK2[判分分布是否右偏?] DET --> CHK3[length / verbosity 异常增?] style H1 fill:#ffebee style ITER2 fill:#fff3e0
5 类 judge hacking 信号:
| 信号 | 表现 | 检测方法 |
|---|---|---|
| length inflation | 平均回答长度月环比升 > 20% | 跑 avg_response_length, 设阈值 |
| score inflation | 整体 judge score 涨但 ground-truth 集分数不涨 | judge vs anchor κ 必须同步监测 |
| judge-anchor 漂移 | judge 分涨 / anchor 分平 | §8.6 元评测 |
| 格式化谄媚 | 模型大量用 markdown / bullet / 强调"专业" | 文本特征统计 |
| 元话语化 | 回答开头 "Great question!" 增多 | n-gram 统计 |
import asyncio
from dataclasses import dataclass
from collections import Counter
from typing import Iterable
@dataclass
class HackingSignals:
avg_length: int
length_growth_pct: float
score_inflation: float
judge_anchor_score_delta: float
sycophancy_phrases: int
markdown_density: float
hacking_risk: str
class JudgeHackingDetector:
"""5 维度 hacking 检测"""
SYCOPHANCY_PHRASES = [
"great question", "excellent question", "absolutely",
"I'd be happy to", "Certainly!",
"好问题", "非常有趣的问题", "您说得对"
]
MARKDOWN_RE_PATTERNS = ["**", "##", "- ", "* "]
def detect(self, current_responses: list[str],
baseline_responses: list[str],
current_judge_score: float,
baseline_judge_score: float,
current_anchor_score: float,
baseline_anchor_score: float) -> HackingSignals:
cur_avg = sum(len(r) for r in current_responses) / max(len(current_responses), 1)
bas_avg = sum(len(r) for r in baseline_responses) / max(len(baseline_responses), 1)
len_growth = (cur_avg - bas_avg) / max(bas_avg, 1)
score_inflation = current_judge_score - baseline_judge_score
anchor_delta = (current_judge_score - current_anchor_score) - \
(baseline_judge_score - baseline_anchor_score)
sycophancy = sum(
sum(1 for p in self.SYCOPHANCY_PHRASES if p.lower() in r.lower())
for r in current_responses
) / max(len(current_responses), 1)
md_density = sum(
sum(r.count(p) for p in self.MARKDOWN_RE_PATTERNS)
for r in current_responses
) / max(len(current_responses), 1)
# 综合判定
risks = 0
if len_growth > 0.20: risks += 1
if score_inflation > 0.10: risks += 1
if anchor_delta > 0.05: risks += 1
if sycophancy > 0.5: risks += 1
if md_density > 4: risks += 1
risk_level = ("high" if risks >= 3 else
"medium" if risks == 2 else
"low")
return HackingSignals(
avg_length=int(cur_avg),
length_growth_pct=round(len_growth * 100, 1),
score_inflation=round(score_inflation, 3),
judge_anchor_score_delta=round(anchor_delta, 3),
sycophancy_phrases=round(sycophancy, 2),
markdown_density=round(md_density, 2),
hacking_risk=risk_level,
)
工程实务的 4 条防 hacking 准则:
- judge 必须配 anchor 监控:单看 judge 分上涨毫无意义——必须 vs 人工 anchor 一致涨
- 每月跑 hacking detector:5 维度信号任 ≥ 3 个红→ 必须改 judge prompt
- judge prompt 加"penalize sycophancy / verbosity":直接惩罚谄媚 / 冗长
- 定期换 judge model:长期同 judge 模型容易被被测模型"摸透偏好"
具体例子(示意数据,需用你自己的数据复算):某团队 6 个月发现 judge 分从 4.1 涨到 4.6(看似进步),detector 报告:
- length_growth_pct = +35% (高)
- judge_anchor_score_delta = +0.18 (高)
- markdown_density = 6.2 (异常)
- 综合 risk = high
诊断结论:模型学会用 markdown + 长答案讨好 judge,但人工 anchor 显示用户体验未改善。修法:judge prompt 加"忽略格式 / 专注内容",重测 anchor κ 回到 0.65 健康水平。
研究背景:
- Sharma et al. 2023 "Towards Understanding Sycophancy in Language Models" arXiv:2310.13548 系统讨论了 sycophancy
Judge hacking 是 Goodhart 定律在评测领域的体现,常见的表述是 "When a measure becomes a target, it ceases to be a good measure."(Strathern 1997 对 Goodhart 原意的概括)。这个 hacking 检测器是一道安全网:它的阈值是示意值,作用是尽早发出信号,最终判断仍要回到人工 anchor。
6.9.2 LLM-as-Judge 的"标签泄漏"问题——judge 知道答案的灾难
一个隐蔽的 judge 失效模式:本该 reference-free 的 judge(如 faithfulness)在 prompt 里无意拿到了 ground-truth。这时 judge 实际在判"像不像标准答案",而不是"有没有依据"——κ 可能看起来漂亮,测的却不是你以为的那个维度。(专门的 reference-based 评测给 judge 看标准答案是设计意图,不算泄漏,见下文"3 类合法用法"。)下面是诊断与修法:
flowchart LR
GT[ground_truth: '巴黎'] --> P[Judge prompt 里包含答案]
P --> J[Judge: 答案是 '巴黎', 这条 RAG 答 'The capital is Paris'<br/>判: ✅ correct]
P --> J2[Judge: 答案是 '巴黎', 这条 RAG 答 '柏林'<br/>判: ❌]
STD{真实问题}
STD --> H[Judge 按「是否与标准答案一致」判分]
H --> LEAK[检索上下文里并无依据、<br/>RAG 凭模型记忆答对<br/>faithfulness judge 仍判 grounded]
style LEAK fill:#ffebee
import re
from dataclasses import dataclass
from collections import Counter
from typing import Iterable
@dataclass
class LeakageCheckResult:
judge_prompt: str
leakage_score: float # 0-1, 越高越可能 leak
leaking_phrases: list[str]
severity: str # "critical" | "warning" | "ok"
suggestion: str
class JudgePromptLeakageDetector:
"""诊断 judge prompt 是否泄漏了 ground-truth"""
# 不含 "answer":很多模板用 {{answer}} 表示被测回答,放进来会误报;
# "reference" 在专门的 reference-based 评测里是合法用法,命中时人工确认即可
GROUND_TRUTH_FIELDS = ["expected", "ideal", "ground_truth",
"label", "reference"]
LEAK_PATTERNS = [
r"(the\s+)?correct\s+answer\s+is",
r"the\s+expected\s+answer\s+is",
r"reference\s+answer\s*[::]",
r"应该回答",
r"标准答案是",
r"\{\{\s*expected\s*\}\}", # 模板变量
r"\{\{\s*ideal\s*\}\}",
r"\{\{\s*ground_truth\s*\}\}",
]
def detect(self, judge_prompt: str,
sample_data: list[dict] = None) -> LeakageCheckResult:
leaking_phrases = []
for pattern in self.LEAK_PATTERNS:
matches = re.findall(pattern, judge_prompt, re.IGNORECASE)
leaking_phrases.extend(matches)
# 检查是否引用了 ground-truth 字段
for field in self.GROUND_TRUTH_FIELDS:
if f"{{{{{field}}}}}" in judge_prompt or \
f"{{{{ {field} }}}}" in judge_prompt:
leaking_phrases.append(f"{{{{{field}}}}}")
# 抽样检查 prompt 渲染后是否含答案文本
actual_leaks = 0
if sample_data:
for sample in sample_data[:10]:
gt = sample.get("expected", "")
if gt and gt.lower() in judge_prompt.lower():
actual_leaks += 1
leakage_score = (
min(len(leaking_phrases) / 3, 0.6) +
actual_leaks / max(len(sample_data or []), 1) * 0.4
)
if leakage_score >= 0.5:
severity = "critical"
suggestion = ("Judge prompt 严重泄漏 ground-truth - "
"立即移除 expected 字段引用 / 改写 prompt "
"用 'judge based on principles' 思路")
elif leakage_score >= 0.2:
severity = "warning"
suggestion = "审查 judge prompt 是否需要 ground-truth - "\
"通常 judge 应该 grounded only on retrieved context"
else:
severity = "ok"
suggestion = "未发现明显泄漏 - 可定期复查"
return LeakageCheckResult(
judge_prompt=judge_prompt[:200],
leakage_score=round(leakage_score, 3),
leaking_phrases=list(set(leaking_phrases)),
severity=severity,
suggestion=suggestion,
)
工程实务的 4 个常见泄漏来源:
| 来源 | 错误示例 | 正确写法 |
|---|---|---|
| 模板变量 | "Expected: {{expected}}, Was the response correct?" | 移除 expected 直接评 quality |
| 自然语言提示 | "The correct answer is {{x}}, judge if response matches" | 让 judge 基于 retrieved context 自行判断 |
| 双重引用 | judge 看到 input + ideal + response 全部 | judge 只该看 input + response,让它独立评估 |
| 偷渡注释 | "# Note: gold answer is X" 出现在 system prompt | 严格 review prompt,去除所有非业务文本 |
3 类合法用法(不算泄漏):
- 专门的 reference-based eval:明确目标是"匹配 reference",prompt 里给 reference 是设计意图
- Faithfulness 评测:要给 retrieved context(不是 ground-truth answer)
- rubric-based grading:给 evaluation rubric(评分标准)而非答案
具体例子(示意数据,需用你自己的数据复算):某团队本想评 groundedness 的 RAG judge prompt:
你是评分员。问题: {{question}}
正确答案: {{expected}} ← 严重泄漏!
被测回答: {{response}}
判 0-5 分。
诊断:leakage_score=0.6(模板层面命中 {{expected}},未传 sample_data),severity=critical。修法:
你是评分员。问题: {{question}}
检索到的上下文:
{{retrieved_context}}
被测回答: {{response}}
基于上下文判断回答是否 grounded、相关、完整。判 0-5 分。
修后 κ 从 0.91 降到 0.65——前者量的是"像不像标准答案",后者才是这个 judge 本该量的 groundedness。
研究背景:
- Liu et al. 2023 "G-Eval" 论文把 LLM 当作 reference-free 指标使用,并与 reference-based、reference-free 两类基线对比(§1 贡献列表)
- ragas v0.2.10 的 Faithfulness 只需要
user_input/response/retrieved_contexts,ResponseRelevancy(Answer Relevance)只需要user_input/response,都不看标准答案(src/ragas/metrics/_faithfulness.py:167-175、src/ragas/metrics/_answer_relevance.py:84-91) - BLEU / ROUGE 是 reference-based——但需要小心区分"reference 计算" vs "judge 看 reference"
建议在 judge prompt 上线前跑一遍 JudgePromptLeakageDetector,把它当作评测体系的"自我体检"。κ 很高但 leakage critical,往往说明 judge 量错了维度。
6.9.3 LLM-as-Judge 的"中文 / 多语言场景陷阱"——英文 prompt 在中文场景的隐藏失分
业界流传最广的一批 LLM-as-Judge prompt 模板(ragas 内置 prompt、OpenAI cookbook、各家 blog)都是英文 prompt,直接拿来评测中文输出很常见。这有两个隐藏陷阱:(1) 英文 prompt 让 judge "更倾向英文逻辑判断",对中文长难句的支持度低;(2) 中文双引号 / 全角标点 / 数字混用让 judge 误判格式问题。本节给读者一份中文场景的 judge 工程化方案,把多语言场景的隐藏失分压到最低。
graph LR
A[英文 prompt judge] --> B{评测中文输出}
B --> C[陷阱 1: 中文长难句<br/>判 'unclear' 偏多]
B --> D[陷阱 2: 全/半角混用<br/>误判格式错]
B --> E[陷阱 3: 中文成语<br/>误判 hallucination]
B --> F[陷阱 4: 文化专有名词<br/>误判 'unsupported']
C & D & E & F --> G[中文场景系统性失分]
A --> H{解决方案}
H --> I[1. judge prompt 用中文]
H --> J[2. 全角/半角统一预处理]
H --> K[3. 加中文文化背景例]
H --> L[4. 中文 anchor 校准]
4 类中文场景陷阱 × 修法(本节末尾会说明:跨语言 judge 偏差目前没有可引用的定量结论,所以这里只列现象与修法,不编失分幅度):
| 陷阱 | 表现 | 修法 |
|---|---|---|
| 长难句失分 | 中文 200 字段判 unclear | judge prompt 改中文 + 加"中文长句正常" |
| 全/半角混用 | "100,200" vs "100,200" 判格式错 | NFKC 归一化预处理 |
| 成语 / 古文误判 | "亡羊补牢"判为编造 | 加文化背景知识库引用 |
| 专有名词 | "支付宝"/"高考" 判 unsupported | 加中文 anchor 集校准 |
配套实现:中文 judge 适配器:
import unicodedata
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class ChineseJudgeAdapter:
cultural_terms_whitelist: set[str] = field(default_factory=lambda: {
"支付宝", "微信", "高考", "春节", "京东", "淘宝", "美团",
"亡羊补牢", "对牛弹琴", "胸有成竹", # 成语
"故宫", "长城", "黄河", "国务院",
})
base_judge_fn: Callable[[str, str, str], dict] | None = None
def normalize_punctuation(self, text: str) -> str:
"""全角 → 半角统一,避免格式误判"""
return unicodedata.normalize("NFKC", text)
def get_chinese_judge_prompt(self) -> str:
return """你是一个中文文本评测专家。请按以下规则严格评分:
[规则]
1. 中文长句(200 字以上)属于正常表达,不应因为长就判为 unclear
2. 中文成语 / 历史典故(如"亡羊补牢")是真实存在的文化知识,不是编造
3. 中文专有名词(如"支付宝"、"高考"、"故宫")是真实存在的,不应判为 unsupported
4. 评分范围 0-1,0.5 是平均水平
5. 必须给出 reason,并指出具体哪句话好 / 不好
[评分维度]
- Faithfulness(忠实于 context)
- Relevance(回答用户问题)
- Cultural appropriateness(文化合适性)
[输出 JSON]
{"score": 0.x, "reason": "...", "cultural_flag": false}
"""
def preprocess(self, output: str) -> str:
return self.normalize_punctuation(output)
def judge_chinese(self, query: str, candidate: str,
context: str) -> dict:
# 预处理:标点归一化
candidate_clean = self.preprocess(candidate)
context_clean = self.preprocess(context)
if self.base_judge_fn is None:
# 演示:返回模拟结果
return {"score": 0.85, "reason": "demo only"}
# 用中文 prompt 调 judge
result = self.base_judge_fn(
self.get_chinese_judge_prompt(),
query + "\n\n候选答案:" + candidate_clean +
"\n\nContext:" + context_clean,
""
)
# 后处理:cultural_flag 修正
if any(term in candidate_clean for term in self.cultural_terms_whitelist):
if result.get("score", 1.0) < 0.6 and "cultural" in result.get("reason", "").lower():
# judge 因为文化术语扣分 → 加分校正
result["score"] = min(1.0, result["score"] + 0.15)
result["reason"] += " [adapter: cultural term recognized, adjusted +0.15]"
return result
@dataclass
class ChineseJudgeBenchmark:
adapter: ChineseJudgeAdapter
test_samples: list[dict] = field(default_factory=list)
def run_compared(self) -> dict:
"""对照英文 judge vs 中文 adapter 的 score 差异"""
diffs = []
for sample in self.test_samples:
en_score = sample.get("english_judge_score", 0.0)
zh_result = self.adapter.judge_chinese(
sample["query"], sample["candidate"], sample.get("context", "")
)
diffs.append({
"sample_id": sample["sample_id"],
"english_score": en_score,
"chinese_score": zh_result["score"],
"delta": zh_result["score"] - en_score,
"type": sample.get("type", "general"),
})
avg_delta = sum(d["delta"] for d in diffs) / max(len(diffs), 1)
return {
"total": len(diffs),
"avg_score_uplift": round(avg_delta, 3),
"by_type_uplift": self._group_by_type(diffs),
# 平均分升高不等于更准,是否降低误判要对照人工标注(第 8 章)
"verdict": "中文 adapter 平均分明显升高" if avg_delta > 0.04 else "差异不大"
}
def _group_by_type(self, diffs: list[dict]) -> dict:
groups: dict[str, list[float]] = {}
for d in diffs:
groups.setdefault(d["type"], []).append(d["delta"])
return {t: round(sum(v) / len(v), 3) for t, v in groups.items()}
举例(示意数据,需用你自己的数据复算):某中文客服跑 200 题对照:
- 这里的分数变化是示例,用来说明报告该长什么样:要按样本类型分开看 uplift,因为整体平均会把"成语 / 专名"这类真正吃亏的子集稀释掉
- 需要警惕的是:中文 adapter 让平均分升高,本身不能证明它更准——分数变高也可能只是判得更松了。判断依据必须是与人工标注的一致性(第 8 章),而不是分数的绝对值
配套行业研究背景:
- 跨语言 judge 偏差目前没有一篇公认的定论性论文可引——本节给的是工程上可复现的检查方法:同一批样本用中文 prompt 与英文 prompt 各判一遍,看结论翻转率,翻转率高就说明存在语言相关偏差
- "全角 / 半角混用"的规范化依据是 Unicode 的 NFKC 归一化(§5.8.1 用的是同一套)
面向中文用户的 LLM-as-Judge 可以接入 ChineseJudgeAdapter,针对上面 4 类陷阱做预处理和 prompt 调整;修得有没有效,仍以中文 anchor 集上与人工的一致性为准。
6.9.4 深远的话题:当 judge 比被测系统还强时
有一个反直觉的工程现实——judge 应该比被测系统更强。
为什么?如果 judge 模型不如被测模型,judge 无法识别被测模型的精细错误。比如让上一代小模型给当代旗舰当 judge——它看不出旗舰答案里的细微 hallucination,容易盲目打高分。
经验:
- 通用任务:judge 模型 ≥ 被测模型
- 推理任务:judge 模型推理能力 > 被测模型
- 创意任务:judge 与被测同等水平 + 多 judge ensemble
这种"judge 必须比被测强"的要求,给 LLM-as-Judge 的工业应用设了天然上限——当 SOTA 模型本身就是被测对象时,没有更强的 judge 可用,只能上人工。
这也是模型方评测自家旗舰模型时离不开人工标注的原因之一:被测对象本身就站在能力前沿,找不到明显更强的 judge。
工程团队的实务判断:你们的应用如果用中小档模型,judge 可以用另一家族的旗舰或中高档模型;如果应用本身就用旗舰模型,元评测必须配置较多人工。
6.10 成本与规模化
6.10.1 Judge 成本优化:分层 judging
judge 调用的 token 量常常不比主链路少(§6.10.5 会拆开算)。在 100% 评测场景下,judge 费用可能比模型推理费还高。
成本优化的标准做法是 分层 judging:
flowchart TB All[所有 trace] --> Cheap[第 1 层<br/>规则判分 + 弱模型 judge] Cheap -->|高置信通过| Pass[直接通过] Cheap -->|可疑| Mid[第 2 层<br/>中高档通用模型 judge] Mid -->|高置信| Final[结论] Mid -->|仍可疑| Strong[第 3 层<br/>推理模型 judge<br/>+ 人工抽样] style Cheap fill:#dbeafe style Mid fill:#fef3c7 style Strong fill:#fee2e2
估算方法(示意值):设第 1 层单价是第 2 层的 1/k,所有 trace 都先过第 1 层、其中比例 p 升级到第 2 层,则相对"全量走第 2 层"的成本是 1/k + p。例如 k = 10、p = 20% 时约为 30%,k = 5 时约为 40%。可靠性损失多少取决于分流规则,必须在 anchor 集上量。
关键是各层之间的"可疑判定阈值"——常见做法是用判分分数的离散度(多次重跑分歧度)作为置信度信号。
6.10.2 常被忽略的成本陷阱:judge token 暴增
LLM-as-Judge 看起来便宜,但有一个隐蔽的成本陷阱:judge prompt 包含的内容比想象多得多。一份典型的 judge prompt:
[System prompt: ~500 tokens]
[Rubric definition: ~300 tokens]
[Example demonstrations (CoT few-shot): ~1500 tokens]
[Question: ~100 tokens]
[Reference answer: ~200 tokens]
[Candidate answer: ~300 tokens]
[Evaluation steps: ~200 tokens]
Total input: ~3100 tokens
Output: ~200 tokens (reasoning + score)
每条 judge 调用约 3300 tokens。单条成本 = 输入 token × 输入单价 + 输出 token × 输出单价。以 Anthropic 官方定价页(2026-09-25)上 claude-sonnet-5 的 $2 / MTok 输入、$10 / MTok 输出为例:3100 × $2/10⁶ + 200 × $10/10⁶ ≈ $0.0082。日 PV 100 万、1% 采样 = 10000 条/天 ≈ $82/天 ≈ $2460/月——已经超出许多团队的"评测预算"心理预期。换模型、换时间都要按当期价目表重算。
成本优化的实务套路:
- prompt 模板瘦身:去掉不必要的 system prompt 描述、压缩 rubric 文字
- Few-shot 选择性加入:只在 judge 一致性低的场景加 demonstration
- 按场景选模型:简单 yes/no 用小档模型(如
claude-haiku-4-5,$1 / $5 每 MTok,是claude-sonnet-5的一半),需要细致推理才用中高档模型 - 缓存 prompt 前缀:OpenAI / Anthropic API 都支持 prompt caching,judge 模板部分可缓存(Anthropic 上
claude-sonnet-5、claude-haiku-4-5的缓存命中按基础输入价的 0.1 倍计费;其他家以官方页为准) - 批量 batch API:Anthropic 的 Batch API 输入输出都打 5 折,OpenAI Batch API 也提供类似折扣,代价是异步、较慢
5 条做完,能压掉相当一部分 judge 成本;判分质量有没有受损,要在 anchor 集上复测确认。这是工业评测的"看不见的工程功夫"。
6.10.3 隐藏的成本陷阱:context window 与 judge 调用
LLM-as-Judge 调用的成本除了模型选型,还受 context window 影响。一个常被忽略的成本陷阱:
判 1 条 RAG 评测样例:
- System prompt: ~500 tokens
- Rubric + examples: ~1500 tokens
- Question: ~100 tokens
- Answer: ~300 tokens
- Context (RAG 检索 5 chunks): ~2500 tokens ← 主要成本
- 输出: ~300 tokens
总 input: ~5000 tokens / 单条 judge 调用
如果你的 RAG context 长(如 20+ chunks 或单 chunk 1k+ tokens),单条 judge 调用可能 ~10000 tokens。1000 条评测的 input 就是 1000 万 tokens——按 claude-sonnet-5 的 $2 / MTok 输入价算 = 10 × $2 = $20(2026-09-25 官方价,不含输出)。
成本优化:
- context 摘要:先用便宜模型把 context 压缩到 1k token 以内再喂 judge
- 抽样关键 chunk:只把"对判断最关键的"chunk 给 judge,不全量喂
- 缓存 prompt 前缀:System prompt + Rubric + examples 几乎不变,用 OpenAI / Anthropic 的 prompt caching,被缓存的那部分按折扣价计费(Anthropic 多数现行模型为基础输入价的 0.1 倍)
这些优化叠加起来,单条成本可以明显下降;具体降多少,把优化前后的 token 构成代进当期价目表算一遍即可(§6.10.5 给了计算器)。
6.10.4 LLM-as-Judge 的"成本-精度边际曲线"——什么时候停止优化
§6.7.6 给了 judge 选型表,但实战中常常在 "再加一个 ensemble member 真值不值" 的边界打转。下面给出 marginal analysis 工具——量化每多 1% 精度提升要花多少钱。
from dataclasses import dataclass
@dataclass
class JudgeMarginalAnalysis:
config_name: str
accuracy_kappa: float
cost_per_query_usd: float
delta_kappa_vs_baseline: float
delta_cost_vs_baseline: float
cost_per_pp_improvement: float # 相对 baseline 的累计 $/pp
marginal_cost_per_pp: float # 相对上一步的边际 $/pp(决策看这一列)
diminishing_returns_flag: bool
class JudgeMarginalCalculator:
"""计算 judge 优化策略的边际效益。
$/pp 的口径:每 volume 次调用(默认 10 万次)多花的美元 ÷ 多得的 κ 百分点"""
DIMINISHING_THRESHOLD_USD_PER_PP = 50.0 # 边际 > $50/pp 视为递减(示意阈值)
def __init__(self, baseline_kappa: float = 0.55,
baseline_cost: float = 0.001,
volume: int = 100_000):
self.baseline_kappa = baseline_kappa
self.baseline_cost = baseline_cost
self.volume = volume
def _usd_per_pp(self, d_cost: float, d_kappa: float) -> float:
return d_cost * self.volume / max(d_kappa * 100, 0.01)
def analyze(self, configs: list[dict]) -> list[JudgeMarginalAnalysis]:
"""configs 按优化路径的先后顺序给出,每一步在上一步基础上叠加"""
results = []
prev_k, prev_c = self.baseline_kappa, self.baseline_cost
for cfg in configs:
delta_k = cfg["kappa"] - self.baseline_kappa
delta_cost = cfg["cost"] - self.baseline_cost
cumulative = self._usd_per_pp(delta_cost, delta_k)
marginal = self._usd_per_pp(cfg["cost"] - prev_c, cfg["kappa"] - prev_k)
results.append(JudgeMarginalAnalysis(
config_name=cfg["name"],
accuracy_kappa=cfg["kappa"],
cost_per_query_usd=cfg["cost"],
delta_kappa_vs_baseline=round(delta_k, 3),
delta_cost_vs_baseline=round(delta_cost, 4),
cost_per_pp_improvement=round(cumulative, 1),
marginal_cost_per_pp=round(marginal, 1),
diminishing_returns_flag=marginal > self.DIMINISHING_THRESHOLD_USD_PER_PP,
))
prev_k, prev_c = cfg["kappa"], cfg["cost"]
return results
flowchart LR B["baseline: 小模型单 judge<br/>κ=0.55 / $0.001"] --> O1["+ CoT<br/>κ=0.62 / $0.003"] O1 --> O2["+ position swap<br/>κ=0.66 / $0.005"] O2 --> O3["+ ensemble 3 judge<br/>κ=0.72 / $0.012"] O3 --> O4["+ reasoning model<br/>κ=0.78 / $0.022"] O4 --> O5["+ 多模型 ensemble<br/>κ=0.81 / $0.045"] O1 -. "$/pp=$30" .-> M1[健康] O2 -. "$/pp=$50" .-> M2[临界] O3 -. "$/pp=$120" .-> M3[递减开始] O4 -. "$/pp=$170" .-> M4[强递减] O5 -. "$/pp=$770" .-> M5[基本不值] style M1 fill:#e8f5e9 style M2 fill:#fff3e0 style M3 fill:#ffebee style M5 fill:#ffebee
图中虚线上的 $/pp 是相对上一步的边际值(每 10 万次调用多花的钱 ÷ 多得的 κ 百分点)。一组示意的决策档位:
| 边际 $/pp | 状态 | 决策 |
|---|---|---|
| < $30 | 高效 | 必上 |
| $30-50 | 健康 | 上 |
| $50-100 | 临界 | 看场景 |
| $100-200 | 递减 | 高赌注才上 |
| > $200 | 基本不值 | 几乎肯定不上 |
具体例子(示意数据,需用你自己的数据复算):客服 RAG 团队的 judge 优化路径:
- baseline:小模型单 judge,κ=0.55,成本 $0.001/q
- +CoT:κ=0.62(较上一步 +7pp),成本 $0.003/q(+$0.002)→ 边际 $/pp ≈ $29 ✅
- +位置 swap:κ=0.66(+4pp),成本 $0.005/q(+$0.002)→ 边际 ≈ $50 ✅
- +ensemble 3:κ=0.72(+6pp),成本 $0.012/q(+$0.007)→ 边际 ≈ $117 ⚠️
- +reasoning:κ=0.78(+6pp),成本 $0.022/q(+$0.010)→ 边际 ≈ $167 ⚠️
- +多模型 ensemble:κ=0.81(+3pp),成本 $0.045/q(+$0.023)→ 边际 ≈ $767 ❌
(相对 baseline 的累计 $/pp 依次是 $29 / $36 / $65 / $91 / $169——累计值会把前几步的"便宜收益"摊进来,掩盖后面几步的递减,所以决策看边际值。)
结论:日常评测停在 +position swap(κ=0.66);高赌注场景可以再上 ensemble 3(κ=0.72);再往后基本不值。
研究背景:
- 边际效益递减(diminishing marginal returns)是古典经济学里的老概念,可追到 Turgot / Ricardo 一脉;它和常被混提的 Lipsey & Lancaster 1956「次优理论」(General Theory of Second Best)不是一回事,别张冠李戴
面对"该不该再加一招优化 judge"的犹豫时,跑一次 marginal analysis,避免在精度优化上无限投入。
6.10.5 LLM-as-Judge 的"成本结构"——为什么 judge 常比生成更贵
行业误区:judge 用同一个 LLM 跑 → 成本应该和生成相当。实际:判 1 个 1000-token 答案的 judge 调用,输入 token 通常 = (system_prompt 200 + rubric 800 + question 200 + answer 1000 + reference 1000) = 3200,是被评答案 token 数的 3 倍多;rubric、reference 更长时还会更高,judge 若再输出 reasoning trace,差距进一步拉大。本节给读者一份 judge 成本结构 + 优化路径。
graph LR
A[Judge 单次调用] --> B[输入 tokens]
A --> C[输出 tokens]
B --> D[system prompt]
B --> E[rubric]
B --> F[question]
B --> G[candidate answer]
B --> H[reference answer]
C --> I[score]
C --> J[reasoning trace]
D & E & F & G & H --> K[输入 3000-5000]
I & J --> L[输出 200-800]
K & L --> M[每次调用成本]
M --> N[千次评测扩大 1000 倍]
N --> O{优化路径}
O --> P[剪短 rubric]
O --> Q[去掉 reference / 改为 reference-free]
O --> R[prefix cache 共用 system + rubric]
O --> S[小模型 judge + 大模型抽审]
Judge 成本结构 5 大组件 + 占比 + 优化空间(占比与压缩比例都是示意值):
| 组件 | 典型 token | 占比 | 优化空间 | 实现路径 |
|---|---|---|---|---|
| system prompt | 200-400 | 8% | 可被 prefix cache 命中 | OpenAI / Anthropic prompt caching |
| rubric / criteria | 500-1000 | 25% | 可压缩 30-50% | 精炼语言 + 删示例 |
| question | 100-300 | 6% | 不可压缩 | — |
| candidate answer | 500-2000 | 35% | 不可压缩(被评对象) | — |
| reference answer | 500-2000 | 25% | 可去除 → reference-free | 第 13 章的 RAG faithfulness 即是一例 |
| 输出 score + reasoning | 100-800 | 不计入输入 | 短模式(仅 score)省 70% | rubric 显式要求 score-only 模式 |
配套实现:judge 成本估算 + 优化建议器:
from dataclasses import dataclass
from typing import Literal
JudgeMode = Literal["full_with_reasoning", "score_only",
"reference_free", "compressed_rubric", "cached_prefix"]
@dataclass
class JudgeCostProfile:
system_tokens: int
rubric_tokens: int
question_tokens: int
candidate_tokens: int
reference_tokens: int
output_tokens: int
# 单价按官方定价页填;默认值取 claude-sonnet-5 的 $2 / $10 每 MTok(2026-09-25)
input_per_million_usd: float = 2.0
output_per_million_usd: float = 10.0
cache_hit_rate: float = 0.0 # prompt caching 命中率
cache_read_price_ratio: float = 0.1 # 缓存命中部分按基础输入价的这个倍数计费(以官方页为准)
def total_input_tokens(self) -> int:
return (self.system_tokens + self.rubric_tokens + self.question_tokens
+ self.candidate_tokens + self.reference_tokens)
def cost_per_call_usd(self) -> float:
cached_part = (self.system_tokens + self.rubric_tokens) * self.cache_hit_rate
billable_input = (self.total_input_tokens() - cached_part
+ cached_part * self.cache_read_price_ratio)
in_cost = billable_input / 1_000_000 * self.input_per_million_usd
out_cost = self.output_tokens / 1_000_000 * self.output_per_million_usd
return in_cost + out_cost
def cost_per_1k_calls_usd(self) -> float:
return self.cost_per_call_usd() * 1000
def apply_optimization(self, mode: JudgeMode) -> "JudgeCostProfile":
"""生成优化后的成本 profile"""
new = JudgeCostProfile(**self.__dict__)
if mode == "score_only":
new.output_tokens = max(20, self.output_tokens // 5)
elif mode == "reference_free":
new.reference_tokens = 0
elif mode == "compressed_rubric":
new.rubric_tokens = int(self.rubric_tokens * 0.6)
elif mode == "cached_prefix":
new.cache_hit_rate = 0.85
return new
def optimization_savings(self, modes: list[JudgeMode]) -> dict:
baseline = self.cost_per_1k_calls_usd()
result = {"baseline_per_1k_usd": baseline, "options": []}
cumulative = self
for m in modes:
cumulative = cumulative.apply_optimization(m)
opt_cost = cumulative.cost_per_1k_calls_usd()
result["options"].append({
"mode": m,
"cost_per_1k_usd": opt_cost,
"savings_pct": (baseline - opt_cost) / baseline * 100,
})
return result
举例(示意数据,需用你自己的数据复算):某团队 judge baseline = system 200 + rubric 800 + question 200 + candidate 1000 + reference 1000 = 3200 in / 500 out,按上面的默认单价($2 / $10 每 MTok)= 3200 × $2/10⁶ + 500 × $10/10⁶ = $0.0114 / call → $11.4/1k:
- 仅 score_only → $7.4/1k(省 35%)
-
- reference_free → $5.4/1k(累计省 53%)
-
- compressed_rubric → $4.76/1k(累计省 58%)
-
- cached_prefix → $3.72/1k(累计省 67%)
每月跑 100k judge 调用:从约 $1140/月 降到约 $372/月,一年省约 $9.2K。
配套行业研究背景:
- "Prompt caching" 价格机制 来自 OpenAI 2024 / Anthropic 2024 公开 pricing
- ragas 的 Faithfulness 是 reference-free judging 的典型实现(第 11 章)
- "judge 比生成更贵"的 token 结构是可以自己算的:把上面这张表的各段 token 数代进当期价目表即可,本节不引任何二手的"成本白皮书"
judge 上线前可以用 JudgeCostProfile 做一次成本分析:看清"为什么 judge 比生成贵",以及每一步优化各省多少(上例四步累计约 67%,换自家 token 构成和单价会不同)。
6.10.6 LLM-as-Judge 的"小模型 judge 验证大模型 judge"——降本不降质的工程化路径
LLM-as-Judge 常见的成本焦虑:如果大部分样本用小档模型(如 claude-haiku-4-5)就能判对,为什么要全部交给旗舰模型?本节给读者一份"分层 judge"工程方案——把小模型 judge 作为一线、大模型作为复审;能省多少、质量掉不掉,取决于分流比例和小模型在自家 anchor 集上的一致率。
graph LR
A[Judge 任务] --> B[一线: 小模型 judge]
B --> C{置信度 + 边界判断}
C -->|高置信 直接通过| D[小模型分数采纳]
C -->|低置信 / 边界| E[二线: 大模型 judge]
E --> F[大模型分数覆盖]
D & F --> G[最终分数]
G --> H[质量监控]
H --> I[抽 5% 全用大模型作 ground truth]
I --> J{小模型一致率 > 阈值?}
J -->|是| K[继续分层]
J -->|否| L[小模型 prompt 调优 / 升级]
3 类样本分层路由策略(占比与节省比例为示意值):
| 样本类型 | 小模型置信度 | 路由 | 占比典型值 | 成本节省 |
|---|---|---|---|---|
| 明确通过 / 明确失败 | logprob ≥ 0.85 | 小模型直采 | 70% | 90% |
| 边界模糊(0.4 < score < 0.6) | logprob 0.5-0.85 | 大模型复审 | 20% | 0% |
| 内容敏感 / 高 stake | always | 大模型 | 10% | 0% |
配套实现:分层 judge 路由器:
from dataclasses import dataclass, field
from typing import Callable, Literal
import math
JudgeTier = Literal["small_only", "small_then_large", "large_only"]
@dataclass
class TieredJudgeRouter:
small_judge: Callable[[str, str], dict] # 返回 {score, logprob, reason}
large_judge: Callable[[str, str], dict]
confidence_threshold: float = 0.85
boundary_low: float = 0.4
boundary_high: float = 0.6
high_stake_keywords: tuple[str, ...] = ("退款", "法律", "医疗", "金融")
small_cost_per_call_usd: float = 0.0005
large_cost_per_call_usd: float = 0.025
def route_decision(self, query: str, score_hint: float | None = None) -> JudgeTier:
if any(kw in query for kw in self.high_stake_keywords):
return "large_only"
if score_hint is not None:
if self.boundary_low <= score_hint <= self.boundary_high:
return "small_then_large"
return "small_only"
def judge(self, query: str, candidate_answer: str) -> dict:
# 第一层:先无脑跑小模型获得 score + logprob
small_result = self.small_judge(query, candidate_answer)
score = small_result["score"]
logprob = small_result.get("logprob", 1.0)
# 决策路由
if any(kw in query for kw in self.high_stake_keywords):
tier = "large_only"
elif logprob < self.confidence_threshold or self.boundary_low <= score <= self.boundary_high:
tier = "small_then_large"
else:
tier = "small_only"
# 执行
if tier == "small_only":
return {"final_score": score, "tier": tier,
"cost_usd": self.small_cost_per_call_usd,
"small_score": score, "large_score": None,
"reason": small_result.get("reason", "")}
large_result = self.large_judge(query, candidate_answer)
return {
"final_score": large_result["score"],
"tier": tier,
# 两种 tier 都已先跑过一次小模型,所以都是 small + large
"cost_usd": self.small_cost_per_call_usd + self.large_cost_per_call_usd,
"small_score": score, "large_score": large_result["score"],
"reason": large_result.get("reason", ""),
"small_large_disagreement": abs(score - large_result["score"]),
}
def estimate_cost_savings(self, n_samples: int,
boundary_pct: float = 0.20,
high_stake_pct: float = 0.10) -> dict:
small_only = int(n_samples * (1 - boundary_pct - high_stake_pct))
boundary = int(n_samples * boundary_pct)
high_stake = int(n_samples * high_stake_pct)
baseline = n_samples * self.large_cost_per_call_usd
tiered = (small_only * self.small_cost_per_call_usd
+ boundary * (self.small_cost_per_call_usd + self.large_cost_per_call_usd)
+ high_stake * (self.small_cost_per_call_usd + self.large_cost_per_call_usd))
return {
"baseline_usd": baseline,
"tiered_usd": tiered,
"savings_usd": baseline - tiered,
"savings_pct": (baseline - tiered) / baseline * 100,
"small_only_count": small_only,
"boundary_count": boundary,
"high_stake_count": high_stake,
}
举例(示意数据,需用你自己的数据复算):某团队月跑 100k judge 调用:
- baseline 全走大模型 = 100k × $0.025 = $2500(单价取上面代码里的示意默认值)
- 接入分层:70% 小模型直采 + 20% 复审 + 10% 高风险直送大模型(后两类也都先跑过一次小模型)= $800
- 节省 $1700/月(68%),一年约 $20K
- 抽 5% 样本全用大模型重判作参照 → 小模型与大模型一致率 0.92(达标),保持分层;再定期用人工 anchor 复核大模型本身
- 半年后小模型升级,重新评估
配套行业研究背景:
- LLM 级联(先便宜模型、不确定再升级)可参考 FrugalGPT(Chen et al. 2023, arXiv:2305.05176)
- 用 AI 反馈替代部分人工偏好标注的思路,可参考 Anthropic "Constitutional AI"(Bai et al. 2022, arXiv:2212.08073)
- "Mixture of Judges" 来自 Meta GenAI 2024("The Perfect Blend: Redefining RLHF with Mixture of Judges", arXiv:2409.20370)
TieredJudgeRouter 可以用来先估一遍"分层后能省多少",它是 §6.10.5 成本分析的一条具体优化路径;省下的钱是否以质量为代价,要看上面那一步抽检。
6.11 排错与调试
6.11.1 被低估的工程能力:debug 失败的 judge 调用
LLM-as-Judge 调用失败 / 输出不规范是常态——可能是 LLM API 报错,可能是 JSON 解析失败,可能是 judge 输出格式漂移。一个工业级 judge 系统的关键工程能力是 优雅 debug。
具体应对:
import json
import re
from pydantic import ValidationError
# call_llm / log_judge_failure / JudgeOutput(pydantic v2 模型,含 score 字段)需自行定义;
# call_llm 这里假设返回 OpenAI 风格的 response,换 SDK 时改取值路径
def call_judge_with_debug(prompt: str, model: str) -> dict:
"""Judge 调用的 debug 友好版本"""
response = call_llm(prompt, model=model)
raw = response.choices[0].message.content
# 第一道: JSON 解析
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
# 尝试 markdown code block 提取
match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', raw, re.DOTALL)
try:
parsed = json.loads(match.group(1)) if match else None
except json.JSONDecodeError:
parsed = None
if parsed is None:
log_judge_failure("json_decode", raw, e)
return {"score": None, "reason": "PARSE_FAILED", "raw": raw}
# 第二道: schema 校验
try:
validated = JudgeOutput(**parsed)
except ValidationError as e:
log_judge_failure("schema_violation", parsed, e)
return {"score": None, "reason": "SCHEMA_FAILED", "raw": raw}
# 第三道: 业务逻辑校验
if validated.score < 0 or validated.score > 10:
log_judge_failure("out_of_range", validated, None)
return {"score": None, "reason": "RANGE_FAILED", "raw": raw}
return validated.model_dump()
这种"三层防护 + 详细日志"的 judge 调用,在生产中能让任何失败都有完整 trace 可追。每次失败都被分类(json_decode / schema / range / API),方便事后归因。
一个可参考的起点:失败率超过 5% 就要警惕——可能 prompt 不够 strict,可能 judge model 在某场景下退化,可能 API 抖动。把"judge 失败率"作为元评测的常驻指标,是评测体系运维的细节工程。
6.11.2 完整的 judge prompt 调试 checklist
工程师写完 judge prompt 后做最后一遍 review 的 checklist:
□ rubric 是否量化(不是"友好"而是"包含礼貌用语 + 不命令")
□ 是否有 5+ examples(高分 + 低分各 2-3 例)
□ 是否显式忽略 length / style / model identity
□ 是否要求输出 reasoning(不只是 score)
□ 输出格式是否结构化(JSON / 固定 schema)
□ 是否包含 fallback(无法判断时输出"NULL")
□ temperature 是否设 0(所用模型仍支持采样参数时)
□ 是否调小 max_tokens 节省成本(不需要 judge 写小说)
□ 是否做了 self-consistency 测试(同样 input 跑 3 次结果一致)
□ 是否 calibration 过(与人工标签对比 Spearman ≥ 0.6)
10 项全过的 judge prompt 是工业级合格 prompt。任一不过都说明这个 judge 还没准备好上生产。
工业实务:每个新 judge prompt 都过一遍这 10 项检查 + 资深成员 review,这份 checklist 可以直接放进 PR 模板。
6.12 判官模型的演进
6.12.1 新趋势:Reasoning Model 作为 judge
2024 年下半年起,OpenAI o1、DeepSeek-R1、Claude 3.7 Sonnet(extended thinking)等推理模型陆续出现(这几款如今多已退役或进入退役计划,现行模型普遍把推理做成可调档位)。它们当作 judge 时的特点(定性对比,需自测):
| 维度 | 普通 LLM judge | Reasoning model judge |
|---|---|---|
| 推理深度 | 浅(直觉判断) | 深(多步推理) |
| 抗 bias | 中 | 可能更强,缺少公开对照 |
| 成本 | 基准 | 数倍到十几倍(推理 token 也按输出计费) |
| 速度 | 秒级 | 10-60 秒 |
| 适合场景 | 大规模日常评测 | 关键决策 / 复杂判定 |
JudgeBench(arXiv:2410.12784)专门针对这个问题:它指出现有 benchmark 大多只测 judge 和人类偏好的对齐度,而在那些众包偏好本身就不能代表事实与逻辑正确性的难题上失效;为此它构造了覆盖知识、推理、数学、代码的高难度答案对来客观评估 judge。它的实测结论是强推理模型在这类判定上明显更可靠——这也正是"关键决策用推理型 judge"的依据。
工业团队的取舍:
- 大规模评测:用普通 judge,成本可控
- 关键决策(模型上线 gate / 合规审计):用 reasoning model judge,成本高出不少,换来的准确率提升在 JudgeBench 这类客观判定上有公开证据
- 混合模式:先用通用模型跑全集筛出可疑 case,再用推理模型复审 → 兼顾成本与精度
这种"reasoning model + 分层审核"是推理模型出现后 judge 工程的新做法。它把 judge 从"一个模型一刀切"升级到"多层模型流水线"——更精细,但也更复杂。
6.12.2 深入的话题:judge 模型的"专业化训练"
工业评测的下一个前沿——专门训练用作 judge 的模型。背景:通用 LLM(GPT / Claude)当 judge 是"通才",但 judge 任务有自己的特点(需要严格、不偏、推理深),可以训练"专才 judge"。
公开方向:
- Prometheus(arXiv:2310.08491):开源专业 judge 模型,用 GPT-4 标注的判分数据微调 Llama
- JudgeLM(arXiv:2310.17631):基于 Vicuna 训练的 judge 模型
- Atla 公司:商业化训练的 judge model,专注 evaluation 用例
这些专业 judge 的优势:
- 校准更好:训练数据全是 judge 任务,bias 更可控
- 成本更低:本地部署的 7B-13B judge 单次调用远比调用闭源旗舰便宜(差多少取决于自家算力成本)
- 领域可定制:能针对医疗 / 法律 / 金融领域微调
- 可解释性强:训练数据透明,输出 reasoning 也透明
但代价:
- 训练成本:需要专业 GPU + 高质量训练数据
- 元评测复杂:判定专业 judge 自身可靠性比通用 LLM 更难
工业团队的判断(经验性建议):小团队用通用 LLM-as-Judge 即可;规模大或高合规的场景值得评估专业 judge——一次性训练成本可能换来长期低成本。注意 JudgeBench 上多数微调 judge 的表现低于随机基线(论文 §4.2),专业 judge 同样要先过元评测。
6.12.3 LLM-as-Judge 的演进脉络
回顾 LLM-as-Judge 这几年的演进(只列有公开出处的节点,不给无出处的一致性数字):
- 2023 年:MT-Bench / Chatbot Arena 论文(2023-06)把 LLM-as-Judge 推成主流,并系统列出位置、冗长、自我偏好等偏差与 position swap 等缓解方法;G-Eval(2023-03)给出 CoT + 概率加权的 pointwise 范式;Prometheus、JudgeLM(2023-10)开启开源专业 judge 路线
- 2024 年:Arena-Hard(2024-06)引入 style control;JudgeBench(2024-10)用客观正确性检验 judge,显示当时最强的通用模型也只有 64% 左右;Agent-as-a-Judge(arXiv:2410.10934,2024-10)提出用 Agent 评 Agent(参见第 14 章 §14.15.1)
- 2025 年起:推理模型当 judge,在 JudgeBench 这类客观判定上明显领先通用模型
judge 与人工的一致性在不断提高,但提高多少没有跨任务通用的数字,只能在自家数据上测。具体技术 1-2 年后可能就显得过时,核心方法学(位置 / 长度 / 自我偏好等偏差的认知与校准)会长期适用。
读者带走的姿态:追踪最新 judge 方法,但更看重底层方法学。具体技术会变,第 6 章的偏差认知 + 校准范式不会变。这是评测体系长期工程能力的根基。
6.12.4 LLM-as-Judge 与传统 ML 的"Gold Label"概念对照
最后做一个跨范式的对照——LLM-as-Judge 与传统监督学习的"Gold Label"概念:
| 概念 | 传统 ML | LLM-as-Judge |
|---|---|---|
| Gold Label | 人工标注的真值 | 强 LLM 给的"准真值" |
| Annotator | 人 | LLM judge |
| Inter-annotator Agreement | Cohen's Kappa | Self-consistency |
| Calibration | 校准到任务真值 | 校准到人工锚点 |
| Drift | 数据分布漂移 | judge 模型 + prompt 漂移 |
这种类比让有传统 ML 背景的工程师能快速掌握 LLM-as-Judge——它不是全新的概念,是把"人工标注"流程"自动化 + AI 化"的演进。
理解这种类比的工程价值:
- 传统 ML 的标注质量管理经验可以直接迁移到 LLM-judge
- 元评测 ≈ "标注员一致性测试"
- judge calibration ≈ "annotator training"
- judge ensemble ≈ "majority voting in 标注"
读懂这个类比让 LLM-as-Judge 的所有概念变得熟悉、可解释。
6.12.5 LLM-as-Judge 在不同业务的"使用强度"差异
LLM-as-Judge 的使用强度按业务类型差别很大。下表是示意配置,不是调研统计:
| 业务类型 | judge 调用频率 | judge 模型 | 主要用途 |
|---|---|---|---|
| 客服 chatbot | 高(10% 在线采样) | 小档 / 中档模型 | Faithfulness / 友好度 |
| 内容生成 | 中(每日批量) | 中高档模型 | 创意度 / 准确度 |
| 代码助手 | 低(CI 触发) | 中高档 / 推理模型 | 代码正确性 |
| 医疗咨询 | 极高(100% 实时) | 推理模型 / 专业 judge + 人工 | 合规 + 准确性 |
| 教育辅导 | 中(每周抽样) | 中高档模型 | 难度匹配 / 解释质量 |
| 翻译 / 写作 | 中-低(按需) | 中高档模型 | 语义保持 / 流畅度 |
差异源于业务对"质量信号"的需求强度——客服需要持续监控,因为用户投诉成本高;医疗需要实时,因为安全敏感;代码助手 CI 时跑就够,因为开发者会自验证。
工程团队的判断:根据自家业务的"质量信号需求"匹配 judge 强度。不是越多越好,是匹配业务。这种"按需配置"的工程素养是评测体系成熟度的标志。
6.12.6 LLM-as-Judge 的"反馈循环"——judge 应该被持续 fine-tune 吗?
随着 judge 用了一阵子,团队会问"我们能不能 fine-tune 一个专门的 judge model?"答案是有条件可以、但要慎重。下面给出工程化的决策框架:
from dataclasses import dataclass
from typing import Iterable
@dataclass
class JudgeFineTuningDecision:
base_model: str
expected_kappa_lift: float
training_data_size: int
cost_estimate_usd: float
risk_score: float
recommendation: str
class JudgeFineTuningAdvisor:
"""是否值得 fine-tune 一个专属 judge?"""
MIN_HUMAN_ANCHOR_FOR_FT = 5000 # 至少 5k 人工标注才值
MIN_KAPPA_LIFT_FOR_GO = 0.05 # 至少 +5pp κ 提升
def evaluate(self, base_kappa: float,
available_anchor_size: int,
domain_specificity: float,
team_ml_capability: str,
monthly_query_volume: int) -> JudgeFineTuningDecision:
# 估算 fine-tune 后的 κ
if available_anchor_size >= self.MIN_HUMAN_ANCHOR_FOR_FT:
kappa_lift = 0.10 if domain_specificity >= 0.8 else 0.05
else:
kappa_lift = 0.02 # 数据不够,效果有限
# 估算成本
# 以下单价全是示意值,按自家算力与 API 价目表替换
training_cost = 100 # 一次性训练(含 GPU 时长)
monthly_inference = monthly_query_volume * 0.0005 # 自托管 judge 每次推理成本
savings_vs_api = monthly_query_volume * 0.001 # 相比调外部 API,每次省下的钱
annual_cost = training_cost + monthly_inference * 12
# 风险评分
risk = 0.0
if team_ml_capability == "low":
risk += 0.4
if domain_specificity < 0.5:
risk += 0.3 # 通用领域不必 fine-tune
if available_anchor_size < self.MIN_HUMAN_ANCHOR_FOR_FT:
risk += 0.3
if (kappa_lift >= self.MIN_KAPPA_LIFT_FOR_GO
and risk < 0.5
and savings_vs_api * 12 > annual_cost):
rec = "GO: fine-tune 值得做"
elif risk >= 0.7:
rec = "STOP: 风险高,先解决数据 / 团队能力问题"
elif kappa_lift < self.MIN_KAPPA_LIFT_FOR_GO:
rec = "WAIT: 提升不足,继续优化 prompt"
else:
rec = "MONITOR: 边界情况,再观察 1 季度"
return JudgeFineTuningDecision(
base_model="<自选的开源底座>",
expected_kappa_lift=round(kappa_lift, 3),
training_data_size=available_anchor_size,
cost_estimate_usd=round(annual_cost, 2),
risk_score=round(risk, 2),
recommendation=rec,
)
flowchart TB
Q[考虑 fine-tune judge?] --> CK1{anchor data ≥ 5k?}
CK1 -->|否| WAIT[WAIT: 先攒数据]
CK1 -->|是| CK2{domain specificity ≥ 0.8?}
CK2 -->|否| MAY[效果可能不大]
CK2 -->|是| CK3{team ML 能力?}
CK3 -->|low| RISK[STOP: 维护成本高]
CK3 -->|mid+| CK4{年成本 < 年节省?}
CK4 -->|否| WAIT
CK4 -->|是| GO[GO: fine-tune]
GO --> FT[fine-tune model]
FT --> DEPLOY[部署 + 周度 calibration]
DEPLOY --> MON[监控 κ 漂移]
style GO fill:#e8f5e9
style RISK fill:#ffebee
style WAIT fill:#fff3e0
工程实务的 5 条 fine-tune 决策原则:
- anchor 数据必须 ≥ 5k:少了 fine-tune 反而过拟合
- domain 必须 specific:通用客服不必 fine-tune,医疗 / 法律值得
- 必须有 ML 团队维护:fine-tune 不是一次性事,需季度 retrain
- 保留 API judge 做 fallback:fine-tuned model 失效时有备份
- fine-tune 的首要目的不是省钱:是为了提升 κ + domain 适配;advisor 里"年成本 < 年节省"只是一道门槛
fine-tune judge 的成功 / 失败例子(示意):
| 场景 | 结果 | 原因 |
|---|---|---|
| 法律领域 + 50k 标注 + 强 ML 团队 | ✅ 成功,κ 0.65 → 0.79 | 数据 + 能力齐备 |
| 客服通用 + 8k 标注 + 中等 ML | ⚠️ 边界,κ 0.72 → 0.74 | 提升小,维护成本高 |
| 医疗 + 3k 标注 | ❌ 过拟合,prod κ 反降 | 数据不够 |
| 任意领域 + 50k 标注 + 无 ML 维护 | ❌ 6 个月后模型 stale | 维护跟不上 |
具体例子(示意数据,需用你自己的数据复算):某金融 chatbot 团队(领域 specific=0.85, anchor=12k, ML 能力=mid, 每月 40 万次 judge 调用),代入上面的 advisor:
- expected lift: +10pp(specificity ≥ 0.8 且 anchor ≥ 5k)
- 训练成本:$100/次
- 推理成本:$200/月(相比调 API 每月省 $400,即 API 约 $600/月)
- 风险:0.0(low)
- 年成本 $2500 < 年节省 $4800 → 决策:GO
12 个月后 κ 从 0.68 涨到 0.74(实际 +6pp,低于预估的 +10pp,这很常见),年成本约 $2500 vs API 约 $7200,省约 65%。
研究背景:
- HuggingFace 的 PEFT (LoRA) 让 fine-tune 成本降到日常工程范畴
- Anthropic 在 RLHF 流程里训练 reward model 其实就是 fine-tuned judge
- judge 模型微调的公开工作见 §6.12.2(Prometheus、JudgeLM 等)
6.13 工程与组织视角
6.13.1 Judge 作为产品决策的工程哲学
最后讨论一个深层问题:LLM-as-Judge 越来越好的同时,工业团队对它的依赖也越来越深。这种依赖是合理的吗?
支持依赖的论点:
- 成本比人工低几个数量级,速度快得多
- 可以 24/7 评测每条生产 trace
- 校准后在不少任务上与人工的一致率接近人与人之间的一致率(MT-Bench:85% vs 81%)
警惕过度依赖的论点:
- judge 自身的 bias 永远存在(再校准也消不完)
- "judge 一致性高" 不等于 "判断正确"——所有 LLM 共享某些训练分布的偏见
- 如果某个 SOTA judge 在某领域系统性误判,整个行业都会受影响
工程团队的平衡建议:
- 任何上线前的关键决策都不应只靠 LLM-as-Judge:必须配人工抽样
- 元评测必须严格:定期验证 judge 的 calibration
- 多 judge ensemble:不要绑定单家 judge 模型
- 保留人工 ground truth 锚点:评测金字塔的最底层不能动
这些建议总结一句话:LLM-as-Judge 是工具不是真理。把它当工具用,能给评测体系巨大杠杆。把它当真理用,会把整个体系建在一个会缓慢漂移的地基上。
6.13.2 LLM-as-Judge 的"反思与升级"循环
最后讨论一个 LLM-as-Judge 应该有的"反思 + 升级"循环:
Quarter 1: 用现有 judge 跑评测,得到基线
Quarter 2: 元评测发现 judge 偏差,调整 prompt
Quarter 3: 换更强 judge model,重新校准
Quarter 4: 引入 ensemble 或 reasoning model
Year 2 起: 持续迭代...
工业实务:把"季度 judge 升级 review"作为团队标准仪式。每个季度审视:
- 当前 judge 模型是否仍是最优?是否在弃用计划里?
- 当前 prompt 是否需要根据新研究更新?
- 是否值得加 ensemble / reasoning model?
- 元评测分数是否仍达标?
LLM-as-Judge 的可靠性需要持续投入维护,不是一次配置完事。
6.14 何时不该用 LLM-as-Judge
诚实告诉读者 LLM-as-Judge 也有天花板:
- 极高合规场景(医疗诊断对错、法律建议)→ 必须人工,LLM-judge 不够可靠
- 创造性输出排序(哪首诗更美)→ judge 偏差太大,人工或大众投票
- 领域专业判断(这段代码是否性能最优)→ 必须领域专家
- 被测模型与 judge 模型同源(同一家族)→ 可能存在自我偏好(证据尚不足以定论,§6.3.3),防御成本很低,应换 judge 或上 ensemble
判断标准:如果你的 judge 模型在该领域的能力明显弱于人类专家,judge 结果就不可信。第 7 章会给出"何时必须人工评测"的完整决策框架。
6.15 跨专栏关联
- 《LangChain 设计与实现》专栏第 7 章讨论的输出解析与结构化输出,对应本章 §6.6 的结构化输出 prompt
- 《MCP 协议设计与实现》专栏第 17 章讨论的 Sampling 协议:MCP server 可借此请求客户端的模型做判分,是在 MCP 体系里接入 LLM-as-Judge 的一种方式
- 本专栏第 8 章 Meta-Eval:会用本章方法验证 judge 自身的可靠性
- 本专栏第 11 章 ragas 源码:会展示 §6.6 prompt 模板在 ragas 里的实际形态
- 本专栏第 16 章 安全评测:会专门讨论"判定 jailbreak 是否成功"这类对抗性 judging
6.16 本章小结
- LLM-as-Judge 把人工标注的成本和耗时降低几个数量级,是评测体系的主力
- 三种工程形态:pointwise(绝对分)、pairwise(两两对比)、reference-based(与黄金答案对比)
- 五类有论文讨论的偏差:Position / Length / Self-Preference / Style / Verbosity——其中自我偏好证据尚不足以定论;每一种都有对应的校准方法;MT-Bench 论文还指出 judge 在数学 / 推理题上判分能力受限
- Position swap、不同模型 judge 是工程必上,length control 推荐;CoT prompting 有真实但温和的提升(G-Eval 消融 0.500 → 0.514)
- JudgeBench 显示当时最强通用 judge(Claude-3.5-Sonnet)只有 64.29% accuracy、GPT-4o 只有 56.57%(随机基线 50%),judge 选型本身就是评测结论里一个十几到三十个百分点量级的噪声源
- Ensemble judging 进一步提升可靠性,对高敏感场景值得
- 极高合规、创造性、领域专业、同源模型场景下,LLM-judge 不该作为唯一手段
下一章我们补完判分方法学的最后一块——人工评测,以及它的标注流程、一致性度量、众包成本控制。