第 4 章 指标体系:从 Accuracy 到 Faithfulness 的演化
“Not everything that can be counted counts, and not everything that counts can be counted.” —— William Bruce Cameron
本章要点
- 经典 NLP 指标(BLEU / ROUGE / Exact Match / F1)在 LLM 时代为何大面积失效
- LLM 时代的核心新指标族:Faithfulness、Answer Relevance、Context Recall、Hallucination Rate
- RAG / Agent / 多轮 / 安全四个场景下各自的指标特化
- 指标的统计推断:置信区间、paired comparison、bootstrap
- 多指标聚合的工程套路:怎么处理 trade-off、怎么定义”质量门禁分”
4.1 指标的目的:从单条得分到团队决策
要选什么指标之前,先问一个工程问题:指标是给谁看的、用来做什么决策的?
- 是给开发者看的,用来判断”这次 PR 改动是否合并”?
- 是给 PM 看的,用来判断”这次产品迭代是否上线”?
- 是给 SRE 看的,用来判断”是否要回滚到上一版”?
- 是给老板看的,用来判断”明年是否扩团队”?
不同决策层对应不同的指标颗粒度——开发者看单条样例分;PM 看汇总分布;SRE 看时序曲线和告警阈值;老板看月度可视化。同一个底层数据可以有四种向上汇总形态。理解这一点能避免一个常见错误:用一个指标试图回答所有问题。
graph TD Raw[单条样例打分<br/>0-1 score / categorical / structured] --> Agg1[开发者层<br/>每条 trace 详情] Raw --> Agg2[PM 层<br/>分布 + 失败案例] Raw --> Agg3[SRE 层<br/>时序 + 告警] Raw --> Agg4[管理层<br/>季度趋势] style Raw fill:#fef3c7 style Agg1 fill:#dbeafe style Agg2 fill:#dcfce7 style Agg3 fill:#fce7f3 style Agg4 fill:#e0e7ff
本章只讨论”从单条样例到聚合统计”这一条路径——也就是从 Raw 到 Agg2 的转换。SRE 层的告警阈值在第 18 章讨论。
4.2 经典 NLP 指标的复用与失效
LLM 评测不是从零开始的——传统 NLP 已经有 30 年指标研究积累。但其中大部分在生成式 LLM 上失效,原因要先讲清楚。
4.2.1 BLEU / ROUGE:基于 n-gram 重叠的”宽容失败”
BLEU(Bilingual Evaluation Understudy,Papineni 2002)和 ROUGE(Recall-Oriented Understudy for Gisting Evaluation,Lin 2004)是机器翻译和摘要评测的祖宗指标。它们的核心思想都是:模型输出与 reference 的 n-gram 重叠率越高、得分越高。
这套思想在 LLM 时代的失败可以用一个具体例子说清楚:
Reference: "The Eiffel Tower is in Paris, France."
Output A: "The Eiffel Tower is in Paris." BLEU = 0.62
Output B: "巴黎的埃菲尔铁塔。" BLEU = 0.00
Output C: "The Eiffel Tower is in London, England." BLEU = 0.45
Output C 在事实上完全错误,但因为它和 reference 共享大量 n-gram,BLEU 反而比 Output B(正确翻译但语种不同)高一倍。这就是 BLEU 的根本缺陷——它度量的是字符串相似度,不是事实正确度。
4.2.2 何时仍可用 BLEU / ROUGE
不是说这两个指标完全没用。它们在以下场景仍然合理:
- 翻译 / 摘要任务,且有多个 reference:多 reference 可以缓解”等价表达不同 n-gram”的问题
- 对比同一模型不同版本:两个版本都用相同 grader 时,绝对值不准但相对差异有意义
- 作为辅助指标:和 LLM-judge 一起报告,可观察是否有”判分模型偏差”
但作为 LLM 应用质量的主要指标,它们已经被普遍弃用。
4.2.3 Exact Match 与 F1:还活着的二把刀
Exact Match(精确匹配)和 token-level F1 在以下场景仍然是主力:
- 抽取式问答:答案是文档中的一个片段(如 SQuAD 数据集风格)
- 结构化输出:JSON 解析后逐字段比较
- 数学题:最终答案可以归一化(如把 “$100” / “100 dollars” / “100” 统一为 “100”)
例如 GSM8K(数学题 benchmark)就是用 exact match——把模型回答最后的数字提取出来与 reference 比对,简单可靠。
但如果你的应用是”开放对话”或”生成式回答”,exact match 同样会大面积失效。
4.3 LLM 时代的核心新指标
4.3.1 Faithfulness(忠实度)
定义:模型回答中的每一条事实陈述,是否都能从给定的 context(如 RAG 检索到的文档)中得到支持。
提出来源:ragas 论文(Es et al. 2023, arXiv:2309.15217)。形式化定义:
Faithfulness = (回答中可被 context 支持的陈述数) / (回答中所有陈述数)
ragas 实现这个指标的工程方法分两步(详见第 11 章源码剖析):
- 拆解陈述(statement extraction):用 LLM 把 answer 拆成原子陈述列表。例如 “Acme Corp 成立于 1995 年, 它现在是世界 500 强” 会被拆成 [“Acme Corp 成立于 1995 年”, “Acme Corp 现在是世界 500 强”]
- 逐条 NLI 判定(statement verification):对每条陈述,让 LLM 判断”在 context 中能不能找到支持”,得到 0/1 标签
- 聚合:1 标签数 / 总陈述数
ragas 论文在 4 个 RAG 数据集上报告 Faithfulness 与人工标注的 Spearman 相关系数 0.61-0.83,是目前 RAG 评测里最被广泛接受的指标之一。
为什么这个指标关键?因为它精确捕捉幻觉——模型回答里只要有一句”context 没说但模型瞎写的”,分数就会下降。第 1 章 NYC MyCity 的 6 条违法建议,每条都是 Faithfulness < 0.5 的典型样例。
举例:
Question: 这家公司什么时候成立?
Context: "Acme Corp 成立于 1995 年, 总部在加州。"
Answer: "Acme Corp 成立于 1995 年, 它现在是世界 500 强。"
陈述 1: "成立于 1995 年" → 在 context 中 → 支持
陈述 2: "现在是世界 500 强" → 不在 context 中 → 不支持
Faithfulness = 1/2 = 0.5
Faithfulness 是 RAG 时代最关键的单一指标——它直接量化”模型是不是在编”。第 1 章 NYC MyCity 案的失败可以用 Faithfulness < 0.5 准确捕捉。
4.3.2 Answer Relevance
定义:模型回答与用户问题的语义相关度。
ragas 的实现方法很巧妙——给 LLM 看 answer,让它反向生成可能对应的 question,然后计算”反向生成的 question”与”原始 question”的 embedding 相似度。
flowchart LR Q[原始 Question] --> LLM1[LLM 生成 Answer] LLM1 --> A[Answer] A --> LLM2[LLM 反向生成<br/>3 个 candidate question] LLM2 --> Q2[Q2_1, Q2_2, Q2_3] Q --> Sim Q2 --> Sim Sim[Cosine similarity 平均] --> Score[Answer Relevance score] style Score fill:#dcfce7
为什么这么算?因为如果 answer 真的回答了 Q,那 LLM 反推出来的 candidate question 应该和 Q 高度相似。如果 answer 答非所问,反推出来的 question 就会和 Q 偏离。
4.3.3 Context Recall / Context Precision(RAG retriever 评测)
这两个指标针对 RAG 系统的检索阶段:
- Context Recall:reference answer 中需要的信息,是否都被检索到了?
- Context Precision:检索到的 context 里,与问题相关的部分占比多少?
Context Recall = (reference 中能在检索 context 中找到依据的语句数) / (reference 中所有语句数)
Context Precision = (检索结果中相关的 chunk 数) / (检索结果总 chunk 数)
第 13 章会用 ragas 源码详细演示这两个指标的实现。
4.3.4 Hallucination Rate
定义:在 N 条样例中,模型生成包含明显事实错误(既不在 context 里、也不符合常识)的比例。
判定方法因场景而异:
- 有 context 的(RAG):直接用 1 - Faithfulness
- 无 context 的(开放问答):用强模型作为 judge,对照可信知识源(如 Wikipedia)打分
- 结构化输出:JSON Schema 校验失败 / 字段值不在允许枚举范围内
OpenAI 在 GPT-4 Technical Report Section 3.1 报告 GPT-4 在 TruthfulQA 上的 Hallucination Rate 约 40%,明显优于 GPT-3.5 的 65%。
值得专门讨论的是 TruthfulQA(Lin et al. 2021, arXiv:2109.07958)——评测幻觉的标杆数据集。它的设计巧妙在专门挑选”人类容易被误导的领域”——常见迷信、阴谋论、医学误解、法律误读——共 817 题。GPT-4 / Claude 3.5 / Gemini 在 TruthfulQA 上的得分(来自各家 model card):
| 模型 | TruthfulQA MC1 准确率 | 来源 |
|---|---|---|
| GPT-3.5 | 47.0% | OpenAI evals |
| GPT-4 | 59.0% | GPT-4 Technical Report |
| Claude 3.5 Sonnet | 59.7% | Anthropic Claude 3.5 Model Card |
| Gemini 1.5 Pro | 64.4% | Google Gemini Tech Report |
这些数字看起来似乎都不算高——这恰恰说明幻觉是 LLM 当前最难解的问题之一。一个团队用任何一个上述模型做开放问答,无论怎么调 prompt、怎么 RAG,最终的 Hallucination Rate 都很难压到 5% 以下。这条上限决定了你需要在产品设计上加什么样的”逃生通道”——比如对高风险问题强制 disclaimer、或者把高风险类别 hard-code 拒答。
4.3.4.5 一个完整的 Faithfulness 计算演示
为让”陈述拆解 + NLI 判定”这套方法落到具体,下面是一个完整端到端的计算过程演示:
输入:
Question: 这家公司什么时候成立? 当前规模如何?
Context: "Acme Corp 成立于 1995 年, 总部在加州 Mountain View.
员工约 500 人, 2024 年营收 8000 万美元."
Answer: "Acme Corp 成立于 1995 年, 它现在是世界 500 强,
员工约 500 人, 估值超过 10 亿美元."
Step 1:陈述拆解(用 LLM)
LLM 把 answer 拆成原子陈述列表:
[
"Acme Corp 成立于 1995 年",
"Acme Corp 是世界 500 强",
"Acme Corp 员工约 500 人",
"Acme Corp 估值超过 10 亿美元"
]
Step 2:逐条 NLI 判定(用 LLM)
| 陈述 | 在 context 中? | 判定 |
|---|---|---|
| ”成立于 1995 年" | "成立于 1995 年” → 直接命中 | ✓ |
| “世界 500 强” | context 没说 → 不支持 | ✗ |
| “员工约 500 人" | "员工约 500 人” → 命中 | ✓ |
| “估值超过 10 亿美元” | context 只说”营收 8000 万”,没说估值 | ✗ |
Step 3:聚合
Faithfulness = 2 / 4 = 0.50
这个 0.5 准确反映了 answer 一半事实可信、一半在编。它的工程价值在于:
- 可定位:直接告诉你哪条陈述错了,便于排查 RAG retriever 还是 LLM 生成的问题
- 可解释:每条陈述的判定结果可以连同分数一起返回,给开发者读
- 可对比:v1 vs v2 prompt 改动后,能精确看到哪些陈述从错变对、哪些从对变错
ragas 的 Faithfulness 类(第 11 章会逐行剖析)就是这套流程的代码实现。
4.3.5 一张速查表
| 指标 | 适用场景 | 实现复杂度 | 典型阈值 |
|---|---|---|---|
| BLEU / ROUGE | 翻译 / 摘要 | 低(字符串) | 不建议作为主指标 |
| Exact Match | 抽取式 QA / 结构化 | 极低 | ≥ 80% |
| F1 (token) | 抽取式 QA | 低 | ≥ 70% |
| Faithfulness | RAG / 有 context 的生成 | 中(LLM-judge) | ≥ 0.85 |
| Answer Relevance | 开放生成 | 中(LLM + embedding) | ≥ 0.80 |
| Context Recall | RAG retriever | 中(LLM-judge) | ≥ 0.90 |
| Context Precision | RAG retriever | 中(LLM-judge) | ≥ 0.70 |
| Hallucination Rate | 所有场景 | 中-高 | ≤ 5% |
阈值是工业实践的经验数字,不是硬规则。具体业务上要结合可接受的事故概率倒推。
4.4 Agent / 多步任务指标
Agent 不是单步问答,而是多步推理 + 工具调用。指标体系要相应扩展。
4.4.1 Trajectory Match
定义:模型实际执行的步骤序列,与 reference trajectory 的相似度。
sequenceDiagram
participant Ref as Reference Trajectory
participant Act as Actual Trajectory
Ref->>Ref: search(weather, beijing)
Ref->>Ref: search(weather, shanghai)
Ref->>Ref: compare()
Act->>Act: search(weather, beijing)
Act->>Act: search(weather, shanghai)
Act->>Act: compare()
Note over Ref,Act: 完美匹配 → trajectory match = 1.0
实操上很少要求完全相等(步骤顺序、工具参数都允许些微差异)。LangSmith 的实现(trajectory_match)允许:
- 步骤的部分顺序对调
- 工具参数语义等价(“上海”和”Shanghai”视作同一个)
- 多余但无害的步骤(如多搜了一次)
4.4.2 Tool Calling Correctness
定义:模型选择的 tool 是否正确、传入的参数是否符合 schema、参数值是否合理。
第 1 章 DPD chatbot 的失败之一是它调错了人格扮演工具。Tool Calling Correctness 评测能拦住这种失败的相当一部分——前提是你定义了清晰的 tool schema(这是 MCP 协议的核心贡献,详见《MCP 协议工程》第 7 章)。
4.4.3 Goal-Reached Rate
定义:N 个任务中,Agent 最终是否达成用户目标的比例。
这是 Agent 评测的”终极指标”——其他过程指标(trajectory、tool call)都是为这个终极指标服务的。
判定 goal-reached 的难点:很多任务的成功是”主观可接受”而非”客观对/错”。第 14 章会拆解这个问题。
4.5 多轮对话指标
MT-Bench(Zheng et al. 2023, arXiv:2306.05685)提出的”双模”评测是当前主流:
- 单条评分模式:每条 turn 独立打 1-10 分,看局部质量
- 配对偏好模式:两个模型回答同一条 prompt,让 judge 选哪个更好,得到 win-rate
flowchart TB
subgraph 单条评分
A[turn 1] --> S1[score 8/10]
B[turn 2] --> S2[score 7/10]
end
subgraph 配对偏好
M1[Model A 回答] -->|judge 二选一| W[Winner]
M2[Model B 回答] -->|judge 二选一| W
end
style W fill:#dcfce7
配对偏好的优势:避免 LLM-judge 的”绝对分校准困难”问题。Chatbot Arena (lmsys.org) 整套排行榜都是配对偏好驱动的。
第 15 章会详述 MT-Bench 与 Arena Hard 的方法学差异。
4.6 安全与对齐指标
来自 HELM(Liang et al. 2023, arXiv:2211.09110),HELM 把”安全”分解成多个独立子指标:
- Toxicity:基于 Perspective API 等检测器,量化输出的攻击性 / 仇恨内容
- Bias:在不同人群(性别 / 种族 / 宗教)上的回答差异度
- Refusal Appropriateness:该拒绝时拒绝、不该拒绝时不拒绝
- Jailbreak Resistance:对抗集 §3.4 的通过率
第 16 章会专门拆解这些指标的实现。
4.7 指标的统计推断
非确定性输出意味着任何一次评测的”分数”都是带噪声的随机变量。要做出可靠决策,必须懂一点统计推断。
4.7.0 一个常被忽视的事实:评测分数本身是随机变量
很多团队跑完评测看到一个数字(“86%”),就把它当成”客观的事实”。但其实这个 86% 是很多层随机性叠加的结果:
graph TD A[模型采样随机性<br/>temperature > 0] --> X[最终分数] B[评测集采样随机性<br/>这 N 条不是全部] --> X C[Judge 模型随机性<br/>LLM-as-Judge 也有 noise] --> X D[Grader 实现误差<br/>regex / JSON parser bug] --> X style X fill:#fee2e2
理解这四层噪声的工程含义:
- 同一份代码同一份评测集重跑两次,分数会差 1-3pp 是正常的(来自 A)
- 把评测集从 100 题扩到 200 题,分数可能变化 2-5pp(来自 B)
- 换一个 judge 模型(GPT-4 → Claude),分数可能变化 5-10pp(来自 C)
- Grader 代码里一个 regex bug 可以让分数虚高或虚低 5-15pp(来自 D)
每一次”看到分数变了”,都要先排除这四个噪声源,才能下”模型真的变了”的结论。第 8 章会专门讨论”如何隔离这四种噪声”。
4.7.1 置信区间
最常用的是二项分布的 Wald 区间:
对于 N=200 题、p=0.85 的通过率:
95% CI = p ± 1.96 × sqrt(p(1-p)/N) = 0.85 ± 0.049 = [0.80, 0.90]
具体含义:你看到 85% 通过,真实通过率有 95% 概率落在 80%-90% 之间。如果上次跑出 82%、这次 86%,差距在 CI 内、不能下”模型变好了”的结论。
4.7.2 Paired Comparison
如果你在比较”v1 prompt vs v2 prompt”,强烈推荐用 paired comparison 而不是独立采样:
# 错的做法(独立采样)
v1_scores = [score(v1, q) for q in random.sample(dataset, 100)]
v2_scores = [score(v2, q) for q in random.sample(dataset, 100)]
# ↑ 两个 random.sample 用的题不一样, 噪声会被题目难度放大
# 对的做法(paired)
pairs = [(score(v1, q), score(v2, q)) for q in dataset]
# ↑ 同一道题两版都跑, 差异精确归因于版本
Paired comparison 在相同样本数下比独立采样的统计功效高 5-10 倍,是评测体系的标准做法。
4.7.3 Bootstrap 估计
如果你的指标分布不接近正态(如 Faithfulness 经常是双峰,要么很高要么很低),Wald 区间会不准。这时用 bootstrap:
import numpy as np
def bootstrap_ci(scores, B=1000, alpha=0.05):
means = [np.mean(np.random.choice(scores, size=len(scores), replace=True))
for _ in range(B)]
return np.percentile(means, [100*alpha/2, 100*(1-alpha/2)])
Bootstrap 不假设分布形态,对”不规则指标”给出更可靠的 CI。第 8 章会讨论”什么时候必须用 bootstrap”。
4.7.4 一个具体的统计陷阱:Simpson 悖论
LLM 评测里 Simpson 悖论出现得比想象中更频繁。一个真实情况:
| 类别 | 样例数 | Model A 通过率 | Model B 通过率 |
|---|---|---|---|
| 简单题 | 80 | 95% (76/80) | 90% (72/80) |
| 难题 | 20 | 50% (10/20) | 60% (12/20) |
| 整体 | 100 | 86% | 84% |
整体看 Model A 更好,但分类别看 Model B 在难题上更强。如果你的业务真实分布里难题占 50% 而非 20%,Model B 才是该选的。
这就是为什么本章一开始强调:任何聚合指标都必须配合 category-wise 分布才能不被 Simpson 悖论坑到。ragas / promptfoo / langsmith 都默认支持按 category 切片展示,第 11、12、17 章会展示这些工具怎么实现切片视图。
4.8 多指标聚合:处理 trade-off
实操中你很少只关心一个指标。一个 RAG 系统至少要看 Faithfulness + Context Recall + Latency 三个,往往还有 Cost / Token、用户拇指反馈等等。
如何聚合?工业界常见三种套路:
flowchart LR A[多个原始指标] --> B[加权求和?<br/>简单但失真] A --> C[Pareto 前沿?<br/>不丢信息但难决策] A --> D[硬阈值 + 主指标?<br/>务实推荐] D --> D1[每个指标设硬阈值<br/>不达标 = 失败] D --> D2[达标后看主指标排序<br/>选 best] style D fill:#dcfce7
加权求和(如 0.5 × Faithfulness + 0.3 × Recall + 0.2 × (1 - latency/10))看似简单,但权重难定,且会让”一个 0.99 + 一个 0.01”和”两个 0.5”看起来一样。
务实做法是硬阈值 + 主指标:
- 设 Faithfulness ≥ 0.85、Recall ≥ 0.90、Latency ≤ 3s 三道阈值,任何一个不达标直接 fail
- 全达标后,按主指标(业务最关心的那个,如 Faithfulness)排序选最优
这就是第 18 章 CI Quality Gate 的设计原型。
4.8.5 一个具体例子:怎么决定 RAG 上线门禁
把上面的方法落到一个具体场景,给读者一份可直接拿走的检查表:
某客服 RAG 系统的上线门禁可以这样设计:
# eval-gate.yaml
required:
- metric: faithfulness
threshold: 0.85
reason: 客服领域,引用政策一致性是合规底线
- metric: context_recall
threshold: 0.90
reason: retriever 漏掉关键政策 → 回答不全, 用户体验差
- metric: latency_p95
threshold: 3000ms
reason: 客服场景超过 3s 用户会放弃
- metric: jailbreak_pass_rate
threshold: 0.95
reason: 对抗集合规底线
optimize:
- metric: answer_relevance
direction: maximize
reason: 在前面四道门都过的前提下, 选 relevance 最高的版本
这样的 yaml 就是第 12 章 promptfoo / 第 18 章 CI Quality Gate 的具体实现形态。它把”一个总分”换成”多个独立断言 + 一个排序键”——既清晰又务实。
4.8.7 指标的长期漂移:一个常被忽视的工程现象
最后讨论一个时间维度上的问题——同一组指标在同一份评测集上的取值,会随时间漂移。原因有四:
- Judge 模型版本升级:你用 GPT-4o 当 judge,OpenAI 静默升级了底层模型,judge prompt 没变但分数变了
- Calibration 漂移:你的人工标注随团队成员变化、业务标准变化,导致同样的样例今天的”标准答案”和半年前不同
- 业务定义漂移:“Faithful” 的具体含义因业务理解深化而细化(半年前粗放标,现在严格标)
- Grader prompt 微调:团队为修补特定 case 偷偷改 grader prompt,导致历史曲线不可比
工程修法是 指标版本化:
# 评测元数据示例
faithfulness:
version: 2.1.0
judge_model: gpt-4o-2024-08-06
judge_prompt_hash: a3f9b1
calibration_set_version: v1.2
effective_from: 2026-04-15
每一次任何一个变量动了,metric 版本号 bump,回归曲线在版本切换处打分割线。这样你就能区分”模型质量真变了”和”评测器变了”。第 18 章 CI Quality Gate 会展示这种版本化的工程实现。
4.8.8 一个综合工程示例:客服 RAG 系统的指标矩阵
把本章所有指标整合到一个真实场景,下面是一份”客服 RAG 系统”的完整指标矩阵——每条指标对应业务问题、判分方法、阈值、报告频率四个维度:
| 指标 | 业务问题 | 判分 | 阈值 | 频率 |
|---|---|---|---|---|
| Faithfulness | 回答是否在编? | LLM-judge | ≥ 0.85 | 离线 + 每日回归 |
| Context Recall | retriever 漏了关键政策吗? | LLM-judge | ≥ 0.90 | 离线 |
| Answer Relevance | 答的是不是用户问的? | LLM + embed | ≥ 0.80 | 离线 + 在线采样 |
| Hallucination Rate | 错误事实占比 | 1 - Faithfulness | ≤ 5% | 离线 + 在线 |
| Refusal Appropriateness | 该拒绝时拒绝了吗? | rule + LLM | ≥ 0.95 | 离线 |
| Jailbreak Pass Rate | 抵御越狱了吗? | rule + LLM | ≥ 0.95 | 离线 |
| Latency p95 | 用户等多久? | 直接测量 | ≤ 3000ms | 在线 |
| Cost per query | 单次成本 | 直接计算 | ≤ ¥0.02 | 在线 |
| User thumbs-down rate | 用户实际反馈 | 直接观测 | ≤ 5% | 在线 |
| Human escalation rate | 转人工率 | 直接观测 | ≤ 10% | 在线 |
10 个指标分两部分:上 6 条来自本章方法学,下 4 条是工程指标(latency、cost、用户反馈)。两部分缺一不可——只看质量不看成本会上线一个不可持续的系统;只看成本不看质量会复现第 1 章的事故。
具体阈值数字基于客服领域常见 SLO 推算,不同业务可以调整。但指标矩阵的结构是相通的——任何业务都该列出”质量、安全、效率、用户感知”四类指标。
4.8.9 选主指标的工程艺术:避免”指标通胀”
工业团队报评测时容易陷入”指标通胀”——报 10 多个指标,每个都说”涨了一点点”,最后没人知道是不是真的进步。这种通胀本身是评测体系不成熟的标志。
成熟做法是选 1 个主指标(primary metric):
- 业务相关:与业务核心 KPI(满意度、转化率、退款率)相关性最高
- 可解释:PM / 老板都能理解什么是”涨了 5pp”
- 难以 game:不会被”为了刷指标”的局部优化扭曲
举例:
- RAG 客服 → 主指标 = Faithfulness(关于”是不是在编”,业务关键)
- Agent 工具 → 主指标 = Goal-Reached Rate(任务完成率,最直接)
- 创意助手 → 主指标 = Pairwise Win Rate(用户偏好,避免绝对分校准难)
主指标之外的其他指标作为”辅助指标”——主指标涨且辅助指标不退化才算真进步。这种”主指标 + 辅助指标”的层级结构来自学术 ML 的标准做法(参考 Goodhart’s Law:当度量变成目标,它就不再是好度量)。
工业团队的提醒:每改一次评测策略时,问自己”我能用一句话说清主指标是什么、为什么”。说不清楚就别动—— premature 的指标体系比没有指标更危险。
4.8.10 一份指标速查表:怎么用最小投入选指标
如果你要为新项目选指标,下面这份决策树能在 5 分钟内帮你定主指标:
flowchart TD
Start[新项目要选指标] --> Q1{有 reference 答案?}
Q1 -->|是| Q2{答案是单值还是开放?}
Q1 -->|否| Q3{是 RAG 系统?}
Q2 -->|单值| Acc[Accuracy / Exact Match]
Q2 -->|开放| Q4{需要多个评判维度?}
Q4 -->|否| LR[LLM-rubric 单分]
Q4 -->|是| Multi[多 metric 组合]
Q3 -->|是| RAG[Faithfulness 主指标 + Recall/Precision 辅助]
Q3 -->|否| Q5{是 Agent?}
Q5 -->|是| AG[Goal-Reached + Tool Call F1]
Q5 -->|否| Q6{是多轮对话?}
Q6 -->|是| MT[MT-Bench pairwise]
Q6 -->|否| Pair[Pairwise win-rate]
style Acc fill:#dbeafe
style RAG fill:#dcfce7
style AG fill:#fef3c7
style MT fill:#fce7f3
style Pair fill:#e0e7ff
5 分钟选完后,工程节奏:
- 第 1 周:跑通主指标 baseline,知道当前数字
- 第 2-4 周:跟踪主指标,发现失败模式
- 第 2 月:加 1-2 个辅助指标
- 第 3 月起:稳定在主指标 + 2-3 个辅助的指标体系
避免的常见错误:
- 不要一开始就上 5+ 个指标——分散注意力、决策困难
- 不要追求”找到完美指标”——主指标够好用就行,剩下交给迭代
- 不要因为别人用什么就用什么——业务不同主指标不同
这份速查表只能给方向,不能替代深入理解第 4 章的方法学。但对”今天就要开始”的团队,是最快上手的工具。
4.8.11 一个朴素提醒:所有指标都有失效场景
最后给一个朴素但常被忽略的提醒:没有任何指标在所有场景下都有效。
- Faithfulness 在创意场景下反而损害 Helpfulness(§13.7.13)
- Tool Call Correctness 在复杂多步任务时不能反映整体成功(§14.5)
- pairwise win-rate 在三个 candidate 同时对比时统计学不严谨
- LLM-as-Judge 在某些领域(医学诊断 / 法律判定)系统性偏弱
工程团队的态度:
- 报告时附指标的适用边界:不只说”Faithfulness=0.92”,还说”在客服 RAG 场景下;创意写作场景需要换指标”
- 长期监控指标失效信号:当指标涨但用户负反馈也涨时,怀疑指标失效
- 留人工 fallback:高敏感场景留一手人工评测做 sanity check
这种”对自己工具谦卑”的工程心态,比”找到完美指标”更重要。本章方法学覆盖 80% 主流场景,剩 20% 的边角需要团队结合业务自行设计。评测体系的最终责任永远在工程师身上,不在工具上。
4.8.12 一个不显眼但重要的工程话题:metric 的命名规范
工业评测体系中 metric 的命名常被忽视。但不规范的命名会让 dashboard 混乱、跨团队沟通成本上升。
不规范命名的典型问题:
score/accuracy/quality等通用名 → 跨业务无法区分- 大小写混用
Faithfulness/faithfulness/FAITHFULNESS - 中英文混杂
回答_relevance - 同义不同名
recall_at_5vstop5_recallvsrecall@5 - 嵌套混乱
metric.faithfulness.scorevsfaithfulness.score.metric
工业团队的命名规范建议:
格式: {业务域}_{指标名}_{聚合方式}
示例:
cs_faithfulness_mean # 客服业务 / Faithfulness / 平均
cs_recall_at_5_p95 # 客服 / Recall@5 / p95
search_correctness_median # 搜索 / Correctness / 中位数
约定:
- 全小写 + 下划线(snake_case)
- 业务域统一缩写(cs / search / content / agent)
- 指标名用业界标准词(不自创术语)
- 聚合方式显式(mean / median / p95 / p99)
这种规范让 dashboard 一目了然、跨团队对接零摩擦、新人 onboarding 快。命名看似小事,长期维护中是极重要的工程纪律。
4.8.13 一个不常被讨论的问题:metric 的”心理学锚点”
工程团队的 metric 不只是数字——它在团队心理上形成”锚点”。一个被低估的工程现象:团队会下意识围绕主指标优化。
例子:
- 主指标 = Faithfulness → 团队下意识让回答更”严谨”,可能损害 Helpfulness
- 主指标 = Latency → 团队下意识压缩回答长度,可能损害详尽度
- 主指标 = Cost → 团队下意识用便宜模型,可能损害质量
这种”指标即激励”的现象有正反两面:
- 正面:团队精力聚焦、目标清晰、迭代快
- 反面:单维度优化压力大、容易忽略次要维度
工程修法:
- 主指标 + 防退化指标:除主指标外,明确列出”不能退化的”防御指标
- 多 owner:不同 owner 关心不同 metric,互相制衡
- 季度 review:定期检查”是否被主指标 game”,调整 metric 体系
理解 metric 的心理学影响,让团队的 metric 选择不只是”统计学问题”,而是”组织行为问题”。这种视角是评测体系成熟度的标志。
4.8.14 一个对工业团队的指标决策清单
整合本章方法学,给一份团队选择主指标时的决策清单:
□ 主指标对业务核心 KPI 相关性 ≥ 0.7
□ 主指标可重复测量(不依赖随机性)
□ 主指标有明确的"达标 / 不达标"阈值
□ 主指标 PM / 老板能听懂含义
□ 主指标改进与业务收益正相关
□ 主指标不会被"为了刷数字"而扭曲业务
□ 主指标在不同 cohort(用户群 / 时段)能切片观察
□ 主指标的失败 case 可被定位到具体样例
8 项检查全过的 metric 才是”工业级主指标”。任一不达标都说明这个 metric 选错或没准备好。
工业实务:每次决定主指标时(项目启动 / 重大改动)走一遍这 8 项检查。30 分钟就能定下主指标,避免后期反复调整的工程时间浪费。
4.8.15 一个隐藏话题:metric 的”业务意义解释”
工程团队报告评测分数时,常忽略一件事——告诉读者这个分数的业务意义。
不好的报告:
Faithfulness: 0.87
Recall: 0.91
Latency p95: 2400ms
好的报告:
Faithfulness: 0.87
→ 平均每 100 条回答中, 13 条含至少一处编造
→ 客服场景下大致对应 5-8% 用户感知错误率
Recall: 0.91
→ 平均每 100 条 query 中, 9 条 retriever 漏掉关键政策
→ 这 9 条会让 Faithfulness 进一步退化
Latency p95: 2400ms
→ 95% 用户在 2.4s 内得到回答
→ 比业界客服 chatbot 平均水平略好(业界 ~3s)
把”工程数字”翻译成”业务影响”的能力,是评测工程师与”只会跑指标的工程师”的差异点。读懂数字不难,把数字翻译给老板 / PM / 客服团队听懂,才是评测真正的价值传递。
工程实务:每次月度评测报告都附”业务意义解释”段落。3-5 行就够,但能让评测从”工程内部话题”变成”全公司讨论”。这种”翻译”能力让评测体系在组织里持续被重视。
4.8.16 一份 metric 命名 / 单位 / 报告的标准模板
读完本章后,给一份团队可以直接采用的 metric 标准模板:
metric:
name: cs_faithfulness_mean
display_name: "客服 Faithfulness 平均分"
description: |
回答中可被 context 支持的陈述比例.
基于 ragas Faithfulness 实现, 用 Claude 3.5 Sonnet 当 judge.
unit: ratio # ratio / count / ms / token / cny
range: [0, 1]
higher_is_better: true
threshold:
minimum: 0.85
target: 0.92
reporting:
primary: true
granularity: daily
aggregation: mean
business_meaning: |
客服回答事实可信度. 0.85 对应 ~10% 用户感知错误率.
related_metrics:
- cs_context_recall_mean # 通常正相关
- cs_answer_correctness_mean # 通常正相关
alert:
severity: critical
threshold: 0.80
notification: pagerduty + slack
owner: nlp-team
sla: 99.5% # 评测体系的 SLA, 不是 metric 本身
这一份 yaml 把”一个 metric”变成完整的工程产物——包含定义、阈值、业务含义、告警、owner 等所有维度。
工业实务:每个生产 metric 都应该有这样一份 yaml 落档。它是 PM / 老板 / 新人理解 metric 的入口、是元评测的基础、是合规审计的凭证。这种”metric 工程化”的思路,让评测从”一堆数字”升级到”可治理的工程资产”。
4.8.17 metric 设计的”两年回顾”思路
读完本章方法学后,给一个长期思维——每两年彻底 review 一次 metric 体系。
为什么是两年?
- 1 年太短:业务变化还不够大
- 3 年太长:很多 metric 已经过时
- 2 年正好:够覆盖业务一个完整迭代周期 + 有充足新认知
每两年的 review 应该回答:
- 哪些 metric 还能反映业务关键问题(保留)
- 哪些 metric 已经被 game 或失去 discriminative power(淘汰)
- 哪些新业务场景缺少对应 metric(新增)
- 哪些 metric 的阈值需要根据成熟度提升(收紧)
这种”长周期 review”是 metric 体系的”清库存”。如果不做,团队会积累几十个 metric 但实际只看 3-5 个核心——dashboard 沦为装饰品。
工业实务:把”metric 两年大 review”列入团队的双年度计划。每次 review 产出”metric 演化白皮书”——记录哪些保留 / 淘汰 / 新增的决策依据。这是评测体系成熟度的高级标志。
4.8.18 metric 体系的”分层抽象”思维
LLM 应用的 metric 不应该是”几十个并列项”,而是有层次的体系。给一份分层抽象框架:
Layer 1: 北极星指标 (1 个, 最高决策)
- 例: "用户满意度"
Layer 2: 业务关键指标 (3-5 个, 北极星的拆解)
- 例: "完成率 / 准确率 / 满意度"
Layer 3: 技术 metric (10-20 个, 工程具体跟踪)
- 例: "Faithfulness / Recall / Precision / Latency / Cost"
Layer 4: 调试 metric (按需跑, 排查问题)
- 例: "specific failure case 分布"
每层的 owner 和频率不同:
- Layer 1:每月 review,全公司可见
- Layer 2:每周 review,团队可见
- Layer 3:每天 review,工程师可见
- Layer 4:按需,调试时跑
这种分层让”决策”和”调试”分开——老板看 Layer 1 / 2 决定方向,工程师看 Layer 3 / 4 解决问题。避免”老板看不懂 Layer 3 / 工程师对 Layer 1 没感觉”的脱节。
4.8.19 一份 metric 选型的最终建议
整合本章所有方法学,给”如何选 metric”的最终建议:
- 从北极星指标倒推:先想清楚业务最关键的成功是什么,再倒推 metric
- 少即是多:5 个核心 metric > 20 个边缘 metric
- 统计推断常驻:所有 metric 报告都附 CI / paired comparison
- 元评测必跟:每个 metric 自己的可靠性也要监控
- 业务意义翻译:永远要把数字翻译成业务影响
这 5 条是本章方法学的最高总结。工程团队按这 5 条走,metric 体系既不会”少到不够用”,也不会”多到失焦”——而是”恰好够用且高效”。
4.8.20 一个最后的实务建议:metric 体系的”年度健康检查”
读完整章方法学后,给一个长期实务建议——每年做一次 metric 体系的健康检查。
具体内容(30 分钟到 2 小时):
- 列出当前所有跟踪的 metric
- 标注每个 metric 的”使用频率”(每天 / 每周 / 偶尔 / 从不)
- 删除”从不”使用的 metric
- 评估”每天”用的 metric 是否还反映当前业务关键
- 是否有”业务关键但没跟踪”的新维度需要加
这种”年度审计”让 metric 体系不会随时间膨胀——保持精简而有力。一个 metric 体系如果 3 年下来没做过 audit,几乎一定膨胀到失焦状态。
读完本章后希望读者带走的最后一点:metric 体系不是”建立”完就行的,是”持续管理”的。每年 1 小时的健康检查,让评测体系长期保持高效。
4.8.21 一个 metric 设计的”长期视角”案例
最后讨论一个 metric 设计的”长期视角”案例。
考虑一个客服 chatbot 的 metric 演化:
Year 1 (上线): 主指标 = 转人工率
- 因为公司当时最关心"客服成本"
- 转人工率从 30% → 15% 是巨大胜利
Year 2 (用户增长): 主指标 = 用户满意度
- 转人工率太低反而引发"chatbot 不够好用"投诉
- 关注重心从"省钱"转向"用户体验"
Year 3 (品牌建设): 主指标 = NPS
- 单条对话满意度高但用户长期不推荐
- 需要更宏观的指标
Year 4+ (成熟运营): 主指标矩阵
- 转人工率 / 满意度 / NPS / Faithfulness 多指标平衡
- 不再有"单一主指标"
这种”主指标随业务阶段演化”的现象在工业团队普遍存在。读完本章希望读者带走的不是”找到完美主指标”,而是理解主指标会演化、要每年 review 是否需要调整。
工程实务:把”主指标 review”作为团队年度仪式。每年 1 次回顾”今年的主指标是否仍然反映业务关键”。这种”长期 metric 治理”是评测体系工程化的最高表现。
4.8.22 metric 体系的”信任建设”
工业团队最容易低估的 metric 议题——指标体系的信任建设。
具体含义:
- 团队相信 metric 反映真实质量 → 决策快、执行强
- 团队怀疑 metric → 每次结果都有人质疑、决策慢
- 团队漠视 metric → metric 沦为摆设
信任建设的关键动作:
- 透明计算:每个 metric 的计算公式公开
- 可追溯:任何分数能追溯到具体样例
- 失败案例可视化:让团队看到”低分时模型实际是怎么答的”
- 校准报告:定期发布 metric 与人工的一致性
- 诚实承认局限:明确”哪些不靠谱”
工业团队的实务:搭起 metric 体系后,前 3 个月要花大量时间做”信任建设”——不是改 metric,而是让团队接受 metric。这种”软工作”比技术工作更难,但更重要。
读完本章希望读者带走的最高视角:metric 体系最终是关于团队信任的工程。技术让数字准确,组织让团队相信数字——两者缺一不可。
4.8.23 metric 体系给”工程文化”的塑造
工业团队的 metric 体系不只是评测工具——它在塑造团队的工程文化:
- 看重 Faithfulness 的团队 → 工程师默认严谨思考”不编造”
- 看重 Latency 的团队 → 工程师默认关注性能
- 看重 Cost 的团队 → 工程师默认成本意识
- 看重 User Satisfaction 的团队 → 工程师默认用户视角
team 关心什么 metric,工程师就会潜意识朝那个方向优化。这种”metric → 文化”的塑造效应是评测体系的”软影响”。
工程负责人的实务:选 metric 不只看技术,看你想塑造什么样的团队文化。一份 metric 列表本质是一份”我们重视什么”的宣言。
读完本章希望读者带走的最高视角:metric 体系是团队文化的载体,不只是数字。这种文化视角让 metric 设计获得超出”指标”的工程价值。
4.8.24 metric 体系给 LLM 工程师的”读完底线”
读完整章 metric 体系内容后,给读者一份”底线清单”——
□ 不会盲目跑一堆 metric 而不思考业务关联
□ 不会让单一 metric 主导决策(避免 Goodhart's Law)
□ 不会忽略 metric 的统计噪声(每个分数都有 ±5pp)
□ 不会跳过元评测就相信 metric 数字
□ 不会让 metric 命名混乱(带来跨团队沟通灾难)
5 项底线对应整章核心方法学。底线不是”做到这些就够”,而是”低于这些就有问题”。
读完本章希望读者带走的最朴素心态:metric 不是越多越好,是越精确越好。少而精的 metric 体系比多而散的更有工程价值——这是评测体系成熟度的低调标志。
4.8.25 一份多指标聚合的 Python 工具
整合本章方法学,给一份”多指标聚合 + 统计推断”的完整 Python 工具:
# metrics_aggregator.py
import numpy as np
from scipy import stats
from dataclasses import dataclass, field
@dataclass
class MetricResult:
name: str
scores: list[float] # 单条样例分数
threshold: float | None = None
higher_is_better: bool = True
@property
def mean(self) -> float:
return float(np.nanmean(self.scores))
@property
def median(self) -> float:
return float(np.nanmedian(self.scores))
@property
def p95(self) -> float:
return float(np.nanpercentile(self.scores, 95))
@property
def std(self) -> float:
return float(np.nanstd(self.scores))
def confidence_interval(self, alpha=0.05) -> tuple[float, float]:
"""Wald 置信区间"""
n = len([s for s in self.scores if not np.isnan(s)])
if n < 30:
# 小样本用 t-distribution
t = stats.t.ppf(1 - alpha/2, n-1)
margin = t * self.std / np.sqrt(n)
else:
z = stats.norm.ppf(1 - alpha/2)
margin = z * self.std / np.sqrt(n)
return (self.mean - margin, self.mean + margin)
def bootstrap_ci(self, B=1000, alpha=0.05) -> tuple[float, float]:
"""非参数 bootstrap CI"""
means = [np.nanmean(np.random.choice(self.scores, len(self.scores)))
for _ in range(B)]
return tuple(np.percentile(means, [100*alpha/2, 100*(1-alpha/2)]))
def passes_threshold(self) -> bool:
if self.threshold is None:
return True
if self.higher_is_better:
return self.mean >= self.threshold
return self.mean <= self.threshold
def paired_comparison(a_scores, b_scores) -> dict:
"""配对比较:A 是否显著优于 B"""
diffs = np.array(a_scores) - np.array(b_scores)
t_stat, p_value = stats.ttest_rel(a_scores, b_scores)
return {
"mean_diff": float(np.mean(diffs)),
"p_value": float(p_value),
"significant_at_05": p_value < 0.05,
"n_pairs": len(diffs),
}
def aggregate_report(results: list[MetricResult]) -> dict:
"""聚合多 metric 的最终报告"""
return {
"metrics": {r.name: {
"mean": r.mean,
"median": r.median,
"p95": r.p95,
"ci_95": r.confidence_interval(),
"bootstrap_ci_95": r.bootstrap_ci(),
"passes": r.passes_threshold(),
} for r in results},
"overall_pass": all(r.passes_threshold() for r in results),
}
不到 80 行代码涵盖第 4 章 §4.7 所有统计推断方法:
- 均值 / 中位数 / p95 / std
- Wald 置信区间(小样本用 t)
- Bootstrap 置信区间(非参数)
- 阈值判定
- Paired comparison(配对比较 + p-value)
- 多指标聚合报告
工业实务:把这份代码作为团队评测库的一部分。任何评测 metric 都通过 MetricResult 包装——自动获得统计推断 + 阈值判定能力。这种统一抽象让评测代码维护成本大幅降低。
4.8.26 一份具体的 Grafana dashboard 面板配置
整合本章方法学,给一份”评测指标 dashboard”的 Grafana 面板示例:
{
"dashboard": {
"title": "LLM Evals Dashboard",
"panels": [
{
"title": "Faithfulness 7d 趋势",
"type": "graph",
"targets": [{
"expr": "avg_over_time(eval_faithfulness[1h])",
"legendFormat": "Faithfulness"
}],
"alert": {
"name": "Faithfulness 退化告警",
"conditions": [{
"evaluator": {"type": "lt", "params": [0.85]},
"operator": {"type": "and"},
"query": {"params": ["A", "5m", "now"]}
}],
"frequency": "60s",
"noDataState": "no_data"
}
},
{
"title": "失败 case 分布(按 category)",
"type": "barchart",
"targets": [{
"expr": "sum by (category) (eval_failure_count[1d])"
}]
},
{
"title": "Latency p50/p95/p99",
"type": "graph",
"targets": [
{"expr": "histogram_quantile(0.5, eval_latency_bucket)", "legendFormat": "p50"},
{"expr": "histogram_quantile(0.95, eval_latency_bucket)", "legendFormat": "p95"},
{"expr": "histogram_quantile(0.99, eval_latency_bucket)", "legendFormat": "p99"}
]
},
{
"title": "Cost / 千次调用",
"type": "stat",
"targets": [{
"expr": "sum(eval_cost_per_query) * 1000"
}],
"thresholds": {
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 50},
{"color": "red", "value": 100}
]
}
},
{
"title": "Pass Rate by Test Category",
"type": "table",
"targets": [{
"expr": "sum by (category) (rate(eval_pass_count[1d])) / sum by (category) (rate(eval_total_count[1d]))"
}]
},
{
"title": "Judge / Grader Drift (元评测)",
"type": "graph",
"targets": [
{"expr": "judge_self_consistency", "legendFormat": "SC"},
{"expr": "judge_calibration_spearman", "legendFormat": "Spearman"},
{"expr": "judge_discriminative_power", "legendFormat": "DP"}
]
}
],
"refresh": "1m",
"tags": ["llm", "evals"]
}
}
6 个面板覆盖:
- 主指标 7 天趋势 + 退化告警
- 失败 case 按 category 分布
- Latency p50/p95/p99
- Cost / 千次调用(含三档颜色阈值)
- Pass Rate 按 category 分组表格
- Judge / Grader 元评测三指标
工业实务:把这份 JSON 导入 Grafana 作为团队评测 dashboard 起点。后续根据业务调整 metric 名 / 阈值即可。这是 LLM 评测可观测性的”开箱即用”方案。
4.8.27 一份”指标计算 step-by-step”演示
整合本章方法学,给一个真实的”4 个核心指标在同一条样例上的完整计算”演示——读者能精确看到每个指标怎么算出来:
输入:
Question: 这家公司什么时候成立? 现在规模如何?
Context: "Acme Corp 成立于 1995 年, 总部在加州 Mountain View.
员工约 500 人, 2024 年营收 8000 万美元."
Answer: "Acme Corp 成立于 1995 年, 它现在是世界 500 强,
员工约 500 人, 估值超过 10 亿美元."
Reference: "Acme Corp 成立于 1995 年, 现有员工约 500 人."
指标 1:Faithfulness
Step 1 - 拆解陈述:
["成立于 1995 年", "是世界 500 强", "员工约 500 人", "估值超过 10 亿美元"]
Step 2 - NLI 判定(每条与 context 比对):
陈述 1: ✓ context "成立于 1995 年" 直接命中
陈述 2: ✗ context 没说"世界 500 强"
陈述 3: ✓ context "员工约 500 人" 直接命中
陈述 4: ✗ context 只说"营收 8000 万", 没说估值
Step 3 - 聚合: 2/4 = 0.50
指标 2:Context Recall
Step 1 - 拆解 reference 陈述:
["成立于 1995 年", "员工约 500 人"]
Step 2 - 检查 context 是否覆盖每条:
陈述 1: ✓
陈述 2: ✓
Step 3 - 聚合: 2/2 = 1.00
指标 3:Answer Relevance
Step 1 - 让 LLM 反推 question:
Generated Q: "Acme Corp 成立时间和规模如何?"
Step 2 - 与原 question 算 cosine similarity:
原: "这家公司什么时候成立? 现在规模如何?"
生成: "Acme Corp 成立时间和规模如何?"
Cosine similarity: 0.87
Step 3 - 检查是否 noncommittal:
Answer 不含"我不知道" → noncommittal=0
Step 4 - 聚合: 0.87 (没被 noncommittal 惩罚)
指标 4:Answer Correctness
Step 1 - 拆解 Answer 与 Reference 的陈述:
Answer: ["1995 年", "世界 500 强", "员工 500 人", "估值 10 亿美元"]
Reference: ["1995 年", "员工 500 人"]
Step 2 - 计算 F1:
TP (Answer 与 Reference 都包含): 2
FP (Answer 多说但 Reference 没说): 2
FN (Reference 说但 Answer 没说): 0
Precision: 2/4 = 0.50
Recall: 2/2 = 1.00
F1: 0.67
Step 3 - 聚合: 0.67
最终汇总:
| 指标 | 分数 | 含义 |
|---|---|---|
| Faithfulness | 0.50 | 一半在编 |
| Context Recall | 1.00 | retriever 完美 |
| Answer Relevance | 0.87 | 切题 |
| Answer Correctness | 0.67 | 含编造但核心对 |
洞察:这个例子说明——retriever 没问题(Recall 1.0),但 generator 编造了”世界 500 强”和”估值 10 亿”。这就是 Faithfulness 评测能精确定位”generator 编造”问题的工程价值。
读完本章希望读者带走的最具体认知:指标不是抽象数字,每条都对应可解释的具体计算。理解这种 step-by-step 让你能 debug 任何评测分数异常——这是评测工程师的基本功。
4.8.28 一份”指标统计显著性”实操手册
工程团队最常踩的坑之一:用 30 题评测集得出”模型 A 比 B 好 3pp”就上线 A——但 30 题样本下 3pp 差距完全可能是随机波动。下面是一份决断手册,让团队任何”模型 A 优于 B”的结论都附带置信度声明。
import math
from dataclasses import dataclass
from scipy import stats
@dataclass
class SignificanceResult:
delta_pp: float
p_value: float
confidence_interval_95: tuple[float, float]
significant: bool
min_n_for_99_power: int
recommendation: str
class MetricSignificanceTester:
"""McNemar / Bootstrap / power 三合一的显著性判定"""
def mcnemar(self, b: int, c: int) -> float:
"""同一题集 A/B 配对:b = A正B错, c = A错B正"""
if b + c == 0:
return 1.0
chi_sq = (abs(b - c) - 1) ** 2 / (b + c)
return 1 - stats.chi2.cdf(chi_sq, df=1)
def bootstrap_ci(self, scores_a: list[float], scores_b: list[float],
n_boot: int = 10_000) -> tuple[float, float]:
import random
deltas = []
for _ in range(n_boot):
indices = [random.randrange(len(scores_a))
for _ in range(len(scores_a))]
a_mean = sum(scores_a[i] for i in indices) / len(indices)
b_mean = sum(scores_b[i] for i in indices) / len(indices)
deltas.append(a_mean - b_mean)
deltas.sort()
return (deltas[int(0.025 * n_boot)], deltas[int(0.975 * n_boot)])
def required_n(self, baseline: float, target_diff: float,
power: float = 0.99, alpha: float = 0.05) -> int:
"""检测 baseline 上 target_diff 大小的差距,需要多少样本"""
z_alpha = stats.norm.ppf(1 - alpha / 2)
z_beta = stats.norm.ppf(power)
p_bar = baseline + target_diff / 2
sigma2 = 2 * p_bar * (1 - p_bar)
return math.ceil((z_alpha + z_beta) ** 2 * sigma2 / (target_diff ** 2))
def evaluate(self, scores_a: list[float], scores_b: list[float],
paired_b: int, paired_c: int) -> SignificanceResult:
delta_pp = (sum(scores_a) / len(scores_a) -
sum(scores_b) / len(scores_b)) * 100
p_val = self.mcnemar(paired_b, paired_c)
ci = self.bootstrap_ci(scores_a, scores_b)
baseline = sum(scores_b) / len(scores_b)
n_required = self.required_n(baseline, abs(delta_pp / 100))
sig = p_val < 0.05 and (ci[0] > 0 or ci[1] < 0)
rec = ("✅ 可上线 A" if sig and delta_pp > 0
else "❌ 不显著,扩样本到 ≥ {}".format(n_required) if not sig
else "⚠️ A 显著差于 B")
return SignificanceResult(
delta_pp=round(delta_pp, 2),
p_value=round(p_val, 4),
confidence_interval_95=(round(ci[0], 4), round(ci[1], 4)),
significant=sig,
min_n_for_99_power=n_required,
recommendation=rec,
)
flowchart LR
A[模型 A 跑评测] --> S1[scores_a]
B[模型 B 跑评测] --> S2[scores_b]
S1 --> M[McNemar 配对检验]
S2 --> M
S1 --> BO[Bootstrap 95% CI]
S2 --> BO
M --> P[p_value]
BO --> CI[delta CI]
P --> J{p<0.05 & CI 不跨 0?}
CI --> J
J -->|是| OK[✅ 显著]
J -->|否| N[❌ 不显著]
N --> R[required_n 算扩多少]
style OK fill:#e8f5e9
style N fill:#ffebee
工程实务的 4 条铁律:
- 任何 A vs B 结论必须带 p-value + 95% CI——单独的 delta_pp 没有意义
- 样本量公式必跑——baseline 80%、想检 3pp 差距、power 99%,n ≥ 750 题;想检 1pp 差距 n ≥ 6700 题
- 配对设计 > 独立两组——同一份题集让 A、B 都跑(McNemar 检验比 chi-square 灵敏 2-3 倍)
- Bootstrap CI > t-test CI——LLM 评分不服从正态分布,bootstrap 更稳
具体例子:评测集 30 题,模型 A 24 对、B 21 对(A=80%, B=70%, delta=10pp 看似很大)——跑 McNemar:b=5, c=2, p_value ≈ 0.45(不显著!)。这就是为何”30 题验证大模型选型”是工程灾难——表面看似明显的差距完全可能是噪声。
读到这里读者应该明白:LLM 评测最便宜的失败方式是”样本不足下的过度解读”。把这套 SignificanceTester 串到所有”A vs B”对比脚本里——团队从此不再被 30 题样本骗到错决策。
4.8.29 LLM 评测特有的 9 类指标”反直觉陷阱”
很多团队在指标的细节上栽过跟头。下面是 LLM 评测特有的 9 类反直觉现象——这些不是”基础统计学问题”,而是 LLM 这个特殊评分对象专属的工程陷阱。
| # | 现象 | 反直觉的”看似” | 实际情况 | 修法 |
|---|---|---|---|---|
| 1 | Faithfulness 100% ≠ Answer 对 | 完全 grounded 应该等于答对 | 答案 grounded 但 retrieval 没拿到正确文档 → 答案完全 grounded 在错文档上 | 必须配 Context Recall ≥ 0.8 才有意义 |
| 2 | Correctness 高 + Recall 高 + Precision 低 | precision 低应该影响 correctness | 检索了一堆 irrelevant context,但 LLM 自己识别出有用的 | precision 低 → 长上下文成本飙升,monitor cost 而非 correctness |
| 3 | Average score 高但失败 case 致命 | 平均分高就稳 | 80% 题 0.95 + 20% 题 0.0(答错某些 case 是越界)→ 平均 0.76 看似好 | 必须看 worst-case score / 高危 case pass rate |
| 4 | Judge 给 5 分但人工给 3 分 | LLM-judge 标准应与人对齐 | judge 看不出某些专业错误(医疗 / 法律) | 元评测用 κ 量化对齐度 |
| 5 | 每次跑分数稳定但与人工差距大 | 稳定 = 可靠 | self-consistency 高但 calibration 低 → 稳定地错 | 区分稳定性与准确性 |
| 6 | 3 月分数涨 + 用户投诉涨 | 评测进步用户应该满意 | 评测覆盖的样本与用户实际遇到的样本分布不同 | drift detection + hard case mining |
| 7 | Toxicity 0% 但拒答率 30% | 安全做得好 | 模型把”该答的”也拒了 | refusal appropriateness 必须独立度量 |
| 8 | Tool call 准确率 95% 但任务完成率 60% | tool 都对应该任务也对 | 工具调对了但顺序错 / 信息整合错 | trajectory 评测 + goal_reached 双指标 |
| 9 | benchmark SOTA 但生产实测差 | 公开榜单 SOTA = 实际能强 | benchmark 已被训练数据污染 | 私有评测集 + 污染检测(§3.9.20) |
flowchart TB
L[看似指标好] --> Q1{"Faithfulness 高?"}
Q1 -->|是| Q2{"Context Recall ≥ 0.8?"}
Q2 -->|否| T1["陷阱 1: grounded on wrong doc"]
Q2 -->|是| Q3{"worst-case OK?"}
Q3 -->|否| T2["陷阱 3: 高危 case 致命"]
Q3 -->|是| Q4{"refusal rate 合理?"}
Q4 -->|否| T3["陷阱 7: 过度拒答"]
Q4 -->|是| Q5{"私有 vs benchmark 差距?"}
Q5 -->|大| T4["陷阱 9: 数据污染"]
Q5 -->|小| OK["真的好"]
style T1 fill:#ffebee
style T2 fill:#ffebee
style T3 fill:#ffebee
style T4 fill:#ffebee
style OK fill:#e8f5e9
工程实务的 5 条防陷阱原则:
- 永远看 ≥ 3 个指标的组合——单维度高分必然误导
- 关心分布而非均值——p99 / worst-case / 高危 case 比 average 重要
- 校准 vs 一致性分开度量——稳定但偏的 grader 比抖动的 grader 更危险
- 生产 vs 评测分布对比——任何指标涨但生产体验降 → 立刻 drift detection
- 公开 benchmark 仅作参考点——核心是私有评测集 + 私有 hard case
研究背景:
- 陷阱 1 / 2 出自 ragas 论文 §3.4 的 “metric correlation analysis”
- 陷阱 3 出自 SafetyBench 论文(arXiv:2309.07045)“high-stakes case 失败远比 average 重要”的论证
- 陷阱 6 出自 Chen et al. 2024 “How is ChatGPT’s Behavior Changing over Time”(arXiv:2307.09009 加更新)
- 陷阱 9 出自 OpenAI 2024-04 SimpleQA 报告中的污染分析
学完这 9 个陷阱后,读者再看任何评测报告会自然问”是不是踩了陷阱 X”——这是从”会算指标”到”懂指标”的关键一跃。
4.8.30 一份”指标生命周期管理”工具——如何 deprecate 旧指标
随着评测体系演化,指标会过期、被替代、被弃用——但很多团队不敢删旧指标,导致 dashboard 上 50+ 指标里 30 个已经”陈年累月没人看”。下面是一份指标生命周期管理工具:
import json
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Iterable
@dataclass
class MetricLifecycle:
metric_id: str
name: str
version: str
status: str # "active" | "deprecated" | "experimental" | "retired"
introduced_at: str
deprecated_at: str | None
successor: str | None
last_referenced_in_decision: str | None
monthly_query_count: int
formula_changed: bool
class MetricLifecycleManager:
"""指标的生老病死管理"""
DEPRECATION_TRIGGER_DAYS = 90
RETIREMENT_AFTER_DEPRECATED_DAYS = 90
def __init__(self, registry_path: Path):
self.path = registry_path
def load(self) -> list[MetricLifecycle]:
if not self.path.exists():
return []
return [MetricLifecycle(**r)
for r in json.loads(self.path.read_text())]
def save(self, metrics: list[MetricLifecycle]):
self.path.write_text(
json.dumps([m.__dict__ for m in metrics],
ensure_ascii=False, indent=2))
def find_deprecation_candidates(self) -> list[MetricLifecycle]:
"""挖出"该 deprecate 的"指标"""
now = datetime.now()
cutoff = now - timedelta(days=self.DEPRECATION_TRIGGER_DAYS)
candidates = []
for m in self.load():
if m.status != "active":
continue
if (m.last_referenced_in_decision and
datetime.fromisoformat(m.last_referenced_in_decision) < cutoff):
candidates.append(m)
if m.monthly_query_count < 5:
candidates.append(m)
return candidates
def deprecate(self, metric_id: str, successor: str = None):
metrics = self.load()
for m in metrics:
if m.metric_id == metric_id:
m.status = "deprecated"
m.deprecated_at = datetime.now().isoformat()
m.successor = successor
self.save(metrics)
def retire_eligible(self) -> list[str]:
"""deprecated 满 90 天 → retire"""
retired_ids = []
metrics = self.load()
cutoff = datetime.now() - timedelta(days=self.RETIREMENT_AFTER_DEPRECATED_DAYS)
for m in metrics:
if (m.status == "deprecated" and m.deprecated_at and
datetime.fromisoformat(m.deprecated_at) < cutoff):
m.status = "retired"
retired_ids.append(m.metric_id)
self.save(metrics)
return retired_ids
def emit_dashboard_filter(self) -> dict:
"""让 dashboard 默认只展示 active + experimental"""
return {
"show": [m.metric_id for m in self.load()
if m.status in ("active", "experimental")],
"hide": [m.metric_id for m in self.load()
if m.status in ("deprecated", "retired")],
}
stateDiagram-v2 [*] --> experimental experimental --> active: validated 1 季度 experimental --> retired: 实验失败 active --> deprecated: 90 天无引用 deprecated --> active: rebound(被重新使用) deprecated --> retired: 90 天后 retired --> [*]
工程实务 4 个生命周期规则:
| 状态 | 定义 | dashboard 可见 | 何时进入 |
|---|---|---|---|
| experimental | 试用中、未验证可靠 | 默认隐藏可手动开 | 新引入 |
| active | 正常工作的核心指标 | 默认展示 | experimental 验证通过 |
| deprecated | 不推荐使用、有 successor | 默认隐藏可查 | 90 天无人引用 / 已有更优替代 |
| retired | 永久退役 | 完全隐藏 | deprecated 90 天后 |
具体例子:某团队 3 年评测体系 dashboard 累积 73 指标。跑 lifecycle manager:
- 28 个 deprecation 候选(last_referenced 超 90 天)
- 12 个直接 retire(deprecated > 6 月)
- 18 个 active 核心
- 15 个 experimental 试用中
结果:dashboard 从 73 减到 33,主管开会时聚焦关键指标,3 分钟看完。
工程实务的 4 个 lifecycle 操作纪律:
- 实验指标必须有”试用期”:默认 90 天后必须做 active or retire 决策
- deprecated 必须给 successor:不能”光弃用不指明替代”
- retire 后保留历史数据但不再展示:让旧报告可追溯
- 每季度跑一次 retire_eligible:自动化清理,不要靠人力 review
研究背景:
- ML 模型有 model card / system card,metric 也该有 metric card——本节生命周期是 metric card 的运营延伸
- DataHub / Amundsen / OpenLineage 等数据治理工具的 lifecycle 抽象是这个思路的源头
把 metric lifecycle 视为评测体系的”垃圾回收机制”——没它的话 dashboard 会像”祖传项目里 5 年没删的 deprecated 函数”一样腐朽。每季度跑一次能确保评测信号简洁聚焦。
4.8.31 一份”复合指标”工程实践——把多维信号聚成一个 health 数
工程团队最常被问到的问题:‘我们的 RAG 健康吗?‘回答用 8 个指标 → 主管头大。回答 1 个 health number → 管理层满意。但简单 average 是反模式——必须用工程化的复合方式。下面是一份生产级 health score 设计:
import math
from dataclasses import dataclass
from typing import Iterable
@dataclass
class HealthComponent:
name: str
raw_value: float
target: float
weight: float
is_higher_better: bool
@dataclass
class CompositeHealthScore:
overall: float # 0-100
grade: str # "A" | "B" | "C" | "D" | "F"
bottleneck: str # 拉低分数的最大维度
components: list[dict]
color: str # green / yellow / red
class CompositeMetricCalculator:
"""多维度信号聚合为 1 个 health 数"""
GRADE_THRESHOLDS = [
(90, "A", "green"),
(80, "B", "green"),
(70, "C", "yellow"),
(60, "D", "yellow"),
(0, "F", "red"),
]
def _normalize(self, comp: HealthComponent) -> float:
"""归一化到 0-100 区间"""
if comp.is_higher_better:
ratio = comp.raw_value / max(comp.target, 1e-6)
else:
ratio = comp.target / max(comp.raw_value, 1e-6)
return min(100, max(0, ratio * 100))
def _grade(self, score: float) -> tuple[str, str]:
for threshold, grade, color in self.GRADE_THRESHOLDS:
if score >= threshold:
return grade, color
return "F", "red"
def calculate(self,
components: list[HealthComponent]) -> CompositeHealthScore:
# 关键:用几何加权平均而非算术平均
# 几何平均能"惩罚"任一维度的低分(一个维度低就拉低整体)
normalized = []
for c in components:
n = self._normalize(c)
normalized.append({
"name": c.name,
"raw": c.raw_value,
"target": c.target,
"normalized": round(n, 2),
"weight": c.weight,
})
log_sum = sum(c["weight"] * math.log(max(c["normalized"], 1))
for c in normalized)
total_weight = sum(c["weight"] for c in normalized)
geo_mean = math.exp(log_sum / max(total_weight, 1))
bottleneck = min(normalized, key=lambda c: c["normalized"])["name"]
grade, color = self._grade(geo_mean)
return CompositeHealthScore(
overall=round(geo_mean, 1),
grade=grade,
bottleneck=bottleneck,
components=normalized,
color=color,
)
# 使用例:客服 RAG 系统的 health score
def customer_service_health() -> CompositeHealthScore:
components = [
HealthComponent("faithfulness", 0.85, 0.9, 0.30, True),
HealthComponent("context_recall", 0.78, 0.85, 0.25, True),
HealthComponent("answer_relevance", 0.91, 0.9, 0.20, True),
HealthComponent("avg_latency_ms", 1800, 2000, 0.10, False),
HealthComponent("cost_per_query_cents", 1.2, 1.5, 0.05, False),
HealthComponent("safety_violation_rate", 0.001, 0.005, 0.10, False),
]
return CompositeMetricCalculator().calculate(components)
flowchart LR
subgraph "组件层"
F[Faithfulness 0.85<br/>weight 30%]
R[Recall 0.78<br/>weight 25%]
AR[Answer Relevance 0.91<br/>weight 20%]
L[Latency 1800ms<br/>weight 10%]
CO[Cost 1.2c<br/>weight 5%]
S[Safety 0.001<br/>weight 10%]
end
F --> N1[normalize 94]
R --> N2[normalize 91]
AR --> N3[normalize 101]
L --> N4[normalize 111]
CO --> N5[normalize 125]
S --> N6[normalize 500]
N1 --> GM[加权几何平均]
N2 --> GM
N3 --> GM
N4 --> GM
N5 --> GM
N6 --> GM
GM --> H[Health Score = 92]
H --> G[Grade A]
H --> B[Bottleneck: Recall]
style H fill:#e8f5e9
style B fill:#fff3e0
工程实务的 4 条复合指标设计原则:
- 几何平均 > 算术平均:算术平均会让 1 个 100 分掩盖 1 个 30 分;几何平均不会
- always 报 bottleneck:health score 必须告诉读者”是哪条拉低”,否则信号空洞
- weight 总和不必为 1:用相对权重——便于添加 / 删减维度
- target 用业务定义而非历史:target 是”应该达到”——用历史均值是反模式
3 类常见错误:
| 错误 | 表现 | 修正 |
|---|---|---|
| 直接 average 0-1 score | latency 1.5s vs 2s 都视为”完美” | 必须先归一化到 0-100 |
| weight 凭直觉设 | safety 仅占 1% | 业务方共识 + 红线维度(safety)weight 至少 10% |
| 只看 score 不看 grade | 看到 78 不知道好坏 | 必转为 A/B/C/D/F 等级 |
工程实务:把 health score 接到 dashboard 的”今日健康度”top 卡片,主管 1 秒看 grade + bottleneck,决定要不要深入查 detail。这是评测体系”管理可消费”的工程化体现。
研究背景:
- DORA 报告的 4 大指标聚合为”DORA Performance Tier” 用类似几何思路
- DataDog APM 的”App Health Score”也用加权几何平均
- Microsoft Reliability Engineering 内部”Service Health”指标公开过同样设计
读者把 CompositeMetricCalculator 接入团队 dashboard,从此主管沟通是 1 秒钟而非 5 分钟——这是评测系统化的最后一步。
4.8.32 一份”指标 SLO + 错误预算”——把评测信号变成可消费的运营纪律
软件工程的 SLO(Service Level Objective)+ Error Budget 思路完全可移植到 LLM 评测。下面是把 Faithfulness / Latency 等指标变成”可观测的承诺”的工程化实现:
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Iterable
@dataclass
class MetricSLO:
metric_name: str
objective: float # SLO target,例如 0.95
window_days: int # 观察窗口
is_higher_better: bool
@dataclass
class ErrorBudgetStatus:
metric_name: str
current_value: float
objective: float
error_budget_total: float
error_budget_consumed: float
error_budget_remaining_pct: float
days_until_exhaustion: float | None
health: str
class MetricSLOTracker:
"""LLM 评测指标的 SLO + Error Budget 追踪器"""
DEFAULT_SLOS = [
MetricSLO("faithfulness", 0.85, 30, True),
MetricSLO("answer_relevance", 0.90, 30, True),
MetricSLO("safety_violation_rate", 0.005, 7, False),
MetricSLO("p95_latency_ms", 2000, 7, False),
]
def calculate_budget(self, slo: MetricSLO,
recent_values: list[float],
consumption_rate_per_day: float) -> ErrorBudgetStatus:
# 几个核心算法(参考 Google SRE Book 的 Error Budget 公式)
if slo.is_higher_better:
# 例: faithfulness SLO = 0.95 ——超过部分是 budget
slack_per_sample = [v - slo.objective for v in recent_values]
below = sum(1 for v in recent_values if v < slo.objective)
error_budget_total = len(recent_values) * (1 - slo.objective)
error_budget_consumed = below
else:
# 例: latency SLO = 2000ms ——低于部分是 budget
below = sum(1 for v in recent_values if v > slo.objective)
error_budget_total = len(recent_values) * 0.05 # 假设 5% 容忍
error_budget_consumed = below
remaining_pct = max(0, 1 - error_budget_consumed /
max(error_budget_total, 1)) * 100
if consumption_rate_per_day > 0:
days_left = (error_budget_total - error_budget_consumed) / \
consumption_rate_per_day
else:
days_left = None
if remaining_pct >= 50:
health = "healthy"
elif remaining_pct >= 20:
health = "burning"
else:
health = "exhausted"
current = recent_values[-1] if recent_values else 0
return ErrorBudgetStatus(
metric_name=slo.metric_name,
current_value=round(current, 3),
objective=slo.objective,
error_budget_total=round(error_budget_total, 1),
error_budget_consumed=round(error_budget_consumed, 1),
error_budget_remaining_pct=round(remaining_pct, 1),
days_until_exhaustion=(round(days_left, 1) if days_left
is not None else None),
health=health,
)
def policy_decision(self, status: ErrorBudgetStatus) -> str:
"""根据 budget 状态决定 release 策略"""
if status.health == "healthy":
return "可以正常 release,不需特别审批"
if status.health == "burning":
return ("评测 budget 消耗速度过快,本周 release 需 "
"evals owner 签字 + 灰度 25% 起")
return ("budget 已耗尽,本周冻结非关键 release,"
"聚焦修复 + RCA")
flowchart LR
M[评测指标周值] --> SLO[SLO 定义]
M --> CALC[Budget 计算]
SLO --> CALC
CALC --> H{remaining %}
H -->|"≥ 50%"| HEALTHY[健康<br/>正常 release]
H -->|"20-50%"| BURNING[消耗中<br/>release 需审批]
H -->|"< 20%"| EXHAUST[耗尽<br/>冻结 release]
HEALTHY -->|绿灯| GO[OK to ship]
BURNING -->|黄灯| YEL[谨慎]
EXHAUST -->|红灯| RED[stop ship]
style HEALTHY fill:#e8f5e9
style BURNING fill:#fff3e0
style EXHAUST fill:#ffebee
工程实务的 4 条 SLO 设计经验:
| 指标 | 推荐 SLO | 窗口 | 错误预算 |
|---|---|---|---|
| Faithfulness | ≥ 0.85 | 30d | 15% |
| Safety violation | ≤ 0.5% | 7d | 0.5% |
| p95 latency | ≤ 2000ms | 7d | 5% |
| User thumbs_down | ≤ 3% | 7d | 3% |
SLO 设计 4 条原则:
- 目标必须可达:SLO=99.99% 听起来好但永远耗 budget——选 90-95% 区间
- 窗口与业务节奏匹配:safety 用 7d、faithfulness 用 30d(反映季度迭代)
- 不同指标不同窗口:不要全部 30d
- 每月校准 SLO:业务变化 → SLO 也变(如 latency 阈值随用户期待降低)
具体例子:某团队的 4 SLO 一周报告:
| Metric | current | SLO | budget 剩 | health | 决策 |
|---|---|---|---|---|---|
| Faithfulness | 0.86 | 0.85 | 78% | healthy | 正常 release |
| Safety violation | 0.7% | 0.5% | 12% | exhausted | 冻结 |
| p95 latency | 1850ms | 2000ms | 65% | healthy | OK |
| Thumbs down | 2.4% | 3% | 53% | healthy | OK |
行动:safety budget 耗尽 → 本周冻结非关键 release,安全团队 RCA。
研究背景:
- Google SRE Book 第 4 章 “Service Level Objectives” 是 Error Budget 的正典
- DORA 把 SLO 列为软件交付 4 大指标之一
- LinkedIn / Stripe 等公司公开过他们 ML 系统的”AI SLO”——本节是 LLM 版
把 MetricSLOTracker 接入团队评测体系——把”评测分数低”这种模糊概念变成”budget 耗尽”这种可执行决策。这是 SRE 文化在 LLM 评测的工程化体现。
4.8.33 一份”指标 vs 用户满意度”的对齐验证——评测的终极正确性检验
任何指标的最终意义在于”能预测用户满意度”。下面给出”指标对齐用户感受”的工程化验证:
from dataclasses import dataclass
from typing import Iterable
@dataclass
class MetricBusinessAlignmentResult:
metric_name: str
correlation_with_nps: float # 与 NPS / thumbs-up 的相关性
correlation_with_retention: float # 与留存率
correlation_with_revenue: float # 与营收影响
business_alignment_score: float
is_load_bearing: bool
class MetricBusinessAlignmentValidator:
"""验证评测指标与业务结果的对齐度"""
def correlate(self, metric_values: list[float],
business_outcomes: list[float]) -> float:
"""简单 Pearson correlation"""
if len(metric_values) < 5:
return 0.0
mean_m = sum(metric_values) / len(metric_values)
mean_b = sum(business_outcomes) / len(business_outcomes)
num = sum((m - mean_m) * (b - mean_b)
for m, b in zip(metric_values, business_outcomes))
den_m = (sum((m - mean_m) ** 2 for m in metric_values)) ** 0.5
den_b = (sum((b - mean_b) ** 2 for b in business_outcomes)) ** 0.5
return num / (den_m * den_b) if (den_m > 0 and den_b > 0) else 0.0
def validate(self, metric_history: list[dict]) -> list[MetricBusinessAlignmentResult]:
"""对每个评测指标算与业务的相关性"""
results = []
for metric_name in ["faithfulness", "answer_relevance",
"latency_p95", "safety_violation"]:
metric_vals = [h.get(metric_name, 0) for h in metric_history]
nps_vals = [h.get("nps", 0) for h in metric_history]
retention_vals = [h.get("retention", 0) for h in metric_history]
revenue_vals = [h.get("revenue_delta", 0) for h in metric_history]
r_nps = self.correlate(metric_vals, nps_vals)
r_ret = self.correlate(metric_vals, retention_vals)
r_rev = self.correlate(metric_vals, revenue_vals)
score = (abs(r_nps) + abs(r_ret) + abs(r_rev)) / 3
is_load_bearing = score >= 0.4
results.append(MetricBusinessAlignmentResult(
metric_name=metric_name,
correlation_with_nps=round(r_nps, 3),
correlation_with_retention=round(r_ret, 3),
correlation_with_revenue=round(r_rev, 3),
business_alignment_score=round(score, 3),
is_load_bearing=is_load_bearing,
))
return sorted(results, key=lambda r: -r.business_alignment_score)
flowchart LR
H[12 周 评测 + 业务历史] --> V[Validator]
V --> M{对每个 metric}
M --> R1[corr vs NPS]
M --> R2[corr vs retention]
M --> R3[corr vs revenue]
R1 --> S[business alignment score]
R2 --> S
R3 --> S
S --> Q{score ≥ 0.4?}
Q -->|是| LB[load_bearing<br/>核心信号]
Q -->|否| NLB[弱关联<br/>降权]
style LB fill:#e8f5e9
style NLB fill:#fff3e0
工程实务的 4 条对齐验证经验:
- 每季度跑一次 validation:用过去 12 周的 metric × business outcome 对照
- load-bearing metrics 应是 P0 dashboard:score ≥ 0.4 的指标主管必看
- 弱关联的指标不上 SLO:与业务无关的指标当 SLO 反而误导
- 0 相关 metric 应考虑退役:3 季度 validation 都接近 0 → §4.8.30 deprecate
具体例子:某客服 chatbot 12 周 validation:
| metric | corr vs NPS | corr vs retention | 综合 | load-bearing |
|---|---|---|---|---|
| Faithfulness | 0.62 | 0.58 | 0.60 | ✅ 核心 |
| Answer Relevance | 0.55 | 0.48 | 0.52 | ✅ 核心 |
| latency_p95 | -0.42 | -0.35 | 0.39 | ⚠️ 边界 |
| safety_violation | -0.71 | -0.45 | 0.58 | ✅ 核心 |
| BLEU score | 0.08 | 0.05 | 0.07 | ❌ 退役 |
| 输出长度 | 0.12 | -0.03 | 0.08 | ❌ 退役 |
洞察:BLEU 与输出长度对业务几乎 0 相关——可以彻底从 dashboard 移除。Faithfulness / Safety 是真核心信号——应作为 SLO 的主体。
3 类常见对齐问题:
| 问题 | 现象 | 修法 |
|---|---|---|
| 指标涨用户骂 | Faithfulness +5pp、NPS -3 | 指标设计有问题,重做 anchor |
| 指标无关 | BLEU 与 NPS 完全独立 | 退役这种”古董”指标 |
| 业务方不关心 | NPS 高但 revenue 不动 | 看 retention,NPS 有时滞后 |
研究背景:
- Hill et al. 2017 “Why Should I Trust You? Explaining the Predictions of Any Classifier” 启发”指标可解释性”思路
- Stripe ML 公开过 “metric-business correlation” 季度审计
- DataDog 工程博客《Beyond Vanity Metrics》系统讨论这套思路
读者把 MetricBusinessAlignmentValidator 接入季度 evals 战略 review——确保团队投入的每个指标都”有业务意义”。这是评测体系的”自我反思”——不仅看分数还看对生意的实际影响。
4.8.34 一份”分位数指标”工程模板——为何均值会骗人
LLM 评测最常用的”均值”在生产环境里经常误导——下面给出”分位数指标”工程化方法,捕捉单纯均值看不到的尾部风险:
import statistics
from dataclasses import dataclass
from typing import Iterable
@dataclass
class QuantileReport:
metric: str
sample_count: int
p10: float
p50: float # 中位数
p90: float
p95: float
p99: float
mean: float
std: float
has_long_tail: bool
business_warning: str | None
class QuantileMetricsAnalyzer:
"""关注分位数而非均值的工程指标"""
def analyze(self, metric_name: str,
values: list[float]) -> QuantileReport:
if not values:
return QuantileReport(metric_name, 0, 0, 0, 0, 0, 0, 0, 0,
False, None)
sorted_v = sorted(values)
n = len(sorted_v)
def pct(p: float) -> float:
idx = min(int(p * n), n - 1)
return sorted_v[idx]
mean = sum(values) / n
std = statistics.stdev(values) if n > 1 else 0.0
# 判定 long tail
p99 = pct(0.99)
long_tail = (p99 > mean * 3) or (std > mean * 0.5)
warning = None
if long_tail:
warning = (f"长尾警告:p99={p99:.3f},均值 {mean:.3f},"
f"5% 用户体验远比平均差")
return QuantileReport(
metric=metric_name,
sample_count=n,
p10=round(pct(0.10), 3),
p50=round(pct(0.50), 3),
p90=round(pct(0.90), 3),
p95=round(pct(0.95), 3),
p99=round(p99, 3),
mean=round(mean, 3),
std=round(std, 3),
has_long_tail=long_tail,
business_warning=warning,
)
def compare_with_mean_only(self, q: QuantileReport) -> str:
"""揭示均值 vs 分位数的洞察差距"""
if q.has_long_tail:
mean_says = f"看均值 {q.mean:.2f} 团队感觉良好"
tail_says = (f"实际 5%(p99={q.p99:.2f})用户经历远差于均值——"
f"对应每天 {int(q.sample_count * 0.05)} 次糟糕体验")
return f"{mean_says}\n但 {tail_says}"
return "均值与分位数一致——无长尾风险"
flowchart LR
V[1000 sample 数值] --> S[排序 + 算分位数]
S --> P10[p10]
S --> P50[p50 中位数]
S --> P90[p90]
S --> P95[p95]
S --> P99[p99]
S --> M[mean / std]
P99 --> LT{p99 > 3x mean?}
M --> LT
LT -->|是| WARN[长尾警告]
LT -->|否| OK[分布健康]
WARN --> EXEC["exec 看到的不再是<br/>'平均 0.85 还行'<br/>而是 'p99=0.4,每天 N 次糟糕体验'"]
style WARN fill:#fff3e0
style EXEC fill:#ffebee
工程实务的 4 类常见均值误导:
| 场景 | 均值看起来 | p99 真相 |
|---|---|---|
| latency | 平均 1500ms | p99=8000ms(5% 用户超时) |
| Faithfulness | 0.87 | p99 = 0.32(5% 完全编造) |
| answer length | 200 字 | p99 = 3000 字(5% 啰嗦到不能用) |
| cost / query | $0.005 | p99 = $0.08(5% 异常昂贵) |
洞察:4 类指标”均值看起来 OK”——但 p99 都揭示存在严重长尾。这是 SLO(§4.8.32)必看 p95/p99 而非均值的根本原因。
具体例子:客服 chatbot 1000 题 Faithfulness 分布:
| 分位 | 值 |
|---|---|
| p10 | 0.62 |
| p50 | 0.91 |
| p90 | 0.96 |
| p95 | 0.93 |
| p99 | 0.32 |
| mean | 0.86 |
| std | 0.25 |
诊断:p99 = 0.32 远低于 mean → has_long_tail = True → 5% 严重编造的 case 是潜在事故。修法:从 p99 case 中找出 50 题入对抗集 + 修 prompt。
3 类常见均值滥用:
| 现象 | 原因 | 修法 |
|---|---|---|
| 主管看均值不看分位 | dashboard 默认显示 | 强制显示 p50/p95 |
| SLA 用均值定 | 历史习惯 | 改 p99 |
| 优化目标是均值 | 报告好看 | 优化 p99 = 优化最差体验 |
研究背景:
- “Tail at Scale” (Dean & Barroso 2013, ACM) 是分位数思路的经典
- Google SRE Book §6 “Latency SLO” 强制 p99 / p99.9
- DataDog APM 默认显示 p50/p95/p99 而非均值
读者把 QuantileMetricsAnalyzer 接到所有评测脚本——dashboard 上必须并排显示 mean + p95 + p99。这是评测体系”诚实面对长尾”的工程化第一步。
4.8.35 一份”指标可视化设计”指南——dashboard 该长什么样
指标量化只是第一步——主管 / 工程师消费时视觉设计决定信号是否真正传达。下面是评测 dashboard 的工程化设计模板:
from dataclasses import dataclass
from enum import Enum
from typing import Iterable
class ChartType(Enum):
BIG_NUMBER = "big_number" # 一个大数字 + 同比
TIME_SERIES = "time_series" # 时间趋势曲线
HEATMAP = "heatmap" # 二维分布
DISTRIBUTION = "distribution" # 直方图 / 箱线图
TABLE = "table" # 失败 case 列表
@dataclass
class DashboardPanel:
title: str
chart_type: ChartType
metric: str
refresh_seconds: int
threshold_red: float | None
threshold_yellow: float | None
audience: list[str] # ["exec", "engineer", "pm"]
class EvalsDashboardLayout:
"""评测 dashboard 的标准布局"""
EXECUTIVE_LAYOUT = [
DashboardPanel("Overall Health", ChartType.BIG_NUMBER,
"composite_score", 60, 0.7, 0.85, ["exec"]),
DashboardPanel("Faithfulness 30d", ChartType.TIME_SERIES,
"faithfulness", 300, 0.85, 0.90, ["exec"]),
DashboardPanel("User Satisfaction 7d", ChartType.TIME_SERIES,
"thumbs_up_pct", 60, 0.7, 0.85, ["exec"]),
DashboardPanel("Safety Violations 24h", ChartType.BIG_NUMBER,
"safety_violation_count", 60, 5, 1, ["exec"]),
]
ENGINEER_LAYOUT = [
DashboardPanel("Latest CI eval", ChartType.TABLE,
"ci_results", 30, None, None, ["engineer"]),
DashboardPanel("Top failed cases", ChartType.TABLE,
"failed_cases", 60, None, None, ["engineer"]),
DashboardPanel("Faithfulness by query type", ChartType.HEATMAP,
"faithfulness_by_type", 300, 0.85, 0.90, ["engineer"]),
DashboardPanel("p99 latency 7d", ChartType.TIME_SERIES,
"p99_latency_ms", 60, 3000, 2000, ["engineer"]),
DashboardPanel("Cost per query 30d", ChartType.TIME_SERIES,
"cost_per_query_usd", 300, 0.02, 0.01, ["engineer"]),
]
PM_LAYOUT = [
DashboardPanel("Goal completion rate", ChartType.BIG_NUMBER,
"goal_completion", 300, 0.6, 0.75, ["pm"]),
DashboardPanel("Hard case mining 7d", ChartType.TABLE,
"mined_cases", 3600, None, None, ["pm"]),
DashboardPanel("Domain breakdown", ChartType.DISTRIBUTION,
"score_by_domain", 300, None, None, ["pm"]),
]
def build_for_audience(self, audience: str) -> list[DashboardPanel]:
return {
"exec": self.EXECUTIVE_LAYOUT,
"engineer": self.ENGINEER_LAYOUT,
"pm": self.PM_LAYOUT,
}.get(audience, self.ENGINEER_LAYOUT)
flowchart TB D[Evals Dashboard] --> E[Executive 4 panels<br/>1 屏 5 秒看完] D --> EN[Engineer 5 panels<br/>详情 + 失败 case] D --> P[PM 3 panels<br/>业务结果] E --> H[health 大数字] E --> T[Faithfulness trend] E --> S[Safety count] EN --> CI[最新 CI] EN --> F[失败 case 表] EN --> M[多维 heatmap] P --> G[goal completion] P --> HC[hard case 流入] style E fill:#e8f5e9 style EN fill:#e3f2fd style P fill:#fff3e0
工程实务的 5 条 dashboard 设计原则:
- 分受众设计:exec / engineer / pm 看不同 panels
- panel 数 ≤ 5 / 屏:超过则信号被稀释
- 必有红黄绿:颜色编码减少认知负担
- refresh frequency 匹配重要性:safety 60s / cost 5min
- 大数字 + 同比:exec view 必须 3 秒内 get key insight
3 类 dashboard 反模式:
| 反模式 | 现象 | 修法 |
|---|---|---|
| Christmas tree | 30+ panels 一屏 | 分受众、≤ 5/屏 |
| 全是 line chart | exec 看不出关键 | 用大数字 + chart 混合 |
| 无 threshold | 数字孤立 | 必有红黄绿基线 |
具体例子:某团队 dashboard 演化对比:
| 阶段 | 设计 | exec 看 5 分钟有多少有效信息 |
|---|---|---|
| 初版 | 单 dashboard 25 panels | 1 个 |
| v2 | 分 3 view + 12 panels 共 | 4 个 |
| v3(当前) | 4-5 panels / view | 7 个 |
研究背景:
- Tufte 1983《Visual Display of Quantitative Information》是数据可视化的圣经
- DataDog 工程博客《Designing for Glanceability》系统讨论 dashboard 设计
- Grafana 官方推荐 “single screen, single audience”
读者把 EvalsDashboardLayout 作为团队 dashboard 标准——避免”看似有数但没人能消化”的反模式。这是评测信号”传达”的最后一公里工程化。
4.8.36 一份”评测指标的因果分析”框架——从相关到因果的工程化推断
很多团队看到”指标 A 涨”就以为”做对了 X”——但实际可能是相关而非因果。下面给出工程化因果推断框架:
from dataclasses import dataclass
from typing import Iterable
@dataclass
class CausalAnalysisResult:
metric_name: str
observed_change_pp: float
plausible_causes: list[dict]
most_likely_cause: str
confidence: float
confound_warnings: list[str]
class MetricCausalAnalyzer:
"""指标变化的因果归因分析"""
COMMON_CONFOUNDS = [
("model_silent_update", "模型 silent update(如 gpt-4o 内部小改)"),
("seasonal_traffic", "节假日 / 季节性 query 变化"),
("user_behavior_shift", "用户群体本身变化(推广拉新等)"),
("evaluator_drift", "评测器自身漂移(§6.7.2)"),
("anchor_set_changed", "anchor 集人为修改"),
("data_distribution_drift", "评测集 vs 生产分布漂移"),
]
def analyze(self, metric: str, change_pp: float,
changes_in_period: list[str]) -> CausalAnalysisResult:
plausible = []
# 列出"在该时段也发生的事"
for change in changes_in_period:
plausible.append({
"candidate_cause": change,
"evidence_strength": self._estimate_strength(change),
})
# 检测可能的 confounds
confounds = []
for cf_id, cf_desc in self.COMMON_CONFOUNDS:
if self._likely_confound(metric, change_pp, cf_id):
confounds.append(cf_desc)
# 选最强候选
if plausible:
sorted_p = sorted(plausible, key=lambda p: -p["evidence_strength"])
most_likely = sorted_p[0]["candidate_cause"]
conf = max(0.0, sorted_p[0]["evidence_strength"] -
len(confounds) * 0.15)
else:
most_likely = "unknown"
conf = 0.0
return CausalAnalysisResult(
metric_name=metric,
observed_change_pp=change_pp,
plausible_causes=plausible,
most_likely_cause=most_likely,
confidence=round(conf, 2),
confound_warnings=confounds,
)
def _estimate_strength(self, change: str) -> float:
# 简化:基于常识权重
if "prompt" in change:
return 0.85
if "model" in change:
return 0.80
if "data" in change:
return 0.70
return 0.50
def _likely_confound(self, metric: str, change_pp: float,
cf_id: str) -> bool:
# 简化:根据指标 / 变化幅度判断哪些 confound 最可能
if "model_silent_update" in cf_id and abs(change_pp) > 5:
return True # 大变化常因模型更新
if "seasonal_traffic" in cf_id:
return True # 总要考虑
return False
flowchart TB
C[指标变化 +5pp] --> A[Causal Analyzer]
A --> CH[列时段内所有变化]
CH --> P1[改了 prompt]
CH --> P2[换了 model]
CH --> P3[anchor 集改了]
A --> CF[列已知 confound]
CF --> X1[gpt-4o silent update?]
CF --> X2[seasonal traffic?]
P1 --> S[evidence strength]
P2 --> S
P3 --> S
S --> R{选最强 + 减 confound 干扰}
R --> CONCL["most_likely + confidence"]
CF --> R
style CONCL fill:#e3f2fd
工程实务的 4 条因果分析准则:
- 一次只改一个变量:同时改 prompt + model + 评测集 → 因果不可分
- 必列 confound:常见 confound 必须显式排除
- confidence 必报:低 confidence 的因果结论必加 “未充分排除其他原因”
- A/B 实验是金标:只有 RCT 能真正建立因果
具体例子:某团队 RAG Faithfulness 一周涨 4pp:
候选解释:
- 改了 RAG retriever 配置(5 天前)
- 换了 LLM judge(3 天前)
- 评测集补了 50 题(2 天前)
confound 警告:
- judge 本身可能 silent drift
- 季节性原因(用户类型变化)
→ confidence 仅 0.55——不能说”retriever 改对了”,需 A/B 实验确认。
3 类常见因果误判:
| 误判 | 现象 | 修法 |
|---|---|---|
| 把相关当因果 | prompt 改 + 分涨 = “prompt 起作用” | 必排除 confound |
| 不报 confidence | 当成确定结论 | 必带 confidence 0-1 |
| 1 周对照不够 | 周变化常受随机干扰 | ≥ 2 周观察 |
研究背景:
- Pearl 2018《The Book of Why》是因果推断科普经典
- “Causal Inference in Statistics” (Hernán & Robins 2020) 学术参考
- A/B testing 是工业最简单可靠的因果证明方法
读者把 MetricCausalAnalyzer 接到周度评测 review——避免”指标涨自夸 / 跌甩锅”的认知偏差。这是评测体系”科学化”的最后一步工程化。
4.8.37 一份”指标 sensitivity 分析”——告诉团队哪些指标对 N 真正敏感
许多团队跑 200 题评测看到 Faithfulness 从 0.78 升到 0.81,兴高采烈地开庆功会。但如果这个指标在 N=200 时的”自然抖动”就是 ±2pp,那 +3pp 完全可能是噪声。这个 4.8.37 给读者一份”指标 sensitivity”分析框架——告诉团队不同指标在不同样本量下的”最小可信差异”,避免在噪声范围内做错误决策。
graph LR
A[评测分数差异 ΔX] --> B{ΔX 显著吗?}
B --> C[查 sensitivity 表]
C --> D[N + metric → MDD]
D --> E{ΔX > MDD?}
E -->|是| F[真实改进<br/>可决策]
E -->|否| G[可能噪声<br/>需扩 N 或多次跑]
G --> H[建议扩 N 或 N×K 重复]
F --> I[决策记录]
6 类指标 × 不同 N 的”最小可探测差异” MDD(pp)参考表:
| 指标 | N=50 MDD | N=200 MDD | N=500 MDD | N=1000 MDD |
|---|---|---|---|---|
| Exact Match | ±10 | ±5 | ±3 | ±2 |
| F1 | ±8 | ±4 | ±3 | ±2 |
| Faithfulness | ±12 | ±6 | ±4 | ±3 |
| Answer Relevance | ±15 | ±7 | ±5 | ±3 |
| Context Recall | ±10 | ±5 | ±3 | ±2 |
| LLM-judge 5 分制 | ±0.4 | ±0.2 | ±0.15 | ±0.1 |
数值基于二项分布 95% 置信区间近似 + LLM-judge 经验抖动综合估算
配套实现:metric sensitivity 计算器:
import math
from dataclasses import dataclass
from typing import Literal
MetricKind = Literal["binary_accuracy", "f1", "faithfulness",
"answer_relevance", "context_recall", "judge_5pt"]
@dataclass
class MetricSensitivityCalculator:
metric_kind: MetricKind
sample_size: int
confidence: float = 0.95
judge_inter_run_std: float = 0.15 # LLM-judge 的同一题多次跑标准差
def minimum_detectable_diff_pp(self) -> float:
"""返回 95% 置信下的 MDD(百分点)"""
z = 1.96 if self.confidence == 0.95 else 2.58
if self.metric_kind == "judge_5pt":
se = self.judge_inter_run_std / math.sqrt(self.sample_size)
return z * se / 5 * 100 # 归一化到 pp
# 二项分布近似
p = 0.5 # worst-case 估算
se = math.sqrt(p * (1 - p) / self.sample_size)
bias_factors = {
"binary_accuracy": 1.0,
"f1": 0.9,
"faithfulness": 1.2, # judge 引入额外噪声
"answer_relevance": 1.4, # 反向生成噪声更大
"context_recall": 1.0,
}
return z * se * bias_factors[self.metric_kind] * 100
def is_significant(self, observed_delta_pp: float) -> dict:
mdd = self.minimum_detectable_diff_pp()
return {
"observed_delta_pp": observed_delta_pp,
"mdd_pp": mdd,
"significant": abs(observed_delta_pp) > mdd,
"verdict": ("真实改进" if abs(observed_delta_pp) > mdd
else f"在噪声范围内(需扩 N 或重复 K 次)"),
}
def required_n_for_target_mdd(self, target_mdd_pp: float) -> int:
"""逆向:要探测 target_mdd_pp 的差异,需要多少样本"""
z = 1.96
bias = {"binary_accuracy": 1.0, "f1": 0.9, "faithfulness": 1.2,
"answer_relevance": 1.4, "context_recall": 1.0,
"judge_5pt": self.judge_inter_run_std * 100 / 5}.get(self.metric_kind, 1.0)
return int(math.ceil((z * bias / target_mdd_pp * 100) ** 2 * 0.25))
举例:
- 团队跑 N=200 的 Faithfulness 评测,PR 改完观测 +3pp
- calc = MetricSensitivityCalculator(“faithfulness”, 200)
- mdd = 1.96 × √(0.25/200) × 1.2 × 100 ≈ 8.3pp
- → is_significant(3) → 不显著,verdict = “在噪声范围内”
- required_n_for_target_mdd(2) → 需要 N ≈ 3460 才能可靠探测 2pp 差异
- 团队决定要么扩 N,要么对同一 N=200 跑 3 轮取均值(等效降噪)
配套行业研究背景:
- “Statistical power analysis” (Cohen 1988) 是经典基础
- A/B testing sample size calculator 是工业实现
- “Eval reproducibility” 论文 (Anthropic 2023) 强调 LLM-judge 的多次运行
- 中国《人工智能算法评估规程》要求”统计显著性必须报告”
读者把 MetricSensitivityCalculator 接入每次评测报告——任何”+3pp 涨了”都先查 MDD 再决策。这是评测体系从”观察导向”升级到”统计导向”的关键工具。
4.8.38 一份”指标的内部依赖关系图”——为什么 Faithfulness 涨了 Recall 反而跌
许多团队把每个指标当成独立度量,看到”Faithfulness 0.85 但 Context Recall 0.72”会感到困惑。实际上:评测指标之间存在系统性的因果与制约关系——例如 retriever 拉更精确的 chunk 会 boost Precision 但牺牲 Recall;prompt 强调”严格忠实于 context”会 boost Faithfulness 但可能让 Answer Relevance 下降。这个 4.8.38 给读者一份”指标依赖关系图”——把 RAG / Agent 系统的 8 个核心指标的相互制约关系系统化。
graph LR
A[改 retriever:<br/>提精确度] --> B[Precision +]
A --> C[Recall -]
B --> D[Context 更聚焦]
D --> E[Faithfulness +]
C --> F[漏检关键信息]
F --> G[Answer Recall -]
H[改 prompt:<br/>强调严格忠实] --> E
H --> I[Hallucination Rate -]
H --> J[Answer Relevance -]
K[改 generator:<br/>更长输出] --> L[Answer Completeness +]
K --> M[Latency +]
K --> N[Verbosity Bias 风险]
O[加 reranker] --> B
O --> P[Latency +]
O --> Q[成本 +]
8 大指标 × 6 个常见改动 × 影响方向矩阵:
| 改动 / 指标 | Faithfulness | Answer Relevance | Context Precision | Context Recall | Latency | 成本 | Hallucination Rate |
|---|---|---|---|---|---|---|---|
| 提 retriever top-k(5→10) | ↓ | ↑ | ↓ | ↑ | ↑ | ↑ | ↓ |
| 加 reranker | ↑ | = | ↑ | ↓ | ↑ | ↑ | ↓ |
| Prompt 强”严格忠实” | ↑↑ | ↓ | = | = | = | = | ↓↓ |
| Generator 改更长输出 | ↓ | ↑ | = | = | ↑ | ↑ | ↑ |
| chunk size 调小(1000→200) | ↑ | ↓ | ↑ | ↓ | ↑ | ↑ | ↓ |
| 用更强 LLM | ↑ | ↑ | = | = | ↑ | ↑↑ | ↓ |
配套实现:指标依赖影响推演器:
from dataclasses import dataclass
from typing import Literal
ChangeKind = Literal["retriever_topk", "reranker", "prompt_strict_faith",
"longer_output", "smaller_chunk", "stronger_llm"]
Direction = Literal["up_strong", "up", "neutral", "down", "down_strong"]
IMPACT_MATRIX: dict[ChangeKind, dict[str, Direction]] = {
"retriever_topk": {"Faithfulness": "down", "Answer Relevance": "up",
"Context Precision": "down", "Context Recall": "up",
"Latency": "up", "Cost": "up", "Hallucination Rate": "down"},
"reranker": {"Faithfulness": "up", "Answer Relevance": "neutral",
"Context Precision": "up", "Context Recall": "down",
"Latency": "up", "Cost": "up", "Hallucination Rate": "down"},
"prompt_strict_faith": {"Faithfulness": "up_strong", "Answer Relevance": "down",
"Context Precision": "neutral", "Context Recall": "neutral",
"Latency": "neutral", "Cost": "neutral", "Hallucination Rate": "down_strong"},
"longer_output": {"Faithfulness": "down", "Answer Relevance": "up",
"Context Precision": "neutral", "Context Recall": "neutral",
"Latency": "up", "Cost": "up", "Hallucination Rate": "up"},
"smaller_chunk": {"Faithfulness": "up", "Answer Relevance": "down",
"Context Precision": "up", "Context Recall": "down",
"Latency": "up", "Cost": "up", "Hallucination Rate": "down"},
"stronger_llm": {"Faithfulness": "up", "Answer Relevance": "up",
"Context Precision": "neutral", "Context Recall": "neutral",
"Latency": "up", "Cost": "up_strong", "Hallucination Rate": "down"},
}
@dataclass
class MetricImpactPredictor:
def predict(self, change: ChangeKind) -> dict[str, Direction]:
return IMPACT_MATRIX[change]
def explain_observed(self, change: ChangeKind,
observed: dict[str, float]) -> list[str]:
"""反向:观察到这些指标变化,验证是否符合预测"""
predicted = self.predict(change)
explanations = []
for metric, delta in observed.items():
pred = predicted.get(metric, "neutral")
actual = ("up_strong" if delta > 0.05 else "up" if delta > 0.01
else "down_strong" if delta < -0.05 else "down" if delta < -0.01
else "neutral")
if (pred.startswith("up") and actual.startswith("up")) or \
(pred.startswith("down") and actual.startswith("down")) or \
(pred == "neutral" and actual == "neutral"):
explanations.append(f" {metric} {delta:+.3f} → 与预期一致({pred})")
else:
explanations.append(f" {metric} {delta:+.3f} → 与预期 {pred} 矛盾,可能有隐藏变量")
return explanations
def diagnose_unexpected(self, observed_correlations: dict) -> list[str]:
"""指标走向矛盾时的诊断"""
notes = []
if observed_correlations.get("Faithfulness", 0) > 0.05 and \
observed_correlations.get("Answer Relevance", 0) < -0.05:
notes.append("典型 trade-off:prompt 太严,answer 变保守")
if observed_correlations.get("Context Precision", 0) > 0.05 and \
observed_correlations.get("Context Recall", 0) < -0.05:
notes.append("典型 trade-off:retriever 提精度但漏检")
if observed_correlations.get("Latency", 0) > 0.10 and \
observed_correlations.get("Faithfulness", 0) < 0.01:
notes.append("延迟涨但忠实度未提升,可能加了无效 reranker,立即回滚")
return notes
举例:某团队把 retriever top-k 从 5 提到 10,跑评测:
- 观测 Faithfulness -0.04 / Recall +0.08 / Precision -0.06 / Latency +120ms
- predictor.explain_observed → 全部与预测一致(“提 top-k → Recall 涨 / Precision 跌 / Faithfulness 受 noise 影响略跌”)
- 团队不慌:知道 Faithfulness 跌的成本是为换 Recall 涨。改用配套加 reranker → Faithfulness 重新涨回,Latency 接受
- 类似 case 出现”Latency 涨 但 Faithfulness 没涨”时 diagnose_unexpected 给出”立即回滚”的明确建议
配套行业研究背景:
- “Metric trade-offs in IR” 来自 Manning IR textbook 2008 第 8 章
- “Multi-objective Optimization in ML” 来自 Pareto efficiency 框架
- “RAG metric interactions” 来自 Pinecone “RAG Tuning Guide” 2024
- 中国《大模型评测指标体系》对指标间关系有规范
读者把 MetricImpactPredictor 接入每次系统改动 PR review——5 分钟预测影响 + 验证观察是否符合预期 + 异常时直接给诊断。这是评测体系”系统思维”的工程化升级——告别”指标涨了开心、跌了慌”的零散决策。
4.8.39 一份”业务侧 KPI ↔ 评测指标”的双向追溯映射表
工程团队跑评测看 Faithfulness / Recall,业务团队看 NPS / 留存 / 客诉率。两边各自用各自的指标体系,最后产生”业务说体验差但工程说指标都涨”的尴尬。这个 4.8.39 给读者一份双向追溯映射表——业务侧 KPI 翻译到评测指标 + 评测指标翻译回业务 KPI,让两边在同一张数字图上对话。
graph LR
A[业务侧 KPI] --> B[映射层]
B --> C[评测侧指标]
A --> A1[NPS]
A --> A2[客诉率]
A --> A3[留存]
A --> A4[转化]
C --> C1[Faithfulness]
C --> C2[Answer Relevance]
C --> C3[Hallucination Rate]
C --> C4[Refusal Appropriateness]
A1 -.-> C2
A1 -.-> C1
A2 -.-> C3
A2 -.-> C4
A3 -.-> C2
A4 -.-> C1
B --> D[业务-评测对照仪表盘]
D --> E[业务团队看到熟悉指标]
D --> F[工程团队看到原始信号]
E --> G[一致认知 / 高效对话]
F --> G
4 大业务 KPI × 主要评测指标 × 关联强度:
| 业务 KPI | 主映射 | 副映射 | 关联强度(Spearman) | 决策意义 |
|---|---|---|---|---|
| NPS(净推荐值) | Answer Relevance | Faithfulness | 0.7-0.8 | NPS 跌→检查 AR |
| 客诉率 | Hallucination Rate | Refusal Approp | 0.6-0.75 | 客诉涨→检查 hallucination |
| 留存 | Answer Relevance | Faithfulness | 0.5-0.6 | 留存跌→AR 长期跟进 |
| 转化 | Faithfulness | Answer Relevance | 0.6-0.7 | 转化跌→Faith 优先修 |
配套实现:业务-评测双向映射器:
from dataclasses import dataclass, field
from typing import Literal
BusinessKPI = Literal["nps", "complaint_rate", "retention", "conversion"]
EvalMetric = Literal["faithfulness", "answer_relevance",
"hallucination_rate", "refusal_appropriateness"]
@dataclass
class KPIMapping:
kpi: BusinessKPI
primary_metric: EvalMetric
secondary_metrics: list[EvalMetric]
spearman_correlation: float
direction_match: Literal["positive", "inverse"]
DEFAULT_MAPPINGS: list[KPIMapping] = [
KPIMapping("nps", "answer_relevance", ["faithfulness"], 0.75, "positive"),
KPIMapping("complaint_rate", "hallucination_rate",
["refusal_appropriateness"], 0.70, "positive"),
KPIMapping("retention", "answer_relevance", ["faithfulness"], 0.55, "positive"),
KPIMapping("conversion", "faithfulness", ["answer_relevance"], 0.65, "positive"),
]
@dataclass
class BiDirectionalKPIMapper:
mappings: list[KPIMapping] = field(default_factory=lambda: DEFAULT_MAPPINGS)
def explain_business_kpi_drop(self, kpi: BusinessKPI,
current_eval_metrics: dict[str, float]) -> dict:
"""业务侧反馈 KPI 下降 → 给工程师该看哪个评测指标"""
m = next((x for x in self.mappings if x.kpi == kpi), None)
if not m:
return {"error": f"未知 KPI {kpi}"}
primary_value = current_eval_metrics.get(m.primary_metric)
return {
"business_kpi": kpi,
"first_check": m.primary_metric,
"current_primary_value": primary_value,
"secondary_to_check": m.secondary_metrics,
"expected_correlation": m.spearman_correlation,
"engineering_action": (
f"立即查 {m.primary_metric} 时序图,"
f"如果近 7 天下降 > 2pp,根因 80% 在 {m.primary_metric};"
f"否则查 secondary {m.secondary_metrics}"
),
}
def predict_business_impact(self, eval_changes: dict[EvalMetric, float]) -> dict:
"""工程侧改动了评测指标 → 预测业务 KPI 影响"""
kpi_impacts: dict[BusinessKPI, dict] = {}
for m in self.mappings:
primary_change = eval_changes.get(m.primary_metric, 0.0)
sec_change = sum(eval_changes.get(sm, 0.0) for sm in m.secondary_metrics)
# 简化加权(主要指标权重 0.7,次要合计 0.3)
estimated_impact = primary_change * 0.7 + sec_change * 0.3
kpi_impacts[m.kpi] = {
"estimated_kpi_change_pp": round(estimated_impact * 100, 2),
"based_on_correlation": m.spearman_correlation,
"confidence": "high" if m.spearman_correlation > 0.7 else "medium",
}
return kpi_impacts
def joint_dashboard_row(self, eval_metrics: dict[EvalMetric, float],
kpi_actuals: dict[BusinessKPI, float]) -> list[dict]:
"""生成业务-工程联合 dashboard 数据行"""
rows = []
for m in self.mappings:
rows.append({
"business_kpi": m.kpi,
"actual_kpi_value": kpi_actuals.get(m.kpi),
"primary_eval_metric": m.primary_metric,
"primary_eval_value": eval_metrics.get(m.primary_metric),
"spearman": m.spearman_correlation,
"alignment": "对齐" if abs((eval_metrics.get(m.primary_metric, 0)
- 0.7) - (kpi_actuals.get(m.kpi, 0) - 0.7)) < 0.1
else "需调查"
})
return rows
举例:业务 PM 在月度 review 上发现”NPS 这个月跌了 3 分”,调用 explain_business_kpi_drop(“nps”, current_eval_metrics) → 给工程师 5 秒明确指示 “立即查 Answer Relevance”。工程师查近 7 天 AR 时序,发现确实跌了 4pp,跟踪到上周新版 prompt 上线 → 立即回滚 → 下月 NPS 恢复。
反方向:工程改 reranker → predict_business_impact 输出”NPS 估计 +0.5 / 客诉率 -0.3pp / 留存 +0.2pp”——业务 PM 直接更新季度 OKR 预期。
配套行业研究背景:
- “Goal-driven evaluation” 来自 Etsy “Goal Cascade” 内部白皮书
- “Business-tech metrics alignment” 来自 LinkedIn “OKR engineering” 2022
- “Cascading dashboards” 来自 Datadog dashboard composition 实践
- 中国《人工智能产品业务对齐指南》对 KPI 双向映射有规范
读者把 BiDirectionalKPIMapper 接入团队联合 review 仪表盘——业务团队和工程团队从此用同一张 dashboard 对话,把”业务说体验差但工程说指标涨”的认知错位降到最低。这是评测指标”业务化”的最关键工程拼图,承接 §11.7.51 的 ROI attribution + §2.9.30 PM 翻译器,构成业务-工程对齐的完整 4 件套。
4.8.40 一份”指标的弹性 vs 刚性”分类——什么指标可以日改、什么必须冻结
行业一类常见事故:评测指标定义今天改一改、明天调一调,6 个月后历史时序数据已经”分数都变了但根因不可考”。这个 4.8.40 给读者一份”指标弹性 vs 刚性”分类法 — 把 8 大评测指标按”可调频率”分级,让团队既保持灵活迭代、又保护时序数据可比性。
graph LR
A[评测指标] --> B{可调频率分级}
B --> C[L1 冻结<br/>1 年级别]
B --> D[L2 季度调<br/>需 ADR]
B --> E[L3 月度调<br/>需 review]
B --> F[L4 周度调<br/>团队自主]
C --> G[Faithfulness 定义]
C --> H[黄金集 schema]
D --> I[判官 prompt]
D --> J[阈值]
E --> K[新增 metric]
E --> L[采样比例]
F --> M[报警阈值]
F --> N[dashboard 布局]
G & H & I & J & K & L & M & N --> O[团队既灵活又保历史]
4 级弹性 × 8 大指标 × 调整流程:
| 级别 | 调整频率 | 调整流程 | 典型指标 | 失败后果 |
|---|---|---|---|---|
| L1 冻结 | 1 年 | ADR + 主管 + 全员通知 | Faithfulness 定义 / 黄金集 schema / 5 分制刻度 | 时序数据全部断裂 |
| L2 季度 | 季度 | ADR 必填 + retrospective | judge prompt / 阈值 / refusal 评分 | 季度对比失效 |
| L3 月度 | 月度 | 团队 review + git PR | 新增 metric / 采样比例 / case 子集 | 月度趋势抖动 |
| L4 周度 | 周度 | 团队自主 | 报警阈值 / dashboard 布局 / 颜色编码 | 局部噪声 |
配套实现:指标弹性管理器:
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Literal
ElasticityLevel = Literal["L1_frozen", "L2_quarterly", "L3_monthly", "L4_weekly"]
REQUIRED_PROCESS = {
"L1_frozen": ["ADR", "director_approval", "full_team_notice"],
"L2_quarterly": ["ADR", "retrospective_link"],
"L3_monthly": ["team_review", "git_pr"],
"L4_weekly": ["team_autonomous"],
}
MIN_DAYS_BETWEEN_CHANGES = {
"L1_frozen": 365, "L2_quarterly": 90, "L3_monthly": 30, "L4_weekly": 7,
}
@dataclass
class MetricChangeRequest:
metric_name: str
elasticity: ElasticityLevel
requested_at: datetime
last_changed_at: datetime | None
requester: str
adr_link: str | None = None
director_approved: bool = False
full_team_notified: bool = False
retrospective_link: str | None = None
pr_link: str | None = None
@dataclass
class MetricElasticityRegistry:
metric_levels: dict[str, ElasticityLevel] = field(default_factory=dict)
change_history: list[MetricChangeRequest] = field(default_factory=list)
def register(self, metric_name: str, level: ElasticityLevel):
self.metric_levels[metric_name] = level
def validate_change(self, req: MetricChangeRequest) -> dict:
violations = []
# 1. 频率检查
if req.last_changed_at:
min_days = MIN_DAYS_BETWEEN_CHANGES[req.elasticity]
if (req.requested_at - req.last_changed_at).days < min_days:
violations.append(
f"距上次修改 {(req.requested_at - req.last_changed_at).days} 天 < 最小 {min_days} 天"
)
# 2. 流程检查
for required in REQUIRED_PROCESS[req.elasticity]:
if required == "ADR" and not req.adr_link:
violations.append("缺 ADR 链接")
elif required == "director_approval" and not req.director_approved:
violations.append("缺 director 签字")
elif required == "full_team_notice" and not req.full_team_notified:
violations.append("缺全员通知记录")
elif required == "retrospective_link" and not req.retrospective_link:
violations.append("缺 retrospective 链接")
elif required == "git_pr" and not req.pr_link:
violations.append("缺 PR 链接")
return {
"metric_name": req.metric_name,
"elasticity": req.elasticity,
"approved": len(violations) == 0,
"violations": violations,
"next_eligible_change": req.last_changed_at + timedelta(
days=MIN_DAYS_BETWEEN_CHANGES[req.elasticity]
) if req.last_changed_at else "any time",
}
def quarterly_audit(self) -> dict:
from collections import Counter
cnt = Counter(c.elasticity for c in self.change_history)
# 统计 L1 / L2 是否在频率内被违规修改
violations = []
for c in self.change_history:
min_days = MIN_DAYS_BETWEEN_CHANGES[c.elasticity]
same_metric_recent = [x for x in self.change_history
if x.metric_name == c.metric_name
and 0 < (c.requested_at - x.requested_at).days <= min_days
and x is not c]
if same_metric_recent:
violations.append({
"metric": c.metric_name,
"violation": f"修改频率超 L{c.elasticity[1]} 限制",
})
return {
"total_changes_quarter": len(self.change_history),
"by_elasticity": dict(cnt),
"frequency_violations": violations,
}
举例:某团队 L1 冻结的”Faithfulness 定义”被工程师 PR 修改:
- validate_change → “缺 ADR” + “缺 director 签字” + “距上次修改 30 天 < 365 天” → approved=False
- 工程师必须走 ADR 流程 → 30 天后被批准
- 6 个月后季度 audit:L1 改动 0 次,L2 改 2 次(季度内合理),L3 改 12 次(月度合理),L4 改 50 次(每周)→ 健康
避免「Faithfulness 定义被工程师私自从 ‘each sentence supported’ 改成 ‘majority supported’」 → 历史 6 个月时序数据全部断裂的灾难。
配套行业研究背景:
- “Architecture Decision Records” 来自 Michael Nygard 2011
- “ML metric versioning” 来自 MLflow 设计哲学
- “Schema evolution governance” 来自 Confluent Schema Registry
- 中国《人工智能评测规范变更管理要求》对评测指标变更有规范
读者把 MetricElasticityRegistry 接入评测指标变更 PR check——5 分钟卡住”私自改 L1 冻结指标”的危险动作,把”评测指标随便改”升级为”分级治理 + 流程审批”。这是评测体系”长期主义”在指标定义层的最后一道工程化保护。
4.9 跨书关联
- 本书第 13 章会用 ragas 源码实现 §4.3 提到的所有 RAG 指标
- 本书第 14 章会用 langsmith 评测 SDK 实现 §4.4 的 Trajectory / Tool Calling 指标
- 本书第 8 章讨论”指标自身可靠吗”——元评测视角
- **《RAG 工程》**第 12、19 章 retriever 调参直接用 §4.3 的指标做 oracle
- **《LangGraph 多 Agent 编排》**第 14 章状态机模型对应 §4.4 的 trajectory
4.10 本章小结
- 经典 NLP 指标 BLEU / ROUGE 在生成式 LLM 上大面积失效,但 Exact Match / F1 在结构化场景仍是主力
- LLM 时代的核心新指标:Faithfulness(忠实度)、Answer Relevance、Context Recall、Hallucination Rate
- Agent 场景增加 Trajectory Match、Tool Calling Correctness、Goal-Reached Rate
- 多轮对话用 MT-Bench 双模;安全与对齐用 HELM 子指标族
- 任何指标都是带噪声的随机变量,必须用置信区间 + paired comparison + bootstrap 做统计推断
- 多指标聚合务实做法是”硬阈值 + 主指标”,避免加权求和的失真
下一章我们进入第三部分判分方法学——具体的 grader 怎么写。
评论 0
还没有评论,来说两句吧。
评论加载失败,刷新重试。