OpenAI结构化输出需满足特定schema约束(additionalProperties:false等),文章实测了约束_decoder之外的三个可测试维度。
你无法有效地测试提供商的约束解码器是否正常工作。你能测试的是围绕它的三件事,而且 bug 恰恰就在那里。
OpenAI 的结构化输出指南描述了一个 format 对象,它携带着 json_schema 类型的 type、name、schema 本身以及一个 strict 标志。在 Responses API 中,它嵌套在 text.format 之下:
{
"model": "gpt-5.2",
"input": [{ "role": "user", "content": "..." }],
"text": {
"format": {
"type": "json_schema",
"name": "invoice_extract",
"strict": true,
"schema": {
"type": "object",
"additionalProperties": false,
"required": ["vendor", "total_cents", "currency"],
"properties": {
"vendor": { "type": "string" },
"total_cents": { "type": "integer" },
"currency": { "type": "string", "enum": ["GBP", "EUR", "USD"] }
}
}
}
}
}
该指南明确说明了 schema 必须满足哪些条件才能被 strict 模式接受:每个对象上设置 additionalProperties 为 false、每个字段都列入 required——没有可选属性——以及根节点是一个 object 而不是 anyOf。文档还记录了若干上限:最多 5000 个对象属性、嵌套最多十层、总共最多 1000 个枚举值,以及所有属性名和枚举值合计最多 120000 字符的限制。参见 OpenAI 的结构化输出指南了解当前列表,该列表以前增加过,以后还会继续增加。
这个承诺是关于形状的。它说的是你返回的字符串可以被解析为 JSON 并符合 schema。它没有说 total_cents 是否是正确的数字。
这里最有价值的测试在生成第一个 token 之前就失败了。Strict 模式会预先验证你的 schema,并对不支持的结构报错,所以一个已经偏离的 schema——有人添加了一个可选字段,或者一个嵌套对象没有 additionalProperties: false——在生产环境是请求时 400 错误,在你的测试套件中也会因为同样的原因成为请求时 400 错误。
你不需要 provider 来做这件事。写一个测试来遍历你交付的 schema,并断言 strict 模式要求的那些不变量。它在几毫秒内运行完成,不需要任何凭证,而且在失败消息中会指出违规的路径,而不必让你去读一个指向根节点的 provider 错误。
import { describe, expect, it } from "vitest";
import { invoiceExtractSchema } from "../src/schemas";
function objects(node: unknown, path = "$"): [string, Record<string, unknown>][] {
if (typeof node !== "object" || node === null) return [];
const self = node as Record<string, unknown>;
const here: [string, Record<string, unknown>][] =
self.type === "object" ? [[path, self]] : [];
const props = (self.properties ?? {}) as Record<string, unknown>;
return Object.entries(props).reduce(
(acc, [key, value]) => acc.concat(objects(value, path + "." + key)),
here,
);
}
describe("invoice schema is legal under strict mode", () => {
for (const [path, node] of objects(invoiceExtractSchema)) {
it("closes " + path, () => {
expect(node.additionalProperties).toBe(false);
expect(new Set(node.required as string[]))
.toEqual(new Set(Object.keys(node.properties as object)));
});
}
});
解析响应,然后用独立的验证器——Ajv、Pydantic,或你的语言提供的任何工具——针对你发送的同一个 schema 对象进行验证。这不是对 provider 的不信任。而是因为你发送的 schema 和下游代码期望的 schema 是两个在不同地方维护的东西,除非你把它们变成一个东西,而重新验证正是使它们变成一个东西的过程。
它还覆盖了 strict 模式触及不到的路径:回退到一个不支持该特性的模型或 provider、在 schema 变更之前记录的缓存响应,以及人工编辑过存储结果的任何路径。要断言解析后对象的符合性,而不是原始字符串——键顺序和空白字符不是契约的一部分,字符串比较却会让它们成为测试的一部分。
模型可以拒绝。OpenAI 的指南将拒绝记录为输出中一个独特的内容对象,其 type 为 refusal,解释内容放在一个 refusal 字段中,而不是作为符合 schema 的 JSON。直接获取 text 部分并解析的代码在遇到拒绝时会抛出异常,而异常表现为 JSON 解析错误,这会让值班的人去寻找一个根本不存在的畸形输出 bug。
用 fixture 来测试,而不是用一个经过精心设计的、会被拒绝的 live prompt:在你的 mock 中放入一个拒绝外形的响应,并断言你的处理器返回一个类型化的拒绝结果而不是抛出异常。那个测试是确定性的,无论本季度任何模型决定拒绝什么,它都保持真实。
一个包含三种货币的枚举保证你得到三种货币之一。它不保证你得到正确的那一个,而且因为输出始终是良好形式的,每个下游层都让它通过了。这正是让人们对 strict 模式过度信任的失败模式:它消除的错误类别是响亮的,而它留下的错误类别是沉默的。
所以真正重要的评估是一个带有预期字段值的标签集,按字段计分,它与上面的测试是不同的制品。把它们分开:schema 符合性是一个单元测试,在每次提交时运行,不需要任何模型调用;字段级准确性是一个按计划评分的黄金数据集。将它们混为一谈会让你得到一个缓慢的套件,它无法区分一个损坏的 schema 和一个更差的模型。
在"无可选字段"规则中隐藏着一个设计后果,大约一个月后就会咬人。因为 strict 模式要求每个属性都出现在 required 中,表达"这个值可能不存在"的方式是允许 null 的类型——即该类型与 null 的联合——而不是省略的键。这改变了你下游代码必须处理的内容:字段始终存在且有时为 null,而不是有时缺失。如果你的消费者是针对一个具有真正可选键的 schema 编写的,它现在会在预期 undefined 的地方看到显式的 null,而类似"这个键存在吗"的检查会变成始终为真。在重新验证测试中直接断言 null 的情况,使用一个每个可空字段都设置为 null 的 fixture,因为这是你的提取代码在一份缺失了一半数据的文档上实际会遇到的形状。
最后,把 schema 放在一个模块中,然后导入到请求构建器和测试中。一个在内联定义在请求中的 schema 是一个没有测试可以遍历的 schema,而上面的请求时检查悄悄地变成了对一个副本的检查。如果你还在决定测试的是两者中的哪一个,参见 JSON 模式与结构化输出与语法约束的区别。
一个操作层面的注意事项可以节省一个下午。文档化的上限——属性计数、嵌套深度、枚举值总数、属性名和枚举值的合计字符限制——在 schema 是从类型定义生成而不是手工编写时,很容易在不知不觉中接近。一个大型域枚举的生成 schema 在真实数据源每添加一行时就增长一次,而超过限制的请求会在所有用户面前同时在请求时失败。在生成的 schema 上添加一个断言,统计属性数量、深度和枚举值总数,在数字还算舒适的时候失败而不是在边界上失败。它就在你已经写的遍历代码旁边加三行,它把一次生产故障变成了一个 PR 评论。