开启 JSON 模式后,大模型返回格式仍然报错,排查记录
1851 字约 7 分钟
摘要接口返回 HTTP 200,内容也能被 json.loads 正常解析,但业务校验仍然连续失败。本文从一次实际排查出发,说明 JSON 语法合法、业务 Schema 符合和服务端受约束生成三者之间的区别。
在运行一批自动邮件的批量改写任务时,后端的 AI 改写模块连续两次报出致命异常。
起初我并不理解原因:模型 API 的额度正常,接口响应很快,HTTP 状态码是 200,返回的文本也能被 Python 的 json.loads 正常反序列化。
既然已经开启了服务商介绍的“JSON Mode”,为什么 Pydantic 校验仍然失败?
查看报错日志,问题出在一个名为 marks 的字段上:
- 后端代码的预期: 一个字符串数组,例如
["bold", "italic"]; - 模型实际返回: 一个对象数组,例如
[{"type": "bold", "start": 0, "end": 4}]。
模型不仅理解了“给文本加粗”的意思,还自行计算了光标的起始和结束位置。结构很丰富,但程序无法识别,于是抛出了 ValidationError。
更麻烦的是内置的重试机制:相同的 Prompt 和参数连续运行三次,模型每次都在同一个地方出错,重试只是白白浪费了调用费用。
为什么不能靠补充提示词重试来解决?这种错误属于确定性的协议分歧。如果模型在当前的上下文和权重影响下,认为返回复杂对象更合适,那么在没有强格式约束(如语法约束解码或 Schema 引导解码)的情况下,即使降低随机性重试,它也可能反复给出同样的结果。
“支持 JSON”具体指什么?
很多开发者在阅读模型文档时,会把“结构化输出”当成一个简单的开关:要么支持,要么不支持。
但在实际工程中,“支持 JSON”至少包含三个层次:
| 层次 | 含义 | 判断标准 | 常见问题 |
|---|---|---|---|
| 第 1 层:语法合法(Valid JSON) | 括号、引号、逗号闭合正确 | json.loads(text) 不报错 | 字段名由模型随意决定,类型嵌套混乱 |
| 第 2 层:契约合法(Schema Valid) | 字段名和数据类型符合代码的定义 | Pydantic 或 Zod 校验通过 | 偶尔缺少必填字段,或者把字符串输出为数组 |
| 第 3 层:受约束生成(Constrained Decoding) | 在推理阶段逐个 Token 约束在语法树内 | 不可能输出非法结构 | 依赖底层推理框架(如 Outlines、vLLM)的支持 |
通俗比喻:填写了答题卡,不代表答案正确为什么“能解析成 JSON”仍然会报错? 假设考试时发了一张答题卡:
- 第 1 层(语法正确):相当于学生用 2B 铅笔把答题卡涂满,读卡机没有报错。但这只能说明涂的是合法的铅笔印,无法判断选的是 A 还是 B;
- 第 2 层(契约正确):要求“第 5 题必须填写姓名”,学生却填了一串数字。答题卡的格式没有问题,但阅卷系统按规则校验后判为错误;
- 第 3 层(强受约束生成):答题卡的格子只允许填入特定的字符,从物理上就无法填错格式。
模型厂商宣称的“JSON Mode”通常只保证了第 1 层,答题卡的具体内容仍然需要碰运气。
什么是受约束生成(Constrained Decoding)?普通的文本生成会从词表中选择概率最高的 Token。受约束生成则在每一步维护一个状态机(上下文无关文法、正则表达式或 JSON Schema)。凡是会导致 JSON 结构错误或类型不符的 Token,其概率会在 Softmax 之前被设为负无穷,从底层杜绝生成非法 JSON 的可能。
我们当时开启的 JSON Mode,最多只保证了第 1 层:输出不会是纯 Markdown,不需要再用正则从正文中提取代码块。
至于某个字段是字符串数组还是对象数组,模型只能靠推测来决定。
HTTP 200 具有误导性
既然要加上 Schema 约束,那就使用各大厂商支持的 response_format 参数。
但现实中,很多自建网关和聚合渠道对参数的支持程度不一。为了了解当前环境的实际情况,我写了一个探针,使用相互冲突的 Prompt 和 Schema 做了三组对照测试:
- 探测 A(严格 Schema): 传入
type: "json_schema"。服务端直接返回HTTP 400: Unsupported parameter,拒绝了请求。 - 探测 B(宽松模式): 传入
type: "json_object"。服务端返回了HTTP 200,但响应内容只有一对花括号,核心字段全部缺失。 - 探测 C(纯文本基线): 不传结构化参数,只靠 Prompt 约束,反而能完整输出所有字段。
探测 B 最容易让人误判。如果健康检查只判断 status_code == 200,就会认为当前网关完整支持 JSON Object,并把这个结论缓存下来。一旦进入真实业务,就会全部失败。
探针原则不能只根据 API 的状态码判断功能是否可用。应该发送一个带有反向诱导的轻量 Schema 请求。只有当模型在 Prompt 的干扰下仍然按照 Schema 输出所有字段,才能判断该节点真正具备受约束生成的能力。
两种治理措施
弄清楚网关的实际能力之后,解决方案分为两部分。
1. 动态能力分级与逐级降级
代码不再写死配置,而是根据探针结果自适应:
- 优先协商
json_schema(强约束); - 协商失败后,自动降级为
json_object; - 仍然不行,则退回
prompt_template,并用本地正则清洗输出。
探针结果会按“网关地址 + 模型版本”进行短期缓存,避免每次线上请求都多一次往返。同时,错误要区分为“协议不支持”和“数据校验失败”。不能因为一次偶发的 Pydantic 报错,就把整个通道降级。
2. 精简 Schema,避免过度复杂
这次事故更深层的原因,是我在 Schema 设计上追求了过多的内容。
最初为了省事,我让模型在改写邮件的同时,直接输出排版样式、字符偏移量,甚至要保留表格的嵌套结构。这种复杂的递归 JSON 结构,即使是人手写也容易出错,更不用说让模型生成。
于是我重新设计了数据契约:
- 模型只负责擅长的核心语义: 只输出一个扁平的键值数组,说明“哪一段占位符应该改成什么新文本”;
- 样式、排版与 DOM 组装: 全部交给本地代码处理,由后端根据原始的 AST 回填。
【改造前】
模型需要生成:文本 + 样式 + 坐标 + 树形嵌套(非常容易出错)
【改造后】
模型只需生成:{"anchor_1": "尊敬的李老师", "anchor_2": "期待与您交流"}
复杂排版由本地代码精确组装当输入和输出的 Schema 简化为一张扁平的表之后,即使使用最简单的纯文本 Prompt,模型的出错率也降到了 0.1% 以下。
不要把 JSON Mode 当作万能方案
这次排查最大的收获,是不再盲目相信“JSON Mode”这个宣传说法。
它并不是开箱即用的类型安全开关,只是在 Token 生成的概率空间里,对花括号加了一点偏置而已。
想让大模型像可靠的微服务一样提供结构化数据,需要注意以下几点:
- 不要让模型做计算和排版,Schema 越扁平越好;
- 不要相信 HTTP 200,要用实际的探针验证真实的约束能力;
- 代码中的 Pydantic 校验和防御性兜底,任何时候都不能去掉。