做个LLM应用集成你最怕碰到什么我猜十有八九是让模型输出JSON结果它给你来一段带解释的Markdown或者花括号缺一个或者温度一高直接开始“思考人生”。标题里那句“模型输出的JSON不可信”不是夸张是每个调过模型接口的人都懂的血泪。JSON看起来是结构化数据但模型内部是按token概率在生成文字它根本不知道自己“有没有违法JSON规范”。这篇博文就是把我这几年在结构化输出上踩过的坑、沉淀出的四层保障方案完整写出来分享给做LLM应用、Agent、自动化和数据管道的朋友你会发现这套思路能救你无数次。1. 先看翻车现场为什么模型输出的JSON总是不靠谱1.1 翻车现场实录先列几个我真实遇到过的样例你看完就能对上号。第一种叫“礼貌污染”。你要求“只输出JSON”模型还是忍不住回一句“好的以下是您需要的JSON”然后才跟一个代码块。这在人眼里没什么但解析器不认识“好的”。第二种叫“Markdown包装”。模型特别喜欢把JSON塞进json和中间而且还喜欢在JSON末尾加一个换行加一个句号。第三种叫“截断”。max_tokens设置得不够或者上下文太长模型输出到一半就断了最后一个数组或者对象是敞开的。第四种叫“类型漂移”。你的schema写明了price是number模型输出是price: 299.00字符串。你说它错也不算大错但强类型应用一校验就崩。第五种叫“幻觉字段”。schema里根本没定义gender模型觉得“这人应该有性别”自作主张加了一个。加字段倒是不影响解析但下游如果对字段做白名单校验它就会挂。第六种叫“结构失序”。让它翻译一段JSON字段值它直接给你把整个JSON重排了键名被翻译成英文标点全变甚至把大括号转义掉。这些问题的共同点是模型不是故意的它只是在做“最自然的下一个token预测”。“最自然”不等于“合法”更不等于“符合你的schema”。你越是把格式要求当“请求”它就越容易当作可以灵活处理的自然语言。1.2 根因分析概率输出和严格解析器的天然矛盾JSON解析器是确定性工具要求字符串严格匹配RFC 8259。花括号、方括号、逗号、引号、转义符一个都不能错错一个就整段报错。而LLM是概率性组件它生成每个token时都在做“最合理猜测”。格式这种严格规则在模型眼里其实“权重不高”除非你在解码层去限制它。一句话总结模型没有“校验器”解析器不会“通融”这中间如果不加保障结构化输出就是一个薛定谔的JSON——你以为它有内容其实它可能是个错误。此外还有几个客观因素会加剧这个问题温度temperature越高采样越随机格式崩塌概率越大。哪怕temperature是0.7也可能在一个需要输出右括号的位置给它分配了一个低概率但非零的token。上下文噪声如果上下文里有很多无关JSON、示例或对话内容模型会模仿它们而不是你的输出格式描述。Token预算JSON本身带括号、引号、逗号token消耗比自然语言大。预算给少了非常容易截断。指令漂移长上下文中模型对末尾格式要求的注意力越来越弱。你开头说“只输出JSON”聊了十几轮之后它就忘了。1.3 为什么“在提示词里让它别出错”不解决根本问题有人会想我提示词写“必须返回合法JSON否则会爆炸”够狠了吧我实话说单靠狠话失败率可以从10%降到3%但3%在生产环境依然是不可接受的。因为模型永远不可能靠“自觉”达到100%符合外部严格标准的输出。它没有外部世界概念不知道你后面有个json.loads在等着它。所以我们不能把“模型输出可靠”寄托在模型自己身上而要把保障拆到不同层次一层叠一层每一层负责挡住一类问题。这就是我要讲的四层保障提示词约束、解码期约束、宽容解析与修复、Schema校验与兜底。下面一层层讲透。2. 第一层保障提示词硬约束先把出错的概率压到最低2.1 一套直接抄作业的System Prompt模板提示词不能只写“输出JSON”三个字。要像写接口文档一样写清楚。我常用的模板长这样你是一个结构化数据输出引擎。你只输出JSON不输出任何解释、前缀、后缀或Markdown代码块标记。 输出格式必须严格遵循以下JSON Schema描述 { type: object, properties: { summary: {type: string, description: 一句话总结}, keywords: {type: array, items: {type: string}, description: 关键词列表}, confidence: {type: number, description: 置信度0到1之间} }, required: [summary, keywords, confidence] } 规则 1. 不要使用Markdown代码块包裹JSON。 2. 不要添加任何JSON以外的字符。 3. JSON中禁止出现注释。 4. 字符串统一使用双引号。 5. 不要省略任何必填字段。 6. 所有数值保持原始类型输出不要把数字写成字符串。 示例输出 {summary: 这是一个示例, keywords: [示例, 测试], confidence: 0.95}这套模板的核心思路是把格式要求变成“系统配置”而不是“对话请求”。用“引擎”“必须”“禁止”这类词建立规则感效果比“请尽量”好很多。2.2 参数配合temperature、top_p、max_tokens要一起调提示词写好了采样参数不配合也白搭。我的建议很简单temperature设成0或者0.1。结构化输出场景随机性是敌人。你不需要创造性你需要稳定性。0和0.1的区别不大都推荐。top_p设成0.9左右。配合temperature使用不要两个都高。max_tokens要留足余量。如果你预估JSON长度是500字符那至少给到对应token的1.5倍。截断导致的破损后面修复层也有办法但宁可别让它发生。我实测过一个场景同样的提示词和模型temperature0.7时JSON非法率大约6%temperature0时降到0.5%以下。你想想这个差距对一个大流量系统意味着什么。2.3 few-shot示例的注意点提示词里放示例是有效的但有讲究。第一示例一定要给“成功的正例”不要给反例。模型会模仿格式也会模仿你给的“错误报错”结构反例反而可能带偏。第二示例数据不要和真实请求数据撞车否则模型会照抄示例里的值造成字段幻觉。第三示例排版要和期望输出完全一致连空格和换行都要一致。模型对格式非常敏感你示例里用了四个空格缩进它就可能放弃标准缩进去学你。2.4 容易被忽略的细节有几个细节不踩一次坑很难注意JSON Schema描述放在代码块里比纯文本描述有效得多。代码块会强化“这是结构化内容”的信号。中英文提示词对格式约束的影响不大但Schema内的description用中英文都行关键是明确。不要要求“如果出错就返回空对象”。模型对“空对象”的理解五花八门有时候返回{}有时候返回null有时候返回None。真出错时你应该让校验层去处理不要寄希望于模型自己判断。尽量在提示词里禁止注释。有些模型在面向开发者的对话里被训练得很爱在JSON里加注释// 这是用户ID这种解析器直接不认识。第一层能挡掉大部分低级问题但挡不住“格式合法但内容缺失”“合法但类型不对”这些靠后面几层。3. 第二层保障解码期约束从生成那一刻就不给乱说话的机会3.1 软约束 vs 硬约束提示词再强本质上是“软约束”。模型可以遵守也可以不遵守。而第二层保障“解码期约束”是“硬约束”它在模型生成每个token时直接限制下一个可选token的范围。不是生成完再校验而是生成过程中就不让它生成非法字符。我把这个比作提示词约束是“我跟你说别乱走”解码约束是“我直接把你关在格子里走”。效果天差地别。3.2 三种解码期约束的实现路径第一种商业API自带的结构化输出能力。比如OpenAI兼容接口里的response_format参数response client.chat.completions.create( modelgpt-4o, response_format{type: json_object}, # 强制JSON对象 messages[ {role: system, content: 你只输出JSON对象}, ], )更严格的是JSON Schema模式response client.chat.completions.create( modelgpt-4o, response_format{ type: json_schema, json_schema: { name: user_profile, strict: True, schema: { type: object, properties: { name: {type: string}, age: {type: integer}, }, required: [name, age], additionalProperties: False } } }, messages[...] )strict: True意味着模型被约束在完全匹配schema的token空间内。additionalProperties: False能阻止幻觉字段。这一档已经能做到很高的可靠性。第二种函数调用Function Calling / Tool Calling。把你要的结构定义成工具函数的参数让模型通过“调用工具”的方式来输出。这比裸输出JSON更稳因为很多模型在函数调用场景下行为被额外微调过。实际项目里如果下游能接受“这是一次函数调用”的语义我建议优先用。第三种开源模型侧的约束解码。如果你在跑本地或私有化模型可以用outlines、guidance、XGrammar这些框架。它们的原理是给模型加载一个“词汇表遮罩”每当模型要生成下一个token框架会把不符合语法的token概率全部置为0模型只能在合法集合里挑。举个例子用outlines强制输出json_schema定义的结构from outlines import models, generate model models.transformers(Qwen/Qwen2.5-7B-Instruct) schema { type: object, properties: { name: {type: string}, score: {type: number} }, required: [name, score] } generator generate.json(model, schema) result generator(请输出用户信息和分数)生成的result每一步都被约束在schema内不可能出现少括号、多逗号、类型错乱这类问题。这类方案在私有化部署场景非常香。3.3 解码约束不能解决什么话不能说满解码约束有它的边界。**它能保证语法合法不能保证语义正确。**模型还是有可能输出一个“结构完全合规但数值是编造的”JSON。它不能解决“整个JSON被截断”。如果max_tokens确实不够约束解码只能保证“截断在语法尽可能完整的位置”不能保证生成你想要的完整性。所以max_tokens余量依然重要。**它会让部分小模型变笨。**约束解码本质上缩小了模型的输出空间对复杂推理任务来说效果可能略有下降。不过对纯结构化抽取任务掉一点点性能是值得的。**它仍然需要第四层Schema校验兜底。**因为API的“strict”和你业务层的“必填、枚举、范围、格式”并不是一回事业务校验必须单独做。4. 第三层保障宽容解析与修复把脏数据收拾成能用的数据4.1 为什么还需要这层你可能想有了解码约束还需要解析修复吗需要。因为你实际遇到的输入来源很杂可能是老模型不支持结构化输出可能是某个框架返回的是Markdown包装可能是一个文本文件里粘着JSON也可能是用户直接把一段“看起来像JSON但不是JSON”的东西贴进了输入框。这些时候解铃还须系铃人先做“宽容解析”。4.2 从文本中提取JSON块的实用方法面对一段混合文本最稳妥的做法不是从头解析而是先“扣出”JSON块import json def extract_json_block(text: str) - str: if not text: raise ValueError(empty text) # 去掉Markdown代码块围栏 if in text: segments text.split() # 优先选包含{的片段 for seg in segments: if { in seg or [ in seg: text seg break # 找第一个{或[然后做括号配对 start_chars [] for i, ch in enumerate(text): if ch in {[: start_chars.append((i, ch)) if not start_chars: raise ValueError(no json start found) # 对每个候选起点做配对扫描返回第一个能配平的 pairs {{: }, [: ]} for start_idx, open_ch in start_chars: stack [] for i in range(start_idx, len(text)): ch text[i] if ch in pairs: stack.append(ch) elif ch in pairs.values(): if not stack: break expected pairs[stack.pop()] if ch ! expected: break if not stack: # 完全配对 return text[start_idx:i 1] raise ValueError(unbalanced json)这个函数的核心思想是别先解析先配对。花括号配平了提取出来的字符串大概率是一个完整JSON。遇到嵌套的情况配对扫描能保证不会从{直接跳到一个错误的位置。对截断输出它会抛异常你直接进入重试逻辑。4.3 修复与规范化json_repair帮你收拾残局提取到的字符串还是可能不合法比如单引号、尾逗号、中文冒号、undefined当值用。这时候可以用json_repair这类库做一次修复from json_repair import repair_json raw {name: 张三, score: 95,} fixed repair_json(raw) print(fixed) # 输出: {name: 张三, score: 95}json_repair的修复策略主要有把单引号转双引号、去尾逗号、把裸词转字符串、修正布尔大小写、补全缺失括号。它比你自己写正则要稳健得多。但要注意一个修复边界修复只能接受“确定性的格式错误”如果修复改变了一个字段的字面含义那宁可报错也不要采用。举个极端例子——原始输出是{age: 二三十}修复器不可能知道该把它变成整数30还是字符串这时候加一个“校验层”发现类型不合规然后重试比让修复器胡猜强得多。4.4 解析后的验证与查询jq是你的左膀右臂解析通过之后先别急着用拿jq查一查结构。jq就是那个“json查询函数”能过滤、映射、排序、校验。echo {name:张三,tags:[a,b]} | jq .tags[0] # a # 校验是否包含某字段 echo {name:张三} | jq has(name) # true # 定位数组长度 echo [1,2,3] | jq length # 3排查模型输出时我最常用的jq命令是# 只看整体结构不看具体内容 echo {a:1,b:{c:2}} | jq . | keys # [a,b] # 提取嵌套字段的值 echo {b:{c:2}} | jq .b.c # 2这比用Python反复写json.loads调试快多了。顺便回应一个很基础的搜索词“json用什么打开”其实不用装任何“打开器”用VS Code直接看带语法高亮或者直接python -m json.tool格式化要查询就用jq。4.5 场景让模型翻译JSON字段值而不破坏结构这是个高频翻车场景。你以为让模型“翻译这个JSON”它会老老实实只翻译字符串值。实际上它可能连键名都翻译了或者把整个结构重排或者把花括号转义成\u007b。正确做法是先解析成Python对象再只翻译值最后序列化import json data json.loads(original_json) translated data def translate_value(v): if isinstance(v, str): # 这里调用你的翻译模型 return translate_model(v) if isinstance(v, list): return [translate_value(item) for item in v] if isinstance(v, dict): return {k: translate_value(val) for k, val in v.items()} return v translated translate_value(data) output json.dumps(translated, ensure_asciiFalse)这种“先解析、后处理、再序列化”的流水线比让模型直接改文本稳定一百倍。翻译这种操作本身就是确定性的字段级处理不该交给概率生成器去重构整个结构。5. 第四层保障Schema校验与重试兜底让系统不会因为一次坏输出全线崩溃5.1 用JSON Schema把“合法JSON”升级为“业务可用JSON”第三层保障解决的问题是“字符串是合法JSON”但合法JSON不等于业务可用。你要的是必填字段都在、类型正确、取值在合理范围。这就是JSON Schema的用武之地。举个例子一个用户画像对象{ type: object, properties: { user_id: {type: string, minLength: 6}, age: {type: integer, minimum: 0, maximum: 120}, gender: {enum: [male, female, other]}, tags: { type: array, items: {type: string}, maxItems: 10 } }, required: [user_id, age], additionalProperties: false }这个schema比“模型提示词里的schema描述”严格得多integer不能是浮点enum只能是三个值additionalProperties禁止多余字段。解析器只校验“语法”这个schema校验“语义和业务约束”。5.2 校验工具与错误信息提取Python生态用jsonschema库import jsonschema from jsonschema import validate, ValidationError schema { ... } # 上面那段 try: validate(instancedata, schemaschema) except ValidationError as e: # e.message 非常关键把它回传给模型做修正 print(校验失败:, e.message) print(出错路径:, list(e.path))这里有个关键技巧校验失败的错误信息不能扔要喂给模型做下一轮重试。模型需要知道“age字段应该是integer你给的是字符串十八”。错误信息越具体重试成功率越高。5.3 带错误信息的重试策略重试也不是简单重发一遍原prompt。我把重试模板写成这样你上次输出的JSON未通过业务校验。 原始输出 {{ previous_output }} 校验错误 {{ validation_error }} 请根据错误信息修正后重新输出JSON。只输出JSON本身。每轮重试时把上一轮的原始输出和校验错误一起塞回去。我测过带错误信息重试第二轮成功率大概能到85%以上第三轮还能再提几个点。但重试次数不是越多越好一般设2到3次就够了。次数太多延迟和成本都受不了而且模型可能开始猜答案。5.4 降级策略结构化不行就半结构化再不行就人审重试次数用尽还是过不了校验就得降级。降级也是分级的结构化输出成功正常走业务流程。结构化失败宽松文本成功降级为“自然语言摘要关键字段抽取”至少业务还能用模糊匹配兜住。结构化失败文本也失败把原始输出交给人工组件复核或者直接标记为“解析失败”进入待审队列。生产级系统必须有这个降级路径。不要为了“必须结构化”把整个流程卡死。另外前端还有一个类似场景用户在输入框粘贴JSON提交。这个输入框也别直接JSON.parse就完事同样过一遍“宽容解析Schema校验错误提示”体验会好很多。我见过太多用户把带尾逗号的JSON贴进去然后一脸懵的情况。5.5 可观测性把失败样本变成模型迭代的燃料第四层保障还有一个隐藏价值它给你提供了失败样本的采集点。每次校验失败都可以把原始输出、修复动作、校验错误、重试次数、最终结果记录到日志里。这些样本比什么评估集都真实是迭代提示词和调优模型的最佳素材。我在项目里会专门落一张表字段就包括raw_output、extracted_json、repair_log、validation_error、retry_count、final_status。每过一段时间就抽样看失败模式如果某种错误反复出现就针对性调整第一层提示词或者第二层schema定义。没有第四层的日志你压根不知道模型在真实世界里怎么犯错的。6. 完整实战四层保障组合成一条可用的生产流水线6.1 整条流水线长什么样用文字描述一下完整流程调用模型拿到原始文本先进“提示词约束”这层做第一遍规范如果用的是支持结构化输出的API在请求阶段就已经加上了“解码期约束”拿到原始文本后过“提取JSON块”函数扣出可能的JSON片段再用json_repair做修复修复成功后用jsonschema做业务校验校验不过就把原始输出和错误信息一起带进重试流程重试次数用完还不行就降级。这四层不是串联依赖而是层层兜底前面任何一层漏过去的下一层都能拦一下。6.2 核心代码一个可落地的四层保障函数下面这个类是我实际生产代码的简化版把四层串在一起import json from json_repair import repair_json from jsonschema import validate, ValidationError class StructuredOutputEngine: def __init__(self, schema: dict, max_retries: int 3): self.schema schema self.max_retries max_retries def _call_model(self, user_msg: str, previous_outputNone, validation_errorNone) - str: # 这里接入你的模型调用OpenAI兼容或自建均可 messages [ {role: system, content: self.system_prompt()}, {role: user, content: user_msg}, ] if previous_output and validation_error: messages.append({ role: user, content: ( f上次输出未通过校验\n{previous_output}\n f错误信息\n{validation_error}\n请修正后只输出JSON。 ) }) # response client.chat.completions.create(... response_format{type: json_object}) # 返回 response.choices[0].message.content pass def system_prompt(self) - str: schema_str json.dumps(self.schema, ensure_asciiFalse) return ( 你是一个结构化数据输出引擎。只输出JSON不输出任何解释或Markdown标记。 fJSON必须满足如下Schema\n{schema_str} ) def extract_and_fix(self, raw_text: str) - str: if in raw_text: raw_text raw_text.split()[1] if raw_text.count() 2 else raw_text return repair_json(raw_text) def generate(self, user_msg: str): previous_output None validation_error None for _ in range(self.max_retries): raw_text self._call_model(user_msg, previous_output, validation_error) try: fixed self.extract_and_fix(raw_text) data json.loads(fixed) validate(instancedata, schemaself.schema) return data # 四层都过返回干净数据 except ValueError as exc: validation_error fJSON格式错误: {exc} except ValidationError as exc: validation_error exc.message except json.JSONDecodeError as exc: validation_error fJSON解析失败: {exc.msg}位置 {exc.pos} previous_output raw_text # 重试用尽降级 raise RuntimeError(结构化输出失败请人工复核)你看这个函数的使用方式engine StructuredOutputEngine(schemauser_schema) data engine.generate(请抽取用户信息)如果正常返回data就是一个经过校验的干净对象。如果抛异常外层就接住走降级流程。6.3 分层使用建议不同场景用不同层数不是所有场景都需要四层全上。我建议按可靠性要求分级场景建议组合理由内部工具、原型Demo第一层 第三层成本低够用一般业务接口第一层 第二层 第四层对解析器友好对数据可靠涉及钱、身份、订单四层全上 人工/异步复核任何概率事件都不能直接放行数据管道批量处理第二层 第三层 第四层一条坏数据会拖垮整个批次数据管道场景额外提醒一句如果你用Spark之类的框架消费模型输出JSON把“解析修复校验”封装成一个UDF因为批次里只要有一条非法JSON整个task都可能失败。6.4 并发写入和JSON文件版本冲突的小坑最后补一个实战小坑。如果你的服务是多实例并发把模型输出保存到同一个JSON配置文件里很快你就会遇到“这个文件被覆盖/被改坏了”的问题。Git上体现就是json merge conflict——两个分支都改了同一行JSON合并不了。我现在的做法是每个输出写独立文件不要所有人挤一个文件文件内加version字段覆盖前先校验版本号如果确实是多人维护同一个配置注册表式管理用唯一键合并不要整文件覆盖。这个坑和模型关系不大但往往是结构化输出工程化之后立刻会遇到的顺手记一下。7. 常见问题与排查技巧实录7.1 拿到一串无法解析的文本怎么快速定位第一步先分清是“包了一层”还是“内容坏了”。用jq看一眼echo {a:1} | jq . # 能出结果说明内容是好的如果jq也报错就看报错位置echo {a:1,} | jq . # parse error: Expected another key-value pair at line 1, column 8列号会告诉你大概哪个位置出问题。再配合肉眼扫一眼通常能立刻判断是尾逗号还是单引号还是中文字符导致的。第二步是把“整个输出”放进一个临时文件不要只看模型返回的字符串。我经常加一个调试中间件打印原始输出的前200字符和最后200字符。很多解析问题光看中间看不出来开头和结尾反而是判断截断和包装的关键。第三步判断是不是截断如果JSON末尾明显少了一个右括号而且正好在max_tokens边界附近那就是截断加max_tokens或缩短输出提示词。7.2 高频问题速查现象常见原因解决方案JSON被Markdown代码块包裹模型默认输出风格提取函数处理fence或第一层提示词明确禁止输出带“好的”“以下是”等文字对话惯性提示词声明“只输出JSON”并用解码约束单引号、尾逗号、undefined值模型对JSON规范掌握不严json_repair修复截断不完整max_tokens不足增大token余量或拆分成更小的输出单元数字变成字符串类型漂移schema校验重试多出未定义字段幻觉schema里additionalProperties设为false日期格式不统一schema缺乏format约束自定义validator校验日期翻译任务中JSON结构被改让模型直接改文本先解析成对象只翻译字符串值再序列化中文乱码编码问题读取/写入文件用utf-8json.dumps设ensure_asciiFalse7.3 工具体验清单jqJSON查询和校验的瑞士军刀强烈建议安装。json_repairPython库宽容解析的救星。jsonschema业务校验的标准库没有替代品。outlines / XGrammar开源模型约束解码的两大主流方案。在线JSON格式化工具偶尔用可以但别把敏感数据粘上去。如果你是接口测试场景想用JMeter做JSON提取我提醒一句JSON Extractor对非法JSON同样无能为力先在外面用jq或json_repair清洗一遍再提取否则每次跑测试都会遇到“结果为空”的玄学。我把这套四层保障从第一版踩坑踩到现在最大的体会就是永远不要相信模型会自觉输出合法JSON但你可以相信一套设计得当的管道能把它的错误挡在业务之外。做LLM应用的人核心能力不是调一次模型调得有多好而是能不能把一个概率事件变成工程上可控的流程。四层保障看完你可以直接照着这个思路改自己的项目别等线上出事故再补。