要 JSON 却给了带包裹的文本,结构化输出不稳怎么治
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
结构化输出不稳,九成不是模型”不听话”,而是你的管线里根本没有一层负责把不确定的文本变成确定的数据。 很多人碰到这个问题的第一反应是回去改措辞——加一句”只返回 JSON,不要任何解释”,加粗,大写,威胁。改完当场好了,跑上几百次又开始漏。原因很简单:自然语言指令是概率约束,不是类型约束。你要的是一个永远拿到合法对象的接口,用一句话去换,本来就赌不赢。
这篇要讲的是排查顺序:先看你到底遇到的是哪一类失败,再决定动哪一层。顺序错了会很浪费时间——比如任务本身设计有问题,你却在解析器里堆正则,越堆越脆。
站内另外两篇讲的是相邻但不同的事:Agent 工具调用报错排查 处理的是工具调用链路本身(参数传错、工具没被调起、返回值没回填),多模型 fallback 设计 处理的是主模型不可用时怎么切备用。本篇只管一件事:当模型已经正常返回了内容,但内容的形状不是你要的,该怎么一层层治。
一、先分清你遇到的是哪一种”不稳”
“JSON 解析失败”是个笼统的报警。把它拆成五类,处置动作完全不同。
第一类:包裹层污染。 模型把 JSON 包在 Markdown 代码块里,或者前面加了”好的,这是你要的结果:“,后面补一句”如果需要调整请告诉我”。JSON 主体本身是合法的,只是被裹住了。这类最常见,也最好治。
第二类:语法级破损。 尾随逗号、单引号当字符串引号、字符串里的换行没转义、注释混进来了。JSON 主体本身不合法,但结构意图是清楚的。
第三类:截断。 输出到一半停了,括号没闭合。这类的特征是尾部一定不完整,而且越是长输出、越是数组元素多的任务,越容易出现。
第四类:结构漂移。 语法完全合法,但字段名变了(user_name 变成 userName),该是数组的给了对象,枚举值给了个没定义过的,或者多套了一层 {"result": {...}}。这类最危险——解析器不报错,脏数据直接进了下游。
第五类:语义错但格式对。 字段齐、类型对、值是编的。这属于模型幻觉,不是结构化输出的问题,具体判别方法见 大模型幻觉。本篇不覆盖这一类。
先做一件事:把失败样本存下来。很多团队排查这个问题时手里一个原始样本都没有,全靠印象。在解析失败的分支里把原始文本、模型标识、请求参数一起落盘,跑一天,你会发现失败分布往往高度集中,多数团队最后都是一两类占了绝大部分,剩下几类是零星长尾。具体是哪一两类,只能靠你自己这份分布说话——没有它,后面所有决策都是猜。
二、判别表:从现象到动作
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
解析报错,原始文本以 ``` 开头或含自然语言前后缀 | 包裹层污染 | 把原始文本打印出来看首尾 30 个字符 | 加提取层:先剥代码块围栏,再取第一个 { 到最后一个 } |
| 报错位置在文本中部,提示意外字符 | 语法级破损(尾逗号、单引号、未转义换行) | 把样本粘进本地 json.loads 复现 | 上宽松解析器兜一层;同时改用原生结构化输出能力 |
| 报错提示输入意外结束,尾部括号缺失 | 截断 | 检查返回元数据里的结束原因是否为长度触顶 | 减少单次输出量:分批、分页、把长数组拆成多轮 |
| 解析成功但下游取不到字段 | 结构漂移 | 上 schema 校验,看具体哪个字段不符 | 校验失败即重试;同时把字段名固化进 schema 而不是靠描述 |
| 偶发失败,同样输入有时成功有时失败 | 采样随机性 | 固定输入连跑 20 次统计成功率 | 降低随机性参数;对确定性任务用原生结构化输出 |
| 某类输入必挂,其他都好 | 任务本身超纲或输入含破坏性字符 | 按输入分组统计失败率 | 拆任务或做输入清洗,别指望重试 |
| 换了模型后突然大面积挂 | 不同模型对结构化能力支持不同 | 用同一批样本跑两个模型对比 | 见第六节止损线二:先压平 schema 再判,能力确实不对等就切回去 |
这张表的用法:从上往下走,不要跳。 包裹层污染没治好就去调采样参数,你会误判改动的效果。
三、第一层:解析要宽容,但宽容要有边界
解析层的目标是”能救的都救回来”,但不能救到把错的当对的。
第一步是剥壳。围栏代码块、开头的客套、结尾的补充说明,全部剥掉。一个实用的顺序是:先按代码块围栏切一刀,如果切到了就用围栏内的内容;切不到就退回全文,取第一个左花括号(或左方括号)到与之匹配的闭合符之间的片段。注意是匹配的闭合符,不是最后一个——直接取最后一个 } 在嵌套加尾部说明的情况下会切错。
import json
def extract_json_block(text: str):
# 剥 Markdown 围栏
if "```" in text:
parts = text.split("```")
for p in parts:
p = p.strip()
if p.startswith("json"):
p = p[4:].strip()
if p.startswith("{") or p.startswith("["):
text = p
break
# 括号配平扫描,取第一个完整对象
start = None
depth = 0
in_str = False
esc = False
for i, ch in enumerate(text):
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
elif ch in "{[":
if depth == 0:
start = i
depth += 1
elif ch in "}]":
if depth == 0:
continue # 前缀里的孤立右括号,忽略掉,否则 depth 变负后再也收不了口
depth -= 1
if depth == 0 and start is not None:
return text[start:i + 1]
return None
raw = extract_json_block(model_output)
data = json.loads(raw) if raw else None
这段代码有两个细节值得单拎出来说,因为它们正是自己写提取器时最容易翻车的地方。
一是字符串内的括号不能计入深度。模型抽取出来的字段值里带一个 } 或 " 是很平常的事(比如抽的是一段代码、一句带引号的原话),不做 in_str 判断的话,扫描会在字段中途提前收口,切出一个语法上恰好合法、内容却被腰斩的对象——解析器不报错,你也不会知道。
二是要处理不配对的右括号。模型在正文前面写一句带 } 的说明(或者上一轮的残留),朴素写法会让 depth 先减成负数,之后即使遇到真正的 { 也回不到 0,函数直接返回空。加一行 if depth == 0: continue 把这种孤立闭合符跳过去就行。这个坑很隐蔽:多数样本都能跑通,只在带前缀噪声的样本上静默失败,而带前缀噪声恰恰就是你写这个函数要解决的场景。
写完这类提取器,建议配一组固定的病态样本做回归:带围栏的、不带围栏的、字段值里含括号的、前缀含孤立括号的、尾部带补充说明的、一个响应里出现两个对象的。每次改提取逻辑跑一遍,二十秒的事,能挡掉绝大多数”改一个 case 崩另一个 case”。
第二步是宽松解析。尾随逗号、单引号这类破损,可以在严格解析失败后走一次容错解析器再试。但容错要留痕:每次靠容错救回来的都记一条指标。如果容错命中率在涨,说明上游在恶化,你只是把问题盖住了。我见过盖了三个月,最后模型换代时一起爆的。
第三步是边界。有两种情况不要救:一是文本里出现了多个互相矛盾的 JSON 块(模型给了两版答案),二是提取出来的对象字段数明显少于预期。这两种直接判失败走重试,硬救回来的是半成品。
这里要说清上面那个提取函数的职责边界:它遇到第一个完整对象就返回,不负责发现”还有第二块”。“多块”的检测得在调用方另做——最省事的办法是让扫描不提前 return,把全文所有顶层完整片段都收集起来,数量大于 1 就直接判失败。别指望提取器替你做这个判断,它设计上就是”取一个”。
四、第二层:把约束从措辞挪到接口
解析层是补救,真正的治本在约束层。优先级从高到低:
原生结构化输出能力最优先。 现在主流模型接口大多提供某种”按 schema 输出”的模式(不同厂商叫法不同,能力边界也不同,具体支持哪些 JSON Schema 子集、是否保证合法,以各家官方最新说明为准)。只要目标模型支持,就用它——它是在解码阶段做约束,和”你请务必只输出 JSON”这种概率约束不是一个量级。
其次是工具调用/函数调用形式。 把你要的结构定义成一个工具的入参 schema,让模型”调用”它。这条路在很多模型上比自由文本 JSON 稳,因为参数结构走的是同一套约束通道。副作用是你得处理模型不调工具直接回话的情况,这属于 Agent 工具调用报错排查 的范畴。
最后才是措辞约束。 如果模型两样都不支持,只能靠指令,那至少做到三件事:给一个完整的输出样例(样例比描述有效得多);字段名用扁平英文小写下划线,别用中文键名和深嵌套;明确写清空值怎么表示(是 null 还是空字符串还是省略键,选一种)。
无论走哪条路,下游都必须有 schema 校验。这是不可省的一层。原生结构化输出也会漂移——比如枚举值超出定义、必填字段给了空串。校验的意义不只是拦错,还在于它给了你一个可靠的重试触发信号:不是”解析失败”才重试,而是”校验不过”就重试。
from pydantic import BaseModel, ValidationError
from typing import Literal
class Extract(BaseModel):
title: str
priority: Literal["high", "medium", "low"]
tags: list[str]
try:
obj = Extract.model_validate(data)
except ValidationError as e:
# 把 e.errors() 里的字段路径带进重试提示,比笼统说"格式错了"有用得多
...
还有一个常被忽略的点:流式输出和结构化输出是天然打架的。流式场景下你拿不到完整对象就得开始渲染,中途的片段永远是非法 JSON。要么放弃流式改整体返回,要么用增量解析器只在字段完整时才 emit。相关的乱序和拼接问题见 流式输出乱序。
五、第三层:重试怎么设才不是白烧
重试的核心原则:每次重试必须带上新信息,否则就是原地掷骰子。
无信息重试(同样的输入再发一次)只对纯随机性失败有效,成功率大致等于单次成功率。如果单次是 90%,重试两次能到 99.9%,看起来够用;但如果单次只有 60%,那说明问题在别处,重试再多也是烧成本。这里有条实用的阈值判断:如果第一次重试的成功率明显低于首次调用成功率,停手——那不是随机失败,是系统性失败。
带信息重试的做法是把具体错误回灌:哪个字段缺了、哪个值不在枚举里、解析在第几个字符断的。注意回灌的是校验器的结构化错误,不是你自己编的一句”格式不对请重来”。前者能让模型定位,后者约等于无信息。
重试次数建议按失败类型分档:
- 包裹层污染、语法破损:不重试,走解析层修复。这类重发大概率还是同一个毛病。
- 结构漂移:重试 1 次,带上字段级错误。第二次还不对,说明 schema 和任务不匹配。
- 截断:不要原样重试,改成缩小输出规模后再发。同样的任务重发还会在同一个地方截断。
- 疑似随机失败:最多 2 次,指数退避。
退避不只是为了礼貌。如果你在解析失败后立刻密集重发,很容易同时撞上限流(HTTP 429),把一个格式问题变成一个可用性问题。429 的具体处理见 DeepSeek API 429 限流——那篇讲的退避思路在这里同样适用。
还有一个成本账要算清楚。重试是实打实的额外调用,整段输入要重新计费一次——部分厂商提供提示缓存可以降低重复前缀的单价,但是否命中、折扣多少各家规则不同且会变,以官方最新说明为准,别把它当成默认成立的前提。真正要盯的是这个乘数:失败率越高、平均重试次数越多,实际调用量相对理想情况的放大倍数就越大,而这个倍数通常比人的直觉大不少。上线前把重试计数和重试原因一起打进监控,按类型分开看,别等账单来了才回头查。相关做法见 API 成本监控。
六、什么情况下别再折腾
这一节比前面几节都重要。结构化输出这个问题特别容易陷入无限调优——每次改完都好一点,永远差最后那几个百分点。给几条明确的止损线:
止损线一:改了三轮措辞,成功率没有台阶式提升。 措辞调优的收益是阶跃的,不是线性的。如果三轮下来只是从 87% 挪到 89%,那已经到顶了,继续磨没意义。该做的是换约束层——上原生结构化输出或工具调用形式。
止损线二:同一个 schema 在两个模型上表现差异巨大。 这通常说明 schema 设计吃了某个模型的特性。把嵌套压平、把枚举值改成更常见的英文词、把可选字段减到最少,再对比。如果压平之后差异消失了,是你的 schema 太挑;如果还在,是模型能力不对等,接受它,别指望调平。
止损线三:失败集中在特定输入类型上。 比如所有含大段代码的输入都挂,所有超长输入都挂。这是任务边界问题,不是输出格式问题。正确动作是拆任务——把”一次抽取 12 个字段”改成两次各抽 6 个,或者先做一次分类再分流处理。硬扛只会让链路更脆。
回滚点:新方案的端到端成功率低于旧方案就回滚,不看单点指标。 典型的翻车方式是:改造把 JSON 解析成功率提上去了,但因为换到了更慢的路径(多一次校验往返、或者原生结构化输出在该模型上延迟更高),超时率跟着涨,端到端拿到可用数据的比例反而降了。单点指标涨、整体指标跌,这种改造上线后往往还要过一阵才被发现。判断标准只有一个:最终拿到合法且可用数据的比例。
换条路的信号:如果你的下游其实不需要严格 JSON,就别要 JSON。 很多场景要的只是几个字段。用换行分隔的 key: value、用固定分隔符的行式输出、甚至让模型只输出一个枚举词,都比 JSON 稳得多——因为出错空间小。JSON 的括号和引号本身就是失败面。这条是最被低估的解法:降低输出格式的复杂度,往往比提升模型的遵循能力容易一个数量级。
最后,如果你在评估要不要为了结构化能力换模型,先想清楚整体代价。切模型会牵动上下文行为、成本结构和已有的调试经验。海外部分工具和模型官方对中国大陆有区域限制、不支持直连使用,虽然存在第三方中转,但可靠性与合规性都需要自己判断,这里不做背书也不给具体渠道。选型的完整流程可参考 AI 工具选型流程。
七、避坑清单
坑一:用正则直接抠 JSON。
为什么会踩:看起来一行就能搞定,re.search(r'\{.*\}', text, re.S) 立刻能跑。
怎么避:正则不理解嵌套和字符串转义,遇到内容里带花括号、带引号的字段就切错,而且切错之后往往还能解析成功,只是内容不全——静默错误比报错难查得多。用配平扫描代替正则,多写二十行换来可预期的行为。
坑二:容错解析器一路兜底,永不上报。 为什么会踩:接上去以后失败率立刻归零,看着很爽。 怎么避:给容错路径单独打指标。容错命中率是上游健康度的先行指标,它涨了说明有东西在变坏(模型换版本、输入分布漂移、上游改了内容),你需要在爆之前知道。
坑三:把重试写在最内层,外层还有一层重试。 为什么会踩:内层是写解析逻辑的人加的,外层是写调度的人加的,两边都不知道对方加了。 怎么避:重试只允许存在于一层,并且在代码里显式注释这是唯一重试点。嵌套重试的实际调用次数是乘法关系:三层、每层最多尝试两次,最坏情况就是 2×2×2 = 8 次真实调用。排查时给每次调用打上一个贯穿全链路的请求 ID,日志里数一数同一个 ID 出现了几次,藏着的那层重试立刻现形。
坑四:schema 里字段全设成可选。 为什么会踩:设成必填会让校验频繁失败,改成可选”世界就清净了”。 怎么避:清净的代价是脏数据进了下游,在更远的地方以更莫名其妙的形式爆出来。必填就是必填,校验失败该重试重试、该降级降级。可选字段只留给业务上真的可以缺的。
坑五:给模型的输出样例里带了真实数据。 为什么会踩:拿一条线上样本当例子最省事。 怎么避:模型会照抄样例里的具体值,尤其在输入信息不足时——你会在结果里反复看到那条样本的名字和数字。样例一律用明显是占位的假值。顺带,真实数据进指令还有数据安全问题,见 AI 数据安全风险。
坑六:把随机性参数调到零就以为确定了。 为什么会踩:直觉上温度为零等于确定性输出。 怎么避:实际上多数服务端实现并不保证跨请求的逐字节确定性,批处理、路由、版本更新都会带来差异。调低随机性能提高格式稳定性,但不能替代校验。校验层永远保留。
坑七:截断了就加大输出上限。 为什么会踩:这是最直接的反应。 怎么避:各家的输出长度上限规则不同且会调整,以官方最新说明为准;但更重要的是,长输出本身的质量往往不是均匀的——同一个响应里,靠后位置的条目容易比靠前的更粗糙。这一点别信我的,自己验:拿同一批任务让模型一次输出 20 条,人工抽查第 1-5 条和第 16-20 条的准确率,差多少一看便知。正确做法是把任务切小,一次抽 20 条改成分 4 批各 5 条。切小还有个附带好处:某一批挂了只需要重跑那一批,而不是整个任务从头再来,重试的代价从”全量”降成了”一批”。
收束
结构化输出的本质是给概率系统装一个确定性接口。装不好的团队通常是在措辞层反复用力,装好的团队都在做同一件事:把约束往下沉,把校验往前提,把重试变得有信息。
上线前对着这张清单过一遍:
- 解析层有没有剥壳 + 括号配平提取,而不是正则?
- 容错路径有没有单独埋点?
- 有没有 schema 校验,且必填字段真的是必填?
- 模型侧用没用原生结构化输出或工具调用形式,而不是只靠一句指令?
- 重试是不是只有一层,且带回了字段级错误?
- 截断类失败是不是走的”缩小规模”而不是”原样重发”?
- 失败样本有没有落盘,能不能按类型出分布?
- 有没有定义止损线:改到什么程度就该换约束层或换输出格式?
这八条全绿之前,先别急着换模型。绝大多数结构化输出的不稳,都不是模型的问题。