☰
大模型结构化输出实战:JSON Mode与Structured Output选型与避坑指南
2026/10/22 18:21:26 网站建设 项目流程

如果你也曾为了“让模型把结果按JSON返回”这件事反复调整提示词,对着输出里的漏字段和错误类型干瞪眼,那么这一篇值得花十分钟读完。我最近在一个内部工具里被这种解析问题折磨了两周,最终靠JSON Mode和Structured Output两条路各配了一遍,才把结构化输出的稳定性拉到一个能上线的水平。这篇文章不是API文档翻译,而是把我在真实接入里遇到的机制差异、配置细节和翻车点一次性讲透。适合正在做大模型应用开发、需要把大模型输出接进业务系统的后端工程师,也适合被“模型输出千奇百怪”劝退的初级研究者。

1. 解析噩梦到底是怎么开始的:从“提示词协议”到“乱码残稿”

1.1 一次看似人畜无害的接入需求

事情要从我给一个内部工单系统做“用户意图路由”说起。需求不复杂:用户提交一段自然语言,比如“帮我找一下上周三的付款失败记录”,系统需要提取出操作类型、查询对象、时间范围、排序方式等字段,然后走后续的自动化流程。

一开始我采用的是最省事的方案:在系统提示词里写“你必须返回JSON,格式如下”,然后把期望字段列出来。第一次联调确实能跑通,模型确实给出了一个看起来像模像样的JSON。但问题在事情脱离“精心构造的示例”之后开始爆发。

用户表达稍微绕一点,模型就会自己“加戏”:字段名变了、某个值变成了null、时间范围里混进了解释性文字。最开始我以为是提示词不够强,于是加了更严格的语气,比如“不要输出任何多余内容,不要解释,不要使用null”。结果模型学了个形,开始返回空字符串,或者把bool字段写成字符串"false"。这时候我才意识到,提示词约束本质是在跟概率掰手腕。

1.2 我亲手收集的三类“经典残稿”

在排查那一周里,我把模型返回的所有异常输出存成了样例,归类后基本是三种:第一类是不合法JSON,例如多了前后注释、单引号、尾逗号、因为长文本截断导致括号未闭合;第二类是结构合法但字段错位,例如对象整体嵌套层数不对,数组字段返回了对象,或者明明要求int,结果出现"二十三"这种中文数字;第三类是最隐蔽的——字段名看起来对,但语义跑了,比如我要求“priority”只能是“high/medium/low”,模型返回了“urgent”。

这三类问题如果靠正则和字符串split去修,每一步都是在为下一个坑铺路。正则能修掉一个尾逗号,但修不了“模型把数组写成了对象”这种结构性问题;能抓住缺失字段,但不知道是让模型重来一次,还是用一个默认值顶上去。那几天我差不多把百分之六十的开发时间耗在写“JSON修复器”上,而这个修复器本身又成了新的维护负担。

1.3 为什么正则和字符串截断永远修不完这个问题

你可以把模型输出理解成一段“带口音的即兴演讲”,提示词只是告诉它“请用书面语写”,但没法保证它不跑题。正则和临时脚本做的是“事后纠错”,也就是等输出已经错了再想办法补救。面对一个结构完全错位的JSON,事后纠错只能靠猜,猜默认值、猜字段映射、猜哪段文本该丢弃,这些猜测在业务量小的时候无所谓,一旦流量上来,每个猜测都是一颗定时炸弹。

这也是我后来转换思路的根本原因:与其在生成之后花巨大精力去修,不如在生成之前就把“结构约束”固定到模型采样的机制里。这也是我关注JSON Mode和Structured Output的起点。它们做的事情不是“改进提示词”,而是改变模型输出时的约束强度。

2. JSON Mode与Structured Output的机制分水岭,别把两件事混为一谈

2.1 JSON Mode的本质:保证“长得像JSON”,不保证“符合你的形状”

很多模型供应商都提供了JSON Mode,我实际用到的最简配置就是在请求参数里加一个响应格式标识,指示模型“你必须输出合法JSON”。它解决的是“输出不是JSON”这个问题,但对JSON里的字段结构基本是放任的。

我测试下来,JSON Mode能很稳定地去掉前后解释性文字,也不会再出现单引号这种低级语法错误。但你让它返回一个包含“action、target、time_range、priority”四个字段的对象,它可能多给你一个“summary”字段,也可能在priority里塞进原文里的形容词。

用一句我常给同事打的比方:JSON Mode保证端上来的是一颗“鸡蛋”,但不保证是“煎蛋”还是“水煮蛋”。如果你的下游系统只要求“是鸡蛋就行”,那它够用;但如果你需要的一定是煎蛋,那还得靠更硬的结构约束。

2.2 Structured Output是怎么把“约束”送进采样过程的

Structured Output在多数实现里是“JSON Schema驱动的输出约束”。你定义一个描述字段名、类型、是否必填、枚举取值、嵌套结构的JSON Schema,把它作为请求的一部分传给模型服务。关键差异在于,模型在逐token生成时就会受到这个Schema的语法约束,生成出的结果不仅在语法上合法,还会在结构层面贴合你给定的形状。

我习惯这样理解:普通JSON Mode是“作文要求里写‘请用总分总结构’,写不好老师只能扣分”——生成的机制不受影响;Structured Output则更像是“直接在答题卡上印好了每一栏空格,你只能在空格里填空”,模型几乎没办法把字段挪位置。

当然,所谓“几乎”意味着严格实现还有服务端校验兜底。一旦模型在某一步生成了不符合Schema的token,服务端可能会拒绝或者触发重新生成。也正因为有这一层,“稳定输出结构化数据”才从一种概率上的“最好表现”变成了工程意义上的“约定行为”。

2.3 不是所有供应商的Structured Output都叫Structured Output

在我同时接入两三家模型服务的过程里,发现一个特别容易踩坑的点:各家对“Structured Output”的定义差异很大。有的实际是“JSON Mode + 后端Schema校验”,也就是先生成再验,不合格就让请求重试;有的则能在采样阶段做约束,返回结果基本不会违背结构定义。

一开始我没有细看文档,想当然地以为所有服务都支持复杂嵌套Schema,结果有的服务直接拒绝了我带“$defs”的Schema定义,有的则对“anyOf”支持不完整。所以后面凡是涉及Structured Output的配置,我都会先做两件事:查清该服务支持的Schema子集,以及用最小样例跑通“枚举字段”和“嵌套对象”两种类型之后,再上真实业务。这个习惯帮我省掉了大量来回试错的时间。

3. 按接口维度拆解:配置JSON Mode与Structured Output的真实差异

3.1 最基础的JSON Mode请求长什么样,什么时候该选它

在接入初期,我会在需要模型返回JSON时使用同步的请求结构,核心参数大致如下:

response = client.chat.completions.create( model="model-x", temperature=0.2, response_format={"type": "json_object"}, messages=[ {"role": "system", "content": "你是一个信息抽取助手,只输出JSON。"}, {"role": "user", "content": user_text} ] )

这段配置足够应付“只要JSON、不要解释”的场景。我实际建议是在三类情况下用它:一是不关心字段精确与否的简单问答;二是需要兼容后续不同业务方的自定义格式;三是在做产品原型、还没冻结数据结构时快速验证。

但也要注意,JSON Mode不会自动补齐字段。如果你的下游代码直接按字典取key,遇到模型没输出某个key,就会抛KeyError。因此我在用JSON Mode的模块里通常会加一个默认值合并函数,把缺的字段用预设值补上。这能缓解问题,但治标不治本。

3.2 推荐做法:先定义严格的JSON Schema,再让模型照着填

切换到Structured Output之后,我的工作重心变成了写JSON Schema。这里我给一个真实用过的简化例子,用来抽取工单意图:

{ "type": "object", "properties": { "action": { "type": "string", "enum": ["query", "update", "create"] }, "target": { "type": "string", "enum": ["order", "payment", "user"] }, "time_range": { "type": "object", "properties": { "start": {"type": "string", "format": "date"}, "end": {"type": "string", "format": "date"} }, "required": ["start", "end"], "additionalProperties": false }, "priority": { "type": "string", "enum": ["high", "medium", "low"] } }, "required": ["action", "target", "time_range", "priority"], "additionalProperties": false }

这个Schema和普通提示词里的“请返回以下字段”有本质区别:它定义了字段的类型、允许的取值集合,还明确禁止额外字段。很多Structured Output实现会严格要求additionalProperties置为false,否则请求直接不通过。我在第一次写时就是因为没加这个属性,请求被服务端拒了,花了好几分钟才定位原因。

3.3 请求侧怎么写,才能让模型真正进入“按Schema工作”的状态

把上面的Schema配到请求里的代码大致如下:

response = client.chat.completions.create( model="model-y", temperature=0.0, response_format={ "type": "json_schema", "json_schema": { "name": "intent_extraction", "schema": intent_schema, "strict": True } }, messages=[ {"role": "system", "content": "只根据用户诉求抽取工单意图,不要补充额外信息。"}, {"role": "user", "content": user_text} ] )

设置temperature为0,是为了让采样过程更稳定;strict开启后,服务端会严格按照Schema约束生成,并对无效输出做处理。使用这种配置后,我几乎再没见过“字段名悄悄变化”的情况。不过要注意,模型语义上仍可能填错值,比如用户明明在查“订单”,模型有可能把target填成“payment”,这时只能靠后续的语义校验解决。

3.4 拿到输出后的解析代码,别再手写一堆容错

Structured Output接入后,我习惯把返回的字符串先做一次json.loads,紧接着丢给Pydantic模型做类型校验,这样字段缺失、类型错位会在进入到业务逻辑之前就被拦截。因为Schema已经在前端约束了一轮,这里的校验压力其实很小,更多是起到“验收记录”的作用。

class TimeRange(BaseModel): start: str end: str class Intent(BaseModel): action: Literal["query", "update", "create"] target: Literal["order", "payment", "user"] time_range: TimeRange priority: Literal["high", "medium", "low"] intent = Intent.model_validate_json(response.choices[0].message.content)

这段代码做两件事:第一,把模型输出转成强类型对象;第二,让后续所有业务处理只依赖类型,不依赖字符串字典。我踩过的经验是,一旦把解析逻辑简单化,后面维护代码的人也不会再有冲动去写“容忍异常细节”的粘合层了。

4. 把Structured Output接入业务链路的完整实例:一个意图路由模块的落地记录

4.1 业务侧先定数据契约,而不是让模型来定

做这个意图路由模块前,我和前端、产品先坐在一起,把字段定义成了不可随意变更的契约:输入是用户原话,输出永远只有action、target、time_range、priority四个字段,每个字段的取值范围固定。这个动作虽然和模型配置无关,但特别重要。因为Structured Output只是保证“按Schema生成”,不会替你判断“这个字段在业务上是否合理”。

如果连业务侧都没想清楚枚举值,Schema写得再严格,也只是把混乱从JSON格式问题转移成了枚举值归类问题。我在这个项目里就吃过亏:一开始把action定义为任意字符串,模型倒是不会漏字段了,但“query”和“search”交替出现,下游统计直接乱掉。后来改成枚举,才真正稳定。

4.2 完整接入流程是最朴素的“请求-校验-重试-落库”

接入代码拆开看很朴素,先是定义Schema,再发起带json_schema的请求,随后用Pydantic校验,最后把校验失败率记进日志。这里我把重试逻辑专门拿出来说一下:即便有Structured Output,我也保留了最多两次重试,触发条件是“HTTP层面的超时”或“返回内容无法通过Pydantic校验”。

第一次重试使用完全相同的配置,第二次则退化成JSON Mode并附带一条系统提示词“请严格按之前的格式返回”。之所以第二次不继续用Structured Output,是因为如果服务端在严格模式下连续失败,说明问题大概率出在Schema本身或请求参数上,继续重试只是浪费延迟预算。

4.3 三种方案在真实消息集上的表现差异

我拿了一个包括1000条真实工单描述的消息集,分别用纯提示词、JSON Mode、Structured Output跑了一遍,记录“直接可用率”(也就是不需要人工修复、可以被下游代码直接解析且字段合法的比例),结果差异非常大:

方案合法JSON率字段完整率枚举值正确率平均耗时
纯提示词82.6%71.3%68.4%1.2s
JSON Mode97.1%83.5%72.2%1.1s
Structured Output99.4%99.2%94.6%1.3s

Structured Output的耗时比其余两者多了零点几秒,但换来的是接近百分之百的字段完整率。在实际业务里,这个代价非常划算,因为人工修复一条解析失败记录的时间成本,远高于模型多发几十毫秒token的算力成本。

4.4 上线后最让我意外的一类错误:方言和简称

虽然Structured Output稳住了结构,但语义层面的归类仍会受表达方式影响。比如用户说“查一下上周三的订单”,模型完全有可能把时间解析成“上周三凌晨到深夜”这种隐含全天的时间,而业务期待的是“上周三的任意时刻”。结构对了,语义理解还是会有偏差。

我的处理方式是再加一个“字段后校验”环节:枚举值只做第一步筛查,第二步再用规则检查时间表达是否符合业务预期。比如“上周三”这种自然语言不适合映射成具体的date格式时,就会让模型返回一个名为relative_time_range的字符串,由业务侧统一换算。这提醒我一个很重要的原则:Structured Output解决结构问题,但永远替代不了业务语义建模。

5. 配置Structured Output时踩过的坑,以及我最终收敛的方案

5.1 坑一:Schema里的递归定义和$defs被服务端拒绝

第一次接入另一个供应商的Structured Output接口时,我图省事在Schema里引用了$defs来定义嵌套类型。结果请求直接被拒,错误信息指向“不支持的Schema功能”。查文档才发现,该服务只支持有限的关键字,比如type、properties、required、enum、additionalProperties,但对ref等引用方式的支持有限。

解决办法也很务实:把嵌套对象写成内联的properties,避免引用复用。虽然Schema会显得重复一点,但换来的是跨服务兼容性。我在项目里把Schema写成一份“纯内联”版本,一份“带引用”版本,按实际服务能力去加载,虽然维护成本多一点,但避免了在关键链路上被Schema语法卡脖子。

5.2 坑二:系统提示词和Schema互相打架,模型开始自动“打补丁”

有段时间我发现返回结果里总出现一个额外的“reason”字段,但我的Schema明明禁止额外字段。后来排查发现,系统提示词里写了这么一句“请解释一下你的判断依据”,模型就倾向于把解释塞进JSON里。

把这一段提示词删掉之后,额外字段立刻消失了。这个坑给我最大的教训是:Structured Output只是格式约束,不是意图约束。凡是与Schema无关的“解释性要求”,绝对不要混进同一个请求里。要么让模型在另一个非结构化请求里做解释,要么就明确告诉它“解释放在文本字段里,不要放进结果对象”。

5.3 坑三:strict模式开启后的字段缺失比想象中更隐蔽

另一个让我排查了很久的问题是:严格模式下,模型偶尔会用一个空字符串来“凑”required字段。结构合法,Pydantic校验也通过,但str字段是空的,落到业务里才发现异常。这才意识到Structured Output并没有办法保证“字段值有实际意义”,它只保证“字段存在且类型正确”。

最后的方案是给关键字段加业务级非空校验,比如action的值必须落在枚举里,time_range的start和end必须能被日期解析,priority不能是空字符串。这些校验逻辑与模型无关,纯粹是业务兜底。我在代码里把它们做成一个独立的校验函数,任何模型输出都要过这一层,而不是信任“Structured Output输出就一定干净”。

5.4 最终收敛:把Structured Output当作安全带,而不是免检标志

在这个项目上线跑了两周之后,我把最终架构稳定成了下面这套组合:请求阶段使用Structured Output,解析阶段使用Pydantic,业务阶段使用规则校验,监控阶段统计“模型返回合法但业务校验失败”的比例。四层各管一件事,拆开看没什么特殊,合在一起才真正解决了“解析噩梦”。

我见过不少团队把Structured Output当成“开了就能躺平”的功能,结果上线后发现字段正确率提高了,但语义错误率还是高,于是又回头质疑模型能力。这个归因其实不准,问题往往在于没有在模型输出之后接上业务校验环节。我的经验是,把Structured Output当作格式安全带,把语义校验当作二次安检,这两个配合起来,才能真正做到“稳定输出结构化数据”。

最后分享一个我日常调试的小技巧:每次调整Schema之前,先写一个只有三条样本的测试用例,分别覆盖“正常输入”“字段缺失的输入”“带干扰词的输入”,跑一次看看模型在这三种样本上的表现。引入复杂Schema之前,先保证这三条样本能稳定通过,再往真实数据上铺开。用这个方法,我后来接入新业务的结构化输出时,基本都能在半天内把方案定下来,不再被解析问题拖住两周。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询