结构化约束如何真正进入 sampler#

这章解决什么问题#

执行模型前面已经讲了 SamplingBatchInfo、vocab mask、grammar sync,但还差最后一块真正闭环的桥:当 response_formatjson_schemaregexebnf 或 tool-call constraint 最终落成 sampling metadata 后,它们在 sampler 这一侧到底怎样实际改变 token 选择?

这一章的目标,就是把“结构化约束如何真正卡进 sampler”讲清楚。

为什么这章不是重复结构化生成章节#

结构化生成章节回答的是:

  • 约束对象怎样创建
  • grammar backend 怎样维护状态
  • scheduler 怎样在 decode 前等待 grammar ready

这一章回答的则是:

  • 一旦这些对象已经 ready,并进入 SamplingBatchInfo
  • sampler 在同一个 forward() 里到底怎样对待它们

这已经是纯 execution-side 的问题。

一张图:约束在 sampler 看来,不是“额外功能”,而是输入分布的一部分#

这张图解决的理解障碍是:很多读者把结构化输出想成外部 validator,而不是 sampler 真实输入的一部分。

flowchart LR
    Params["SamplingParams / response_format / tool constraint"] --> SBI["SamplingBatchInfo"]
    SBI --> Mask["vocab_mask + apply_mask_func"]
    Mask --> Logits["masked logits"]
    Logits --> Sample["Sampler token selection"]

图比纯文字多解释的一点是:约束一旦进入 execution,不再是“对输出结果做检查”,而是直接改写 logits 空间。

SamplingBatchInfo.update_regex_vocab_mask() 真正做了什么#

这是整条桥最值得盯住的函数之一。它会:

  1. 先检查 self.grammars 是否存在
  2. 找到第一个可用 grammar 作为 mask allocator
  3. 为整个 batch 分配 vocab_mask
  4. 对每个 request 的 grammar 执行 fill_vocab_mask(...)
  5. 把 mask 移到执行设备上

这个顺序说明 grammar 对 sampler 的影响不是单请求即兴判断,而是一套正式的 batch-level 预处理。

apply_logits_bias(...) 是约束真正生效的关口#

SamplingBatchInfo.apply_logits_bias(...) 里,会按顺序应用:

  • additive penalties
  • scaling penalties
  • orchestrator penalties
  • vocab_mask
  • logit_bias

这里最关键的点是:vocab_mask 不在 sampler 外部单独处理,而是和其他采样前置变换一起进入统一的 logits 改写关口。

这说明从 execution 角度看,结构化约束不是旁路功能,而是与 penalty、logit bias 平级的一等采样前置变换。

为什么这会影响你对“采样参数”的理解#

如果你只是把采样理解成温度、top-p、top-k 的数学变换,那么结构化约束会看起来像一个后补丁。但源码组织已经明确表明:

  • 温度 / top-p / top-k
  • penalty
  • logit bias
  • vocab mask

都在执行边界汇合。也就是说,Sampler 在本质上是在一个“被多层约束重写过的 logits 空间”里选 token。

need_top_p_sampling 和 grammar mask 为什么不是同一类条件#

这一点也值得写清楚:

  • need_top_p_samplingneed_top_k_sampling 主要改变 sampler 走哪种抽样算法
  • grammar mask 主要改变“候选空间中哪些 token 还活着”

它们都影响采样,但不是同一维度的影响。好技术书应该把这种维度区分讲清楚,否则读者容易把所有采样复杂度都看成一回事。

tool-call constraint 进入这条链后又发生了什么#

前面结构化生成章节已经写过,OpenAI chat 请求会把 tool-call constraint 转成 json_schemastructural_tag 再注入 sampling_params。到了 execution 层,重要的不是“它最初来自工具调用”,而是“它现在已经退化成 grammar / mask 语义的一种变体”。

也就是说,一旦到了 sampler,这些不同来源的约束会开始呈现统一形状:

  • 要么改变 vocab mask
  • 要么改变 logits 空间

这正是 system design 中“多种上游语义在低层收敛成同一执行形式”的经典例子。

这一层最容易出现的误判#

1. 以为 structured output 主要在输出后检查#

execution 侧已经把它变成采样前的 logits 改写了。

2. 以为 grammar mask 只是一个小辅助字段#

它会真正改变 token 候选空间,并触发更保守的同步语义。

3. 以为 sampler 的复杂度只来自 top-p/top-k#

实际上结构化约束和 penalties 也在同一关口汇合。

如果你想顺着这条桥读源码,先怎么走#

建议按这个顺序:

  1. SamplingParams 里哪些字段能承载结构化约束。
  2. SamplingBatchInfo.from_schedule_batch(...)
  3. 再看 update_regex_vocab_mask()apply_logits_bias()
  4. 最后回到 Sampler.forward() 理解这些改写后的 logits 是怎样被采样的。

小结#

这一章真正想补齐的,是 execution model 里最容易被忽略的一点:

  • 结构化约束不是停在 scheduler 边上
  • 它会真正进入 sampler 视野,变成 logits 空间的一部分
  • 也正因为这样,约束代价与采样代价在 execution 层会真正叠加

到这里,执行模型对“结构化约束如何进入采样”才真正形成闭环。