JSON Mode成功率99%,但Schema正确率可能为0
昨天有人问我:“你写了十年技术专栏,被问最多的问题是什么?”
我想都没想:“怎么让大模型老老实实输出JSON?”
真的。
这个问题前两年简直是我的噩梦。那时候我在做训练数据生成,经常半夜被报警吵醒——程序解析JSON失败了。打开日志一看,模型前面加了一句“好的,以下是JSON结果”,或者最后少了个引号。json.loads()直接崩了。
你想想那个场景:凌晨三点,你盯着屏幕上的一行报错,心里把模型骂了一百遍。
但现在回头看,这事儿其实挺有意思的。它看起来是个小问题,实际上把LLM的核心矛盾暴露得明明白白——一个概率采样器,怎么输出严格的结构化语言?
这事儿为什么这么难?
先别急着要解决方案,理解一下问题本质。
LLM是自回归模型,每一步只能看到前面生成的内容,然后预测下一个token的概率分布。但JSON是个上下文无关语言,它的合法性依赖全局结构——一个{必须有对应的},数组开始后必须正确结束。
有篇论文做了个实验,让模型生成匹配的括号对。GPT-2 XL在长度超过36个字符时,错误率超过95%。就连参数量接近78亿的Gemma,到282个字符时也崩了。
这不是模型笨。这是架构层面的矛盾。
神经网络天然不擅长需要精确计数和配对的算法性任务。它得隐式维护一个类似栈的状态,这事儿对概率模型来说太难了。
所以解决方案沿着两个方向走:要么在生成外部做文章,要么在生成过程中介入。
Prompt工程能解决一部分问题
我刚开始做的时候,觉得这事儿靠Prompt就能搞定。写了一大堆“请输出纯JSON”、“不要加任何解释”、“不要用Markdown包裹”。
翻车了。
模型嘴上答应得好好的,该加“好的”还是加。后来我学聪明了,不只说“要做什么”,更要强调“禁止做什么”。
这是我现在的系统提示词模板:
你是一个专业的数据格式化助手。
请严格遵循以下规则:
1. 仅输出符合RFC 8259标准的纯JSON,无任何多余文字、解释、注释。
2. 禁止使用Markdown代码块、禁止加```json标记。
3. 字段名称、类型严格按照要求,字符串用双引号,禁止尾随逗号。
4. 只返回JSON,不回答任何其他问题。但光有规则不够。模型对“示例”的服从度远高于文字描述。
我一般会加Few-Shot,给2-3个输入输出对。而且用TypeScript Interface代替JSON Schema来描述结构,模型理解得更好。这个技巧是我从掘金上的一篇文章学来的,试了一次就离不开了。
Prompt这层,成本最低,能解决大概60%的问题。适合原型验证或者低风险场景。
但生产环境?光靠Prompt就是在赌博。
API原生能力让稳定性上一个台阶
大部分厂商现在都提供了强制JSON输出的参数。
OpenAI有response_format={"type": "json_object"},DeepSeek也有类似的JSON Mode。它的实现原理是约束解码——在预测下一个token时,把不符合JSON语法的token直接mask掉。
我测试过,GPT-4o和DeepSeek用这招,合法JSON的成功率能到99%以上。
但有个坑:合法JSON不代表字段对。
模型可能给你一个完全合法的JSON,但字段名叫name_xxx,或者该是数字的字段给了字符串。JSON Mode只保证语法,不保证Schema。
所以OpenAI后来又推出了Structured Outputs,可以直接传完整的JSON Schema:
response = client.chat.completions.create(
model="gpt-4o",
response_format={
"type": "json_schema",
"json_schema": {
"name": "extraction_result",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
}
}
}
}
)这个就稳多了。字段名、类型、必填项都能保证。
但问题是,不是所有模型都支持这功能。你用开源模型自己部署,就没这待遇了。
自己动手的时候到了
如果你自己部署开源模型(vLLM或者Llama.cpp),那就得上点硬核手段了。
Outlines库和Llama.cpp的Grammars功能,能从Logits层面锁死输出格式。它不是“建议”模型输出JSON,而是“强制”——在每一步生成时,只允许符合语法规则的token出现。
我部署Qwen-2的时候用过Outlines,稳得一批。而且还有个意外收获:推理速度变快了。因为候选token少了,计算量小了。
不过配置起来确实麻烦。你要用Pydantic定义数据结构,然后转成JSON Schema,再传给Outlines。我第一次配的时候搞了一下午。
还有个方案是Function Calling。让模型把结构化数据当成“函数参数”输出。这个思路挺巧妙的,因为Function Calling的参数天然就是JSON格式。
但说实话,小模型连Function Calling都不一定稳定。我试过硅基流动上的一些模型,参数JSON照样给你少个引号。
绝了。
这时候就得最后一层兜底了。
后处理兜底
不管前面做了多少工作,永远假设模型输出可能不合法。
我现在的做法是:拿到输出先json.loads(),失败了就进修复流程。
修复策略有几个层次:
- 去掉前后的多余文字(正则匹配第一个{到最后一个})
- 修复常见错误:单引号改双引号、去掉尾随逗号
- 用json parser定位错误位置,从那儿重新生成
有个库叫strict-json,就是干这事儿的。它会解析到出错的地方,然后只重试出错之后的部分,不用从头生成。省钱。
如果还不行,就完整重试。我一般设3次重试,每次把上次的错误信息喂给模型:“你上次输出的JSON在第42个字符处有语法错误,请修正”。
三次还不行?那这条数据大概率有问题,先记下来人工处理。
两个让我头疼了无数次的事
除了格式问题,还有两个坑。
截断:JSON坏了是因为max_tokens不够,模型没说完就被掐断了。
解决办法两个:一是把max_tokens设大点,二是用流式输出+增量解析。别等整个请求结束再解析,用jstream这类库,收到一部分就处理一部分。哪怕最后断了,前面的数据也能保住。
幻觉:你让模型提取合同金额,合同里没写,它非要编个“100万”。
这时候Schema里要善用Optional和Nullable。Prompt里明确告诉模型:原文没找到就填null,别瞎编。
用约束解码时这点尤其重要。因为如果不给模型留“我不做”的出口——就是null——在约束的逼迫下,它可能被迫选一个看起来最像答案的错误答案。
我的决策路径
写了这么多,给个简单的抄作业指南吧:
如果你主要调OpenAI或Claude的API,直接用Instructor库。它把Pydantic定义、JSON Mode、重试逻辑全封装好了,写代码像调本地函数。这是我目前最推荐的方案。
自己部署开源模型的话,上Outlines或者Llama.cpp Grammars。从Logits层面锁死格式,最稳,还能加速推理。
处理超复杂数据时,试试Function Calling加CoT。先让模型在thought字段里思考,再在data字段里输出JSON。很多时候模型输出不对,是因为没想清楚。
Prompt上有个小技巧:用TypeScript Interface代替JSON Schema描述结构,在Completion模式下手动预填充{。
我还没想明白的是...
微调到底值不值得?
理论上,用LoRA微调几百条纯JSON数据,模型就能形成“肌肉记忆”。我试过,Qwen-2微调后在特定任务上确实能媲美GPT-4,成本还低。
但维护一个微调模型带来的MLOps成本,远比你写几行代码做后处理要高。模型更新了你要重新微调,数据分布变了你要重新微调,换了个任务你又要重新微调。
所以我的原则是:能用工程手段解决的,别轻易上微调。
这套策略可能明年就变了。毕竟这领域发展太快。半年前我还觉得JSON Mode是银弹,现在Structured Outputs都出来了。
谁知道明年会怎样呢。
大概就是这样吧。
读者评论 4