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(...) 正在明确做这几件事:
- 判断 batch size。
- 决定是否走
_batch_tokenize_and_process(...)批量分词路径,还是逐条 tokenization。 - 为每个内部 request 建立
state和_wait_one_response(...)generator。 - 非 streaming 时
asyncio.gather(...)收齐所有结果。 - streaming 时维护
rid_to_index和task_map,按完成顺序持续 yield。
这说明 batch 生命周期不是“外层循环套一下单请求逻辑”,而是有自己的一套显式 orchestrator。
批量 tokenization 和顺序 tokenization 为什么都存在#
源码显式区分:
_should_use_batch_tokenization(...)命中时走批量 tokenization- 否则逐条 tokenization + 发送
这说明 batch 路径内部也在做 tradeoff:有时批量化前处理收益更大,有时顺序路径更稳或更兼容当前条件。优秀技术书应该把这种“不是所有 batch 都统一走一条最激进路径”的现实说清楚。
parallel_sample_num > 1 为什么让事情更复杂#
当 parallel_sample_num > 1 时,系统不是简单重复最终结果,而是会:
- 先把原始请求 tokenize 一次。
- 先发一次
max_new_tokens = 0的请求,用来 cache common prefix。 - 再为每个样本复制请求、分配新
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 请求行为,先怎么查#
建议按这个顺序:
- 看当前是不是普通 batch,还是
parallel_sample_num > 1。 - 看是否走了
_batch_tokenize_and_process(...)。 - 看 internal
rid是否被正确生成并映射。 - 看 streaming 场景下
index是否被正确回补。 - 最后再判断问题出在 fan-out、本地聚合,还是下游执行本身。
小结#
这一章真正想补齐的,是 request lifecycle 里经常被忽略的“多请求展开层”:
- batch 请求不是简单的多次单请求
- parallel sampling 也不是简单的多份结果复制
- 一旦进入这些路径,生命周期就必须额外处理 fan-out、身份恢复和结果重组
到这里,请求生命周期对“多请求形态”才真正完整。
叶王 © 2013-2026 版权所有。如果本文档对你有所帮助,可以请作者喝饮料。