“请只返回 JSON”不是接口契约。它只是自然语言愿望:模型可能加 Markdown 围栏、漏字段、换枚举、把数字写成字符串;即使 JSON.parse 成功,里面的订单金额、权限范围或日期仍可能在业务上无效。生产系统需要连续回答三个问题:输出是否遵守结构,响应是否处于可消费状态,结构正确的数据是否满足领域规则。
本文面向已经用过 Zod、Pydantic 或 JSON Schema 的开发者。你需要知道对象、联合类型和运行时校验的区别。目标不是介绍 JSON,而是建立一条从模型到业务代码的窄边界。
图 1(1600 × 900):WEB/SUN 原创 SVG。先分类响应状态,再解析 Schema,最后检查业务约束;任一步失败都进入不同的受控分支。
Structured Outputs 解决哪一层问题
OpenAI 的结构化输出指南区分了 Structured Outputs 与 JSON mode:两者都能生成合法 JSON,但只有前者承诺遵守所提供的受支持 Schema。JSON mode 仍需要应用检查字段、类型与枚举,不能因为能解析就当成目标结构。
Responses API 有两类结构化边界:
- 最终回复需要成为应用数据时,使用
text.format定义 JSON Schema,或使用 SDK 的 Zod/Pydantic 解析辅助函数。 - 模型需要请求应用能力时,在 function tool 的
parameters中定义 Schema,并以strict: true约束参数。
第一类回答“模型最终产出什么”,第二类回答“模型建议调用工具时提交什么参数”。不要为了让用户看到一段自然语言,强行套一层 {"answer": "..."};也不要把会产生副作用的意图伪装成普通结构化回复。工具调用有 call_id、结果回传和多轮协议,语义不同。
从领域类型反推 Schema
先写消费者真正需要的最小类型,再生成 Schema,而不是把 prompt 中所有可能信息做成一个巨型对象。例如工单分流只需要类别、紧急度、依据和是否需要人工:
const Triage = z.object({
category: z.enum(['billing', 'account', 'technical', 'other']),
urgency: z.enum(['low', 'normal', 'high']),
evidence: z.array(z.string()).max(3),
needsHuman: z.boolean(),
});
const response = await client.responses.parse({
model: 'gpt-5.6',
input,
text: { format: zodTextFormat(Triage, 'triage') },
});
这里刻意没有 userId、canRefund 或自由形式的下一步命令。这些字段要么来自可信服务端上下文,要么应该通过受控工具处理。Schema 越大,模型要做的决定越多,测试组合也越多;减少字段本身就是降低攻击面。
官方函数调用严格模式说明给出两个重要约束:每个对象要设置 additionalProperties: false,properties 中的字段都要列入 required。需要表达“可选”时,应让字段必需但类型包含 null。这与很多应用代码把缺省字段直接省略的习惯不同,转换 Schema 时要特别检查。
不要假定任意 JSON Schema 关键字都可用。创建 Response 的参数契约定义了结构化输出的请求位置,而 Structured Outputs 只支持 JSON Schema 的一个子集;复杂 Schema 应先对照官方支持范围,并在构建或测试阶段实际发起契约测试。无法表达的领域规则留给应用验证,而不是塞进冗长描述后声称“Schema 已保证”。
响应消费是一台状态机
即使 Schema 正确,也不能直接读取“第一个 message 的第一个 content”。官方指南专门描述了拒绝分支:面对用户生成输入时,模型可能基于安全原因返回 refusal,而拒绝文本不必遵守业务 Schema。应用要把它显示为拒绝或提供替代路径,不能把它记录成解析器故障。
消费顺序应固定:
- 检查 Response 的终态;
completed、incomplete与failed分开处理。 - 遍历输出 item,确认目标 message 与内容类型,而不是依赖数组位置。
- 若内容是
refusal,进入拒绝分支。 - 若 SDK 没有得到
parsed,保留原始响应与配置版本,进入契约错误。 - 对解析对象执行应用自己的运行时 Schema,再执行领域校验。
- 只有全部通过,才把结果提交到数据库或下游工具。
incomplete 也不是“再 parse 一次”。达到输出上限、内容过滤或其他中断会导致对象不完整;界面应说明可重试原因,服务端可依据 incomplete_details 决定是否安全重试。若一次请求已经触发外部动作,则必须先查询动作状态,不能只重跑生成。
解析最好发生在可信服务端。浏览器可以再次验证用于渲染的公开对象,却不应接触包含内部策略的完整 Schema、原始私有上下文或授权字段。服务端将通过验证的领域 DTO 返回客户端,并在返回前删除仅供模型推理的证据片段;这样前端类型、后端领域类型和模型契约之间有明确的转换点。
Schema 正确不代表业务正确
假设结构化对象是:
{
"orderId": "ord_123",
"refundAmount": 999,
"reason": "duplicate"
}
Schema 可以保证 refundAmount 是数字且理由属于枚举,却不知道当前用户是否拥有该订单、可退款余额是多少、币种是什么、订单是否已经退款。这些事实必须由应用重新读取并校验。模型生成的 ID 不能自动成为对象引用;模型生成的布尔值也不能成为权限证明。
因此可以把验证拆成四圈:
| 圈层 | 负责的问题 | 典型失败 |
|---|---|---|
| 语法 | 是否为可解析的 JSON | 截断、额外文本 |
| Schema | 类型、枚举、必需字段是否匹配 | 未知字段、错误类型 |
| 领域 | 值在当前业务状态下是否成立 | 金额超限、资源不存在 |
| 授权 | 当前主体能否执行该动作 | 跨租户、权限不足 |
Structured Outputs 主要强化第二圈,无法替代后三圈的业务代码。OpenAI 的官方入门示例展示了从 SDK 类型生成 Schema 和解析对象的方式;生产实现还需要把领域与授权检查接在解析之后。
演进、兼容与评测
Schema 是公开给模型、解析器和消费者三方的接口,改动要像 API 版本一样管理。新增必需字段会改变生成任务;重命名枚举会破坏历史评测;把一个对象拆成联合类型可能超出部分模型或 Schema 子集。给 Schema 计算稳定哈希并随 trace 记录,能把某次失败准确关联到契约版本。
消费者升级应采用“先读后写”:先让新版本同时理解旧、新结构,部署完成后再切换模型输出,最后删除旧分支。若数据需要长期保存,存领域对象和 schemaVersion,而不是只存模型原始文本。模型响应仍可按隐私策略保留用于审计,但不应成为唯一业务记录。
对联合类型尤其要避免“万能 fallback”。如果分支由 kind 判别,消费者要对每个已知 kind 穷尽处理,未知 kind 进入兼容错误并记录版本;不要落到默认分支后用猜测字段渲染。严格失败虽然会暴露升级次序问题,却比把新类型悄悄显示成旧数据更安全。
评测至少覆盖正常、边界、拒绝、截断和对抗输入。除了“能否解析”,还要测枚举选择、字段间一致性、引用是否来自输入、未知信息是否用 null 而非编造。每次修改模型、prompt 或 Schema,都对同一数据集比较字段级差异。
失败模式与取舍
常见错误是:用正则从 Markdown 中抠 JSON;用 as SomeType 代替运行时校验;Schema 允许任意额外字段;把所有字段都设为字符串后在下游猜类型;没有拒绝分支;解析成功就执行写操作;Schema 改了却没有版本与回归样本。
严格结构也有代价。它限制开放式表达,对复杂或快速变化的数据模型增加维护成本,首次设计 Schema 需要和业务方对齐。解决方式不是退回“只返回 JSON”,而是缩小结构化部分:让模型输出少量可判定字段,面向用户的解释保留自然语言;高风险动作通过工具协议单独处理。
结论
结构化输出的价值不是省掉一次 JSON.parse,而是把模型与应用之间的形状约束提升为可测试契约。可靠消费需要三段式边界:Structured Outputs 保证受支持的 Schema,状态机识别完成、拒绝与中断,应用验证领域事实和权限。
只有当代码能明确回答“这是拒绝还是解析失败”“字段合法还是业务可执行”“契约版本是什么”,结构化数据才真正适合进入生产链路。
Sources
- Structured model outputs — OpenAI,访问于 2026-07-16。
- Function calling — OpenAI,访问于 2026-07-16。
- Introduction to Structured Outputs — OpenAI,访问于 2026-07-16。
- Create a model response — OpenAI,访问于 2026-07-16。