LogitsProcessor、采样输出与 finish 语义#

执行模型前面的章节已经把 forward -> sample 主循环、graph runner、speculative decoding 和 overlap scheduling 讲得很深了,但如果没有一章把“输出语义层”单独收束,execution model 仍然会停在“next token 是怎么来的”。真正更贴近运行时的问题是:模型前向给出的 logits 怎样被整理成 LogitsProcessorOutput,采样之后的 token 怎样重新落回 req.output_ids,系统又是怎样决定该不该停、停下时向外说了什么。

这章补的正是 execution model 的后半段:不是“如何生成候选”,而是“如何把候选变成正式输出语义”。只要这部分读稳了,finish_reason、output logprobs 和 surface 层看到的结束语义就不再像几处分散字段,而会变成一条完整链。

先把“logits 到 finish”放回同一条链#

这条链最容易被读散,因为相关逻辑分布在不同文件里:

  • layers/logits_processor.py
  • layers/sampler.py
  • schedule_batch.py
  • scheduler_output_processor_mixin.py
  • surface 层的 serving_chat.py / serving_completions.py

下面这张图的作用,就是把它们重新压回同一条后半段执行链:

flowchart LR
    Fwd["Model forward"] --> LP["LogitsProcessorOutput"]
    LP --> Samp["Sampler / batch_next_token_ids"]
    Samp --> Req["req.output_ids append / extend"]
    Req --> Finish["req.check_finished()"]
    Finish --> Meta["meta_info.finish_reason"]
    Meta --> Surf["OpenAI / native surface translation"]

图里最关键的一点是:finish 并不是 API 层突然补出来的字段,而是沿着“logits -> token -> request state -> response metadata”逐层固化出来的。

LogitsProcessorOutput 不是前向结果,而是后半段的共享工作台#

layers/logits_processor.py 里最值得抓的,不是字段数量,而是对象被清楚地分成了几部分:

  • 一部分由 LogitsProcessor 自己填,例如 next_token_logits、部分 hidden states
  • 一部分由 Sampler 后续回填,例如 next_token_logprobs、top logprobs、指定 token ids 的 logprobs
  • 另外还有 prefill-only 场景下的 input logprobs,以及 diffusion / customized info 这类特殊信息

这说明 LogitsProcessorOutput 根本不是“模型 forward 的直接结果”。更准确的理解是:它是执行后半段的共享工作台。前向先把原始 logits 和必要上下文放进来,采样层继续在这个对象上附着 logprob 和 token 级证据,后面的 output processor 再从这里把一部分语义沉淀回 request state。

从技术书角度看,这种“桥对象”特别值得单独点明。因为很多后半段问题并不该先看 sampler 或 surface,而应该先问:LogitsProcessorOutput 此刻到底已经装了哪些语义,还缺哪些语义。

LogitsProcessor.forward(...) 真正决定的是“后半段要处理哪些位置”#

这段函数如果只当“再包一层”来读,很容易错过它最关键的价值。它真正做的,是把最容易膨胀的后半段组合先收口:

  1. 统一 ForwardBatch / LogitsMetadata 视图
  2. 根据 forward mode、prefill-only、是否需要 input logprobs 等条件,裁剪 hidden states 和 sample indices
  3. 产出真正适合 sampler 继续消费的 LogitsProcessorOutput

这一步很重要,因为 execution model 到这里已经不是“所有位置一视同仁地处理”。它首先要决定:哪些位置值得继续进入 next-token 选择,哪些位置值得继续保留 hidden states 或 input logprob 语义。也正因为有这层工作,后面的采样和 finish 才不会无序膨胀。

为什么 chunked logprobs 处理特别值得注意#

源码里的 enable_logprobs_chunklogprobs_chunk_size 暗示了一件很现实的事:logprobs 返回从来不是零成本的“附带功能”。一旦 batch 很大、又要求较多 logprob 信息,系统就必须切换处理方式。这意味着:

  • return_logprob=True 不是纯输出格式选择
  • 它会真实改变 execution model 后半段的形态和代价

因此,当你看到“只是多要一点 logprob”时,别把它想得太轻。它本质上是在向执行后半段提出更重的证据要求。

Sampler 把“可选 logits”变成“正式 token”#

layers/sampler.py 是这条链里的下一跳。更稳的阅读方式不是盯着某个采样算法,而是先抓住它做的四件事:

  1. _preprocess_logits(...) 先处理自定义 processor 和 NaN
  2. 根据 greedy / probabilistic / deterministic / backend-specific 分支决定 next token
  3. 在需要时把 logprobs 附着回 logits_output
  4. 在必要场景下做 _sync_token_ids_across_tp(...)

这意味着 sampler 的职责从来不只是“抽样”,而是把采样策略、返回证据和分布式现实一起做完。也正因此,前面讲 output logprobs、后面讲 finish_reason,这两端最后都必须重新回到 sampler 这一层。

Req.check_finished() 才是“停下”真正发生的地方#

很多人会把 finish_reason 下意识看成 API 层的包装字段。源码里真正更重要的地方是 schedule_batch.pycheck_finished(...)。它并不是简单塞一个字符串,而是按明确顺序判断:

  1. 是否已经 finished
  2. 是否更早就被 to_finish 标记
  3. 是否命中 max_new_tokens
  4. grammar 是否已经终止
  5. token-based stop 是否生效
  6. vocab boundary 是否异常
  7. stop string / stop regex 是否匹配

这条顺序非常值得记。因为它说明多个结束条件同时存在时,系统不是随机挑一个,而是有正式优先级。也就是说,finish 语义首先是执行层和调度层共同定义的运行时状态,然后才会被 surface 层翻译成 OpenAI、Anthropic 或 native response 里的字段。

其中 vocab boundary finish 尤其值得单独提醒。因为这类 finish_reason 看起来很怪时,问题未必在 stop 条件,而可能更早就出在 logits 稳定性或前置预处理上。没有这一层理解,排障很容易直接跳错地方。

scheduler_output_processor_mixin.py 负责把这些语义真正落回请求状态#

在非 spec 或 spec v2 后处理路径里,系统通常会:

  1. next_token_id append 或 extend 到 req.output_ids
  2. 更新 reasoning tokens
  3. 更新 prefix cache 与时间戳
  4. req.check_finished(new_accepted_len)
  5. 在需要时把 output logprobs、top logprobs、指定 token id 的 logprobs 一起 append 回 request
  6. 如果存在 grammar,再继续 accept_token(...) 并处理异常

这条链真正证明了一件事:execution model 的后半段不是“采样完就结束”。恰恰相反,采样之后才是真正把执行结果沉淀回 request state 的关键时刻。也正因为这样,request lifecycle、调度与内存和 execution model 才会在这里重新接起来。

对外 surface 看到的 finish_reason 已经是第二次翻译#

当 response 最终进入 serving_chat.pyserving_completions.py 这些 surface 时,代码会读取 content["meta_info"]["finish_reason"] 再按 surface 需求继续翻译。比如 tool-call 场景下,如果底层 finish type 先是 "stop",但上层检测到了 tool calls,surface 会把它转成 "tool_calls"

这说明一个很重要的边界:

  • 底层执行层负责产生 finish 的基础事实
  • 上层 surface 可以在不推翻底层事实的前提下,为兼容协议做第二次语义翻译

这也是为什么本章必须放在 execution model,而不是纯 API 章节里讲。因为如果不先把底层 finish 事实讲清,后面的 surface 翻译看起来就会像凭空改字。

最容易出现的三类误判#

第一,误把 finish_reason 当成 API 层字段。
更稳的做法是先回到 Req.check_finished() 看它是怎样在执行边界上被决定的。

第二,误把 logprobs 返回看成“免费副产物”。
实际上 LogitsProcessorSampler 都会因为 logprob 返回而走更重路径。

第三,看到 "tool_calls" 就以为底层直接产出了 "tool_calls"
很多时候底层先产生的是 "stop",然后再由 surface 根据 tool parser 结果改写成协议友好的 finish reason。

真正定位问题时,更稳的入口顺序#

如果这条链出了问题,建议按下面顺序收缩:

  1. 先看 LogitsProcessorOutput 是否已经包含正确的 next_token_logits、hidden states、logprob 字段。
  2. 再看 Sampler 是否因为 greedy / top-p / deterministic / RL on-policy 分支走了不同路径。
  3. 再看 req.output_ids 是怎样 append 或 extend 的,尤其是 spec v2 场景。
  4. 再看 Req.check_finished() 最终命中了哪个条件。
  5. 最后才看 serving_chat.pyserving_completions.py 是否对 finish reason 做了 surface 级翻译。

这种顺序的价值在于,它先确认“底层事实有没有成立”,再去怀疑“对外翻译是不是把事实改坏了”。

小结#

这章真正补齐的,是 execution model 后半段最容易被跳过的那一块:LogitsProcessorOutput 先把 logits 后处理与采样输出收敛成桥对象,Sampler 再把分布变成 token 并附带 logprob 证据,Req.check_finished() 把 token 序列落成结束语义,surface 最后再把这套结束语义翻译成协议字段。

到这里,execution model 才真正从“token 怎样生成”扩成“token 怎样被解释、记录、终止并对外呈现”。