AI 评测与回归:让每次改动都有证据
建立可复现的数据集、指标、基线与变更比较,区分模型质量、系统正确性和上线门禁。
本页内容
学习目标与使用边界#
用途:回答“换模型、改提示词、调整检索之后,到底变好了没有”。当 AI 功能进入多人使用、需要持续迭代或产生业务结果时,就需要评测。学习到 L2 的要求是独立维护一个离线评测集,解释指标变化,并在发布前阻止明确的退化;不要求自己开发通用评测平台。
前置能力是能编写 Node 脚本、读写 JSON、理解异步调用。本章示例使用 Node.js 22 或更高版本,只调用本地函数,无依赖、无密钥、无模型费用。规则函数是教学替身,帮助你先建立评测机制,不能把它的分数当成任何大模型的能力报告。
从前端测试走到 AI 评测#
组件测试通常有确定答案:输入固定 props,按钮应该出现。生成式应用可能有多种合理表达,因此需要把“看起来不错”拆成业务可以裁决的要求。退款咨询分类可以有唯一标签;答案是否包含政策出处可以程序检查;回答是否完整、是否误导需要明确量表和人工判断。先选择能做确定检查的部分,剩余部分再引入人工或模型裁判。
评测对象也要拆开。模型输出非法 JSON 属于结构失败;检索没找到适用条款属于召回失败;找到条款却答错属于生成失败;工具重复扣费属于系统行为失败。它们可以在同一个用户请求中出现,却不能只压成一个“满意度”分数,否则无法定位改哪一层。
数据集不是随手凑几句提问。每条案例应包含稳定 id、输入、期望、来源类别、难度、政策版本和标注理由。真实请求先脱敏,保留必要语境;合成样本用于补齐边界,不能替代真实分布。按用户、文档或会话分组切分,避免同一对话的改写同时出现在开发集和测试集。时间变化明显的业务可以保留最近一段时间作为测试集。
开发集供你反复改提示词;验证集用于挑选方案和阈值;测试集用于最终报告,尽量不边看答案边调参数。规模小时也应保留这些角色,不必机械追求固定比例。建立一次冻结的版本,例如 refund-v1,同时维护新增难例池;发现严重线上失败时增加回归案例,但不能悄悄改历史集合后宣称旧版进步。
指标、基线与人工标注#
对二分类,把“退款”设为正类:TP 是该判退款且判对;FP 是普通咨询被误判退款;FN 是退款漏判。Precision 回答“判为退款的有多少可信”,Recall 回答“真实退款找回多少”,F1 是二者的调和平均。准确率可能被多数类掩盖:大量都是普通咨询时,永远输出普通也能很高。多分类还要看每类表现和宏平均,不能只看总数。指标定义
建立基线可以先用现网版本、简单规则或人工流程。新方案必须对同一组案例运行,记录从错到对、从对到错的 id。只展示总体上涨会遮住高风险退化,例如退款提升但安全拒绝失效。门禁应同时约束核心指标、关键案例、延迟与成本,阈值由业务损失决定。
人工标注先写操作说明:允许哪些标签、何时算信息不足、引用必须支持哪些事实。让两个人独立标一小批,对分歧做裁决后再扩量。不要把无法判定的答案强塞为正确;可以保留“需复核”状态,并单独报告占比。标注人看到模型名称容易形成偏好,比较时尽量打乱顺序、隐去版本。
LLM 裁判适合帮助扩展评价,但会受答案长度、措辞、顺序、同系列模型偏好以及被评文本中的指令影响。应使用明确量表、限制其只读材料,保留理由,并用人工样本校准。A/B 成对比较可交换顺序复测;程序能判断的字段不要交给裁判。裁判不是事实数据库,也不应直接批准高影响操作。评测实践
完整示例:离线比较两版分类器#
建立目录,保存以下完整文件为 eval.mjs。运行环境只需 Node;安装 Node 后用 node --version 确认版本,不需要 npm install。
// 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;
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 以上,仅标准库。此例延续固定标签任务,把“指标提高”与“存在回归”分开。运行时不调用模型,基线和候选结果都是明确标注的教学预测。
# 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,也不要把重点案例从总体统计里拿掉。报告需要同时输出总体是否达到门槛、关键案例是否通过和失败原因列表。
参考实现与解释
在 report 返回前计算 critical = {"a", "f"},再计算 critical_regressions = sorted(critical.intersection(regressions))。以 reasons = [] 开始,如果 len(regressions) 大于一则加入“普通回归超过门槛”;如果 critical_regressions 非空则加入“重点案例回归”。最终 release_allowed = not reasons,并把两个检查结果及 reasons 加入返回对象。这些规则也可以单独抽成以下完整纯函数,便于脱离模型测试。
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 改成普通案例,观察门禁为何变化,并说明这是否符合实际风险政策。
最终评测报告应让未参加实验的人也能复核:测的任务是什么、案例如何取得、版本怎样固定、采用什么判分合同、有哪些缺失或异常、指标与切片结果如何、重大回归是什么以及为何接受或拒绝发布。原始结果、人工裁决和报告需要相互关联。离线通过之后,仍要做受控上线与线上监控,因为真实流量、权限状态和外部依赖不会完全被离线夹具覆盖。
练习、提示与参考解答#
练习:增加“我要了解退货政策”和“收到破损商品,需要退回款项”两条案例,故意使新版本漏判第二条。设计一个门禁,要求没有旧版正确案例退化,并输出每个错误的输入与期望。
提示:先写期望再改规则;新增集合要升级版本;已有基线也要在新集合上重跑,不能把新集合候选分数直接与旧集合历史分数比较。
参考答案
将两条分别标为 other、refund,增加到共同 cases;把返回结果扩充为 id、text、expected、actual、ok。门禁先检查 regressions.length,再检查重点退款案例是否全部正确。候选规则没有“退回款项”时,会在新增案例失败;不要直接删除难例。把失败原因写入报告,修改识别实现后重跑双方。实际 AI 任务应优先补充多种表达与反例,避免为一个字符串打补丁。
可验证验收#
保存数据集版本和运行报告;能复算 TP、FP、FN;能故意引入回归得到非零退出码;能列出修复与退化 id;能说明八条样本的适用范围。再由另一名读者根据标注规则独立标注,记录分歧而不覆盖原判断。
自测问答#
- 准确率高为什么仍可能不能上线? 多数类可以掩盖关键少数类漏判,且指标可能没有覆盖事实、安全和操作结果。
- 测试集可以拿来反复调提示词吗? 这样会形成对测试集的适配;应在开发与验证集迭代,保留独立测试评估。
- 模型裁判说通过就代表正确吗? 不代表。必须结合确定检查、证据与人工校准,对高影响结果保留独立审核。