
如果你也曾为了“让模型把结果按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( modelmodel-x, temperature0.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( modelmodel-y, temperature0.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.2sJSON Mode97.1%83.5%72.2%1.1sStructured Output99.4%99.2%94.6%1.3sStructured 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之前先保证这三条样本能稳定通过再往真实数据上铺开。用这个方法我后来接入新业务的结构化输出时基本都能在半天内把方案定下来不再被解析问题拖住两周。