结构化输出 JSON mode 怎么用
你写过这种脚本吧:从客服工单里抽取「问题类型」和「优先级」,提示词里加一句”请输出JSON格式”,本地跑三次有两次正常,第三次模型忽然多嘴了一句”好的,以下是提取结果:“,json.loads 直接甩你一个 json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0),批处理脚本凌晨两点跑到一半就崩了。这不是运气差,是纯提示词约束这条路本来就靠不住——模型骨子里是个文本续写引擎,“看起来像 JSON”和”真的能被解析”之间隔着字段顺序、多余逗号、未转义引号一堆坑,纯靠”听话”兜不住生产环境。
JSON mode 是从解码层面强制模型只能吐合法 JSON,不是”建议”而是”约束”。适用于信息抽取、评分、分类等一切需要机器可读结果的场景。OpenAI 兼容 API 提供两种方式:response_format: {type:"json_object"} 和 response_format: {type:"json_schema"}(Structured Output,严格模式)。
两种方式对比
| 方式 | 支持程度 | 特点 | 适用场景 |
|---|---|---|---|
json_object | 大多数模型支持 | 保证输出合法 JSON,字段不受约束 | 简单提取,字段灵活 |
json_schema (Structured Output) | 部分新模型支持 | 严格按 Schema 输出,字段、类型均受控 | 固定结构,下游强类型消费 |
| 提示词约束(无 mode) | 全模型 | 不保证合法性,依赖模型遵从度 | 兜底方案,不推荐生产 |
底层发生了什么?json_object 是在解码阶段加了一层语法级约束(业内一般叫 constrained decoding),每一步候选 token 都要满足”这最终得拼成一段合法 JSON”这个语法树,但它不管你具体要哪些字段、字段是什么类型——所以你完全可能拿到 {"结果": "张三"} 这种合法但字段名跟你预期对不上的输出。json_schema 严格模式在此基础上更进一步,会把你传的 Pydantic 模型或 JSON Schema 编译成一份更细的约束,模型生成每一个字段时都要满足你定义的类型、是否必填、甚至枚举取值范围。这也是为什么 Structured Output 响应时间会比 json_object 略慢一丢丢——解码器每一步都要多算一层 Schema 校验,不是模型变笨了,是它在替你把关。
选哪个别纠结,一句话判断:如果你只是要”保证能 json.loads 成功,字段自己再校验一遍”,用 json_object 够了,成本低、兼容模型多;如果下游是强类型系统(比如直接反序列化成后端 DTO、写数据库字段),字段类型错一个都会炸,那就上 json_schema,让模型自己保证不会给你塞一个字符串到本该是数字的字段里。
json_object 模式
使用 json_object 时,必须在 system 或 user 消息中明确要求输出 JSON,否则部分模型会拒绝或输出自然语言。
import os, json
from openai import OpenAI
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.lidayun.com/v1"),
)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "你是信息提取助手,只输出 JSON,不输出其他内容。"},
{"role": "user", "content": "从以下文本提取姓名和邮箱:'联系张三,邮箱 zhang@example.com'"},
],
response_format={"type": "json_object"},
max_tokens=256,
)
data = json.loads(resp.choices[0].message.content)
print(data) # {"name": "张三", "email": "zhang@example.com"}
这段代码里最容易被忽略的一行是 system 消息里那句”只输出 JSON”——这不是客气话,是硬性要求。OpenAI 官方文档写得很明白:调用 json_object 时,如果你整个 messages 数组里连”JSON”这个词都没出现过,API 会直接拒绝这次请求,返回类似这样的报错:
Error code: 400 - {'error': {"message": "'messages' must contain the word 'json' in some form, to use 'response_format' of type 'json_object'.", ...}}
第一次踩到这个坑的人往往一脸懵:“我提示词写得好好的怎么就 400 了”——回头看一眼,多半是把要求写在了一段很长的中文描述里,通篇没出现”JSON”或”json”这个英文单词(写了”结构化数据""格式化结果”之类的同义词也不算)。解决办法很直接:system 或 user 消息里明确带上”JSON”这个词,哪怕只是”请以 JSON 格式输出”这一句垫底也行。
另一个常见现象是输出被截断。如果你把 max_tokens 设得太小,模型还没把 JSON 拼完整就被砍断,比如输出停在 {"name": "张三", "ema,这时候不是 JSON mode 失效了,是你给的 token 预算不够。判断方法很简单:查一下 resp.choices[0].finish_reason,如果是 "length" 而不是 "stop",就说明是被截断的,加大 max_tokens 或者精简你要抽取的字段即可。这个坑比”忘记提 JSON”更隐蔽,因为报错发生在你自己的 json.loads 里,很容易被当成”模型输出了非法内容”来排查,其实根源在参数设置上。
json_schema 严格模式(Structured Output)
from pydantic import BaseModel
class ContactInfo(BaseModel):
name: str
email: str
resp = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06", # 需支持 Structured Output 的模型
messages=[
{"role": "system", "content": "提取联系人信息。"},
{"role": "user", "content": "联系张三,邮箱 zhang@example.com"},
],
response_format=ContactInfo,
)
contact = resp.choices[0].message.parsed
print(contact.name, contact.email)
用 Pydantic 模型这条路之所以省心,是因为 SDK 帮你把「严格模式」要求的几个隐藏规则都补齐了:Structured Output 底层其实要求 Schema 里 additionalProperties 必须显式设为 false(不许模型塞你没声明过的字段),并且每一个字段都要出现在 required 数组里(哪怕业务上是可选字段,也得在类型里用 Optional 或联合 null 来表达”可以不填”,而不是直接从 required 里去掉)。如果你不是用 .parse() 这条封装好的路,而是自己手写一份 JSON Schema 字典传给 response_format,这两条漏掉任何一条,API 会直接报 Schema 校验错误,提示 additionalProperties 或 required 不满足严格模式规范。这也是为什么官方更推荐用 Pydantic(Python)或 Zod(Node 生态里对应的方案)来生成 Schema,而不是手写——手写很容易漏掉这些严格模式特有的约束项。
还有一点容易被忽略:能用 json_schema 严格模式的模型是有版本门槛的,不是所有挂着”支持 JSON mode”招牌的模型都支持严格 Schema 约束。示例里用的 gpt-4o-2024-08-06 是官方明确支持 Structured Output 的版本号,如果你换成更早的 gpt-4o 别名或者第三方兼容模型,大概率会报”不支持该 response_format 类型”或者干脆忽略 Schema 退化成普通 json_object 行为。接入前先去对应模型的文档确认一下版本号,别想当然。
Node.js 示例(json_object)
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL ?? "https://api.lidayun.com/v1",
});
const resp = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "只输出 JSON,不输出其他内容。" },
{ role: "user", content: "提取:张三,zhang@example.com" },
],
response_format: { type: "json_object" },
});
const data = JSON.parse(resp.choices[0].message.content);
console.log(data);
Node.js 生态里对应 Structured Output 的封装方式是配合 Zod 用 zodResponseFormat 生成 Schema(较新版本 SDK 才有),如果你用的是老版本 openai npm 包,就只能走上面这种手写 json_object + 自己在提示词里描述字段的路子,跟 Python 那边能直接拿 Pydantic 模型生成严格 Schema 相比省心程度差一截。团队里 Python/Node 混用的话,建议把 Schema 定义放在一处(比如共用一份 JSON Schema 文件),两边分别加载,别各写各的,字段名对不上排查起来很烦。
解析防御
JSON mode 只保证语法合法,不保证字段完整——模型依然可能给你返回 {"姓名": "张三"} 而不是你要的 {"name": "张三"},尤其是提示词写得不够精确的时候。所以拿到结果之后,别急着当成”绝对可靠的结构化数据”直接往下游甩,至少做两层防御:
import json
raw = resp.choices[0].message.content
try:
data = json.loads(raw)
name = data.get("name", "") # 用 get 提供默认值
email = data.get("email", "")
except json.JSONDecodeError:
# 极少数情况下模型输出仍非法,做兜底
name, email = "", ""
这段兜底代码看着简单,但生产环境里真正让人头疼的往往不是”完全解析失败”,而是”解析成功但字段是空的或者类型不对”——比如你要的 email 字段,模型给了个空字符串或者写成了 "未提供" 这种自然语言占位符,这种情况 json.loads 不会报错,但你下游按邮箱格式去用就直接崩。所以在 get 拿到默认值之后,最好再加一层业务校验,比如用正则简单验证 email 格式是否合法,不合法就整条记录标记为”需人工复核”而不是直接扔进下游库表。
批量抽取场景下,还建议在解析失败时把原始 raw 内容连同请求参数一起落地记日志(哪怕只是写进本地文件),而不是简单地 name, email = "", "" 完事——这样出了问题你至少能回头翻日志看看模型到底吐了什么鬼东西,是提示词的问题还是模型本身抽风,不然batch跑挂了你除了知道”失败了”什么都查不到。遇到大批量抽取还伴随限流报错(比如 429 Too Many Requests)的情况,建议配合指数退避重试,这块可以直接参考并发控制与速率限制里的重试策略,别自己从零造轮子。
常见问题
json_object 模式输出了多余的文字前缀怎么办? 检查是否在消息中明确要求”只输出 JSON”;同时升级 SDK 版本,部分旧版 SDK 不会传递 response_format 参数。
Structured Output 的 json_schema 和 json_object 哪个更贵? token 定价相同,Structured Output 可能因引导模型遵从 Schema 而略微增加输出 token,整体差异可忽略。
流式输出能用 JSON mode 吗? 可以,但需要在流结束后拼接完整字符串再解析,无法逐 token 解析中间状态。
模型不支持 json_schema 怎么办? 降级为 json_object + 在提示词里描述期望字段,或改用支持 Structured Output 的模型(如 gpt-4o-2024-08-06 及更新版本)。
用 json_schema 会不会因为字段太多导致输出变慢很多? 会有影响,但通常不是主要瓶颈。真正拖慢速度的是 max_tokens 设置和输出内容本身的长度,Schema 校验带来的额外开销更多体现在首 token 延迟上,如果你的场景本来就要求低延迟(比如实时对话里插入一次结构化抽取),建议实测对比一下两种模式的响应时间再决定,别凭感觉判断。
进阶:批量场景怎么控制成本和稳定性
如果你不是偶尔调一次,而是要跑成千上万条记录的批量抽取,光会写上面的代码还不够,这几件事得提前想清楚:
并发怎么开。 单条请求跑批量数据效率太低,但一股脑全部并发发出去大概率会撞上速率限制。实际做法是用信号量或者线程池控制并发数(比如同时最多 10-20 个请求在飞),配合失败重试和退避策略,具体实现思路见并发控制与速率限制,这里不重复展开。
成本怎么估。 JSON mode 本身不额外计费,但 Structured Output 的 Schema 描述会占用一部分输入 token(Schema 越复杂占用越多),批量跑之前先拿一条真实数据测一遍,看输出里 usage.prompt_tokens 和 usage.completion_tokens 的实际数值,乘以你要跑的总条数,心里有个数再开工,别等跑完账单出来才发现比预期贵一截。
流式场景怎么处理。 前面提过 JSON mode 配合 stream=True 用的时候,拿到的是一段一段的增量文本片段,中间任何一个切片单独拿出来都不是合法 JSON,只有等 finish_reason 变成 "stop"、所有片段拼接完整之后才能扔给 json.loads。如果你的场景是要一边生成一边展示进度(比如前端做打字机效果),可以把片段先原样往前端推,同时后端用一个缓冲区攒着做最终解析,两件事分开处理,不要指望对半截 JSON 做增量解析——除非你愿意自己写一个容错的流式 JSON 解析器,一般场景不建议这么折腾。
动手验证一下: 把本文 json_object 的示例代码跑一遍,故意把 system 消息里的”JSON”字样删掉再跑一次,你应该会看到前面提到的那个 400 报错——亲眼见过这个报错长什么样,以后排查同类问题就不会绕远路了。再试着把 max_tokens 调到很小(比如 5),观察 finish_reason 是不是变成了 "length",输出内容是不是被硬生生截断——这两个实验做完,你对 JSON mode 的边界条件基本就摸透了。
更多接入基础见大模型 API 接入完全指南与接入教程专题。需要调用外部函数并返回结构化结果,可结合 function calling 工具调用;高并发批量抽取时请参考并发控制与速率限制。