“请只返回 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') },
});

这里刻意没有 userIdcanRefund 或自由形式的下一步命令。这些字段要么来自可信服务端上下文,要么应该通过受控工具处理。Schema 越大,模型要做的决定越多,测试组合也越多;减少字段本身就是降低攻击面。

官方函数调用严格模式说明给出两个重要约束:每个对象要设置 additionalProperties: falseproperties 中的字段都要列入 required。需要表达“可选”时,应让字段必需但类型包含 null。这与很多应用代码把缺省字段直接省略的习惯不同,转换 Schema 时要特别检查。

不要假定任意 JSON Schema 关键字都可用。创建 Response 的参数契约定义了结构化输出的请求位置,而 Structured Outputs 只支持 JSON Schema 的一个子集;复杂 Schema 应先对照官方支持范围,并在构建或测试阶段实际发起契约测试。无法表达的领域规则留给应用验证,而不是塞进冗长描述后声称“Schema 已保证”。

响应消费是一台状态机

即使 Schema 正确,也不能直接读取“第一个 message 的第一个 content”。官方指南专门描述了拒绝分支:面对用户生成输入时,模型可能基于安全原因返回 refusal,而拒绝文本不必遵守业务 Schema。应用要把它显示为拒绝或提供替代路径,不能把它记录成解析器故障。

消费顺序应固定:

  1. 检查 Response 的终态;completedincompletefailed 分开处理。
  2. 遍历输出 item,确认目标 message 与内容类型,而不是依赖数组位置。
  3. 若内容是 refusal,进入拒绝分支。
  4. 若 SDK 没有得到 parsed,保留原始响应与配置版本,进入契约错误。
  5. 对解析对象执行应用自己的运行时 Schema,再执行领域校验。
  6. 只有全部通过,才把结果提交到数据库或下游工具。

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