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.pylayers/sampler.pyschedule_batch.pyscheduler_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(...) 真正决定的是“后半段要处理哪些位置”#
这段函数如果只当“再包一层”来读,很容易错过它最关键的价值。它真正做的,是把最容易膨胀的后半段组合先收口:
- 统一
ForwardBatch/LogitsMetadata视图 - 根据 forward mode、prefill-only、是否需要 input logprobs 等条件,裁剪 hidden states 和 sample indices
- 产出真正适合 sampler 继续消费的
LogitsProcessorOutput
这一步很重要,因为 execution model 到这里已经不是“所有位置一视同仁地处理”。它首先要决定:哪些位置值得继续进入 next-token 选择,哪些位置值得继续保留 hidden states 或 input logprob 语义。也正因为有这层工作,后面的采样和 finish 才不会无序膨胀。
为什么 chunked logprobs 处理特别值得注意#
源码里的 enable_logprobs_chunk 和 logprobs_chunk_size 暗示了一件很现实的事:logprobs 返回从来不是零成本的“附带功能”。一旦 batch 很大、又要求较多 logprob 信息,系统就必须切换处理方式。这意味着:
return_logprob=True不是纯输出格式选择- 它会真实改变 execution model 后半段的形态和代价
因此,当你看到“只是多要一点 logprob”时,别把它想得太轻。它本质上是在向执行后半段提出更重的证据要求。
Sampler 把“可选 logits”变成“正式 token”#
layers/sampler.py 是这条链里的下一跳。更稳的阅读方式不是盯着某个采样算法,而是先抓住它做的四件事:
_preprocess_logits(...)先处理自定义 processor 和 NaN- 根据 greedy / probabilistic / deterministic / backend-specific 分支决定 next token
- 在需要时把 logprobs 附着回
logits_output - 在必要场景下做
_sync_token_ids_across_tp(...)
这意味着 sampler 的职责从来不只是“抽样”,而是把采样策略、返回证据和分布式现实一起做完。也正因此,前面讲 output logprobs、后面讲 finish_reason,这两端最后都必须重新回到 sampler 这一层。
Req.check_finished() 才是“停下”真正发生的地方#
很多人会把 finish_reason 下意识看成 API 层的包装字段。源码里真正更重要的地方是 schedule_batch.py 的 check_finished(...)。它并不是简单塞一个字符串,而是按明确顺序判断:
- 是否已经 finished
- 是否更早就被
to_finish标记 - 是否命中
max_new_tokens - grammar 是否已经终止
- token-based stop 是否生效
- vocab boundary 是否异常
- 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 后处理路径里,系统通常会:
- 把
next_token_idappend 或 extend 到req.output_ids - 更新 reasoning tokens
- 更新 prefix cache 与时间戳
- 调
req.check_finished(new_accepted_len) - 在需要时把 output logprobs、top logprobs、指定 token id 的 logprobs 一起 append 回 request
- 如果存在 grammar,再继续
accept_token(...)并处理异常
这条链真正证明了一件事:execution model 的后半段不是“采样完就结束”。恰恰相反,采样之后才是真正把执行结果沉淀回 request state 的关键时刻。也正因为这样,request lifecycle、调度与内存和 execution model 才会在这里重新接起来。
对外 surface 看到的 finish_reason 已经是第二次翻译#
当 response 最终进入 serving_chat.py、serving_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 返回看成“免费副产物”。
实际上 LogitsProcessor 和 Sampler 都会因为 logprob 返回而走更重路径。
第三,看到 "tool_calls" 就以为底层直接产出了 "tool_calls"。
很多时候底层先产生的是 "stop",然后再由 surface 根据 tool parser 结果改写成协议友好的 finish reason。
真正定位问题时,更稳的入口顺序#
如果这条链出了问题,建议按下面顺序收缩:
- 先看
LogitsProcessorOutput是否已经包含正确的next_token_logits、hidden states、logprob 字段。 - 再看
Sampler是否因为 greedy / top-p / deterministic / RL on-policy 分支走了不同路径。 - 再看
req.output_ids是怎样 append 或 extend 的,尤其是 spec v2 场景。 - 再看
Req.check_finished()最终命中了哪个条件。 - 最后才看
serving_chat.py或serving_completions.py是否对 finish reason 做了 surface 级翻译。
这种顺序的价值在于,它先确认“底层事实有没有成立”,再去怀疑“对外翻译是不是把事实改坏了”。
小结#
这章真正补齐的,是 execution model 后半段最容易被跳过的那一块:LogitsProcessorOutput 先把 logits 后处理与采样输出收敛成桥对象,Sampler 再把分布变成 token 并附带 logprob 证据,Req.check_finished() 把 token 序列落成结束语义,surface 最后再把这套结束语义翻译成协议字段。
到这里,execution model 才真正从“token 怎样生成”扩成“token 怎样被解释、记录、终止并对外呈现”。
叶王 © 2013-2026 版权所有。如果本文档对你有所帮助,可以请作者喝饮料。