线上大模型项目里最头疼的问题之一就是模型输出的 JSON 格式不稳定。同一个 Prompt上一轮还能正常解析下一轮可能就多了一句解释文本或者字段名变了甚至整段返回一个 Markdown 代码块。很多人第一反应是继续调 Prompt调 temperature调 few-shot但线上跑一段时间会发现这些手段都只是降低概率不能消除异常。真正能稳住输出格式的方法是跳出“只调 Prompt”的思路从提示词、校验、后处理、重试四层搭建一套完整方案。这篇文章就按这个思路拆一遍。1. 先确认你拿到的字符串到底坏在哪一类线上 JSON 输出格式问题看起来都是“解析报错”但背后原因差别很大。不先分类后面的校验和后处理都无从设计。我一般会先收集一两天线上日志找出原始返回的字符串按异常形态归类。1.1 几乎所有格式问题都能归成这几类异常形态典型案例前后包裹文本输出好的这是结果{a:1}Markdown 代码块输出json {...}尾逗号或漏逗号{a:1,}或{a:1 b:2}单引号代替双引号{a: 1}值里带换行或特殊字符description: 第一行\n第二行未转义字段缺失、字段名变化说好返回order_id结果返回id完全不是 JSON模型直接给了一段解释文字第一类最常见也最好处理。模型往往会在 JSON 外面补一句“好的这是你要的结果”这句对用户阅读很友好但解析器根本不认识。代码块包裹也类似很多模型在训练数据里见过 json 标记就会自动加上。这两类不需要惊动模型后处理层用简单逻辑就能去掉。第二类属于“差一点就能解析”的情况。比如尾逗号有些模型会在最后一个字段后面加一个逗号这种在 JavaScript 里可能没问题但标准 JSON 解析器会报错。漏逗号更麻烦比如两个字段之间直接写了空格这通常说明模型对 JSON 结构掌握不够稳定需要靠重试而不是后处理。字段缺失和字段名变化要特别留意。很多项目做业务校验时只检查“JSON 合法”没有检查必填字段结果后面取数据时才发现是 None。这类问题表面上像格式问题本质上是 Prompt 里的字段约束不够清晰或者模型在长文本下注意力漂移。所以格式校验一定不能只看语法。1.2 为什么只调 Prompt 解决不掉大模型的输出是概率采样格式约束没有被硬编码。Prompt 的作用是提高输出合法 JSON 的概率而不是保证。在生产环境里影响输出的因素太多了请求内容长度、当前对话轮数、温度参数、模型版本切换甚至同一段文本每次采样都会有波动。如果只靠 Prompt 微调会遇到一个很尴尬的情况加了一句“必须返回合法 JSON”测试样例可能都通过但线上跑一会儿又出现新的异常。因为每条输入的内容和长度都在变模型对“格式指令”的跟随程度也会变。还有一个容易被忽略的点单点修复是发散的。今天修了字段缺失明天可能冒出新的问题今天把某个 Prompt 改严谨了可能让另一个场景的输出多了一段解释。而校验、后处理、重试是一个收敛结构把模型所有可能的输出统一收口到“要么拿到合法 JSON要么明确失败”。只要这个收口逻辑稳定模型输出的不确定性就被控制住了。所以第一步不是立刻改 Prompt而是建立对异常形态的持续统计。在日志里记录原始输出字符串用解析器去 parse失败时把错误位置附近 50 个字符截取出来。做一两天后你会看到异常类型不是均匀分布的。通常最多的是代码块包裹、前后解释文本、字段缺失这三类。有了这个统计你才知道后处理和重试要先解决哪个。2. 稳定方案不是一条链而是四层配合很多人把这个问题理解成一条处理流水线先 Prompt再解析失败就重试。这样设计容易漏掉中间的细节。我的建议是把它拆成四个独立关注的层次每一层只解决自己的问题并且每一层都有明确的输入和输出。2.1 每一层解决什么问题层级核心动作目标提示词层明确输出格式、字段示例、失败兜底说明降低输出非法 JSON 的概率校验层JSON 语法校验、字段类型和值域校验尽早拦截无效输出后处理层去代码块、提取 JSON、修复尾逗号等把可修复的输出救回来重试层错误反馈重试、降级输出给最后一次机会提示词层不能保证结果但可以减少后处理和重试成本。校验层不能修复问题但它能快速判断“这个输出还能不能要”。后处理层针对的是“虽然不符合标准 JSON 语法但业务信息完整”的输出。重试层则是前面三层都失败后的兜底。很多项目只做了提示词和重试中间两层空着。结果就是模型一输出不规范内容就重新调用一次成本高而且大概率还会失败。因为重试时如果只是原样再调一次没有把错误原因反馈给模型模型很可能会犯同样的错误。2.2 一个可参考的处理流程整个流程可以这样设计调用大模型拿到原始文本。先做后处理去掉代码块包裹、提取 JSON 候选段。再做 JSON 语法解析。解析成功执行字段校验和业务校验。任一环节失败把错误类型和原始文本带入下一轮重试。重试达到次数上限返回兜底 JSON 或标记失败。为什么先做后处理再解析因为很多模型输出虽然外层有解释文字但 JSON 本身是完整的直接解析会因为前面有字符而失败而后处理的成本很低。如果后处理提取后还是解析失败再考虑重试避免把本可以修复的输出浪费掉。下面是一个 Python 伪代码框架描述核心控制流def process_model_output(raw: str, schema: dict) - dict: candidate raw error_info None for attempt in range(MAX_ATTEMPTS): cleaned normalize_output(candidate) try: data json.loads(cleaned) except Exception as e: error_info classify_parse_error(e, cleaned) candidate repair_and_retry(candidate, error_info) continue biz_error validate_business(data, schema) if biz_error: if is_fixable(biz_error): data apply_defaults(data, biz_error) return data error_info biz_error candidate build_corrective_prompt(raw, data, biz_error) continue return data raise JSONOutputError(error_info)这段代码不是某个库的完整实现而是一个组织逻辑的示例。实际项目中建议把这套逻辑抽成一个独立服务或独立模块方便多个业务复用。别把格式处理散落在各个业务代码里否则后面想统一加指标都很难。四层配合的真正好处是每一层都可以单独调整不影响其他层。比如后处理逻辑优化了重试率可能下降Prompt 改得更准了后处理和重试压力都会变小。只要把每层的关键指标记录下来优化方向就非常清楚。3. 提示词层把“请返回 JSON”改成可解析的格式合约提示词层不是万能的但它是最便宜的一层。改一段 Prompt 只需要几分钟而重试和人工排错要贵得多。问题在于很多人写的 JSON Prompt 太随意了。3.1 明确格式而不是口头要求“请用 JSON 格式返回”这句话太模糊。模型对“JSON 格式”的理解和解析器不一样它可能觉得加一句解释也无所谓也可能用单引号就算 JSON但你的解析器不认。所以提示词里要把“输出必须是合法 JSON不得输出任何附加文字”写清楚。这里说的“清楚”不是语气更强硬而是给模型一个可模仿的格式样例。模型更擅长模仿样例而不是理解抽象指令。只有一句话要求时模型会调用自己的“JSON 记忆”但记忆里的 JSON 可能是教学场景里的格式化文本不是你的业务字段。3.2 用 JSON 示例和字段约束替代抽象描述一个更可靠的 Prompt 写法是给出目标 JSON 的具体结构。请从下面文本中提取订单信息只输出一个 JSON 对象不要输出任何解释文字。 输出格式如下 { order_id: 字符串必填, amount: 数字必填单位为元, status: 枚举值可选pending, paid, cancelled, note: 字符串可选没有就返回空字符串 } 文本{input_text}这里有几个细节值得注意。字段说明要写在 JSON 示例后面不要让模型去解析 JSON 注释。因为很多模型没有真正按 JSON 标准理解注释示例里的字段说明反而可能被输出。更稳妥的方式是在示例后面用单独一行写清楚每个字段的约束或者把字段类型直接写进字段名的含义里。另外给一个“失败时的兜底输出”也很有用。比如在 Prompt 末尾加上“如果无法提取任何信息请返回 {result: null}”。这样模型不会为了满足 JSON 格式而乱编字段后续校验层也能清楚区分“没有数据”和“解析失败”。3.3 典型 Prompt 陷阱第一个陷阱是任务过多。让模型同时提取订单信息、写用户评价、做情感判断还要输出 JSON格式跟随概率会明显下降。建议一个请求只让模型做一件事把多步骤拆成多次调用。第二个陷阱是示例格式不严谨。有的开发者把示例写成{order_id: 123}结果模型把订单号也输出成数字类型而业务里需要字符串。所以示例里的字段值要尽量符合真实业务类型不能为了省事随便写。第三个陷阱是温度参数。如果格式稳定性优先temperature 不要超过 0.5。很多场景直接设 0.1 甚至 0。必须留随机性的创意类任务不适合用这套强约束流程建议单独处理。第四个陷阱是多轮对话下只写一次 System Prompt。随着对话轮数增加模型可能逐渐忽略最初的格式要求。更稳妥的做法是在每轮用户输入后都重新强调“当前回复只输出 JSON”尤其当上下文里已经有自然语言回复时。提示词层做得好可以显著降低后面三层的压力。但即使 Prompt 写得再完美也不能保证 100%。所以其他三层不是可选项而是必选项。它们用来承接剩下的小概率异常。4. 校验层宁可让请求失败也不要带病往下走校验层的作用是在数据进入业务逻辑之前把不符合要求的输出拦截下来。很多线上问题不是模型没返回 JSON而是返回了一个“看起来合法、但业务字段错误”的 JSON程序在更后面才报错。4.1 校验的三个级别语法校验、字段校验、业务校验第一级是语法校验。判断这段文本能不能被标准 JSON 解析器解析。这个最简单但只做它不够。第二级是字段校验。检查必填字段是否存在字段类型是否匹配。比如模型输出了amount: 100.0这本身是合法 JSON字符串类型但业务里期望的是数字。如果不校验类型后面做金额计算时可能直接报错。第三级是业务校验。检查值域和业务规则。比如status字段只允许pending、paid、cancelled但模型返回了success。这种错误解析器识别不了字段校验也不一定能发现必须靠业务规则兜底。4.2 先做 JSON 解析再做业务规则示例校验逻辑大致如下def validate_schema(data, schema): required schema.get(required, []) for field in required: if field not in data: raise FieldMissingError(field) if not isinstance(data[field], schema[types].get(field)): raise FieldTypeError(field, type(data[field]).__name__) for field, enum_values in schema.get(enums, {}).items(): if field in data and data[field] not in enum_values: raise EnumValueError(field, data[field])实际项目中可以用现成的 JSON Schema 校验库也可以手写。手写的优势是能输出更精确的错误信息比如“缺少 order_id 字段”“status 值不在允许范围内”。这些错误信息就是重试层要用的反馈。校验层尽量不要直接写在业务函数内部。因为不同业务需要的校验规则不一样混在一起会导致模块臃肿。我建议单独建一个output_validator模块接收原始文本、schema 和业务规则返回校验结果。校验结果要么是解析好的 dict要么是一个结构化的错误对象。4.3 错误分类是重试和后处理的关键校验层不能只返回“失败”要返回结构化错误信息包括错误类型、字段名、错误位置。这样才能判断下一步是后处理修复还是重新调用模型还是直接失败。错误类型是否可修复处理方式语法错误尾逗号、单引号可修复后处理修复修复失败再重试文本包裹可修复后处理提取必填字段缺失不可后处理修复但可重试带错误反馈重试字段类型错误可尝试转换如果安全则转换否则重试值域错误不可修复重试或降级纯解释文本无 JSON不可修复直接重试这里要特别注意“字段类型错误”。有些类型转换是安全的比如把字符串形式的数字100.0转成浮点数。但有些转换是危险的比如把unknown转成 0这会掩盖业务问题。更稳妥的做法是只有字段的可选值域明确时才做默认值补全其他情况一律交给重试层让模型自己修正。校验层的一个价值是“快速失败”。如果模型输出了明显不可修复的文本不要进入后处理不要反复调用重试而是直接返回兜底结果。这样可以节省成本也避免加重模型服务端压力。5. 后处理层修复能修的不能修的直接判失败后处理层是四层里最容易被人当“脏活”的一层但恰恰是它对成功率提升最明显。原因很简单模型输出的很多问题是有规律的小错误根本不需要重新调用一次模型。5.1 常见修复手段第一去掉 Markdown 代码块。可以用正则匹配json 和并把中间的文本提取出来。需要把首尾标记都处理干净否则可能残留反引号。第二去掉首尾解释文本。更稳妥的方法是找到第一个{和最后一个}截取中间内容。这个方法对 JSON 对象有效但如果模型输出的是 JSON 数组就要找[和]。第三修复尾逗号。用正则把,\s*}替换成}把,\s*]替换成]。这类问题在模型生成“最后字段后面多一个逗号”的场景下很常见修复成本很低。第四单引号问题。不建议简单地把所有单引号替换成双引号因为 JSON 字符串值里可能本身包含单引号例如用户输入里的its。强行替换会把合法内容也改坏。如果一定要修建议只针对最外层是单引号包裹的情况做处理并且替换完立即重新解析解析失败就放弃。5.2 提取 JSON 的兜底策略一个简单但有效的提取函数如下def extract_json(raw: str) - str: start raw.find({) end raw.rfind(}) if start -1 or end -1 or end start: raise NotJSONError(raw[:200]) return raw[start:end 1]这个方法会把 JSON 之外的所有解释文字全部丢掉。它的适用前提是业务输出一定是一个 JSON 对象且文本中不包含其他多余的{。如果输入文本本身就包含花括号比如用户消息里有大括号这个策略可能提取错误。所以提取后必须再做一次json.loads验证。实际项目中可以根据首次提取失败的位置来调整策略。比如第一个{前面如果是解释文字直接截掉如果提取后解析报错再判断是漏了右括号还是截断错误。后处理逻辑不要一上来就写得很复杂先覆盖出现频率最高的那三类异常代码块、前后文本、尾逗号。5.3 后处理的边界后处理不能无限修复也不能为了解析成功而改变业务语义。举一个例子文本里有未转义的换行符。你可以用replace(\n, \\n)来修复但如果这个换行符本来出现在 JSON 结构之外替换后反而会引入别的字符如果原文本里已经有\\n再替换一次就可能变成\\\\n导致最终内容错误。所以修复动作必须限定在“确定它是某种错误”的场景并且每步修复后都重新解析而不是做一套无差别替换。另一个边界是不要把值里的内容改掉。比如用户输入里有http://example.com修复脚本不应该去动它。如果修复逻辑太激进把合法 JSON 变成非法 JSON或者改变了业务数据那比解析失败更危险因为程序可能带着错误数据继续往下走。我建议给每一步后处理打上日志记录修复类型和成功率。如果一个修复手段连续跑了一周成功率始终很低就说明这个错误类型更适合交给重试层而不是继续在后处理里硬修。6. 重试层失败后不是重新再调一次这么简单重试层是最容易被误用的一层。很多人写的重试就是一个 for 循环原样调用模型两三次以为能提高成功率。但如果没有错误反馈没有超时控制没有降级逻辑重试只是在烧钱。6.1 重试前必做的三个准备第一错误分类。是解析错误、字段缺失、业务校验失败还是接口超时或限流不同错误对应的重试策略完全不同。解析错误可以调整后处理逻辑字段缺失需要把缺哪个字段告诉模型接口超时要检查网络和请求超时时间限流则需要等待退避。第二重试条件。只有一次调用还有机会修正时才重试。例如模型输出缺少必填字段重试时把“缺少 order_id”告诉模型模型大概率能补全但如果模型输出的是完全无关的文本说明模型根本没有理解任务重试同样的 Prompt 基本不会改善。第三超时控制。每次重试都要重新计算超时时间不能无限等待。线上服务还要考虑整体请求耗时不能为了一个 JSON 输出等待几十秒把用户请求拖垮。6.2 带上下文的重试和降级方案一个简单的重试消息模板如下RETRY_MESSAGES { parse_error: 上次输出无法解析为 JSON请严格输出合法 JSON不要包含额外文字, field_missing: 上次输出缺少必填字段 {field}请补全后重新输出 JSON, enum_error: 字段 {field} 的值必须是 {allowed}请修正后重新输出 JSON }重试时可以把上一轮的原始输出截断和错误信息一起发给模型。这样做比干巴巴地重试一次效果更好因为模型能知道自己错在哪。但注意不要把完整错误堆栈发给模型否则上下文会变得又长又乱反而影响后续输出。还有一种做法是重试时降低 temperature或者去掉一些不相关的历史上下文。如果模型在长对话流中无法记住格式要求可以只传当前用户输入和格式示例不传前面的聊天记录让模型从“干净的上下文”里重新生成。重试时也可以让模型扮演“修正者”角色。例如下面这一段是上一个大模型试图生成的 JSON但存在问题 {raw_output} 请只输出一个修正后的 JSON不要输出解释。这种方式在某些模型上更有效因为模型理解了“修正”这个动作而不是重新从零生成。6.3 设置重试上限和兜底输出重试次数一般 2 到 3 次就足够。超过这个次数大概率不是偶发问题而是 Prompt 或输入本身有问题继续重试只会增加成本和延迟。更合理的做法是所有重试都失败后返回一个业务能接受的兜底 JSON。比如订单提取失败时可以返回{order_id: null, amount: null, status: unknown, note: }然后在日志里标记为json_failed。这样线上不会因为一条坏输出直接抛异常用户端也不会看到 500。业务层可以根据这个标记决定是展示默认提示还是转人工处理。还要注意成本。大模型 API 按请求计费重试会成倍增加成本。建议在提示词层和后处理层多做一些重试只作为最后一道防线。每增加一次重试都要关注线上平均延迟和成本变化。7. 线上落地避坑先小流量再全量方案设计出来后直接全量上线是有风险的。因为模型输出有很强的随机性你很难靠几组测试样例证明方案是稳的。正确的做法是先离线回归再小流量灰度最后全量。7.1 指标与日志怎么埋线上需要持续观察这些指标JSON 一次成功率不重试、不后处理直接解析成功的比例。后处理修复率原始输出有问题但后处理后成功解析的比例。各错误类型占比文本包裹、代码块、字段缺失各占多少。重试率、重试成功率、平均重试次数。最终失败率所有手段都无效返回兜底 JSON 的比例。日志里至少记录原始输出、错误类型、后处理操作、重试次数、最终输出。原始输出要截断保存避免日志过大。截断长度一般 500 到 1000 字符足够定位问题但也要保留完整的错误上下文。这些指标最好以分钟级或小时级聚合画成趋势曲线。因为模型输出质量不是恒定的模型版本更新、服务端限流、上下文长度变化都可能导致成功率波动。没有指标你很难判断“这次改动到底有没有效果”。7.2 先在离线样本上跑回归我建议从线上挑几百条典型输入组成一个回归集。这些输入要覆盖不同格式、不同长度、不同字段值最好也包含以前出现过的异常案例。每次修改 Prompt、后处理或校验逻辑都重跑同一份回归集对比 JSON 成功率。这个方法能防止“改了一个 case坏了另一个 case”。比如你为了修复尾逗号加了一个正则替换结果发现它把某些字段值里的,也替换掉了。在回归集上跑一遍就能暴露出来。回归集不一定需要人工标注线上失败的日志就是最好的素材。可以把过去一周失败的原始输出和期望输出存下来作为反向用例。7.3 常见排查顺序线上如果突然出现 JSON 失败率升高按这个顺序排查先看接口状态。如果模型服务返回空、超时或限流问题可能不在输出格式而在网络和服务端。再看原始输出。解析失败时打开日志看原始字符串先归类是代码块包裹、字段缺失还是完全不是 JSON。不要凭感觉猜。再看 Prompt。字段缺失频繁时检查 Prompt 里的字段名是否唯一是否和业务字段别名冲突。有时候用户文本里出现过别的id模型就会把它当成order_id。再看后处理。如果某次改动后成功率下降优先检查后处理函数是不是误伤了合法输出。再看重试。如果重试不生效看错误分类是否稳定。如果每次重试回来的错误都不一样说明模型根本没理解要求需要回头改 Prompt而不是加更多重试次数。最后看兜底。返回兜底 JSON 的请求占比是否在可接受范围。如果超过 5%说明前几层都没有真正解决主要问题需要回到结构设计上找原因。线上 JSON 输出稳定方案真正落地时最该盯住的不是某个 Prompt 写得有多完美而是每一层的成功率和错误类型。把失败路径收窄把可观测性做起来模型输出的不确定性才能被控制。个人建议这套方案不要一次性全上。先把校验和后处理上线观察几天再加重试。这样每一层效果都能量化出了问题也好定位。