第 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 章源码剖析):

  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 论文在 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.547.0%OpenAI evals
GPT-459.0%GPT-4 Technical Report
Claude 3.5 Sonnet59.7%Anthropic Claude 3.5 Model Card
Gemini 1.5 Pro64.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%
FaithfulnessRAG / 有 context 的生成中(LLM-judge)≥ 0.85
Answer Relevance开放生成中(LLM + embedding)≥ 0.80
Context RecallRAG retriever中(LLM-judge)≥ 0.90
Context PrecisionRAG 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 通过率
简单题8095% (76/80)90% (72/80)
难题2050% (10/20)60% (12/20)
整体10086%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 指标的长期漂移:一个常被忽视的工程现象

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

  1. Judge 模型版本升级:你用 GPT-4o 当 judge,OpenAI 静默升级了底层模型,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.8.8 一个综合工程示例:客服 RAG 系统的指标矩阵

把本章所有指标整合到一个真实场景,下面是一份”客服 RAG 系统”的完整指标矩阵——每条指标对应业务问题、判分方法、阈值、报告频率四个维度:

指标业务问题判分阈值频率
Faithfulness回答是否在编?LLM-judge≥ 0.85离线 + 每日回归
Context Recallretriever 漏了关键政策吗?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_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.8.13 一个不常被讨论的问题:metric 的”心理学锚点”

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

例子:

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

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

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

工程修法:

  1. 主指标 + 防退化指标:除主指标外,明确列出”不能退化的”防御指标
  2. 多 owner:不同 owner 关心不同 metric,互相制衡
  3. 季度 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”的最终建议:

  1. 从北极星指标倒推:先想清楚业务最关键的成功是什么,再倒推 metric
  2. 少即是多:5 个核心 metric > 20 个边缘 metric
  3. 统计推断常驻:所有 metric 报告都附 CI / paired comparison
  4. 元评测必跟:每个 metric 自己的可靠性也要监控
  5. 业务意义翻译:永远要把数字翻译成业务影响

这 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

最终汇总

指标分数含义
Faithfulness0.50一半在编
Context Recall1.00retriever 完美
Answer Relevance0.87切题
Answer Correctness0.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 条铁律:

  1. 任何 A vs B 结论必须带 p-value + 95% CI——单独的 delta_pp 没有意义
  2. 样本量公式必跑——baseline 80%、想检 3pp 差距、power 99%,n ≥ 750 题;想检 1pp 差距 n ≥ 6700 题
  3. 配对设计 > 独立两组——同一份题集让 A、B 都跑(McNemar 检验比 chi-square 灵敏 2-3 倍)
  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.8.29 LLM 评测特有的 9 类指标”反直觉陷阱”

很多团队在指标的细节上栽过跟头。下面是 LLM 评测特有的 9 类反直觉现象——这些不是”基础统计学问题”,而是 LLM 这个特殊评分对象专属的工程陷阱。

#现象反直觉的”看似”实际情况修法
1Faithfulness 100% ≠ Answer 对完全 grounded 应该等于答对答案 grounded 但 retrieval 没拿到正确文档 → 答案完全 grounded 在错文档上必须配 Context Recall ≥ 0.8 才有意义
2Correctness 高 + Recall 高 + Precision 低precision 低应该影响 correctness检索了一堆 irrelevant context,但 LLM 自己识别出有用的precision 低 → 长上下文成本飙升,monitor cost 而非 correctness
3Average score 高但失败 case 致命平均分高就稳80% 题 0.95 + 20% 题 0.0(答错某些 case 是越界)→ 平均 0.76 看似好必须看 worst-case score / 高危 case pass rate
4Judge 给 5 分但人工给 3 分LLM-judge 标准应与人对齐judge 看不出某些专业错误(医疗 / 法律)元评测用 κ 量化对齐度
5每次跑分数稳定但与人工差距大稳定 = 可靠self-consistency 高但 calibration 低 → 稳定地错区分稳定性与准确性
63 月分数涨 + 用户投诉涨评测进步用户应该满意评测覆盖的样本与用户实际遇到的样本分布不同drift detection + hard case mining
7Toxicity 0% 但拒答率 30%安全做得好模型把”该答的”也拒了refusal appropriateness 必须独立度量
8Tool call 准确率 95% 但任务完成率 60%tool 都对应该任务也对工具调对了但顺序错 / 信息整合错trajectory 评测 + goal_reached 双指标
9benchmark 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 条防陷阱原则:

  1. 永远看 ≥ 3 个指标的组合——单维度高分必然误导
  2. 关心分布而非均值——p99 / worst-case / 高危 case 比 average 重要
  3. 校准 vs 一致性分开度量——稳定但偏的 grader 比抖动的 grader 更危险
  4. 生产 vs 评测分布对比——任何指标涨但生产体验降 → 立刻 drift detection
  5. 公开 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 操作纪律:

  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 / 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. 几何平均 > 算术平均:算术平均会让 1 个 100 分掩盖 1 个 30 分;几何平均不会
  2. always 报 bottleneck:health score 必须告诉读者”是哪条拉低”,否则信号空洞
  3. weight 总和不必为 1:用相对权重——便于添加 / 删减维度
  4. target 用业务定义而非历史:target 是”应该达到”——用历史均值是反模式

3 类常见错误:

错误表现修正
直接 average 0-1 scorelatency 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.8530d15%
Safety violation≤ 0.5%7d0.5%
p95 latency≤ 2000ms7d5%
User thumbs_down≤ 3%7d3%

SLO 设计 4 条原则

  1. 目标必须可达:SLO=99.99% 听起来好但永远耗 budget——选 90-95% 区间
  2. 窗口与业务节奏匹配:safety 用 7d、faithfulness 用 30d(反映季度迭代)
  3. 不同指标不同窗口:不要全部 30d
  4. 每月校准 SLO:业务变化 → SLO 也变(如 latency 阈值随用户期待降低)

具体例子:某团队的 4 SLO 一周报告:

MetriccurrentSLObudget 剩health决策
Faithfulness0.860.8578%healthy正常 release
Safety violation0.7%0.5%12%exhausted冻结
p95 latency1850ms2000ms65%healthyOK
Thumbs down2.4%3%53%healthyOK

行动: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 条对齐验证经验:

  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.8.30 deprecate

具体例子:某客服 chatbot 12 周 validation:

metriccorr vs NPScorr vs retention综合load-bearing
Faithfulness0.620.580.60✅ 核心
Answer Relevance0.550.480.52✅ 核心
latency_p95-0.42-0.350.39⚠️ 边界
safety_violation-0.71-0.450.58✅ 核心
BLEU score0.080.050.07❌ 退役
输出长度0.12-0.030.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平均 1500msp99=8000ms(5% 用户超时)
Faithfulness0.87p99 = 0.32(5% 完全编造)
answer length200 字p99 = 3000 字(5% 啰嗦到不能用)
cost / query$0.005p99 = $0.08(5% 异常昂贵)

洞察:4 类指标”均值看起来 OK”——但 p99 都揭示存在严重长尾。这是 SLO(§4.8.32)必看 p95/p99 而非均值的根本原因。

具体例子:客服 chatbot 1000 题 Faithfulness 分布:

分位
p100.62
p500.91
p900.96
p950.93
p990.32
mean0.86
std0.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 设计原则:

  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 tree30+ panels 一屏分受众、≤ 5/屏
全是 line chartexec 看不出关键用大数字 + chart 混合
无 threshold数字孤立必有红黄绿基线

具体例子:某团队 dashboard 演化对比:

阶段设计exec 看 5 分钟有多少有效信息
初版单 dashboard 25 panels1 个
v2分 3 view + 12 panels 共4 个
v3(当前)4-5 panels / view7 个

研究背景:

  • 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 条因果分析准则:

  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》是因果推断科普经典
  • “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 MDDN=200 MDDN=500 MDDN=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 个常见改动 × 影响方向矩阵

改动 / 指标FaithfulnessAnswer RelevanceContext PrecisionContext RecallLatency成本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 RelevanceFaithfulness0.7-0.8NPS 跌→检查 AR
客诉率Hallucination RateRefusal Approp0.6-0.75客诉涨→检查 hallucination
留存Answer RelevanceFaithfulness0.5-0.6留存跌→AR 长期跟进
转化FaithfulnessAnswer Relevance0.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 必填 + retrospectivejudge 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