batch 请求、并行采样与回包 fan-out / fan-in#

这章解决什么问题#

前面的 request lifecycle 已经讲了单请求主线、streaming 回包、多 worker、控制请求分叉和请求元数据传播,但还有一层很关键的真实行为没有单独展开:当用户提交的是一个 batch,或者同一条请求带 parallel_sample_num > 1,系统到底怎样把一条表面请求扇出成多条内部 request,又怎样把它们重新收回来?

这一章就是把这条 fan-out / fan-in 链讲清楚。

为什么这层值得单独成章#

因为一旦进入 batch / parallel sampling 场景,请求生命周期不再只是“一条 request 一路向前”:

  • 内部会生成多个 rid
  • 会创建多个 _wait_one_response(...) generator
  • streaming 模式下还要持续把多个内部结果按 index 重新交织回外部调用方

如果不把这层讲出来,整本书的 request lifecycle 仍然更偏单请求直线模型,不够像真实系统。

一张图:一个 batch 请求是怎样在内部被扇出,再在外部被收回的#

这张图解决的理解障碍是:很多读者默认 batch 请求只是“同一个函数多跑几次”,而源码实际已经为它建立了一套独立的 fan-out / fan-in 机制。

flowchart LR
    Batch["batch request / parallel_sample_num"] --> Split["tokenize / duplicate / assign rid"]
    Split --> Gens["_wait_one_response generators"]
    Gens --> Runtime["scheduler / detokenizer / outputs"]
    Runtime --> Merge["gather / index-based merge"]
    Merge --> Client["batch response / streaming chunks"]

这张图比纯文字多解释的一点是:batch 请求的复杂度主要不在 tokenization 本身,而在“内部请求如何分裂、外部结果如何重组”。

_handle_batch_request(...) 为什么是整个生命周期里最值得看的 batch 入口#

TokenizerManager._handle_batch_request(...) 正在明确做这几件事:

  1. 判断 batch size。
  2. 决定是否走 _batch_tokenize_and_process(...) 批量分词路径,还是逐条 tokenization。
  3. 为每个内部 request 建立 state_wait_one_response(...) generator。
  4. 非 streaming 时 asyncio.gather(...) 收齐所有结果。
  5. streaming 时维护 rid_to_indextask_map,按完成顺序持续 yield。

这说明 batch 生命周期不是“外层循环套一下单请求逻辑”,而是有自己的一套显式 orchestrator。

批量 tokenization 和顺序 tokenization 为什么都存在#

源码显式区分:

  • _should_use_batch_tokenization(...) 命中时走批量 tokenization
  • 否则逐条 tokenization + 发送

这说明 batch 路径内部也在做 tradeoff:有时批量化前处理收益更大,有时顺序路径更稳或更兼容当前条件。优秀技术书应该把这种“不是所有 batch 都统一走一条最激进路径”的现实说清楚。

parallel_sample_num > 1 为什么让事情更复杂#

parallel_sample_num > 1 时,系统不是简单重复最终结果,而是会:

  1. 先把原始请求 tokenize 一次。
  2. 先发一次 max_new_tokens = 0 的请求,用来 cache common prefix。
  3. 再为每个样本复制请求、分配新 rid、重新进入 _wait_one_response(...)

这说明 parallel sampling 在这里不是普通 batch 的一个小参数,而是会引入一次“前缀预热 + 扇出复制”的特殊生命周期。

这是非常值得成书的一层,因为它解释了为什么 n > 1 有时表现得不像“只是多拿几份样本”。

为什么要先做一次 max_new_tokens = 0 预热请求#

这步逻辑的意义在于:让多个样本共享相同前缀准备,而不是每个样本从头重复这段工作。也就是说,系统在 parallel sampling 场景下显式承认:

  • 前缀准备可以共享
  • 样本扩散应该发生在“共享前缀之后”

这是一种非常典型的 runtime 优化思路,而不是只是循环复制对象。

streaming batch 为什么要维护 rid_to_index#

在 streaming batch 场景下,_handle_batch_request(...) 会:

  • 先建立 rid_to_index
  • 再用 asyncio.wait(..., FIRST_COMPLETED) 收各 generator 的新 chunk
  • 给每个 result 补 index

这说明对调用方来说,“这是 batch 中第几个请求的结果”并不是天然保留的,而是需要在 fan-in 阶段被显式恢复。

这点特别重要,因为它解释了为什么 batch streaming 不能只是“把内部 chunk 原样往外喷”。

为什么 rid 在 batch 路径里更重要#

单请求时,rid 主要用于状态索引和 abort;batch 路径里,它还承担:

  • 在多个并发 generator 之间区分结果
  • 把结果重新映射回 batch index
  • 在 parallel sample 场景里区分多个复制出来的样本

这也是为什么本书前面一直强调 request object 的稳定身份形状。batch 路径正好把这个问题放大到了最明显的程度。

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

1. 把 batch 请求理解成“单请求 for 循环”#

源码明确显示还有 generator fan-out、gather、task map 和 index 恢复。

2. 以为 parallel_sample_num 只是多拿几份后处理结果#

实际上它会改变内部请求扇出形状和前缀准备路径。

3. 以为 streaming batch 能天然保序#

真实系统更接近“谁先准备好谁先回”,然后由 index 恢复外部语义。

如果你在排查 batch / n>1 请求行为,先怎么查#

建议按这个顺序:

  1. 看当前是不是普通 batch,还是 parallel_sample_num > 1
  2. 看是否走了 _batch_tokenize_and_process(...)
  3. 看 internal rid 是否被正确生成并映射。
  4. 看 streaming 场景下 index 是否被正确回补。
  5. 最后再判断问题出在 fan-out、本地聚合,还是下游执行本身。

小结#

这一章真正想补齐的,是 request lifecycle 里经常被忽略的“多请求展开层”:

  • batch 请求不是简单的多次单请求
  • parallel sampling 也不是简单的多份结果复制
  • 一旦进入这些路径,生命周期就必须额外处理 fan-out、身份恢复和结果重组

到这里,请求生命周期对“多请求形态”才真正完整。