
做线上大模型项目的人几乎都经历过同一种“薛定谔的 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: float0到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_msgJSON 解析器的具体报错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, temperaturebase_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_retries3): 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 硬撑的阶段我建议的下一步很小今天就去加一条日志把模型原始输出和失败原因记录下来。有了这份日志你不需要猜该补哪一层数据会告诉你。