做线上大模型项目的人,几乎都经历过同一种“薛定谔的 JSON”:调用模型接口之前,你永远不知道返回的到底是完整 JSON、JSON 代码块、还是夹杂着解释文字的混合文本。最常见的一幕是,线上日志里随机出现三类报错:Expecting ',' delimiter、Unterminated string starting at、Expecting value。业务侧只抛出一句通用错误:解析失败。用户那边看到的则是另一个版本:服务开小差了。
先别急着把锅甩给模型。大模型的输出机制决定了它不可能像json.dumps()一样稳定地序列化对象。它本质上是在做下一个 token 的概率采样,不是在执行 JSON 序列化协议。所以即使你用了当时公认很强的主力模型,在输入超长、角色切换、内容包含特殊字符时,输出格式仍然会飘。
这篇文章不聊微调,也不建议你遇到问题就立刻重训模型。我讨论的是另一种更适合线上稳定性的路径:在提示词约束、校验解析、后处理修正、失败重试和降级兜底之间,搭出一条完整的容错链路,让每次调用出现格式问题时,系统仍然能拿到结构化数据。
1. 先把问题看清楚:JSON 输出不稳定,到底不稳定在哪
1.1 线上最常见的失败形态
在线上日志里泡过一段时间后,你会发现模型输出的 JSON 失败形态基本可以分成五类。
第一类是代码块包裹。模型没有直接输出 JSON,而是输出了带 Markdown 标记的代码块:
```json {"name": "demo", "status": "ok"}这种形态在真实场景里出现频率相当高,因为模型训练数据里的 JSON 大多以代码块形式出现。业务层如果直接拿它去执行 `JSON.parse`,立刻就会失败。 第二类是**多余文本前后缀**。模型没有只输出 JSON,而是在前后加了解释性文字,比如“好的,这是你要的数据:{...}”。这种问题在小样本调参时不容易遇到,但一旦输入变成真实用户消息、上下文里充满对话历史,模型很容易把“回应”和“数据”混在一起。 第三类是**语法损坏**。尾逗号、缺失逗号、引号未闭合、单引号代替双引号、`None` / `True` 代替 `null` / `true`。这类问题在内容里含有特殊字符时特别容易出现,也是解析错误里的大头。 第四类是**字段值丢失或类型漂移**。JSON 结构本身合法,但某个字段变成了 `null`,或者本应是字符串的字段变成了数组,也可能是数字变成指数形式。这类错误最容易被忽略,因为解析层不会报错,数据却已经失真。 第五类是**整体截断**。输出达到最大 token 限制,JSON 只生成了一半。这种最麻烦,因为没有任何解析器能直接补齐缺失的后半部分。 不同类型的失败,原因和应对策略完全不同。如果混在一起来处理,后面的步骤就没法设计。 ### 1.2 为什么微调 Prompt 不能根治 先说清楚一个容易被忽略的事实:只要你还在调用普通的对话补全或生成接口,模型输出 JSON 是否合法,本质上是一个概率事件。 从生成机制看,模型在每个位置选择下一个 token 时,选择的是概率最高的候选,但它没有“合法 JSON”这样的全局约束。那些写了“必须输出 JSON”的提示词,只是把约束翻译成了人类语言,模型在多数情况下会遵循,但在上下文过长、指令冲突、输出内容里存在大量引号和转义字符时,这种遵循会松动。 微调确实能提升格式稳定性,因为它把大量 JSON 输出样本编进了模型参数里。但微调的代价也很明显: - 需要准备大量高质量、格式一致的数据集; - 训练周期和成本不是小团队随时能承担的; - 模型升级后,微调流程要重新走一遍; - 微调只能降低坏概率,不能把概率降为零。 所以我的判断是:如果业务已经上了线,第一优先级不是微调模型,而是先在系统层面把“输出格式不可控”当成既定事实来设计容错链路。Prompt 继续做,但它是这条链路的一部分,不是唯一防线。 > 注意:不要因为某次解析成功了,就认定模型已经稳定。线上项目要把每一次输出都当成“可能不合法”来设计。 ## 2. Prompt 层:它解决不了全部问题,但能决定错误的暴露形式 ### 2.1 一个相对稳妥的 JSON 输出约束模板 Prompt 虽然不能治本,但它可以大幅降低坏格式出现的概率,同时让后续的校验和后处理更容易工作。我一般建议在 System Prompt 里明确给出 JSON 的 Schema 和一个精确示例,并且把“不要解释,只输出 JSON”写到单独的一行。 一个模板结构大致如下: ```text 你是一个只输出 JSON 的对象解析器。 请根据用户输入生成以下结构的 JSON: { "summary": "string,对话总结", "key_points": ["string,要点列表"], "sentiment": "positive | neutral | negative", "confidence": "float,0到1之间" } 规则: 1. 只输出 JSON 本身,不要使用 Markdown 代码块。 2. 不要附带任何解释性文字。 3. 字符串中如果有引号,一律使用转义符。 4. 如果信息不足,confidence 填 0,不要返回 null。这里有几个容易被忽略的设计细节。
第一,给出字段类型,但值示例不要给得太具体。值示例如果给得过于具象,模型容易照抄示例值,反而污染业务结果。
第二,明确写出禁止事项比强调“必须合规”更有效。比如“不要使用 Markdown 代码块”比“你必须输出合法 JSON”更容易被模型理解,因为它给出了具体的否定指令。
第三,对“信息不足”的情况给出兜底规则。比如要求模型填 0 或空字符串,而不是返回null。这样后续校验逻辑就不用为每个字段做额外的判空处理。
第四,Schema 要简短。字段数量控制在 5 到 8 个以内时,模型更容易完整生成;字段一多,生成顺序或者缺字段的概率就会明显上升。如果业务确实需要大量字段,可以考虑拆成两级结构,外层只返回关键标识,内层再单独调用一次生成。
2.2 Prompt 的边界:什么时候该停手
我见过很多团队在 Prompt 上花了两周时间来回改,效果却很难衡量。这里给你一个判断标准。
如果已经试过以下三轮调整,仍然出现格式错误,就不要再做第四轮:
- 补充 Schema 和字段说明;
- 增加一个正例和一个反例;
- 明确禁止代码块和多余文本。
超过这个范围之后,继续加提示词通常会进入边际递减区域,甚至引出反效果。你强调“不要解释”,模型反而更容易在开头输出“好的”之类的过渡语;你强调“必须合法 JSON”,模型可能为了合法而丢失信息,把原本应该输出的字符串截断。
原因在于,模型对指令的遵循能力是有容量限制的。指令越长,真正被模型有效吸收的约束比例反而可能降低。与其把所有希望押在一段越来越长的 Prompt 上,不如接受一个事实:Prompt 只能减少错误,不能消除错误。接下来要把重心转移到代码层。
3. 校验层:让错误在进入业务逻辑之前停下来
3.1 校验不只是 JSON.parse
很多项目的校验层就是一行json.loads(response),出错就抛异常。这样处理在 demo 阶段没问题,但放到线上会有两个隐患。
第一,错误信息太原始,不利于分级处理和告警。你不知道是代码块包裹、语法损坏、还是截断,也就不知道该走哪条修复路径。
第二,一旦抛出异常,前文所有输入、上下文和原始输出都会丢失,后续复盘很难推进。
所以在真实项目里,校验层至少要完成三件事。
第一,剥离边界。先看返回文本是否包含代码块标记。如果存在```json和```,先把中间内容提取出来。这一步不算解析,属于提取候选 JSON 片段。
第二,执行严格解析。json.loads或JSON.parse是第一道必过的解析。不要为了兼容格式就放宽要求,比如用正则手动拼接 JSON。严格解析能保证进入业务逻辑的数据是可信的。
第三,结构校验。解析通过不代表结束,还要检查关键字段是否存在、类型是否正确、枚举值是否在允许范围内。
可以用 Python 写一个简单版本:
import json def parse_model_output(text: str): candidate = extract_json_candidate(text) data = json.loads(candidate) # 严格解析 validate_structure(data) # 结构校验 return datavalidate_structure内部可以根据 Schema 逐个字段检查。检查失败时,要保留原始文本、失败原因和字段路径,方便后续处理。
3.2 错误分类与信息保留
更合理的做法是,把校验失败的信息结构化,而不是只抛一个异常。比如定义一个JsonValidationError,里面包含:
raw_text:模型原始输出;parse_error_msg:JSON 解析器的具体报错;error_type:是代码块包裹、多余文本、语法损坏还是结构不完整;candidate:提取出来的候选 JSON 文本。
这样后续无论是做后处理、重试,还是进日志和监控,都有足够上下文。
同时,把校验失败分成两类:
- 可修复型:代码块包裹、多余文本、尾逗号等。
- 不可修复型:整体截断、字段缺失严重、内容被改写。
这个分类决定了后续是进入修复流程,还是直接触发重试。千万不要一遇到解析失败就把所有情况都塞进同一个异常分支,那样后处理层和重试层的设计都会失去针对性。
4. 后处理层:能修的尽量修,不能修的交出去
4.1 哪些情况可以安全修复
后处理层的原则是:用确定性的代码去修正可预期的模型输出偏差,但修复动作本身要有严格限制,避免把一个本来能解析的 JSON 越修越坏。
安全修复清单大概包括以下几种。
代码块剥离:如果候选字符串是```json ... ```,直接提取中间部分。
去除前后非 JSON 文本:找到第一个{或[的位置,以及最后一个对应的闭合符号位置,截取中间内容。这个策略能解决大多数“好的,这是你要的数据:{...}”问题。
尾逗号清理:用正则或者逐字符扫描,把对象和数组最后的逗号去掉。要注意不能把所有逗号都去掉,只能处理尾部。
单引号转双引号:只在严格解析失败时尝试。这一步要非常谨慎,因为 JSON 内部内容里可能含有单引号,直接全局替换可能破坏数据结构。
裸值替换:把None、True、False等 Python 风格字面量替换为null、true、false。
补全缺失的闭合括号:如果文本明显是截断在某个}之前,可以尝试补上缺失的右括号。但这里只建议补一层。如果内容里还有嵌套对象且缺失多层,就不能用硬补的方式处理。
4.2 修复顺序与风险控制
修复不是把所有方法都套上去,而是按顺序执行,每执行一步就重新尝试解析:
REPAIR_STEPS = [ strip_code_block, extract_json_substring, remove_trailing_commas, replace_python_literals, fix_unbalanced_brackets, ] for step in REPAIR_STEPS: try: text = step(text) data = json.loads(text) if validate_structure(data): return data except (json.JSONDecodeError, ValidationError): continue这套流程的关键在于:每一步都是确定性的,并且每步之后都重新尝试解析,解析成功且结构校验通过才返回,不会出现把错误修复当成正确结果的情况。
风险控制还有第二条:修复之后仍然无法解析的,不要继续硬修。硬修的每一层尝试都可能引入新的错误,而且时间成本会线性上升。在线上环境,修复层的作用是降低重试次数,不是替代重试。
注意:后处理中的正则和字符串替换,一定要先在日志里保留原始输出。否则一旦修复逻辑引入 bug,你连原始数据都找不回来。
5. 重试与兜底:把单次成功变成系统可用
5.1 重试策略怎么设计
重试是整条链路里最后一道强约束。但重试不是简单地在异常处加一个try again标志。线上项目里,重试必须考虑三个问题。
第一,重试的触发条件。只有明确是模型输出格式问题,才值得重试。如果是网络超时、限流、或输入本身有问题,重试的收益很低。触发条件要绑定到校验层和后处理层给出的错误类型,比如只对代码块、尾逗号、括号缺失、截断这类错误发起重试。
第二,重试的次数与间隔。一般建议最多重试 2 到 3 次。如果使用过程中允许浮点温度,每次重试可以把 temperature 提高 0.1 左右,让它走一条和上次不完全一样的采样路径。如果 temperature 本来就是 0,普通重试拿到的结果很可能是同一个文本,这时候更有价值的做法是让重试带上一个调整过的系统指令,比如更简洁的版本,或者临时把 temperature 提到 0.3,打破确定性路径。
第三,重试的成本意识。大模型按 token 计费,一次失败的重试会带来额外的调用成本。重试次数和间隔要设置上限,并且把重试率纳入监控。如果重试率一直偏高,就需要回头检查 Prompt 和校验逻辑,而不是继续加重试。
一个常见的重试流程:
for attempt in range(max_attempts): raw_text = call_model(prompt, temperature=base_temp + attempt * 0.1) data = try_parse_and_validate(raw_text) if data is not None: return data # 所有尝试都失败,进入兜底 return fallback_output(raw_text)5.2 重试仍失败时的降级方案
很多人忽略兜底输出,等到线上连续失败才发现没有 B 方案。
兜底可以分几层。
缓存兜底:如果是同类输入,可以先查缓存,返回上一次成功的结构化结果。这在对话总结、信息抽取这类重复性较高的场景里很有效。
字段级兜底:如果结构校验失败,但模型返回的文本里能提取出部分字段值,就把能确认的字段保留下来,缺失字段用默认值代替。这样业务侧至少不会因为一个字段的缺失而整体失败。
静态兜底:如果完全无法解析,就返回一个预设的“处理失败”结构化数据,并在响应里带上error字段。
在业务部署上,兜底数据也要纳入流量观测。如果兜底比例突然升高,说明上游模型服务的稳定性在下降。这时候要查看模型链路、Prompt 或上下文长度是否发生了明显变化,而不是只盯着下游的解析逻辑。
6. 落地方案与排查链路
6.1 一套最小可运行的全链路流程
把前面的分层整合成一个最小可运行的流程,大概是这样的顺序:
- 调模型前,组好 Prompt,并确认 Schema 版本。
- 拿到模型输出后,先提取候选 JSON 文本。
- 执行严格解析和结构校验。
- 校验不通过,按修复列表逐层尝试。
- 修复失败,触发重试,最多 N 次。
- 重试全部失败,走兜底分支。
- 无论成功失败,都记录完整日志。
这个流程可以封装成一个统一入口,业务方只需要调用一个函数,不关心内部修复和重试逻辑。
def generate_structured_data(prompt, schema, max_retries=3): for attempt in range(max_retries): raw = call_model(prompt) parsed = robust_parse(raw, schema) if parsed is not None: return parsed log_failure(raw, attempt) return fallback_output()robust_parse内部包含提取、解析、校验、修复的完整逻辑。封装完成后,业务代码里不再出现json.loads,而是统一走generate_structured_data。
6.2 常见问题的按层排查顺序
如果线上出现了新问题,不要直接改代码,按下面的顺序排查。
| 现象 | 优先排查层 | 关键动作 |
|---|---|---|
| 带代码块标记 | 提取层 | 检查剥离逻辑是否覆盖```json和```两种写法 |
| JSON 前后有解释文本 | 提取层 | 检查是否准确截取到第一个{和最后一个} |
| 报逗号或引号错误 | 校验/修复层 | 检查尾逗号清理和引号修复是否只作用于目标位置 |
| 结构合法但字段为 null | 结构校验层 | 检查 Prompt 是否给出了空值兜底规则 |
| 输出被截断 | 重试层 | 检查 max_tokens 是否足够,以及截断后是否进入重试 |
排查顺序的核心思想是:先把“外部输入问题”和“内部代码问题”分开,再从数据层面逐步逼近根因,不要一上来就重新调 Prompt。
如果日志里保存了原始输出,大部分问题都能在十分钟内定位。真正难查的往往不是模型输出异常,而是修复逻辑本身把原本还有希望修复的文本处理坏了。
这套方案的收益不在于让某一次调用百分百成功,没有任何方案能保证这一点。它的价值在于把失败从“不可控的随机事件”变成了“有路径、有状态、有出口的可控事件”。你不需要让模型永远不犯错,你只需要让它在犯错时,系统知道怎么走接下来的路。
如果你现在项目里还停在靠一条 Prompt 硬撑的阶段,我建议的下一步很小:今天就去加一条日志,把模型原始输出和失败原因记录下来。有了这份日志,你不需要猜,该补哪一层,数据会告诉你。