
做 LLM 应用开发的人大概率都经历过这样的瞬间明明在 prompt 里写了“请以 JSON 格式返回”结果模型给了一坨长得像 JSON 的文本里面混着解释、Markdown 代码块甚至还有 Python 的None。我刚接手“客服工单结构化”项目时也栽过跟头。需求很简单把一段自由文本抽成带固定字段的 JSON比如sentiment、product、issue、resolution因为涉及后续的数据库入库和前端渲染我们必须依赖结构化输出。结果测试环境跑了一天日志里出现了三种怪东西第一种JSON 前面多了一句“好的我为您抽取如下信息”第二种整个返回被三个反引号包起来第三种字段值里出现None而不是null。这次的经历让我把“模型输出的 JSON 不可信”当成了第一原则。这篇是这个系列的第二篇我会完整拆解一套实践下来的“结构化输出四层保障”提示词软约束、解码层硬约束、解析与 Schema 校验、失败兜底循环。每层解决什么问题、边界在哪里、怎么落地我都会结合真实踩坑记录讲清楚。如果你正在做 Agent、数据抽取、报表自动化或者任何需要让大模型稳定返回 JSON 的场景这篇文章应该能帮你少走不少弯路。1. 先坦白一个问题模型输出JSON时十次里总有几次是“伪JSON”1.1 一个我真实遇到过的惨烈案例当时我负责的项目是“客服工单结构化”。原始数据是一段自由文本比如“昨天买的无线鼠标用了半天就失灵了找客服换货态度还可以但是流程特别慢。”我们的目标是从里面抽取一个 JSON 对象包含review_text、sentiment、product_items这样的字段。需求评审时所有人都觉得这事儿简单让模型直接输出一个 JSON 对象不就行了。结果第一个版本的 prompt 写得很天真请从以下文本中抽取信息输出JSON。 文本...听起来没毛病对吧线上跑了一天后我看到的失败形态包括输出前面带着一句“好的我为您抽取如下信息”输出被json 代码块围住字段名变了模型自己发明了心情而不是sentiment字段值出现None这不是合法 JSONJSON 对象末尾多了个逗号json.loads直接抛JSONDecodeError最离谱的是拿一条很短的文本去测模型有时会返回一个空对象{}。你问它为什么它不会回答你——因为在它的概率世界里一段话后面跟{}也是“合规”的续写方式。这件事给我的第一个教训是模型返回的字符串在成功解析之前一律按“它只是长得很像 JSON 的文本”处理。哪怕调用的是 GPT-4o 这类大模型也不能把它的输出直接当成数据。1.2 为什么语言模型就是会犯这种低级错误这里有一个基础但很重要的认知。LLM 的训练目标是“预测下一个 token”。无论我们看到的生成文本多流畅模型内部其实是在做一个概率分布采样每一步都在选“最像人类会接着写的那个 token”。JSON 这种格式在训练语料里出现得非常多所以模型学会了“像 JSON 一样去续写”但它并没有内置一个 JSON 语法解析器。你可以把模型想象成一个模仿能力很强的实习生。你让他抄一份会议记录他能写得有模有样但它不保证所有括号都闭合、所有逗号都合法。语法规则不会被精确地“记住”它只是被“概率化地模仿”。更麻烦的是模型还会模仿各种脏东西训练语料里常见的前置寒暄语比如 “Sure! Here is the JSON:”某些场景输出 Markdown 代码块的习惯Python 风格字面量None、True、False混进 JSON超长输出被max_tokens截断后最后一个对象只剩一半这些都不是小概率事件。按照我在自己项目里做过的抽样统计不加任何保障措施的情况下一次直接解析 JSON 的成功率大约在 70% 到 90% 之间。看起来还行但如果你每天要跑十万条失败数量就是很可观的一批。1.3 常见的五类失败形态我把踩过的坑归纳成五类建议直接当成一张“失败清单”来看失败类型典型表现影响语法层失败括号不闭合、尾逗号、单引号、Nonejson.loads直接抛异常包装层失败前后有解释文字、Markdown 代码块能找到 JSON 子串但必须先清洗结构层失败字段缺失、字段名改变、多出无关字段解析能通过但业务逻辑拿不到数据值域层失败枚举值写错、类型变成字符串、日期格式错误校验才能发现解析阶段看不出来截断层失败输出到达max_tokens上限JSON 只生成了一半解析与校验都失败必须重新生成后面整个四层保障本质上就是逐层去堵这五类问题。2. 第一层保障提示词工程不是玄学是第一次缓冲带2.1 什么样的提示词能显著减少JSON错误第一层保障成本最低也最容易被忽略。不要觉得这不是技术活实际上一个写得好的格式约束提示词能直接消化掉大部分“包装层失败”和一部分“结构层失败”。我在项目里沉淀了一套固定模板核心是四个要素。要素一明确声明输出格式为严格 JSON。不要写“请以 JSON 格式输出”要写“只输出一个 JSON 对象不要输出任何解释、注释、Markdown 标记或额外文本”。重点是“只”。模型对指令中出现的强限定词很敏感“ONLY”这个词在英文 prompt 中的约束力远大于“Please”。要素二给出 JSON Schema 定义。光说“JSON”不够模型不知道你需要的字段和类型。把 Schema 转成字符串放进 prompt让模型照着填。这一步能显著减少“字段名改变、字段缺失”这类结构层失败。要素三提供一句“不符合的后果”。例如“如果你的输出无法被 Python 的json.loads解析系统会拒绝这笔数据。”这听起来像威胁但对模型确实有约束效果——因为它在训练数据里见过很多类似的指令知道这类任务通常要求严格输出。要素四给一个示例输出。一个 few-shot 示例比十句描述都管用。尤其对于嵌套结构、枚举值这种容易出细节的地方示例直接把“长什么样”钉死了。我常年在用的一段 prompt 长这样You are a data extraction engine. Return ONLY a valid JSON object. Do NOT include any prose, code fences, or explanations. The JSON must match this schema: {schema_json} Input: {input_text} Output:注意我这里是英文 prompt。不是说中文不行而是我在 A/B 测试中发现英文 prompt 与主流模型的训练数据分布更接近对格式指令的理解更稳定。你可以自己测但不要纯靠感觉。2.2 为什么软约束永远只是“缓冲带”而不是“安全网”很多文章到这里就结束了好像提示词写得好就能保证 JSON 合法。我必须泼一盆冷水软约束是靠不住的。原因很简单所有提示词指令本质上是“概率分布的偏置”不是“语法层面的禁行”。哪怕你把指令写得再清楚模型仍然有可能在少数情况下跑偏。而且某些模型特别是小参数模型、量化过的本地模型对指令的服从性本来就弱prompt 稍微复杂一点格式就开始崩。我在内部测试时碰到过一个特别无语的例子prompt 里已经写了“不要输出解释”结果模型在 JSON 前面加了一句“请注意”。为什么因为训练数据里恰好有一种模式模型觉得如果前面加一句提示更能“帮到用户”。所以在四层架构里我把提示词工程定义为第一次缓冲带——它把错误率从百分之几十压到几个百分点但它自己不足以支撑生产环境。3. 第二层保障解码层硬约束让模型只能吐出合法JSON既然提示词压不住那就上物理手段。第二层是在模型解码阶段做文章核心思想是在每一步采样时把“不可能构成合法 JSON 的 token”直接屏蔽掉。3.1 云端API的JSON Mode与它的边界现在主流模型厂商都提供了一定程度的结构化输出能力平台方式具体约束OpenAIresponse_format{type: json_object}保证输出是合法 JSON 对象但不保证 schema 匹配Geminiresponse_mime_typeapplication/json类似强制结构化响应Anthropic Claudeprompt 约束为主可通过工具调用来间接约束结构我最常用的就是 OpenAI 的 JSON mode。它解决的最大问题是“语法层失败”——你基本上不会看到单引号、None、尾逗号这些低级问题了。但文档明确说它不保证字段与类型符合你的要求。也就是说模型可能输出一个合法 JSON但里面没有你要求的字段或者字段类型不对。还有两个容易踩的坑如果你在 prompt 里完全没提“JSON”两个字接口会直接报错。JSON mode 仅保证输出是对象以{开头如果你需要数组或字符串得再包一层对象。如果你用的是支持 Strict 结构化输出的接口它会在解码层做 Schema 级别的约束比老的json_object更可靠。它同时要求 prompt 里必须包含完整的 Schema 描述。代价是出错时的调试成本更高但收益也更大。在实际项目里我把 JSON mode 当成“保底语法合法”的手段但从来不指望它做 schema 匹配。3.2 本地模型的Grammar约束Outlines/Jsonformer/vLLM如果你用的是私有化部署的模型或者需要离线环境那就得走我们常说的 constrained decoding。这个领域的代表工具有 Outlines、Jsonformer、llama.cpp 的 GBNF 文法以及 vLLM 的 guided decoding。它们的基本原理可以统一概括为将 JSON Schema 或一个正则表达式编译成有限状态机FSM。在模型每一步生成 token 前遍历当前词表中所有 token只保留那些“能让状态机继续往前走”的 token。其余 token 的概率被强制设为-inf模型只能在合法集合里采样。这样生成出来的序列从第一毫秒开始就是合法的。比起“生成之后再校验”这等于把错误从源头干掉。打个比方普通生成是让施工队在空地自由施工constrained decoding 则是在路段两边拉上围挡——你想走错路都走不了。我个人的选型经验是需要复杂嵌套 Schema 时用 Outlines 或 vLLM 的 guided decoding因为它们能把 JSON Schema 翻译成 FSM。需要精确的正则约束比如日期格式\d{4}-\d{2}-\d{2}用 Outlines 的 regex 功能直接锁格式。在 llama.cpp 这类轻量场景下可以手写 GBNF 文法文件虽然麻烦但很灵活。这里有一段使用 Outlines 的典型代码from outlines import models, generate model models.transformers(Qwen/Qwen2.5-7B-Instruct) schema { type: object, properties: { sentiment: {type: string, enum: [positive, negative, neutral]}, product: {type: string}, }, required: [sentiment, product], additionalProperties: False, } generator generate.json(model, schema) text 昨天买的无线鼠标用了半天就失灵了 result generator(f抽取如下文本中的信息{text}) print(result)这段代码你不需要写各种try/except因为generator在内部保证输出结构满足 schema——字段存在、类型正确、枚举值正确。用着是挺爽但要注意这种硬约束在遇到中文等 token 化方式复杂的文本时有时会限制表达多样性如果 Schema 写得太复杂FSM 状态数会膨胀生成速度会明显下降。另外一点硬约束模式下温度通常会被调低因为很多实现要求使用 greedy 或低随机采样高温度会和 masked logits 打架。3.3 硬约束解决不了的两个盲区即使你上了硬约束依然有两个盲区。盲区一语义对错。模型可以输出一个完全符合 Schema 的 JSON但内容是错的。比如把“无线鼠标”抽取成“无线键盘”。语法和结构保住了业务逻辑仍然可能要你的命。盲区二截断问题依旧存在。别被“模式”两个字骗了。如果你本地加载一个很小的模型或者跑一个过时的基础模型约束解码的 FSM 只能保证生成过程不越界但如果模型在生成中途因为上下文耗尽被截断你依然会拿到一个不完整对象。云端大模型相对好一些但本地小模型必须自己处理截断。所以第二层保障的定位是把语法层和结构层错误压到接近零但剩下的值域层和截断层必须靠后面两层。4. 第三层保障拿到结果之后先别高兴解析和Schema校验才是真相无论你用了多牛的提示词、多强的约束解码只要你的流程需要对接外部系统、数据库或者前端都要在“模型输出”和“业务代码”之间立一道关卡。这就是第三层解析与校验。4.1 从文本到对象这一步有多少隐藏坑最简单的做法是json.loads(output)。但别想当然这里藏着几个很容易中招的坑。坑一None和NaN。Python 的json.loads默认是允许NaN、Infinity、-Infinity这些非标准值的但它不允许None、True、False。很多同学不知道NaN会被解析成float(nan)后面做数值比较时nan ! nan这种鬼问题就来了。坑二Markdown 代码块。模型如果输出{sentiment: negative}直接json.loads必然失败。必须先剥掉 包裹层。坑三前导解释文本。模型可能会在对象前输出Sure! Here is your JSON:。你需要做一次“提取子串”操作——取第一个{到最后一个}之间的内容。坑四尾逗号。{sentiment: negative,}在严格 JSON 里是非法的但部分宽松解析库会接受。我建议不要依赖非标准解析直接在清洗阶段把尾逗号去掉。坑五数字被写成了字符串。模型常把quantity输出成3而不是3。如果你不校验类型后面跟数据库交互时就会出类型错误。我把这些处理写成了一个工具函数见后面第六章的clean_and_parse。它不是万能的但能消化掉大部分“包装层失败”。4.2 JSON Schema字段、类型、枚举、嵌套解析成 Python 对象之后就要做真正的“领域校验”。这里我强烈建议使用 JSON Schema jsonschema这个组合而不是自己写一堆if isinstance(...) and ...。原因有二JSON Schema 是声明式的业务规则一目了然便于代码评审。校验错误是结构化的可以在重试时直接喂回模型这就是第四层的关键输入。举个例子import jsonschema review_schema { type: object, required: [review_text, sentiment, product_items], additionalProperties: False, properties: { review_text: {type: string, minLength: 1}, sentiment: {type: string, enum: [positive, negative, neutral]}, product_items: { type: array, items: { type: object, required: [product_name, quantity], additionalProperties: False, properties: { product_name: {type: string}, quantity: {type: integer, minimum: 1} } } } } } try: jsonschema.validate(instanceparsed, schemareview_schema) print(schema ok) except jsonschema.ValidationError as e: print(e.message) # 例如sentiment is a required property print(e.json_path) # 例如$你注意additionalProperties: False这条非常关键。如果不设置模型会随意加一堆它自己发明的字段你的前端和数据库都很难处理。设置了之后只要多一个字段校验直接报错。不过这里的取舍也要清楚additionalProperties: False会让模型输出稍微“僵硬”一点。我的建议是在面向业务的数据结构上设为 False在允许自由备注的场景比如raw_response设为 True。4.3 校验失败时的排查思路校验失败不要慌。先把错误类型分成三类JSONDecodeError语法不过关大概率是包装或截断问题。ValidationError语法过了但结构/值域不过关大概率是字段缺失或类型错误。业务校验错误比如日期解析失败、金额为负。这种最隐蔽因为 Schema 检查不出来。排查思路是这样把原始输出完整存进日志截断成一个易于复现的最小样例。手动用json.loads试一次确定是语法问题还是结构问题。如果是语法问题先走清洗逻辑剥 Markdown、提取{...}清洗后再解析。如果是结构问题记录schema和validation_error.message作为第四层的重试输入。日志里永远要保留“模型原始输出”和“清洗后输出”两份。不要只记录最终结果否则出了问题你只能靠猜。提示不要在生产环境里只用json.loads一个函数来解析模型输出它只能拦住最浅层的语法问题。真正有价值的校验是 Schema 校验和业务校验。5. 第四层保障失败的兜底循环——修复、重试、降级前三层之后错误率已经降得很低了。但生产环境要求的是“哪怕出错也不能让流程断掉”。所以最后一步是把“失败”变成“可控的失败”。这就是第四层的意义。5.1 自动修复把错误信息喂回模型第二层和第三层构建的“边界”恰好给第四层提供了燃料当解析或校验失败时我们手里有原始输出和错误信息。为什么不直接让模型自己改这是我最推的做法比单纯重新生成靠谱得多。盲猜重试等于是让模型再做一遍题它很可能再犯同一个错而把具体的 error message 给它等于告诉它错误在哪里。修复的 prompt 长这样Your previous output could not be parsed or did not match the required schema. Here is the original output: {raw_output} Here is the error: {error_message} Please output ONLY the corrected JSON object, nothing else.注意几点把错误信息格式化得尽量直白。不要只丢一个 Python 异常字符串比如Expecting property name enclosed in double quotes对模型来说有点抽象可以转换成“你输出的字段名必须使用双引号”。保持温度很低例如 0.0 或 0.2修复任务没有多样性需求稳定优先。限制修复次数。我的默认值是 2 次最多 3 次。5.2 重试策略与指数退避别把API打爆修复也是一种重新调用所以必须考虑并发和费用。我常用的策略是重试次数上限max_retries 2第 3 次失败就走降级。指数退避第一次失败后等 0.5 秒第二次等待 1 秒最多等 2 秒。这是给服务端喘息空间也避免触发限流。并发控制如果服务 QPS 很高用信号量或队列限制同时进行的模型调用防止服务商限流。对反复失败的数据做标记如果同一条数据连续失败多次说明模型可能确实处理不了继续死磕没有意义。另外一个容易被忽视的点重复调用之间要保证输入一致。不要把第一次的原始输出直接拿来当第二次的输入否则模型会越改越偏。修复调用中原始输出只能作为“待修正的内容”出现真正要分析的对象还是最初的那条用户输入。5.3 最终降级宁可返回半成品不要返回崩溃如果修复多次仍失败你需要一个“降级出口”。什么是降级出口就是流程无论如何都会有一个确定的结果不管是给前端返回一个带错误标记的对象还是丢给人工处理。我常用的降级结构是{ ok: false, raw_input: 原始文本, raw_output: 模型原始输出, parsed_data: null, error: { stage: validation, message: ... } }为什么不干脆抛异常交给上层因为在上层尽心处理之前“至少返回一个结构”能让日志、监控、队列补偿都正常工作。你可以用字段stage标记错误发生在哪一层parse/schema/business这样监控面板上就能直接看到失败分布。另一个更细的策略是“部分可用”如果product_items这个字段解析出来了但sentiment缺失那我可以只把缺失字段置空其余字段照常入库。这样虽然少了一个维度但总比整条数据废掉好。这里的关键是部分可用必须提前定义好哪些字段是强需求、哪些是弱需求。Schema 校验失败时先做字段级拆分能救多少救多少。6. 四层保障串起来一个端到端的抽取服务示例前面四层拆开讲完了最后把它们组装成一个完整的代码示例。这个服务处理“客户反馈结构化抽取”我会一步步展示每一层是怎么拦截错误的。6.1 完整的Python实现下面的代码可以跑通整个流程调用模型 → 清洗解析 → Schema 校验 → 失败自动修复 → 达到上限后降级。import json import re import time from openai import OpenAI from jsonschema import validate, ValidationError client OpenAI() SCHEMA { type: object, required: [review_text, sentiment, product_items], additionalProperties: False, properties: { review_text: {type: string, minLength: 1}, sentiment: {type: string, enum: [positive, negative, neutral]}, product_items: { type: array, items: { type: object, required: [product_name, quantity], additionalProperties: False, properties: { product_name: {type: string}, quantity: {type: integer, minimum: 1} } } } } } def build_prompt(text: str) - list[dict]: return [ {role: system, content: You are a strict JSON extraction engine.}, {role: user, content: ( Return ONLY a valid JSON object. No explanations, no code fences, no extra text.\n\n Schema:\n json.dumps(SCHEMA, ensure_asciiFalse) \n\n Input:\n text )} ] def call_model(messages: list[dict]) - str: resp client.chat.completions.create( modelgpt-4o-mini, response_format{type: json_object}, messagesmessages, temperature0.0, ) return resp.choices[0].message.content def clean_and_parse(raw: str) - dict: # 剥代码块 fence re.compile(r(?:json)?\s*(.*?)\s*, re.DOTALL) m fence.search(raw) if m: raw m.group(1) # 提取第一个 { 到最后一个 } start, end raw.find({), raw.rfind(}) if start -1 or end start: raise ValueError(no JSON object found) candidate raw[start: end 1] return json.loads(candidate) def validate_data(data: dict) - None: validate(data, SCHEMA) def repair(raw_output: str, error: str, text: str) - str: messages build_prompt(text) messages.append({role: assistant, content: raw_output}) messages.append({role: user, content: ( Your last output was rejected. Here is the error:\n error \nFix the JSON so that it passes validation. Return ONLY JSON. )}) return call_model(messages) def process(text: str, max_retries: int 2, backoff: float 0.5) - dict: last_error unknown raw call_model(build_prompt(text)) for attempt in range(max_retries 1): try: data clean_and_parse(raw) validate_data(data) return { ok: True, data: data, attempts: attempt 1, raw_output: raw, } except Exception as e: last_error f{type(e).__name__}: {e} if attempt max_retries: time.sleep(backoff * (2 ** attempt)) raw repair(raw, last_error, text) return { ok: False, data: None, attempts: max_retries 1, raw_output: raw, error: last_error, }调用方式很直接review 昨天买的无线鼠标用了半天就失灵了找客服换货态度还可以但是流程特别慢。 result process(review) print(json.dumps(result, ensure_asciiFalse, indent2))6.2 用三组真实输出看每一层是如何拦截的拿三组常见的模型输出演示整个流程。样例一模型在 JSON 前加了解释文字Sure! Here is the JSON: {review_text: 昨天买的无线鼠标用了半天就失灵了。, sentiment: negative, product_items: []}clean_and_parse会先找到第一个{再找到最后一个}把中间的文本作为 JSON 解析。如果中间还夹着解释文字则解析仍会失败进入repair。样例二模型输出了 Markdown 代码块json {review_text: 昨天买的无线鼠标用了半天就失灵了。, sentiment: negative, product_items: []}clean_and_parse 中的正则 fence 会先剥掉代码块再正常解析。 **样例三模型输出合法 JSON 但类型错误** text {review_text: 昨天买的无线鼠标用了半天就失灵了。, sentiment: negative, product_items: [{product_name: 无线鼠标, quantity: 1}]}clean_and_parse能解析成功但validate_data会报1 is not of type integer。此时进入repair把错误信息喂回去模型会改成1第二轮校验通过。如果第二轮还失败就进入降级返回。这套流程跑下来我线上结构化输出的“首轮解析成功率”能从原本的 80% 左右提升到 98% 以上剩下的 2% 也不会让系统崩溃。6.3 工程落地的监控与日志建议最后补几条工程侧建议给每一层都打点。至少统计total_requests、first_pass、after_repair、final_failure。这样才能看出到底是提示词不行、解码约束不行还是业务数据本身太脏。日志永远存三样prompt、raw_output、repair_error。别只存最终结果。对失败样本做定期抽样分析。很多问题不是模型变笨了而是你的 Schema 在变复杂或者业务数据出现新的形态。抽样能帮你提前调整 prompt 和 Schema。如果成本允许可以把repair循环次数提高到 3但不要更高。超过 3 次边际收益极低而且很容易因为模型反复纠错导致输出漂移。我个人在实际操作中的体会是四层保障真正核心的不是“让模型永远正确”而是“让错误发生的边界清晰、错误传递的路径可控”。就算某天模型厂商更新了版本或者你换了一个模型底座这套分层框架仍然适用。你把每一层的职责定好了换模型时只需要微调第一层和第二层的实现细节后面两层几乎不用动。