strict模式将JSON Schema编译为解码约束而非提示词提示,输出必定符合格式无需重试;但Schema需符合OpenAI接受的JSON Schema子集。
Strict 模式不是"更好的 JSON 模式"。它约束解码过程,使模型只能输出符合 schema 的 token,这正是输出无需重试循环就能通过校验的原因——也是为什么 schema 本身必须适配 JSON Schema 的一个受限子集,OpenAI 才会接受它。
Schema 通过 response_format 传入,被包装在一个同时携带 name 和 strict 标志的对象中:
{
"model": "gpt-4o-2024-08-06",
"messages": [
{"role": "system", "content": "Extract the event details."},
{"role": "user", "content": "Standup moved to Thursday 09:30 in room B2, Ana and Kwame."}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "event",
"strict": true,
"schema": { /* see below */ }
}
}
}
strict: true 是整个特性的核心。当它缺失或为 false 时,schema 只是一个模型会尝试遵循的提示。当它存在时,OpenAI 将 schema 编译为解码约束,返回的字符串保证能解析且符合规范。支持是从 gpt-4o-2024-08-06 快照版本开始的,这也是锁定快照的一个具体原因——同一别名下较早的 GPT-4o 快照没有这个特性。
以下是一个普通的、有效的 JSON Schema。这是开发者不假思索就会写出的形状,而 OpenAI 会拒绝下面标记的所有内容:
{
"type": "object",
"properties": {
"title": { "type": "string", "minLength": 1 },
"day": { "type": "string", "enum": ["Mon","Tue","Wed","Thu","Fri"] },
"start": { "type": "string", "format": "time" },
"room": { "type": "string" },
"attendees": { "type": "array", "items": { "type": "string" }, "minItems": 1 }
},
"required": ["title", "day", "start"]
}
三个字段是必填的,两个是可选的;两个约束使用了表达边界的关键字(minLength、minItems);一个使用了 format;additionalProperties 未指定,这在 JSON Schema 中意味着"允许其他任何字段"。
{
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
"day": { "type": "string", "enum": ["Mon","Tue","Wed","Thu","Fri"] },
"start": { "type": "string", "description": "24-hour HH:MM" },
"room": { "type": ["string", "null"] },
"attendees": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["title", "day", "start", "room", "attendees"]
}
现在每个键都在 required 中;additionalProperties 显式设为 false;可选性移到了 room 的类型中;边界关键字被移除了——其中一个被 description 替代,而 description 是承载语法无法表达的约束的正确位置。
每个对象都需要 additionalProperties: false。不只是根对象——包括数组项内部和 $defs 内部的每个嵌套对象。在一个嵌套对象上省略它是最常见的被拒绝原因,而且错误会指出路径,所以看路径而不是重新读根对象。
每个属性必须出现在 required 中。所有属性,在每个层级都是。这是因为约束被编译进了解码语法,而一个可能发出也可能不发出某个键的语法比始终发出该键的语法要复杂得多。
Optional 变成 nullable。既然不能省略键,就把"无值"表达为允许的 null:"type": ["string", "null"]。这个改动会影响到你的应用代码——room 现在始终存在且有时为 null,所以应该用 if (obj.room) 而不是 if ("room" in obj)。如果你从 schema 生成类型,它们会是 nullable 而不是 optional,你的编译器会迫使你处理它,而这正是你想要的结果。
不支持的关键字必须移除。支持的核心是结构关键字——types、enum、anyOf、$ref 和 $defs、数组和嵌套对象。像 minLength、maxLength、pattern、minItems、maxItems、minimum 和 maximum 这类值域关键字历史上一直不在支持范围内。把它们都移到 description 中,模型会读取它,解析后再自行校验。语法无法强制的约束仍然是你的责任。
遵守结构限制。OpenAI 文档记录了一个 schema 中对象属性数量、嵌套深度和 enum 值总大小的上限。从大型数据库模型生成的 schema 会超出这些限制。解决方案是建模你需要的输出而不是镜像你的领域:一个有 200 个字段的提取 schema 通常是在让一次调用做五个人的工作。
支持关键字的精确列表和结构上限是此特性最可能已扩展的部分;OpenAI 随着时间推移在不断增加。建议在移除你更想保留的关键字之前,先查看 OpenAI 的 Structured Outputs 指南。这五个转换本身源自特性的工作原理,因此是稳定的。
有两个被支持且值得了解的特性,因为它们省去了人们通常采用的别扭方案。anyOf 可以在除根以外的任何地方使用,这就是你对结果类型进行带判别联合建模的方式。$defs 配合 $ref 是可用的,包括递归引用,所以树形结构或嵌套评论线程可以直接表达。
手动维护 strict schema 和代码解析的类型会导致二者逐渐漂移。OpenAI SDK 以类型为事实来源,并从中生成兼容的 schema——Python 用 Pydantic models,TypeScript 用 Zod schemas——同时还给你一个已解析的、类型化的对象而不是字符串:
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
const Event = z.object({
title: z.string(),
day: z.enum(["Mon", "Tue", "Wed", "Thu", "Fri"]),
start: z.string().describe("24-hour HH:MM"),
room: z.string().nullable(),
attendees: z.array(z.string()),
});
const res = await client.beta.chat.completions.parse({
model: "gpt-4o-2024-08-06",
messages,
response_format: zodResponseFormat(Event, "event"),
});
const event = res.choices[0].message.parsed; // typed, or null on refusal
注意这个辅助函数对 schema 做了什么:nullable() 变成了 string-or-null 联合类型;additionalProperties: false 被加到了所有地方;每个键都进入了 required;.describe() 变成了携带语法无法表达的约束的 description。这就是五个转换的自动化。底层仍然适用相同的限制——字符串上的 .min(1) 不可强制执行,会被丢弃或拒绝,所以这个辅助函数消除的是一类错误而不是限制本身。
同一个子集也约束着严格函数调用,此时 schema 描述的是工具参数而不是回复。如果你已经为一个场景完成了这项工作,它可以直接迁移——函数定义上的 strict 模式应用的是同一个编译器,只是作用在不同的字段上。
保证是精确的:如果模型产生了 completion,输出就是符合 schema 的有效 JSON。有两种它无法防止的情况。
截断。如果生成在 token 预算用完时恰好在一个对象中间,你会得到 finish_reason: "length" 和一个有效 JSON 的前缀,解析不出任何东西。这是让人们最意外的失败,因为这个特性被宣传为一种保证。在解析前始终检查 finish reason——了解每个 finish_reason 值的含义。深层 schema 加长字符串字段最容易触发这个问题。
拒绝。模型可以拒绝回答,当它拒绝时,消息携带的是一个拒绝字符串而不是内容:
{
"message": {
"role": "assistant",
"content": null,
"refusal": "I can't help with that."
},
"finish_reason": "stop"
}
这个字段的存在正是因为 strict 模式使得拒绝变得无法表示:符合 schema 的对象没有"无"的槽位。要显式处理它,而不是把 null content 当作错误;并且注意拒绝来自模型自身的训练而不是你做的任何 moderation 调用——这是两个独立的系统。
const msg = res.choices[0].message;
if (res.choices[0].finish_reason === "length") throw new Error("truncated");
if (msg.refusal) return { refused: msg.refusal };
const event = JSON.parse(msg.content); // safe: strict mode guarantees the shape
这三行按这个顺序排列就是完整的契约。检查 finish reason,检查拒绝,然后无需 try/catch 也无需修复循环就进行解析——因为从不触发的修复循环是死代码,终有一天会掩盖真正的问题,而 strict 模式正是它从不触发的原因。如果你在从 JSON 模式迁移,删除"无效 JSON 时重试"的路径就是这个特性真正为 schema 工作买单的时刻。
最后注意一下范围。Strict 模式约束输出的形状,不约束其他。类型为 string 的字段会是一个 string,但不一定是正确的 string。Enum 保证值是你的五个选项之一,但不保证它是对的。Schema 的一致性移除了一整类解析失败,并把剩余问题原封不动地移到了它该在的地方:评估提取的内容是否准确。
response_format json_schema: How OpenAI's Structured Output Type Works
Strict Mode in OpenAI Function Calling: What It Rejects
finish_reason Values in the OpenAI API and What Each One Means