
做AI应用开发这几年要说哪个环节最磨人不是模型选型不是Prompt调优而是让大模型规规矩矩吐出一段能直接用的JSON。明明提示词里写了“只返回JSON”它偏要在前面加一句“好的以下是您需要的JSON”再在结尾补一个反引号好不容易清理干净了字段又少了一个把温度调到0重试三次每次返回的字段名还不一样。这个问题在团队里被我们戏称为“大模型结构化输出的玄学”。直到我把底层原理和工程方案彻底捋了一遍才算真正摆脱了靠运气解析JSON的窘境。这篇文章我会从“为什么模型总把JSON搞砸”讲起再把目前主流的稳定化方案——JSON Mode、Function Calling、约束解码、Schema校验与自修复——逐一拆开揉碎最后给出一套可以直接抄作业的本地部署实操附上完整代码和踩坑记录。内容主要面向正在做AI应用开发、Agent编排、数据抽取类项目的工程师也适合刚接触大模型API、被JSON解析搞到崩溃的初学者。1. 为什么大模型返回 JSON 这么难1.1 问题的根源在生成机制不在模型“笨”要理解大模型为什么连一个“完美的JSON”都给不出来得先回到模型的基本工作原理。大模型本质上是逐token预测的每生成一个字符都是在概率分布上采样一次目标只是“下一个词最合理”而不是“整段输出符合某种语法结构”。JSON要求括号成对、引号闭合、逗号位置准确、键名不能拼错这属于严格的上下文无关文法约束和语言模型天然的概率生成逻辑存在冲突。你可以把大模型想成一个特别健谈的同事你问他“给我一份《三体》的读后感200字左右”他能给你交出一篇像模像样的文章但如果你说“给我一段JSON表示三体人的坐标”他大概率会先把JSON里该写的字段写出来然后画蛇添足地在外面包一层Markdown代码块或者在最后一个对象后面多加一个逗号。原因很简单在训练语料里JSON经常出现在代码块的包裹中也经常带注释和尾逗号模型学到的“JSON的样子”比标准JSON更自由。更麻烦的是这种偏差在小参数模型上会被放大。7B、13B级别的开源模型本身指令遵循能力就偏弱一旦上下文长度拉长、输出文本变多很容易出现字段名拼错、结构嵌套错误、未闭合的数组这类低级问题。这也解释了为什么同一个Prompt在GPT-4上能稳定输出换到本地小模型就频繁翻车。1.2 概率性输出与业务刚需之间的矛盾如果只是偶尔格式不合法多试几次也就是了但在实际工程里结构化输出的失败会造成连锁反应。比如我做数据抽取任务时流程是“模型输出JSON → 解析入库 → 下游任务读取”一旦某条数据解析失败整个批次都要回滚重跑。再比如Agent工具调用模型输出的是工具入参如果入参JSON解析不了工具根本执行不了整个Agent流程就断了。另一个隐性成本是“脏数据”入库。有些JSON虽然能解析成功但字段类型不对、枚举值不在预期范围内、嵌套结构缺失这些不是解析报错能捕获的。比如模型把price: 19.99输出成price: 19.99很多解析器不会报错但下游计算就会出问题。这种“看起来合法、实际有毒”的输出比纯格式错误更难排查。所以在生产环境里我们需要的不是一个“大概率合法”的JSON而是一个“结构可验证、失败可重试、异常可感知”的闭环。这也决定了一篇提示词能解决的问题是有限的必须在采样策略、Schema约束、解析校验三个层面同时下手。1.3 行外人常踩的误区加一句“只输出JSON”就完事我见过不少同学的做法是在Prompt末尾加一句“请只输出JSON不要有任何其他内容”然后发现模型还是很叛逆。这里要明白一个事实提示词是软约束它通过影响概率分布来引导输出但无法从机制上禁止模型输出JSON之外的字符。模型完全可能在结尾多一个空格、少一个右括号或者把注释写进JSON里。真正能对输出格式形成硬约束的是采样和解码层面的技术这就引出了后面要讲的JSON Mode和约束解码。提示词当然要写但只靠提示词远远不够。工程上的正解是“提示词负责意图理解解码策略负责格式保障解析层负责最后兜底”三者各司其职。2. 让 JSON 稳定输出的五种方案拆解2.1 提示词强约束与Few-shot示例成本最低但最不可靠最朴素的做法是把结构化输出的要求写死在System Prompt里再给一两个输入输出示例告诉模型“别废话直接给JSON”。这套方案的优点是零成本、零额外依赖任何模型都适用。缺点也很明显稳定性随模型能力波动长输出场景下格式漂移严重。我自己的经验是提示词强约束能解决70%~80%的场景剩下的20%一旦出现就是硬伤。尤其是当模型需要在一次输出里包含多个JSON对象或者嵌套层级很深时漏括号、漏字段的概率会明显上升。Few-shot示例能改善一些但示例等于是在教模型“照猫画虎”如果模型本身不理解JSON结构规则给它十个示例它还是会犯错。我的建议是如果项目刚起步、对格式要求不苛刻、后续有人工兜底可以用这招。但一旦进入自动化流水线一定要升级到下面几种方案。2.2 JSON Mode让解码器只输出合法 JSON 字符JSON Mode是目前各大模型API普遍支持的一种采样模式Ollama里叫format: jsonOpenAI里是response_format: {type: json_object}Anthropic也有类似的JSON输出保证。它的原理是在解码阶段对候选token做过滤只允许那些“不会破坏JSON语法”的token被采样从机制上杜绝了输出非法JSON的可能。但要注意JSON Mode保证的是“输出是合法JSON”不保证“输出符合你期望的结构”。模型可能返回{result: 这是文本}而你要的是{status: success, data: {items: []}}。它解决的是语法层的问题结构层的校验还得靠后面的Schema方案来兜底。还有一个容易被忽略的细节很多模型的JSON Mode要求Prompt里必须出现“json”这个词否则会报错。用Ollama时要特别注意部分版本的JSON Mode会在格式不满足时直接报错而不是自动降级这个我们在第四部分排查章节详细说。2.3 Function Calling用参数Schema锁定输出结构Function Calling工具调用本来是让模型选择调用哪个函数、生成什么参数用的但它天然适合做结构化输出——因为函数的参数定义本身就是JSON Schema。你只需要定义一个“虚拟函数”函数的参数就是你要让模型输出的结构模型在调用函数时会严格按照参数Schema生成JSON而且生成的参数一般都能通过JSON Schema校验。这样做的好处是双重的一是结构被限定在了Schema内模型不会自由发挥二是很多框架比如LangChain、Vercel AI SDK对Function Calling的输出有内置的解析和校验逻辑拿到结果直接就是类型安全的对象。这套方案是目前我在生产环境用得最多的也是兼容性最好的。几乎主流大模型API都支持Function Calling包括OpenAI、Claude、Gemini以及Ollama这类本地部署方案。本地小模型偶尔会出现“不调用工具、直接回复文本”的情况这时需要配合一个“强制工具调用”的开关比如Ollama的tools参数配合message的tool_calls字段。2.4 约束解码连生成的 token 都按 Schema 来如果说JSON Mode是“语法级约束”Function Calling是“结构级约束”那约束解码就是“token级硬约束”。这类方案以llama.cpp的GBNFGrammar-Based Finite-state和Outlines库为代表做法是先把你期望的输出结构编译成一个有限状态机然后在每一步解码时只允许符合该状态机规则的token被采样。举个例子如果你定义的Schema里某个字段是整数类型约束解码器在生成这个字段时就只会从数字token里去采样根本不会给你输出字母或引号的机会。这等于是在解码器层面写死了一条“生产流水线”产品从生产线上下来就是合格的。约束解码的执行效果很惊艳但代价是复杂度和兼容性。GBNF语法本身需要学习写起来比JSON Schema繁琐Outlines支持把JSON Schema自动转成约束但它依赖特定模型后端目前对Transformers和llama.cpp支持较好和在线API基本无缘。所以这套方案主要适合本地部署场景或者说对格式稳定性要求极高、已经愿意为模型定制推理后端的场景。2.5 解析兜底与自修复循环最后的保险丝不管前面用了哪种方案解析层都必须做好兜底。一个成熟的结构化输出流程应该包含这几层拿到模型输出 → 清理Markdown围栏和多余字符 → 尝试JSON解析 → 按Schema校验字段 → 校验失败则自动重试或触发修复。修复环节可以调用一次大模型把报错信息和原始输出一起回传让模型重新生成。这套“校验失败就重试”的思路配合前面的JSON Mode或Function Calling能把单次的成功率从95%推到99.5%以上。我有一个调优经验不要把重试机制做成同一套Prompt无限循环而是要在每次失败后把“具体的报错信息”拼进下一次请求这样才能引导模型修正。否则它大概率会犯一模一样的错。我把这五种方案从稳定性、开发成本、适用场景做了个对比方便你按项目情况选型方案稳定性开发成本适用场景提示词约束低极低一次性脚本、有兜底的人工流程JSON Mode中保证语法合法低API调用、快速验证Function Calling高结构受控中Agent工具调用、生产级API、强类型业务约束解码极高高本地部署、对格式零容忍的场景解析兜底自修复中高依赖前置方案中任何生产流程都建议加3. 实操用本地模型跑通一套稳定结构化输出3.1 环境准备Ollama 部署与模型选择我在本地跑通结构化输出的方案用的是Ollama理由很简单安装简单、模型管理方便、对JSON Mode和Function Calling支持都比较成熟。先在官网下载安装对应系统的Ollama装好后用命令行拉取模型ollama pull qwen2.5:7b模型选择上如果机器显存紧张8GB左右优先选7B级别的模型比如qwen2.5:7b、llama3.1:8b显存在16GB以上可以尝试14B效果会好一截。我实测下来qwen系列在中文场景和JSON输出上比同尺寸Llama更稳这可能和它的中文训练语料更充足有关。装好模型后再确认一下Ollama的服务是否在运行ollama list curl http://localhost:11434/api/tags能看到模型列表就说明服务正常。后续的所有请求都走http://localhost:11434/api/chat这个接口非常方便。3.2 基础方案JSON Mode 明确结构提示先来写第一个版本启用Ollama的JSON Mode同时用提示词明确结构。请求体里的format: json就能激活JSON Mode模型输出会被约束为合法JSON。但注意它保证的是语法字段结构还是要靠提示词去约束。import requests import json url http://localhost:11434/api/chat payload { model: qwen2.5:7b, messages: [ { role: system, content: ( 你是一个信息抽取助手。请从用户输入中抽取商品信息 只输出JSON不要输出任何多余文字。 JSON格式如下 {name: 商品名称, price: 19.99, category: 分类, in_stock: true} ) }, { role: user, content: 这款无线机械键盘售价499元属于电脑外设类目目前有货。 } ], format: json, stream: False, options: {temperature: 0} } resp requests.post(url, jsonpayload) data resp.json() content data[message][content] parsed json.loads(content) print(parsed)关于参数有几点要说明。temperature设为0是为了让采样尽可能确定性降低随机性带来的格式漂移。stream设为False是为了拿到完整输出再统一解析流式输出虽然体验好但要做增量解析复杂度高很多第一版不建议碰。format: json是JSON Mode的开关不加也能跑但加了之后模型会在解码阶段被约束。这段代码跑通后你会发现在绝大多数情况下输出的JSON是合法的直接json.loads不会报错。但一旦遇到模型把in_stock输出成有货而不是布尔值你就知道光靠JSON Mode还不够。3.3 进阶方案Pydantic Schema 自修复循环为了解决“合法但不符合预期结构”的问题我把方案升级为“JSON Schema定义结构 Pydantic解析校验 失败自动重试”。这一套组合拳打下来基本能满足生产环境的要求。先定义输出结构。Pydantic的好处是用Python类型注解就能生成JSON Schema同时还能直接反序列化校验一举两得from pydantic import BaseModel, Field from typing import List class Product(BaseModel): name: str Field(description商品名称) price: float Field(description商品价格用数字表示) category: str Field(description商品分类) in_stock: bool Field(description是否有货用true/false表示) keywords: List[str] Field(description商品关键词列表)接着实现一个核心客户端把“请求模型→解析JSON→Pydantic校验→失败重试”串成闭环。这里的关键点是重试时一定要把上一次的报错信息拼进Prompt里让模型知道错在哪import requests import json from pydantic import ValidationError class StructuredOutputClient: def __init__(self, base_url: str, model: str, schema_model): self.base_url base_url self.model model self.schema_model schema_model def generate(self, user_input: str, max_retries: int 3) - BaseModel: system_prompt ( 你是一个结构化输出助手。请根据用户输入生成JSON fJSON必须严格符合以下JSON Schema要求\n{self._schema_text()}\n 只输出JSON本身不要加任何解释、注释或Markdown代码块标记。 ) messages [ {role: system, content: system_prompt}, {role: user, content: user_input}, ] for attempt in range(max_retries): payload { model: self.model, messages: messages, format: json, stream: False, options: {temperature: 0}, } resp requests.post(f{self.base_url}/api/chat, jsonpayload) resp.raise_for_status() content resp.json()[message][content] # 第一层清理潜在的非JSON杂质 content content.strip() if content.startswith(): # 去掉可能的代码块围栏 lines content.splitlines() if lines and lines[0].startswith(): lines lines[1:] if lines and lines[-1].startswith(): lines lines[:-1] content \n.join(lines) # 第二层JSON语法解析 try: raw_obj json.loads(content) except json.JSONDecodeError as e: messages.append({role: user, content: f你上次输出的JSON解析失败错误信息{e}。请重新输出合法的JSON。}) continue # 第三层Pydantic结构校验 try: return self.schema_model.model_validate(raw_obj) except ValidationError as e: messages.append({role: user, content: f你上次输出的JSON结构校验失败错误信息{e}。请严格按照Schema要求重新输出。}) continue raise RuntimeError(f重试{max_retries}次后仍然失败) def _schema_text(self): # 把Pydantic模型的JSON Schema转成一段可读文本拼进System Prompt return json.dumps(self.schema_model.model_json_schema(), ensure_asciiFalse, indent2)这个类封装完调用时就非常清爽了client StructuredOutputClient( base_urlhttp://localhost:11434, modelqwen2.5:7b, schema_modelProduct, ) product client.generate(这款无线机械键盘售价499元属于电脑外设类目目前有货。) print(product) # name无线机械键盘 price499.0 category电脑外设 in_stockTrue keywords[]注意看这个例子里我特意没在原文中给关键词信息模型会输出空列表而不是编造这就是Schema约束的价值所在它限制了模型自由发挥的空间。我把这个过程称之为“把模型从写散文的人变成一个填表格的人”。3.4 更省心的方式LangChain 的 with_structured_output如果你项目里已经在用LangChain可以省掉自己写客户端类的功夫。LangChain的with_structured_output方法把“绑定Schema→调用模型→解析输出”整个流程做了封装底层原理就是Function Calling。拿Ollama举例from langchain_ollama import ChatOllama from langchain_core.pydantic_v1 import BaseModel, Field class MovieReview(BaseModel): title: str Field(description电影名称) rating: float Field(description评分0到10之间的数字) is_recommended: bool Field(description是否推荐) summary: str Field(description一句话摘要) llm ChatOllama(modelqwen2.5:7b, temperature0) structured_llm llm.with_structured_output(MovieReview) review structured_llm.invoke(《盗梦空间》我给9分烧脑但非常值得看推荐大家去补。) print(review.title) # 盗梦空间with_structured_output会自动把Pydantic模型转成Tool Schema并且走一遍Function Calling流程返回的对象就是Pydantic实例类型安全、字段完整。如果你不想自己管理重试还可以在invoke外面包一层循环用异常触发重试。这里要提醒的是LangChain对Ollama的Function Calling依赖模型本身的能力。qwen2.5系列表现不错但如果是特别老的模型比如llama2工具调用不稳定with_structured_output会退化成纯Prompt解析模式效果会打折扣。所以用这套方案前先确认模型支持工具调用。3.5 硬核手段约束解码不给模型犯错机会最后聊一下我前面提到的约束解码。如果你用的是本地模型并且已经到了“无法接受一次失败”的程度可以考虑直接上Outlines。Outlines的一个突出能力是把JSON Schema编译成解码约束在生成阶段就限制token序列必须符合Schema。from outlines import models, generate model models.ollama(qwen2.5:7b) schema Product.model_json_schema() generator generate.json(model, schema) result generator(这款无线机械键盘售价499元属于电脑外设类目目前有货。) print(result)需要注意的是Outlines和Ollama的集成方式在不同版本上有差异有的版本建议直接用Transformers后端。我个人实践下来Outlines更适合和transformers本地加载的模型一起用配合GBNF格式的约束效果最理想。如果你想用llama.cpp的后端则需要手写GBNF语法学习曲线陡一些但一旦写好稳定性的提升是肉眼可见的。4. 常见问题与排查技巧实录4.1 经典报错failed to deserialize the json body into the target type这个报错我在生产环境见过太多次了。它本质上是“反序列化失败”最常见的原因就是模型返回的JSON里缺少了某个必填字段。比如你期望Schema里有summary字段但模型觉得“原文里没提到摘要就不写了”于是字段缺失校验直接挂掉。解决办法分两层。第一层在System Prompt里明确说明“如果某个字段的信息未提供使用null值填充”而不是让模型自由跳过字段。第二层在Pydantic模型里用Optional[...]声明可空字段避免因为一个字段缺失导致整条数据报废from typing import Optional class Product(BaseModel): name: str price: float category: str 未知 # 给一个默认值兜底 in_stock: Optional[bool] None keywords: List[str] Field(default_factorylist)还有一个经验是报错信息里的input: missing field后面会跟着字段名这是最直接的线索。我排查时基本不看其他日志直接抓这个字段名然后判断是“模型漏了”还是“提示词没有约束到”针对性修复。4.2 输出总带Markdown代码块解析器直接报错当模型被训练语料里“JSON代码块”的格式“带偏”时它会在JSON前后加 json 和 。虽然JSON Mode能限制字符范围但加上提示词不给力时这类情况依然偶尔发生。我见过的解法有两种弱的解法是在解析前写一个清理函数把代码块围栏剥掉强的解法是在System Prompt里反复强调“不要用Markdown代码块包裹”。清理函数我建议一定要写因为它成本极低但能救你一次def clean_json_content(content: str) - str: content content.strip() if content.startswith(): lines content.splitlines() if lines and lines[0].startswith(): lines lines[1:] if lines and lines[-1].startswith(): lines lines[:-1] content \n.join(lines) return content.strip()这个函数放在解析前调用几乎不会误伤正常输出但在异常场景里能避免一次重试。我在生产代码里一直保留着它。4.3 字段名大小写被改变跨语言协作时尤其明显有个非常隐蔽的坑模型的词表里大小写敏感如果提示词里写的是小写字母开头的字段名模型有时会擅自改成大写开头或者反过来。更常见的是比如在Java后端用Jackson反序列化时如果Java Bean的变量名是大写字母开头虽然不规范但确实存在JSON序列化后会自动变小写导致前后端字段名对不上报错排查半天才发现是命名规范问题。这类问题没有特别优雅的解法我的建议是三层配合第一所有跨系统字段名统一用snake_case或camelCase在System Prompt里明确字段名拼写第二解析后用Schema校验字段是否存在而不是只校验类型第三在跨语言边界尽量用Pydantic/Schema自动生成标准字段名避免手工维护字符串。另外如果你的业务是用Java处理大模型返回的数据推荐用Jackson配合JsonNode先做一层宽松解析再用convertValue转到目标POJO这样即使字段顺序变化也不会影响反序列化。字段顺序本身其实无所谓只要键名对得上就行。4.4 模型重试越修越错问题出在修复提示词自修复循环里最常见的失败模式是第一次失败把报错喂给模型模型重新生成的JSON反而错得更离谱。我排查下来的原因是修复提示词太笼统只说了“你错了”没有说“具体哪里错了”。模型的注意力被模糊的负面反馈干扰反而开始怀疑正确字段把原本对的也改错了。正确的做法是把JSON解析器和Pydantic生成的报错内容原封不动地拼进Prompt让模型看到精确的校验失败原因。报错信息越具体修复成功率越高。还有一个细节修复时建议把原始用户输入也带上避免模型在“改错”的过程中忘了原始任务。如果重试两三次仍然失败我建议直接放弃自动修复把这条数据记入失败队列交给人工处理或在下一批次里重新跑一遍。无限重试不仅消耗token还容易拖垮整个流水线。4.5 模型输出速度慢怎么优化结构化输出通常要求低温度、非流式这在模型推理时会有额外开销。实测下来7B模型在纯CPU上生成50个token的JSON可能需要十几秒对交互式应用来说不太乐观。优化思路有几个方向一是换更小的模型比如qwen2.5:3b结构简单时效果也能接受二是升级到GPU推理这需要你本地有NVIDIA显卡并配置好CUDA环境三是合理限制输出长度在提示词里要求“字段值保持在20字以内”避免模型生成冗长废话。还有个小技巧如果只是提取少量字段可以拆成多个小请求并行调用而不是一个大请求等全部完成。比如一次要抽10个字段拆成两个各抽5个字段的并行请求整体耗时通常比单请求更快。4.6 问题排查速查表现象可能原因解决方案JSON解析直接报错模型输出非JSON字符开启JSON Mode清理函数兜底报错显示缺失字段模型漏掉必填字段提示词要求null填充Schema用Optional字段类型不符模型输出字符串而非数字/布尔Pydantic强校验重试时带报错信息字段名拼写不一致词表采样漂移提示词明确拼写Schema校验键名修复越修越错重试提示词不够具体拼接原始报错带上用户输入输出速度慢模型大/CPU推理换小模型限制输出长度并行请求偶尔返回空content模型触发安全拒绝或超长截断检查上下文长度拆分任务5. 我的一点经验之谈跑了快一年的结构化输出我最大的感受是没有万能银弹组合拳才是正解。方案选型上简单任务用提示词就够了生产环境至少要做到“JSON Mode Schema校验 失败重试”的组合。如果项目里已经接了LangChain直接用with_structured_output省心且稳定如果你在本地部署模型且对稳定性要求极高再入坑约束解码。最后分享一个小技巧无论你选了哪套方案一定要在开发阶段把模型的原始输出落盘建立一个“JSON失败样本库”。每次解析失败就把原始输出、报错信息、修复后的结果存下来隔一段时间统计一下失败类型。你会发现模型翻车的模式高度重复针对这些模式去优化提示词或重试策略远比泛泛地调参数高效。我的样本库里曾经统计出“中文引号污染JSON”这类少见问题就是靠这种积累才发现的。踩过这些坑之后再回头看结构化的价值其实不在“稳”而在“可预期”——你终于知道模型会怎么输出了下游系统也终于可以放心地建立在模型之上。