ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLM返回JSON总出错?从软约束到硬校验的工程实践

LLM返回JSON总出错?从软约束到硬校验的工程实践 近年来只要做 AI 应用几乎都绕不开让大模型返回 JSON 的场景。我过去大半年在项目里不断让 LLM 输出结构化数据踩过的坑能列一长串明明在 prompt 里写了“只输出合法 JSON不要任何多余内容”结果返回里带上了 Markdown 代码块明明要求只给一个对象模型却在后面补一段解释。时间一长你会发现只靠自然语言去约束大模型输出 JSON本质上是在赌概率。这篇把我踩过的坑、测过的数据和最终沉淀下来的工程方案一次讲清楚适合所有想把 LLM 输出接入自动化流程的开发者参考。1. 一次崩溃日志引发的“格式玄学”1.1 线上事故回放有段时间我负责一个评论分类服务逻辑很简单把一段商品评论丢给 LLM让它返回 JSON里面包含sentiment、tags、summary三个字段。当时的 prompt 结尾写得非常“强硬”You are a classification engine. Return ONLY a valid JSON object. No explanation, no markdown, no extra text.本地测试连续调了几次都正常直接发布了。结果上线第三天凌晨报警电话把我叫醒——某个处理消息的消费端崩了。扒开日志一看LLM 这次没有返回对象而是把 JSON 包在一对json代码块里而且前头还带了一行标题Here is the classification result: json { sentiment: positive, ... }下游模块拿到这段字符串直接 JSON.parse当场抛异常。因为队列里积压了大量消息重试逻辑又疯狂地把同样的坏数据塞回队列死循环解不开。 更诡异的是我拿同样的输入在测试环境重放一次都复现不出来。这种“事故靠运气测试像抽卡”的体验是很多 LLM 应用开发者最早对“格式化输出”产生心理阴影的来源。 ### 1.2 复现实验出错并非偶然 为了搞清楚模型真实出错率我在同样这批测试数据上跑了 100 次请求做了详细统计。结果分成两种一种是完全不能被 JSON.parse 解析的“硬失败”另一种是能解析出来但字段缺失、内容错位的“软失败”。 同一个模型temperature 0 的时候硬失败率大约 2%~3%调到 0.3 后硬失败率升到 4%再调到 1.0硬失败率直奔 10% 左右。换不同厂家的模型结果差异更大有的遵循性好一点有的动不动就在 JSON 前后输出一段“思考说明”。 4% 的失败率看起来不高但这是质变问题。一个每天被调用几十万次的线上服务4% 意味着每天有几万次异常。如果每次异常都会触发重试、告警、日志堆积你的系统很快就会被这种小概率事件打垮。Demo 阶段你有的是耐心手动重试生产环境可没有这种容错。 ### 1.3 把问题定性自然语言约束是“软约束” 这件事真正让我想明白的一点是prompt 本质上是自然语言指令不是代码语法模型并不是像程序执行 if 那样去执行你的要求。 你在 prompt 里写“不要输出解释”模型只是在采样时把“输出解释”这个分支的概率压低但它仍然存在。哪天输入里出现了某种措辞模式让模型觉得“应该先说点什么再给答案”它就会把解释吐出来。你说“只能输出 JSON”模型无法像解析器一样逐字检查括号配对、引号闭合它只是在尽力模仿对话历史中见过的“人类要 JSON”的样子。 这个“尽力”和“保证”之间隔着工程上最要命的距离。 ## 2. 从生成机制看LLM 并不“校验” JSON 合法性 ### 2.1 token 级预测与全局文法约束之间的鸿沟 想要根治这个问题先得理解大模型的底层生成逻辑。LLM 生成文本时做的事情其实是重复一个步骤根据已经生成的 token预测下一个 token 的概率分布然后从中采样一个 token 拼上去。过程会一直持续到采样出终止符为止。 关键点在于模型每一步只关心“下一个 token 选谁”它没有“整段输出最终是什么”的全局视角。模型内部并不维护一个 JSON 解析器的状态它不知道现在字符串里是不是漏掉了一个转义符也不知道数组的方括号是不是还差一个才闭合。 可以类比成让一位没见过 JSON 规范的实习生照着一句话填表。他知道大概应该怎么填但他的每一步判断都是靠日常经验而不是靠合法性的严格校验。你很难要求实习生凭“感觉”填出完全符合格式规范的表格LLM 也一样。 JSON 这类结构化格式之所以让 LLM 头疼是因为它属于上下文无关文法括号配对、引号转义、逗号分隔都是全局约束。比如下面这个对象 json { name: 张三, tags: [A, B] }模型生成完name: 后下一个 token 是“张”这个没问题但接着它会不会在这一层字符串里直接输出一个未转义的英文引号这取决于概率分布而不是“有没有一个解析器拦住它”。可一旦引号在错误的位置出现整个 JSON 就废了。2.2 为什么越长的输出越容易“差一点点”LLM 文本生成本质上是一个 token 一个 token 累积的过程。我们可以用一个简化模型来感受这个问题假设每次 token 生成时破坏 JSON 合法性的概率只有 0.1%听起来非常安全对吧但一个包含 20 个字段、部分 value 又是数组、嵌套对象的 JSON往往要 200~400 个 token 才能生成完。如果每个 token 破坏整体格式的概率是 0.1%并且彼此独立那么整段输出毫不出错的概率大约是 0.999 的 300 次方算出来只有 74% 左右。这还是在乐观假设下。而真实场景里一旦遇到需要转义的特殊字符、文本内容中出现换行、字段值需要在字符串和数字之间来回切换出错的局部概率会瞬间高几个数量级。这就是为什么“简单的单字段 JSON”很少翻车一牵扯到嵌套结构、长文本摘要就事故频发。自然语言出错人类可以自动纠错JSON 出错只能整体崩溃。模型并没有“写成合法 JSON 就收手”的安全钩子。2.3 纯 Prompt 失败形态清单我见过且实际踩过的失败形态整理了一张表。它们多数不能靠“再写一句更严厉的提示词”根除失败类型典型表现根因倾向Markdown 包裹输出被json和包住模型觉得“代码块更符合代码展示习惯”附带解释前缀JSON 前出现“Here is the result:”或思考过程模型把任务当成对话而非序列化字符串内换行字段值包含真实换行而不是\n文本摘要时模型直接把内容照搬字段被删改缺失必须字段或改了字段名schema 约束只存在于 prompt 语义中单引号 JSON用name代替name数据分布中 JSON5 / JS 风格污染输出整体偏差生成了 YAML 或纯文本列表模型未能理解“JSON 对象”的意图从这张表你能看到它们的共同点不是“模型坏”而是 prompt 只提供语义压力不提供文法边界。真正可靠的解决方案必须让“格式约束”从提示词层面下沉到生成机制层面。3. 可靠方案的分层从格式开关到解码约束解决“只靠 prompt 不可靠”的办法就是不要把格式要求当成提示词的一部分而是把它作为生成过程的硬性约束。按照约束硬度的递增实践中通常有四层方案。3.1 接口层的 JSON Mode 与结构化输出现在很多托管 API 提供了格式约束参数。最基础的是我们常说的 JSON Mode比如在调用时传入response_format并声明希望输出 JSON 对象。开启后模型会被引导生成一个完整的、可解析的 JSON 对象而不是随意的自然语言。这种方案能解决掉“前面带解释文字”和“用 Markdown 包裹”这两类高频问题因为它把“输出必须是 JSON”变成了模型端的“模式开关”而不是自然语言请求。但它仍然有一个重要限制JSON Mode 通常只保证“这是一个 JSON 对象”不保证对象内的字段名、类型、嵌套关系都符合你的业务要求。你要求返回name、tag它给你返回Name、tag_list照样能把下游代码搞崩。老版本接口甚至要求在 prompt 里显式包含“json”字眼否则调用直接报错这是实际使用中容易忽略的细节。比 JSON Mode 更进一步的是结构化输出Structured Outputs。它会要求你提供一个 JSON Schema模型在生成时被要求严格匹配该 Schema 的字段名和类型。你可以这样理解JSON Mode 是一个“只能输出 JSON”的开关结构化输出则进一步把“字段和类型”也锁死在接口层。from openai import OpenAI client OpenAI() response client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: 给这段评论分类}], response_format{ type: json_schema, json_schema: { name: review_classification, schema: { type: object, properties: { sentiment: {type: string}, tags: {type: array, items: {type: string}}, summary: {type: string} }, required: [sentiment, tags, summary] } } } )执行后拿到的message.content理论上就是一个完全合规的 JSON 对象。这类方案能把失败率从百分之几压到极低“字段缺失”“字段改名”这类问题基本堵死。需要注意的是结构化输出不一定所有模型和接口版本都支持不支持时不要硬凑退化到后面几种方案更稳妥。3.2 用工具调用让模型以“函数参数”思考很多现成的模型 API 支持函数调用function calling / tool calling语义上是让模型根据用户输入决定要不要调用某个工具并填入对应的函数参数。工程上有个很巧妙也很实际的用法即使你根本不需要调用任何外部函数也可以声明一个名字叫extract_result的空函数把你要的 JSON 结构定义为函数参数然后强制模型走这个函数调用。tools [ { type: function, function: { name: extract_result, parameters: { type: object, properties: { sentiment: {type: string}, tags: {type: array, items: {type: string}}, summary: {type: string} }, required: [sentiment, tags, summary] } } } ] response client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: 给这段评论分类}], toolstools, tool_choice{type: function, function: {name: extract_result}} )随后从response.choices[0].message.tool_calls[0].function.arguments里直接取字符串解析后就是你要的 JSON。这套方案为什么比纯 prompt 稳定一方面工具调用是模型训练时被重点强化的能力模型对“函数参数必须符合声明结构”的遵循度远高于对普通自然语言指令的遵循度另一方面工具参数天然就是结构化数据模型不需要再纠结“要不要加解释”“有没有 Markdown”它只会返回参数本身。实际测试中工具调用遇到的最大问题是“模型偶尔会重复调用工具多次或者漏填参数”。但只要你的参数声明里把每个字段都设为 required并且通过tool_choice锁定目标函数绝大多数厂商模型的稳定性都能达到可接受水平。这层方案最大的门槛是模型必须支持函数调用不支持时看下一节。3.3 开源推理侧的 grammar 约束把 JSON 变成硬性规则如果你是自己部署开源模型没有厂商 layer 那种 response_format 或函数调用能力也不要慌。还有一类方案是在解码阶段做约束每一轮预测下一个 token 时用一个预定义的文法状态机去过滤候选 token只允许能匹配 JSON 语法的 token 进入采样列表。这就是 outlines、guidance、llama.cpp 中的 GBNF grammar 等工具在做的事情。它们做的事情可以理解成把“输出必须是一个 JSON 对象”从自然语言请求变成一道代码围墙。模型再也没有机会在 JSON 前面插一段解释也不可能生成未闭合的数组因为在状态机看来那些 token 根本不可选。llama.cpp 的常见做法是用--grammar指定一个 GBNF 文件。粗糙的 JSON 语法规则长这样root :: { ws } | { ws string ws : ws value ws (, ws string ws : ws value ws)* } value :: object | array | string | number | true | false | null看着繁琐但效果非常硬核输出会从概率上杜绝语法层面的失败。需要说明的是grammar 只约束“结构性合法”不约束“语义正确”。比如一个字段声明为 stringgrammar 不会阻止模型把一串数字塞进去语义层的校验仍需在应用侧补上。这种解码约束还会带来额外 token 计算但是在需要绝对稳定输出的内部接口里这点开销完全值得。4. 退到纯 Prompt 时怎么把出错率压到最低现实情况是不是所有项目都能立刻切到上面那些“机制约束”。可能模型不支持、接口版本没升级、也可能你只是在维护一个写了很久的老项目不方便大改。这时你仍然要靠 Prompt 和 LLM 交互。既然“完全可靠”做不到那就把失败率尽量压低再结合第 5 章的兜底方案一起用。4.1 Schema 位置与角色设定纯 prompt 方案第一条原则不要把 JSON Schema 的语气写在最后一句请求里也别写在用户输入之后。我见过太多人把 schema 放在 prompt 末尾说漏字符然后前面大段描述自己的业务模型非常容易忽略最后的信息。我会把 JSON 结构直接放进 system 区块用独立小标题区分开明确告诉模型这里是强制要求。Schema 位置越靠前、越像“系统级规则”模型遵循度越高。如果你的接口支持 system prompt一定把 schema 放那儿而不是混在用户输入里。注意 不要在 prompt 里只写“输出 JSON 格式”要把字段名、值的类型、哪些字段是必须的、字段的枚举取值范围全部描述清楚。省略的部分模型会用训练分布里的惯用方式自动“脑补”脑补的结果往往不是你要的。4.2 一个可直接套用的低错误提示词模板下面是我一轮轮压测后沉淀下来的模板风格纯靠自然语言约束时这个写法成功率最高system: 你是结构化数据抽取引擎。所有输出必须严格满足以下 JSON Schema不得修改字段名不得增加额外字段不得输出非 JSON 内容。 Schema: {type: object, required: [sentiment, tags, summary], properties: {...}} 输出规则: - 输出必须是合法 JSON 对象以 { 开头以 } 结尾 - 禁止使用 json 代码块包裹 - 禁止在 JSON 前或后输出任何说明文字、前缀、注释 - 如果某个字段无法确定使用 null不要省略字段这个模板关键的三个点字段声明和“输出规则”分开明确“无法确定就填 null”避免字段缺失把“禁止代码块”这种反常识提醒写进去因为很多模型天然觉得代码块更规范。最后不要在 prompt 里加“请”之类太客气的措辞你是在让它执行格式化任务不是请求帮助。4.3 更多把坑填平的调参细节除了模板结构还有几个细节能显著降低失败率temperature 能调 0 就调 0。需要一定创造性也尽量保持在 0.3 以下温度越高格式破坏率越大。如果业务允许让模型按固定顺序输出字段。比如把name, type, content这种顺序写进 schema 声明,它输出的字段顺序更稳定后续逐个提取也方便。输入文本里如果本身含有换行、引号等特殊字符明确要求模型在 JSON 字符串中按 JSON 转义规则处理不要直接换行。给一个完整输出示例比十句“不要做什么”都有效。few-shot 示例能直接把模型拉到你期望的输出形态上。纯 prompt 压测到极致仍然会有 0.5%~1% 左右的格式异常这是“语义约束”的天花板。所以无论如何下一章的内容都不允许省。5. 最后一道防线解析、校验与自动修复讲完前置的生成阶段优化再看看应用侧如何兜底。我见过太多项目把 LLM 的返回值直接丢给JSON.parseparse 崩了就报错完全没想过做防御。这是最脆弱的写法。正确做法是把“解析”设计成一条包含剥离、校验、修复、重试的完整链路。5.1 从脏输出里剥离 JSON哪怕模型真的加了前导说明文字、或者用 Markdown 包住了 JSON这些坏数据也并非不可救药。解析前先做一层“剥离清洗”。最常用的策略是找到整段文本中第一个{的位置再从右侧找最后一个}的位置把这段截取出来单独尝试解析。虽然不优雅但对“前缀文本 JSON 后缀文本”这种事故形态命中率最高。更进一步可以用正则把常见的json包裹剥离掉import json import re def extract_json(raw: str): if not raw or not isinstance(raw, str): raise ValueError(empty LLM output) # 去掉 Markdown 代码块包裹 cleaned re.sub(r(?:json)?\s*, , raw).strip() start cleaned.find({) end cleaned.rfind(}) if start -1 or end -1 or end start: raise ValueError(no JSON object found in output) candidate cleaned[start:end 1] return json.loads(candidate)注意这段只是降级策略不要把“剥离后就能解析”当作常态。生成阶段你能用结构化约束就用结构化约束剥离只解决“小概率事故不至于让链路直接断掉”的问题。5.2 Schema 校验与二次修复请求json.loads能过不代表数据符合业务要求。用 pydantic 或 jsonschema 做一次 schema 校验是性价比很高的投入。不要只满足于“能解析”。假如模型少返回了summary字段json.loads完全不会报错代码后续一访问data[summary]就崩。与其把崩溃散落在业务代码各处不如在刚解析完的一刻集中校验。用 Python 的 pydantic 定义模型再对返回数据执行校验不通过就进入修复流程。“二次修复”是我在生产里用得比较多的方法把原始 LLM 输出、完整 JSON Schema、以及解析校验时产生的错误信息一并发给模型让它基于这些上下文重新输出一份合法 JSON。system: 你的上一次输出不符合要求。原始输出为 {{raw_output}}。 解析错误{{error_message}}。 请根据以下 Schema 修正并重新输出 JSON{{schema_str}}。这个方法之所以有效是因为错误信息本身提供了“哪里错了”的强信号模型第二次修正时能精准避坑。实际项目中一次修复的成功率很高基本能把残存的失败率再压一个数量级。5.3 重试、降级与数据可用性标记修复也不是万能的。完整调用链路里最好配上有限次数的重试以及超时后的明确降级策略。我给生产环境的经验是生成阶段总重试次数不超过两次每次重试之间稍微退避一下避免把模型接口打爆。若重试后仍然无法得到合法 JSON就不要硬塞数据。这时候直接把这次调用标记成“数据不可用”写入 trace。宁可错杀不要放过这比让一个字段错位的数据悄悄拿去训练下游模型要好得多。这也引出一个容易忽略的点下游消费逻辑要对“LLM 返回的这份数据”始终保存怀疑态度。把解析结果包在一个带存活状态的数据结构里调用方根据 result status 决定是否可用而不是默认“return 出来的一定是对的”。有了这一层你会发现前面哪怕仍有 0.1% 的失败率对整体服务稳定性的冲击已经非常有限了。我在项目里最终保留下来的习惯是优先使用工具调用或结构化输出锁住 schema实在没有条件就用精心设计的 prompt 低 temperature拿到内容后永远先剥离、再校验、必要时修复、同时限制重试次数。这套组合拳用下来线上由 JSON 解析失败导致的告警才真正从每天几十条降到了几乎为零。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进