LLM 评测体系

第 4 章 指标体系:从 Accuracy 到 Faithfulness 的演化

作者 杨艺韬 · 23,456 字 · 发布于 · 更新于

"Not everything that can be counted counts, and not everything that counts can be counted."

—— William Bruce Cameron,《Informal Sociology: A Casual Introduction to Sociological Thinking》(Random House,1963),p. 13。这句话长期被挂在爱因斯坦名下,实际出自卡梅伦这本社会学小书。

本章要点

  • 经典 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.63
Output B:   "巴黎的埃菲尔铁塔。"                       BLEU = 0
Output C:   "The Eiffel Tower is in London, England." BLEU ≈ 0.49

(上面是按标点切分、4-gram、带平滑的一种算法;换 tokenizer 或换平滑方式,绝对值会变,但三者的排序不会变——这正是问题所在。)

Output C 在事实上完全错误,却因为与 reference 共享大量 n-gram 拿到近 0.5;Output B 语义完全正确,只因换了语种、一个 n-gram 都不重合,直接得 0。这就是 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 章源码剖析):

  1. 拆解陈述(statement extraction):用 LLM 把 answer 拆成原子陈述列表。例如 "Acme Corp 成立于 1995 年, 它现在是世界 500 强" 会被拆成 ["Acme Corp 成立于 1995 年", "Acme Corp 现在是世界 500 强"]
  2. 逐条 NLI 判定(statement verification):对每条陈述,让 LLM 判断"在 context 中能不能找到支持",得到 0/1 标签
  3. 聚合:1 标签数 / 总陈述数

ragas 论文(Es et al. 2023, arXiv:2309.15217)用自建的 WikiEval 数据集做人工对照:让人标出两个候选答案里哪个更 faithful,再看指标的偏好和人是否一致,报告的是成对比较的一致率(accuracy)——Faithfulness 0.95、Answer Relevance 0.78、Context Relevance 0.70,同表两条基线(直接让 ChatGPT 打 0-10 分、直接让 ChatGPT 二选一)分别只有 0.72 / 0.52 / 0.63 与 0.54 / 0.40 / 0.52。注意这是成对偏好的一致率,不是分数相关系数,也只在 WikiEval 这一份英文维基数据上验证过——换到你的业务分布上要重测。

为什么这个指标关键?因为它精确捕捉幻觉——模型回答里只要有一句"context 没说但模型瞎写的",分数就会下降。第 1 章 NYC MyCity 给出的那几条违法建议,都是这个指标要抓的典型样例。

举例:

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 要捕捉的对象。

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 数)

上面的 Context Precision 是便于理解的朴素写法。ragas 0.2.10 的实际实现(src/ragas/metrics/_context_precision.py 的 _calculate_average_precision)是按排名加权的平均精度:对每个被判为相关的位置 k 取 precision@k,再对相关位置求平均——同样是"相关占比",但相关 chunk 排得越靠前分数越高。

第 11 章会用 ragas 源码详细演示这两个指标的实现。

4.3.4 Hallucination Rate

定义:在 N 条样例中,模型生成包含明显事实错误(既不在 context 里、也不符合常识)的比例。

判定方法因场景而异:

  • 有 context 的(RAG):直接用 1 - Faithfulness
  • 无 context 的(开放问答):用强模型作为 judge,对照可信知识源(如 Wikipedia)打分
  • 结构化输出:JSON Schema 校验失败 / 字段值不在允许枚举范围内

值得专门讨论的是 TruthfulQA(Lin et al. 2021, arXiv:2109.07958)——评测幻觉的标杆数据集。它的设计巧妙在专门挑选"人类容易被误导的领域"——常见迷信、阴谋论、医学误解、法律误读——共 817 题。

有公开出处可引的对照只有一组:GPT-4 Technical Report(arXiv:2303.08774)的 Figure 7 报告了 TruthfulQA 多选题的准确率,经过 RLHF 的 GPT-4 约 60%,GPT-3.5 约 47%;论文同时说明未经 RLHF 的 GPT-4 基座模型只比 GPT-3.5 略好——也就是这一项的提升主要来自后训练而不是预训练规模。

其他厂商的模型卡多数并不报告 TruthfulQA(Claude 3.5 Sonnet、Gemini 1.5 Pro 的官方评测表里都没有这一项),所以本专栏不列跨厂商横向对照表——凡是给出这类表格的二手材料,先去原始模型卡里核一遍有没有这个数。

即便是 60% 这个数——把它换算成"约 40% 的题仍然答错"——也说明幻觉是 LLM 当前最难解的问题之一。一个团队用任何一个上述模型做开放问答,无论怎么调 prompt、怎么 RAG,都很难把 Hallucination Rate 压到可以忽略的程度。这条上限决定了你需要在产品设计上加什么样的"逃生通道"——比如对高风险问题强制 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

实操上很少要求完全相等(步骤顺序、工具参数都允许些微差异)。工程上常见的宽松匹配(第 14 章给实现)允许:

  • 步骤的部分顺序对调
  • 工具参数语义等价("上海"和"Shanghai"视作同一个)
  • 多余但无害的步骤(如多搜了一次)

4.4.2 Tool Calling Correctness

定义:模型选择的 tool 是否正确、传入的参数是否符合 schema、参数值是否合理。

Tool Calling Correctness 评测的前提是你定义了清晰的 tool schema(这是 MCP 协议的核心贡献,详见《MCP 协议设计与实现》专栏第 5 章)。注意第 1 章 DPD chatbot 那类事故是越狱话术绕过了人格设定,不是工具调错,要靠第 16 章的安全评测来拦。

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,现独立为 LMArena)整套排行榜都是配对偏好驱动的。

第 15 章会详述 MT-Bench 与 Arena Hard 的方法学差异。

4.6 安全与对齐指标

HELM(Liang et al. 2023, arXiv:2211.09110)把 Toxicity、Bias 等列为与 Accuracy 并列的独立指标(完整的 7 类指标见第 16 章 §16.3);工业上再补上后两项,构成常用的安全子指标:

  • Toxicity:基于 Perspective API 等检测器,量化输出的攻击性 / 仇恨内容(Perspective API 官方宣布服务到 2026-12-31 止,新项目宜选其他检测器,见第 16 章)
  • 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]

具体含义:这个区间的构造方式保证了"按同样方法反复取样,95% 的区间会盖住真实通过率"。工程上够用的读法是:85% 这个点估计的不确定度大约有 ±5pp,如果上次跑出 82%、这次 86%,差距落在这个量级内,就不能下"模型变好了"的结论。

Wald 区间的适用条件必须记住:它是正态近似,只有在 N 足够大、且 p 不贴近 0 或 1 时才准。经验门槛是 N·p ≥ 5 且 N·(1−p) ≥ 5;跑 50 题拿到 98% 这种情形(N·(1−p)=1)它会给出越过 100% 的荒唐上界。这类场景改用 Wilson 区间或 Clopper-Pearson 精确区间(scipy.stats.binomtest(...).proportion_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]
# ↑ 同一道题两版都跑, 差异精确归因于版本

为什么配对更灵敏:独立采样时差值的方差是 σ_A² + σ_B²,配对后变成 σ_A² + σ_B² − 2ρ·σ_A·σ_B,其中 ρ 是两版在同一题上得分的相关系数。评测里同一道题对两个版本的难度通常高度相关,ρ 越高这一项压掉的越多(两版方差相同时,ρ=0.7 即把差值方差压到独立采样的 30%)——题目难度的方差被消掉了,剩下的才是版本差异。省下多少样本量取决于 ρ 有多高,不是一个固定倍数,但方向永远是配对更省。

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 悖论

聚合指标会把类别构成"藏"起来。一个示意例子:

类别 样例数 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%,按业务权重重算后 A 为 72.5%、B 为 75%,Model B 才是该选的。

严格意义上的 Simpson 悖论更极端:每个类别里都是 B 更好,合起来却是 A 更好。它只在两边各类别样本构成不同时出现——比如 A、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.1 具体例子:怎么决定 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.2 综合工程示例:客服 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 章的事故。

表中阈值是示意值,不同业务要按自己的事故容忍度定。但指标矩阵的结构是相通的——任何业务都该列出"质量、安全、效率、用户感知"四类指标。

4.8.3 选主指标的工程艺术:避免"指标通胀"

工业团队报评测时容易陷入"指标通胀"——报 10 多个指标,每个都说"涨了一点点",最后没人知道是不是真的进步。这种通胀本身是评测体系不成熟的标志。

成熟做法是选 1 个主指标(primary metric):

  • 业务相关:与业务核心 KPI(满意度、转化率、退款率)相关性最高
  • 可解释:PM / 老板都能理解什么是"涨了 5pp"
  • 难以 game:不会被"为了刷指标"的局部优化扭曲

举例:

  • RAG 客服 → 主指标 = Faithfulness(关于"是不是在编",业务关键)
  • Agent 工具 → 主指标 = Goal-Reached Rate(任务完成率,最直接)
  • 创意助手 → 主指标 = Pairwise Win Rate(用户偏好,避免绝对分校准难)

主指标之外的其他指标作为"辅助指标"——主指标涨且辅助指标不退化才算真进步。这种"主指标 + 辅助指标"的层级结构来自学术 ML 的标准做法(参考 Goodhart's Law:当度量变成目标,它就不再是好度量)。

工业团队的提醒:每改一次评测策略时,问自己"我能用一句话说清主指标是什么、为什么"。说不清楚就别动——过早定型的指标体系比没有指标更危险。

4.8.4 "复合指标"工程实践——把多维信号聚成一个 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, 1e-9))

        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.4]
  R --> N2[normalize 91.8]
  AR --> N3[normalize 100 封顶]
  L --> N4[normalize 100 封顶]
  CO --> N5[normalize 100 封顶]
  S --> N6[normalize 100 封顶]
  N1 --> GM[加权几何平均]
  N2 --> GM
  N3 --> GM
  N4 --> GM
  N5 --> GM
  N6 --> GM
  GM --> H[Health Score = 96.2]
  H --> G[Grade A]
  H --> B[Bottleneck: Recall]
  style H fill:#e8f5e9
  style B fill:#fff3e0

工程实务的 4 条复合指标设计原则:

  1. 几何平均 > 算术平均:算术平均会让 1 个 100 分掩盖 1 个 30 分;几何平均不会
  2. always 报 bottleneck:health score 必须告诉读者"是哪条拉低",否则信号空洞
  3. weight 总和不必为 1:用相对权重——便于添加 / 删减维度;注意 _normalize 对超额达标的维度封顶到 100,所以"safety 比目标好 5 倍"不会把整体分抬上去,只是不再拖后腿
  4. 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。这是评测体系"管理可消费"的工程化体现。

研究背景:

  • 几何平均"一维塌陷就整体塌陷"的性质,是它比算术平均更适合做健康度的唯一理由——这是数学性质,不需要引任何厂商实现来背书
  • 反过来说,几何平均对"某一维恰好为 0"极度敏感(整体直接归零),所以代码里用 max(normalized, 1) 兜了底;用之前先想清楚这个兜底对你的业务是否可接受

4.8.5 "指标的内部依赖关系图"——为什么 Faithfulness 涨了 Recall 反而跌

许多团队把每个指标当成独立度量,看到"Faithfulness 0.85 但 Context Recall 0.72"会感到困惑。实际上:评测指标之间存在系统性的因果与制约关系——例如 retriever 拉更精确的 chunk 会 boost Precision 但牺牲 Recall;prompt 强调"严格忠实于 context"会 boost Faithfulness 但可能让 Answer Relevance 下降。本节给读者一份"指标依赖关系图"——把 RAG 系统 7 个核心指标的相互制约关系系统化。

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[成本 +]

7 个指标 × 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": "up"},
    "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《Introduction to Information Retrieval》(2008) 第 8 章 Evaluation in information retrieval——precision / recall 此消彼长是那一章的核心结论
  • "Multi-objective Optimization in ML" 来自 Pareto efficiency 框架
  • 上面那张影响矩阵是定性推演不是实测:方向来自各指标定义本身(如"top-k 变大 → 召回的 chunk 更多 → Recall 涨、Precision 跌"),幅度必须用你自己的系统测

4.9 选指标:少而精

4.9.1 指标速查表:怎么用最小投入选指标

如果你要为新项目选指标,下面这份决策树能在 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.9.2 对工业团队的指标决策清单

整合本章方法学,给一份团队选择主指标时的决策清单:

□ 主指标与业务核心 KPI 的相关性已在自家数据上验证(门槛自定,如 ≥ 0.7)
□ 主指标可重复测量(不依赖随机性)
□ 主指标有明确的"达标 / 不达标"阈值
□ 主指标 PM / 老板能听懂含义
□ 主指标改进与业务收益正相关
□ 主指标不会被"为了刷数字"而扭曲业务
□ 主指标在不同 cohort(用户群 / 时段)能切片观察
□ 主指标的失败 case 可被定位到具体样例

8 项检查全过的 metric 才是"工业级主指标"。任一不达标都说明这个 metric 选错或没准备好。

工业实务:每次决定主指标时(项目启动 / 重大改动)走一遍这 8 项检查。30 分钟就能定下主指标,避免后期反复调整的工程时间浪费。

4.9.3 朴素提醒:所有指标都有失效场景

最后给一个朴素但常被忽略的提醒:没有任何指标在所有场景下都有效。

  • Faithfulness 在创意场景下反而损害 Helpfulness(§13.8.2)
  • Tool Call Correctness 在复杂多步任务时不能反映整体成功(§14.5)
  • pairwise win-rate 在三个 candidate 同时对比时统计学不严谨
  • LLM-as-Judge 在某些领域(医学诊断 / 法律判定)系统性偏弱

工程团队的态度:

  • 报告时附指标的适用边界:不只说"Faithfulness=0.92",还说"在客服 RAG 场景下;创意写作场景需要换指标"
  • 长期监控指标失效信号:当指标涨但用户负反馈也涨时,怀疑指标失效
  • 留人工 fallback:高敏感场景留一手人工评测做 sanity check

这种"对自己工具谦卑"的工程心态,比"找到完美指标"更重要。本章方法学覆盖的是主流场景,边角场景需要团队结合业务自行设计。评测体系的最终责任永远在工程师身上,不在工具上。

4.9.4 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.12.2)
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 条防陷阱原则:

  1. 永远看 ≥ 3 个指标的组合——单维度高分必然误导
  2. 关心分布而非均值——p99 / worst-case / 高危 case 比 average 重要
  3. 校准 vs 一致性分开度量——稳定但偏的 grader 比抖动的 grader 更危险
  4. 生产 vs 评测分布对比——任何指标涨但生产体验降 → 立刻 drift detection
  5. 公开 benchmark 仅作参考点——核心是私有评测集 + 私有 hard case

研究背景:

  • 陷阱 1 / 2 对应 ragas 论文(arXiv:2309.15217)把 RAG 质量拆成 Faithfulness / Answer Relevance / Context Relevance 三个彼此不可互相替代的分量这一主张
  • 陷阱 3 是"均值掩盖尾部"的一般统计事实,本章 §4.12.3 的分位数方法就是它的修法
  • 陷阱 6 讲的是样本分布漂移;它的另一面——模型本身随时间漂移——有 Chen et al. 2023 "How is ChatGPT's behavior changing over time?"(arXiv:2307.09009)这一公开证据
  • 陷阱 9 对应 benchmark 污染这一整条线的公开讨论,最系统的一篇是 Sainz et al. "NLP Evaluation in Trouble"(arXiv:2310.18018)

学完这 9 个陷阱后,读者再看任何评测报告会自然问"是不是踩了陷阱 X"——这是从"会算指标"到"懂指标"的关键一跃。

4.9.5 "指标 sensitivity 分析"——告诉团队哪些指标对 N 真正敏感

许多团队跑 200 题评测看到 Faithfulness 从 0.78 升到 0.81,兴高采烈地开庆功会。但如果这个指标在 N=200 时的"自然抖动"就有 ±6.7pp(见下表),那 +3pp 完全可能是噪声。这个 4.9.5 给读者一份"指标 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 ±11.1 ±5.5 ±3.5 ±2.5
F1 ±10.0 ±5.0 ±3.2 ±2.2
Faithfulness ±13.3 ±6.7 ±4.2 ±3.0
Answer Relevance ±15.5 ±7.8 ±4.9 ±3.5
Context Recall ±11.1 ±5.5 ±3.5 ±2.5
LLM-judge 5 分制 ±0.42 ±0.21 ±0.13 ±0.09

这张表的算法必须写清楚,否则没法复核:前五行是二项分布 95% 区间的半宽 1.96·√(p(1−p)/N),取 p ≈ 0.8(多数团队的实际通过率区间),再按各指标额外的 judge 噪声乘一个 0.9-1.4 的示意系数(与下面计算器的 bias_factors 相同;F1 因为是连续值、比 0/1 判定稳,系数取 0.9);最后一行是 5 分制下按"题目间得分标准差 ≈ 1.5 分"算的 1.96·σ/√N,单位是分不是 pp。

下面的计算器取的是更保守的 p = 0.5(二项方差最大值),所以它给出的 MDD 会比这张表大四分之一(√0.25 / √0.16 = 1.25)。两个都对,区别只在"你愿意假设自己的通过率有多接近 0.5"——不确定时用计算器的保守值。

配套实现: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
    # 注意:这里要的是**题目之间**得分的标准差(决定均值的标准误),
    # 不是同一题重复跑的抖动——后者只影响单题测量误差,别混用。
    # 5 分制上题目间标准差经验值在 1.0-1.5 分之间。
    judge_score_std: float = 1.5

    def minimum_detectable_diff_pp(self) -> float:
        """返回 95% 置信下的 MDD(百分点;judge_5pt 归一化到满分 5 分的百分比)"""
        z = 1.96 if self.confidence == 0.95 else 2.58
        if self.metric_kind == "judge_5pt":
            se = self.judge_score_std / math.sqrt(self.sample_size)
            return z * se / 5 * 100  # 归一化到"占满分的百分点"
        # 二项分布近似
        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 "在噪声范围内(需扩 N)"),
        }

    def required_n_for_target_mdd(self, target_mdd_pp: float) -> int:
        """逆向:要探测 target_mdd_pp 的差异,需要多少样本"""
        z = 1.96
        if self.metric_kind == "judge_5pt":
            # 连续分:MDD = z·σ/√N 折成满分百分点,反解 N,不带二项方差 0.25
            return int(math.ceil((z * self.judge_score_std * 100 / 5 / target_mdd_pp) ** 2))
        bias = {"binary_accuracy": 1.0, "f1": 0.9, "faithfulness": 1.2,
                "answer_relevance": 1.4, "context_recall": 1.0}[self.metric_kind]
        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 轮取均值只能压低单题重复跑的抖动,压不低这张表算的"题目抽样"误差——后者只能靠扩 N

配套行业研究背景:

  • Cohen 1988《Statistical Power Analysis for the Behavioral Sciences》是功效分析的经典基础
  • 各家 A/B 实验平台的 sample size calculator 是同一套公式的工业实现
  • 需要提醒的是:上面按"跑 K 轮取均值等效降噪"的做法,只有在同一题多次跑的抖动是独立噪声时才成立;如果 judge 的偏差是系统性的(位置偏差、长度偏差),跑一百轮也消不掉——那要靠第 6 章的 bias 校准

4.9.6 "指标的弹性 vs 刚性"分类——什么指标可以日改、什么必须冻结

行业一类常见事故:评测指标定义今天改一改、明天调一调,6 个月后历史时序数据已经"分数都变了但根因不可考"。这个 4.9.6 给读者一份"指标弹性 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

4.10 命名、单位与报告规范

4.10.1 不显眼但重要的工程话题:metric 的命名规范

工业评测体系中 metric 的命名常被忽视。但不规范的命名会让 dashboard 混乱、跨团队沟通成本上升。

不规范命名的典型问题:

  • score / accuracy / quality 等通用名 → 跨业务无法区分
  • 大小写混用 Faithfulness / faithfulness / FAITHFULNESS
  • 中英文混杂 回答_relevance
  • 同义不同名 recall_at_5 vs top5_recall vs recall@5
  • 嵌套混乱 metric.faithfulness.score vs faithfulness.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.10.2 metric 命名 / 单位 / 报告的标准模板

下面是一份团队可以直接采用的 metric 标准模板:

metric:
  name: cs_faithfulness_mean
  display_name: "客服 Faithfulness 平均分"
  description: |
    回答中可被 context 支持的陈述比例.
    基于 ragas Faithfulness 实现, 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: |
    客服回答事实可信度. 与用户感知错误率的换算要用自家数据标定.
  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.10.3 不常被讨论的问题:metric 的"心理学锚点"

工程团队的 metric 不只是数字——它在团队心理上形成"锚点"。一个被低估的工程现象:团队会下意识围绕主指标优化。

例子:

  • 主指标 = Faithfulness → 团队下意识让回答更"严谨",可能损害 Helpfulness
  • 主指标 = Latency → 团队下意识压缩回答长度,可能损害详尽度
  • 主指标 = Cost → 团队下意识用便宜模型,可能损害质量

这种"指标即激励"的现象有正反两面:

  • 正面:团队精力聚焦、目标清晰、迭代快
  • 反面:单维度优化压力大、容易忽略次要维度

工程修法:

  1. 主指标 + 防退化指标:除主指标外,明确列出"不能退化的"防御指标
  2. 多 owner:不同 owner 关心不同 metric,互相制衡
  3. 季度 review:定期检查"是否被主指标 game",调整 metric 体系

理解 metric 的心理学影响,让团队的 metric 选择不只是"统计学问题",而是"组织行为问题"。这种视角是评测体系成熟度的标志。

4.10.4 隐藏话题:metric 的"业务意义解释"

工程团队报告评测分数时,常忽略一件事——告诉读者这个分数的业务意义。

不好的报告:

Faithfulness: 0.87
Recall: 0.91
Latency p95: 2400ms

好的报告:

Faithfulness: 0.87
  → 平均而言, 回答里约 13% 的陈述在 context 中找不到依据
  → 按上季度人工抽检标定, 约对应 X% 用户感知错误率(X 用自家数据填)

Recall: 0.91
  → 平均而言, reference 所需信息里约 9% 没被 retriever 召回
  → 这部分缺口会让 Faithfulness 进一步退化

Latency p95: 2400ms
  → 95% 用户在 2.4s 内得到回答
  → 与自家 3s 的体验门槛相比留有余量

把"工程数字"翻译成"业务影响"的能力,是评测工程师与"只会跑指标的工程师"的差异点。读懂数字不难,把数字翻译给老板 / PM / 客服团队听懂,才是评测真正的价值传递。

工程实务:每次月度评测报告都附"业务意义解释"段落。3-5 行就够,但能让评测从"工程内部话题"变成"全公司讨论"。这种"翻译"能力让评测体系在组织里持续被重视。

4.10.5 "指标可视化设计"指南——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 设计原则:

  1. 分受众设计:exec / engineer / pm 看不同 panels
  2. panel 数 ≤ 5 / 屏:超过则信号被稀释
  3. 必有红黄绿:颜色编码减少认知负担
  4. refresh frequency 匹配重要性:safety 60s / cost 5min
  5. 大数字 + 同比: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《The Visual Display of Quantitative Information》是数据可视化的圣经
  • Grafana 官方 dashboard 最佳实践文档强调 "A dashboard should tell a story or answer a question"

4.10.6 具体的 Grafana dashboard 面板配置

整合本章方法学,给一份"评测指标 dashboard"的 Grafana 面板示例(沿用旧版 graph 面板与面板内 alert 写法,仅示意结构;Grafana 11 起旧版告警已移除,告警要改在 Grafana Alerting 里配置规则):

{
  "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) (increase(eval_failure_count[1d]))"
        }]
      },
      {
        "title": "Latency p50/p95/p99",
        "type": "graph",
        "targets": [
          {"expr": "histogram_quantile(0.5, sum by (le) (rate(eval_latency_bucket[5m])))", "legendFormat": "p50"},
          {"expr": "histogram_quantile(0.95, sum by (le) (rate(eval_latency_bucket[5m])))", "legendFormat": "p95"},
          {"expr": "histogram_quantile(0.99, sum by (le) (rate(eval_latency_bucket[5m])))", "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.11 接进运营:SLO、门禁与对齐

4.11.1 "指标 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.85 ——超过部分是 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 条原则:

  1. 目标必须可达:SLO=99.99% 听起来好但永远耗 budget——选 90-95% 区间
  2. 窗口与业务节奏匹配:safety 用 7d、faithfulness 用 30d(反映月度迭代)
  3. 不同指标不同窗口:不要全部 30d
  4. 每月校准 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 第 3 章 "Embracing Risk"(提出 Error Budget)与第 4 章 "Service Level Objectives" 是这套做法的正典
  • DORA 的四大指标(部署频率 / 变更前置时间 / 变更失败率 / 服务恢复时间)里并没有 SLO,但"变更失败率 + 恢复时间"和这里的错误预算是同一套思路:给失败留一个可量化的配额,用完就改行为

把 MetricSLOTracker 接入团队评测体系——把"评测分数低"这种模糊概念变成"budget 耗尽"这种可执行决策。这是 SRE 文化在 LLM 评测的工程化体现。

4.11.2 "指标 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 条对齐验证经验:

  1. 每季度跑一次 validation:用过去 12 周的 metric × business outcome 对照
  2. load-bearing metrics 应是 P0 dashboard:score ≥ 0.4 的指标主管必看
  3. 弱关联的指标不上 SLO:与业务无关的指标当 SLO 反而误导
  4. 0 相关 metric 应考虑退役:3 季度 validation 都接近 0 → §4.11.4 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 的主体。

但这张表(本身是示例数据)有一个必须讲明的统计前提:12 周只有 12 个数据点,相关系数的不确定度大得惊人——n=12 时算出 r=0.60,它的 95% 置信区间大约是 [0.04, 0.87]。这意味着这类验证只够用来做一件事:筛掉那些相关性接近 0 的指标(如上表的 BLEU 与输出长度)。拿它给 0.52 和 0.60 两个指标排名次是过度解读——要排名次得攒到几十个周期,或者改用样本量更大的单条级别对照(每条 trace 的指标 vs 该条的用户反馈)。

3 类常见对齐问题:

问题 现象 修法
指标涨用户骂 Faithfulness +5pp、NPS -3 指标设计有问题,重做 anchor
指标无关 BLEU 与 NPS 完全独立 退役这种"古董"指标
业务方不关心 NPS 高但 revenue 不动 看 retention,NPS 有时滞后

研究背景:

  • Ribeiro et al. 2016 "Why Should I Trust You? Explaining the Predictions of Any Classifier"(LIME,arXiv:1602.04938)是"模型可解释性"这条线的代表作之一
  • "vanity metric"(虚荣指标)这个说法出自精益创业圈,指涨得好看但不驱动任何决策的指标——评测指标同样适用

4.11.3 "业务侧 KPI ↔ 评测指标"的双向追溯映射表

工程团队跑评测看 Faithfulness / Recall,业务团队看 NPS / 留存 / 客诉率。两边各自用各自的指标体系,最后产生"业务说体验差但工程说指标都涨"的尴尬。这个 4.11.3 给读者一份双向追溯映射表——业务侧 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 × 主要评测指标(下表给的是映射假设,不是实测相关系数——任何声称"Answer Relevance 与 NPS 的 Spearman 是 0.75"的表格都是编的,因为这个系数只能在你自己的业务数据上算出来。这张表的用法是:把它当作待验证的假设清单,用下面的代码在自家数据上把系数量出来,相关不上就换映射):

业务 KPI 主映射(假设) 副映射(假设) 决策意义(若假设成立)
NPS(净推荐值) Answer Relevance Faithfulness NPS 跌→先查 AR
客诉率 Hallucination Rate Refusal Approp 客诉涨→先查 hallucination
留存 Answer Relevance Faithfulness 留存跌→AR 长期跟进
转化 Faithfulness Answer Relevance 转化跌→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"]

# 注意:spearman_correlation 字段的初始值是占位假设,必须用自家历史数据
# (每周的 KPI 序列 vs 每周的评测指标序列)实测替换后,这套映射才有决策意义
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,优先按 {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 预期。

配套行业研究背景:

  • "把工程指标翻译成业务 KPI"没有可引的标准做法——这张映射表的价值在于逼团队把假设写下来再去验证,而不是它本身正确
  • Goodhart's Law:任何一条映射一旦被写进 OKR,它就同时变成了被优化的目标,相关性会随之失真——所以映射表必须定期用新数据重算

4.11.4 "指标生命周期管理"工具——如何 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 操作纪律:

  1. 实验指标必须有"试用期":默认 90 天后必须做 active or retire 决策
  2. deprecated 必须给 successor:不能"光弃用不指明替代"
  3. retire 后保留历史数据但不再展示:让旧报告可追溯
  4. 每季度跑一次 retire_eligible:自动化清理,不要靠人力 review

研究背景:

  • ML 模型有 model card / system card,metric 也该有 metric card——本节生命周期是 metric card 的运营延伸
  • DataHub / Amundsen 等数据目录工具对数据资产的 deprecation 标注是类似思路

把 metric lifecycle 视为评测体系的"垃圾回收机制"——没它的话 dashboard 会像"祖传项目里 5 年没删的 deprecated 函数"一样腐朽。每季度跑一次能确保评测信号简洁聚焦。

4.12 统计与因果:别被数字骗了

4.12.1 "指标计算 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 - 聚合: 这里只算事实 F1 这一项 = 0.67
  (ragas 默认 weights=[0.75, 0.25]:最终分 = 0.75 × F1 + 0.25 × answer 与 reference 的语义相似度)

最终汇总:

指标 分数 含义
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 编造"问题的工程价值。

4.12.2 "指标统计显著性"实操手册

工程团队最常踩的坑之一:用 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 条铁律:

  1. 任何 A vs B 结论必须带 p-value + 95% CI——单独的 delta_pp 没有意义
  2. 样本量公式必跑——把上面的 required_n 跑一遍就知道有多贵:baseline 80%、两组独立、α=0.05 双侧,检 3pp 差距在 power=0.8 时需 n ≈ 2600 题 / 组,power 提到 0.99 要 n ≈ 6200;检 1pp 差距 power=0.8 就要 n ≈ 24600。想用几百题证明 1-3pp 的改进,数学上就不成立。注意 required_n 用的是独立两组的公式,配对设计(同一题集两版都跑)实际需要的样本要少得多,少多少取决于两版得分的相关性 ρ
  3. 配对设计 > 独立两组——同一份题集让 A、B 都跑,用 McNemar 而不是两样本 chi-square:McNemar 只看两版结论不同的那些题(b 和 c),两版都对 / 都错的题不携带任何区分信息,把它们纳入检验只会稀释信号
  4. 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.12.3 "分位数指标"工程模板——为何均值会骗人

LLM 评测最常用的"均值"在生产环境里经常误导——下面给出"分位数指标"工程化方法,捕捉单纯均值看不到的尾部风险。

动手前先分清指标方向,这是本节最容易搞反的一点:

  • 越小越好的指标(latency、cost、输出长度):糟糕的样本落在高分位,要盯 p95 / p99
  • 越大越好的指标(Faithfulness、Answer Relevance、通过率):糟糕的样本落在低分位,要盯 p1 / p5 / p10

对 Faithfulness 这种越大越好的指标去看 p99,看到的是"表现最好的那 1%",恰恰是最没有信息量的一端。分位数天然单调不减(p10 ≤ p50 ≤ p90 ≤ p99),任何一张"p90=0.96、p95=0.93"的表都算错了。

import math
import statistics
from dataclasses import dataclass
from typing import Iterable

@dataclass
class QuantileReport:
    metric: str
    sample_count: int
    higher_is_better: bool
    p1: float
    p5: float
    p10: float
    p50: float        # 中位数
    p90: float
    p95: float
    p99: float
    mean: float
    std: float
    bad_tail_value: float   # 按指标方向取到的"坏尾"分位值
    has_long_tail: bool
    business_warning: str | None

class QuantileMetricsAnalyzer:
    """关注分位数而非均值的工程指标"""

    def analyze(self, metric_name: str, values: list[float],
                 higher_is_better: bool = True) -> QuantileReport:
        if not values:
            raise ValueError("空样本无法算分位数")

        sorted_v = sorted(values)
        n = len(sorted_v)

        def pct(p: float) -> float:
            # 最近秩法:p 分位取排序后第 ceil(p*n) 个(1-indexed)
            idx = max(0, min(math.ceil(p * n) - 1, n - 1))
            return sorted_v[idx]

        mean = sum(values) / n
        std = statistics.stdev(values) if n > 1 else 0.0

        # 坏尾按指标方向取:越大越好看低分位,越小越好看高分位
        bad_tail = pct(0.05) if higher_is_better else pct(0.95)
        # 坏尾离中位数有多远(用中位数而非均值,避免长尾把基准也拖走)
        median = pct(0.50)
        gap = abs(median - bad_tail)
        long_tail = gap > max(abs(median) * 0.2, 1e-9)

        warning = None
        if long_tail:
            side = "最差 5%" if higher_is_better else "最慢 / 最贵 5%"
            warning = (f"长尾警告:{side} 落在 {bad_tail:.3f},"
                       f"中位数 {median:.3f},均值 {mean:.3f}")

        return QuantileReport(
            metric=metric_name,
            sample_count=n,
            higher_is_better=higher_is_better,
            p1=round(pct(0.01), 3),
            p5=round(pct(0.05), 3),
            p10=round(pct(0.10), 3),
            p50=round(median, 3),
            p90=round(pct(0.90), 3),
            p95=round(pct(0.95), 3),
            p99=round(pct(0.99), 3),
            mean=round(mean, 3),
            std=round(std, 3),
            bad_tail_value=round(bad_tail, 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%(={q.bad_tail_value:.2f})远差于中位数 "
                         f"{q.p50:.2f}——对应每 100 次调用里 5 次糟糕体验")
            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]
  S --> BAD["坏尾分位<br/>越大越好取 p5<br/>越小越好取 p95"]
  BAD --> LT{坏尾偏离中位数 > 20%?}
  M --> LT
  LT -->|是| WARN[长尾警告]
  LT -->|否| OK[分布健康]
  WARN --> EXEC["exec 看到的不再是<br/>'平均 0.85 还行'<br/>而是 '最差 5% 只有 0.32,每天 N 次糟糕体验'"]
  style WARN fill:#fff3e0
  style EXEC fill:#ffebee

工程实务的 4 类常见均值误导:

场景 指标方向 均值看起来 坏尾真相
latency 越小越好 平均 1500ms p99 = 8000ms(1% 用户等到超时)
cost / query 越小越好 $0.005 p99 = $0.08(1% 异常昂贵)
answer length 越小越好 200 字 p99 = 3000 字(1% 啰嗦到不能用)
Faithfulness 越大越好 0.87 p5 = 0.32(最差 5% 大段编造)

洞察:均值看起来都 OK,但每类指标的坏尾都藏着严重问题——前三行的坏尾在高分位,最后一行的坏尾在低分位。这是 SLO(§4.11.1)必须按指标方向选分位、而不是一律看 p99 的根本原因。

具体例子(示意数据,需用你自己的数据复算):客服 chatbot 1000 题 Faithfulness 分布(越大越好,坏尾在左):

分位 值
p1 0.10
p5 0.32
p10 0.62
p50 0.91
p90 0.96
p95 0.98
p99 1.00
mean 0.86
std 0.25

诊断:p5 = 0.32 远低于中位数 0.91 → has_long_tail = True → 最差 5% 是大段编造的 case,也是潜在事故源。修法:把落在 p5 以下的样例捞出来入对抗集 + 修 prompt。注意这里 mean(0.86)低于 median(0.91),正是左长尾的典型信号——均值被少数极低分拖下去了。

4 类常见均值滥用:

现象 原因 修法
主管看均值不看分位 dashboard 默认显示 强制并排显示 median + 坏尾分位
SLA 用均值定 历史习惯 改成坏尾分位(延迟 p99 / 质量 p5)
优化目标是均值 报告好看 优化坏尾 = 优化最差体验
质量指标也去看 p99 照抄延迟指标的习惯 按方向选分位——越大越好的指标坏尾在左

研究背景:

  • "The Tail at Scale"(Dean & Barroso, Communications of the ACM, 2013)是"尾部延迟才是用户体验"这条思路的经典
  • Google SRE Book 讲 SLO 的章节反复强调延迟要按分位数定,而不是按均值
  • 需要留意的是:这些经典讨论的都是越小越好的延迟指标,直接把 p99 的习惯搬到质量指标上就会看反方向

dashboard 上应并排显示 mean、median 和按指标方向取的坏尾分位。

4.12.4 "评测指标的因果分析"框架——从相关到因果的工程化推断

很多团队看到"指标 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.8.8)"),
        ("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 条因果分析准则:

  1. 一次只改一个变量:同时改 prompt + model + 评测集 → 因果不可分
  2. 必列 confound:常见 confound 必须显式排除
  3. confidence 必报:低 confidence 的因果结论必加 "未充分排除其他原因"
  4. 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》是因果推断科普经典
  • Hernán & Robins《Causal Inference: What If》(2020) 是学术参考(书名常被记成 "Causal Inference in Statistics",那是 Pearl 等人 2016 年的另一本入门书)
  • A/B testing 是工业最简单可靠的因果证明方法

4.12.5 指标的长期漂移:一个常被忽视的工程现象

最后讨论一个时间维度上的问题——同一组指标在同一份评测集上的取值,会随时间漂移。原因有四:

  1. Judge 模型版本升级:你用 gpt-4o 这类不带日期的别名当 judge,别名被切到新快照,judge prompt 没变但分数变了
  2. Calibration 漂移:你的人工标注随团队成员变化、业务标准变化,导致同样的样例今天的"标准答案"和半年前不同
  3. 业务定义漂移:"Faithful" 的具体含义因业务理解深化而细化(半年前粗放标,现在严格标)
  4. 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.13 长期维护与组织影响

4.13.1 多指标聚合的 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.7 的统计推断方法:

  • 均值 / 中位数 / p95 / std
  • Wald 置信区间(小样本用 t)
  • Bootstrap 置信区间(非参数)
  • 阈值判定
  • Paired comparison(配对比较 + p-value)
  • 多指标聚合报告

工业实务:把这份代码作为团队评测库的一部分。任何评测 metric 都通过 MetricResult 包装——自动获得统计推断 + 阈值判定能力。这种统一抽象让评测代码维护成本大幅降低。

4.13.2 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.13.3 定期审计:让指标体系不随时间膨胀

给一个长期思维——每两年彻底 review 一次 metric 体系。

为什么是两年?

  • 1 年太短:业务变化还不够大
  • 3 年太长:很多 metric 已经过时
  • 2 年正好:够覆盖业务一个完整迭代周期 + 有充足新认知

每两年的 review 应该回答:

  • 哪些 metric 还能反映业务关键问题(保留)
  • 哪些 metric 已经被 game 或失去 discriminative power(淘汰)
  • 哪些新业务场景缺少对应 metric(新增)
  • 哪些 metric 的阈值需要根据成熟度提升(收紧)

这种"长周期 review"是 metric 体系的"清库存"。如果不做,团队会积累几十个 metric 但实际只看 3-5 个核心——dashboard 沦为装饰品。

工业实务:把"metric 两年大 review"列入团队的双年度计划。每次 review 产出"metric 演化白皮书"——记录哪些保留 / 淘汰 / 新增的决策依据。这是评测体系成熟度的高级标志。

读完整章方法学后,给一个长期实务建议——每年做一次 metric 体系的健康检查。

具体内容(30 分钟到 2 小时):

  • 列出当前所有跟踪的 metric
  • 标注每个 metric 的"使用频率"(每天 / 每周 / 偶尔 / 从不)
  • 删除"从不"使用的 metric
  • 评估"每天"用的 metric 是否还反映当前业务关键
  • 是否有"业务关键但没跟踪"的新维度需要加

这种"年度审计"让 metric 体系不会随时间膨胀——保持精简而有力。一个 metric 体系如果 3 年下来没做过 audit,几乎一定膨胀到失焦状态。

4.13.4 metric 设计的"长期视角"案例

最后讨论一个 metric 设计的"长期视角"案例。

考虑一个客服 chatbot 的 metric 演化:

Year 1 (上线): 主指标 = 转人工率
  - 因为公司当时最关心"客服成本"
  - 转人工率从 30% → 15% 是巨大胜利

Year 2 (用户增长): 主指标 = 用户满意度
  - 转人工率太低反而引发"chatbot 不够好用"投诉
  - 关注重心从"省钱"转向"用户体验"

Year 3 (品牌建设): 主指标 = NPS
  - 单条对话满意度高但用户长期不推荐
  - 需要更宏观的指标

Year 4+ (成熟运营): 主指标矩阵
  - 转人工率 / 满意度 / NPS / Faithfulness 多指标平衡
  - 不再有"单一主指标"

这种"主指标随业务阶段演化"的现象在工业团队普遍存在。要点不是"找到完美主指标",而是主指标会演化,要每年 review 是否需要调整。

工程实务:把"主指标 review"作为团队年度仪式。每年 1 次回顾"今年的主指标是否仍然反映业务关键"。这种"长期 metric 治理"是评测体系工程化的最高表现。

4.13.5 指标体系的组织影响:信任与文化

工业团队最容易低估的 metric 议题——指标体系的信任建设。

具体含义:

  • 团队相信 metric 反映真实质量 → 决策快、执行强
  • 团队怀疑 metric → 每次结果都有人质疑、决策慢
  • 团队漠视 metric → metric 沦为摆设

信任建设的关键动作:

  • 透明计算:每个 metric 的计算公式公开
  • 可追溯:任何分数能追溯到具体样例
  • 失败案例可视化:让团队看到"低分时模型实际是怎么答的"
  • 校准报告:定期发布 metric 与人工的一致性
  • 诚实承认局限:明确"哪些不靠谱"

工业团队的实务:搭起 metric 体系后,前 3 个月要花大量时间做"信任建设"——不是改 metric,而是让团队接受 metric。这种"软工作"比技术工作更难,但更重要。

工业团队的 metric 体系不只是评测工具——它在塑造团队的工程文化:

  • 看重 Faithfulness 的团队 → 工程师默认严谨思考"不编造"
  • 看重 Latency 的团队 → 工程师默认关注性能
  • 看重 Cost 的团队 → 工程师默认成本意识
  • 看重 User Satisfaction 的团队 → 工程师默认用户视角

team 关心什么 metric,工程师就会潜意识朝那个方向优化。这种"metric → 文化"的塑造效应是评测体系的"软影响"。

工程负责人的实务:选 metric 不只看技术,看你想塑造什么样的团队文化。一份 metric 列表本质是一份"我们重视什么"的宣言。

4.14 跨专栏关联

  • 本专栏第 11 章剖析 ragas 源码里 §4.3 这些 RAG 指标的实现,第 13 章把它们组装成端到端的 RAG 评测
  • 本专栏第 14 章会用 ragas 的 ToolCallAccuracy / AgentGoalAccuracyWithReference 实现 §4.4 的 Trajectory / Tool Calling 指标(langsmith 评测 SDK 见第 17 章)
  • 本专栏第 8 章讨论"指标自身可靠吗"——元评测视角
  • 《RAG 工程与检索系统设计》专栏第 12、13、14 章 retriever 调参直接用 §4.3 的指标做 oracle
  • 《LangGraph 设计与实现》专栏第 6 章 Pregel 执行引擎的执行模型对应 §4.4 的 trajectory

4.15 本章小结

  • 经典 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 双模;安全与对齐用 Toxicity / Bias / Refusal / Jailbreak 等子指标(前两项源自 HELM)
  • 任何指标都是带噪声的随机变量,必须用置信区间 + paired comparison + bootstrap 做统计推断
  • 多指标聚合务实做法是"硬阈值 + 主指标",避免加权求和的失真

下一章我们进入第三部分判分方法学——具体的 grader 怎么写。