第 11 章 · 采样Tool calling & constrained decoding

工具调用与受限解码

深入阅读 · 第 11 章 Sampling——第 15 章已经带你看过 tool_call 格式;这里要讲的是,一个生产系统怎样只靠本章自己的 sampling 机制、外加一个额外的 mask,就能让这个格式变得不可能出错

让模型调用外部函数这件事,可以追溯到 ReAct(Yao 等人,2022 年 10 月):在同一段生成流里,把自由文本推理和离散的动作——一次查询、一次搜索——交织在一起,让模型能够计划、行动、观察、再重新计划。OpenAI 在 2023 年 6 月把这个模式产品化,做成了 function calling(模型 gpt-4-0613 / gpt-3.5-turbo-0613):模型被微调成会吐出一段描述函数调用的 JSON,而不是散文。Simon Willison 就在同一周写道,这其实正是——「an implementation of the ReAct pattern, with models that have been fine-tuned to execute it」(对 ReAct 模式的一次实现,只是模型被微调过去执行它)。这里的陷阱是:微调只能教会模型大多数时候吐出格式良好的 JSON,并没有任何硬性保证它每次都会这样。

靠 mask,而不是靠祈祷

这个修法根本不碰训练——它发生在本章整章都在讲的那个确切时刻:挑选下一个 token。通常你拿到原始 logits,可能套上 temperature,可能只保留 top-k 或 top-p 那部分概率质量,再采样或 argmax。受限(基于语法的)解码在这之前再加一步:跟踪给定 schema 和目前为止已生成内容,哪些 token 仍然在句法上合法,把每一个不合法的都 mask 成 ,然后才跑通常的 sampling。Outlines 论文(Willard & Louf,2023 年)把这形式化为:把一个正则表达式或 JSON schema/CFG 编译成一个有限状态机,再把词表按 FSM 状态建好索引;llama.cpp 的 GBNF 语法在 C++ 里做的是同一件事。跟着第 15 章引入的那个确切 tag 格式——<tool_call><function=get_weather><parameter=city>Paris</parameter></function></tool_call>——一步步走一遍:

给语法上 mask,一次一个 token
目前为止的补全
<tool_call><function=get_weather><parameter=city>
原始概率(无约束)
Paris
0.52
纯文本——只要不含裸露的 `<`,任何内容都是合法的字符串值
I
0.09
同样合法——语法只检查句法,不检查语义
<function=
0.19
这里出现裸露的 `<` 就会像是在开始一个新标签
{
0.12
这套 tag 格式从不使用 JSON 花括号
</parameter>
0.08
还没写任何内容就收尾——city 不能是空的
语法 mask → 重新归一化
Paris被选中
0.85
纯文本——只要不含裸露的 `<`,任何内容都是合法的字符串值
I
0.15
同样合法——语法只检查句法,不检查语义
保留的概率质量:0.61 —— 5 个候选中有 2 个存活

这和本章其余部分是同一套机制:在词表上建一个保留/剔除的 mask,把每个被剔除的 logit 设为 −∞,重新归一化,再对存活下来的部分跑 argmax 或 top-p。唯一变化的是谁来决定这个 mask——top-p 用的是概率截断,这里用的是语法合法性。

完整的补全,一步步拼出来:
<tool_call><function=get_weather><parameter=city>Paris</parameter></function></tool_call>

这是针对这套 tag 格式假想出的一个语法所用的示意概率——和 Outlines 把 regex/CFG 编译成 FSM、或 llama.cpp 的 GBNF 语法是同一个思路——不是模型的实时运行,也不是本仓库自己的生成循环实际在做的事(见下文)。

注意这个 mask 的形状并不总是一样。取值取到一半时,几乎什么都合法(值本来就是自由文本)——mask 几乎不怎么收窄候选范围。而紧接在 get_weather 唯一必需的那个参数闭合之后,恰好只有一个 token 合法,</function>——在 temperature 或 top-p 有机会说话之前,mask 本身就已经决定了下一个 token。这是一条通用的道理:语法 mask 保证的是句法不可能出错;它对内容——那个城市名对不对、背后的推理靠不靠谱——什么都没保证。这是两个彻底独立的问题。

mask 买来了什么,有数字为证

OpenAI 自己的数字把这个道理讲得很干净。2024 年 8 月上线 Structured Outputsstrict: true)时,他们把这次改动描述成从「微调然后祈祷」走向「一种确定性的、工程化的方法,叫做 constrained sampling」。在他们自己的复杂 JSON schema 评测上:gpt-4-0613(2023 年那一代、只靠微调的 function calling)得分不到 40%;更新的 gpt-4o-2024-08-06模型只靠微调达到 93%;在此之上再加上受限解码(Structured Outputs),达到100%。mask 并不会让模型更聪明地知道该调用什么——它只是让调用的形状变得不可能出错。

你浏览器里的 Qwen 这样做吗?

没有。这个仓库的 sampler(crates/mlx-core/src/sampling.rs)实现的正好就是 temperature、top-k、top-p、min-p——本章前面讲过的那几个旋钮——没有任何为语法或 schema 合法性 mask token 的东西:没有 logit_bias,没有允许 token 的 mask,整条流水线里也没有编译过的语法(这一点通过直接读 sampler 源码、并在这个项目的 browser/WASM 驱动代码里搜索语法相关关键词确认过——一个都没有)。工具调用反而是事后找回来的:模型完全自由地生成,而crates/mlx-core/src/tools/mod.rs——它自己的文档注释就直说了——「uses simple string-based parsers instead of regex」(用简单的基于字符串的解析器,而不是正则),在生成结束之后去扫描已经写完的文本里的 <tool_call> 标签。没有硬约束这件事,最清楚的证据是:这个解析器自己的结果类型 ToolCallResult 有真实的失败状态——invalid_jsonmissing_nameparse_error——而这些状态之所以存在,正是因为一开始就没有任何东西阻止模型生成格式不对的输出。真正的语法 mask 会让这些状态在设计上就不可达;在这里,它们是活生生会被走到的代码路径。所以 第 15 章给你看过的那个 tag 格式,其可信程度正好等同于模型自己学会的、把格式写对的那个习惯——和 OpenAI 最早 2023 年的 function calling 属于同一类,而不是后来那种受限解码的类别。