枚举安全保障来自三种不同机制:受限解码(provider 强制)、指令遵循(prompt 描述)、应用层校验。测试需区分三者,因为只有第三层是应用完全可控的。
谁在真正强制执行 enum
"模型返回其中一个值"这句话背后有三种不同的机制,它们给出的保证也截然不同。
Constrained decoding。提供者将你的 schema 编译成语法树,并屏蔽采样器,使得任何会导致语法非法(grammar-invalid)的 token 无法被选中。在这种机制下,超出 enum 定义的值不是"不太可能",而是"根本不可能"——但这仅在你已启用支持该功能的 strict 模式,并且 schema 满足其限制条件时才适用。
Instruction following。enum 在 prompt 或非严格 schema 中被描述,然后模型被要求遵守。这通常有效,但偶尔会失效,而且失败的情况恰好集中在没有任何成员适用的那类输入上。
Your validator。无论提供者做了什么,值都会跨越边界进入你的代码并被解析。这是唯一你控制的层,也是测试真正要验证的地方。
实际结果是:写测试时要让它在 constrained decoding 下因为正确的原因通过,同时在 instruction following 下仍然通过——因为代码处理了违规行为。两者的区别在 JSON mode 与 structured outputs 的对比中已有覆盖。
文档中的限制
严格 schema 支持并非无界限的,边界就是保证悄然失效的地方。OpenAI 的 structured outputs 文档指出,一个 schema 在所有 enum 属性中最多可以有 1,000 个 enum 值,且当单个 enum 属性有超过 250 个字符串值时,所有 enum 值的字符串总长度不能超过 15,000 字符。同一份指南要求每个字段都标记为 required,每个对象都设置 additionalProperties 为 false——具体支持的关键字列表请参阅 OpenAI 的 structured outputs 指南。
这些数字对测试很重要,因为超出限制的 schema 会被拒绝或被静默放弃执行,而超出限制的 schema 通常是动态生成的。从数据库表构建的 category enum 今天有 240 个成员,下个季度就有 260 个了。在一个从不调用模型的单元测试中断言这个不变量:
import { describe, it, expect } from "vitest";
import { answerSchema } from "../schema";
const enums = collectEnums(answerSchema); // string[][]
describe("schema stays inside strict-mode limits", () => {
it("has at most 1000 enum values in total", () => {
expect(enums.flat().length).toBeLessThanOrEqual(1000);
});
it("respects the string-length rule above 250 values", () => {
for (const values of enums) {
if (values.length > 250) {
expect(values.join("").length).toBeLessThanOrEqual(15_000);
}
}
});
});
这些限制是撰写本文时文档中记录的,也是提供者会修订的数字。在依赖精确数值之前请重新阅读指南;无论如何,上面的测试值得保留,因为约束的形状比其常量数字更持久。
一个用普通输入喂给测试、断言值能被解析的测试几乎什么都证明不了,因为普通输入能干净地映射到某个成员上。产生违规的 case 是没有任何成员是对的:
超出分类体系的输入。一个支持分类器有 billing、bug 和 feature 三个成员,收到一条关于数据删除请求的消息。
跨越两个成员的输入。一条消息同时既是 bug 也是 billing 问题。模型经常用类似 bug/billing 的拼接形式回答,而这恰恰是宽松解析器会接受的形状。
空或无意义的输入。一个字符、空字符串、base64 blob。
包含指令的输入。消息文本说分类是 URGENT_ESCALATE。这是子 agent 输出验证中测试的那种注入的 enum 版本,在 instruction following 下它生效的频率比你想象的要高。
import { z } from "zod";
const Category = z.enum(["billing", "bug", "feature", "other"]);
it.each(adversarialInputs)("stays inside the enum for %s", async (name, input) => {
const raw = await classify(input); // returns the parsed JSON object
const result = Category.safeParse(raw.category);
expect(result.success, `got ${JSON.stringify(raw.category)} for ${name}`).toBe(true);
});
在失败消息中放入收到的值。一个消息是"期望 true,得到 false"的 enum 失败会让你多花一个调试会话;而一个说模型回答了 bug/billing 的消息能让你立即知道该怎么修复。
给它一条合法的退路
第一个对抗性 case 的修复不是更好的 prompt,而是另一个成员,或一个 nullable 字段。被迫在三个错误答案中选择的模型会选一个,constrained decoder 只保证答案是集合中的一个——而不是说它是正确的。在 constrained decoding 下,去掉逃生口不会消除不确定性;它会把一个可见的违规变成一个自信的错误分类,这反而更难检测。
所以测试套件在相反方向上增加一个断言:在超出分类体系的输入上,断言值是 other 而不是仅仅在 enum 中。这是关于行为的断言,所以要在多个样本上运行并将其视为一个比率(rate),且把它放在评估套件中而不是单元测试里。在生产中监控 other 的比率也是你将构建的最便宜的分类漂移警报:比率上升意味着世界上出现了一个你的 enum 没有涵盖的分类。
测试拒绝路径
最后,测试验证失败时会发生什么,因为那段代码也只在重要的日子里才会运行。完全绕过模型,直接把你的验证器喂进坏值。
it.each([
["unknown member", "urgent"],
["concatenated", "bug/billing"],
["case variant", "Billing"],
["whitespace", " bug "],
["null", null],
["number", 3],
])("rejects %s", (_n, value) => {
expect(Category.safeParse(value).success).toBe(false);
});
大小写和空格的行会迫使你做出一个本会偶然做出的决定。如果你的产品应该接受 Billing,就在验证前做规范化并断言规范化行为;如果不应该,就断言拒绝。你不能有一个拒绝它的验证器和一个在下游 later 将其小写化的处理。
然后断言其后果:一个无效的 category 产生一个调用方可以分支处理的类型化失败,被计入某个指标名称下,而不是变成数据库列中的字符串 undefined。一个流入存储的 enum 违规不再是验证 bug,而是一个数据质量问题,它会比导致它的部署活得更久。