上个月把客服工单的抽取流程重写了一遍。旧版本每个月总有几十条解析失败,日志里躺着半截 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 重试那套东西对付的是网络和服务端,跟格式问题是两码事。约束解码能让你少写一半兜底代码,不是全部。