JSON 老是解析失败?约束解码:把 Schema 编译成 token 掩码(2026实战)

上个月把客服工单的抽取流程重写了一遍。旧版本每个月总有几十条解析失败,日志里躺着半截 JSON,后面跟着一串 JSONDecodeError。我当时的处理是重试三次,再不行丢给人工,补丁摞了两百多行。

后来发现根本不用这么费劲。问题不在模型笨,在我一直让它自由发挥。🙃

它吐什么,取决于每一步允许它选什么

大模型生成是逐 token 采样。每一步,模型给出一个覆盖整个词表的概率分布,temperature 调平,top_p 截断,然后从剩下的候选里抽一个。整套流程里没有任何一处检查「这个 token 加上去,JSON 还合不合法」。

所以代码围栏、多一个逗号、把枚举值写成同义词,都是完全正常的采样结果。它没犯错,它只是在做概率游戏。

约束解码(constrained decoding)改的就是这一步。把 JSON Schema 编译成一个状态机,每生成一个 token,就把所有会让状态机走进死路的候选概率压到负无穷,剩下的才参与采样。

换个说法:不是事后检查,是事前禁止。模型压根没有机会吐出非法字符。🔧

这套东西有几个名字。llama.cpp 里叫 GBNF 语法,vLLM 里叫 guided decoding 或者 structured outputs,Ollama 直接叫 format。底层思路一样,把结构描述翻译成 token 级的掩码。

同一句话,我跑了九次

说点具体的。模型 gpt-oss:20b,Ollama 0.34.2,RTX 5060 Ti 16GB,temperature 0.9,同一段客服描述:

客户说上周五买的耳机左耳没声音,想换货,订单号 A10293,他有点着急。请提取工单信息,只输出 JSON。

schema 要求四个字段:order_id 必须匹配 ^A[0-9]{5}$,issue_type 只能是 no_sound、broken、late_delivery、other 之一,priority 三选一,items 是对象数组。三种做法各跑三次。

第一种,只在提示词里写「只输出 JSON」:

  • 三次里有一次套了代码围栏,json.loads 直接失败
  • 字段名三次三个样:orderNumber、order_id、orderNumber
  • items 一次都没出现

第二种,Ollama 的 format 传字符串 "json",也就是常说的 JSON mode:

  • 三次都能解析,这一点确实改善了
  • 字段名是 issue、request、urgency 这一套,没有一次对得上 schema
  • items 依然缺席

第三种,format 直接传整个 schema:

  • 三次全部四个字段齐全
  • issue_type 三次分别是 no_sound、broken、no_sound,全部落在枚举集合内
  • order_id 三次都是 A10293,正则约束生效

JSON mode 保证「是 JSON」,约束解码保证「是这个形状」。这两件事差得挺远。不少团队上了 JSON mode 就以为万事大吉,结果字段名对不上,还得写一堆 get("issue", get("problem")) 这种兜底。

Ollama 里的写法

Ollama 从 2024 年 12 月开始支持结构化输出,format 字段既收 "json" 这个字符串,也收完整的 JSON Schema。Python 里最省事的是 Pydantic,model_json_schema() 造出来直接丢进去。

curl -s http://localhost:11434/api/chat -d '{
  "model": "gpt-oss:20b",
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "order_id": {"type": "string", "pattern": "^A[0-9]{5}$"},
      "issue_type": {"type": "string",
        "enum": ["no_sound", "broken", "late_delivery", "other"]},
      "priority": {"type": "string", "enum": ["low", "normal", "high"]}
    },
    "required": ["order_id", "issue_type", "priority"]
  },
  "messages": [{"role": "user", "content": "客户说耳机左耳没声音,订单号 A10293"}]
}'

Python 版本短得多:

from ollama import chat
from pydantic import BaseModel

class Ticket(BaseModel):
    order_id: str
    issue_type: str
    priority: str

resp = chat(
    model="gpt-oss:20b",
    messages=[{"role": "user", "content": text}],
    format=Ticket.model_json_schema(),
)
ticket = Ticket.model_validate_json(resp.message.content)

速度有一点代价。同样跑三次,不加约束平均 56 tok/s,加约束 46 tok/s 左右,慢了一成多。模型加载完之后单条响应 1.1 到 1.7 秒,日常抽取够用。样本只有三次,别当基准测试看。

官方文档还有一句提醒我照做了:把 schema 用文字也贴一份进 prompt,让模型知道自己在填什么,比只给机器可读的 schema 更稳。约束管的是能不能吐出来,语义还得靠说明。💡

它管不了的,正好是最要命的部分

约束解码有个特别容易被忽略的边界:它只约束形式。

我做了个对照。把订单号从原文里删掉,schema 依然要求 order_id 且必须匹配五位数字。模型两次都给了 A12345。格式完美,正则通过,Pydantic 校验也通过。

这个号是编的。

枚举也一样。issue_type 只能从四个值里挑,但挑哪个是语义判断。三次里有一次把「左耳没声音」判成了 broken,而不是更贴切的 no_sound。语法层面无懈可击,业务层面错了。

所以顺序得摆正:约束解码负责让下游代码不用写 try/except 和字段猜测,业务规则校验该写还得写。订单号存不存在,得查库。金额对不对,得对账。它替你省的是解析代码,不是判断力。

还有个细节,temperature 别因为上了约束就拉到 1.2。语法合法的输出也可以很离谱,只是离谱得很整齐。

vLLM 和 OpenAI 的写法

vLLM 的入口是 structured_outputs 参数,支持 json、regex、choice、grammar 四类约束。后端是 xgrammar 和 guidance,默认 auto 自动挑。跑 vLLM 本身的基础可以看vLLM 是什么那篇,这里只说接口。

completion = client.chat.completions.create(
    model=model,
    messages=[{"role": "user", "content": "提取工单信息"}],
    extra_body={"structured_outputs": {"json": schema}},
)

老的 guided_json、guided_regex 这些顶层字段已经移除了,参数统一收进 structured_outputs。网上不少 2024 年的教程还在写旧版本,照抄会直接报参数错误。

OpenAI 那边叫 Structured Outputs。Responses API 里挂在 text.format,Chat Completions 里挂在 response_format,两个都要显式开 strict。它有几条硬性要求,第一次用很容易卡住:

  • 所有字段都必须列进 required,想表达可选就用 ["string", "null"] 这种联合类型
  • 对象必须写 additionalProperties: false
  • 根节点必须是 object,不能是 anyOf
  • 不支持的关键字不会静默忽略,开了 strict 直接报错

好处是输出的 key 顺序跟 schema 一致,日志读起来舒服。JSON mode 只保证能解析,Structured Outputs 保证符合 schema,官方文档把这两者放在一张表里对比,别混着用。

再往下还有一层,llama.cpp 的 GBNF。它不认 JSON Schema,认的是巴科斯范式,适合约束非 JSON 的格式,比如棋谱、特定协议报文。JSON 场景一般不用手写,引擎会替你生成。

我的使用边界

现在的习惯是这样:输出要被程序消费,一律上约束。抽取、分类、工具参数、给前端渲染的结构,全都走 schema,解析代码从两百行掉到十几行。

纯给人看的文字不上。写文章、写邮件、解释概念,硬套 schema 只会让模型写得别扭,句子被字段名切碎。

schema 也别写太深。嵌套四五层、根节点 anyOf、递归引用,这些在部分引擎上要么降速要么直接不支持,报错信息还很难读。能拍平成两层就拍平。

还停在提示词阶段的,可以先翻结构化输出提示词那 6 个技巧,讲的是怎么用几句话把格式说清楚。真要接生产,还是得让引擎去管。提示词是请求,约束是规则,可靠程度不在一个量级。

顺带一句,重试逻辑别急着删。API 重试那套东西对付的是网络和服务端,跟格式问题是两码事。约束解码能让你少写一半兜底代码,不是全部。

发表评论