尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

大模型输出JSON的硬约束方案:从结构化API到引导解码与兜底修复

大模型输出JSON的硬约束方案:从结构化API到引导解码与兜底修复 前两天有个朋友来找我说他们组在做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_intro316不锈钢内胆长效保温, selling_points: [保温, 便携, 防漏,], price_range: 99-199, }第三种语法没问题但schema对不上。你在prompt里定义了字段是product_name它返回name你要求short_intro是string它给了一个数组你要求的五个字段它只返回三个。这类问题最隐蔽因为代码不会崩但下游解析后拿到空数据排查反而更费劲。1.2 这些问题的共同根源模型在“概率采样”不是在“执行程序”很多人遇到上面这些情况第一反应是“prompt写得还不够狠”于是继续堆叠“你必须”“你绝对不能”“这是命令”。效果可能有但天花板很低。原因是大模型本质上是个自回归的概率模型它每生成一个token都是在条件概率分布上采样而不是在严格按你的指令“执行”。哪怕你已经说了“只输出JSON”模型也只是把这个要求当成一个高概率偏好在执行它依然可能觉得“好的以下是……”这个前缀在训练数据里太常见了顺手就输出了。这也是为什么把temperature调到0并不能根治问题。temperature0只是让模型每次选概率最高的那条路径可概率最高的那条路径本身就可能是错的。我在项目里实测下来很多模型即便在temperature0时也偶尔会带出解释文本或格式噪声。想明白这一点你就不会再执着于“把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_keyos.environ[API_KEY]) resp client.chat.completions.create( modelgpt-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 Schemaresp client.chat.completions.create( modelgpt-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( modelgpt-4o-mini, messages[ {role: system, content: 你是商品信息助手必须调用工具返回结构化数据。}, {role: user, content: 请返回智能保温杯的商品信息}, ], toolstools, 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引导解码与Grammars4.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_schemajson_schema) sampling_params SamplingParams(guided_decodingguided_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修复成合法JSONimport json from json_repair import repair_json raw { product_name: 智能保温杯, short_intro316不锈钢内胆长效保温, 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_length3, max_length5) 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, retries2): for attempt in range(retries 1): resp client.chat.completions.create( modelgpt-4o-mini, messagesmessages, 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_namestringshort_introstringselling_pointslist[string]数量在3到5个price_rangestring格式像“100-200”sales_rankstring只能是“高”“中”“低”三选一categorystringPydantic模型写成这样from pydantic import BaseModel, Field class ProductInfoOutput(BaseModel): product_name: str short_intro: str selling_points: list[str] Field(min_length3, max_length5) price_range: str sales_rank: str Field(pattern^(高|中|低)$) category: str6.2 完整调用函数清洗、解析、校验、重试一锅端下面这段代码是我目前最常搬上生产的模板用的是OpenAI兼容格式换成国内模型就把base_url和model换掉就行import re import json from openai import OpenAI from pydantic import ValidationError client OpenAI(api_keyYOUR_API_KEY, base_urlYOUR_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( modelgpt-4o-mini, messagesmessages, response_format{type: json_object}, temperature0.0, max_tokens1024, ) 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要改但只要你把这三件事焊死在管线上晚上睡觉就不会被线上告警吵醒。
返回列表