# AI 评测与回归：让每次改动都有证据

## 学习目标与使用边界

**用途**：回答“换模型、改提示词、调整检索之后，到底变好了没有”。当 AI 功能进入多人使用、需要持续迭代或产生业务结果时，就需要评测。学习到 L2 的要求是独立维护一个离线评测集，解释指标变化，并在发布前阻止明确的退化；不要求自己开发通用评测平台。

前置能力是能编写 Node 脚本、读写 JSON、理解异步调用。本章示例使用 Node.js 22 或更高版本，只调用本地函数，无依赖、无密钥、无模型费用。规则函数是教学替身，帮助你先建立评测机制，不能把它的分数当成任何大模型的能力报告。

## 从前端测试走到 AI 评测

组件测试通常有确定答案：输入固定 props，按钮应该出现。生成式应用可能有多种合理表达，因此需要把“看起来不错”拆成业务可以裁决的要求。退款咨询分类可以有唯一标签；答案是否包含政策出处可以程序检查；回答是否完整、是否误导需要明确量表和人工判断。先选择能做确定检查的部分，剩余部分再引入人工或模型裁判。

评测对象也要拆开。模型输出非法 JSON 属于结构失败；检索没找到适用条款属于召回失败；找到条款却答错属于生成失败；工具重复扣费属于系统行为失败。它们可以在同一个用户请求中出现，却不能只压成一个“满意度”分数，否则无法定位改哪一层。

数据集不是随手凑几句提问。每条案例应包含稳定 id、输入、期望、来源类别、难度、政策版本和标注理由。真实请求先脱敏，保留必要语境；合成样本用于补齐边界，不能替代真实分布。按用户、文档或会话分组切分，避免同一对话的改写同时出现在开发集和测试集。时间变化明显的业务可以保留最近一段时间作为测试集。

开发集供你反复改提示词；验证集用于挑选方案和阈值；测试集用于最终报告，尽量不边看答案边调参数。规模小时也应保留这些角色，不必机械追求固定比例。建立一次冻结的版本，例如 refund-v1，同时维护新增难例池；发现严重线上失败时增加回归案例，但不能悄悄改历史集合后宣称旧版进步。

## 指标、基线与人工标注

对二分类，把“退款”设为正类：TP 是该判退款且判对；FP 是普通咨询被误判退款；FN 是退款漏判。Precision 回答“判为退款的有多少可信”，Recall 回答“真实退款找回多少”，F1 是二者的调和平均。准确率可能被多数类掩盖：大量都是普通咨询时，永远输出普通也能很高。多分类还要看每类表现和宏平均，不能只看总数。[指标定义](https://scikit-learn.org/stable/modules/model_evaluation.html)

建立基线可以先用现网版本、简单规则或人工流程。新方案必须对同一组案例运行，记录从错到对、从对到错的 id。只展示总体上涨会遮住高风险退化，例如退款提升但安全拒绝失效。门禁应同时约束核心指标、关键案例、延迟与成本，阈值由业务损失决定。

人工标注先写操作说明：允许哪些标签、何时算信息不足、引用必须支持哪些事实。让两个人独立标一小批，对分歧做裁决后再扩量。不要把无法判定的答案强塞为正确；可以保留“需复核”状态，并单独报告占比。标注人看到模型名称容易形成偏好，比较时尽量打乱顺序、隐去版本。

LLM 裁判适合帮助扩展评价，但会受答案长度、措辞、顺序、同系列模型偏好以及被评文本中的指令影响。应使用明确量表、限制其只读材料，保留理由，并用人工样本校准。A/B 成对比较可交换顺序复测；程序能判断的字段不要交给裁判。裁判不是事实数据库，也不应直接批准高影响操作。[评测实践](https://developers.openai.com/api/docs/guides/evaluation-best-practices)

## 完整示例：离线比较两版分类器

建立目录，保存以下完整文件为 eval.mjs。运行环境只需 Node；安装 Node 后用 node --version 确认版本，不需要 npm install。

```javascript
// eval.mjs：固定数据、逐条比较、输出可读的回归证据
const cases = [
  { id: "r1", text: "我要退款", expected: "refund" },
  { id: "r2", text: "钱能退回吗", expected: "refund" },
  { id: "r3", text: "退货退款怎么办", expected: "refund" },
  { id: "r4", text: "申请退回付款", expected: "refund" },
  { id: "n1", text: "退款政策在哪看", expected: "other" },
  { id: "n2", text: "物流到哪里了", expected: "other" },
  { id: "n3", text: "修改收货地址", expected: "other" },
  { id: "n4", text: "今天有活动吗", expected: "other" },
];

function baseline(text) {
  return text.includes("退款") ? "refund" : "other";
}
function candidate(text) {
  // 先识别本例约定的政策咨询，再扩充退款表达
  if (text.includes("政策")) return "other";
  return /退款|钱能退回|退回付款/.test(text) ? "refund" : "other";
}
function evaluate(predict) {
  let tp = 0, fp = 0, fn = 0, correct = 0;
  const results = cases.map(item => {
    const actual = predict(item.text);
    if (!["refund", "other"].includes(actual)) {
      throw new Error("非法分类结果：" + item.id);
    }
    const ok = actual === item.expected;
    correct += Number(ok);
    tp += Number(actual === "refund" && item.expected === "refund");
    fp += Number(actual === "refund" && item.expected !== "refund");
    fn += Number(actual !== "refund" && item.expected === "refund");
    return { id: item.id, ok };
  });
  // 分母为零的约定必须在报告中固定，不能每版换一种算法
  const precision = tp + fp ? tp / (tp + fp) : 0;
  const recall = tp + fn ? tp / (tp + fn) : 0;
  const f1 = precision + recall
    ? 2 * precision * recall / (precision + recall) : 0;
  return { accuracy: correct / cases.length, precision, recall, f1, results };
}
const oldRun = evaluate(baseline);
const newRun = evaluate(candidate);
const regressions = newRun.results.filter((r, i) => oldRun.results[i].ok && !r.ok);
const fixed = newRun.results.filter((r, i) => !oldRun.results[i].ok && r.ok);
console.log(JSON.stringify({
  dataset: "refund-v1",
  baseline: { accuracy: oldRun.accuracy, f1: oldRun.f1 },
  candidate: { accuracy: newRun.accuracy, f1: newRun.f1 },
  fixed: fixed.map(r => r.id),
  regressions: regressions.map(r => r.id)
}, null, 2));
// 退出码是 CI 可以读取的接口，不仅仅是终端文字
process.exitCode = regressions.length || newRun.f1 < oldRun.f1 ? 1 : 0;
```

```bash
node eval.mjs
```

预期基线 accuracy 为 0.625、F1 约 0.5714；候选版两者均为 1，fixed 为 r2、r4、n1，regressions 为空，退出码为 0。这只是八条教学数据上的结果，不能外推真实业务。

逐段看：cases 把输入和标签固定下来；两个函数只有实现不同，评价口径相同；evaluate 先验证输出集合，再累计混淆矩阵；最后用同一索引比较，因为本例两次严格共用相同数组。真实批处理可能乱序，必须按 id 关联，而不是照搬索引比较。

替换为 API 时，predict 的契约应变成异步输入文本、返回已校验标签。把超时、限流、格式错误记录为独立状态，明确它们是否计入总失败率；禁止为了分数好看而删除失败请求。固定模型标识、提示词哈希、检索索引版本、采样配置与运行时间，存原始输出时执行脱敏和访问控制。非确定模型需要重复运行并报告波动范围，单次多对一道题可能只是随机差异。

## 常见错误与排障

分数突升先检查数据泄漏、测试集是否变小、失败案例是否被过滤，以及标注标准是否偷偷改变。总体分数不变却收到投诉，按业务类别、语言、输入长度与高风险操作切片，查看从对到错的案例。程序检查通过但答案胡编，要增加事实与证据一致性评价；JSON 合法只证明结构，不能证明内容。

不要用生产全量用户数据直接调用外部裁判。先确定许可和脱敏策略，把标注材料缩到完成任务所需的最小范围。也不要每次提交都跑昂贵全量评测：提交时运行固定冒烟集，候选发布跑完整集，线上持续抽样，这三者各有职责。

## 从业务目标设计样本，而不是从模型答案反推标准

前面的分类器只有一个输出标签，适合看清计数方式。现在把目标改成“客服助手根据当前退款制度给出建议，必要时转人工”。此时一次成功至少包含三件事：识别用户真正想解决的问题，使用适用的政策事实，给出当前用户可以采取的动作。若只检查回答是否出现“退款”两个字，模型复制关键词也会得高分，但没有帮助用户。

开始收集数据前，先写一份判分合同。合同应说明输入由哪些字段组成、模型能看到哪些信息、输出允许有哪些类型、哪些错误必须阻止发布。比如订单状态缺失时应询问或转人工，而不是猜“已发货”；找不到有效政策时允许明确不知道，而不是强迫每条都有答案。这样的可接受拒答会降低机械意义上的“回答率”，却提高真正的业务可信度。

样本单位必须与任务一致。多轮任务的一条样本通常是完整会话及其状态快照，不是其中一句问话。最后一句“那就退吧”离开前文无法判断指向哪笔订单、金额多少、是否已经确认。把它当独立输入，会把上下文缺失误记为模型能力不足。反过来，若线上只向模型提供最后三轮，离线却把完整历史提供给它，评测就是在测一个更容易的系统。

采样可以从业务流量中按任务类型分层：普通咨询、信息不足、明确退款、政策冲突、越权要求各占一定份额。你可以故意提高严重但稀少问题的比例，让发布检查更敏感，但必须注明这是风险回归集。不能把这种重新配比的集合直接用来估计真实线上整体错误率；后者需要按真实分布抽样或使用恰当权重。

每个案例保存来源时间、政策版本、预期证据、标签和纳入原因。纳入原因不是装饰：它让后来维护者知道为什么存在一个奇怪的边界输入。若政策由七天改成十五天，相关案例需要版本化更新，而不是偷偷改标签。新旧政策分别适用哪些订单，是业务事实；评测作者不能只为了让当前答案通过而选择某一版。

真实数据与合成数据可以配合。真实数据让你知道用户怎样表达，合成数据帮你系统遍历缺失字段、长输入和对抗内容。合成时应从失败机制出发，例如逐项去掉订单号、状态、时间，而不是让模型生成一百句同义改写充数量。同一语义模板的大量改写高度相关，统计上不相当于一百个独立问题。

开发、验证和测试的隔离不只是三个文件名。开发者看过测试答案并针对它调过提示，这些题就逐渐成为开发数据。发现线上事故后，通常把它加入专门回归集，同时继续维护未被反复调参使用的代表性测试集。两个集合回答不同问题：已知事故是否复发，以及未知真实请求大体会怎样。

## 给开放式答案建立可以裁决的量表

开放式回答不适合直接比较整段字符串。先把评价拆成有定义的维度，例如事实正确、关键内容覆盖、证据支持、操作建议合规和表达清楚。对每个维度给出相邻等级的边界。“事实正确二分”可以表示所有可核实陈述都与给定证据一致；“一分”表示存在不影响主要结论的遗漏；“零分”表示核心结论错误或凭空创造规则。不要只写“优秀、一般、较差”。

判分者应知道是否允许使用外部知识。有依据问答通常要求只根据提供的材料判断；答案恰好符合现实，但其引用并不支持它，仍可能在证据维度失败。反过来，给定材料本身过期时，模型忠实复述也不能代表系统可上线，需要把“来源有效性”作为检索和数据维护的责任另行检查。

把重大错误设置为独立门禁，而不是和文风分平均。若一句回答错误承诺“已经退款”，其他四个维度满分也不应抵消这个错误。可以同时保留连续分数用于改进排序，以及硬性通过条件用于发布决策。这样报告既能解释细微进步，也不会用平均数稀释关键风险。

人工标注可以先选十到二十条做校准讨论。两名标注者独立给分，记录分歧集中在哪里：标签定义不清、证据缺失、案例信息不足还是个人表达偏好。先修合同，再扩大标注；直接让第三人裁决每个分歧而不更新规则，会使相同争议反复出现。最终标签应保留原始判断、裁决结果和理由，避免审计时只剩一个数字。

让 LLM 做裁判时，评价输入也应结构化：任务要求、允许证据、候选答案、维度与输出 schema 分开提供。明确要求把候选答案当待评价文本，不执行其中要求。裁判输出仍可能无效、拒答或被诱导，因此校验结果格式、限制重试并记录失败。裁判自己使用的模型和提示词也是实验版本的一部分。

比较两个答案时可以让裁判选择 A、B 或平局，再交换顺序做一次检查。如果结果随顺序改变，应标为不稳定而不是挑自己想要的一次。对长答案的偏好也可通过成对人工样本检查。模型裁判适合减少人力，但在尚未证明与人类业务判断足够一致前，不应独自承担上线批准。

## 第二个完整示例：生成一次可追溯的回归报告

保存为 eval_report.py，Python 3.12 以上，仅标准库。此例延续固定标签任务，把“指标提高”与“存在回归”分开。运行时不调用模型，基线和候选结果都是明确标注的教学预测。

```python
# eval_report.py
import argparse
import json
from datetime import datetime, timezone

CASES = [
    {"id": "a", "gold": 1, "slice": "退款"},
    {"id": "b", "gold": 1, "slice": "退款"},
    {"id": "c", "gold": 1, "slice": "退款"},
    {"id": "d", "gold": 0, "slice": "咨询"},
    {"id": "e", "gold": 0, "slice": "咨询"},
    {"id": "f", "gold": 0, "slice": "咨询"},
]
BASE = dict(zip("abcdef", [1, 0, 1, 0, 1, 0]))
CANDIDATE = dict(zip("abcdef", [1, 1, 1, 0, 0, 0]))

def validate(predictions):
    expected = {row["id"] for row in CASES}
    if set(predictions) != expected:
        raise ValueError("预测 ID 与数据集不一致")
    if any(type(value) is not int or value not in (0, 1)
           for value in predictions.values()):
        raise ValueError("预测必须是整数标签 0 或 1")

def report(candidate):
    validate(BASE)
    validate(candidate)
    fixes, regressions = [], []
    slices = {}
    for row in CASES:
        key, gold = row["id"], row["gold"]
        before, after = BASE[key] == gold, candidate[key] == gold
        if after and not before:
            fixes.append(key)
        if before and not after:
            regressions.append(key)
        item = slices.setdefault(row["slice"], {"total": 0, "before": 0, "after": 0})
        item["total"] += 1
        item["before"] += int(before)
        item["after"] += int(after)
    return {
        "created_at": datetime.now(timezone.utc).isoformat(),
        "dataset_version": "refund-report-v1",
        "baseline_version": "rule-v1",
        "candidate_version": "rule-v2",
        "fixes": fixes,
        "regressions": regressions,
        "slices": slices,
        # 这是教学门禁：任何既有正确案例退化都不通过
        "release_allowed": not regressions,
    }

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--regress", action="store_true")
    args = parser.parse_args()
    candidate = CANDIDATE.copy()
    if args.regress:
        candidate["f"] = 1  # 故意引入一条咨询误判
    result = report(candidate)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    return 0 if result["release_allowed"] else 1

if __name__ == "__main__":
    raise SystemExit(main())
```

执行 python eval_report.py，预期 fixes 为 b、e，regressions 为空，允许发布。执行 python eval_report.py --regress，预期修复仍有两条，但退化包含 f，退出码为一。候选总正确数仍然高于基线，这正是只看总分容易遗漏的问题。created_at 每次不同，其余实验数据保持固定。

validate 先检查完整性，避免缺失结果使分母缩小；report 按稳定 id 比较，支持预测以任意顺序到达；slices 保存计数而不是只保存百分比，报告读者能知道分数背后有几条题。教学门禁采用零回归，真实业务可以设不同风险规则，但每个例外应有依据，不能为了通过一次发布临时改标准。

第三个实验是失败注入：在 CANDIDATE 中删除一个键，程序应在计算前失败；把某个标签改为字符串 "1"，也应失败。这里不是要让输入格式苛刻，而是证明数据合同发生变化时会被发现。若生产接口允许字符串标签，应在明确的适配层转换，并对转换规则单独验证。

## 误差、波动与变更归因

每次评测都能得到一个确定数字，但这个数字只是当前样本和当前随机运行的观测。样本少时，一道题就可能改变几个百分点；题目来自同一模板时，有效独立信息更少。报告必须同时给出样本量、任务覆盖和重复运行差异，而不是把小数保留很多位营造精确感。

重复运行的目的不是挑最高分，而是看方案稳定性。可以在固定案例上运行多个随机重复，分别统计每条通过率和整体指标分布。若新版平均更好但某个关键案例时好时坏，需要检查生成约束、检索波动或裁判一致性。增加重复次数可以减少对运行随机性的误判，却不能补救没有覆盖真实业务的数据集。

比较两个版本时，使用同一批样本的配对信息通常比只比较两个独立平均数更有价值。记录每条由错到对和由对到错，就能观察改动集中影响哪类问题。想进一步给出置信区间，可以按独立业务组进行重采样，而不是把同一会话的十条片段当成十个独立单位。复杂统计结论需要足够样本和清楚假设，不应把通用百分比阈值当科学定律。

一次只改变一个主要变量更容易解释原因，例如先固定模型改提示，再固定提示换检索器。确实需要一起发布多个变化时，应保留各组合的实验记录，否则发现回归后只能靠猜。模型供应商的同名别名也可能指向变化的实现；能固定版本就固定，不能固定时记录运行日期和可取得的模型标识，并持续观察。

## 综合练习：把报告接到发布决策

任务是基于第二个例子增加重点案例集合，例如 a 和 f；普通案例允许一条回归，重点案例不允许回归。不要修改 gold，也不要把重点案例从总体统计里拿掉。报告需要同时输出总体是否达到门槛、关键案例是否通过和失败原因列表。

<details><summary>参考实现与解释</summary>

在 report 返回前计算 critical = {"a", "f"}，再计算 critical_regressions = sorted(critical.intersection(regressions))。以 reasons = [] 开始，如果 len(regressions) 大于一则加入“普通回归超过门槛”；如果 critical_regressions 非空则加入“重点案例回归”。最终 release_allowed = not reasons，并把两个检查结果及 reasons 加入返回对象。这些规则也可以单独抽成以下完整纯函数，便于脱离模型测试。

```python
def gate(regressions, critical, maximum_regressions=1):
    if maximum_regressions < 0:
        raise ValueError("允许回归数不能为负")
    critical_failures = sorted(set(regressions) & set(critical))
    reasons = []
    if len(regressions) > maximum_regressions:
        reasons.append("回归总数超过门槛")
    if critical_failures:
        reasons.append("重点案例回归")
    return {
        "allowed": not reasons,
        "critical_failures": critical_failures,
        "reasons": reasons,
    }

assert gate(["b"], {"a", "f"})["allowed"]
assert not gate(["f"], {"a", "f"})["allowed"]
assert not gate(["b", "e"], {"a", "f"})["allowed"]
```

这段是可独立运行的门禁函数与断言，不包含模型调用。保存为 gate_exercise.py，执行 python gate_exercise.py，成功时没有输出，退出码为零。再把重点案例 f 改成普通案例，观察门禁为何变化，并说明这是否符合实际风险政策。

</details>

最终评测报告应让未参加实验的人也能复核：测的任务是什么、案例如何取得、版本怎样固定、采用什么判分合同、有哪些缺失或异常、指标与切片结果如何、重大回归是什么以及为何接受或拒绝发布。原始结果、人工裁决和报告需要相互关联。离线通过之后，仍要做受控上线与线上监控，因为真实流量、权限状态和外部依赖不会完全被离线夹具覆盖。


## 练习、提示与参考解答

练习：增加“我要了解退货政策”和“收到破损商品，需要退回款项”两条案例，故意使新版本漏判第二条。设计一个门禁，要求没有旧版正确案例退化，并输出每个错误的输入与期望。

提示：先写期望再改规则；新增集合要升级版本；已有基线也要在新集合上重跑，不能把新集合候选分数直接与旧集合历史分数比较。

<details><summary>参考答案</summary>

将两条分别标为 other、refund，增加到共同 cases；把返回结果扩充为 id、text、expected、actual、ok。门禁先检查 regressions.length，再检查重点退款案例是否全部正确。候选规则没有“退回款项”时，会在新增案例失败；不要直接删除难例。把失败原因写入报告，修改识别实现后重跑双方。实际 AI 任务应优先补充多种表达与反例，避免为一个字符串打补丁。

</details>

## 可验证验收

保存数据集版本和运行报告；能复算 TP、FP、FN；能故意引入回归得到非零退出码；能列出修复与退化 id；能说明八条样本的适用范围。再由另一名读者根据标注规则独立标注，记录分歧而不覆盖原判断。

## 自测问答

1. **准确率高为什么仍可能不能上线？** 多数类可以掩盖关键少数类漏判，且指标可能没有覆盖事实、安全和操作结果。
2. **测试集可以拿来反复调提示词吗？** 这样会形成对测试集的适配；应在开发与验证集迭代，保留独立测试评估。
3. **模型裁判说通过就代表正确吗？** 不代表。必须结合确定检查、证据与人工校准，对高影响结果保留独立审核。