传统按功能目录组织测试会导致相同断言重复编写、修改一处漏改其他;应按失败类型(JSON 格式错误、指令忽略、幻觉数据)分类组织,一处修改精准影响同类所有用例。
大多数 prompt 套件的组织方式与应用架构一致:一个功能一个目录,一个 prompt 一个文件。这与代码结构一一对应,但恰恰是错误的做法,因为 prompt 的变更不会遵循功能边界,而按功能组织失败用例根本无法告诉你失败属于哪种类型。
两个问题会同时出现,并且相互加剧。
第一个问题是重复。每个功能目录都需要相同的几项检查——输出能被解析、没有禁忌字段出现、所需工具被调用——所以同样的三个断言被写了六次,伴随六个略有不同的辅助函数。当你收紧其中一个时,你收紧的是六分之一,而另外五个悄然不再匹配。套件最终变得难以维护,很少是因为规模太大,更多是走这条路走到黑。
第二个问题是失败本身不携带任何信息。构建失败显示 checkout_summary.test.ts 失败了,告诉你该打开哪个 prompt,但无法告诉你模型是返回了格式错误的 JSON、拒绝了一个完全合理的请求、忽略了用用户语言回答的指令,还是编造了一个订单号。这四种情况的原因和应对方式完全不同,你只能通过阅读 diff 来判断是哪种。三五个失败用例时还好,三十个就够你花一下午了。
按失败模式组织,两个问题就都消失了。每个模式拥有一条断言,只写一次。每个模式是一个目录,目录名就是第一个分诊问题的答案。
有人反对说功能布局反映了所有权归属,而模式布局则没有。这个反对意见确实存在,但它是个错误的权衡。实际工作中 prompt 失败并不归功能团队所有——修改共享系统前缀的人同时影响所有功能——所以一个假装所有权归属的布局实际上把每个失败都放到了六个地方。
当一个模式拥有其他模式没有的断言形态时,它才值得拥有一个目录。有六个这样的模式。
Format(格式)。输出不满足契约:不是有效的 JSON、缺少必需字段、声明了数字的地方出现了字符串、枚举值超出范围。断言是 schema 验证——Zod 的 safeParse、Pydantic 的模型构造函数、JSON Schema 验证器——而且是二进制的,这使得它是测试成本最低的模式,完全应该放在关卡层级上。
Refusal(拒绝)。模型拒绝处理一个它应该处理的输入。这里的糟糕断言是搜索"I'm sorry"这个子字符串,一旦拒绝的措辞稍有不同,这个断言就失效了。好的断言需要 prompt 配合:让它输出一个包含有限值集合的 status 字段,然后断言 status !== "declined"。你把一个文本判断转化成了格式检查,这就是整个模式群的核心操作。
Instruction drop(指令丢失)。Prompt 中声明的约束被静默地没有应用:按输入语言回答、控制在 200 词以内、永不提及竞品、始终引用来源 id。每一项都可以针对输入进行机械检查——脚本检测、token 计数、黑名单、集合成员测试——无需对质量做任何判断。
Fabrication(捏造)。输出断言了一些你提供的上下文中不存在的内容。这通常是不可测试的;但当你控制着上下文时——在回归套件中你始终控制着上下文——它完全可测。从输出中提取所有标识符形状的 token——订单号、SKU、日期、价格、引用的文档 id——然后断言它们每个都逐字出现在输入中。这能在不需要判断文本是否真实的情况下捕获编造的引用。
Tool behaviour(工具行为)。这里有四种不同的失败:需要时没有调用工具、调用了错误的工具、调用的工具正确但参数不满足其 schema、以及同一工具在循环中被反复调用。这四种都是对结构化字段的断言,而非文本:工具名称、解析后的参数、调用次数。
Leakage(泄漏)。上下文中本不该被回显的内容出现在输出中——系统 prompt 片段、另一租户的记录、被纳入检索文档的 API key、原始电子邮件地址。通过模式以及从 fixture 本身种子的精确值黑名单来断言,这样测试能准确知道哪些字符串是敏感的。
Tone 是第七种人们想要按之组织的类别,但它不属于其他模式之列。没有"太简短"这样的不变量。每个可用的检查都是人或另一个模型应用的评分标准,这意味着它本质上是一种判断,这意味着它的虚假失败率比上述六种高出一个数量级。
这并不代表它不可测试,只是代表它属于不同的层级。Tone 用例应该放在定时运行中,用书面评分标准打分,作为趋势而非门槛报告,绝不应该阻止合并。如果你把带评判的 tone 用例放到门槛层级,你会把 sizing 算法中得出的整个分诊预算都耗在上面,而五种确定性模式就会被饿死。现有关于 eval rubrics 的页面是评分本身的正确处理方式。
功能变成 fixture 数据。目录变成模式。
tests/regression/
fixtures/
checkout.json # inputs, grouped by feature
support.json
onboarding.json
format_test.py # one assertion, every fixture
refusal_test.py
instruction_test.py
fabrication_test.py
tools_test.py
leakage_test.py
nightly/
tone_test.py # judged; not on the gate
每个文件加载所有 fixture 并对其应用一条断言。添加功能意味着添加一个 JSON 文件,它免费继承所有六项检查——这与按功能布局正好相反,在那种布局下添加功能意味着重新编写六条断言。
如果你宁愿按模式选择而不是按路径选择,pytest markers 可以在不移动文件的情况下做到。注册它们,这样拼写错误会报错而不是静默地空运行:
# pyproject.toml
[tool.pytest.ini_options]
markers = [
"format: output contract violations",
"refusal: declines a valid request",
"instruction: a stated constraint was dropped",
"fabrication: asserts something absent from context",
"tools: wrong tool, args, or call count",
"leakage: context content echoed into output",
]
# then
# pytest -m "format or tools" gating tier
# pytest -m "not (format or tools)" everything else
这种布局在失败时刻带来回报,回报是一个你从摘要行就能做出的决定,而无需打开任何文件。
分布在所有 fixture 中的十四个格式失败不是十四个 bug。它是一个输出契约破坏,几乎可以肯定是上一个 prompt 编辑触碰了描述响应形状的部分,响应是回滚该编辑。一个 fixture 中的一个捏造失败则相反:是一件具体的、本地的事情,值得仔细阅读。一组工具失败伴随零格式失败指向工具描述而非 prompt 主体,这是不同的文件,通常是不同的作者。
这种布局还使缺失变得可辨识,这是人们低估的部分。一个空的 leakage_test.py 是一个你从未测试过的模式,坐在目录树中你能看到它的地方。在按功能布局下,同样的空白是不可见的——每个目录看起来都填充完整,你只在生产中才发现缺失的模式。这是大多数绿色套件仍然漏掉回归问题背后的机制,而目录列表是对此的一种廉价防御。