读输出尾部:scheduler output processor 与 detokenizer#

这章解决什么问题#

前面的代码导读已经把 TokenizerManager 收成了一棵正式的桥梁树,但还有一段同样关键、却更容易被忽略的尾部链路没有被单独讲清:模型前向结束、sampler 给出下一个 token 之后,系统到底怎样把这些执行层结果整理成可回包的输出?这里真正负责收尾的并不是单个函数,而是两棵紧密咬合的树:

  • scheduler_output_processor_mixin.py
  • detokenizer_manager.py

如果不把这两棵树合起来读,很多读者会在 execution model 章节里知道 BatchTokenIDOutputBatchStrOutput 这两个名字,却不知道它们在源码里怎样被生产、传递和消费。

这章的目标,就是把输出尾部压成一条稳定的阅读路径。

为什么这两棵树应该配对阅读#

单读 scheduler_output_processor_mixin.py,你会看到:

  • output ids 怎样写回 request state
  • finish_reason 怎样被检查
  • output logprobs 与 reasoning token 怎样被附着
  • BatchTokenIDOutput 怎样发往下游

单读 detokenizer_manager.py,你会看到:

  • BatchTokenIDOutput 怎样被 decode
  • stop trim 怎样发生
  • decode_status 怎样跨 chunk 保存状态
  • BatchStrOutput 怎样再送回 tokenizer 侧

真正的工程意义只会在两者配对时出现:前者把“执行结果”整理成“token 级输出对象”,后者再把“token 级输出对象”整理成“文本级输出对象”。这就是整个 runtime 尾部收敛的核心。

一张图:输出尾部真正发生了什么#

这张图解决的理解障碍是:很多读者会自然地把“sample 完了”当成输出已经结束,但源码里 sample 之后还有一整条正式 pipeline。

flowchart LR
    Exec["forward + sample"] --> Proc["scheduler_output_processor_mixin"]
    Proc --> Tok["BatchTokenIDOutput"]
    Tok --> IPC["send_to_detokenizer.send_output(...)"]
    IPC --> Det["DetokenizerManager.event_loop()"]
    Det --> Str["BatchStrOutput"]
    Str --> TM["TokenizerManager handle_loop"]

图比纯文字多解释的一点是:sample 之后并不是立刻回到调用方,中间还有一个 token 输出层和一个文本收口层。

第一棵树:scheduler_output_processor_mixin.py 应该怎么读#

更稳的阅读问题不是“这个 mixin 有多少方法”,而是:

  1. request state 在这里怎样被更新
  2. 哪些元信息在这里被固定下来
  3. 什么时候发 BatchTokenIDOutput

从代码位置上看,最关键的一段是构造 BatchTokenIDOutput(...) 并通过:

self.send_to_detokenizer.send_output(...)

往下游送。

这说明 output processor 不是执行后的装饰层,而是 scheduler 和 detokenizer 之间的正式边界层。

output processor 在这里真正做了哪些事#

结合 scheduler_output_processor_mixin.py 和前文 execution model 章节,可以把它的职责压成四类:

1. 更新 request 自身输出状态#

例如:

  • req.output_ids
  • req.reasoning_tokens
  • req.send_output_token_logprobs_offset

这意味着 request 的输出历史并不是只在 detokenizer 侧维护;token 级状态首先在 scheduler 尾部被固定。

2. 检查 finish 语义#

它会在 token 写回 request 后继续推动 req.check_finished(...) 或相近逻辑。也就是说,finish_reason 的主要判定并不在 HTTP surface,而在 scheduler 尾部。

3. 组织增量 logprobs / reasoning / hidden metadata#

特别是 send_output_token_logprobs_offset,它说明 logprobs 不是每次全量重发,而是按已发送边界做增量切片。这是输出稳定流式化的关键细节。

4. 产出 BatchTokenIDOutput#

这是这棵树最值得记住的动作:它把分散在 request 内部状态上的结果压成了一种正式跨进程对象。

BatchTokenIDOutput 为什么值得单独记住#

因为它不是“还没 decode 的临时结构”而已。它代表一层明确的抽象边界:

  • 对 scheduler 来说,已经足够把一轮执行的输出表达完整
  • 对 detokenizer 来说,仍然保留足够多的 token 级语义去做后续 decode 和 trim

这说明 BatchTokenIDOutput 是一个非常成功的中间层对象:既不太早丢失信息,也不把 detokenizer 逼进 scheduler 内部实现。

第二棵树:detokenizer_manager.py 应该怎么读#

如果要稳,建议只先抓五个点:

  1. event_loop()
  2. decode_status
  3. _decode_batch_token_id_output(...)
  4. _grouped_batch_decode(...)
  5. handle_batch_token_id_out(...)

这样读的好处是,你能先建立 detokenizer 的骨架,再去看 trim stop、surrogate text、tool parser 兼容这些细节。

event_loop() 的核心意义是什么#

event_loop() 本身非常短:

  • recv_from_scheduler.recv_pyobj() 收对象
  • 用 dispatcher 分发
  • 把 output 通过 send_to_tokenizer.send_pyobj(output) 回给 tokenizer 侧
  • 每轮 feed soft watchdog

它的重要性不在复杂,而在于它把 detokenizer 明确成了一个:

  • 长驻
  • 有状态
  • 可被独立监控

的输出尾部进程,而不是一个被 scheduler 内联调用的 helper。

decode_status 为什么是这棵树的中心#

decode_status = LimitedCapacityDict(...) 非常关键。它说明 detokenizer 不是 stateless decode:

  • 某个 rid 的前文 decode 状态会被保留
  • 跨 chunk、跨 streaming 轮次的 surrogate text / read text 会被继续使用
  • 结束时才会从 decode_status 清掉

这与 TokenizerManagerrid_to_state 形成非常漂亮的对照:

  • tokenizer 侧维护“面向调用方的请求状态”
  • detokenizer 侧维护“面向 decode 连续性的输出状态”

这也是为什么两边都必须有状态表,而不是一边有、一边完全无状态。

_grouped_batch_decode(...) 为什么体现了 detokenizer 的真正职责#

这段逻辑按 (skip_special_tokens, spaces_between_special_tokens) 分组,再调用 tokenizer.batch_decode(...)。这说明 detokenizer 的核心价值不是“会 decode”,而是“能把一个 batch 里不同 decode 语义的请求重新分组并稳定处理”。

换句话说,它吸收的是输出语义差异,而不只是 token 到文本的机械转换。

trim_matched_stop(...) 为什么要放在 detokenizer#

这也是一个特别值得技术书点出来的边界选择:

  • scheduler 侧更适合判断 finish 语义
  • detokenizer 侧更适合处理文本层 stop trim

因为 stop trim 依赖文本级视图,而不是只看 token id。把这部分放在 detokenizer,能避免 scheduler 输出层背负过多文本恢复语义。

handle_batch_token_id_out(...) 怎样把这棵树收口#

这一函数的作用可以压成一句话:

BatchTokenIDOutput 翻译成 BatchStrOutput

但真正值得读的是这个翻译过程中发生了什么:

  • 先 decode token 级输出
  • 再 trim / 拼文本
  • 再保留 finish / logprob / metrics 这些必要元信息
  • 最后构造 BatchStrOutput(...)

这说明 BatchStrOutput 不是“比前一个对象少一点字段”的简化版,而是另一层抽象:它开始以文本回包为中心组织信息。

BatchTokenIDOutputBatchStrOutput 应该怎样对照理解#

更稳的对照方式不是“谁先谁后”,而是“谁面向哪一层”:

  • BatchTokenIDOutput 面向 scheduler 尾部与 detokenizer 输入
  • BatchStrOutput 面向 tokenizer 侧状态收敛与最终回包

这样理解之后,很多代码位置会自然归位:

  • execution model 章节里为什么重点讲前者
  • request lifecycle 和 tokenizer manager 为什么更频繁处理后者

为什么这章对排障也有直接价值#

输出尾部问题经常被误判成“模型没生成”或“HTTP 在抖”,但很多真实问题其实卡在这里:

  • BatchTokenIDOutput 没发出去
  • detokenizer 卡在 event_loop()
  • decode_status 状态错乱
  • stop trim / grouped decode 行为异常
  • BatchStrOutput 元信息丢失

只要把这章的骨架记住,排障时就可以先问一个更准确的问题:

问题是在 sample 之前,还是在输出尾部收口之后?

如果你要顺着源码读这一段,推荐顺序是什么#

建议按这个顺序:

  1. scheduler_output_processor_mixin.pyBatchTokenIDOutput(...) 的构造点
  2. scheduler.pysend_to_detokenizer.send_output(...)
  3. detokenizer_manager.py::event_loop()
  4. detokenizer_manager.py::_decode_batch_token_id_output(...)
  5. detokenizer_manager.py::handle_batch_token_id_out(...)
  6. 最后回到 TokenizerManagerhandle_loop() / _handle_batch_output(...)

这样读,你得到的是一条闭环,而不是两棵孤立源码树。

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

1. 以为 sample 结束就等于输出完成#

实际还有 output processor 和 detokenizer 两层正式收口。

2. 以为 detokenizer 只是 tokenizer.decode() 的包装#

它有自己的状态表、分组 decode 和 trim 语义。

3. 以为 BatchTokenIDOutputBatchStrOutput 只是字段多少不同#

它们对应的是两层不同抽象边界。

小结#

这一章真正要补齐的,是代码导读里输出尾部的正式阅读入口:

  • scheduler_output_processor_mixin.py 负责把执行结果整理成 token 级输出对象
  • detokenizer_manager.py 负责把 token 级对象整理成文本级输出对象
  • 两者合起来,才构成 sample 之后真正的回包尾部

读懂这条尾部链路之后,execution model、request lifecycle 和 codebase walkthrough 三条主线就会真正闭环,而不是各自停在不同抽象层上。