做后端接入大模型的同学,十有八九都遇到过这种场景:你让模型按 JSON 返回一个配置,它答得很好,却在开头加一段“好的,我来帮你生成”,结尾再补一句“希望这个回答对你有帮助”。代码里json.loads()直接抛异常,日志刷红,你盯着那行输出愣了半天:明明是同一个 Prompt,上周还能稳定返回,今天怎么就开始自由发挥了?
这篇内容就是围绕“让大模型稳定返回 JSON”这件事写的。我会把这几年实际接大模型接口时踩过的坑、验证过的方案、常用的降级策略都摊开讲,偏实战,不搞花架子。适合正在做 AI 应用开发、需要把大模型接入业务系统的同学参考,尤其是那些被模型“自由格式文本”折磨过的后端和算法工程师。
1. 为什么大模型写不对 JSON?根子出在训练目标上
先说一个很容易被忽略的事实:大模型本质上是一个“按概率预测下一个 Token”的文本生成器,不是数据库,也不是规则引擎。它擅长的是生成“看起来像 JSON 的文本”,而不是“严格符合 JSON 规范的文本”。
这就导致一个典型现象:模型在生成 JSON 时,会在它认为合适的地方插入自然语言。比如你问它“返回北京今天的天气”,它可能输出:
{"city": "北京", "weather": "晴"}多数时候没问题,但只要你稍微改变 Prompt 的措辞,或者换一个模型版本,它就可能在 JSON 前后加上解释性文字,甚至把 key 的引号漏掉。原因很简单:训练数据里,JSON 和自然语言是混在一起的,模型只是在模仿人类写代码时的习惯,而人类写代码时本来就会加注释、加说明。
1.1 概率采样放大不确定性
除了训练目标,还有一个关键因素:解码策略。我们调用模型时通常会设置temperature,temperature 越高,模型越倾向于“发散”,生成的 Token 分布越均匀,JSON 的稳定性直线下降。理论上 temperature 设为 0 时模型输出最确定,但注意,temperature=0并不是数学上的“完全确定”,只是采样时选了概率最高的 Token。很多大模型底层还带了 top_p、top_k 之类的采样参数,哪一个设置不当都可能影响输出质量。
我做过一个简单实验:让同一个模型生成同一个 JSON 结构,分别用temperature=0.3和temperature=0.9各跑 100 次。结果0.9的情况下,将近 20% 的输出无法被标准 JSON 解析器直接解析;而0.3时,失败率直接降到 3% 以内。这说明采样参数对结构化输出的影响,远比很多人想象的大。
1.2 输出长度和注意力偏移
还有一个容易被忽视的点:输出 Token 长度。如果你把max_tokens设置得太短,模型在生成 JSON 的末尾时可能被硬生生截断,导致括号没有闭合。而如果max_tokens太长,一些模型在生成完 JSON 后不会立刻停止,反而会继续输出“祝你使用愉快”之类的文本,污染整个输出。
这就引出一个核心矛盾:你无法用纯 Prompt 完全控制模型行为,只能通过工程手段去约束生成过程。这也是结构化输出(Structured Output)技术存在的意义。
2. 技术选型:先搞清四种“让模型返回 JSON”的实现路径
别一上来就写 Prompt。你需要先了解市面上常见的几种方案,再根据模型能力和业务场景选合适的。我按约束能力从弱到强排列一下。
| 方案 | 原理 | 约束能力 | 模型要求 | 推荐场景 |
|---|---|---|---|---|
| Prompt 强约束 | 在指令中反复强调输出格式 | 弱,全凭模型自觉 | 无 | 快速原型验证 |
| JSON Mode(response_format) | 框架层提示模型生成合法 JSON | 中,不校验内容 | OpenAI 兼容接口和部分国产模型 | 一般业务接口 |
| 工具调用 / Function Calling | 让模型生成结构化参数供后端调用 | 较强 | 模型需支持 tool calling | 智能体、路由分发 |
| 约束解码 / Structured Output | 通过 Grammar 或 Schema 约束每一步 Token | 强,能保证语法合法 | 需专门支持,如 vLLM、部分云厂商 | 对稳定性要求极高的生产链路 |
2.1 Prompt 强约束:最简单,也最容易翻车
很多人一开始只用 Prompt,比如:
请返回 JSON,格式如下:{"name": "", "age": 0} 不要输出任何其他内容。在小模型上,这种方式的失败率非常高。因为模型没有“保证”机制,它只是在模仿一个听话的人,而不是真被关在 JSON 的笼子里。我还遇到过一种情况:同一个 Prompt 在 A 模型上很稳定,换到 B 模型上就频繁失败,排查半天发现是 B 模型的 Chat Template 里带了额外的系统提示,把用户指令挤到次要位置了。
2.2 JSON Mode:模型层面的“语法提醒”
目前很多模型推理框架都实现了response_format={"type": "json_object"}这种参数,它的原理是:在模型生成时注入一个隐式的格式化提示,并且在采样过程中更容易生成合法的 JSON。你可以把它理解为“给模型戴了一个语法提醒器”,但它只保证“输出看起来是 JSON”,不保证 JSON 符合你定义的 Schema,也不保证 JSON 里没有多余字段。
实测下来,JSON Mode 对成功率提升明显。比如 DeepSeek、Qwen 等在各自的 API 中都支持类似模式,使用 OpenAI 兼容协议时可以直接传入。但它也有坑:某些框架要求你在 Prompt 里必须出现 “json” 这个词,否则会报错;某些模型的 JSON Mode 对temperature有建议范围,太高照样失效。
2.3 Function Calling / Tool Calling:把结构交给参数补齐
Function Calling 是当前接入业务系统比较稳的一种方式。它的大致过程是:你把函数定义(包括参数名、类型、描述)传给模型,模型在回答时优先输出一个匹配函数签名的 JSON 结构,而不是自由文本。你可以把这个过程理解成“让模型去填一张你提前画好的表”,它的自由度被限制在函数定义内。
这样做的好处有两个:一是模型不需要先想“我要怎么描述”,只需要按参数名填值;二是你可以通过定义多个函数,让模型自行选择调用哪个,从而完成意图路由。比如一个客服机器人,可以定义query_order、create_ticket、transfer_human三个函数,模型根据用户问题选择调用,后端只要解析arguments字段即可。
不过使用 Function Calling 时要特别注意:在 OpenAI 兼容服务里,函数调用的返回格式与普通 chat 不同,内容是tool_calls字段里的 JSON 字符串,而不是content字段。很多初学者踩坑,就是因为在服务端仍然去读content内容,结果得到null。
2.4 Structured Output:最硬核的约束
再进一步,是当前最“硬”的方案:结构化输出,框架层直接约束输出 Grammar 或 JSON Schema。vLLM 提供了guided_json/guided_grammar参数,OpenAI 也推出了json_schema类型的response_format。它使用约束解码,是的,不只是提示模型,更是在采样时限制每个 Token 的候选集合,让模型在概率最高的合法轨道里生成。
这种方案基本能保证输出可以被解析成功,让不可控问题变得可控,但缺点是概念上可能加大推理耗时,部分框架需要额外分词和编译 automaton,可能导致单次生成延迟增加。很多生产系统对实时性要求高,因此不一定每一层都使用强制解码,而是只在关键节点用。
3. 实操:基于 OpenAI 兼容接口的 JSON 稳定输出配置
下面进入实操。我不会绑定具体某一家厂商,而是以通用的 OpenAI 兼容接口为例,你可以把地址替换成自己公司部署的服务或云厂商 API。这里假设你已经有一个可用的 Chat 模型接口,并且支持response_format参数。
3.1 最简实现:JSON Mode
先看一段 Python 示例:
import json from openai import OpenAI client = OpenAI( base_url="http://your-model-endpoint/v1", api_key="your-api-key" ) resp = client.chat.completions.create( model="your-chat-model", messages=[ { "role": "system", "content": "你是一个信息抽取助手,只输出 JSON。" }, { "role": "user", "content": "从下面文本中抽取人物姓名、年龄和职业。文本:张三今年28岁,是一名后端工程师。" } ], response_format={"type": "json_object"}, temperature=0, max_tokens=1000, ) content = resp.choices[0].message.content print(content)如果你运气好,输出可能是:
{"姓名": "张三", "年龄": 28, "职业": "后端工程师"}注意几点:
messages里我加了system指令,明确要求“只输出 JSON”。虽然 JSON Mode 本身有内置约束,但配合指令更稳。temperature=0降低随机性。- 有些服务要求 Prompt 中必须出现 “json” 字样,所以我特意在 system 里写了“只输出 JSON”,否则某些网关会直接拒绝请求。
3.2 用 Pydantic 定义 Schema,再转给模型
你可能会问:“我就想要一个固定结构的 JSON,有没有办法让模型别乱加字段?”答案是使用结构化输出,即json_schema。
在 OpenAI 新版本 SDK 中,可以这样:
from openai import OpenAI from pydantic import BaseModel client = OpenAI(base_url="http://your-model-endpoint/v1", api_key="your-api-key") class PersonInfo(BaseModel): name: str age: int occupation: str resp = client.beta.chat.completions.parse( model="your-chat-model", messages=[ {"role": "user", "content": "张三今年28岁,是一名后端工程师。"} ], response_format=PersonInfo, ) person = resp.choices[0].message.parsed print(person)这里 SDK 会帮你把 Pydantic 模型转成 JSON Schema,再传给模型,并自动解析返回内容。不过要提醒一下,不是所有兼容接口都完整支持beta.chat.completions.parse这种调用方式,很多开源框架只支持response_format={"type": "json_schema", "json_schema": {...}}。所以最通用的姿势是先定义 JSON Schema 字典,然后按标准参数传。
3.3 约束解码:借助 vLLM 让语法级稳定
如果你的模型是自己部署的,且使用的是 vLLM 推理框架,可以用guided_json实现语法级约束。示例:
from vllm import LLM, SamplingParams llm = LLM(model="your-model", gpu_memory_utilization=0.8) json_schema = { "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, "occupation": {"type": "string"} }, "required": ["name", "age", "occupation"] } prompt = "从文本中抽取人物信息:张三今年28岁,是一名后端工程师。" outputs = llm.generate( [prompt], SamplingParams( temperature=0, max_tokens=256, guided_json=json_schema, ), ) print(outputs[0].outputs[0].text)vLLM 会在解码阶段用有限状态机约束输出 Token 序列,确保最终一定是合法 JSON Schema 结构。实测小模型(如 7B 级别)也能做到几乎 100% 的语法正确率。但注意,它约束的是“语法”,不约束“事实”。比如age字段虽然是整数,但模型可能从原文里抽错年龄,规则无法拦截这种语义错误,需要业务层校验。
3.4 LangChain / LlamaIndex 的快捷封装
如果你已经在用 LangChain,可以这样结构化处理:
from langchain_openai import ChatOpenAI from langchain_core.pydantic_v1 import BaseModel from langchain_core.output_parsers import PydanticOutputParser class PersonInfo(BaseModel): name: str age: int occupation: str model = ChatOpenAI(model="your-chat-model", temperature=0) parser = PydanticOutputParser(pydantic_object=PersonInfo) prompt = PromptTemplate( template="抽取人物信息。\n{format_instructions}\n{input}", input_variables=["input"], partial_variables={"format_instructions": parser.get_format_instructions()}, ) chain = prompt | model | parser result = chain.invoke({"input": "张三今年28岁,是一名后端工程师。"}) print(result)LangChain 的PydanticOutputParser会在 Prompt 里塞一段“输出必须是 JSON 对象”的格式化指令,并尝试自动修复解析过程中的小错误。对于不支持原生 JSON Mode 的模型,这是一个有效的“软约束”手段。
4. 核心细节:写 Schema 时最容易忽略的 5 个问题
结构化输出看起来省事,但坑往往都藏在 Schema 设计里。
4.1 不要放过 additionalProperties
在 JSON Schema 中,有一个常用字段:
{ "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"} }, "required": ["name", "age"], "additionalProperties": false }additionalProperties: false的意思是:禁止输出 properties 之外的字段。在约束解码中,它能有效避免模型“超纲发挥”,塞进来一个你没定义过的 key。我见过很多线上问题,就是模型自行加了一个summary字段,而下游入库代码是按固定字段名解析的,报警报了一整屏。
但注意,某些模型的 JSON Mode 并不完全尊重additionalProperties,它可能只把这当成一个普通 JSON 字段提示。所以在拿到模型返回后,后端仍要自己校验,不能指望模型端完全严格。
4.2 枚举值必须写 enum,别写在描述里
假设你要让模型返回情感分析结果,只有positive、negative、neutral三种取值,最稳的做法是:
{ "type": "object", "properties": { "sentiment": { "type": "string", "enum": ["positive", "negative", "neutral"] } }, "required": ["sentiment"] }如果你只在字段描述里写“可选值为正面、负面或中性”,模型仍然可能输出中文“正面”,也可能输出英文“positive”,甚至输出一个“unknown”。enum 是语法级硬约束,description 是语义级软指导,两者缺一不可。
4.3 字段命名也要小心
模型很容易受你的字段名影响。比如:
{"personName": "张三", "personAge": 28}输出合法,因为模型能理解你的意图。但如果字段名定义得模糊,比如data、info、value,模型就可能在多个候选语义之间摇摆,导致字段值质量下降。我在实际业务中用过一个极端的例子:字段名叫str,模型直接输出了一个 JSON 对象而不是字符串——因为在很多语言里str有特殊含义,模型不知道你要什么。字段名要尽量使用自然语言中语义明确的英文,避免缩写和过短单词。
4.4 嵌套结构越深,越依赖模型推理能力
一些应用喜欢把输出结构设计得非常深:
{ "result": { "data": { "list": [ {"id": 1, "name": "x"} ] } } }诚实地讲,这种嵌套结构在约束解码下一般能被正确生成,但如果是纯 JSON Mode,fail 的概率会显著提高。因为模型在生成长文本时,很容易忘掉前面的括号层级。一个经验法则是:能展平的数据不要嵌套,能让模型“少记状态”就少记状态。把复杂结构拆成多个独立任务,往往比一个 Prompt 指望模型一次输出到位更稳。
4.5 数组边界别太“开放”
如果你希望模型返回一个数组,并且这个数组的长度是不确定的,需要给它一个边界提示,否则它可能从 1 条一路写到 50 条,把 Token 撑爆。做法是在 Prompt 或 Schema 描述里明确上限,比如“最多返回 5 个元素”。约束解码只能约束语法,无法约束模型的“表达欲”,这类业务上限必须由你定义。
5. 实战链路:从生成到入库的完整解析与校验
有了稳定的输出格式,下一步要解决的是“如何优雅地把模型输出接入业务代码”。
5.1 先做 JSON 解析,再做字段校验
很多人写代码是这样的:
data = json.loads(resp.choices[0].message.content) save_to_db(data)代码短,但问题很多。模型的 JSON 虽然能解析,不代表里面的内容是正确的。我之前遇到一个生产事故:模型返回的price字段是一个浮点数,但后端数据库表定义成了 int,导致入库后所有 price 的精度直接丢失,财务对账怎么都对不上。后来我在解析层加了两道卡口。
第一道,语法解析:
raw = resp.choices[0].message.content try: data = json.loads(raw) except json.JSONDecodeError as e: # 记录原始输出和报错位置 log_error("json_parse_failed", raw=raw, error=str(e)) raise第二道,字段类型校验。推荐用pydantic:
from pydantic import BaseModel, ValidationError class Product(BaseModel): id: int name: str price: float try: product = Product.model_validate(data) except ValidationError as e: log_error("schema_validation_failed", raw=data, error=e.errors()) raise只做第一步,你只能判断这是不是“一段 JSON”;做了第二步,你才能判断这是不是“你要的业务对象”。真实业务中,第二道卡口才是让系统稳定运转的保险。
5.2 让模型同时返回置信度
判断模型是否成功往往比返回值本身更困难。最实际的做法是:让模型对关键判断输出一个置信度字段。
例如实体抽取任务:
{ "entities": [ {"text": "张三", "type": "person", "confidence": 0.98} ] }后端拿到confidence之后,可以设置一个阈值,低于阈值的记录进入人工复审队列,而不是直接落库。这样既利用了模型能力,又把“模型可能出错”的风险环节交给了人来兜底。
业务接入时,如果你只需要抽取一个字段,置信度阈值可以设低一些;但像医疗、金融这类高风险场景,阈值建议设到 0.85 以上,宁可召回不足,也不能让错误结果进入下游。
5.3 对数组输出做去重与排序
模型在一次输出多个实体时,偶尔会把同一个实体拆分到两个元素里。比如:
{"name": "张", "occupation": "程序员"} {"name": "张三", "occupation": "工程师"}这其实是同一句话里的同一个实体。简单方案是后处理去重:先用规则把同类实体归一化,再比较相似度。很多结构化输出教程不会讲这些脏活,但生产环境里真正消耗时间的往往是这类数据质量问题。
6. 常见报错与排查技巧实录
这一节我把实际中遇到最多的报错、现象和定位方法整理成一个速查表。如果你在接入过程中遇到奇葩问题,先照着这个表排查一遍。
6.1 报错速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
返回内容无法json.loads | 模型输出包含 Markdown 代码块标记或解释文字 | 增加结构化约束,或后端先剥离 markdown block |
| 提示缺少字段,实际模型输出了空字符串 | Schema required 写错,或模型没理解字段含义 | 检查 JSON Schema required,给字段加描述并给出示例 |
| 返回字段名不一致(有时候驼峰,有时候下划线) | 模型在模仿外部代码风格 | 在 Schema 中固定 key 名,传入json_schema明确类型 |
多个 key 并存(同时有name和姓名) | 模型沿用训练数据里的双语习惯 | 后端做字段白名单筛选,不认识的 key 直接丢弃 |
| JSON 闭括号正常,但数组多了一个元素 | 模型输出不收敛,Token 预算不够 | 调低 max_tokens,使用 enum 限定枚举值 |
Function Call 返回的content是null | 你读取了解析错误的普通文本内容 | 改为读取tool_calls[0].function.arguments |
| 大模型回复“JSON 格式示范”而非数据 | Prompt 里出现“格式是什么”的问法 | 引导直接输出,而不是示例 |
6.2 剥离 Markdown 代码块的防御逻辑
即使加了 JSON Mode,有时候本地小模型还是会输出:
好的,这是结果: ```json {"name": "张三"}这时候后端最好做一段剥壳逻辑: ```python import re def extract_json_str(content: str) -> str: # 先把 ```json ... ``` 提取出来 pattern = r"```(?:json)?\s*([\s\S]*?)```" match = re.search(pattern, content) if match: return match.group(1).strip() # 否则尝试从第一个 { 起到最后一个 } 截取 start = content.find("{") end = content.rfind("}") + 1 if start >= 0 and end > start: return content[start:end] raise ValueError("cannot extract json from content")但剥壳只能救一时,不是正道。对于生产环境,我会在前面再加一层:若模型支持response_format,直接把格式锁死;若模型不支持,在提示里要求“不要使用 markdown 代码块”。
6.3 关于双重编码的坑
我自己遇到过一种很隐蔽的情况:模型输出的content是:
"{\"name\": \"张三\"}"看起来是 JSON 字符串,但其实是外层还戴了一层引号。直接json.loads会报错,但json.loads(content, strict=False)也救不了。正确做法是先转义处理,或直接用两步解析:
content = '{"name": "张三"}' if content.startswith('"') and content.endswith('"'): content = json.loads(content) # 去掉外层字符串 data = json.loads(content)这类问题多出现在模型引用已有 JSON 字符串而不是重新生成对象时。后端解析层加一个自动检测就能避免。
7. 兜底策略:结构化输出不是 100%,必须设计熔断机制
最后聊一个我特别看重的话题:容错和兜底。很多人部署了大模型接口,就觉得模型输出一定能按格式返回,然后出了事故才开始搞重试。实际上,可靠系统必须把“模型可能失败”当作默认前提来设计。
7.1 重试:两三次即可,别无限
对解析失败的情况,简单且有效的方法是重试。但重试必须带条件:
- 如果模型本身返回了网络层错误(超时、5xx),可以重试 2 到 3 次。
- 如果模型成功返回但 JSON 解析失败,重试时建议修改 Prompt,加入一句“请严格控制 JSON,不要把内容放在代码块里”。
- 如果重试后仍失败,立即降级到人工处理或返回默认值,不要再徒劳重试,否则用户端延迟会爆炸。
7.2 降级:预设默认对象
举个例子,一个智能客服需要判断用户意图:查询订单、退款、转人工。如果模型输出解析失败,系统不应该直接报 500,而应该返回一个默认意图,比如transfer_human。用户至少还有人工兜底,不会觉得系统“完全坏了”。
代码结构上可以这样设计:
def parse_intent(text: str) -> Intent: try: resp = call_model_with_json_mode(text) data = validate_and_parse(resp) return Intent(data["intent"]) except Exception as e: logger.warning("intent parse failed, fallback to human", exc_info=e) return Intent("transfer_human")7.3 用规则做一层“硬约束”
对某些关键字段,即使模型返回了,我们也可以在自己代码里强制再过滤一遍。例如:
ALLOWED_TYPES = {"order", "refund", "human"} def sanitize_type(value: str) -> str: if value not in ALLOWED_TYPES: return "human" return value这个规则不依赖模型,所以永远可以执行。你可以把模型当“候选生成器”,规则代码当“最终把关者”。两层叠加,系统整体可靠性才会接近 100%。
8. 给你的一套关键建议
如果现在有人问我“大模型稳定输出 JSON 最快上手的路径是什么”,我会建议按顺序做这几件事:
- 先把你定义好的 JSON Schema 写清楚,字段语义别模糊,能加 enum 就加 enum。
- 首选带约束解码或 Function Calling 的模型接口,不要单纯依赖 Prompt。
- 后端解析时必须叠加 Pydantic/JsonSchema 校验,不能默认模型输出可信。
- 加一层 Markdown 剥离、双重 JSON 转义的处理。
- 接入日志里记录完整原始输出,出问题后能快速回溯。
- 对关键业务链路设计默认降级值或者人工兜底。
我在多个项目里用这套思路之后,模型返回 JSON 的失败率从最初的 10% 上下降到了 0.1% 以内。当然,就算降到 0.1%,生产系统依然要按“可能出现”去设计。后面如果再遇到模型输出不稳定,先别急着换大模型,把你的 Schema、采样参数、解析链路和降级策略都过一遍,多数问题都可以在不动模型的前提下解决。