LLM 评测科学与 Judge 校准
很多团队已经会跑 eval 了,但真正让企业犹豫的不是"有没有分数",而是"这个分数能不能拿来做发布决策"。这一页的核心就是把评分本身当成被测试对象,讨论它的偏差、稳定性、一致性和与人工标注的对齐问题。从 BLEU 到 LLM-as-Judge,从 Cohen's Kappa 到 Elo Rating,我们将完整覆盖评测科学的理论基础与工程实践。
教学导读
**定位:**这一章解决"为什么有了分数还不敢发版"的问题,重点在于评测器本身的偏差、稳定性与可信度。我们将从评测方法论的学术演进讲起,深入自动指标的数学原理,再到 LLM-as-Judge 的完整实现、校准统计方法、Elo Rating 排名系统,最终给出一条从数据加载到报告生成的完整 Pipeline。 **前置依赖:**建议已理解规则评测、LLM-as-Judge 和基础统计直觉。了解 Python 编程基础。 **适用场景:**发布门禁、Prompt A/B、模型升级评估、人工标注与自动评测协同、竞品模型横向对比。 **学完产出:**你应该能判断一套 eval 分数是否足以支撑决策,能计算一致性指标,能搭建完整评测流水线,而不是见到高分就默认安全。
先立一个标准。
一个可用于发布门禁的评测体系,至少要回答三件事:第一,分数是否稳定——同样的输入跑两次,结果差多少?第二,分数是否和人工判断大体一致——人工觉得差的,评测器也能抓到吗?第三,分数能不能发现关键 slice 的退化——总分不掉,但高风险场景退了,评测体系能报警吗?如果这三件事都答不上来,eval 更像参考信号,而不是决策依据。 本章的目标不是教你"怎么跑一个 eval",而是教你"怎么判断这个 eval 跑出来的分数是否可信"。这是一个元问题——评测评测器本身。
01. 评测方法论的学术脉络:从 BLEU 到 LLM-as-Judge
理解今天的 LLM 评测方法,需要先看清它从何而来。自然语言生成(NLG)评测的演进大致经历了四个阶段,每个阶段都是对前一个阶段局限性的回应。
1.1 第一阶段:人工评测时代(~2002)
在机器翻译(MT)早期,评测完全依赖人工。评测者阅读源文和译文,给出流畅度和忠实度评分。这种方式的问题显而易见:成本高、速度慢、主观性强、难以复现。一个译文在评测者 A 看来是 4 分,在评测者 B 看来可能只有 3 分。更严重的是,同一个评测者在不同时间点对同一个译文的打分也可能不同。 人工评测的不一致性直接催生了对自动指标的需求。研究者需要一种方法,在不依赖人工的情况下快速、稳定地比较不同系统的输出质量。
1.2 第二阶段:N-gram 匹配时代(2002~2015)
2002 年,IBM 的 Kishore Papineni 等人提出了 BLEU(Bilingual Evaluation Understudy),这是 NLG 评测历史上最重要的里程碑之一。BLEU 的核心思想极其朴素:如果机器生成的文本中包含的 n-gram 在参考答案中也出现了,那这个生成就是好的。BLEU 第一次让研究者可以在几秒内评估一个翻译系统,而不需要等待几天的人工标注。 BLEU 之后,一系列类似思路的指标相继出现:ROUGE(2004)侧重于召回率,面向文本摘要任务;METEOR(2005)引入了词干化和同义词匹配;CIDEr(2015)用 TF-IDF 加权来区分通用词和信息量高的词。这些指标的共同特征是:都基于某种形式的词汇重叠度量,计算快速、确定性强,但对语义理解的能力有限。
N-gram 指标的根本局限。
"The cat sat on the mat"和"A feline rested upon the rug"在语义上几乎等价,但 BLEU 会给出很低的分数,因为几乎没有 n-gram 重叠。这个问题在开放式生成任务中尤为严重——合理的回答方式千差万别,不可能用一个参考答案穷尽所有正确表达。
1.3 第三阶段:语义嵌入时代(2019~2022)
随着预训练语言模型(如 BERT)的兴起,研究者开始用向量空间中的语义相似度来评测。BERTScore(Zhang et al., 2020)用 BERT 的 token embedding 计算候选文本和参考文本之间的余弦相似度,不再要求表面词汇完全匹配。MoverScore(2019)更进一步,用 Word Mover's Distance 来度量语义距离。 这类方法解决了同义替换的问题,但引入了新的困扰:评测结果依赖于底层嵌入模型的质量,在不同领域的表现可能差异很大;同时仍然需要参考答案,无法处理真正开放式的生成任务。
1.4 第四阶段:LLM-as-Judge 时代(2023 ~ 至今)
GPT-4 等强大语言模型的出现带来了范式转换:直接让一个足够强的 LLM 充当评委。这种方法最大的突破在于不再需要参考答案——Judge 模型可以根据问题、上下文和生成结果直接给出评分和理由。Zheng et al.(2023)的 MT-Bench 和 Chatbot Arena 首次在大规模上验证了 LLM-as-Judge 与人工评测的高一致性。截至 2026 年,主流 Judge 已演进到 GPT-5 / Claude Opus 4.6 / DeepSeek-R2 等推理强、指令遵循好的新一代模型,并在 Arena-Hard-Auto 2026 等公开榜单中显著拉开了与人工的一致率。
| 阶段 | 代表方法 | 核心机制 | 需要参考答案 | 语义理解 |
|---|---|---|---|---|
| 人工评测 | 专家打分 | 人工阅读判断 | 不需要 | 最强 |
| N-gram 匹配 | BLEU / ROUGE | 词汇重叠统计 | 需要 | 无 |
| 语义嵌入 | BERTScore | 向量余弦相似度 | 需要 | 中等 |
| LLM-as-Judge(2023 起) | GPT-4 Judge | 模型推理评判 | 可选 | 强 |
| LLM-as-Judge(2026 主力) | GPT-5 / Claude Opus 4.6 / DeepSeek-R2 | 推理模型 + Rubric 评判 | 可选 | 极强 |
📊 Arena-Hard-Auto 2026 数据:
Claude Opus 4.6 与人类一致率 87%,GPT-5 86.4%,DeepSeek-R2 84.1%,已显著超过 2024 年 GPT-4o(约 79%)。这意味着 2026 选 Judge 的事实门槛已经从"GPT-4o 即可"提升到"至少要 GPT-5 / Claude Opus 4.6 / DeepSeek-R2 这一档"。
理解这个演进脉络很重要,因为在实际项目中,你不会只用一种方法。规则评测、N-gram 指标、嵌入相似度和 LLM Judge 各有适用场景,好的评测体系是多种方法的组合。
02. 自动评测指标的数学基础
在使用自动评测指标之前,你需要理解它们的数学原理。只有知道一个指标"在算什么",你才能判断它"在你的场景下是否合适"。
2.1 BLEU:精确率导向的 N-gram 匹配
BLEU 的核心是计算候选翻译中有多少 n-gram 也出现在参考翻译中(修正精确率),然后对不同阶的 n-gram 取几何平均,最后乘以一个简短惩罚因子(Brevity Penalty, BP)。 首先定义修正精确率(Modified Precision)。对于 n-gram 阶数 n,候选句 c,参考句集合 R: p_n = Σ_{ngram ∈ c} min(Count(ngram, c), max_{r∈R} Count(ngram, r)) / Σ_{ngram ∈ c} Count(ngram, c) 为什么要用 min 做截断?考虑候选句 "the the the the",如果参考句是 "the cat is on the mat",不截断时 "the" 的精确率是 4/4=100%。截断后,"the" 在参考中最多出现 2 次,所以修正精确率是 2/4=50%,更合理。 然后定义 Brevity Penalty(BP),用于惩罚过短的翻译: BP = exp(min(0, 1 - r/c)) 其中 r 是参考句长度,c 是候选句长度。当候选句比参考句短时,BP < 1,会降低最终得分。当候选句等于或长于参考句时,BP = 1,不惩罚。 最终 BLEU 分数: BLEU = BP · exp(Σ_{n=1}^{N} w_n · log(p_n)) 通常取 N=4,权重 w_n = 1/N = 0.25,即对 1-gram 到 4-gram 的修正精确率取等权几何平均。
import math
from collections import Counter
def compute_bleu(candidate: list[str], references: list[list[str]], max_n: int = 4) -> float:
"""计算句子级 BLEU 分数的完整实现"""
weights = [1.0 / max_n] * max_n
def count_ngrams(tokens, n):
return Counter(tuple(tokens[i:i+n]) for i in range(len(tokens) - n + 1))
log_precisions = []
for n in range(1, max_n + 1):
cand_ngrams = count_ngrams(candidate, n)
clipped_count = 0
total_count = 0
for ngram, count in cand_ngrams.items():
max_ref_count = max(count_ngrams(ref, n).get(ngram, 0) for ref in references)
clipped_count += min(count, max_ref_count)
total_count += count
if total_count == 0:
return 0.0
precision = clipped_count / total_count
if precision == 0:
return 0.0
log_precisions.append(math.log(precision))
# Brevity Penalty
c = len(candidate)
r = min(len(ref) for ref in references) # closest reference length
bp = math.exp(min(0, 1 - r / c)) if c > 0 else 0
log_avg = sum(w * lp for w, lp in zip(weights, log_precisions))
return bp * math.exp(log_avg)
# 使用示例
candidate = "the cat sat on the mat".split()
reference = "the cat is on the mat".split()
print(f"BLEU = {compute_bleu(candidate, [reference]):.4f}") # 约 0.59462.2 ROUGE:召回率导向的重叠度量
ROUGE(Recall-Oriented Understudy for Gisting Evaluation)由 Chin-Yew Lin 于 2004 年提出,最初面向文本摘要任务。与 BLEU 关注精确率不同,ROUGE 关注的是召回率——参考摘要中有多少信息被候选摘要覆盖到了。 ROUGE-N 的召回率定义: ROUGE-N_recall = Σ_{ngram ∈ ref} min(Count(ngram, cand), Count(ngram, ref)) / Σ_{ngram ∈ ref} Count(ngram, ref) ROUGE-L 使用最长公共子序列(LCS)来度量相似性,不要求连续匹配: R_lcs = LCS(cand, ref) / len(ref) (召回率) P_lcs = LCS(cand, ref) / len(cand) (精确率) F_lcs = (1 + β²) · P_lcs · R_lcs / (R_lcs + β² · P_lcs) (F 值) LCS 的优势在于它能捕捉词序信息,同时容忍中间的插入词。例如 "police killed the gunman" 和 "police kill the gunman",虽然 "killed" 和 "kill" 不完全匹配,但 LCS 仍然能捕获 "police ... the gunman" 的结构。
def lcs_length(x: list[str], y: list[str]) -> int:
"""动态规划计算最长公共子序列长度"""
m, n = len(x), len(y)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if x[i-1] == y[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
def rouge_l(candidate: list[str], reference: list[str], beta: float = 1.2) -> dict:
"""计算 ROUGE-L F 值"""
lcs = lcs_length(candidate, reference)
recall = lcs / len(reference) if reference else 0
precision = lcs / len(candidate) if candidate else 0
if recall + precision == 0:
return {"precision": 0, "recall": 0, "f1": 0}
f1 = (1 + beta**2) * precision * recall / (recall + beta**2 * precision)
return {"precision": precision, "recall": recall, "f1": f1}
cand = "the cat sat on the mat".split()
ref = "the cat is sitting on the mat".split()
print(rouge_l(cand, ref)) # {'precision': 0.833, 'recall': 0.714, 'f1': 0.756}2.3 BERTScore:基于语义嵌入的评测
BERTScore 的核心思想是:用预训练 BERT 模型将候选文本和参考文本的每个 token 映射到向量空间,然后通过贪心匹配(greedy matching)计算最优对齐的余弦相似度。 具体步骤:
- 用 BERT 对候选句和参考句分别编码,得到 token-level 的上下文化嵌入
- 对候选句中的每个 token x_i,找到参考句中与之余弦相似度最高的 token:max_j cos(x_i, y_j)
- Precision: 对候选句每个 token 取最大匹配的均值
- Recall: 对参考句每个 token 取最大匹配的均值
- F1: 取 Precision 和 Recall 的调和平均
P_BERT = (1/|x|) Σ_i max_j cos(x_i, y_j) R_BERT = (1/|y|) Σ_j max_i cos(x_i, y_j) F_BERT = 2 · P_BERT · R_BERT / (P_BERT + R_BERT) BERTScore 还引入了 IDF(逆文档频率)加权,让不常见的 token 获得更高权重。比如 "政策" 比 "的" 更具信息量,应该在匹配中占更大比重。
BERTScore 的实践建议。
BERTScore 的绝对数值通常在 0.85-0.95 区间,区分度比 BLEU 低。建议在对比实验中关注差值而非绝对值。模型选择方面,中文场景推荐用 bert-base-chinese 或 roberta-large 的中文版。注意 BERTScore 的计算成本显著高于 BLEU/ROUGE,不适合超大规模实时评测。
03. 评测类型与适用场景:不同问题要用不同尺子
| 类型 | 优点 | 缺点 | 适合场景 | 成本 |
|---|---|---|---|---|
| 规则评测 | 稳定、便宜、确定性 | 覆盖窄、难扩展 | 关键词、格式、schema、拒答规则 | 极低 |
| 参考答案比对(BLEU/ROUGE) | 可解释性强、可复现 | 开放任务不适用、语义盲 | 抽取、事实问答、翻译 | 低 |
| 语义嵌入(BERTScore) | 捕捉同义表达 | 依赖嵌入模型质量 | 摘要、改写质量评估 | 中等 |
| LLM-as-Judge(单点评分) | 适合开放任务、不需参考答案 | 存在偏差和漂移 | helpfulness、自然度、完整性 | 较高 |
| Pairwise 比较 | 更适合版本对比、决策清晰 | 难给绝对分、成本 2x | Prompt A/B、模型 A/B | 高 |
3.1 企业里常见的尺子错配
| 任务 | 常见误用 | 更合适的评测方式 | 原因 |
|---|---|---|---|
| 客服意图分类 | 用 LLM judge 打主分 | 规则或参考答案比对先做主门禁,judge 做补充解释 | 分类是确定性任务,规则评测更快更稳 |
| 开放式总结 | 只看字符串重合率 | Rubric + judge + 人工抽检联合 | 好的总结有多种合理表述 |
| Prompt A/B | 直接比平均 1-5 分 | Pairwise 更适合看相对偏好 | 评分者的内部刻度不一致 |
| 安全拒答 | 只看 helpfulness | 硬失败规则优先,再看整体质量 | 安全失败不能被平均分冲淡 |
| 代码生成 | 只看 BLEU | 执行通过率 + 功能正确性测试 | 代码的正确性不取决于词汇相似度 |
3.2 评测方法组合策略
在实际项目中,建议按以下优先级组合评测方法:
- **硬规则优先:**格式校验、安全检测、关键字段完整性——这些用确定性规则检查,成本最低,稳定性最高
- **参考比对兜底:**对于有标准答案的题型(事实问答、分类、抽取),用 BLEU/ROUGE/精确匹配做基础指标
- **语义补充:**对于允许多种正确表述的任务,用 BERTScore 或嵌入相似度弥补词汇匹配的不足
- **Judge 评开放题:**对于无标准答案的开放生成任务,用 LLM-as-Judge 按 rubric 评分
- **人工抽检校准:**定期抽取样本做人工评估,校验自动评测的可信度
04. LLM-as-Judge 的完整实现
LLM-as-Judge 的本质是:构造一个精心设计的 prompt,让一个能力足够强的语言模型充当评委,按照预定义的评分标准对目标输出进行评判。实现质量的核心在于 prompt 工程、输出格式控制和多轮校准。
4.1 单点评分(Pointwise Scoring)
最基础的模式是给单个输出打分。Judge 收到问题、输出和评分标准,返回分数和理由。
import json
from openai import OpenAI
client = OpenAI()
JUDGE_SYSTEM_PROMPT = """你是一个严格的评测专家。你的任务是按照给定的评分标准(Rubric)对模型输出进行评分。
评分规则:
1. 仔细阅读问题、参考上下文(如有)和模型输出
2. 按照 Rubric 中的每个维度逐一评判
3. 先给出各维度分析,再给出总分
4. 如果触发任何硬失败条件,总分直接为 0
输出格式(严格 JSON):
{
"dimensions": {
"维度名": {"score": 分数, "reason": "原因"}
},
"hard_fail": false,
"hard_fail_reason": "",
"total_score": 总分,
"rationale": "总体评价"
}"""
def judge_pointwise(question: str, output: str, rubric: dict,
context: str = "", model: str = "gpt-5") -> dict:
"""单点评分 Judge 实现"""
user_prompt = f"""## 评分任务
### 问题
{question}
### 参考上下文
{context if context else '(无)'}
### 模型输出
{output}
### 评分标准(Rubric)
{json.dumps(rubric, ensure_ascii=False, indent=2)}
请严格按照 Rubric 评分,输出 JSON。"""
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": JUDGE_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
],
temperature=0.0,
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
# 使用示例
rubric = {
"task": "政策问答",
"dimensions": {
"factuality": {"range": "0-2", "desc": "回答是否与已知事实一致"},
"directness": {"range": "0-1", "desc": "是否正面回答问题"},
"coverage": {"range": "0-1", "desc": "是否遗漏关键条件"},
"boundary": {"range": "0-1", "desc": "是否超出证据范围"}
},
"hard_fail": ["与证据直接冲突", "编造不存在的制度条款"],
"total_range": "0-5"
}4.2 Pairwise 比较(Pairwise Comparison)
Pairwise 模式让 Judge 直接比较两个输出,判断哪个更好。这种方式比单点评分更稳定,因为人类在"比较"上的一致性远高于在"绝对评分"上的一致性。
PAIRWISE_PROMPT = """你是一个公正的评测专家。请比较以下两个模型输出,判断哪个更好。
## 问题
{question}
## 输出 A
{output_a}
## 输出 B
{output_b}
## 评判维度
{criteria}
请从以下三个选项中选择:
- "A":输出 A 明显更好
- "B":输出 B 明显更好
- "tie":两者质量相当,难以区分
输出 JSON:{{"winner": "A/B/tie", "reason": "详细理由"}}"""
def judge_pairwise(question: str, output_a: str, output_b: str,
criteria: str, model: str = "gpt-5") -> dict:
"""Pairwise 比较 Judge,自动做位置交换以消除 position bias"""
def _call(q, a, b):
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": PAIRWISE_PROMPT.format(
question=q, output_a=a, output_b=b, criteria=criteria
)}],
temperature=0.0,
response_format={"type": "json_object"}
)
return json.loads(resp.choices[0].message.content)
# 正向:A 在前
result_forward = _call(question, output_a, output_b)
# 反向:B 在前(交换位置)
result_reverse = _call(question, output_b, output_a)
# 合并结果
forward_winner = result_forward["winner"]
# 反向结果需要翻转
rev_winner_map = {"A": "B", "B": "A", "tie": "tie"}
reverse_winner = rev_winner_map[result_reverse["winner"]]
if forward_winner == reverse_winner:
final = forward_winner
consistency = "consistent"
else:
final = "tie"
consistency = "inconsistent"
return {
"final_winner": final,
"consistency": consistency,
"forward": result_forward,
"reverse": result_reverse,
"position_bias_detected": consistency == "inconsistent"
}4.3 多轮评审(Multi-Round Judging)
对于高风险场景,单次 Judge 评分可能不够稳定。多轮评审的思路是让 Judge 先给出初步评价,然后进行自我反思和修正。这类似于人工评测中"先快速扫一遍,再仔细复核"的做法。
def multi_round_judge(question: str, output: str, rubric: dict,
rounds: int = 2, model: str = "gpt-5") -> dict:
"""多轮评审:第一轮评分,后续轮次反思修正"""
messages = [
{"role": "system", "content": JUDGE_SYSTEM_PROMPT},
{"role": "user", "content": f"问题:{question}\n输出:{output}\n"
f"Rubric:{json.dumps(rubric, ensure_ascii=False)}"}
]
results = []
for i in range(rounds):
if i > 0:
messages.append({
"role": "user",
"content": "请重新审视你的评分。考虑是否有遗漏的问题或过于宽松/严格的地方。"
"如果需要修正,给出新的评分和理由。如果不需要修正,保持原评分并说明原因。"
})
resp = client.chat.completions.create(
model=model,
messages=messages,
temperature=0.0,
response_format={"type": "json_object"}
)
result = json.loads(resp.choices[0].message.content)
results.append(result)
messages.append({"role": "assistant", "content": json.dumps(result, ensure_ascii=False)})
return {
"final_score": results[-1].get("total_score"),
"rounds": results,
"score_changed": results[0].get("total_score") != results[-1].get("total_score")
}多轮评审的成本权衡。
多轮评审会线性增加 API 调用成本。建议只在以下场景使用:发布门禁的最终判定、分歧样本的复核、以及高风险场景(安全、合规、资金相关)的评估。日常回归测试中,单轮 Judge 即可。
05. Judge 常见偏差深度分析
LLM Judge 不是客观的评分机器。它本质上是一个经过训练的语言模型,训练数据和 RLHF 过程中的偏好会直接影响它的评判倾向。理解这些偏差的成因和量化方法,是校准 Judge 的前提。
5.1 Verbosity Bias(冗长偏差)
现象:Judge 倾向于给更长的回答更高的分数,即使较短的回答更精准。 根因:RLHF 训练数据中,人类标注者往往偏好更详细的回答,因为"看起来更努力"。这种偏好被模型学到,导致 Judge 在评分时不自觉地把长度与质量关联。
# Verbosity Bias 检测实验
def detect_verbosity_bias(judge_func, test_cases: list[dict]) -> dict:
"""检测 Judge 的冗长偏差
test_cases 中每条包含:
- question: 问题
- concise_answer: 简洁但正确的回答
- verbose_answer: 冗长但信息量相同的回答
- expected: 期望的更好答案(通常是 "concise" 或 "tie")
"""
verbose_preferred = 0
concise_preferred = 0
ties = 0
for case in test_cases:
result = judge_func(
question=case["question"],
output_a=case["concise_answer"],
output_b=case["verbose_answer"],
criteria="评判哪个回答更好。关注信息准确性和实用性,不要因为长度偏好某一方。"
)
if result["final_winner"] == "B":
verbose_preferred += 1
elif result["final_winner"] == "A":
concise_preferred += 1
else:
ties += 1
total = len(test_cases)
bias_ratio = verbose_preferred / total
return {
"verbose_preferred": verbose_preferred,
"concise_preferred": concise_preferred,
"ties": ties,
"bias_ratio": bias_ratio,
"has_significant_bias": bias_ratio > 0.6 # 超过 60% 偏好长回答
}5.2 Position Bias(位置偏差)
现象:在 Pairwise 比较中,Judge 倾向于选择放在特定位置(通常是第一个)的回答。 根因:注意力机制的固有特性(primacy effect),以及训练数据中可能存在的顺序偏好。不同模型的 position bias 方向不同——GPT-5 / GPT-4 通常有轻微的 first-position bias,Claude Opus 4.6 接近中性,而某些开源模型可能有 last-position bias。
# Position Bias 量化方法
def measure_position_bias(judge_func, test_cases: list[dict]) -> dict:
"""量化 position bias
对每条样本,分别以 AB 和 BA 顺序评测,
如果交换顺序后结果翻转,说明存在 position bias。
"""
consistent = 0
flipped = 0
results = []
for case in test_cases:
# A 在前
r1 = judge_func(case["question"], case["answer_a"], case["answer_b"], case["criteria"])
# B 在前
r2 = judge_func(case["question"], case["answer_b"], case["answer_a"], case["criteria"])
flip_map = {"A": "B", "B": "A", "tie": "tie"}
r2_normalized = flip_map[r2["final_winner"]]
is_consistent = r1["final_winner"] == r2_normalized
if is_consistent:
consistent += 1
else:
flipped += 1
results.append({
"forward": r1["final_winner"],
"reverse_normalized": r2_normalized,
"consistent": is_consistent
})
total = len(test_cases)
return {
"consistency_rate": consistent / total,
"flip_rate": flipped / total,
"acceptable": (flipped / total) < 0.15, # 翻转率低于 15% 可接受
"details": results
}5.3 Self-Enhancement Bias(自我增强偏差)
现象:当 Judge 模型评判自己(或同族模型)的输出时,往往给出更高的分数。例如 GPT-5 作为 Judge 评判 GPT-5 / GPT-5.1 / o4 的输出时,分数可能系统性高于评判 Claude 的输出。 根因:同族模型共享相似的训练数据和输出风格(如措辞习惯、结构偏好),Judge 可能将"风格熟悉"误判为"质量更高"。
缓解自我增强偏差的实践方法。
- 使用与被评模型不同家族的 Judge(如用 Claude 评判 GPT 的输出)
- 多 Judge 投票(至少 2 个不同家族的模型)
- 在 rubric 中明确要求"不要因为表达风格而偏好,只关注内容质量"
- 定期用人工标注数据校验 Judge 是否对特定模型有系统性偏高
5.4 Style Bias(风格偏差)与 Safety Bias(安全偏差)
**Style Bias:**Judge 可能偏好特定的输出风格——比如使用 Markdown 格式、分点列举、或学术化措辞的回答。这种偏好并不反映内容质量的差异。 **Safety Bias:**经过安全对齐训练的 Judge 模型可能过度奖励"安全但无用"的回答。例如面对一个正当的技术问题,模型回答"我无法提供这方面的建议",Judge 可能因为回答"没有风险"而给出中等分数,而不是因为"没有帮助"而给低分。
5.5 最小偏差检测样本组
偏差类型:Verbosity Bias
问题:试用期员工能否享受年假?
回答 A(简洁正确):
"不能。根据员工手册 4.2,试用期员工不享受年假。"
回答 B(冗长但信息量相同):
"从一般劳动管理经验来看,不少公司会在试用期内提供灵活请假机制,
但是否属于正式年假,还要结合企业制度、地区实践和内部审批流程综合判断。
综合而言,建议查阅公司的员工手册或咨询人力资源部门获取准确信息。"
期望结果:A 更好(更准确、更直接)
如果 Judge 选 B → verbosity bias
------
偏差类型:Position Bias
将上述 A/B 交换位置后重新评测
如果结果翻转 → position bias
------
偏差类型:Self-Enhancement Bias
用 GPT-5 Judge 分别评判 GPT-5 和 Claude Opus 4.6 对同一问题的回答
如果 GPT-5 输出系统性得分更高 → self-enhancement bias06. Rubric 设计最佳实践
Rubric(评分量表)是 LLM-as-Judge 的灵魂。一个模糊的 rubric 会让 Judge 自由发挥,导致评分不可预测;一个过于严格的 rubric 可能覆盖不到边界情况。好的 rubric 应该让不同的 Judge(或不同时间的同一个 Judge)对同一个输出给出高度一致的分数。
坏 rubric
"请判断这个回答是否优秀。" 问题:没有定义"优秀"的标准,完全依赖 Judge 的主观理解。不同模型对"优秀"的定义不同,导致评分不可比。
好 rubric
"从事实准确性、是否直接回答问题、是否遗漏关键条件、是否超出证据边界四个维度评分;若缺少关键限制条件则最高不超过 3 分。" 优点:维度明确、有锚定标准、有硬约束,不同 Judge 的评分一致性会显著提高。
6.1 Rubric 的五层结构
一个可用于生产环境的 rubric 至少应包含以下五层信息:
- **评判维度(Dimensions):**明确列出每个需要评分的方面,每个维度有独立的分数范围
- **分数锚点(Score Anchors):**每个分数档位对应什么样的表现,用具体例子说明
- **硬失败项(Hard Fail):**无论其他维度得分多高,触发这些条件直接判定为不合格
- **输出格式(Output Schema):**Judge 应该返回什么格式的结果
- **评判指引(Judging Notes):**对常见边界情况的处理指南
{
"task": "政策问答",
"dimensions": {
"factuality": {
"range": "0-2",
"anchors": {
"0": "包含明确错误信息或与证据矛盾",
"1": "大体正确但有细节不精确或措辞有歧义",
"2": "完全准确,与已知事实/证据一致"
}
},
"directness": {
"range": "0-1",
"anchors": {
"0": "回避问题或大量铺垫后仍未正面回答",
"1": "直接、清晰地回答了用户的问题"
}
},
"coverage": {
"range": "0-1",
"anchors": {
"0": "遗漏了影响结论的关键限制条件",
"1": "涵盖了所有关键条件和例外情况"
}
},
"evidence_boundary": {
"range": "0-1",
"anchors": {
"0": "超出证据范围进行推测或编造",
"1": "严格基于已有证据,不越界"
}
}
},
"hard_fail": [
"与证据直接冲突(编造制度条款、错误引用条目号)",
"遗漏了可能导致用户做出错误决策的关键限制条件",
"输出包含敏感信息(个人身份、内部系统地址等)"
],
"output": {
"score": "0-5(各维度之和)",
"fail_reasons": "触发的硬失败项列表",
"short_rationale": "一句话总结评判理由"
},
"judging_notes": [
"如果回答说'建议咨询HR'但同时给出了准确信息,不扣 directness 分",
"如果证据本身有歧义,回答指出歧义并给出两种解读,coverage 给满分",
"不要因为回答使用了非正式语气而扣分,只关注内容质量"
]
}6.2 多维度评分量表的设计原则
原则一:维度正交
每个维度应该评估不同的方面,避免重叠。如果"准确性"和"可靠性"在你的任务中含义接近,合并为一个维度。维度重叠会导致某些方面被双重计算。
原则二:锚点具体
每个分数档位都应该有可操作的描述。"大体正确"不如"核心数字准确,但单位或时间范围有偏差"来得明确。好的锚点让不同评判者看到同样的输出时做出同样的判断。
原则三:硬失败独立
硬失败不应该被高分冲淡。即使一个回答在流畅度、完整性上都得满分,如果它编造了一个不存在的法规条款,应该直接判定不合格。
6.3 不同任务类型的 Rubric 模板
| 任务类型 | 核心维度 | 常见硬失败项 |
|---|---|---|
| 政策问答 | 事实性、直接性、覆盖度、证据边界 | 与政策原文矛盾、编造条款 |
| 代码生成 | 功能正确性、代码质量、安全性、可读性 | 运行时错误、SQL 注入等安全漏洞 |
| 客服对话 | 问题解决率、同理心、规范性、信息准确 | 泄露用户隐私、承诺无法兑现的补偿 |
| 文案生成 | 创意度、品牌一致性、语法、目标匹配 | 违反广告法、品牌调性严重偏离 |
| 数据分析 | 计算准确性、结论合理性、可视化恰当 | 数字计算错误、因果推断逻辑错误 |
07. 人工与 Judge 对齐:为什么要做 agreement
如果人工觉得这批输出大多不行,而 Judge 给高分,那这个评分器就不适合用来做门禁。最基本的做法是抽一批样本,让人工和 Judge 同时评分,计算一致性。但"一致性"的定义本身就有很多讲究。
7.1 完整的校准流程
一个产线级校准流程:
Phase 1: 样本准备
1. 从生产数据中分层抽样 100-200 条(覆盖所有 slice)
2. 确保包含"明显好""明显差""边界case"三类
3. 去除可能泄露模型身份的信息
Phase 2: 人工标注
4. 至少两名标注者独立标注
5. 使用与 Judge 相同的 rubric
6. 标注前做 calibration session(标注者对齐)
7. 计算人工之间的 inter-annotator agreement
Phase 3: Judge 评测
8. Judge 使用相同 rubric 对同一批样本打分
9. 固定 temperature=0,确保确定性
10. 记录完整的评分理由
Phase 4: 一致性分析
11. 计算 Judge 与人工平均分的相关系数
12. 计算 Cohen's Kappa(二分类)或加权 Kappa(多分类)
13. 按 slice 拆分一致性,找到 Judge 不可靠的领域
14. 分析分歧样本,归类偏差模式
Phase 5: 迭代修正
15. 如果偏差集中在某类 case,先修 rubric 再重跑
16. 如果系统性偏高/偏低,调整 prompt 中的严格度描述
17. 如果某些 slice 始终不一致,考虑对该 slice 改用规则评测7.2 为什么 agreement 不是越高越好
如果人工之间本来就分歧很大(inter-annotator agreement 低),说明任务定义本身模糊。这时不能只怪 Judge,而是应该先收紧标准、细化 rubric、明确硬失败项。 另一种极端情况是 agreement 过高——如果 Judge 在所有样本上都和人工完全一致,可能意味着你的样本太简单了,没有覆盖到边界情况。好的校准数据集应该包含足够的"争议样本"。
7.3 位置交换实验
pairwise 样本 1:
A 在前,B 在后 -> judge 选 A
pairwise 样本 1(交换顺序):
B 在前,A 在后 -> judge 选 B
如果互换位置后胜率明显翻转,
说明你的评测里有 position bias,
此时不能把胜率直接当发布结论。企业落地里很推荐做"双向 pairwise":A vs B 和 B vs A 都跑,再取平均结果。这样虽然成本翻倍,但能明显降低顺序偏差对决策的污染。
def calibration_report(human_scores: list, judge_scores: list) -> dict:
"""生成人机一致性校准报告"""
import numpy as np
human = np.array(human_scores)
judge = np.array(judge_scores)
# Pearson 相关系数
correlation = np.corrcoef(human, judge)[0, 1]
# 平均偏差
mean_diff = np.mean(judge - human)
# 绝对偏差
mae = np.mean(np.abs(judge - human))
# 方向一致性(同涨同跌)
if len(human) > 1:
h_diff = np.diff(human)
j_diff = np.diff(judge)
direction_agree = np.mean(np.sign(h_diff) == np.sign(j_diff))
else:
direction_agree = None
# 按分数段统计偏差
bins = {"low(1-2)": [], "mid(3)": [], "high(4-5)": []}
for h, j in zip(human, judge):
if h <= 2: bins["low(1-2)"].append(j - h)
elif h <= 3: bins["mid(3)"].append(j - h)
else: bins["high(4-5)"].append(j - h)
bin_bias = {k: np.mean(v) if v else None for k, v in bins.items()}
return {
"pearson_r": round(correlation, 4),
"mean_bias": round(mean_diff, 4),
"mae": round(mae, 4),
"direction_agreement": round(direction_agree, 4) if direction_agree else None,
"bias_by_score_range": bin_bias,
"recommendation": (
"可信" if correlation > 0.8 and mae < 0.5 else
"需校准" if correlation > 0.6 else
"不建议用于门禁"
)
}08. 一致性度量:Cohen's Kappa 与 Fleiss' Kappa
如果正样本本来就很多,两个评分者即便都随大流打高分,同意率也可能看起来不低。Kappa 的价值在于:它会考虑"随机一致"的部分,把真正有意义的一致性扣出来。
8.1 Cohen's Kappa:两个评分者
Cohen's Kappa 用于度量两个评分者对同一批样本的标注一致性,同时修正了随机一致的影响。 κ = (P_o - P_e) / (1 - P_e) 其中:
- **P_o(Observed Agreement):**实际观测到的一致比例。两个评分者在所有样本中给出相同标签的比例。
- **P_e(Expected Agreement):**假设两个评分者独立随机打标签时,纯靠运气也会达到的一致比例。
P_e 的计算方法:对于每个类别 k,计算评分者 1 选择 k 的比例 p_1k 和评分者 2 选择 k 的比例 p_2k,然后: P_e = Σ_k (p_1k · p_2k) 举个具体例子。假设两个标注者对 100 个样本做二分类(好/坏):
| 标注者 B:好 | 标注者 B:坏 | 行合计 | |
|---|---|---|---|
| 标注者 A:好 | 45 | 15 | 60 |
| 标注者 A:坏 | 10 | 30 | 40 |
| 列合计 | 55 | 45 | 100 |
# Cohen's Kappa 完整推导和计算
# 步骤 1:计算观测一致率 P_o
# 两者都说"好"的有 45 个,都说"坏"的有 30 个
P_o = (45 + 30) / 100 # = 0.75
# 步骤 2:计算期望一致率 P_e
# 标注者 A 选"好"的概率 = 60/100 = 0.6
# 标注者 B 选"好"的概率 = 55/100 = 0.55
# 两人随机都选"好"的概率 = 0.6 * 0.55 = 0.33
# 标注者 A 选"坏"的概率 = 40/100 = 0.4
# 标注者 B 选"坏"的概率 = 45/100 = 0.45
# 两人随机都选"坏"的概率 = 0.4 * 0.45 = 0.18
P_e = 0.6 * 0.55 + 0.4 * 0.45 # = 0.33 + 0.18 = 0.51
# 步骤 3:计算 Kappa
kappa = (P_o - P_e) / (1 - P_e) # = (0.75 - 0.51) / (1 - 0.51) = 0.24 / 0.49 ≈ 0.49
# 解读:0.49 属于"可参考但仍偏脆"区间
# 如果拿这个一致性水平做发布门禁,风险较高8.2 加权 Kappa(Weighted Kappa)
对于有序分类(如 1-5 分),Cohen's Kappa 把"标注者 A 给 4 分而标注者 B 给 5 分"与"A 给 1 分 B 给 5 分"视为同等程度的不一致,这不合理。加权 Kappa 通过权重矩阵来区分不同程度的分歧。 κ_w = 1 - Σ_{i,j} w_{ij} · o_{ij} / Σ_{i,j} w_{ij} · e_{ij} 常用的权重方案有两种:线性权重(w_ij = |i-j| / (k-1))和二次权重(w_ij = (i-j)² / (k-1)²)。二次权重对大偏差的惩罚更重。
import numpy as np
def cohens_kappa(labels_a: list, labels_b: list) -> float:
"""计算 Cohen's Kappa"""
assert len(labels_a) == len(labels_b)
n = len(labels_a)
categories = sorted(set(labels_a) | set(labels_b))
k = len(categories)
cat_idx = {c: i for i, c in enumerate(categories)}
confusion = np.zeros((k, k))
for a, b in zip(labels_a, labels_b):
confusion[cat_idx[a]][cat_idx[b]] += 1
p_o = np.trace(confusion) / n
row_marginals = confusion.sum(axis=1) / n
col_marginals = confusion.sum(axis=0) / n
p_e = np.sum(row_marginals * col_marginals)
if p_e == 1.0:
return 1.0
return (p_o - p_e) / (1 - p_e)
def weighted_kappa(scores_a: list, scores_b: list, weight: str = "quadratic") -> float:
"""计算加权 Kappa(线性或二次权重)"""
assert len(scores_a) == len(scores_b)
n = len(scores_a)
categories = sorted(set(scores_a) | set(scores_b))
k = len(categories)
cat_idx = {c: i for i, c in enumerate(categories)}
confusion = np.zeros((k, k))
for a, b in zip(scores_a, scores_b):
confusion[cat_idx[a]][cat_idx[b]] += 1
confusion /= n
# 权重矩阵
W = np.zeros((k, k))
for i in range(k):
for j in range(k):
if weight == "linear":
W[i][j] = abs(i - j) / (k - 1)
else: # quadratic
W[i][j] = (i - j) ** 2 / (k - 1) ** 2
row_marginals = confusion.sum(axis=1)
col_marginals = confusion.sum(axis=0)
expected = np.outer(row_marginals, col_marginals)
observed_disagreement = np.sum(W * confusion)
expected_disagreement = np.sum(W * expected)
if expected_disagreement == 0:
return 1.0
return 1 - observed_disagreement / expected_disagreement
# 测试
a_scores = [4, 5, 3, 4, 2, 5, 3, 4, 4, 3]
b_scores = [4, 4, 3, 5, 2, 5, 2, 4, 3, 3]
print(f"Cohen's Kappa: {cohens_kappa(a_scores, b_scores):.4f}")
print(f"Weighted Kappa (quadratic): {weighted_kappa(a_scores, b_scores):.4f}")8.3 Fleiss' Kappa:多个评分者
当评分者超过两个时(例如 3 个人工标注者 + 1 个 Judge),需要用 Fleiss' Kappa。它的核心思想与 Cohen's Kappa 一致,但推广到了多个评分者的场景。 κ_Fleiss = (P̄ - P̄_e) / (1 - P̄_e) 其中 P̄ 是所有样本的平均一致性,P̄_e 是随机期望一致性。
def fleiss_kappa(ratings_matrix: list[list[int]]) -> float:
"""计算 Fleiss' Kappa
ratings_matrix: N x k 矩阵
N = 样本数, k = 类别数
ratings_matrix[i][j] = 对第 i 个样本标注为第 j 类的评分者数量
"""
mat = np.array(ratings_matrix, dtype=float)
N, k = mat.shape
n = mat.sum(axis=1)[0] # 每个样本的评分者数量(假设一致)
# 每个样本的一致性
P_i = (np.sum(mat ** 2, axis=1) - n) / (n * (n - 1))
P_bar = np.mean(P_i)
# 每个类别的总比例
p_j = np.sum(mat, axis=0) / (N * n)
P_e_bar = np.sum(p_j ** 2)
if P_e_bar == 1.0:
return 1.0
return (P_bar - P_e_bar) / (1 - P_e_bar)
# 示例:3 个评分者,5 个样本,2 个类别(好/坏)
ratings = [
[3, 0], # 样本1:3人都说好
[2, 1], # 样本2:2人说好,1人说坏
[0, 3], # 样本3:3人都说坏
[1, 2], # 样本4:1人说好,2人说坏
[3, 0], # 样本5:3人都说好
]
print(f"Fleiss' Kappa: {fleiss_kappa(ratings):.4f}")| Kappa 区间 | 直觉解释 | 发布建议 |
|---|---|---|
| < 0.4 | 一致性偏弱 | 先修 rubric 或任务定义,不宜直接拿来做门禁 |
| 0.4 - 0.6 | 可参考但仍偏脆 | 适合作辅助信号,需配人工抽检 |
| 0.6 - 0.8 | 一致性较好 | 可以进入较正式的发布流程 |
| > 0.8 | 一致性很强 | 适合高频自动回归,但仍要监控漂移 |
09. Slice 评测:总分不掉,不代表真的没事
企业里最危险的情况之一是:总体平均分持平,但 P0 场景显著下降。比如"退款规则""权限隔离""敏感信息拒答"这种高价值 slice,只要退一点都可能比总分退三分更严重。
| Slice | 为什么必须单独看 | 建议的评测方法 |
|---|---|---|
| 高风险业务问题 | 影响资金、合规、法律责任 | 规则 + Judge 双重检查 |
| 安全与越狱 | 总分好不代表风险低 | 专门的安全评测集 + 硬失败规则 |
| 长上下文 | 平均场景可能掩盖极端退化 | 按上下文长度分桶统计 |
| 工具调用 | 普通问答高分无法覆盖执行链路质量 | 端到端执行验证 |
| 多轮对话 | 单轮评测可能掩盖上下文遗忘 | 多轮连贯性专项测试 |
| 低资源语言 | 主流语言的好表现不代表全局 | 多语言分语种统计 |
所以报告至少要有两层。
第一层是总体趋势,方便管理层看;第二层是 slice 明细,方便研发做决策。没有 slice,平均分经常会把真正的发布风险藏起来。
9.1 一个真实感很强的反例
总分对比:
版本 A:4.32
版本 B:4.35
看起来版本 B 提升了。
但 slice 拆开后:
退款政策:4.6 -> 3.9 ⚠️ 下降 15%
权限隔离:4.8 -> 4.0 ⚠️ 下降 17%
敏感拒答:4.7 -> 3.8 ⚠️ 下降 19%
营销文案:4.1 -> 4.5 ↑ 提升
日常闲聊:3.8 -> 4.6 ↑ 提升
结论:
平均分上涨,是因为低风险文案类和闲聊任务进步了(样本量大);
真正不能退的 P0 场景反而显著下降(但样本量小,被冲淡了)。9.2 Slice 评测的自动化实现
from collections import defaultdict
import statistics
def slice_analysis(results: list[dict]) -> dict:
"""按 slice 维度拆分评测结果
results: [{"slice": "退款政策", "score": 4.2, "version": "A"}, ...]
"""
by_slice = defaultdict(lambda: defaultdict(list))
for r in results:
by_slice[r["slice"]][r["version"]].append(r["score"])
report = {}
for slice_name, versions in by_slice.items():
slice_report = {}
for version, scores in versions.items():
slice_report[version] = {
"mean": round(statistics.mean(scores), 3),
"std": round(statistics.stdev(scores), 3) if len(scores) > 1 else 0,
"n": len(scores),
"min": min(scores),
"max": max(scores)
}
# 如果有两个版本,计算变化
vs = list(versions.keys())
if len(vs) == 2:
a_mean = statistics.mean(versions[vs[0]])
b_mean = statistics.mean(versions[vs[1]])
delta = b_mean - a_mean
pct_change = (delta / a_mean * 100) if a_mean != 0 else float('inf')
slice_report["delta"] = round(delta, 3)
slice_report["pct_change"] = round(pct_change, 1)
slice_report["alert"] = abs(pct_change) > 10 # 变化超过 10% 报警
report[slice_name] = slice_report
return report10. Bootstrap 置信区间的完整推导与代码
10.1 为什么需要 Bootstrap
当我们说"版本 A 的平均分是 4.32,版本 B 是 4.35"时,我们在用有限样本的统计量(均值)来估计未知的总体参数。但这个估计值有多精确?如果再抽 100 条样本评测一遍,均值会变化多少? 传统的置信区间计算需要知道分布的形状(通常假设正态分布),而评测分数的分布往往是偏态的(比如大多数输出得 4-5 分,少数得 1-2 分)。Bootstrap 方法不需要对分布做任何假设,它通过重采样来模拟"如果我能反复收集数据"的效果。
10.2 Bootstrap 的数学直觉
假设我们有 n 个评测分数 {x_1, x_2, ..., x_n}。Bootstrap 的过程是:
- 从这 n 个分数中有放回地抽取 n 个样本,组成一个 Bootstrap 样本
- 计算这个 Bootstrap 样本的均值(或其他你关心的统计量)
- 重复步骤 1-2 B 次(通常 B=1000 或更多),得到 B 个统计量
- 取这 B 个统计量的第 2.5% 和 97.5% 分位点,作为 95% 置信区间
Bootstrap 95% CI = [θ*(α/2), θ*(1-α/2)]
其中 α = 0.05, θ* 是 Bootstrap 统计量的排序序列 Bootstrap 之所以有效,是因为 Efron 定理:当样本量足够大时,原始样本到总体的关系,与 Bootstrap 样本到原始样本的关系是类似的。换句话说,Bootstrap 重采样模拟了"重复抽样"这个理论上不可行的操作。
10.3 完整实现
import random
import statistics
from typing import Callable
def bootstrap_ci(data: list[float], stat_func: Callable = None,
n_bootstrap: int = 10000, ci: float = 0.95,
seed: int = 42) -> dict:
"""Bootstrap 置信区间的完整实现
Args:
data: 原始评测分数列表
stat_func: 统计量函数,默认为均值
n_bootstrap: Bootstrap 重采样次数
ci: 置信水平
seed: 随机种子,确保可复现
"""
if stat_func is None:
stat_func = lambda x: sum(x) / len(x)
random.seed(seed)
n = len(data)
# 原始统计量
observed = stat_func(data)
# Bootstrap 重采样
boot_stats = []
for _ in range(n_bootstrap):
sample = [random.choice(data) for _ in range(n)]
boot_stats.append(stat_func(sample))
boot_stats.sort()
# 置信区间
alpha = 1 - ci
lo_idx = int(n_bootstrap * alpha / 2)
hi_idx = int(n_bootstrap * (1 - alpha / 2))
return {
"observed": round(observed, 4),
"ci_lower": round(boot_stats[lo_idx], 4),
"ci_upper": round(boot_stats[hi_idx], 4),
"ci_level": ci,
"std_error": round(statistics.stdev(boot_stats), 4),
"n_bootstrap": n_bootstrap,
"n_samples": n
}
# 使用示例
scores_a = [4, 5, 4, 3, 5, 4, 4, 3, 5, 4, 4, 5, 3, 4, 4, 5, 4, 3, 4, 5,
4, 4, 3, 5, 4, 4, 5, 4, 3, 4, 5, 4, 4, 3, 5, 4, 4, 5, 3, 4,
4, 5, 4, 3, 5, 4, 4, 3, 5, 4]
scores_b = [4, 5, 4, 4, 5, 4, 5, 3, 5, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5,
5, 4, 3, 5, 4, 5, 5, 4, 4, 4, 5, 4, 5, 3, 5, 4, 5, 5, 4, 4,
4, 5, 4, 4, 5, 4, 5, 3, 5, 4]
ci_a = bootstrap_ci(scores_a)
ci_b = bootstrap_ci(scores_b)
print(f"版本 A: {ci_a['observed']:.2f} [{ci_a['ci_lower']:.2f}, {ci_a['ci_upper']:.2f}]")
print(f"版本 B: {ci_b['observed']:.2f} [{ci_b['ci_lower']:.2f}, {ci_b['ci_upper']:.2f}]")
# 判断是否显著
overlap = ci_a['ci_upper'] >= ci_b['ci_lower'] and ci_b['ci_upper'] >= ci_a['ci_lower']
print(f"区间重叠: {overlap}")
print("结论:", "无显著差异" if overlap else "存在显著差异")10.4 Bootstrap 差值检验
更严格的做法是直接对差值做 Bootstrap:对每次重采样,同时从 A 和 B 中抽样,计算差值的分布。如果差值的 95% CI 不包含 0,说明差异显著。
def bootstrap_diff_test(scores_a: list[float], scores_b: list[float],
n_bootstrap: int = 10000, seed: int = 42) -> dict:
"""Bootstrap 差值显著性检验"""
random.seed(seed)
na, nb = len(scores_a), len(scores_b)
observed_diff = sum(scores_b)/nb - sum(scores_a)/na
diffs = []
for _ in range(n_bootstrap):
sample_a = [random.choice(scores_a) for _ in range(na)]
sample_b = [random.choice(scores_b) for _ in range(nb)]
diffs.append(sum(sample_b)/nb - sum(sample_a)/na)
diffs.sort()
ci_lo = diffs[int(n_bootstrap * 0.025)]
ci_hi = diffs[int(n_bootstrap * 0.975)]
# p-value: 在零假设下(无差异),观测到如此极端差值的概率
# 用 permutation test 的近似
p_value = sum(1 for d in diffs if d <= 0) / n_bootstrap
return {
"observed_diff": round(observed_diff, 4),
"ci_lower": round(ci_lo, 4),
"ci_upper": round(ci_hi, 4),
"significant": ci_lo > 0 or ci_hi < 0,
"p_value_approx": round(min(p_value, 1-p_value) * 2, 4),
"direction": "B > A" if observed_diff > 0 else "A > B"
}10.5 为什么区间比单点分数更像工程指标
| 只看平均分 | 看平均分 + 置信区间 |
|---|---|
| 容易把随机波动看成升级收益 | 能区分"真的稳步提升"和"抽样刚好占便宜" |
| 适合汇报,不适合严肃决策 | 更适合发布门、回归判定和资源分配 |
| 无法量化不确定性 | 明确告诉你"我们有多不确定" |
11. Elo Rating 在模型对比中的应用
当你需要比较多个模型(而不只是两个版本的 A/B),Pairwise 比较会产生大量两两配对。Elo Rating 系统提供了一种优雅的方法,将所有 pairwise 比较结果汇聚成一个全局排名分数。Chatbot Arena 正是用 Elo 来排名数百个 LLM 的。
11.1 Elo Rating 的数学原理
Elo 系统的核心是两个公式:期望胜率和分数更新。 给定两个模型 A 和 B,当前 Elo 评分分别为 R_A 和 R_B,模型 A 的期望胜率为: E_A = 1 / (1 + 10^((R_B - R_A) / 400)) 这个公式的直觉是:如果 A 的评分比 B 高 400 分,A 的期望胜率约为 91%。如果两者评分相同,期望胜率各 50%。 每场对局后,根据实际结果更新评分: R_A' = R_A + K · (S_A - E_A) 其中 S_A 是实际比赛结果(胜=1, 平=0.5, 负=0),K 是更新系数(控制每场对局对评分的影响力度,K 越大波动越剧烈)。
11.2 完整 Elo 算法实现
import random
from collections import defaultdict
import math
class EloRating:
"""LLM 评测场景的 Elo Rating 系统"""
def __init__(self, k: int = 32, initial_rating: int = 1500):
self.k = k
self.initial_rating = initial_rating
self.ratings = defaultdict(lambda: initial_rating)
self.history = []
def expected_score(self, rating_a: float, rating_b: float) -> float:
"""计算 A 的期望胜率"""
return 1.0 / (1.0 + math.pow(10, (rating_b - rating_a) / 400))
def update(self, model_a: str, model_b: str, winner: str):
"""更新一场对局
winner: "A", "B", 或 "tie"
"""
ra = self.ratings[model_a]
rb = self.ratings[model_b]
ea = self.expected_score(ra, rb)
eb = 1 - ea
if winner == "A":
sa, sb = 1.0, 0.0
elif winner == "B":
sa, sb = 0.0, 1.0
else:
sa, sb = 0.5, 0.5
self.ratings[model_a] = ra + self.k * (sa - ea)
self.ratings[model_b] = rb + self.k * (sb - eb)
self.history.append({
"model_a": model_a, "model_b": model_b,
"winner": winner,
"rating_a": self.ratings[model_a],
"rating_b": self.ratings[model_b]
})
def get_leaderboard(self) -> list[dict]:
"""生成排行榜"""
board = [{"model": m, "rating": round(r, 1)}
for m, r in self.ratings.items()]
return sorted(board, key=lambda x: x["rating"], reverse=True)
def bootstrap_ratings(self, battles: list[dict], n_rounds: int = 1000) -> dict:
"""对 Elo 评分做 Bootstrap 置信区间
由于 Elo 依赖对局顺序,我们通过打乱顺序多次计算来获得稳定的估计。
"""
all_ratings = defaultdict(list)
for _ in range(n_rounds):
elo = EloRating(k=self.k, initial_rating=self.initial_rating)
shuffled = battles.copy()
random.shuffle(shuffled)
for battle in shuffled:
elo.update(battle["model_a"], battle["model_b"], battle["winner"])
for model, rating in elo.ratings.items():
all_ratings[model].append(rating)
result = {}
for model, ratings in all_ratings.items():
ratings.sort()
n = len(ratings)
result[model] = {
"median": round(ratings[n // 2], 1),
"ci_lower": round(ratings[int(n * 0.025)], 1),
"ci_upper": round(ratings[int(n * 0.975)], 1),
"std": round((sum((r - sum(ratings)/n)**2 for r in ratings) / n)**0.5, 1)
}
return result
# 使用示例:模拟多模型对比
battles = [
{"model_a": "GPT-5", "model_b": "Claude-Opus-4.6", "winner": "A"},
{"model_a": "GPT-5", "model_b": "Llama-4-405B", "winner": "A"},
{"model_a": "Claude-Opus-4.6", "model_b": "Llama-4-405B", "winner": "A"},
{"model_a": "GPT-5", "model_b": "Qwen3-Max", "winner": "tie"},
{"model_a": "Claude-Opus-4.6", "model_b": "Qwen3-Max", "winner": "B"},
{"model_a": "Llama-4-405B", "model_b": "Qwen3-Max", "winner": "B"},
{"model_a": "GPT-5", "model_b": "Claude-Opus-4.6", "winner": "tie"},
{"model_a": "GPT-5", "model_b": "Llama-4-405B", "winner": "A"},
{"model_a": "Claude-Opus-4.6", "model_b": "Llama-4-405B", "winner": "A"},
{"model_a": "Qwen3-Max", "model_b": "GPT-5", "winner": "B"},
]
elo = EloRating(k=32)
for b in battles:
elo.update(b["model_a"], b["model_b"], b["winner"])
print("=== 排行榜 ===")
for entry in elo.get_leaderboard():
print(f" {entry['model']}: {entry['rating']}")
print("\n=== Bootstrap 置信区间 ===")
ci = elo.bootstrap_ratings(battles, n_rounds=1000)
for model, stats in sorted(ci.items(), key=lambda x: x[1]["median"], reverse=True):
print(f" {model}: {stats['median']} [{stats['ci_lower']}, {stats['ci_upper']}]")11.3 Elo 系统的注意事项
Elo 的局限性与陷阱。
- **顺序敏感:**Elo 评分依赖对局顺序,先输入的对局对最终结果的影响更大。解决方法是使用 Bootstrap 打乱顺序多次计算。
- **K 值选择:**K 太大导致评分波动剧烈,K 太小导致收敛太慢。LLM 评测场景通常用 K=4~32。
- **非传递性:**A 赢 B、B 赢 C 不代表 A 一定赢 C。模型在不同 slice 上的相对表现可能不一致。
- **样本量要求:**每对模型至少需要 50+ 次对比才能得到稳定的 Elo 差值。
12. Judge 模型选择策略
选择哪个模型做 Judge,本身就是一个需要实验验证的工程决策。不同 Judge 模型在成本、准确度、偏差特征和速度上差异显著。
12.1 主流 Judge 模型对比(2026-04)
| 维度 | GPT-5 | Claude Opus 4.6 | DeepSeek-R2 | 开源 Judge(Prometheus-3 / Auto-J 2 等) |
|---|---|---|---|---|
| 与人工一致性(Arena-Hard-Auto 2026) | 86.4% | 87% | 84.1% | 中等(领域微调后可达 80%+) |
| Position Bias | 轻微 first-position | 更均衡 | 较均衡 | 因模型而异,通常更明显 |
| Verbosity Bias | 中等 | 较低 | 较低 | 较高 |
| Self-Enhancement | 对 GPT-5 / o4 系输出偏高 | 对 Claude 系输出偏高 | 对 DeepSeek 系略偏高 | 无明显自我偏好 |
| 成本(每千条评分) | ~$15-40 | ~$25-60 | ~$2-6 | 自部署成本,GPU 依赖 |
| 速度 | 快 | 快 | 中等(reasoning 模式略慢) | 取决于硬件 |
| 数据隐私 | 数据发送到 OpenAI | 数据发送到 Anthropic | 可走国内 / 私有化部署 | 完全本地 |
| — 历史参考:GPT-4o 一致率约 79%、Claude 3.5 Sonnet 约 81%、Prometheus-2 约 73% — |
12.2 多 Judge 投票机制
在高风险场景下,建议使用多个 Judge 模型投票来提高评测的稳定性和可信度。
import statistics
def multi_judge_vote(question: str, output: str, rubric: dict,
judges: list[dict]) -> dict:
"""多 Judge 投票评分
judges: [{"name": "gpt-5", "func": judge_func_gpt5}, {"name": "claude-opus-4-6", "func": judge_func_claude}, ...]
"""
all_scores = []
all_results = {}
for judge in judges:
result = judge["func"](question, output, rubric)
score = result.get("total_score", 0)
all_scores.append(score)
all_results[judge["name"]] = {
"score": score,
"rationale": result.get("rationale", "")
}
median_score = statistics.median(all_scores)
mean_score = statistics.mean(all_scores)
score_range = max(all_scores) - min(all_scores)
# 一致性检查
agreement = score_range <= 1 # 所有 Judge 的分差不超过 1 分
return {
"final_score": round(median_score, 2),
"mean_score": round(mean_score, 2),
"agreement": agreement,
"score_range": score_range,
"individual_results": all_results,
"recommendation": (
"高一致性,可信" if agreement else
"存在分歧,建议人工复核"
)
}12.3 Judge 选择决策树(2026-04 推荐)
如何选择 Judge 模型?
- 如果数据不能出境 → 使用开源 Judge(Prometheus-3、Auto-J 2、Qwen3-Reasoner 本地部署等)或国内私有化 DeepSeek-R2
- 如果被评模型是 GPT / o 系列 → 优先用 Claude Opus 4.6 做 Judge(避免 self-enhancement)
- 如果被评模型是 Claude 系列 → 优先用 GPT-5 或 DeepSeek-R2 做 Judge
- 如果被评模型是国产模型(DeepSeek / Qwen / GLM / Doubao / Kimi)→ 用 GPT-5 + Claude Opus 4.6 双 Judge 组合
- 如果是高风险场景 → 使用 2+ 个不同家族的 Judge 投票(推荐:GPT-5 + Claude Opus 4.6 + DeepSeek-R2)
- 如果是日常回归 → 单 Judge(GPT-5 或 Claude Sonnet 4.6)+ 定期人工校准即可
- 如果预算极其有限 → 用 GPT-5-mini / Claude Haiku 4 / DeepSeek-R2 / 开源 Judge,但必须做更频繁的人工校准
13. 完整评测 Pipeline:从数据加载到报告生成
以下是一个可直接使用的评测流水线实现,涵盖数据加载、模型推理、Judge 评分、统计分析和报告生成全流程。
13.1 Pipeline 架构
评测流水线的完整流程:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 数据加载 │ -> │ 模型推理 │ -> │ Judge │ -> │ 统计分析 │ -> │ 报告生成 │
│ Dataset │ │ Inference│ │ Scoring │ │ Analysis │ │ Report │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
评测集 JSON 模型 API 调用 LLM Judge + Bootstrap CI HTML/JSON
+ slice 标签 + 重试 + 缓存 规则检查 + Kappa 报告13.2 完整代码实现
import json
import time
import random
import hashlib
import statistics
from pathlib import Path
from collections import defaultdict
from dataclasses import dataclass, field, asdict
from typing import Optional
from datetime import datetime
@dataclass
class EvalSample:
"""评测样本"""
id: str
question: str
context: str = ""
reference: str = ""
slice_tag: str = "general"
metadata: dict = field(default_factory=dict)
@dataclass
class EvalResult:
"""评测结果"""
sample_id: str
model_output: str
judge_score: float
judge_rationale: str
dimensions: dict = field(default_factory=dict)
hard_fail: bool = False
slice_tag: str = "general"
latency_ms: float = 0
class EvalPipeline:
"""完整评测流水线"""
def __init__(self, model_name: str, judge_model: str = "gpt-5",
cache_dir: str = ".eval_cache"):
self.model_name = model_name
self.judge_model = judge_model
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True)
self.results: list[EvalResult] = []
def load_dataset(self, path: str) -> list[EvalSample]:
"""加载评测数据集"""
with open(path) as f:
data = json.load(f)
samples = []
for item in data:
samples.append(EvalSample(
id=item.get("id", hashlib.md5(
item["question"].encode()).hexdigest()[:8]),
question=item["question"],
context=item.get("context", ""),
reference=item.get("reference", ""),
slice_tag=item.get("slice", "general"),
metadata=item.get("metadata", {})
))
print(f"加载 {len(samples)} 条样本,"
f"覆盖 {len(set(s.slice_tag for s in samples))} 个 slice")
return samples
def _get_cache_key(self, sample: EvalSample) -> str:
content = f"{self.model_name}:{sample.question}:{sample.context}"
return hashlib.md5(content.encode()).hexdigest()
def run_inference(self, sample: EvalSample) -> str:
"""调用被评模型生成输出(含缓存)"""
cache_key = self._get_cache_key(sample)
cache_file = self.cache_dir / f"{cache_key}.json"
if cache_file.exists():
return json.loads(cache_file.read_text())["output"]
# 此处替换为实际的模型调用
# response = client.chat.completions.create(...)
# output = response.choices[0].message.content
output = f"[模型 {self.model_name} 对问题 '{sample.question[:30]}...' 的回答]"
cache_file.write_text(json.dumps({
"output": output, "model": self.model_name,
"timestamp": datetime.now().isoformat()
}, ensure_ascii=False))
return output
def run_judge(self, sample: EvalSample, output: str, rubric: dict) -> EvalResult:
"""Judge 评分(含规则检查)"""
start = time.time()
# 规则检查层(硬失败)
hard_fail = False
fail_reason = ""
if len(output.strip()) == 0:
hard_fail = True
fail_reason = "空输出"
elif sample.reference and sample.reference in output and len(output) > len(sample.reference) * 3:
pass # 可以加更多规则检查
# LLM Judge 评分
# judge_result = judge_pointwise(sample.question, output, rubric, ...)
# 此处使用模拟结果
score = random.uniform(2, 5) if not hard_fail else 0
latency = (time.time() - start) * 1000
return EvalResult(
sample_id=sample.id,
model_output=output,
judge_score=round(score, 2),
judge_rationale="模拟评分理由",
hard_fail=hard_fail,
slice_tag=sample.slice_tag,
latency_ms=round(latency, 1)
)
def run(self, samples: list[EvalSample], rubric: dict) -> list[EvalResult]:
"""执行完整评测流程"""
print(f"开始评测 {len(samples)} 条样本...")
self.results = []
for i, sample in enumerate(samples):
output = self.run_inference(sample)
result = self.run_judge(sample, output, rubric)
self.results.append(result)
if (i + 1) % 10 == 0:
print(f" 进度: {i+1}/{len(samples)}")
print(f"评测完成,共 {len(self.results)} 条结果")
return self.results
def analyze(self) -> dict:
"""统计分析"""
scores = [r.judge_score for r in self.results]
# 总体统计
overall = {
"mean": round(statistics.mean(scores), 3),
"median": round(statistics.median(scores), 3),
"std": round(statistics.stdev(scores), 3) if len(scores) > 1 else 0,
"n": len(scores),
"hard_fail_rate": round(
sum(1 for r in self.results if r.hard_fail) / len(self.results), 3)
}
# Bootstrap CI
ci = bootstrap_ci(scores)
overall["ci_95"] = [ci["ci_lower"], ci["ci_upper"]]
# Slice 分析
by_slice = defaultdict(list)
for r in self.results:
by_slice[r.slice_tag].append(r.judge_score)
slice_stats = {}
for tag, tag_scores in by_slice.items():
tag_ci = bootstrap_ci(tag_scores)
slice_stats[tag] = {
"mean": round(statistics.mean(tag_scores), 3),
"n": len(tag_scores),
"ci_95": [tag_ci["ci_lower"], tag_ci["ci_upper"]],
"hard_fail_rate": round(
sum(1 for r in self.results
if r.slice_tag == tag and r.hard_fail) / len(tag_scores), 3)
}
return {
"model": self.model_name,
"timestamp": datetime.now().isoformat(),
"overall": overall,
"slices": slice_stats
}
def generate_report(self, output_path: str = "eval_report.json"):
"""生成评测报告"""
analysis = self.analyze()
report = {
"meta": {
"model": self.model_name,
"judge": self.judge_model,
"n_samples": len(self.results),
"timestamp": analysis["timestamp"]
},
"summary": analysis["overall"],
"slices": analysis["slices"],
"alerts": [],
"raw_results": [asdict(r) for r in self.results]
}
# 生成告警
for tag, stats in analysis["slices"].items():
if stats["mean"] < 3.0:
report["alerts"].append({
"level": "critical",
"slice": tag,
"message": f"Slice '{tag}' 平均分 {stats['mean']} 低于门禁线 3.0"
})
if stats["hard_fail_rate"] > 0.1:
report["alerts"].append({
"level": "warning",
"slice": tag,
"message": f"Slice '{tag}' 硬失败率 {stats['hard_fail_rate']*100:.1f}% 超过 10%"
})
with open(output_path, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"\n=== 评测报告 ===")
print(f"模型: {self.model_name}")
print(f"总体均分: {analysis['overall']['mean']} "
f"[{analysis['overall']['ci_95'][0]}, {analysis['overall']['ci_95'][1]}]")
print(f"硬失败率: {analysis['overall']['hard_fail_rate']*100:.1f}%")
print(f"\nSlice 明细:")
for tag, stats in sorted(analysis["slices"].items(),
key=lambda x: x[1]["mean"]):
print(f" {tag}: {stats['mean']} (n={stats['n']})")
if report["alerts"]:
print(f"\n⚠ 告警 ({len(report['alerts'])} 条):")
for alert in report["alerts"]:
print(f" [{alert['level']}] {alert['message']}")
return report13.3 Pipeline 使用示例
# 完整使用流程
# 1. 准备评测数据集
eval_data = [
{"id": "001", "question": "试用期员工能否享受年假?",
"context": "员工手册 4.2:试用期员工不享受年假。",
"reference": "不能。根据员工手册 4.2,试用期员工不享受年假。",
"slice": "政策问答"},
{"id": "002", "question": "如何申请退款?",
"context": "退款政策:7天内可无条件退款,需提供订单号。",
"slice": "退款流程"},
# ... 更多样本
]
# 保存为 JSON
with open("eval_dataset.json", "w", encoding="utf-8") as f:
json.dump(eval_data, f, ensure_ascii=False, indent=2)
# 2. 定义 Rubric
rubric = {
"dimensions": {
"factuality": {"range": "0-2"},
"directness": {"range": "0-1"},
"coverage": {"range": "0-1"},
"boundary": {"range": "0-1"}
},
"hard_fail": ["与证据冲突", "编造条款"],
"total_range": "0-5"
}
# 3. 运行评测
pipeline = EvalPipeline(model_name="gpt-5-mini", judge_model="gpt-5")
samples = pipeline.load_dataset("eval_dataset.json")
results = pipeline.run(samples, rubric)
# 4. 生成报告
report = pipeline.generate_report("eval_report.json")14. 工程落地清单
14.1 发布门禁配置指南
- 给每个开放任务写明 rubric,不要只写"评分 1-5"。rubric 至少包含 3 个评分维度和 2 个硬失败条件。
- 定期做人工与 judge 校准(至少每季度一次),尤其在 judge 模型变更后。目标 Kappa > 0.6。
- 对 pairwise 比较做位置交换,记录翻转率。翻转率 > 15% 时需要调整 Judge prompt 或换 Judge 模型。
- 把硬失败项独立出来,不与平均分混算。硬失败率单独设门禁线(建议 < 5%)。
- 报告中同时提供总体分、slice 分和 95% 置信区间。只有当新版本的 CI 下界高于旧版本的 CI 上界时,才能声称"显著提升"。
- 每次 eval 跑完后自动生成报告,包含 slice 退化告警。P0 slice 退化超过 10% 自动阻止发布。
- 使用与被评模型不同家族的 Judge 模型。高风险场景使用多 Judge 投票。
- 维护一个"偏差检测样本集",每次更换 Judge 或修改 rubric 后运行,量化偏差变化。
14.2 常见陷阱与规避
| 陷阱 | 现象 | 规避方法 |
|---|---|---|
| 评测集泄露 | 模型在评测集上分数虚高 | 定期更新评测集,使用动态生成的变体 |
| Goodhart 定律 | 针对指标优化导致实际质量下降 | 多维度评测 + 人工抽检 + 线上指标 |
| Judge 漂移 | Judge 模型更新后评分标准隐性变化 | 用固定版本的 Judge,更新时做校准对比 |
| 样本不均衡 | 高风险 slice 样本太少被冲淡 | 分层抽样,每个 slice 至少 30 条 |
| 过度依赖总分 | 总分掩盖 slice 退化 | 每个 P0 slice 独立设门禁 |
14.3 课堂练习
- 为"政策问答"设计一个 5 分制 rubric,至少包含 4 个维度和 2 个硬失败条件。给出每个分数档位的锚点描述。
- 给一个 pairwise 评测方案,说明你会如何控制 position bias。包括:样本数量、交换策略、可接受的翻转率阈值。
- 假设版本 A 平均分 4.31(CI: [4.22, 4.40]),版本 B 平均分 4.35(CI: [4.25, 4.45]),应该如何向团队解释结果?
- 设计一个包含 3 个 slice 的评测数据集结构,说明每个 slice 的样本数量和选择理由。
- 用本文提供的
EloRating类,模拟 5 个模型的 100 场对局,并用 Bootstrap 方法为每个模型的 Elo 评分计算置信区间。
14.4 参考答案要点
- 开放任务 rubric 至少要明确:事实性(含锚点)、是否直接回答、是否遗漏关键条件、是否越过证据边界。每个维度的 0 分和满分都要有具体的行为描述。
- 控制 position bias 的最小方案是双向 pairwise + 随机顺序 + 记录交换前后胜率差。建议每对至少 50 条样本,翻转率阈值 15%。
- 当置信区间高度重叠时(如本题两个 CI 完全重叠),更稳妥的说法不是"B 明显更好",而是"当前证据不足以证明显著提升,建议补样本或按关键 slice 复核"。可以用 Bootstrap 差值检验来量化。
- Slice 设计示例:高风险政策问答(50条,覆盖退款、合规、权限)、安全拒答(30条,覆盖越狱、信息泄露)、常规问答(100条,覆盖日常场景)。高风险 slice 样本少但独立设门禁。
- Elo Bootstrap 关键:每轮打乱对局顺序重算,取 1000 轮结果的 2.5% 和 97.5% 分位点作为 CI。
14.5 自测标准
学完这一页后,你应该能:
- 解释从 BLEU 到 LLM-as-Judge 的评测方法演进脉络及各方法的适用场景。
- 手动推导 BLEU、ROUGE-L 的公式并解释每个组件的作用。
- 实现一个包含 Pointwise 评分和 Pairwise 比较的 LLM-as-Judge 系统。
- 识别并量化 Judge 的 verbosity bias、position bias、self-enhancement bias。
- 设计一个包含维度、锚点、硬失败和评判指引的完整 rubric。
- 计算 Cohen's Kappa 和加权 Kappa,并解释结果的含义。
- 用 Bootstrap 方法为评测分数计算置信区间,并做差值显著性检验。
- 实现一个 Elo Rating 系统来对多个模型进行排名。
- 搭建一条从数据加载到报告生成的完整评测 Pipeline。
- 知道为什么发布门不能只看一个总分,以及如何设计 slice 级门禁。