1. 为什么大模型返回的 JSON 总是不“听话”
做 AI 应用开发的朋友一定都经历过这种场景:你精心设计了 prompt,要求模型“只返回 JSON,不要任何多余内容”,结果它偏偏在你辛辛苦苦拿正则去匹配的内容前面加了一句“好的,以下是您需要的 JSON 数据”,或者在里面多了一个逗号,甚至把字段名从user_name悄悄改成了username。你写好的解析逻辑瞬间崩掉,程序直接报错,而你只能对着日志干瞪眼。
这个问题在真实项目中出现的频率,比你想象中高得多。很多人一开始觉得“这不就是 prompt 没写好吗”,但实际上,大模型的本质是一个“下一词预测器”。它并不天然理解什么叫做“严格的 JSON 格式”,它只是在模仿训练数据里常见的文本分布。你以为你在跟一个“会写 JSON 的程序员”对话,实际上你在跟一个“看过海量 JSON 片段的作家”对话——这个作家偶尔会灵感迸发,帮你加个注释,偶尔会手滑,多打一个逗号。
结构化输出的诉求非常明确:让大模型的返回结果在语法层面就是一个合法的 JSON,并且字段、类型、嵌套结构都符合你的预期。这样才能把大模型真正接进业务系统里,作为数据管道的一环去使用。比如做客服工单自动分类、舆情情感分析批量跑批、信息抽取生成结构化数据库记录,这类场景根本没法接受“十次里面有两三次需要人工修数据”的现状。
这篇文章就是来解决这个问题的。我会从原理到实践,把目前业界主流的几种结构化输出方案捋一遍,包括提示词约束、JSON Mode、JSON Schema 约束、函数调用(Function Calling),以及本地模型部署时的受控解码方案。然后给出一套可以在项目里直接复用的代码实现,以及大量我在实际踩坑中总结出来的问题排查经验。无论你是用 OpenAI 的 API,还是接的本地部署的 Ollama、vLLM 上的开源模型,这篇文章都能派上用场。
2. 结构化输出的几种技术路线,到底该怎么选
2.1 提示词约束:成本最低但最不靠谱
先说说最直观的方案:在 system prompt 里写一句“请以 JSON 格式返回结果”。
这种方式确实简单直接,调用接口的时候塞一段精心编写的 prompt,然后寄希望于模型“理解你的良苦用心”。对于现在的 GPT-4 级别或者 Claude 级别的大模型,配合 few-shot 示例,大部分情况下确实能输出像模像样的 JSON。但它的问题在于:这是概率保证,而不是确定性保证。
我在实际项目中遇到过几种典型的翻车现场:
- 模型在 JSON 前面加了 ```json 这样的 Markdown 代码块标记,如果你的解析逻辑没有专门处理,直接
json.loads()必定爆炸。 - 模型在 JSON 外面包了一层说明文字,比如“这是您要的结果:{...}”,虽然核心 JSON 在中间,但你得额外写一套提取逻辑。
- 当模型遇到模糊的指令或者用户输入里包含恶意/矛盾内容时,它可能会“自由发挥”,输出一堆看似合理但其实无法解析的文本。
提示词约束的优势是零成本、零额外依赖、任何模型都能用。但它的劣势非常致命:你无法在工程上做出任何确定性承诺。如果你做的是一个内部工具,返回结果错误可以重试,那这个方法够用。但如果你的下游是自动化流水线、数据库写入、计费系统,那绝对不能把可靠性押在“大模型心情好”上。
2.2 JSON Mode:把格式约束内置到解码阶段
JSON Mode 又叫“JSON 模式”,是各家大模型 API 普遍提供的一种输出模式。它的核心机制是:在模型生成文本的过程中,强制它只能生成符合 JSON 语法的合法字符。OpenAI 在 2023 年底上线了这个功能,后来的国产大模型 API、Ollama 的format: json参数,本质上都是一回事。
JSON Mode 的工作方式我简单解释一下。大模型生成每个 token 的时候,本来是从整个词表里按照概率采样。而 JSON Mode 会用一个受限解码器(constrained decoding)去动态计算“哪些 token 是合法的”,然后把这些合法 token 的概率重归一化,再从中采样。比如 JSON 里的字符串开头是双引号,如果模型输出的上一个 token 是{,那下一个 token 只能是",而不可能是字母“a”,因为 JSON 语法不允许{a这样的内容出现。
这样做的好处是:输出在语法上一定是合法的 JSON(严格来说需要看具体实现,但底层用受限解码的基本可以保证)。不合法的情况被从根源上杜绝了。
不过这里有个很常见的误解:JSON Mode 保证的是“输出是合法 JSON”,它不保证“输出符合你的业务字段定义”。你让模型返回{ "name": "张三" },它给你返回{ "username": "张三" },这在语法上没任何问题,但你的业务代码依然会解析失败。所以 JSON Mode 需要配合详细的 prompt 约束来使用。
2.3 JSON Schema:在格式之上再叠加“语义契约”
如果 JSON Mode 是“保证你能生成 JSON”,那 JSON Schema 约束就是“保证你生成的 JSON 长成我想要的样子”。
JSON Schema 本身是一个标准,用来描述 JSON 数据的结构:哪个字段是必须的、类型是 string 还是 integer、数组最大长度多少、嵌套对象的层级长什么样。OpenAI 的response_format参数里可以直接传一个 JSON Schema,模型会按照这个 Schema 来组织输出内容。
举个例子,你要模型返回一个“人物信息”的结构,Schema 可以这样写:
{ "type": "object", "properties": { "name": { "type": "string" }, "age": { "type": "integer" }, "is_student": { "type": "boolean" }, "tags": { "type": "array", "items": { "type": "string" } } }, "required": ["name", "age", "is_student", "tags"] }当你把这个 Schema 传给 API 之后,模型生成的内容在语法上符合 JSON,在结构上也必须包含name、age、is_student、tags这四个字段,且类型对得上。这实际上就是把你和后端接口对接时用的那套合约,直接套到了大模型输出上面。
这种方案是我目前在生产环境里最推荐的一种。它既保证了格式的合法性,又最大程度保证了字段结构的稳定性。你不再需要写一堆容错代码去兼容“模型今天把 age 返回成了字符串 '28'”这种破事。
2.4 函数调用(Function Calling):从“让模型写 JSON”变成“让模型填参数”
函数调用是另一种非常高明的思路。它不直接要求模型“输出 JSON”,而是要求模型“从用户输入中抽取参数,并以 JSON 的形式填入你定义好的函数签名里”。
这种模式和 JSON Schema 很像,但它在语义上多了一层:函数定义里写了参数名、类型、描述,模型会去理解“用户在说什么”,然后把对应的信息映射到函数参数中。比如你定义了一个create_ticket(title: str, priority: str, description: str)函数,用户在聊天中说“给我建一个工单,标题是打印机坏了,很急,麻烦尽快处理”,模型会自动提取出:
{ "title": "打印机坏了", "priority": "high", "description": "很急,麻烦尽快处理" }然后你的代码拿到这个 JSON,直接调用create_ticket函数就可以了。
在工程实现上,Function Calling 和 JSON Schema 的底层机制其实有大量重合。但对业务开发者来说,函数调用的心智负担更小:你不需要关心“大模型怎么产出一个合法的 JSON”,你只需要定义好自己的业务函数,让模型帮你自动完成参数抽取。
近两年大部分主流 API 都开始弱化“Function Calling”和“JSON Mode”的边界了,OpenAI 的新接口甚至直接在response_format里统一支持,不管是古老的函数调用写法还是 JSON Schema 写法,最终都会被规范成同一种结构化输出形态。
2.5 本地模型的受控解码与结构化生成框架
如果你用的是本地部署的开源模型,比如通过 Ollama、vLLM 调用 Qwen、GLM、Llama 系列模型,那处理逻辑会有一些不同。
Ollama 提供了很简洁的format: "json"参数,开启后效果类似 API 的 JSON Mode。但注意,Ollama 的这个参数只保证输出是合法的 JSON,不会保证字段结构正确,所以你依然需要在 prompt 里把字段名、字段含义、示例写得足够清晰。
vLLM 的生态更完整一些,它支持structured_outputs功能,可以直接在推理的时候传入 JSON Schema,通过底层受限解码库(比如 outlines 或者 xgrammar)来硬性保证输出符合 Schema。这也是本地部署场景下最专业、最靠谱的路径。
如果你是刚入门本地模型部署,我的建议是:先用 Ollama 的format: json把流程跑通,再逐步迁移到 vLLM + JSON Schema 的配置。不要一上来就铺太复杂的技术栈,先让业务闭环运转起来。
3. 手把手实战:从 API 调用到本地部署的完整实现
3.1 写一个健壮的“JSON 格式强制返回”封装函数
不管底层用哪种方案,我建议你都封装一个统一的fetch_json函数,把 API 调用、参数组装、异常处理、重试逻辑全部收敛起来。这样业务代码里不会到处都是json.loads()的 try-except,也不会有“调用方忘了传response_format”这种低级错误。
基于 OpenAI SDK 的封装可以这样写:
import json from openai import OpenAI from typing import Any, Optional client = OpenAI() JSON_SCHEMA = { "type": "object", "properties": { "summary": {"type": "string"}, "sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]}, "keywords": {"type": "array", "items": {"type": "string"}}, }, "required": ["summary", "sentiment", "keywords"], "additionalProperties": False, } def fetch_json( user_prompt: str, system_prompt: str = "", schema: Optional[dict] = None, max_retries: int = 3, ) -> dict[str, Any]: for attempt in range(max_retries): try: response = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": system_prompt or "You are a helpful assistant."}, {"role": "user", "content": user_prompt}, ], response_format={ "type": "json_schema", "json_schema": { "name": "output_schema", "strict": True, "schema": schema or JSON_SCHEMA, }, }, ) raw_content = response.choices[0].message.content return json.loads(raw_content) except json.JSONDecodeError as exc: # 理论上 JSON Mode 不该出现解析错误,但防御性编程永远值得 print(f"[retry {attempt+1}] JSON decode failed: {exc}") except Exception as exc: # 网络问题、API限流、response_format 不被模型支持等 print(f"[retry {attempt+1}] API error: {exc}") raise RuntimeError("Failed to get valid JSON from model.") # 使用示例 if __name__ == "__main__": result = fetch_json( user_prompt="分析这句话的情感:今天天气真好,适合出去走走。", system_prompt="你是一个文本分析助手,请严格按照 JSON Schema 返回结果。", ) print(result)这个函数里有几个关键点,值得单独拎出来讲:
response_format传的是json_schema类型,并且在json_schema里设置了strict: true。严格模式下,API 会强制模型输出完全符合 Schema 的结构,不会有多余字段。如果设置成 false 或者不传 Schema,那就退化成普通的 JSON Mode,只保证合法不保证结构。additionalProperties: false是在 Schema 层面告诉模型:“你只能输出我列出的这些字段,多一个都不行”。这对控制输出非常有用,能有效防止模型自作主张给你塞一个confidence或者explanation字段。- 加重试循环的原因很实际:大模型的 API 偶尔会不稳定,限流、超时、服务端错误都可能发生。一个健壮的封装函数应该能自动扛过这些瞬时故障。但重试次数不宜过多,否则会拖慢整体响应时间,我一般设置 3 次。
3.2 用 Ollama 在本地跑通 JSON 结构化输出
如果你的数据涉及隐私,或者你想省掉 API 调用的费用,本地部署是更好的选择。Ollama 是目前上手门槛最低的本地大模型运行时,一条命令就能把模型跑起来。
安装完 Ollama 之后,先拉取一个模型,以 Qwen2.5 7B 为例:
ollama pull qwen2.5:7b启动模型服务后,用 Python 调 Ollama 的接口实现结构化输出。
import json import requests OLLAMA_URL = "http://localhost:11434/api/generate" payload = { "model": "qwen2.5:7b", "prompt": "分析以下评论的情感倾向,返回情感类别:positive、neutral 或 negative。评论:这家店的服务态度太差了,等了一个小时还没上菜。", "format": "json", "stream": False, "options": { "temperature": 0.2, }, } response = requests.post(OLLAMA_URL, json=payload) data = response.json() print(data["response"])这段代码的关键在format: "json"参数。设置之后,Ollama 会在解码阶段强制约束模型输出 JSON 语法。但请你务必注意:这个参数只是解决了“合法 JSON”的问题,它不保证字段一定是你想要的。你必须在 prompt 里把要求写清楚,最好能给一个 few-shot 示例。
我在本地模型上测过很多次,类似的 prompt 用 7B 小模型,大部分时候输出是:
{"sentiment": "negative"}但偶尔也会出现:
{"sentiment": "negative", "confidence": 0.95}这没违反 JSON 语法,但多出来的confidence字段可能让你的下游代码解析报错。解决方案是你自己在代码里加校验,或者干脆换用 vLLM + JSON Schema 那种硬约束的方案。
3.3 本地方案进阶:vLLM 里用 XGrammar 做 Schema 硬约束
vLLM 是高性能大模型推理框架,是目前本地和私有化部署场景里的主流选择。它集成了多种结构化输出方案,其中 xgrammar 是其中一种效率很高的实现。
注册 Schema 的方式很简单。先把上面的 JSON Schema 定义保存成 Python 字典,然后在初始化LLM引擎时传入--structured-output-backend xgrammar。之后每次调用chat接口时,在guided_json参数里传入 Schema 字典。
from vllm import LLM, SamplingParams llm = LLM( model="Qwen/Qwen2.5-7B-Instruct", structured_output_backend="xgrammar", ) schema = { "type": "object", "properties": { "summary": {"type": "string"}, "sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]}, "keywords": {"type": "array", "items": {"type": "string"}}, }, "required": ["summary", "sentiment", "keywords"], } sampling_params = SamplingParams( temperature=0.2, max_tokens=500, guided_json=schema, ) outputs = llm.chat( messages=[{"role": "user", "content": "分析这句话的情感:产品很好用,但是电池续航不太行。并提取关键词。"}], sampling_params=sampling_params, ) print(outputs[0].outputs[0].text)guided_json这个名字已经说明了一切:模型在生成时会受到“引导”,每一步解码都必须符合你给出的 JSON 结构,能输出的 token 是从“合法集合”里选出来的。所以它不仅能保证 JSON 合法,还能保证枚举值、数组长度、嵌套层级这些细节都符合 Schema 要求。
用 vLLM 做结构化输出的好处非常明显:它对业务方来说是透明的。调用方只需要提供一个 Schema 字典,模型返回的就是稳定的、完全符合预期的 JSON。不需要在 prompt 里写一堆“你必须”、“你只能”这种祈求式的话,也不需要做二次清洗和字段校验。
3.4 一个典型的业务场景:让模型输出关键词并做去重
讲一个我做过的具体案例。当时是在做一个舆情监控系统,需要让大模型从一条新闻文本里提取主题关键词,并且输出为 JSON 数组。初版直接用提示词约束,结果模型输出过["科技","AI"]这种干净结果,也输出过关键词:["科技", "AI"]这种带了无关前缀的结果,甚至偶尔会把关键词用顿号分隔而不是放在数组里。
后来我改成用 OpenAI 的 JSON Schema 约束,定义了如下结构:
{ "type": "object", "properties": { "keywords": { "type": "array", "items": {"type": "string"}, "maxItems": 10 } }, "required": ["keywords"] }然后 prompt 里只需要简单写“从以下文本中提取最重要的 3 到 5 个关键词,按重要性排序”,剩下的全部交给 Schema 去约束。实测下来,连续跑了上千条文本,没有出现过一次解析失败。
这就是“把工程问题交给工程的机制去解决,而不是交给模型的自觉”。你不需要靠言语感化模型,你只需要用机制锁死它的发挥空间。
4. 结构化输出的更多玩法:多步骤链路、批量跑批、级联调用
4.1 用“Schema 链”拆解复杂任务
在实际项目中,结构化输出真正的威力在于它可以串联。大模型应用不只是一次问答,而是一整套流程。
我经常用的一个模式是“多步级联”:第一步,让模型判断用户意图,输出一个分类结果;第二步,根据分类结果走不同的处理分支;第三步,每个分支调用不同的 Schema 来抽取结构化数据。
举个例子:做一个智能工单系统。
- 第一步 Schema:
{ "intent": "bug_report" | "feature_request" | "general_inquiry" } - 第二步,如果是
bug_report,调用 Schema:{ "product": string, "severity": "low"|"medium"|"high"|"critical", "description": string, "steps_to_reproduce": string[] } - 如果是
feature_request,调用另一个 Schema:{ "feature_title": string, "expected_behavior": string, "use_case": string }
这种链路的好处是,每一步的输出都是严格结构化的,下一步的 Prompt 构造可以直接用模板拼字符串,而不需要写复杂的自然语言逻辑判断。整个过程就像写后端接口一样清爽:上一个函数的返回值是下一个函数的入参,中间没有任何“模糊地带”。
4.2 批量跑批时,怎么把失败率压到最低
跑批和在线调用是不一样的。在线调用可以接受偶发的失败然后重试,但批量任务一旦有 1% 的数据解析失败,累积下来就是几千条脏数据,需要人工修,让人非常头大。
我给一个我在生产环境中跑过的参数组合,效果很稳定:
temperature设置为 0 到 0.2 之间。温度越低,输出越确定,幻觉和随机发挥的可能性越小。max_tokens不要设太小。如果模型被强制在输出中途截断,那返回的 JSON 很可能是不完整的。要估算你期望输出的最大长度,然后给足空间。- 开启
response_format的 JSON Schema 模式(API 场景),或者用 vLLM 的guided_json(本地场景)。 - 在跑批脚本外层加一层“解析失败重试”逻辑:如果 JSON 解析失败,用上次的 response 做 few-shot 示例再请求一次。很多时候第二次就会纠正过来。
- 准备一个兜底逻辑:如果重试了三次还是失败,把原始输入和原始输出写入一个专门的“无法解析队列”文件,等批量任务结束后统一人工查看。
我的经验是,做到前面两点,失败率就能从“平均每百条失败几条”降到“每万条才偶尔失败一次”。剩下的极少数失败,靠兜底队列人工捞出来修一下就好。
4.3 让大模型先写 JSON 再自我校验,能大幅减少低级错误
还有一个比较反常识的技巧:让模型先把自己要输出的 JSON 写出来,然后让它再读一遍,检查是否存在错误。听起来像“让小偷看自己偷的东西是不是赃物”,但在实践中确实有效。
原因在于大模型的自回归生成过程是“只看前面的 token,不看后面的 token”。如果它在一个很长的 JSON 里前面写了一个字段名,后面写到一半忘了,是完全没有“回头看”的能力的。但如果你让它再审视一遍,相当于给了它一次纠正的机会。
具体做法是在 prompt 里加一句话,要求模型“分两步回答,先输出 JSON 的草稿,再检查草稿,输出最终 JSON”。此时让 Schema 约束最后的输出字段,通常能得到质量更高的结果。唯一的代价是响应时间变长一些。
这个方法不适合所有场景。如果你对延迟要求极高,在线实时对话场景就不要用了。但在跑批场景里,多花一点时间换取更高的数据质量,非常值得。
5. 常见问题与排查技巧实录
5.1 高频报错速查表
我把这些年做结构化输出时遇到的高频问题整理成了一张表,方便你快速定位:
| 问题现象 | 可能原因 | 排查思路 / 解决方案 |
|---|---|---|
json.loads()抛JSONDecodeError | 模型输出带了 Markdown 代码块标记或前后缀文字 | 检查response_format是否正确传入;如果是本地模型,确认有没有开format: json;对现有输出先做清洗再解析 |
| 返回了 JSON 但不缺字段 | JSON Mode 只约束语法,不约束结构 | 改用 JSON Schema 模式,并在 Schema 里把required字段写全 |
| Schema 定义没问题但模型经常违反 | 使用的小模型能力不足/不理解 Schema | 换更大的模型(推荐 7B 以上)或调整 Prompt 措辞,并给 few-shot 示例 |
| 枚举值总是超出预设范围 | enum约束没有被传递到推理引擎 | 检查strict: true是否设置;vLLM 场景检查guided_json是否传对参数 |
max_tokens截断导致 JSON 不完整 | 输出长度预估不足 | 增大max_tokens,或调整 Schema 里数组的maxItems |
| 本地模型的 JSON 含有中文乱码或不配对的引号 | 编码问题或模型分词器问题 | 确认模型加载时trust_remote_code参数,并尝试换用 Qwen 等对中文更友好的模型 |
5.2 一个让我印象深刻的“玄学”问题
有段时间,我在某国产大模型 API 上启用 JSON Mode 后,发现整体成功率反而下降了。排查了很久,最终发现问题出在system_prompt上。
我原来 system prompt 里写了一句话:“如果无法确定,请返回空值”。模型在 JSON Mode 下遇到这种情况,会把字段值输出成""或者直接不返回该字段,导致下游处理空字符串的逻辑爆炸。后来我把 system prompt 简化成“请严格按照用户要求提取信息,无法确定时输出null”,并把这个也放进了 Schema:{"type": ["string", "null"]}。问题立刻消失。
这个案例给我的经验是:在结构化输出场景下,prompt 里的自然语言指令要尽量少、尽量清晰,能用 Schema 表达的约束就不要用文字表达。你写得越复杂,模型的自由空间越大,出幺蛾子的概率越高。
5.3 模型能力的边界:不是所有模型都适合结构化输出
最后说一个很多人不愿意面对的事实:结构化输出的质量上限,和模型本身的能力强相关。
3B 以下的小模型,即使在 JSON Mode 或 Schema 约束下,也经常会在复杂嵌套结构上出错。模型生成的 token 虽然会被受限解码器约束“语法上必须合法”,但模型内部其实并不理解“这个字段应该填什么内容”,它只是在蒙一个看起来合理的值。如果你的 Schema 比较复杂(比如嵌套三层对象,或者数组里套对象),小模型很可能会“语法全对、内容全错”。
如果你必须在端侧或小模型上做结构化输出,我的建议是:
- 尽量把 Schema 拍扁,不要让嵌套层次太深。
- 用非常具体的字段名和字段描述,帮助模型理解每个字段的语义。
- 每次只做一件事,比如一次只抽取“实体”或者一次只做“分类”,别让模型同时干太多活。
- 如果效果还是不达标,老老实实把 7B 量化模型放到推理服务上,用吞吐换质量。
我自己在实际项目里的体会是:结构化输出这件事,越早接入越好。如果你现在还在用纯 prompt 约束来对接大模型输出,我建议立刻把它升级成 JSON Schema + 受限解码的方案。刚开始改造成本确实有一些,但改完之后,你会发现下游解析逻辑瞬间变得干净无比,再也不用写一堆正则和大括号匹配的脏代码了。这也正是大模型工程化的从“能用”到“好用”的关键一步。
最后再分享一个小技巧:无论你用哪种方案,一定要在解析层保留一条“兜底路径”——先用严格 Schema 解析,解析失败后用宽松 JSON 解析(比如提取代码块中的 JSON),再失败就进入人工队列。这个兜底逻辑作为保险丝,能让你在大胆接入模型能力的同时,不至于被偶发异常拖垮整个系统。