跳到主要内容

真正能阻断 PR 合并的提示词回归测试

· 阅读需 12 分钟
Tian Pan
Software Engineer

问任何一个 AI 工程团队是否测试了他们的提示词,他们都会说"是的"。再问一句:一个有问题的提示词能否让 PR 失败并阻断合并?房间里会安静很多。对大多数团队而言,诚实的答案是否定的 —— 他们偶尔会跑一些评估笔记本,也许有一份记录已知提示词问题的共享 Notion 文档,以及一种模糊的感觉:事情比以前更糟了。那不是测试,那是在碰运气。

这个差距的存在,是因为提示词测试在感觉上与单元测试有本质区别。代码要么行为正确,要么不正确。提示词的输出处于一个连续谱上,输出是非确定性的,而且运行足够多的样本以建立信心会花费真金白银。这些都是真实的约束,但没有一个是无法克服的。那些建立了真正阻断合并的提示词 CI 的团队,并不是在每次构建上花费五十美元 —— 他们在三分钟以内、花费不到一美元的情况下完成运行,这得益于几个让这个问题变得可处理的设计决策。

为什么"我们测试提示词"这个说法站不住脚

典型的提示词测试工作流是这样的:工程师修改了一个系统提示词,在 playground 里随便看了几个输出,把一些例子粘贴到聊天线程中来比较新旧版本,然后就发布了。有时会有一个共享的评估电子表格。有时有人写了一个 Jupyter notebook,运行了二十个例子并输出一个分数。但几乎从未有人将那个分数接入 CI,以一种可以阻止合并的方式。

这种现状的存在有三个原因。

非确定性被当作借口。 LLM 不会两次产生相同的输出(除非你强制它这样做),所以团队假设你无法编写断言。这是错误的。你可以为你的部分用例编写确定性断言,并为其余部分编写带阈值的概率性断言。这种方法与单元测试不同,但结论 —— 这个 PR 让事情变得更糟了,不要合并 —— 是一样的。

评估成本被视为固定的。 如果你天真地以生产 temperature 对每次提交运行整个评估套件,是的,你会花太多钱。但你不必这样做。可行的提示词 CI 版本涉及一个精心挑选的小型测试用例集(30–100 个例子),而不是你的完整生产流量样本。当你停止将"CI 门控"与"全面的离线评估"混为一谈时,大部分成本问题就消失了。

没有人负责失败信号。 在传统代码库中,一个失败的单元测试会阻断合并并通知 PR 作者。而对于提示词评估,结果会出现在某个仪表板中,需要有人记得去查看。如果失败信号没有集成到 PR 审查流程中,它就会在截止日期的压力下被忽略。CI 集成不是可选的 —— 它是整个关键所在。

轻量级提示词测试框架的结构

目标是一个在五分钟内运行完成、每次运行成本在两美元以下、并能产生 CI 系统可以像处理任何其他测试退出代码一样消费的通过/失败信号的套件。以下是构建方法。

黄金测试用例文件

黄金测试用例(golden fixture)是一个版本化的输入/输出对,代表你的提示词必须正确处理的情况。把它想象成前端开发中的快照测试 —— 它捕获已知的良好行为,并在某些内容改变时发出警告。

一个支持工单分类提示词的测试用例文件可能如下所示:

- id: billing-cancellation-intent
input: "I want to cancel my subscription and get a refund for this month"
expected_intent: billing_cancellation
expected_sentiment: negative
must_contain: ["refund", "cancel"]
must_not_contain: ["upgrade", "recommend"]

- id: technical-bug-report
input: "The export button does nothing when I click it in Firefox"
expected_intent: technical_bug
expected_sentiment: frustrated
must_contain: ["issue", "team"]

优秀的黄金测试用例有几个共同属性。它们由人工精心整理,而不是自动生成 —— 只有当你确信预期输出实际上是正确的时,测试用例才有价值。它们包含边缘案例和历史上有问题的输入,而不仅仅是你的提示词一直以来处理得很好的简单情况。它们与提示词本身一起被提交到版本控制中,因此提示词变更和测试用例变更在同一次提交中同行。

你今天编写的测试用例将编码你对正确行为看起来是什么样子的理解。这值得投入时间。

在 Temperature 零下的确定性断言

对于硬断言 —— 有特定正确答案的情况 —— 以 temperature=0 运行你的模型。这使模型具有确定性:给定相同的输入和相同的模型版本,它每次都会产生相同的输出。现在你可以编写能够可靠地通过或失败的断言了。

这是大多数团队所遗漏的部分。他们以生产 temperature(通常是 0.7 或更高)运行他们的 CI 评估,然后想知道为什么结果会有噪音。分割你的测试用例集:确定性情况使用 temperature=0 和硬断言;模糊或创造性的情况使用带有软评分的单独评估路径。

确定性子集不需要很大。三十到五十个覆盖你最关键行为的情况 —— 如果这些行为退化会对真实用户造成伤害的情况 —— 足以给你一个有意义的门控。

LLM 作为语义断言的评审

并非所有内容都可以表达为字符串匹配或分类标签。对于"正确性"是语义性的输出 —— 捕获关键点的摘要、准确但不是逐字的解释 —— 你可以使用第二个模型调用来评估第一个。

这个模式很简单:你将模型输出加上评分标准发送给评审模型,并要求它按比例评分或发出带推理的通过/失败判定。这里的评审提示词非常重要。模糊的标准会产生不一致的分数。具体的、分解的标准会产生你可以依赖的分数。

评估产品描述输出的标准可能如下所示:"如果描述提到了至少两个具体的产品特性,不包含任何事实性错误的声明,并且以第二人称书写,则评分为 1。否则评分为 0。"这是评审模型可以持续评估的内容。

评审调用的成本通常比原始推理小,而且你只在需要语义评估的情况下运行它。对于一个 50 个用例的测试用例集,这每次运行增加几分钱。

让 PR 失败

框架需要发出退出代码。成功退出为 0,失败退出为 1 —— 这就是世界上每个 CI 系统知道是否允许合并的方式。这听起来很明显,但在你迭代只打印结果的评估笔记本时,很容易省略。

像 Promptfoo 和 DeepEval 这样的工具正是为此而设计的。Promptfoo 的 CLI 在断言失败时以非零代码退出。DeepEval 与 pytest 集成,因此失败会在你的标准测试运行器中以测试失败的形式出现。两者都可以。重要的是失败信号以开发者无法忽视的格式到达 PR。

在不牺牲覆盖率的情况下管理成本

对提示词 CI 最常见的反对意见是成本。以下是一个现实预算的样子。

一个包含确定性断言和评审评估混合的 50 个测试用例集通常在 60–120 秒内运行完成,根据模型选择和输出长度,成本为 0.20–0.80 美元。每个 PR 不到一美元。对于一个每天发出 20 个 PR 的团队,你每天的花费约为 15–20 美元 —— 比大多数团队花在咖啡上的钱还少。

几个实践可以使成本可预测:

使用更小的评审模型。 你不需要你的旗舰模型来评估结构化标准。用于评审的更小、更快的模型对于定义明确的标准通常同样准确,而成本只是其中的一小部分。

仅在提示词变更时运行完整的测试用例集。 大多数 PR 不会触碰提示词。在变更文件上设置提示词评估门控 —— 如果 prompts/evals/ 目录未被触碰,跳过昂贵的测试用例运行。你的 CI 系统在决定运行哪些任务之前可以检测到变更的文件。

使用批处理 API 进行离线评估。 当你进行全面的季度评估而不是 PR 门控时,批处理 API(大多数主要提供商都提供)以异步完成为代价将成本降低约 50%。同步运行留给 CI;批处理用于深度评估。

需要避免的三个陷阱

评估漂移

你的评审模型将被其提供商更新、弃用或更改。你的黄金测试用例会随着时间积累,并停止反映你实际的生产流量分布。这两者都是缓慢的侵蚀,使你的 CI 门控在没有任何人注意到的情况下变得越来越没有意义。

安排季度审查:运行你的测试用例集,检查失败的用例是否实际上是错误的,或者你的标准是否已经漂移,并淘汰不再代表真实场景的测试用例。明确跟踪你的评审模型版本。当你更改它时,重新运行完整的测试用例集,并将分数变化视为迁移事件,而不是惊喜。

不稳定的确定性

即使在 temperature=0 下,由于跨硬件的浮点并行性差异,某些模型也会在边缘情况下表现出非确定性。如果你看到在没有提示词变更的情况下偶尔翻转的测试,不要只是提高你的失败阈值 —— 调查具体情况。通常测试用例正在测试处于决策边界的内容。要么收紧断言,要么用更清晰的情况替换测试用例。

将覆盖率误认为信号

一个 200 个用例的测试用例集感觉很全面。但它并不是。生产中的提示词失败通常涉及看起来与你的测试用例毫无相似之处的输入 —— 用不标准英语书写的用户、触发工具 schema 边缘情况的输入、带有异常空白字符的对话轮次。测试用例集的覆盖率与输入空间的覆盖率毫无关系。

你的 CI 门控的目标不是穷举覆盖率。而是快速且廉价地捕获最常见的回归模式。全面评估 —— 涵盖多样化的输入分布、对抗性用例和长尾行为 —— 属于你的预发布评估流程,而不是你的 PR 门控。将两者混淆会导致昂贵的门控或对廉价门控的虚假信心。

设置 CI 集成

如果你使用 GitHub Actions,集成大约需要十行代码:

- name: Run prompt regression tests
run: npx promptfoo eval --config evals/ci-fixtures.yaml
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

Promptfoo 读取你的测试用例 YAML,运行断言,并在失败时以非零代码退出。失败的断言详细信息出现在任务输出中。你的分支保护规则可以要求这个任务在合并之前通过,与任何其他必需的状态检查一样。

对于使用 DeepEval 的 Python 团队,设置类似 —— 你的评估文件是一个 pytest 文件,因此它使用 pytest evals/ci_fixtures.py 运行,并与任何现有的测试报告基础设施集成。

思维转变

成功运行提示词 CI 的团队不把它视为"测试 AI" —— 一种感觉从根本上不确定且难以量化的活动。他们把它视为变更检测:鉴于我更改了这个提示词,它是否破坏了任何我知道应该正常工作的内容?这种框架更容易操作化。

你不是在试图证明你的提示词是好的。你是在试图在回归到达用户之前捕获它。你应用于代码的同样标准 —— 如果你更改了登录流程,你运行登录测试;如果那些测试失败,你不合并 —— 适用于提示词。如果你更改了支持工单分类提示词,你运行分类测试用例。如果三个曾经通过的测试用例现在失败了,某些东西改变了,你需要在发布之前理解它。

工具存在。成本是可管理的。大多数团队缺少的是将提示词变更视为代码变更的决定 —— 受到相同的审查严格性和相同的自动化安全网的约束。

从小处开始:十个测试用例,一个评审模型,一个 GitHub Actions 步骤。第一周的目标不是全面覆盖 —— 而是一个在某些东西出问题时会变红的绿色对勾。一旦你有了它,扩展覆盖率只是添加测试用例。基础设施已经在那里了。

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