news 2026/10/20 6:53:37

大模型结构化输出实战:Structured Outputs与JSON Schema避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大模型结构化输出实战:Structured Outputs与JSON Schema避坑指南

做自然语言处理落地的开发者,几乎都经历过一个让人血压升高的瞬间:模型吐出一大段内容,你满怀信心地把它丢进json.loads(),却换来一个JSONDecodeError。再仔细一看,输出里多了个 Markdown 代码块标记,或者结尾跟了一句“以上就是我的回答”。早期对接各种大模型接口时,生产环境里这类“解析地狱”几乎是必修课。

Structured Outputs 的出现,算是把这条最容易翻车的路焊上了护栏。简单说,它让大模型在生成阶段就被约束,直接输出符合你预先定义的 JSON Schema 的数据结构。注意,这不是靠提示词去“请求”模型配合,而是在解码层面把当前不该出现的 Token 提前屏蔽掉。结果就是,模型返回的内容天然是合法、可解析、可校验的 JSON。

这篇文章我会从原理讲起,再给出一套可以直接抄作业的 Schema 设计方法和调用形态,最后把我踩过的坑整理成一份避坑清单。如果你正在做文本抽取、RPA 自动化、数据流水线,或者正在搭 Agent 工具链,只要你有“把自由文本变成结构化数据”的需求,这篇内容都值得读完。

1. 为什么要把大模型的“自由发挥”关进Schema里

1.1 自由文本返回的三大痛点

我接手过某公司的信息抽取流水线,最早一版就是用提示词硬扛:系统提示里写“请只输出 JSON,不要任何解释”。模型心情好的时候确实给你纯 JSON,心情不好就给你一段 Markdown 围栏,然后在围栏外面加一句“请注意,上述内容为 JSON 格式”。这种输出丢给解析器,结果可想而知。

第一类痛点是解析不稳定。模型可能在 JSON 前后加注释、加反引号、加自然语言说明,甚至把布尔值写成True。你当然可以写正则去剥壳,但每换一个模型版本,壳的花样就可能翻新一遍,维护成本全堆在解析函数里。

第二类痛点是字段漂移。就算模型勉强输出了合法 JSON,结构也可能和你预期对不上:status字段有时是"success",有时是"成功",数字金额偶尔变成字符串"5.20"。下游代码一当float处理,线上直接炸。自由文本的问题不在于“不合法”,而在于“合法但不可预测”。

第三类痛点是难以串联。Agent 流程里每一步的输出都要被下一步读取,如果上一步返回的是一段长文本,下一步要么让模型再猜一遍,要么靠正则抽取关键信息,整个链路脆弱得像多米诺骨牌。我在实际项目中体会到,把每一步的输出都固定成 JSON,是整个流程能稳定跑起来的前提。

1.2 从JSON Mode到Structured Outputs,瓶颈到底在哪

很多平台早就提供了 JSON Mode,也就是“保证输出合法 JSON”的模式。这比裸提示词前进了一大步,但问题依然明显:它只保证“语法上是 JSON”,不保证“结构符合你的业务模型”。你问它要{name, age},它可能给你{result: "..."},照样合法,照样没法用。

再后来是 Function Calling / Tool Calling。这个方案把 Schema 塞进工具声明里,模型在规定格式内输出参数,规范程度确实提高了。但它的设计初衷是“让模型调用工具”,而不是“让模型产出结构化数据”。实际用下来,你会发现平台可能改写你的参数描述,某些字段被忽略,输出格式也被平台内部逻辑牵着走。

Structured Outputs 的核心差异在于:它把整个输出定义为一份你和模型之间的“合约”,合约的语法就是 JSON Schema。模型不是被提示词“说服”去遵守合约,而是在采样的每一步都被约束住,只能产生符合合约的 Token 序列。换句话说,瓶颈从“模型听话程度”转移到了“你 Schema 定义得是否准确”。

1.3 JSON Schema为什么能成为“共同语言”

JSON Schema 本身不绑定任何语言,前后端都在用,jsonschema这种校验库几乎是标配。对大模型来说,Schema 是一段结构清晰的文本,模型天然能理解其中的描述和约束;对工程侧来说,Schema 又是一份可以本地校验、可以版本化、可以拿来做接口契约的元数据。

这套设计最大的价值在于“可验证”。输出结果到底合不合规,不靠人眼看,跑一遍jsonschema.validate()就知道。正因为有了这份共同语言,Structured Outputs 才不只是“更结构化的 JSON Mode”,而是一套可以纳入严格质检流程的正式方案。

2. 核心原理:系统是怎么保证输出一定符合Schema的

2.1 约束解码:让采样器只选择“合法”的Token

理解 Structured Outputs,绕不开“约束解码”这个词。普通大模型生成文本时,每一步会根据当前上下文计算整个词表上所有 Token 的概率,然后按概率采样。模型没有机制保证走到的路径一定是合法的 JSON。

约束解码的思路很直接:在每一步采样之前,额外计算一个“合法掩码”。系统根据当前已经生成的部分内容和目标 Schema,判断接下来哪些 Token 是允许出现的,哪些一旦出现就会让整个 JSON 永远不可能合法,于是把非法 Token 的概率直接置零。随机性仍然存在,但随机范围被限制在“永远不会破坏结构”的空间里。

打个比方,这就像填空题答题卡。考试时不是让考生乱写,而是先把候选字先筛一遍,只留那些能填进格子的选项。模型依然可以选择带引号的字符串、数字、数组,但它没法输出“```json”这种围栏,也没法在逗号位置突然蹦出一段解释。

2.2 从Schema到文法:一次预编译的过程

具体实现上,系统会把 JSON Schema 预先编译成一个上下文无关文法(Context-Free Grammar),或者等价的状态机。输入一份 Schema,比如{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},系统会生成类似这样的合法序列规则:先是一个{,然后出现"name"这个 key,再跟一个冒号,后面接一个 JSON 字符串,最后是可选的空白,再以}结束。

生成过程中,状态机的当前状态决定了下一步可用的 Token 集合。如果刚生成完 key,那下一个 Token 必须是冒号或者空白;如果刚生成完 value,那下一个 Token 必须是逗号、右括号,或者直接结束。这个状态机贯穿整个生成流程,模型只要还在正常生成,就不会跳出约束边界。

部分平台会在这个基础上做“合法前缀强制”,也就是所谓的 prefill:先生成确定的结构前缀,再让模型填充具体内容。这能进一步降低早期 Token 偏离的概率。但无论如何,真正的质量分水岭在于:约束是发生在解码层,还是仅仅发生在后处理层。如果只是在生成完后再拿 Schema 去校验、不行就重试,那不叫 Structured Outputs,叫“碰运气”。

2.3 与Function Calling、提示词约束的差异对比

我把几种方案的差异整理成一张表,方便做技术选型时快速对比:

方案结构保证程度约束发生位置典型问题
提示词要求“输出JSON”几乎不保证无会包 Markdown、加注释、语气词混入
JSON Mode只保证合法 JSON解码层,但无业务结构约束字段缺失、类型漂移
Function Calling结构较规范,但受平台改写影响解码层 + 平台工具层参数描述被忽略,schema 被改写
Structured Outputs基于 Schema 的强约束解码层 + 文法编译有 schema 方言与递归限制

我的观点很明确:如果业务数据最终要进数据库、进下游 API,或者要被 Agent 的下一步消费,Structured Outputs 是目前综合成本最低的选项。它不是“更聪明的提示词”,而是把“结构可靠”这件事从概率问题变成了工程问题。

3. 实操进阶:把Structured Outputs真正接入你的代码

3.1 Schema设计:从业务模型反推JSON结构

先把结论放在前面:Schema 不是写给模型看的说明书,而是写给你的下游代码看的接口契约。所以设计 Schema 的第一步,不是想“模型能不能看懂”,而是想“我的代码希望拿到什么结构”。

我通常按四个步骤来设计。

第一步列字段。把最终需要的字段全部列出来,反推 type。比如要做会议纪要抽取,最终需要会议标题、日期、参会人列表、决策项列表,每个决策项又包含内容、负责人和截止日期。

第二步定约束。哪些字段必填,哪些字段有格式要求。日期字段可以用"pattern": "^\\d{4}-\\d{2}-\\d{2}$"来约束格式;状态字段用enum限定取值范围;数字字段用minimum、maximum做范围检查。

第三步处理可选与空值。不同平台在 strict 模式下对可选字段的处理不一样,最常见的做法是用anyOf配合{"type": "null"}表达可空字段。不要直接省略字段,否则下游代码拿到缺失 key 时,处理逻辑会变得很啰嗦。

第四步关掉多余属性。在对象层级上把additionalProperties设为false。这一步很关键,否则模型可能给你塞进一个 Schema 里根本没定义的imagination字段。我在实测中见过不少次无中生有的 key,关掉之后整个世界清净了。

下面是我常用的一个会议纪要抽取 Schema 模板:

{ "type": "object", "properties": { "meeting_title": {"type": "string"}, "date": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$"}, "attendees": { "type": "array", "items": {"type": "string"} }, "decisions": { "type": "array", "items": { "type": "object", "properties": { "content": {"type": "string"}, "responsible_person": {"type": "string"}, "deadline": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$"} }, "required": ["content", "responsible_person", "deadline"], "additionalProperties": false } } }, "required": ["meeting_title", "date", "attendees", "decisions"], "additionalProperties": false }

这个 Schema 写出来,一眼就能看出来业务模型长什么样。关键字段全部显式列出,嵌套层级控制在两层以内,日期格式用 pattern 约束住。这样的结构,下游直接json.loads之后就能映射到数据类,不需要再来一轮清洗。

3.2 关键代码实现路径(伪代码与平台无关)

不同厂家的 SDK 形态有差异,但核心调用模式大同小异。下面这段伪代码是我在多个平台上反复对照后总结出来的通用路径:

import json from some_sdk import create_client, ResponseFormat client = create_client(api_key="your_key") schema = { # 上面那个会议纪要 Schema } resp = client.chat.create( model="some-model", messages=[ {"role": "system", "content": "你是结构化数据抽取助手,只输出符合给定 JSON Schema 的 JSON。"}, {"role": "user", "content": "把下面这段会议纪要转成结构化数据:\n" + raw_meeting_text} ], response_format=ResponseFormat( type="json_schema", json_schema={ "name": "meeting_minutes", "schema": schema, "strict": True } ) ) raw_content = resp.content data = json.loads(raw_content) # 本地二次校验,永远不要省 import jsonschema jsonschema.validate(data, schema)

有几个细节值得强调。第一,strict参数多数平台要求 Schema 内所有对象属性都进required且additionalProperties为false,这正好符合我在上一节推荐的写法。第二,拿到返回值后我依然会做一次本地校验,原因后面避坑章节会细说。第三,不要把解析逻辑写在主流程里,建议拆成parse_and_validate(content, schema)这个独立函数,方便把异常分支统一处理。

3.3 参数选择与性能调优心得

温度参数在 Structured Outputs 里的重要性下降了,因为结构已经被文法固定住,温度只会影响具体内容的用词。为了稳定输出,我一般把 temperature 设为 0,或者非常接近 0。实测下来,设 0 能让同样的输入产出更可复现的结果,对后续评估和缓存都有好处。

max_tokens 必须留足余量。JSON 输出本身有大量固定字符,key 名、括号、引号都要算 Token。如果输出一个包含 50 个决策项的数组,Token 消耗会明显上升。我的习惯是先按“预估内容长度 × 2 + 200”来粗算 max_tokens,跑一批样本后看截断率,再根据实际值收紧。

系统提示词里不要再写“请一定输出合法 JSON”这类废话。约束由解码层保证,提示词写多了反而稀释注意力。把提示词聚焦在业务语义上,比如字段的口径定义、抽取规则、模糊信息的处理方式,这些才真正影响内容质量。

如果你的业务允许,尽量用maxItems之类的约束给数组封顶。一个 Schema 里描述“最多 20 个决策项”,比在提示词里写“最多输出 20 条”可靠性高得多。我在项目里见过模型一次性输出几百条决策项,直接把 Token 预算烧光的案例,加上maxItems之后,问题立刻缓解。

4. 避坑手册:Schema兼容性、校验失败与重试策略

4.1 平台对JSON Schema的“方言”差异

第一个坑是“同一个 Schema,换个平台就报错”。各大模型平台都声称支持 JSON Schema,但实际支持的子集各不相同。有平台只支持 draft-07 的常见关键字,有的支持 draft-2020-12 的$ref和递归定义,有的对pattern、format直接忽略。

我的处理办法是两层。第一,把所有平台都支持的最小公共子集作为默认写法:type、properties、required、additionalProperties、enum、const、items、minimum/maximum。第二,在联调之前,先把 Schema 发给平台侧做一次“Schema 自检”,而不是直接拿着业务 Schema 去跑生成。绝大多数平台都会在接口层校验 Schema,提前跑一次能省掉无数调试时间。

还有一个常见的隐藏差异:additionalProperties的默认值。JSON Schema 规范里它是 true,也就是说你没写这个字段时,对象里可以出现任意额外属性。但在许多 Strict 模式下,平台会按 false 处理或者要求你必须显式声明。所以我的建议永远是:每个对象都显式写additionalProperties: false,不要依赖默认值。

4.2 校验失败后的正确打开方式

不少人以为用了 Structured Outputs 就万事大吉,拿到响应直接丢给下游。我还是坚持在本地再做一遍json.loads加jsonschema.validate,因为存在几种边缘情况会造成“生成时合法,拿到手不合法”。

第一种是输出截断。模型生成的 Token 数超过了 max_tokens,JSON 会停在半路,变成一个不合法文档。第二种是内容安全过滤。某些平台在内容过滤器触发时,会返回一段提示性文本而不是结构化结果。第三种是网络传输层面的异常,虽然概率极低,但风险不值得赌。

校验失败之后的策略,我排成了优先级。

第一步,把校验错误信息拼装成一段反馈文本,附加回对话上下文里,让模型自己看错误原因并重新生成。比如:

error_msg = "你上次输出的 JSON 不符合 schema,原因如下:" error_msg += str(validation_error) messages.append({"role": "user", "content": error_msg}) retry_resp = client.chat.create(...)

第二步,控制重试次数。两到三次足够,超过还不成功,说明大概率是 Schema 设计有问题,或者输入文本本身不适合抽取,继续重试只是浪费 Token。

第三步,对于高价值场景,加一层人工审核兜底。自动抽取的结果先入库,但标记为“待审核”,让业务人员抽查确认。Structured Outputs 解决的是“格式可靠”,不解决“内容正确”,这两件事一定得分清楚。

4.3 高阶Schema特性:递归、anyOf与谨慎使用oneOf

递归在构建树形结构时很诱人,比如文档目录、思维导图、知识图谱节点。用$ref可以让 Schema 自引用,看起来优雅。但实战中发现,递归深度一旦超过两层,模型开始表现得不知所措,经常提前收尾或者在深层节点里返回空对象。

我的建议是:能不用递归就不用,实在需要树形结构时,限制深度并配合maxItems控制每层的宽度。还可以在 Schema 的 description 里写上“最多展开到第三层,超出部分合并成摘要”,让模型有个具体的行为指令。

anyOf和oneOf的坑更隐蔽。两者都能表达“几个候选结构选一个”,但如果候选结构之间没有明显的区分字段,模型非常容易犯迷糊。我遇到过一个案例:目标是一个对象,可能是“个人用户”也可能是“企业用户”,两者都有 name 字段,但一个需要personal_id,另一个需要business_license。模型在生成时摇摆不定,甚至一次输出两个分支的字段。

解决办法是在每个候选里放一个const区分字段,比如:

{ "type": "object", "oneOf": [ { "properties": { "user_type": {"const": "personal"}, "personal_id": {"type": "string"} }, "required": ["user_type", "personal_id"], "additionalProperties": false }, { "properties": { "user_type": {"const": "enterprise"}, "business_license": {"type": "string"} }, "required": ["user_type", "business_license"], "additionalProperties": false } ] }

这样模型可以先确定user_type这个判别字段,再往对应的分支填内容,歧义大幅降低。凡是涉及多态结构,我的第一反应就是找判别字段,找不到就想办法业务侧强加一个。

5. 真实场景落地:从数据抽取到Agent工作流

5.1 非结构化文本的信息抽取管线

我最先落地 Structured Outputs 的业务是合同关键信息抽取。合同文本来源五花八门:有 PDF 转出的纯文本,有扫描件 OCR 的结果,还有直接在邮件正文里贴的。这些文本格式混乱,但需要抽出的字段高度固定:合同双方、金额、签署日期、有效期限、核心条款列表。

原来的做法是提示词抽取加人工校对,五页合同的人工复核时间在五到十分钟。引入 Structured Outputs 之后,我设计了一个抽取 Schema,把字段、格式、枚举范围全部钉死。模型输出直接落库,由规则引擎做一轮自动初筛,只有金额异常、日期缺失之类的情况才转人工。整个处理耗时压缩到一两分钟,且输出结构永远一致。

这个场景的关键点有两个。第一,抽取任务要控制输入长度,合同太长要分章节抽取再拼接,不要让模型一口气处理上万字。第二,要对抽取结果做常态化的抽样评估,因为模型的抽取质量会随着模型版本升级而波动,定期用一批人工标注过的历史合同跑一遍准确率,能及时发现退化。

5.2 多步Agent与工具调用的结构化衔接

另一个深度使用场景是 Agent 工作流。一个多步骤 Agent 的内部状态如果都用自由文本传递,每个 Agent 的理解都会有偏差,累积起来就是灾难。Structured Outputs 在这里承担的职责是“消息契约”,让每个 Agent 的产出都是可验证的 JSON。

举个例子,我搭过一个模拟项目 X 的采购助手。规划 Agent 的输出必须是这样的结构:当前步骤序号、要执行的动作名称、动作参数对象。执行 Agent 拿到这个 JSON,调用对应的工具,再把工具返回的结构化结果喂给下一个规划步骤。整个循环里,没有任何一步需要靠正则去猜“上一步到底想干嘛”。

更关键的是,工具调用结果有时也是自由文本。比如某个老系统的接口返回纯文本描述,我在这类工具外面套了一层“结果标准化”调用,用 Structured Outputs 把文本转成统一 JSON。这样下游 Agent 看到的永远是整齐的结构,而不是一段需要重新理解的文字。这套做法让整个 Agent 链路的调试成本大幅下降,出了问题也能快速定位是哪一步产生的脏数据。

5.3 效果评估与成本控制指标

引入任何新技术,都要回答“效果怎么评估”和“成本是否可控”这两个问题。我习惯建立一套最小指标集:

指标含义建议基线
parse_success_rate返回内容能被 json.loads 解析的比例99.9% 以上
schema_compliance_rate解析后通过 jsonschema 校验的比例99% 以上
retry_rate首次调用失败需要重试的比例1% 以下
p95_latency单次调用耗时视模型而定,需压测
token_per_task单条任务消耗 Token 数持续监控趋势

成本侧面要看 Token 开销。JSON 输出天然比纯文本输出更费 Token,因为大量 key 名、引号、括号都是固定开销。我测过一个抽取任务,输出 JSON 比同样内容的对话式回答多消耗约 30% 的 Token。这不是 Structured Outputs 的缺点,而是结构化表达的固有成本,但值得你在预算评估时提前算进去。

控制成本的实用手段包括:缩小 Schema 里 description 的长度,避免把过长的帮助文本塞进每个字段;用maxItems限制数组长度;对确定性的抽取任务设置缓存,相同输入直接命中历史结果。这些手段加起来,能省下的成本非常可观。

6. 写在最后:我的一些实操心得

从最早写提示词、到 JSON Mode、再到 Structured Outputs,我最大的感受是:技术让我们离“模型输出可以直接进生产系统”这个目标越来越近,但它并没有免费赠送“免维护”的权利。

我现在的固定习惯有三条。第一,任何结构化输出方案,本地二次校验永远是标配,平台保证的只是解码层的结构合法,我保证的才是业务数据的可用性。第二,Schema 要像代码一样做版本管理,每次修改都要回归跑一遍历史样例,防止新约束把旧场景带崩。第三,永远留一条降级路径:模型服务异常、输出反复校验失败、内容质量不达标时,至少有 fallback 到旧流程或者人工处理的开关,而不是让整条业务链跟着停摆。

如果你正准备在一个新项目里用 Structured Outputs,我的建议是从一个小而关键的场景切入,比如把“用户输入转成查询参数”这类低频但必须稳定的需求先跑通。等 Schema 设计、重试策略、监控指标这套东西都磨合顺了,再往更大范围推广。这样踩坑半径小、验证路径短,后面扩展起来会从容得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 6:53:19

基于粒子群算法的冷热电联供系统运行优化与Matlab实现

做综合能源系统仿真的朋友,对粒子群算法在冷热电联供(CCHP)系统运行优化里的应用应该都不陌生。这个项目用Matlab实现了一套完整的粒子群算法冷热电联合综合能源系统运行优化模型,针对现有CCHP系统在实际负荷波动下运行成本偏高、…

作者头像 李华
网站建设 2026/10/20 6:53:04

Java安全实战:RASP运行时防护原理与接入调优指南

1. RASP到底解决的是哪一类Java安全难题先说个实际场景。前几年某团队上线了一套交易系统,开发阶段该做的安全测试都做了,防火墙、WAF也都部署到位。结果上线第三个月,业务接口被人绕过了权限校验,直接拖走了一批客户数据。事后复…

作者头像 李华
网站建设 2026/10/20 6:53:04

TypeScript类型契约驱动的LLM前端适配层设计

1. 项目概述:一次轻量级但高信息密度的模型适配改造“80行 TypeScript Mods,把 Claude Code 改造成 Deepseek Harness”——这个标题一出现,我就在好几个技术群看到有人截图转发,配文大多是“真就80行?”“Claude Code…

作者头像 李华
网站建设 2026/10/20 6:52:36

“双重防线”下的 Hermes 恢复机制:状态库与 FTS 索引对齐实践

只要跑过 Hermes 状态库这套东西的人,基本都经历过类似的凌晨:节点异常退出,业务请求全打进来,状态库倒是回放到最新进度了,搜索却返回半年前的结果。更抓狂的是,索引文件看起来是完整的,状态记…

作者头像 李华
网站建设 2026/10/20 6:51:44

Agent编排中的失败降级设计:从兜底策略到主干路径

1. 这不是容错设计,是生产环境的生存法则“给每个环节都加失败降级”——这句话听上去像一句过度谨慎的工程师口头禅,甚至有点反直觉。毕竟我们写代码时总被教导要“优雅地处理异常”,而不是提前把整条链路都预设成“随时可能崩”。但我在某跨…

作者头像 李华
网站建设 2026/10/20 6:51:43

内核调度器与实时优先级:机器人低延迟优化实战

做了这么多年机器人系统,我越来越觉得,实时性问题的根子往往不在ROS/ROS2应用代码里,而在更底下那层——任务到底由谁调度、以什么策略调度。之前接过一个移动底盘的活儿,现象很典型:ROS2节点的控制指令在低负载时延稳…

作者头像 李华