跳到主要内容

悄然失效的评估:当你的测试套件在衡量一个已不存在的世界

· 阅读需 11 分钟
Tian Pan
Software Engineer

你的评测套件通过了。240 个案例全是绿色,和上周一样。你发布了代码。两天后,支持工单激增。当你阅读对话记录时,你发现了一种你的套件完全没有立场的失败模式——不是某个案例从通过变成了失败,而是用户开始问一些你的套件从未想到过要去问的问题。

这就是评测(evals)的无声失败。我们将“全绿”视作对现状的肯定:“系统运行正常”。实际上,它只是对过去的一种陈述——即编写这些评测案例的那一刻。六个月前编写的评测编码了当时的三样东西:产品的范围、模型的失败模式,以及真实用户表达请求的方式。而这三者都在变化。功能增加了新的界面,模型升级了两次。随着用户了解产品的功能,输入分布也发生了漂移。套件没有随之移动,因此全绿的运行结果越来越只是在证明一个不再存在的世界。

没有人注意到,因为没有东西崩溃。过时的评测不会报错。它会继续自信地通过,但衡量的关键内容却越来越少。

评测是快照,而快照会老化

准确定义什么是评测案例会有所帮助。一个案例绑定了一个输入、一个预期行为或评分标准,以及一个隐含的声明:这个输入代表了用户的某种行为,而这个预期行为就是所谓的“正确”结果。 这两部分声明都有保质期。

输入会老化,因为用户行为不是静止的。系统在第三个月处理的查询很少能与发布周的分布匹配。用户发现了新能力,放弃了让他们失望的能力,并随着你的产品词汇渗入他们的思维而采用了新的措辞。行业术语在变,流行语在变,人们认为值得向 AI 提问的事情比任何事情变化都快。冻结在发布时的评测案例会一直测试发布周的用户,而那个用户早已不复存在。

预期行为也会老化,而且更危险,因为它是在无声无息中老化的。当你升级模型时,曾经区分“尚可”和“优秀”的标准可能会失效——新模型可以如此轻易地跨过旧门槛,以至于该案例不再具有区分度。当产品改变时,“正确”答案也会随之改变:如果一个案例期望智能体拒绝某个请求,那么在那个请求变成支持功能的那天,该案例就变错了。案例仍在运行。它仍在报告结果。它只是在根据一个早已被悄然废弃的规范进行评分。

因此,一个全绿的评测套件并不是一个声明。它是对世界的数百个微小声明,每个声明都是在不同的一天做出的,而且都没有自动重新审视。套件整体的新鲜度取决于其最老且未被检查的案例。

套件腐烂的三种方式

过时并非单一现象。它至少通过三个不同的渠道降临,并需要不同的应对措施。

范围漂移 (Scope drift)。产品覆盖面扩大了,但评测套件没有。你添加了新工具、新集成、全新的工作流——而套件仍然只覆盖最初的三个功能。覆盖率占实际功能的比例默默地从 90% 下降到了 55%。每个案例依然通过。套件并没有错,只是相对于它现在需要防御的范围来说太小了。这是最常见也最容易被忽视的腐烂,因为现有的案例完全没有发出它们已不足够的信号。

分布漂移 (Distribution drift)。产品界面是稳定的,但输入不是。你的案例采样自发布周的流量,而发布周的流量已经消失了。新的边缘案例——模棱两可的措辞、多意图的请求、假设了六个月前尚不存在的功能的问题——在套件中是缺席的,因为当套件构建时,它们在现实世界中也是缺席的。该套件测试的是一个已经迁移的人群的代表性样本。

规范漂移 (Spec drift)。这是最阴险的一种。产品行为是有意改变的,但没有人将这种改变同步到评测标准中。现在,套件编码的是旧的契约。在这里,一个“通过”的案例反而是 Bug:它在强制执行团队已经刻意废弃的行为。而一个“失败”的案例可能是正确的——系统做出了新的、正确的事,而评测却因为它没做旧的、正确的事而惩罚它。信号已经反转,而套件看起来依然在正常工作。

为什么要区分这些,是因为它们在仪表盘上看起来一模一样——全是绿色——但每一个的修复方式都不同。范围漂移需要新案例。分布漂移需要重新采样。规范漂移需要 重写 案例,而在重写之前,套件实际上是在撒谎。

失败运行的歧义:是回归还是案例过时?

大多数团队在套件变红时都有对应的说法:出问题了,找到它,修好它。这种说法只对了一半,而缺失的一半正是过时套件造成损害的地方。

当一个案例从通过变为失败时,存在两种截然相反的可能性。要么是 系统回归 ——它以前能做对,现在做不对了;要么是 评测过时 ——系统的新行为其实是正确的,而案例仍在按照旧的预期评分。前者需要修复代码。后者需要修复评测。如果对它们一视同仁,你最终要么会为了让套件满意而撤销真正的改进,要么会为了消除真实的回归而“修复评测”。这两种情况都很常见,而且在当时都感觉像是取得了进展。

区分它们需要主观判断,但这是有结构的。按顺序询问:产品规范的改变是否涉及该案例? 如果是,怀疑评测,而不是系统。模型改变了吗? 如果是,且只有边缘案例发生了变动,怀疑评分标准失效。新的输出是你愿意展示给用户的吗? 如果是,评测可能过时了;如果让你感到不适,那很可能是一个回归。这种分诊(triage)的产出不仅是一个修复 —— 它是一个裁决,应该记录在案例上,因为一个被裁定为“过时”一次的案例正在告诉你关于它整个批次的一些信息。

这就是为什么失败的运行比全绿更健康。失败的案例至少迫使你提出问题。而一个通过的过时案例永远不会被分诊,这正是它存活下来的原因。

将新鲜度视为评测集的一项属性

修复并非靠英雄主义。关键在于停止将评测集(eval suite)视为固定资产,而是将其视为具有半衰期的代码——这种代码会按计划衰减,也必须按计划进行维护。

首先,为每个用例标注日期。 每个用例都包含 created(创建日期)和 last_validated(最后验证日期)——即人类上一次查看该用例并确认其输入仍具代表性、预期行为仍符合当前规范的时间。验证并不等于运行用例;自动化运行只能告诉你用例是否通过,而不能告诉你这个用例是否还有意义。一旦用例标注了日期,“新鲜度”就成了一个真实的指标,你可以将其与通过率并列放在仪表盘上:用例年龄中位数、上个季度验证的用例百分比、最久未验证的用例。一个通过率为 95% 且用例年龄中位数为 11 个月的评测集并非健康的评测集,它只是表现得过于自信。

其次,设定明确的过期策略。 根据产品迭代速度选择合适的重新验证周期——许多快速迭代的 AI 产品通常定为一个季度左右。当一个用例超过该期限时,它不会被删除,而是会被标记为需要人工重新验证。评审人员要么更新 last_validated 日期,要么重写用例以匹配当前现状,或者因为其保护的行为已不复存在而将其停用。重点在于,评测集中的任何内容都不能仅凭很久以前做出的决定而无限期地拥有投票权。

持续从生产环境中补充评测集。 解决分布偏移(distribution drift)的良方是持续采样。挖掘生产环境的追踪记录(traces)——尤其是失败案例、升级处理(escalations)和低置信度的交互——并将它们提升为评测用例。一个能将生产环境中的失败转化为明天的回归测试的流水线,是防止评测集逐渐脱离现实的唯一可靠防线。这也意味着范围漂移(scope drift)可以实现自我修正:当新功能发布并开始产生流量时,这些流量就会开始产生候选用例。

最后,在进行模型升级时,不仅要检查通过率,还要进行新鲜度检查。 在采用新模型之前,不要只问“评测集还能通过吗?”,还要问“评测集还有辨别力吗?”运行升级并查看有多少用例之前处于边界状态(borderline),而现在变得轻而易举(trivial)。如果新模型轻而易举地搞定了一大批用例,这并不是胜利的凯旋,而是一个提醒:这些用例已经失去了衡量价值,评测集需要更难的用例。

实施这四项措施,评测集就拥有了代谢能力:用例从生产中进入,按时钟周期验证,在规范变动时重写,并在失去辨别力时停用。用例会老化,但评测集不会。

停止迷信绿色

这种信任“绿色”评测运行的本能,与导致过时评测变得危险的本能是一样的。一个“全绿”的运行结果并不是系统正确的证据。它只是证明系统仍然满足一系列关于世界的断言,而其中的每一条在编写当天都是成立的。

这种纪律要求我们要不断追问那一天是什么时候。为你的用例标注日期。将用例年龄与通过率并列放在仪表盘上。在修改代码之前,先对每一次“飘红”的运行进行分类:是 回归(regression)还是过时(stale)? 根据过期策略轮换用例,从生产环境中补充,并在每次模型升级时重新检查辨别力。这一切都不稀奇。这与你已经应用在依赖项和基础设施上的维护卫生习惯是一样的——最终应用到了那个你一直信任、用以告诉你一切是否正常运行的工具上。

一个从未经过重新验证的评测集并不能保护你。它只会让你发现它已失效的那一刻来得更晚,代价也更高昂。最糟糕的评测失败不是评测集变红,而是它在保持绿色的同时,悄无声息地失去了任何衡量价值。

References:Let's stay in touch and Follow me for more thoughts and updates