☰
大模型输出JSON的硬约束方案:从结构化API到引导解码与兜底修复
2026/10/8 5:14:06 网站建设 项目流程

前两天有个朋友来找我,说他们组在做AI商品审核,需求方反复强调“大模型必须输出JSON”。光是prompt就改了七八版,几乎每版都写着“严禁输出任何解释”“不要包含markdown代码块”“只输出纯JSON”,可线上日志里照样能翻出各种野路子格式——有的返回带```json代码块,有的前面跟一句“好的,以下是您需要的JSON:”,有的干脆把product_title写成title,再把price_range写成“99-199元”这种带单位字符串。他问我:这到底有没有一个“终极解法”?

我当时的回答是:限定大模型输出JSON这件事,真正的关键不是让模型“自觉”,而是要在管线里对输出做硬约束。这篇东西我就把这几年在项目里沉淀下来的方案完整过一遍——底线原因是啥、API层有哪些硬约束、本地推理怎么做引导解码、出错了怎么兜底修复、以及最后一条可以直接照抄的完整链路。

1. 大模型输出JSON的翻车现场:问题远不止“不是JSON”一个

1.1 先说几个我实际收集到的“翻车样本”

做AI后端的人,手机相册里大概率都存着几张这种截图。我把它们整理成几类最常见的问题,你们对照一下自己遇到过几种。

第一种,返回的不是纯JSON,而是被markdown代码块包起来的文本。有些模型还会在代码块前面加一句解释:

好的,以下是您需要的JSON数据: ```json { "product_name": "智能保温杯", "short_intro": "316不锈钢内胆,24小时长效保温", "selling_points": ["保温", "便携", "防漏"], "price_range": "99-199" }
这种输出在用户界面里看着没问题,但你的后端如果直接json.loads,第一行就会报错。 第二种,JSON本身写得不合法。这是重灾区,常见的有键名没加双引号、用了中文冒号或中文逗号、数组尾巴多了一个逗号、字符串用了单引号、括号不闭合等等。我随手攒了一个样本,几乎集齐了所有经典错误: ```text { product_name: "智能保温杯", "short_intro":"316不锈钢内胆,长效保温", "selling_points": ["保温", "便携", "防漏",], 'price_range': "99-199", }

第三种,语法没问题,但schema对不上。你在prompt里定义了字段是product_name,它返回name;你要求short_intro是string,它给了一个数组;你要求的五个字段,它只返回三个。这类问题最隐蔽,因为代码不会崩,但下游解析后拿到空数据,排查反而更费劲。

1.2 这些问题的共同根源:模型在“概率采样”,不是在“执行程序”

很多人遇到上面这些情况,第一反应是“prompt写得还不够狠”,于是继续堆叠“你必须”“你绝对不能”“这是命令”。效果可能有,但天花板很低。原因是:大模型本质上是个自回归的概率模型,它每生成一个token,都是在条件概率分布上采样,而不是在严格按你的指令“执行”。哪怕你已经说了“只输出JSON”,模型也只是把这个要求当成一个高概率偏好在执行,它依然可能觉得“好的,以下是……”这个前缀在训练数据里太常见了,顺手就输出了。

这也是为什么把temperature调到0并不能根治问题。temperature=0只是让模型每次选概率最高的那条路径,可概率最高的那条路径本身就可能是错的。我在项目里实测下来,很多模型即便在temperature=0时,也偶尔会带出解释文本或格式噪声。

想明白这一点,你就不会再执着于“把prompt写到完美”,而是会去想:能不能让模型在结构上无法生成非JSON内容?

2. 为什么“提示词里写上只输出JSON”靠不住:解码机制的真相

2.1 提示词本质上只是“概率偏好”,不是“约束”

自回归生成的过程,说白了就是:模型根据已生成的token序列,计算下一个token的概率分布,然后从这个分布里挑一个token接上去。提示词的作用是改变这个概率分布,让某些token的权重变高。但权重再高,也只是“更可能”,不是“不可能”。

我举个更容易理解的例子。你跟出租车司机说“千万别走错路”,司机大概率能做好,但你没法保证他今天不会走神。大模型也一样,你说的每一句“只输出JSON”,都只是给司机的叮嘱,而不是把车锁死在导航车道上的物理隔离。要真正做到100%,需要在采样层直接掐死非JSON token的可能性。

2.2 软约束和硬约束,是两代解法

我习惯把所有手段分成两类:

软约束类,包括提示词、few-shot示例、temperature、top_p这些,它们都在改变概率分布,能降低翻车率,但无法保证结果。

硬约束类,包括API层的response_format、Structured Outputs、Function Calling,以及本地推理里的guided decoding、grammar约束。这些手段能让模型在结构上“只能”生成合法JSON,或者让非法token根本不会进入候选集。

如果你的系统只是给人看看结果,那软约束可能够用。但如果你的下游是代码、是业务流程、是自动化处理,那格式稳定性必须放到硬约束层来兜,不能赌模型的自觉。

2.3 一个非常重要的推论:把“格式稳定性”从prompt里拿出来

我见过不少团队,花了大量精力在prompt里加各种限定句式,结果模型一换代,之前的prompt全部失效。为什么?因为不同模型对指令的敏感度差别巨大,有的模型你只要说一次“JSON”,它就非常听话;有的模型你把“严格JSON”写在system里,它还是会在输出前面加一句“好的”。

所以我的观点很明确:提示词里要写“只输出JSON”这句话吗?要写,但只是第一道防线。真正的防线在管线里。你需要在调用层、解码层、校验层分别做约束,才能保证生产环境的稳定。

3. 先上个硬约束:结构化输出API与函数调用

3.1 JSON Mode:只能保证“是JSON”,不能保证“是你要的JSON”

现在主流的大模型API都提供了结构化输出能力。OpenAI体系的入门方案是response_format参数,你把type设成json_object,模型就会被引导输出一个可解析的JSON对象。代码长这样:

import os from openai import OpenAI client = OpenAI(api_key=os.environ["API_KEY"]) resp = client.chat.completions.create( model="gpt-4o-mini", response_format={"type": "json_object"}, messages=[ {"role": "system", "content": "你是一个只能输出JSON对象的助手,不要输出解释和markdown代码块。"}, {"role": "user", "content": "请返回智能保温杯的商品信息"}, ], ) raw = resp.choices[0].message.content

注意,system message里最好明确包含“JSON”这个关键词,这是官方文档里反复提醒的细节。用了json_object之后,返回的内容基本能保证json.loads不会崩,但它只保证“是一个JSON”,不保证字段名、字段类型符合你的业务schema。也就是说,模型可能返回{"title": "保温杯"},而你的代码等的是{"product_name": "保温杯"}。

3.2 Structured Outputs / JSON Schema:连结构一起锁死

如果业务里对字段有严格要求,可以用新版的结构化输出,直接给模型一个JSON Schema:

resp = client.chat.completions.create( model="gpt-4o-mini", response_format={ "type": "json_schema", "json_schema": { "name": "product_info", "strict": True, "schema": { "type": "object", "required": ["product_name", "short_intro", "selling_points", "price_range"], "properties": { "product_name": {"type": "string"}, "short_intro": {"type": "string"}, "selling_points": {"type": "array", "items": {"type": "string"}}, "price_range": {"type": "string"} }, "additionalProperties": False } } }, messages=[ {"role": "system", "content": "你是商品信息结构化助手,必须按给定schema输出JSON对象。"}, {"role": "user", "content": "请返回智能保温杯的商品信息"}, ], ) data = json.loads(resp.choices[0].message.content)

strict模式开启后,API层会拒绝不符合schema的输出,required字段缺失、类型错误、额外字段都会被拦下来。这样你在业务侧拿到的,就是一个能对得上字段定义的结构化数据。

但这里有个坑:strict模式下,schema里所有字段不能设置默认值,additionalProperties也建议设成False,否则一些实现会校验不过。我一开始也在这里栽过跟头,因为按传统JSON Schema习惯给字段加了default,结果请求直接被API拒绝。

3.3 Function Calling / Tool Calling:我更偏爱的方式

如果说structured outputs是“让模型返回指定结构的content”,那Function Calling就是“让模型把结构化参数写进一个工具调用槽位里”。我个人在生产环境用得最多的其实是后者,因为它对模型行为的约束更强,而且content里就算有解释文本也没关系,后端只需要解析tool_calls里的arguments。

tools = [ { "type": "function", "function": { "name": "return_product_info", "description": "返回商品信息的结构化JSON", "parameters": { "type": "object", "required": ["product_name", "short_intro", "selling_points", "price_range"], "properties": { "product_name": {"type": "string"}, "short_intro": {"type": "string"}, "selling_points": {"type": "array", "items": {"type": "string"}}, "price_range": {"type": "string"} } } } } ] resp = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": "你是商品信息助手,必须调用工具返回结构化数据。"}, {"role": "user", "content": "请返回智能保温杯的商品信息"}, ], tools=tools, tool_choice={"type": "function", "function": {"name": "return_product_info"}}, ) arguments = resp.choices[0].message.tool_calls[0].function.arguments data = json.loads(arguments)

用tool_choice强制模型必须调用这个函数,就能让模型无法随便在content里东扯西拉。这个方法在国内很多模型上也很稳定,因为这些年各家模型几乎都兼容了function calling协议。

3.4 第三方模型与开源模型的兼容性提醒

换成国产模型或者开源模型的OpenAI兼容端点时,有几个细节要注意。

第一,不是所有模型都完整支持strict json_schema。有些模型对required字段约束不严,可能把可选字段当成不存在,导致你强行按schema解析时出错。这种情况就退回到function calling,或者在后端再加校验。

第二,不同模型的system message敏感度差很多。同一个提示词,在A模型上很听话,在B模型上就可能带出解释。所以换模型时要回归测试一遍格式稳定性。

第三,如果模型上下文很小,而你要的JSON结构又很大,模型可能在生成中途截断,输出残缺JSON。这跟约束无关,是长度问题,后面我们讲兜底时会提到。

4. 自建推理怎么保证精准:JSON Schema引导解码与Grammars

4.1 引导解码:在采样阶段就“封死”非法token

如果你用的是本地部署的开源模型,没法靠云端API的strict来兜底。但本地部署有一个API场景做不到的硬手段,叫constrained decoding,中文一般叫引导解码或受限解码。

它的原理是:在模型做token采样之前,先用一个自动机解析当前已经生成的内容,算出“哪些token在这个位置上是合法的”。比如解析到JSON对象的冒号后面,自动机只允许数字、字符串、布尔值、null和{[这几个合法起始符号,模型只能从这些token里选下一个。这样生成出来的结果,一定是符合语法的。

我用一个类比解释就是:普通生成是让模型在整条马路上自由开,你只能在Prompt里喊一句“别压线”;引导解码是直接在马路两侧装了物理护栏,车根本开不出去。

4.2 vLLM里的guided_json用法

如果你在用vLLM做推理服务,最简单的做法是给SamplingParams传一个guided_decoding参数:

from vllm import LLM, SamplingParams from vllm.sampling_params import GuidedDecodingParams json_schema = { "type": "object", "required": ["product_name", "short_intro", "selling_points", "price_range"], "properties": { "product_name": {"type": "string"}, "short_intro": {"type": "string"}, "selling_points": {"type": "array", "items": {"type": "string"}}, "price_range": {"type": "string"} } } guided_params = GuidedDecodingParams(json_schema=json_schema) sampling_params = SamplingParams(guided_decoding=guided_params) llm = LLM(model="/path/to/model") outputs = llm.generate(["请返回智能保温杯的商品信息"], sampling_params)

如果是通过vLLM的OpenAI兼容接口调用,可以在请求体里传上对应的guidance字段。不同版本参数名会变,上线前先查一下当前版本的文档,别照抄老代码。

4.3 llama.cpp的JSON Schema约束

另一波人喜欢用llama.cpp跑本地量化模型,它原生支持grammar文件和json schema约束:

./llama-cli \ -m /path/to/model.gguf \ -n 512 \ --json-schema '{ "type": "object", "required": ["product_name", "short_intro", "selling_points", "price_range"], "properties": { "product_name": {"type": "string"}, "short_intro": {"type": "string"}, "selling_points": {"type": "array", "items": {"type": "string"}}, "price_range": {"type": "string"} } }' \ -p "请返回智能保温杯的商品信息JSON"

如果你更习惯HuggingFace生态,可以关注outlines这个库,它就是专门做受限解码的,和transformers配合得很好。不过在实际项目里,我遇到更多的还是vLLM和llama.cpp这两个部署方案。

4.4 引导解码的性能与坑位

引导解码在每一步都要做状态解析,吞吐会有一点下降,但对绝大多数业务来说可以接受。真正要留意的是这几个坑:

第一,引导解码只能保证JSON“语法合法”,不能保证“业务语义正确”。模型可能在约束下输出一个完全合法但内容空泛的JSON,比如字段全给空字符串。所以Schema里能加enum、pattern约束就尽量加上。

第二,Schema里required字段不写清楚,模型可能生成一个{ }就结束了,因为空对象在JSON语法上也是合法对象。配合Pydantic校验才能拦住这种情况。

第三,不同版本的vLLM对guided_json的支持实现有差异,参数名和位置会变,升级后要跑一遍回归测试。

5. 兜底工程:JSON修复、校验与重试闭环

5.1 先把“脏输出”清洁成JSON

不管你用了多强的硬约束,我都建议在代码里保留一层“清洗逻辑”。这不代表你不信任模型,而是防御性编程的基本素养。我常用的清洗流程很简单:

  • 判空:如果模型什么都没返回,直接记失败;
  • 去BOM和首尾空白;
  • 去除markdown代码块标记;
  • 定位最外层大括号,截取从第一个{到最后一个}之间的内容;
  • 用json.loads做第一次解析。

这套流程写在工具函数里,所有模型调用都走同一个入口,后面遇到换模型也不用改。

5.2 json_repair:能修一部分,修不了全部

针对前面那种键名没引号、中文冒号、尾巴多逗号的脏JSON,我推荐直接用json_repair这个库。它能把不太离谱的非法JSON修复成合法JSON:

import json from json_repair import repair_json raw = ''' { product_name: "智能保温杯", "short_intro":"316不锈钢内胆,长效保温", "selling_points": ["保温", "便携", "防漏",], 'price_range': "99-199", } ''' good_json = repair_json(raw) data = json.loads(good_json) print(data)

但json_repair不是万能的。它主要修的是语法层问题,像键名写错、类型给错这种语义问题,它也束手无策。所以修复之后,一定要过业务侧的校验。

5.3 Pydantic模型校验与宽容化处理

后端校验我一般用Pydantic,因为它能同时做字段校验和类型转换。拿商品信息举例:

from pydantic import BaseModel, Field class ProductInfo(BaseModel): product_name: str short_intro: str selling_points: list[str] = Field(min_length=3, max_length=5) price_range: str sales_rank: str = Field(pattern="^(高|中|低)$")

定义好模型之后,把解析出来的字典丢给Pydantic解析即可:

try: product = ProductInfo.model_validate(data) except Exception as e: # 记录错误,之后进入重试逻辑 err = str(e)

我在生产环境里的做法是:对关键字段严格校验,对非关键字段可以稍微宽容,比如数字和数字字符串之间互转。但如果你不确定业务能接受哪些容错,宁可失败重试,也不要悄悄改字段值。

5.4 错误反馈重试闭环:给模型一次“改正”的机会

就算有清洗和校验,模型还是可能犯错。所以最后一道保险是重试,但重试不是简单地把原prompt再发一遍,而是要把上一次的报错信息反馈给模型,让它知道错在哪。

def call_json_model(client, messages, retries=2): for attempt in range(retries + 1): resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, response_format={"type": "json_object"}, ) raw = resp.choices[0].message.content try: data = json.loads(extract_json_object(raw)) return ProductInfo.model_validate(data), raw except Exception as e: if attempt >= retries: raise RuntimeError(f"重试后仍失败: {raw}") from e messages = messages + [ {"role": "assistant", "content": raw or "(空输出)"}, {"role": "user", "content": f"你刚才的输出未通过校验,错误:{e}。请重新只输出一个合法JSON对象,不要解释。"} ] raise RuntimeError("unreachable")

注意重试次数要有限制,我一般控制在2到3次。重试太多次既增加成本,又可能出现死循环。重试产生的日志也要完整记录,方便之后判断是模型问题还是prompt问题。

6. 一条完整实战链路:从需求到稳定交付

6.1 明确“需求”:不是让模型自由发挥,而是返回一个确定Schema

前面讲了这么多方案,最终要落到一条能直接抄作业的链路上。我拿一个真实场景举例:做一个商品详情页内容生成器,希望模型输出六个结构化字段,用来直接填充页面。

Schema定义如下:

  • product_name:string
  • short_intro:string
  • selling_points:list[string],数量在3到5个
  • price_range:string,格式像“100-200”
  • sales_rank:string,只能是“高”“中”“低”三选一
  • category:string

Pydantic模型写成这样:

from pydantic import BaseModel, Field class ProductInfoOutput(BaseModel): product_name: str short_intro: str selling_points: list[str] = Field(min_length=3, max_length=5) price_range: str sales_rank: str = Field(pattern="^(高|中|低)$") category: str

6.2 完整调用函数:清洗、解析、校验、重试一锅端

下面这段代码是我目前最常搬上生产的模板,用的是OpenAI兼容格式,换成国内模型就把base_url和model换掉就行:

import re import json from openai import OpenAI from pydantic import ValidationError client = OpenAI(api_key="YOUR_API_KEY", base_url="YOUR_BASE_URL") def extract_json_object(text: str) -> str: if not text: raise ValueError("empty content") text = text.strip() if text.startswith("```"): text = re.sub(r"^```(?:json)?\s*", "", text) text = re.sub(r"\s*```$", "", text) start = text.find("{") end = text.rfind("}") if start == -1 or end == -1 or end <= start: raise ValueError("no JSON object found") return text[start:end + 1] def generate_product_info(user_input: str, max_retries: int = 2): messages = [ {"role": "system", "content": "你是一个严格的数据提取助手,只输出JSON对象,不要输出解释,不要使用markdown代码块。"}, {"role": "user", "content": f"请根据以下内容返回智能保温杯商品信息JSON:{user_input}"}, ] for attempt in range(max_retries + 1): resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, response_format={"type": "json_object"}, temperature=0.0, max_tokens=1024, ) raw = resp.choices[0].message.content try: payload = json.loads(extract_json_object(raw)) product = ProductInfoOutput.model_validate(payload) return product, raw except (ValidationError, ValueError, json.JSONDecodeError) as e: if attempt >= max_retries: raise RuntimeError(f"最终失败,原始输出: {raw}") from e messages = messages + [ {"role": "assistant", "content": raw or "(空输出)"}, {"role": "user", "content": f"你刚才的输出未通过校验,错误:{e}。请重新只输出一个合法JSON对象,字段必须符合要求。"}, ] # never reach

这段代码把前面讲的所有兜底手段都串起来了:先用response_format做第一道硬约束,再用extract_json_object清洗,然后json.loads解析,最后Pydantic做schema校验。任何一步失败都会把错误反馈给模型重试。

6.3 接入API硬约束后,链路怎么组合

如果你已经决定用Function Calling或Structured Outputs,那上面这个模板也要跟着调整。比如把response_format替换成json_schema,或者把messages里加上tools和tool_choice。清洗和重试逻辑保留即可,只是报错概率会更低,重试次数可以相应减少。

我线上最常用的组合是:Function Calling做硬约束 + extract_json_object做清洗 + Pydantic做校验 + 最多重试两次。这套组合下,我最近几个项目的JSON解析成功率都维持在99.9%以上,剩下0.1%基本是模型服务超时或者上下文截断这类基础设施问题。

6.4 日志与监控建议:不放过每一次失败

上线之后,一定要把以下内容记录到日志或监控系统里:

  • 模型原始输出raw;
  • 清洗后文本;
  • 校验错误信息;
  • 重试次数;
  • 最终返回结果。

没有这些日志,遇到用户投诉时你根本没法定位是prompt问题、模型问题还是数据问题。我自己有过一次惨痛教训:某天线上失败率突然从0.1%涨到2%,排查了半天,最后发现是模型服务商悄悄把默认温度从0调高了。就因为我在日志里记录了原始输出和重试信息,才快速定位到是temperature波动导致格式翻车。

7. 实测对比与选型建议

7.1 不同方案的“合法性”经验值

我先给一张基于我个人项目经验的数据表,注意不同模型差异很大,这不是绝对指标,但能给你一个选型方向:

方案JSON语法合法性Schema字段合规性额外复杂度适用场景
纯提示词约束约70%-85%约60%-80%最低原型验证、内部调试
提示词+清洗+重试95%以上约70%-85%低低流量、可接受延迟
JSON Mode接近100%约70%-85%低通用API调用
Structured Outputs接近100%高中对字段有强约束的线上业务
Function Calling接近100%高中配套工具调用、逻辑注入
引导解码/grammar100%语法较高中高本地部署、私有化场景

“JSON语法合法性”和“Schema字段合规性”是两回事,一定要分开看。语法合法只能保证json.loads能过,字段合规才能保证业务逻辑正确。

7.2 按场景拿方案,别一套模板走天下

如果你调用的是云端大模型API,我的优先级是:Function Calling优先,Structured Outputs其次,最后用JSON Mode加后端校验兜底。原因很简单,function calling把参数放在专门的工具调用槽位,模型就算在content里胡说八道,也不影响参数解析,天然适合多轮链路。

如果你是自己部署开源模型,优先用vLLM的guided_json或llama.cpp的json-schema。这两个方案能在解码层保证100%语法合法,剩下的业务校验再交给Pydantic。

如果只是做个Demo或者内部工具,纯提示词加json_repair其实也够用。但我想提醒一句,这组合千万别直接上生产,因为它的失败率完全取决于模型心情。

还有一个容易被忽略的点:重试不是银弹。如果某个模型持续输出乱码,多试几次可能还是乱码。这时候要回头检查输入prompt是否清晰、Schema是否太复杂、模型版本是否适配、上下文是否足够,而不是闷头加重试次数。

7.3 我的体感:硬约束为主,兜底为辅

最后说点个人体感。做AI后端这几年,“限定大模型输出JSON”这个需求听起来很小,但几乎每个项目都会在这里被折磨一阵。真正稳定下来的方案,靠的从来不是某一句狠话,而是把约束下沉到解码层和校验层。

我的标配思路是:能用API硬约束就用API硬约束,本地部署就用引导解码,后端永远保留“清洗+校验+重试”这个兜底三角。模型版本会变、供应商会换、prompt要改,但只要你把这三件事焊死在管线上,晚上睡觉就不会被线上告警吵醒。

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

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

立即咨询