AI / LLM

开启 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 做了三组对照测试:

  1. 探测 A(严格 Schema): 传入 type: "json_schema"。服务端直接返回 HTTP 400: Unsupported parameter,拒绝了请求。
  2. 探测 B(宽松模式): 传入 type: "json_object"。服务端返回了 HTTP 200,但响应内容只有一对花括号,核心字段全部缺失。
  3. 探测 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 校验和防御性兜底,任何时候都不能去掉。

评论