大模型稳定输出JSON的工程实战:从Prompt约束到结构化解码

发布时间:2026/9/8 18:22:49
大模型稳定输出JSON的工程实战:从Prompt约束到结构化解码 做后端接入大模型的同学十有八九都遇到过这种场景你让模型按 JSON 返回一个配置它答得很好却在开头加一段“好的我来帮你生成”结尾再补一句“希望这个回答对你有帮助”。代码里json.loads()直接抛异常日志刷红你盯着那行输出愣了半天明明是同一个 Prompt上周还能稳定返回今天怎么就开始自由发挥了这篇内容就是围绕“让大模型稳定返回 JSON”这件事写的。我会把这几年实际接大模型接口时踩过的坑、验证过的方案、常用的降级策略都摊开讲偏实战不搞花架子。适合正在做 AI 应用开发、需要把大模型接入业务系统的同学参考尤其是那些被模型“自由格式文本”折磨过的后端和算法工程师。1. 为什么大模型写不对 JSON根子出在训练目标上先说一个很容易被忽略的事实大模型本质上是一个“按概率预测下一个 Token”的文本生成器不是数据库也不是规则引擎。它擅长的是生成“看起来像 JSON 的文本”而不是“严格符合 JSON 规范的文本”。这就导致一个典型现象模型在生成 JSON 时会在它认为合适的地方插入自然语言。比如你问它“返回北京今天的天气”它可能输出{city: 北京, weather: 晴}多数时候没问题但只要你稍微改变 Prompt 的措辞或者换一个模型版本它就可能在 JSON 前后加上解释性文字甚至把 key 的引号漏掉。原因很简单训练数据里JSON 和自然语言是混在一起的模型只是在模仿人类写代码时的习惯而人类写代码时本来就会加注释、加说明。1.1 概率采样放大不确定性除了训练目标还有一个关键因素解码策略。我们调用模型时通常会设置temperaturetemperature 越高模型越倾向于“发散”生成的 Token 分布越均匀JSON 的稳定性直线下降。理论上 temperature 设为 0 时模型输出最确定但注意temperature0并不是数学上的“完全确定”只是采样时选了概率最高的 Token。很多大模型底层还带了 top_p、top_k 之类的采样参数哪一个设置不当都可能影响输出质量。我做过一个简单实验让同一个模型生成同一个 JSON 结构分别用temperature0.3和temperature0.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 Moderesponse_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_urlhttp://your-model-endpoint/v1, api_keyyour-api-key ) resp client.chat.completions.create( modelyour-chat-model, messages[ { role: system, content: 你是一个信息抽取助手只输出 JSON。 }, { role: user, content: 从下面文本中抽取人物姓名、年龄和职业。文本张三今年28岁是一名后端工程师。 } ], response_format{type: json_object}, temperature0, max_tokens1000, ) content resp.choices[0].message.content print(content)如果你运气好输出可能是{姓名: 张三, 年龄: 28, 职业: 后端工程师}注意几点messages里我加了system指令明确要求“只输出 JSON”。虽然 JSON Mode 本身有内置约束但配合指令更稳。temperature0降低随机性。有些服务要求 Prompt 中必须出现 “json” 字样所以我特意在 system 里写了“只输出 JSON”否则某些网关会直接拒绝请求。3.2 用 Pydantic 定义 Schema再转给模型你可能会问“我就想要一个固定结构的 JSON有没有办法让模型别乱加字段”答案是使用结构化输出即json_schema。在 OpenAI 新版本 SDK 中可以这样from openai import OpenAI from pydantic import BaseModel client OpenAI(base_urlhttp://your-model-endpoint/v1, api_keyyour-api-key) class PersonInfo(BaseModel): name: str age: int occupation: str resp client.beta.chat.completions.parse( modelyour-chat-model, messages[ {role: user, content: 张三今年28岁是一名后端工程师。} ], response_formatPersonInfo, ) 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(modelyour-model, gpu_memory_utilization0.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( temperature0, max_tokens256, guided_jsonjson_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(modelyour-chat-model, temperature0) parser PydanticOutputParser(pydantic_objectPersonInfo) 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 Modefail 的概率会显著提高。因为模型在生成长文本时很容易忘掉前面的括号层级。一个经验法则是能展平的数据不要嵌套能让模型“少记状态”就少记状态。把复杂结构拆成多个独立任务往往比一个 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, rawraw, errorstr(e)) raise第二道字段类型校验。推荐用pydanticfrom 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, rawdata, errore.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, strictFalse)也救不了。正确做法是先转义处理或直接用两步解析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_infoe) 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、采样参数、解析链路和降级策略都过一遍多数问题都可以在不动模型的前提下解决。