传统测试的断言是二元的:输入确定,输出必须等于某值。AI 应用的输出是一段自然语言,同一个输入两次跑出两个版本,都"说得通"。于是团队很容易滑向两个极端:要么不测,靠人工抽检;要么追求一个绝对分数,最后拿着一堆没人解释的数字做发布决策。
评测集真正的用途只有一个:判断这次改动相比基线是变好还是变差。它是测量工具,不是成绩单。这篇讲怎么建这把尺子,以及尺子怎么接进发布流程。
1. 与普通软件测试的差异
| 维度 | 普通测试 | AI 应用评测 |
|---|---|---|
| 输出确定性 | 确定 | 同输入多次不同输出 |
| 正确性标准 | 唯一答案 | 多维(正确、有据、得体、不越界) |
| 通过判据 | 断言 | 断言 + 参考对比 + 打分 |
| 用例构造 | 等价类、边界值 | 等价类 + 真实分布抽样 |
| 回归含义 | 不破坏既有行为 | 不破坏既有行为,且不劣于基线 |
最关键的一行是最后:AI 应用的"回归"不是"通过率 100%",而是**"关键用例不退化"**。基线分数永远不是满分,所以门禁要卡的是"相对基线的变化",而不是绝对值。
2. 评测集的四个来源
| 来源 | 获取方式 | 覆盖什么 | 注意 |
|---|---|---|---|
| 真实输入抽样 | 线上日志按场景分层抽样 | 真实分布 | 需脱敏,避免只抽到高频简单问 |
| 历史失败回放 | 事故与差评沉淀 | 已知坑 | 只增不删 |
| 构造边界用例 | 空输入、超长、多语言、歧义 | 极端输入 | 少而精,别造"现实中不存在"的输入 |
| 对抗用例 | 越权提问、注入、诱导编造 | 边界与拒答 | 需定期更新,模型迭代会改变反应 |
# 分层抽样:按场景 × 难度 × 语言分层,避免高频场景淹没长尾
def sample(logs, per_stratum=30):
buckets = defaultdict(list)
for r in logs:
buckets[(r.scene, r.difficulty, r.lang)].append(r)
return [r for k, v in buckets.items() for r in random.sample(v, min(per_stratum, len(v)))]
抽样最容易犯的错是只抽"有反馈"的样本。点过赞或踩过的用户只是少数,按反馈抽样会把评测集变成"投诉集",测出来的分数与真实体验无关。
3. 标注口径:先定义"对",再谈分数
| 维度 | 取值 | 判定依据 |
|---|---|---|
| 答案正确性 | 正确 / 部分正确 / 错误 | 与权威资料一致 |
| 引用准确性 | 有据 / 无据 / 错引 | 引用是否真支撑该句 |
| 是否应答 | 应答 / 应拒答却答 / 不该拒却拒 | 依业务边界 |
| 有无有害内容 | 有 / 无 | 依内容规范 |
def cohen_kappa(a, b):
po = sum(x == y for x, y in zip(a, b)) / len(a)
pe = sum((a.count(c) / len(a)) * (b.count(c) / len(b)) for c in set(a))
return (po - pe) / (1 - pe) # 一致性扣除随机巧合
多人标注必须先对齐口径再开标:同一句"部分正确"两个人的理解可能完全不同。做法是先各自标 20 条,算一致性,不一致的逐条讨论定标准,再全量开标。一致性低时不要硬算平均分,先修口径。
标注还要记录判定依据的来源(哪份文档、哪一版),否则半年后没人能解释某条为什么是"错误"。
4. 自动评测的三层实现
| 层次 | 手段 | 覆盖 | 偏差来源 |
|---|---|---|---|
| 规则断言 | 正则、字段校验、必含/必不含 | 格式、硬约束 | 覆盖窄,但确定性高 |
| 参考答案对比 | 关键点命中、语义相似度 | 事实性 | 表述差异导致误判 |
| 模型打分 | LLM-as-judge | 语义质量、语气 | 位置偏差、自评偏好、过宽 |
def evaluate(case, output):
r = {"structure": parses(output, case.format)}
r["keywords"] = all(k in output for k in case.must_contain) \
and not any(k in output for k in case.must_not)
if case.get("rubric"):
r["judge"] = judge(output, case.rubric, scale=1..5) # 需固定 rubric 与温度
return r
模型打分的三个已知偏差要主动对冲:位置偏差(成对比较时偏好先出现的答案)→ 交换顺序各评一次;长度偏好(偏好更长答案)→ rubric 里明确"简洁不加分";自评偏好(用自己的模型评自己)→ 换一个不同家族的模型做评委。
打分只适合做相对比较(A 版本 vs B 版本),不适合当绝对质量分。把 judge 分数当 KPI,很快就会被优化到失真。
5. 线上线下的一致性
| 漂移来源 | 表现 | 缓解 |
|---|---|---|
| 输入分布变化 | 新场景涌入,评测集没覆盖 | 定期从线上重抽样 |
| 知识过期 | 资料更新但评测集仍是旧答案 | 评测集与知识库版本绑定 |
| 用户行为变化 | 提问方式变短、更多口语 | 季度重标一轮 |
| 模型版本升级 | 旧用例的期望答案失效 | 升级时重跑并复核失败项 |
def drift_check(eval_set, online_sample):
# 对比长度、语言、场景占比等粗粒度分布
return {k: wasserstein(eval_set[k], online_sample[k])
for k in ["len_tokens", "lang", "scene"]}
漂移检测不需要复杂:看输入长度分布与场景占比这两个粗指标就能发现大部分问题。评测集分数很高但线上体验差,八成是分布对不上。
6. 灰度:新旧版本并行对比
| 分流方式 | 适用 | 风险 |
|---|---|---|
| 按用户比例 | 大多数场景 | 同一用户需稳定命中同一版本 |
| 按请求类型 | 高风险功能小流量试 | 需明确路由规则 |
| 按租户 | B 端客户可控试点 | 样本量小,统计力弱 |
| 影子流量 | 只对比不返回新结果 | 无用户影响,但成本翻倍 |
// 稳定分流 + 版本指纹进日志,便于事后归因
int bucket = Math.floorMod(Objects.hash(userId), 100);
String ver = bucket < rolloutPct ? "v2" : "v1";
log.info("route", "bucket", bucket, "ver", ver, "reqId", reqId);
灰度必须解决两件事:同一用户不能来回跳版本(否则体验抖动、指标失真),两个版本不能共享缓存(缓存键不带版本时,新版本会读到旧版本的答案)。
上线后的核心指标:拒答率(该答不答)、追问率(答了但用户还得再问)、人工接管率(转人工)、显式反馈(点赞/踩)、以及同一问题的重问率——最后这个指标最能反映"答了等于没答"。
7. 回归门禁
| 门禁项 | 判据 | 不通过的处置 |
|---|---|---|
| 硬约束用例 | 必须全通过 | 阻断发布 |
| 关键用例集 | 不允许任何一条由通过转失败 | 阻断 |
| 总体分数 | 不低于基线,允许小幅波动 | 需人工复核 |
| 拒答边界 | 对抗用例拒答率不降 | 阻断 |
| 成本与延迟 | 不超过设定上限 | 评估后再发 |
gate:
hard_fail: 0 # 硬约束失败数必须为 0
regressions: 0 # 关键用例不允许由通过转失败
score_delta: -0.02 # 相对基线的容忍下限
p95_latency_ms: BASELINE_P95 * 1.2
门禁的价值在于把"要不要发"从讨论变成判定。没有门禁时,发布决策依赖当天谁的嗓门大。
8. 评测集的维护
评测集会腐化。用例写于半年前,期望答案对应的是旧版知识库,标注人也早已离职。腐化的评测集比没有评测集更危险——它给出一个稳定但失真的数字。
| 维护项 | 频率 | 动作 |
|---|---|---|
| 分布重抽样 | 每季度或输入分布变化时 | 用线上日志重建部分用例 |
| 期望值复核 | 知识库更新时 | 逐条确认答案仍成立 |
| 失败项沉淀 | 每次事故后 | 新增用例,附来源与日期 |
| 过期清理 | 半年 | 删除业务已下线的场景 |
| 一致性抽检 | 每次扩标 | 重算 kappa,低于阈值先修口径 |
eval_set:
version: 2026-11-01 # 与知识库版本一并记录
cases: 420
coverage: {scene: 12, adversarial: 38, regression: 96}
kappa: 0.71 # 低于约定阈值时不用于门禁
每条用例带 created_at 与 source(线上抽样 / 事故 / 构造),这样"有多少条来自真实事故"就变成一个可追踪的数。
9. 排错
| 现象 | 成因 | 处理 |
|---|---|---|
| 评测集分数很高但线上差 | 分布漂移 | 从线上重抽样,重建评测集 |
| 只看平均分掩盖严重退化 | 用例不均衡 | 单列关键用例,卡"零回退" |
| 打分模型评自己 | 同一模型做生成与评审 | 换模型家族,或改用规则+人工 |
| 灰度期间结果串味 | 两版本共享缓存 | 缓存键加版本指纹 |
| 同一用户答案时好时坏 | 请求级随机分流 | 改为用户级稳定分流 |
| 旧用例突然大面积失败 | 知识库更新后期望值过期 | 期望答案与知识库版本绑定 |
| 标注争议不断 | 口径未对齐就开标 | 先小样本对齐一致性再全量 |
10. 边界
| 问题 | 评测集能解决吗 | 说明 |
|---|---|---|
| 检索召回不足 | 部分 | 需专门的检索评测(召回、排序) |
| 提示词措辞微调的效果 | 是 | 同集对比即可判断 |
| 用户是否满意 | 否 | 需线上反馈与访谈 |
| 长尾罕见问题 | 部分 | 构造用例可覆盖,但未必真实 |
| 成本与延迟 | 否 | 属于性能与容量问题 |
评测集与检索评测是两把尺子:前者量端到端体验,后者量召回与排序。混在一起看,会把"检索没召回"误判成"模型不会答"。
11. 自测
- AI 应用的"回归"为什么不是通过率 100%,而是关键用例不退化?
- 评测集四个来源里,为什么不能只按"有反馈"的样本抽样?
- 多人标注前为什么要先做小样本一致性对齐?
- 模型打分的三个已知偏差分别是什么,怎么对冲?
- 灰度期间两个版本共享缓存会造成什么后果?
- 回归门禁里,哪类用例应当直接阻断发布?