Junie's Blog

开了 JSON 模式,大模型返回格式依然报错的排查

全文共 1964预计阅读 7 分钟

在跑一批自动化邮件的批量改写任务时,后端的 AI 改写模块突然连续报了两次致命异常。

第一反应很纳闷:调用的模型 API 额度正常,接口秒级响应,HTTP 状态码是清清爽爽的 200,抓包拿出来的文本也能被 Python 的 json.loads 一字不差地成功反序列化。

按常理说,既然都开启了服务商吹得很神的“JSON Mode”,怎么后端 Pydantic 校验还是当场暴毙了?

翻看报错日志,崩溃点卡在一个叫 marks 的字段上:

  • 后端代码预期: 一个简单的字符串数组,比如 ["bold", "italic"]
  • 模型实际返回: 一组极其“贴心”的对象数组 [{"type": "bold", "start": 0, "end": 4}]

大模型不仅看懂了“要给文本加粗”,甚至还自作主张、画蛇添足地把光标开始和结束的偏移量都帮我算好了!结构很丰富,但程序不认,直接抛出 ValidationError

更无奈的是内置的失败重试:同样的 Prompt、同样的参数连跑三遍,模型忠实地在同一处跌倒了三遍,白白浪费调用费。

为什么不能直接加几句重试 Prompt 解决?

这种错误属于确定性的协议分歧。如果模型在当前的上下文和权重诱导下,认定返回复杂对象更符合它的“审美”,在没有强格式约束(Grammar/Schema-guided decoding)的情况下,相同温度重试只会让它一次又一次吐出同一种优雅的错误答案。

“支持 JSON”到底在说什么?

很多开发者在看模型文档时,常常把“结构化输出”当成一个非黑即白的布尔值:支持,或者不支持。

但在真实工程落地里,“支持 JSON”其实隔着三道深不见底的鸿沟:

层次等级真实含义判定标准常见崩溃场景
第 1 层:语法合法 (Valid JSON)括号、引号、逗号闭合正确json.loads(text) 不报错字段名被模型任意发挥,类型胡乱嵌套
第 2 层:契约合法 (Schema Valid)字段名、数据类型完全符合代码定义Pydantic / Zod 校验通过模型偶尔漏掉必填字段,或者把 string 吐成 array
第 3 层:受约束生成 (Constrained Decoding)在推理生成阶段逐 Token 强制锁死在 AST 语法树上永远不可能吐出非法结构依赖底层推理框架(如 Outlines / vLLM)支持
通俗比喻:填空题写了汉字,不代表写对了答案

为什么“能解析成 JSON”还是会报错? 想象老师发了一张高考答题卡:

  • 第 1 层(语法正确):相当于学生确实是用 2B 铅笔把答题卡涂满了,读卡机没有卡纸报错。但这只证明“他是合法的铅笔印”,根本不管他涂的是 A 还是 B;
  • 第 2 层(契约正确):老师要求“第 5 题必须填姓名”,学生却在里面写了一串数字。虽然字写得很工整(合法 JSON),但阅卷系统一对照规则(Schema 校验)当场打零分;
  • 第 3 层(强受控生成):答题卡孔位设计成只能塞进特定的汉字卡片,物理上就不可能涂错格式。

模型厂商宣称的“支持 JSON Mode”,通常只保证了第 1 层不卡纸,答题卡里的具体内容依然是在碰运气。

什么是受约束生成(Constrained Decoding)?

传统的文本生成是模型从词表中采样概率最高的 Token。而在受约束生成中,推理引擎会在每个 step 维护一个状态机(CFG / Regex / 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(宽松 Mode):type: "json_object"。服务端很痛快地返回了 HTTP 200,但定睛一看,响应 body 里除了两个大括号,里面空空如也,连核心字段都丢光了!
  3. 探测 C(纯文本基线): 不带结构化参数,纯靠 Prompt 调教,反而能把字段吐齐。

探测 B 最具欺骗性:如果健康检查代码只判断 status_code == 200,就会误以为当前网关完美支持 JSON Object,从而把配置缓存下来;一旦真正跑业务,瞬间全军覆没。

探针法则

永远不要只看 API 返回的状态码来判断特性支持。必须发送一个带有反向诱导的轻量 Schema 请求,只有当模型在 Prompt 干扰下依然老老实实按 Schema 吐出字段,才能判定该节点真正具备受控生成能力。

把模型逼到死角的治理策略

摸清了网关能力的深浅后,解决方案分成了两条线推进:

1. 动态能力分级与渐进降级

代码不再写死一个配置,而是根据探针自适应:

  • 优先协商 json_schema(强制约束);
  • 协商失败,自动降级到 json_object
  • 再不行,退回 prompt_template + 本地正则清洗。

更重要的是把探针结果按“网关地址 + 模型版本”做短生命周期缓存,避免每次线上请求多耗费一次往返;同时把错误细分为“协议不支持”和“数据校验失败”,绝不因为一次偶发的 Pydantic 报错就草率地把整个通道降级。

2. 削减 Schema 的贪婪度

造成这次事故的更深层原因,其实是我在 Schema 设计上的“贪婪”。

最初为了省事,我试图让模型在改写邮件的同时,直接输出排版样式、字符 mark 偏移量、甚至还要保留表格嵌套结构。这种复杂的递归 JSON 结构,别说大模型,人类手写都容易错位。

我立刻大刀阔斧重构了数据契约:

  • 模型只负责最擅长的核心语义: 只输出一个扁平的键值对数组,告诉系统“哪段占位符改成了什么新文本”;
  • 样式、排版与 DOM 组装: 全部收归本地代码处理,由后端在内存里按原始 AST 回填。
【改造前】
模型需要生成:文本 + 样式 + 坐标 + 树形嵌套(极易爆死)
 
【改造后】
模型只需生成:{"anchor_1": "尊敬的李老师", "anchor_2": "期待与您交流"}
复杂排版由本地代码精准缝合(稳如磐石)

当输入和输出的 Schema 简化为一张纯粹的扁平表后,哪怕在最简陋的纯文本 Prompt 模式下,模型生成出错的概率也骤降到了 0.1% 以下。

别把 JSON Mode 当成免死金牌

这次踩坑的最大收获,是打破了对“JSON Mode”这个宣传词汇的盲信。

它不是一个开箱即用的类型安全开关,它只是在 Token 生成的概率空间里,轻轻给花括号加了一点偏置权重而已。

想让 LLM 真正像一个靠谱的微服务一样为你提供结构化数据:

  • 别让它做算术和排版,契约越扁平越好;
  • 别信 HTTP 200,用实战探针验证真实约束;
  • 代码底层的 Pydantic 校验和防御性兜底,一刻也不能撤下。

评论