Req 对象:从 origin_input_ids 到 output_ids#

前面的调度与内存章节已经分别讲了 batch 状态机、ReqToTokenPool、prefix match、cache_protected_len、host hit / load-back 和 eviction policy,但这些机制如果不最终落回同一个对象上,读者仍然会遇到一个很典型的问题:我知道这些局部机制都成立,可它们最后到底在改谁的状态?

schedule_batch.py 里的 Req 正是这个问题的答案。它几乎可以被看成 scheduler 视角下单请求的真实运行时本体:输入怎样被保持、前缀怎样被挂上、KV 怎样被承认、输出怎样增长、什么时候该 finished,最后都落在它身上。没有这一章,很多前文讲过的细节会继续像“很多彼此正确的局部事实”;有了这一章,它们才会重新汇成一条单请求状态生命史。

先把 Req 看成一条状态演化线,而不是字段清单#

很多人第一次读 Req 时会自然把它当成“请求字段全集”。更稳的理解是:它是一条持续演化的状态线。下面这张图的价值就在这里,它不是在列字段,而是在压缩这条状态生命史的几个关键转折点。

flowchart LR
    In["origin_input_ids"] --> Fill["fill_ids"]
    Fill --> Prefix["prefix_indices / cache_protected_len"]
    Prefix --> Extend["extend_input_len"]
    Extend --> Out["output_ids"]
    Out --> Finish["to_finish / finished_reason"]
    Finish --> KV["kv_committed_len / kv_allocated_len"]

图里最重要的一点是:Req 的关键不是“有多少字段”,而是这些字段会随着调度、cache、执行和收尾不断改写。只要这个视角稳住,前面很多章节看起来分散的内容就会突然重新连起来。

第一层:输入从一开始就不是单一视图#

Req 构造时最先出现的是输入本体:

  • origin_input_ids
  • origin_input_ids_unpadded

这两个字段看起来只差一点,但它们已经在提醒读者:请求对象从一开始就不是简单 token list。更贴近运行时的理解是:

  • origin_input_ids 更接近当前系统真正持有的输入视图
  • origin_input_ids_unpadded 更像 padding 之前的原始序列边界

这种区分在多模态、增量 detokenize,以及某些需要回到原始输入边界的逻辑里特别重要。也就是说,Req 从构造之初就已经在为后面的多层视图做准备,而不是等到问题复杂时才临时补字段。

第二层:fill_ids 才更接近调度现实#

如果说 origin_input_ids 更像输入事实,那么 fill_ids 则更像当前执行现实。源码里很直接:

  • fill_ids = origin_input_ids + output_ids

这意味着调度器和 prefix 相关逻辑真正面对的,往往不是初始输入,而是“当前这条请求在本轮 prefill / extend 视角下完整需要考虑的序列”。因此:

  • origin_input_ids 更像这条请求最初是什么
  • fill_ids 更像系统此刻觉得这条请求变成了什么

这也是为什么 prefix match、set_extend_input_len(...) 之类的逻辑都会更依赖 fill_ids。如果不把这一点讲明白,读者会很容易误以为“调度总是只盯着原始 prompt”,而忽略输出增长本身也在持续改变请求的有效形态。

第三层:cache 不是只存在于 tree 里,请求对象自己也在携带一份视图#

一旦进入 prefix match,Req 就会拿到:

  • prefix_indices
  • last_node
  • last_host_node
  • host_hit_length
  • cache_protected_len

这一点非常值得技术书主动点破。很多人读 cache 章节时,天然会把 prefix tree、allocator 和 pool 看成“缓存真正存在的地方”,而忽略请求对象本身也在携带一份“我当前已经拥有怎样的前缀视图”的状态。

这就是为什么前面 prefix reuse、host load-back 和 SessionAwareCache 那几章最后都必须回到 Req。无论前缀来自纯 device 命中、host 层恢复,还是会话恢复,最后都要折回请求对象身上,变成这条请求当前真正承认的 cache 视图。

set_extend_input_len(...) 是这条生命史里的关键翻译点#

这段函数表面上像在算长度,实际上是在做一次很重要的状态翻译:

  • 当前总 fill_ids 长度
  • 减去当前 prefix_indices 长度
  • 得到本轮真正还需要 forward 的输入长度

同时,它还会结合:

  • logprob_start_len
  • prefix_indices
  • extend_input_len

去继续计算 extend_logprob_start_len。也就是说,Req 在这里不只是把 prefix 视图翻成“还要算多少 token”,还进一步把它翻成“哪些位置还需要 logprob 语义”。这一步尤其能说明 Req 为什么不是纯数据容器,而是调度和执行边界上的关键翻译体。

KV 状态为什么要分成 committed 和 allocated#

kv_committed_lenkv_allocated_len 是这棵树里最值得单独强调的一层分离。更稳的理解是:

  • kv_committed_len 是这条请求已经正式承认并持有的 KV 长度
  • kv_allocated_len 是为了执行方便暂时多给出来的更大长度

这说明请求对象自己也明确区分了两件事:

  • 已经落成事实的 KV
  • 只是为了当前运行便利先行分配的 KV

也正因为有这层区分,后面才会自然长出:

  • pop_committed_kv_cache()
  • pop_overallocated_kv_cache()

这种看似底层、其实非常合理的所有权拆分。如果没有这层讲解,retract、回收和 prefix cache 相关逻辑看起来就会像很多零散动作,而不是同一套所有权体系。

output_ids 开始增长时,请求的重心其实已经换了#

在 decode 阶段之后,output_ids 会越来越成为 Req 的中心字段。因为这时后续很多行为都围绕它展开:

  • check_finished(...)
  • output_ids_through_stop
  • incremental detokenize
  • reasoning token 计数
  • routed experts 切片

这说明请求对象的重心会经历一次真正的切换:前半段更像“输入和前缀怎样被组织”,后半段则越来越像“输出怎样增长、怎样停下、怎样继续被解释”。从书稿结构上看,这一点特别有价值,因为它把第 4 节和第 5 节真正连了起来。

to_finishcheck_finished(...) 说明结束语义不是贴标签,而是请求对象自己做出来的#

源码里有一个很成熟的设计信号:

  • 如果想中途终止请求,不应该直接设 finished_reason
  • 而应该先设 to_finish

这说明 finished 语义并不是一个立即写死的外部标签,而是请求对象内部先经历一次“将要结束”的缓冲状态,再通过 check_finished(...) 结合:

  • max_new_tokens
  • grammar 结束
  • token-based stop
  • string-based stop

等条件,最终落成 finished_reason。也就是说,请求对象本身在承载一部分运行时语义计算,而不是单纯被外部系统写状态。

这也是为什么 Req 值得被看作“单请求运行时状态机”,而不是“request payload with methods”。

增量 detokenize 和 retract 进一步证明它是状态机本体#

init_incremental_detokenize() 会围绕:

  • surr_offset
  • read_offset
  • surr_and_decode_ids
  • cur_decode_ids_len

去维护请求的增量 detokenize 窗口。这说明 even 在 detokenizer 真正介入之前,请求对象自己就已经在为“输出增量怎样被读出来”维护状态。

reset_for_retract() 则更进一步,它会重置:

  • prefix_indices
  • routed_experts
  • last_node
  • swa_uuid_for_lock
  • mamba_*
  • kv_allocated_len
  • kv_committed_len

必要时还会清空 output_ids。这说明 retract 不是调度器在外面“简单往回退一下”,而是请求对象本身经历了一次正式的状态重置。把这一点读懂,前面很多关于 retract 为什么复杂的章节就会自然闭环。

这其实是很多前文章节共同宿主#

现在再回头看,你会更容易发现:

  • 第 2 节请求生命周期讲的是它怎样进入系统
  • 第 4 节调度与内存讲的是它怎样携带 prefix 和 KV 所有权
  • 第 5 节执行模型讲的是它怎样增长输出并决定 finish
  • 第 8 节观测层讲的是它怎样挂住 time_stats

也就是说,很多章节并不是在讲不同对象,而是在讲同一个对象在不同阶段的不同侧面。一本更像书的技术作品恰恰需要这种回扣方式:围绕少量真正核心的对象反复下钻,而不是每章重新发明一组名词。

真正顺着源码读这条生命史时,推荐顺序是什么#

建议按下面这个顺序:

  1. Req.__init__
  2. adjust_max_prefix_ids()
  3. set_extend_input_len(...)
  4. check_finished(...)
  5. init_incremental_detokenize()
  6. reset_for_retract()

这样读,你会先看到初始状态,再看到 prefix 和 cache 视图怎样进入,最后看到输出、完成语义和 retract 怎样收尾。它和前面章节的顺序也正好形成呼应。

小结#

Req 之所以值得单独成章,不是因为它字段多,而是因为前面很多看似分散的调度、cache、输出和 finish 语义,最后都落在它身上。

Req 读成一条状态生命史,而不是一份字段清单,是这章真正想帮读者建立的视角。