第 19 章 评测

前面十八章一直在说“这样更可靠”“那样更稳”。

这一章要回答那个一直欠着的问题:凭什么?换了一版之后,你怎么知道它真的比上一版好,而不只是在你随手试的那几条上看着更顺眼?

图 19-1 标出第五篇的位置:评测、可观测、迁移、版本治理与安全审查共同回答“怎样证明它稳定”。

全书六篇路线,第五篇被突出显示

图 19-1 全书六篇路线:第五篇


一、回归表建晚了,就买不到“提升了多少”

回到第 1 章那次重构。

那张五十三条的回归表,是在重构前夕才系统整理出来的。新版跑出来通过四十八条——但旧版从来没有在同一口径下跑过完整基线,所以这个数字只能说明新版当时的绝对表现,说不出“比旧版好了多少”。

这件事本身就是这一章的第一个教训:

回归表建得越晚,你能从它那里买到的结论就越少。 等到要证明一次重构有没有价值的时候才开始整理用例,那个最想要的对比数字已经永远拿不到了。

但即使建晚了,它仍然改变了一件根本性的事情——从那以后,每一次改动第一次变得可以被证伪。

第 1 章那份提示词能长到两千两百行,根因就在这里。当时改完只能靠“感觉好像好了”,没有任何机制能证明某一条规则其实没用、或者删掉它不会出事。于是理性的选择就是不删——因为新增的风险是模糊的,删除的风险是具体的:万一出事,就是你删的那条。

一句话概括这一章存在的理由:

没有评测,删除永远比新增危险;有了评测,删除才重新成为一个可以做的动作。

第 16 章那次减法之所以敢做,前提就是这张表。

不过还要说准一层:当时缺的并不是回归集——回归集有了;缺的是一份两个版本都跑过、并且独立于修改过程的评测集。这两样东西回答的不是同一个问题,下一节先把“感觉不错”排除掉,第三节再来分清它们。

二、“感觉不错”为什么不算证据

改完提示词随手试几条,这个动作的信息量比想象中低得多。三个原因:

第一,样本偏差。 你会去试的用例,是你想得到的用例。而系统经常翻车在你没想到的那些地方——已经想到的,通常在写的时候就处理了。

第二,方差。 第 1 章那次观察里有一条:同一输入多次运行,结果并不总是一样,回归通过率会在版本之间上下摆动。这意味着“试了一次通过”这件事,本身就不构成证据。

第三,确认偏误。 刚改完的人更倾向于看到它变好。这不是态度问题,是人之常情——所以判定不该依赖改动者当场的主观感受。

三条加起来的结论是:随手试几条既不覆盖旧问题,也不重复足够次数,它给不出任何可以拿来做决定的信息。

三、三份表,回答三个不同的问题

回归集能回答的问题,比很多人以为的窄。

如果一份回归集主要由历史失败长出来,而团队又围绕它反复改系统,那么它最擅长证明的是已知的错误有没有回来。它不保证整体能力提升——系统完全可能把五十三个旧坑全补平,却在新的真实分布上变差。

所以要分三层,各自回答不同的问题:

表 19-1 回归集、冻结集与线上评测的分工

回答什么 用在哪 能不能改
回归集 已知问题有没有回来 发布门禁 随故障、需求与边界演进,变更留痕
冻结评测集 新旧版本整体能力谁更好 版本比较 版本周期内冻结,要改就发新版本
线上评测 真实分布里究竟怎么样 影子运行、灰度、真实指标 反映现实,不由你决定

中间这层最容易被跳过,而它的关键就在“冻结”两个字:一旦你为了让某一版好看而调整它,它就失去了跨版本比较的资格。

但冻结不等于永远不更新。业务范围会变、用户的说法会变、旧任务会消失——一份长期不更新的评测集自己也会失真。准确的做法是:在一个评测版本周期内保持不变,不随被测系统的调参而调整;当业务分布发生实质变化时,发布一个新的评测集版本,保留旧版本的结果用于历史比较,而不是悄悄改动原有用例。

还有一个更隐蔽的失效方式:文件一个字没改,但研发每天盯着它调参——第三十七条不过就专门去修第三十七条。这样它虽然名义上冻结着,实际上已经参与了开发过程。条件允许时,留一部分开发阶段看不到的样本,或者按周期以版本化方式更新评测集。这和第 4 章那条留出用例的道理是同一个。

回归、冻结评测和线上数据回答的是三个不同问题,不要拿其中一把尺子代替另外两把。

这三层和下一节那四个测试级别是两根不同的轴:级别说的是“在多细的粒度上测”,这三层说的是“这次测试想回答什么”。 同一条会话级用例,既可能属于回归集,也可能属于冻结评测集。

四、四级测试:规则往低层测,链路在高层验

评测不是一张表,是四层。

表 19-2 四级测试体系

级别 测什么 判分方式 出错时能定位到
组件级 归并器、闸门、验证器、状态机 完全确定性 具体函数与分支
单次调用级 一次抽取或一次渲染是否符合契约 结构断言为主 哪份契约被违反
会话级 一整段脚本化对话的最终状态与动作序列 状态断言 + 文本约束 这段对话不对
线上级 真实流量的抽样、影子运行 抽样人工 + 指标 分布层面的问题

组件级是拆分带来的最大一笔红利。 归并器是纯函数、闸门是确定性判定、验证器是一道开关——它们都可以像普通单元测试一样测:给一组事件,断言折叠出来的候选状态;给一个状态,断言闸门的判定和 blockers。这一级不需要调用模型,跑得飞快,而且失败时直接指向某一个分支。第 16、17 章做的那些分工和拆分,收益有相当一部分在这里兑现。

反过来说,如果一个系统只有会话级用例,那么每一条失败都只能告诉你“这段对话不对”,说不出错在抽取、归并、判定还是表达。第 1 章那次排查之所以困难,一部分原因就在这里。

组件级最便宜、最快、定位最准,线上级最贵、最慢、最难定位。但便宜不等于可以取代:归并器、闸门、策略各自单测通过,不代表串起来就是对的——跨组件的契约和整条链路,仍然需要会话级的覆盖。

所以纪律是一句话:

规则尽量往低层测,链路必须在高层验。

五、可验收:什么叫“算通过”

一条用例的核心不是输入,是“什么算通过”。这个标准必须明确、可重复执行:能写成程序断言的,尽量写成断言;不能完全机械判定的,也必须有稳定的评分标准和判分流程(第七节会讲后一种)。标准含糊的那种,才不算用例,只是一段素材。

一条用例大致长这样:

case_id      用例唯一标识
origin       它为什么存在:失败编号/需求编号/不变量/风险/边界条件
输入与上下文  用户这一轮说了什么、之前的状态是什么
require      输出必须包含什么、必须问哪个字段
forbid       不许包含什么、不许问哪个字段
match        必须满足的格式
state        跑完之后,字段状态、阶段、ask_count 必须是什么
action       主动作必须是哪一个

生产观察 第 1 章那份回归表里,判分规则采用了下面这种结构。为避免保留可识别的业务组合,示例中的业务词与金额表达已按前言口径匿名化,断言类型和判分关系保持不变:

TC01  {“forbid”: [“直接报价”, “\\d+\\s*元”, “哪个单位”],
       “require_any”: [“功能|场景|使用需求”],
       “require_all”: [[??]]}

TC02  {“turn”: 2, “forbid”: [“直接报价”], “require_any”: [“单位|登记”]}

TC01 防的是“首次问价直接报数字,并且顺势索取单位”。三条断言各管一件事:forbid 里那个正则拦的是任何形式的报价数字,require_any 要求回答落在配置或需求上,require_all 那个 [??] 则要求这一轮必须以一个问句收尾——它把“答完要往前推一步”也变成了机械可判的条件。

TC02 多了一个 turn: 2,因为它测的是“重复问价才触发信息收集”——第一轮不该触发,第二轮才该触发,判分必须能指定看哪一轮。

逐轮对话表里还有一列叫“禁止出现(命中即不通过)”。这一列的存在本身就说明了一件事:在真实业务里,“不该说什么”往往比“该说什么”更需要被机械守住。

前三行是对文本的断言,后两行是对状态的断言。这两类的稳定性差别很大。

对文本的断言很脆:话术换个说法,用例就红了,但系统其实没坏。对状态的断言稳定得多——换了措辞,字段状态和主动作不会变。

而对状态做断言这件事,是第三篇之后才拿得到的能力。在只有一整块提示词的时候,你能观察到的只有输出的那段话;有了槽位、闸门和状态机之后,你才能断言“这一轮之后 phone.status 应当是 DECLINED,主动作应当不是询问手机号”。可测试性不是测试团队争取来的,是架构给出来的。

origin 那一行不能省:这条用例为什么存在。 它应该能追溯到某一次真实失败、某项需求、某条不变量,或者某个风险与边界条件。第 1 章那次重构后新增的机制,每一条都能说出自己防的是什么——这条信息在第 22 章讨论规则退役时是决定性的,因为它回答了“这条规则还有没有必要留着”。

追溯链完整的样子是这样:失败或需求 → 规则 → 用例 → 实现位置 → 版本 → 退役。缺了 origin,这条链在第一环就断了。

六、strict k/k:跑一次不算数

模型有方差,所以一条用例跑一次通过,不能算通过。

做法是同一条用例连续跑 k 次,k 次全过才算过。这叫 strict k/k。

为什么不用“通过率不低于某个比例”?因为生产环境里,用户只会跑一次。你不知道他碰上的是通过的那次,还是没通过的那次。对安全关键的用例——已拒绝的字段不许再问、未提交不许说完成语——八成通过意味着单次运行仍有两成的失败风险,而这类失败一次就够贵。

还要说清 strict k/k 证明了什么。假设某条用例的真实通过概率是九成五,连跑五次全过并不罕见。所以 5/5 通过证明的是“这五次观测里没有看到失败”,不是“生产环境不会失败”。strict k/k 是一条严格的发布门禁规则,用来抬高门槛、暴露随机失效,不是对真实失败概率的证明。

方差本身也应该被当成一个指标:

先说清“不一致”指的是什么。这一次问“方便留个微信吗”、下一次问“可以留个微信方便沟通吗”,不算缺陷——措辞本来就该有自由度(第 17 章)。真正的问题是这一次的主动作是询问微信、下一次变成了询问手机号,或者这一次没有完成语、下一次擅自说了“已经提交”。

同一个版本、同一条用例,多次运行如果跨越了同一条验收标准的通过/不通过边界,这种不稳定本身就是可靠性问题,哪怕它大多数时候是对的。

第 1 章那次规模回涨之所以被察觉,一部分线索正是“回归通过率重新出现了方差”。单次冒烟可能看起来一切正常,而重复运行会更早暴露偶发失效——所以不稳定往往在版本整体指标明显恶化之前就能被观察到,而单看平均通过率会把它抹平。

生产观察 那份回归表的说明里写着两条不同的执行要求:

【联系方式必测】上线前每条至少跑 3 遍,重点观察同一输入是否稳定
【联系方式抽测】至少跑 1 遍

同一份回归集里,k 是按风险分层的:涉及联系方式与拒绝约束的那批用例 k=3 且看稳定性,其余 k=1。下面的适用边界会解释为什么这样分层——它不是设计出来的规范,是跑不动全量高 k 之后自然分出来的。

适用边界 k 越大越贵,全量用例都跑高 k 不现实。合理的做法是按风险分层:安全关键用例取较大的 k 且要求全过,一般用例取较小的 k,冒烟用例甚至可以 k=1。k 应该由这条用例的风险等级决定,不要全局取一个数。

七、判分器:能写成断言的,别交给模型

第 5 章已经定过一个口径:结构化任务尽量用代码判分,开放生成类任务则需要明确的评分标准、人工标注,或者经过校验的模型评审。这里把它展开。

对可判定的部分,一律用代码判分。 字段值、状态、主动作、格式、必须包含与不许包含——这些都能写成断言,就不要交给模型去看。三条理由:

  1. 方差会叠加。 用一个有方差的东西去判定另一个有方差的东西,失败的时候你分不清是被测系统错了,还是判分器看走眼了;
  2. 判分器会漂移。 判分模型升级之后,同一份用例的历史结论就不再可比——而回归的全部意义就在于可比;
  3. 没人测判分器。 被测系统有回归集保护,判分器通常没有。它出错的时候,你会以为是系统出错了。

对开放生成的部分——一段解释、一份说明、一次安抚——确实写不成断言。这时可以用评分标准配人工标注,也可以用模型评审,但有三条纪律:

  • 模型评审自己要被评测:拿一批人工标注过的样本去测它,否则你不知道它准不准。而且不能只看总体一致率——如果样本里九成本来就该通过,一个永远判通过的判分器也能拿到九成一致率。真正要盯的是关键失败的漏判率:把危险的坏答案判成通过,比把好答案判成不通过危险得多;
  • 版本要固定并记录:判分用的模型和提示词是评测基础设施的一部分,它变了,历史数据的可比性就断了;
  • 它不能成为安全关键与硬业务约束的唯一门禁:这类条件本来就写得成断言,最终否决权不该交给一个会看走眼的判分器。至于开放生成的质量,在经过人工标注校准、版本固定、持续抽检之后,模型评审是可以参与门禁的——前提是明确它的误判边界,而不是假装它没有误判。

收成一句:

能写成断言的,绝不交给判分模型;写不成断言的,判分模型也只是一个需要被评测的组件。

八、回归集怎么长起来,又怎么烂掉

回归集不是一次建好的,它是长出来的。来源不止一种:

真实失败    线上出过的问题
需求规格    新功能上线前先写好期望
边界条件    空值、超长、并发、重复提交
不变量      已拒绝不得再问、未提交不得说完成语
对抗场景    注入、越权、诱导(第 21 章)
语义变体    同一个意思的不同说法

生产观察 这份回归表的增长是可以看见的:第 1 章那次重构时它是五十三条,到后来的版本已经是八十三条,其中一次就新增了二十七条“联系方式专项”用例,覆盖单位与联系人同句抽取、号码清洗与校验、数字角色消歧、拒绝与重新授权、更正覆盖、摘要与接听时间的顺序。

更值得注意的是它规定的执行顺序:

哨兵用例  →  两条历史故障用例  →  必测  →  抽测  →  原有全量

哨兵排在最前面,因为它最便宜,而且一旦它挂了,后面所有用例的结果都不可信(第 20 章)。两条历史故障用例紧随其后——它们是这份回归集里 origin 最硬的两条:先确认最贵的那两次事故没有回来,再跑别的。

第一类有个顺序不能反:先补用例,再改系统。 先改后补,你无法证明这次修复真的有效——因为缺了一条会失败的用例作为起点。对事故用例来说,最好先让它红,再让它绿。

但这条纪律只适用于事故用例。需求、边界、不变量、对抗这几类是提前写下的规格测试,它们从上线第一天起就是绿的,这不影响它们的价值。

最后一类值得单独说。修完一次事故之后,如果只把出事的原话写成用例,你测到的可能只是这一句话。“手机号先不留了”是事故原句;而“这次先不留号码”“暂时不方便留手机号”“先不要我的联系方式”“后面需要的话我再给”这些没有被直接针对过的说法,才检验得出你修的到底是什么。这和第 4 章那条留出用例的纪律是同一件事:

事故用例通过,说明原来的坑在当前测试条件下没有复现;未见过的变体通过,才为“修的是规则、而不是那一句话”提供更强的证据。

至于它怎么烂掉,常见的有两种,而且都不是“覆盖不够”:

第一种,断言写得太死。 用例绑在具体话术上,于是每次调整文案就红一大片。红得多了,跑用例的人开始逐条判断“这个红是真的还是假的”,再往后就整体注释掉了。这也是上一节强调状态断言的现实理由。

第二种,只增不减。 用例越攒越多,跑一次要很久,于是提交前不跑了,改成“合并前统一跑”,再后来变成“发布前跑一次”,最后没人跑。

所以维护回归集需要两个反向动作:定期清理确实该退役的用例,以及为不同场景准备不同规模的集合——提交前跑冒烟集,合并前跑核心集,发布前跑全量。

退役的判据不能是“它从来没红过”。一条“已终止的会话不得被普通事件重新激活”的用例,可能长期都没红过——那说明这条防线一直有效,不说明它没用。该问的是另外几件事:

它对应的需求还存在吗?
它防的那次失败还有可能发生吗?
它是不是已经被另一条更强的用例完整覆盖?
它现在还分得出正确实现和错误实现吗?

几条都指向“不必要”,才算退役候选。这条判据在第 22 章讲规则退役时会再用一次。

回归集真正的敌人不是覆盖不够,是没人跑。

需要留痕的是变更本身。用例可以改——业务变了,期望自然要跟着变;但改动要说清楚是哪条业务规则变了,而不是因为跑不过就把断言放松。为了让用例变绿而修改用例,是这套体系最常见的失效方式。

九、能证伪了,还不够

到这里,开场那个问题可以回答了:凭什么说这版更好?

答案要分成三段:回归集证明旧问题没有回来,冻结评测集在目标任务分布上比较整体能力,线上评测验证这种表现能不能延续到真实流量。三样都有,“更好”才是一个有依据的结论;只有第一样,你能说的其实只是“没有退步”。

还有一层要留神:线上指标能说明真实环境里的表现是什么,但若要进一步声称某个业务指标的变化是新版本造成的,还需要 A/B、灰度对照或别的能支持因果比较的设计。同期的流量来源、活动、季节和用户结构都在变,把它们一起算进版本的功劳,就是在制造一个不存在的因果。

评测部分最后保留四条执行原则:

没有评测,删除永远比新增危险。

回归集告诉你旧问题有没有回来;冻结评测集告诉你整体能力有没有变化;线上数据告诉你这种变化在真实世界里值不值钱。

能写成断言的,绝不交给判分模型。

多次运行跨越了通过/不通过的边界,本身就是可靠性问题。

但评测能给你的只有一件事:哪一条挂了。

它不会告诉你为什么挂——错在抽取把话理解偏了,还是归并把新旧事实合错了,还是闸门的条件写漏了一种情况,还是渲染越过了它的权限。第 18 章讲错误分型时说过,症状总是出现在最后一层,而病因往往不在那里。

一条红掉的用例,是一个症状。要把它变成一个可以动手的结论,还需要能看见系统内部这一轮到底发生了什么。