跳到主要内容

评估迁移税:为什么 Prompt Schema 的一次变更会毁掉 800 个测试用例

· 阅读需 13 分钟
Tian Pan
Software Engineer

我见过的每一个发布过“小规模”输出 Schema 变更的 AI 团队,都经历过同样的一周。有人在系统提示词(system prompt)中重命名了一个字段——比如将 summary 改为 tldr,或者工具目录中增加了一个必填的 confidence 参数——结果下一次 CI 运行就在 800 个与该变更毫无关系的 Eval 用例中亮起了红灯。提示词的 diff 只有 15 行。而 Eval 的 diff 却变成了一个为期四天的迁移项目,且无人规划、无人负责,也从未包含在预算之内。

这就是 Eval 迁移税(Eval Migration Tax)。这是任何路线图都没有考虑到的维护成本,它以发布延迟的形式支付,而这些延迟往往被归咎于“不稳定的测试”(flaky tests),而非真正导致它们的架构选择。大多数团队在意识到这一模式之前已经支付了数年的代价,因为每一个单独的事件看起来都像是普通的日常损耗。只有当你统计一个季度内用于迁移 Eval 的工程小时数,并发现它们超过了用于改进 Eval 本应衡量的模型行为的时间时,这种复利效应才会显现。

解决这一问题的重新构思借鉴了数据库社区三十年前解决的一个问题:当你的数据具有不断演进的结构时,你不能将数据视为静态固定装置(static fixtures)。你需要迁移、版本控制以及让 Schema 变更成本降低的工具。Eval 套件是版本化数据。在 2026 年发布 AI 功能最快的团队,是那些最早意识到这一点的人;而深陷 Eval 迁移债务的团队,则是那些仍将“黄金集”(golden sets)视为已提交的 JSON blobs,并认为它们会永远保持原始形状的人。

税负是如何累积的

Eval 套件通常在开始时既小又整洁。一位提示词工程师编写了三十个捕捉重要行为的示例,将预期输出编码为字面量 JSON 字符串,并配置了一个 CI 检查,通过严格相等来对比实际输出和预期输出。运行正常。团队发布。信心增强。

然后套件开始增长。生产事故增加了用例。利益相关者要求覆盖他们负责的工作流环节。测试用例的数量每个季度翻一番。到了第九个月,你已经有了 800 个用例,而其中几乎没有任何一个在添加之后被重新检查过。它们是堆积而成的,而不是精心策划的。

现在考虑当系统提示词的输出 Schema 发生变化时会发生什么。也许你为下游解析增加了一个新的必填字段。也许一次 LLM 升级对你的工具调用 Schema 的解析比之前的模型更严格,你不得不收紧参数名称。也许由于产品需求,某个类别被重命名了。对提示词的改动很小。但对每个测试用例“预期输出”的改动却涉及所有 800 条记录。

出现了三种失败模式:

  • 绑定到字面量输出字符串的脆弱断言。Eval 并不关心模型是否仍然正确推理;它关心的是新输出在字节级别是否等于旧的固定装置。重命名一个字段会使整个固定装置失效。
  • 工具调用形状耦合。Eval 用例断言模型使用完全相同的参数名称调用了 search_users(filter="active")。增加一个 limit 参数,每个检查旧形状的断言都会报错,即使模型的意图并未改变。
  • 隐式时间耦合。八个月前的一个案例期望得到一个提到 "Q3 2025" 的响应。今天的 Eval 仍然断言该字符串。提示词本身没有问题;而是用例过时了。

当这三层同时失效时,团队面临一个选择:花一周时间手动迁移 Eval 套件,或者跳过 Schema 变更。两者代价都很高。团队通常在当下选择看起来更便宜的选项——跳过变更——于是提示词积累了技术债务,并在下次有人尝试改动它时复利增长。

架构层面的错误

这一切底层的错误在于将 Eval 套件视为静态固定装置(static fixtures)而非版本化数据(versioned data)。静态固定装置是一个冻结的制品:值就是那个值,任何破坏该值的周围系统变更都是一个需要通过重写该值来解决的问题。版本化数据集则相反:数据有 Schema,Schema 有版本,变更通过以编程方式操作数据的迁移来协调。

数据库社区几十年前就意识到了这一模式。当 Schema 发生变化时,拥有百万行记录的生产数据库无法通过手动编辑行来迁移。像 Flyway 和 Alembic 这样的工具之所以存在,是因为每一次有意义的 Schema 变更都需要一个知道如何转换数据的脚本——而这些脚本成为仓库中的一等公民,与 Schema 本身一起被审查、测试和进行版本控制。

Eval 套件也有同样的变更放大问题。一次 Schema 变更涉及每一条记录。如果没有迁移工具,每一次变更都会变成一个 O(N) 的手动编辑项目,其中 N 是你的测试用例数量。有了迁移工具,它就变成了一个在数据集上运行一次的 O(1) 脚本。

架构上的领悟是:提示词 Schema 和 Eval 数据集之间的耦合方式,与应用程序的 ORM 模型及其数据库之间的耦合方式是一样的。你不是通过承诺永远不更改模型来管理这种耦合,而是通过让 Schema 变更成为一种正常的、工具化的操作来管理。

将输入与输出断言解耦

第一个具体的实践是保持评测输入与评测断言在不同的层级。输入是你发送给模型的内容:用户的查询、对话历史、工具目录、系统提示词(system prompt)。而断言是你对响应进行的检查。

大多数自研的评测套件会将这些内容合并为一条记录:

{
"input": "What's my balance?",
"expected_output": "{\"answer\": \"Your balance is $42.\", \"confidence\": 0.9}"
}

这在第一天非常方便,但到了第九个月就会演变成灾难。输入描述的是一种在架构变更时应保持稳定的行为——“用户询问其余额,模型应回答余额”。而预期输出描述的是该行为在某一特定时间点的特定编码方式。当你将两者耦合时,你就失去了在不重写行为描述的情况下更改编码方式的能力。

解决方法是使断言变得语义化而非字面量化。不要断言响应等于某个 JSON 对象,而是断言响应包含一个值为 "$42" 的余额字段,或者模型使用了用户的账号 ID 调用了 get_balance 工具。像 Promptfoo 和 DeepEval 这样的框架通过可组合的断言类型支持这一点——JSON 路径检查、子字符串包含、结构匹配、LLM 作为评委(LLM-as-judge)——这些方法在架构变更中表现得比字面等价法更具韧性。

更难的准则是编写描述意图而非形式的断言。“模型应拒绝此请求”比“模型应输出 {\"refused\": true}”更持久。拒绝行为可以表现为一个标志位、一句自然语言,或者是调用 escalate_to_human 工具——其意图是相同的,改变的只是形式。请将你的断言锚定在意图上。

具备架构版本标识的评测记录

第二个实践是在每条评测记录上记录架构版本(schema version)元数据。每个测试用例都带有一个 schema_version 字段(理想情况下还有 prompt_versionmodel_version),标明它是基于哪个版本的架构编写的。

当架构发生变化时,你增加版本号。旧记录继续保留在数据集中,并标记其旧版本号——CI 任务会针对相应的旧 Prompt 运行它们以检测回归(regressions),或者当基础行为不再受支持时静默弃用它们。新记录则针对新架构版本编写。迁移脚本在边界处运行,在转换属于机械性操作时,将旧记录转换为新形状。

这听起来像是额外负担,直到你第一次进行模型迁移。当你更换底层模型并导致一百个评测案例失败时,你需要知道它们失败是因为新模型变差了,还是因为旧的断言与旧模型输出的偶然特性绑定在了一起。具备架构版本的记录使这种诊断变得可行:你可以针对每个版本的 Prompt 重新运行每个版本的套件,查看哪些行为是稳定的,哪些只是巧合。

一个实际的实现方式:每条评测记录都是版本化表中的一行,或者是带有 schema_version 字段的 JSON 文档。每个版本的架构都作为 JSON Schema 或 TypeScript 类型检入代码库。CI 会拒绝任何形状与最新架构不符的新记录。迁移工具——而且应该有且仅有一个——通过读取两个架构,知道如何将记录从版本 N 转换为版本 N+1。

从结构上讲,这正是 Alembic 和 Flyway 对关系型数据所做的事情。这种模式可以完美迁移到这里。

将迁移脚本作为核心产物

第三个实践由第二个延伸而来:当架构发生变化时,负责更改的工程师需要编写迁移脚本。不是一次性的“查找/替换”,不是手动编辑,而是一个真实的脚本,它被检入代码库,与 Prompt 变更一起进行代码审查,并作为架构变更 PR 的一部分运行。

评测迁移的形态通常很简单。将 summary 重命名为 tldr:一个遍历每条记录并重命名字段的函数。给工具调用增加一个必需的 confidence 参数:一个遍历每个工具使用案例并添加默认值,或者将该案例标记为需要人工复核的函数。删除一个不再存在的分类:一个查找并删除引用该旧分类的记录,或将其重写为合理替代方案的函数。

这种做法的变革性在于,迁移代码成为了 Prompt 历史记录中永久的一部分。六个月后,当一名新工程师想知道为什么某个字段被命名为 tldr 时,他们可以通过 git log 查看 Prompt,并找到将其从 summary 重命名的迁移脚本,以及解释原因的提交信息。架构不再记录在容易过时的独立 Wiki 页面上;它记录在描述架构如何演变为当前形态的可执行记录中。

测试你的团队是否已经内化了这一实践的一个有用方法是:询问上次提交 Prompt 架构变更的人是否编写了迁移脚本。如果答案是“没有,我只是手动更新了测试固件(test fixtures)”,那么你还没有建立起迁移规约。你拥有的是一套目前凑效的手工劳动规约,仅仅是因为你的数据集足够小,手动编辑尚且可行。

评测所有权应归属于 Prompt 编写者

第四个实践是组织层面而非技术层面的。评测(Eval)的所有权必须归属于 Prompt 的所有者——而不是放在单独的 QA 积压任务中,不是交给下游测试团队,也不是交给负责审查二十个产品团队 PR 的“平台”组。

原因很简单:每一次 Prompt 的变更都意味着评测的变更。Prompt 所有者与评测所有者之间的任何交接都会增加延迟并丢失信息。如果 Prompt 工程师发布了 Schema 变更,而由其他人来迁移评测,那么 Schema 变更只是发布的一半,迁移是另一半。这两者会被具有不同思维模型的人乱序执行,而 Bug 就会出现在两者之间的缝隙中。

有效的替代方案是协同维护(co-location)。编写 Prompt 的工程师(或小组)也负责编写评测。修改 Prompt Schema 的同一个 PR 也修改评测 Schema。审查 Prompt 的同一个 Review 也会审查迁移脚本。CI 在合并前会针对新 Prompt 运行新评测。因为没有分离,所以不存在交接。

对于习惯了明确的开发/QA 分离的组织来说,这可能会让人感到不适。但这种分离假设软件行为具有足够的确定性,使得 QA 可以在独立的工作流中编写测试,并相信它们会随着软件的演进而保持有效。大模型驱动的系统并不满足这一假设。其预期行为会随着 Prompt、模型和工具集的更新而不断变化。如果测试没有与其描述的产物协同放置,几乎会立即过时。

实践中的表现

一个内化了这些实践的团队,其评测套件的增长速度与 Prompt 的复杂度同步,而不是更快。Schema 变更作为包含 Prompt 变更、评测 Schema 变更和迁移脚本的小型 Diff 发布——在单个 PR 中即可完成评审。旧的评测记录继续存在并针对旧版本的 Prompt 运行,从而提供跨 Schema 演进的回归测试覆盖。模型迁移变成了几天的练习,而不是在一堆没人理解的测试固件(fixtures)中进行为期数周的“考古挖掘”。

而没有内化这些实践的团队,其 CI 套件可能已经飘红了三周,因为在上次 Prompt 变更后,没人有时间去迁移评测。团队学会了忽略 CI。真正的回归错误在噪音中溜走。最终,有人提议从头重写评测套件——于是循环往复。

评测迁移成本是将版本化数据视为静态固件(static fixtures)所付出的代价。要么现在就在工具和纪律上投入,要么就在没人知道如何归因的延迟发布中永远为此买单。

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