跳到主要内容

Eval-as-Code:当你的发布门禁只是某人笔记本电脑上的一个 Notebook

· 阅读需 14 分钟
Tian Pan
Software Engineer

决定一个模型是否上线生产环境的数字,是由运行在某个工程师 MacBook 上的 Jupyter Notebook 生成的。数据来源是 Slack 私聊中的一个 CSV 文件,评分则由一个没人固定版本的裁判模型完成。两周后,在工程师又动了三次 Notebook,且 API 供应商悄悄发布了一个微小的模型更新后,团队里已经没人能重现那个数字了——包括当初生成它的那个工程师。然而,那个数字就是准入闸门。它决定了 GPT-4o-mini 是否足以在客户支持流程中取代 GPT-4;它决定了新提示词模板的发布;它决定了微调模型的晋升。团队把它视为核心承重构件,却像对待便利贴一样存储它。

这就是“评估差距”(eval gap)。五年来,业界一直在将评估视为一个方法论问题——哪种评分技术、哪种裁判模型、哪种评分标准、哪种数据集——却几乎从未将其视为一个工程问题。但是,一旦你的评估套件开始充当生产发布的守门员,它就继承了生产栈其余部分所遵循的所有要求:可重现性、版本控制、所有权、可观测性、依赖管理、延迟与可靠性预算,以及一套在构建它的工程师离职后依然能运行的流水线。大多数团队完全跳过了这一层,只有在发生重大事故后才发现它的缺失——通常是评估分数显示绿色,而用户体验却是一片红色。

解决办法不是换一个更好的评估框架。解决办法是将评估套件视为它已悄然变成的生产系统——对评估流水线应用与它所守护的服务同等的工程严谨性。我们可以称之为评估即代码(eval-as-code)。这个词有意借用了“基础设施即代码”(IaC):不是因为评估是基础设施,而是因为它们的失败模式是相同的。决定什么被部署的东西本身是一个无文档、不可重现、手动操作的产物,而组织在结构上对其投入不足,因为没人把它放进路线图中。

Notebook 问题不仅仅是 Notebook 本身

听到“你的评估只是一个 Notebook”的工程师会寻求显而易见的解法:把它重写为一个 Python 模块,放入主代码库,然后收工。这忽略了更深层次的问题。Notebook 只是团队对评估套件用途所做的一系列决策(隐含地通过疏忽做出的)的症状。在重写产生任何意义之前,必须明确这些决策。

谁负责评估?在大多数团队中,答案是“谁写的谁负责”——通常是最初提出该功能的 ML 工程师或应用科学家。代码库里没有 CODEOWNERS 条目。当那个人轮岗离开项目时,评估就成了孤儿代码。新团队成员不敢动它,因为他们不知道哪些阈值是起支撑作用的。裁判模型的提示词没有任何注释。三个月后,评估仍在运行,但当前团队中没人能回答为什么某个阈值是 0.72 而不是 0.75。

评估在软件开发生命周期(SDLC)中处于什么位置?大多数团队的回答是“我们在合并前运行它”,这听起来像 CI 但其实不是。CI 意味着它在每次变更时自动运行,在沙盒环境中,使用可重现的输入,针对已知的数据集,并生成合并按钮可以读取的结果。在笔记本电脑上运行只是一种手动仪式;工程师大部分时间都记得去做。那不是质量闸门,那是多了一些步骤的基于“感觉”的发布流程。

评估改变时,会发生什么变化?在生产代码中,行为变更会经过评审、语义化版本控制和部署。而评估套件则会被就地修改。昨天数据集有 200 个样本;今天变成了 230 个,因为有人添加了他们喜欢的边缘案例。曾经意味着“可以发布”的阈值现在意义不同了,仪表盘上的历史分数也不再能与当前分数相比。没有评估的 v1.4.0 标签。没有迁移记录。仪表盘显示的一条平滑曲线,实际上是三个披着同一指标外衣的不同度量衡。

这些是一个严肃的 eval-as-code 投入所要回答的问题。它们不是技术问题,而是组织问题,而“重写 Notebook”的方法跳过了这些问题。

对触及分数的每一个环节进行版本控制

一个可重现的评估是一个数学意义上的函数:相同的输入产生相同的输出。评估套件失去这一特性的原因是输入分散在具有不同版本模型的系统中,且只有一部分被固定了。完整的输入列表比大多数团队预想的要长。

数据集本身需要一个版本。不仅仅是一个文件名——而是一个内容哈希、一个提交记录、一个不可变快照。“我们基于最新数据集重新运行”这句话掩盖了回归风险:新的失败可能根本不是模型退化,而是数据集变难了。没有版本,你无法辨别。最廉价的修复方案是将数据集作为版本化构件提交到 Git,并将每次添加视为一个 PR。在前几次迭代中这可能显得笨重,但当有人问“分数下降是因为模型变差了,还是因为我们添加了新的边缘案例?”时,它就体现出价值了。

受测的提示词也需要以同样的纪律进行版本控制。提示词的改变就是行为的改变,而没有版本的行为改变就是“海森堡 Bug”(Heisenbug)的工厂。已经有几个团队写过关于像对待代码一样对待提示词的文章,共识正在趋于一致:将它们存储在 Git 中,赋予语义化版本,在部署元数据中通过版本引用它们,并且永远不要在不写回源代码控制的 UI 中编辑它们。提示词管理 UI 没问题,但如果它是“唯一事实来源”就有问题了。

裁判模型需要固定版本。这是团队最常遗忘的一点,因为裁判模型感觉更像基础设施而不是参数。事实并非如此。裁判模型是一个带有权重的模型,供应商 API 会在不另行通知的情况下更改这些权重——有时是悄悄更改,有时是以“微小更新”的名义而不更改版本字符串。固定到带日期的模型别名(例如 claude-sonnet-4-6-20250122,而不是 claude-sonnet-latest)是最低门槛。更高一级是定期针对裁判模型运行一小部分校准集,并在裁判模型对固定参考集的评分发生漂移时发出告警,因为即使是固定版本的供应商偶尔也会重新路由推理。

测试框架需要像任何生产服务一样进行依赖固定——锁定文件(lockfile)、固定的 Python 版本、容器化的运行时。评估流水线如果使用开发者本地安装的任何 pandas 版本,产生的结果将取决于该开发者当时安装了什么。你会在工程师升级版本且分词器的更改导致每个分数偏移 1% 的那天发现这一点。

模型参数也需要包含在版本中。Temperature、top-p、seed、max tokens、系统提示词——所有这些。这些不是评估流水线的配置,它们是评估契约的一部分。一个团队今天以 temperature 0.2 运行相同的提示词,明天以 0.0 运行,就是在用同一个名字产出两个不同的指标。

决定发布内容的流水线的 SLO

这是大多数团队即使在做对了一切其他事情后也会忽略的部分。他们对数据集进行了版本管理,固定了评测模型(judge),并在 CI 中设置了评估门槛 —— 然后他们却将流水线本身视为“尽力而为”的基础设施。它运行在 GitHub Actions 的免费层额度中。当它超时时,开发团队就重试;当它失败时,有人手动重新启动;当评测模型的 API 在凌晨 3 点发布前夕触发限流时,评估干味就不运行了,然后有人根据上周的分数批准了发布。

没有 SLO 的发布准入(release gate)称不上是发布准入,那只是一个建议。评估流水线需要与其准入的服务具有相同的可用性、延迟和可靠性目标。一个实用的初步方案包括:

  • 延迟 (Latency):从 PR 开启到评估结果产出的 P95 实际耗时,需有明确的预算。如果整套测试需要 90 分钟,工程师们就会想办法绕过它。
  • 可靠性 (Reliability):流水线本身的成功率(评估完整运行并产生分数),这与评估结果无关。一个有 15% 概率失败的不稳定流水线会产生 15% 的隐性回归。
  • 吞吐量 (Throughput):流水线在高峰期每小时能支持多少次评估运行。发布窗口会产生突发流量。处理稳定状态的流水线往往会在发布周崩溃。
  • 时效性 (Freshness):生产环境的故障能多快转化为测试集中的新测试用例。这虽然难以量化但确实存在 —— 六周的滞后意味着生产环境已经在比当前版本更差的评估标准下运行了六周。

这些都是 SLO,它们需要像任何其他 SLO 一样进行监控和告警。一个展示评估运行历史、流水线耗时趋势以及每次运行的评测模型成本的仪表盘,并不是可有可无的。它是你发现评估套件在大家都在关注模型指标时已经缓慢退化了两个月的方式。

必然的结果是:评估流水线需要一个值班机制。不需要 7x24 小时轮换,但需要对“评估流水线坏了且阻碍了发布,谁来修复?”有一个明确的答案。如果答案是“那个编写它的工程师”,那么只要那个人一休假,整个系统就会面临发布冻结。

评估结果不稳定是流水线 Bug,而非评估结果

随机模型会产生随机输出,这意味着单次评估运行只是一个样本,而不是一次测量。将单次运行视为测量的团队会将这种不稳定性带入准入机制中。典型的模式是:一个没有改动任何内容的 PR 导致评估失败;工程师重试;评估通过;PR 发布。团队由此学到:评估失败有时只是噪声。下次它因为真实原因失败时,有人会重试两次,然后照样发布。

修复方法应该在流水线层面,而非评估层面。评估即代码 (eval-as-code) 将运行层面的偏差视为一个已有成熟解决方案的测量问题。样本量:多次运行每次评估并进行聚合。偏差报告:在均值旁边显示标准差,并根据置信区间而非点估计值进行准入控制。在模型 API 支持的情况下使用种子随机化(OpenAI 的 seed 参数等),可以让你在单元级评估的重现性上走得很远,即便它并不完美 —— 研究表明,GPU 数量、硬件和批次大小(batch size)仍然可能导致相同种子提示词的输出在不同运行之间产生百分之几的偏差。这是一个真实的上限:你可以降低评估偏差,但无法将其消除,你的准入逻辑必须接受这一点。

正确的准入逻辑是统计学意义上的:“得分下降超过 X,置信度为 Y”。错误的准入逻辑是:“得分比昨天下降了 0.01”。交付错误准入逻辑的团队在浪费时间追踪噪声;交付正确逻辑的团队则在追踪信号。两者的区别在于在测试框架上投入的几个小时,以及接受一个带有误差棒 (error bars) 数字的意愿。

导致评估投入缩水的成本不对称

有一场预算对话几乎会让每一个评估即代码 (eval-as-code) 倡议夭折。团队提议在评估基础设施上投入两周的工程时间。领导层询问回报是什么。诚实的回答 —— “我们将能够信任我们的质量准入,发布更快且回归更少,并能重现历史结果” —— 与“我们将交付新功能”相比,听起来很虚。于是团队用评估工作换取了功能开发,评估套件便在另一个季度里继续维持笔记本 (notebook) 形态。

这种模式会不断重复,直到一次事故重新排列优先级。这类事故的形式在不同团队中惊人地一致:评估套件未能捕获的生产环境回归,事后追溯发现是因为团队没意识到评测模型发生了变化,或者提示词修改没有更新版本,或者数据集已经偏离了生产流量。事故发生后,评估即代码的预算会立即出现。在几乎所有情况下,那次事故的代价都是预防性投入的数倍。

能让工作更早获得资金的预算措辞不是“评估基础设施投入”,而是“发布准入的可靠性”。拒绝资助前者的领导层会资助后者,因为“发布准入可靠性”是他们在传统服务中听过的词汇,而且他们知道一旦失败会发生什么。使用 SRE 职能的语言,而不是 ML 职能的语言。评估流水线是 SRE 级别的基础设施,只不过碰巧是由 ML 工程师操作的。

优秀的标准

一个做得出色的团队会有几个明显的标志。评测套件(eval suite)是主服务代码库中的一个目录,而不是一个独立的项目,并且它有一个指定了实际负责团队的 CODEOWNERS 条目。数据集在 git 中进行版本控制,并附带新增内容的变更日志(changelog)。每一次评测运行都基于 commit hash 可复现;评测框架(harness)会将 commit、模型版本、裁判版本、数据集版本、种子(seed)和温度(temperature)记录在结果中。评测失败会以评论的形式出现在 PR 上,并附带指向产生该失败的运行链接。流水线(pipeline)有一个状态页面。团队会进行季度评审,像审查服务 SLO 一样审查评测套件,评审会回答诸如“我们的评测吞吐量是否跟上了 PR 的量”以及“我们的单次运行裁判成本是否有我们需要处理的变化”等问题。

这一切并不神秘。这些都是生产团队为他们交付的服务所做的常规工作。将其应用于评测的唯一特殊之处在于,大多数团队还没有这样做,因为评测套件最初只是某人的 notebook,并且从未“毕业”。“评测即代码”(Eval-as-code)就是这场毕业典礼。这项工作并不光鲜,但其结果是让发布门槛(release gate)上的数字变成了你真正可以辩护的数字——向你的团队、你的审计员,以及模型刚刚回复了其支持工单的客户。否则,你的发布流程中最重大的决策将由一个无人负责、无人版本化且无人能重新运行的产物做出。

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