视频加载失败

2.4 Chunked Prefill 与统一 Token Budget 调度

3830 字
19 分钟
2.4 Chunked Prefill 与统一 Token Budget 调度

Continuous Batching 那一节留了个尾巴:当一个超长 Prompt 的 Prefill 挤进某一步计算时,会把同批正在生成的 Decode 请求全部拖慢,用户能明显感到”卡了一下”。Chunked Prefill 就是来收拾这个尾巴的——把长 Prefill 切成小块,掺进 Decode 的间隙里做,谁也不耽误谁。而 vLLM V1 更进一步,用一个统一的 Token 预算调度器,把 Prefill 和 Decode 的界限彻底抹平。这一节讲透这两件事。

⏰ 信息时效:切块思想依据 SARATHI 论文;vLLM 的调度逻辑、参数与调优建议基于 v0.32.0(2026-10-18 发布)。

1. Prefill 是怎么拖慢 Decode 的#

先把干扰的机制讲清楚。回顾上一章的两条结论:

  • Prefill 是 Compute Bound:一次要并行处理整段 Prompt 的所有 Token,Prompt 越长,这一步的矩阵运算量越大、耗时越久。
  • Decode 是 Memory Bound:每步只处理 1 个 Token,本身很快,用户感受到的出字速度(TPOT)就靠它一步一步稳定推进。

问题出在:Continuous Batching 是每一步重新组批的。如果某一步里,调度器把一个 4000 Token 的长 Prompt 的 Prefill 和一批 Decode 请求塞进了同一个 step,那么这一步的耗时会被那个庞大的 Prefill 计算整体拉长——同批所有 Decode 请求的这个 Token 都得等 Prefill 算完才能一起出来。

用类比来说:Decode 请求像流水线上匀速前进的小件包裹,本来每几秒过一个;突然来了一个巨大的集装箱(长 Prefill)也要挤上同一条流水线过检,整条线被它堵住,后面的小包裹全部被迫等待。用户端的表现就是:出字很流畅,然后突然卡顿一下,再恢复流畅——TPOT 出现尖刺(P99 抖动)。

📌 关键点:这不是”要不要批处理”的问题,而是”一步里塞了太多 Compute Bound 的活”。既然长 Prefill 是罪魁,那把它拆小、分几步做,不就不会一次性堵死流水线了吗?


2. Chunked Prefill:把长 Prefill 切块#

Chunked Prefill(分块预填充) 的思路非常直接:不要求一个长 Prompt 在一步里 Prefill 完,而是把它切成若干个 Chunk(块),分散到多个 step 里逐块处理。 SARATHI 论文按等长切块;vLLM 不预设块长,每块多大由当步剩余的 Token 预算决定(见第 5 节)。

比如一个 4000 Token 的 Prompt,按每块 512 Token 切,就是 7 个满块加 1 个 416 Token 的尾块,共 8 个 Chunk。每一步只处理其中一块,剩下的算力留给同批的 Decode 请求正常出字。这样:

  • 每一步的计算量被控制在一个可预期的范围内,不会被超长 Prefill 突然撑爆。
  • Decode 请求得以在长 Prefill 进行的同时持续、平稳地推进,TPOT 尖刺被抹平。

下图把第 1 节的场景和切块后的场景画在同一条时间轴上。每个格子是一步:蓝色是 8 个 Decode 请求各出 1 个 Token 的部分,黄色是长 Prompt 的 Prefill 部分,黑框标出含 Prefill 的步,橙色虚线是长 Prompt 出首 Token 的时刻。步的宽度按”固定开销 + 与 Prefill Token 数成正比”的简化模型估算,只用来示意相对长短。

一个 4000 Token 的 Prompt 插入 8 个 Decode 请求:不分块时这一步耗时约为纯 Decode 步的 9 倍,8 个请求同时卡顿;按 512 分块后每步约为纯 Decode 步的 2 倍,首 Token 晚约 7 个 Decode 步

用回流水线的类比:现在把那个大集装箱拆成一箱箱标准货物,每次过检只放一箱进流水线,中间穿插着让小包裹继续通过。集装箱是慢慢过完了,但整条线始终匀速运转,没有谁被彻底堵死。

🔑 核心概念:Chunked Prefill 用”把大块 Prefill 摊到多步”换来”Decode 的平稳”,本质是把一次性的计算尖峰削平成多个可控的小峰,从而稳住 TPOT 尾延迟。

⚠️ 注意:切块会让这个长 Prompt 的 TTFT 略微变大(它要跨多步才 Prefill 完,首 Token 出得稍晚)。这是一个刻意的权衡——牺牲少量单请求 TTFT,换取整批 Decode 的 TPOT 稳定。在追求平稳出字体验的服务里,这笔交易通常很划算。


3. Token Budget:调度的统一货币#

Chunked Prefill 要落地,调度器需要一个统一的”度量衡”来决定每一步塞多少活。这个度量衡就是 Token Budget(Token 预算)。

核心洞察是:无论 Prefill 还是 Decode,对 GPU 来说本质都是”处理若干个 Token 的前向计算”。

  • 一个做 Decode 的请求,这一步贡献 1 个 Token(生成下一个)。
  • 一个做 Prefill 的请求(或它的一个 Chunk),这一步贡献 N 个 Token(这一块的长度)。

于是调度器给每一步设定一个总的 Token 预算上限(比如”这一步最多处理 2048 个 Token”),然后往里面装请求:先保证正在生成的 Decode 请求(每个占 1 个 Token 额度),剩下的预算再分给 Prefill 的 Chunk。预算用完,这一步就发车。

∑Decode 请求⏟每个 1 token+∑Prefill Chunk⏟每个 chunk_size token≤token_budget\underbrace{\sum \text{Decode 请求}}_{\text{每个 1 token}} + \underbrace{\sum \text{Prefill Chunk}}_{\text{每个 chunk\_size token}} \leq \text{token\_budget}

💡 提示:用 Token 预算这把统一的尺子,“Chunk 该切多大""一步能塞几个 Decode""长 Prefill 和 Decode 怎么共处一步”这些问题就都归结成了同一个装箱问题——在预算内尽量把 GPU 装满,同时优先保证 Decode 不断流。


4. vLLM V1 的统一调度器#

早期的 vLLM(V0)调度器里,Prefill 和 Decode 是两类被区别对待的阶段,调度逻辑要分别处理,代码复杂且容易在两者切换时产生空隙。

vLLM V1 引擎做了一个漂亮的简化:彻底取消 Prefill 和 Decode 的二分。在 V1 的调度器眼里,没有”Prefill 请求”和”Decode 请求”之分,只有”这一步每个请求要处理多少个 Token”。调度决策被表达成一个简洁的字典:

# V1 调度器每一步的决策,本质是这样一个映射
{
"req_A": 1, # 正在 Decode,处理 1 个新 token
"req_B": 1, # 正在 Decode,处理 1 个新 token
"req_C": 512, # 长 Prompt 的一个 Prefill chunk
"req_D": 128, # 短 Prompt,一步就能 Prefill 完
}
# 约束:所有 value 之和 ≤ token_budget

🔑 核心概念:V1 用 {request_id: num_tokens} 这一个统一表示,同时涵盖了 Chunked Prefill、Prefix Cache 和 Speculative Decoding。 因为它们本质都是”某个请求这一步要处理多少 Token”的不同取值——Prefill chunk 是一个较大的值,Decode 是 1,投机解码验证是若干个候选 Token。一套调度框架,优雅地统一了所有情况。

这也是为什么 V1 能把 Chunked Prefill 做成默认行为:既然调度天然以 Token 为单位、不区分阶段,长 Prompt 自然就被预算约束切成了 Chunk,无需额外的特殊逻辑。


5. 源码走读:Token Budget 如何切出 Chunk#

把上面的思想落到 vLLM V1 源码。调度逻辑在 vllm/v1/core/sched/scheduler.py 的 Scheduler.schedule()。这里不重复 2.2 节讲过的两队列结构,只聚焦”Token 预算怎么把长 Prefill 自然切成 Chunk”这一点(代码持续演进,以你所用版本为准)。

关键在于:调度器给每个请求计算”这一步处理多少 Token”时,会用剩余预算去截断。长 Prompt 一步吃不完,就只吃预算允许的那一截——切块不是一个独立的”切块函数”,而是预算截断的自然结果。

# 伪代码:还原 schedule() 里"预算截断即切块"的逻辑(非逐字源码)
def schedule(self):
token_budget = self.max_num_batched_tokens
scheduled = {}
# 正在运行的请求:Decode 每个只要 1 个 token,也可能是上一步没填完的 prefill 续块
for req in self.running:
if token_budget == 0:
break
# 这个请求还差多少 token 才算完(prefill 未完成部分 或 decode 的 1 个)
need = req.num_tokens_with_spec - req.num_computed_tokens
# 关键:被剩余预算截断 → 长 prefill 在这里被"切"出一个 chunk
num_tokens = min(need, token_budget)
blocks = self.kv_cache_manager.allocate_slots(req, num_tokens)
if blocks is None:
... # 显存不足:抢占并重试,细节见 2.2 节 6.2
continue
scheduled[req.id] = num_tokens # {request_id: num_tokens}
token_budget -= num_tokens
# 预算有剩,再从 waiting 拉新请求(同样会被预算截断成首个 chunk)
# 源码里本步若发生过抢占,就跳过这一段,不接新请求
while self.waiting and token_budget > 0 and self._can_admit():
req = self.waiting.peek_front()
# 先查前缀缓存:命中的 token 不用再算(见 2.3 节)
_, num_cached = self.kv_cache_manager.get_computed_blocks(req)
need = req.num_tokens - num_cached
num_tokens = min(need, token_budget) # 长 prompt 首个 chunk 也在此截断
if self.kv_cache_manager.allocate_slots(req, num_tokens) is None:
break # 显存不够就不再接新请求
self.waiting.pop_front()
self.running.append(req)
scheduled[req.id] = num_tokens
token_budget -= num_tokens
return SchedulerOutput(scheduled)

下图用一组具体数字走一遍上面的循环。预算 2048:8 个 Decode 请求各取 1 个(蓝色);running 里的长请求 R 还差 1440 个 Token 没 Prefill,一次拿完(黄色);剩下的 600 分给 waiting 里 900 Token 的新请求 W1,它被截成首个 Chunk(绿色);W2 没分到预算,留在队列里(灰色虚线)。

Token 预算装箱示例:2048 预算依次扣成 2040、600、0,R 的 Prefill 续块拿 1440,W1 的 900 Token Prompt 被截成 600 的首块,W2 本步不调度

两个值得记住的点:

  1. min(need, token_budget) 就是切块的本质:一个 4000 Token 的 Prompt,若这一步只剩 512 预算,就只 Prefill 512,剩下 3488 留到后续步——它自动成了一个 Chunk,没有任何专门的”分块器”。
  2. 续块靠 num_computed_tokens 记账:请求的”已计算到第几个 Token”被持久记录,下一步从断点继续,直到 num_computed_tokens 追上 num_prompt_tokens(Prefill 完成)后自然转入每步 +1 的 Decode。这正是 2.2 节”不分 Prefill/Decode 阶段”在数据上的体现。

📌 关键点:理解 V1 调度的精髓就一句话——num_computed_tokens 一路推向 num_tokens_with_spec,每步推进多少由 min(剩余需求, 剩余预算) 决定。Chunked Prefill、Decode、投机解码验证,都只是这个式子在不同请求上取了不同的值。


6. 关键参数与调优#

在 vLLM 中,与本节相关的核心参数主要是三个。V1 在条件允许时默认开启 Chunked Prefill,并且组批时优先安排 Decode 请求,一般不需要手动打开:

⚙️ 参数含义调优方向
max_num_batched_tokens一步能处理的最大 Token 数(即 Token Budget)调大 → 吞吐↑、单步耗时↑(TPOT 可能↑);调小 → TPOT 更稳、吞吐可能↓
max_num_seqs一步最多容纳的请求数(批次上限)受显存/KV Block 约束,与上者共同决定实际批大小
long_prefill_token_threshold超过这个长度的 Prompt 被视为”长 Prefill”,单步处理的 Token 数被限制在该值以内默认 0(不限制);混跑大量长 Prompt 时设一个值,防止单个长请求吃掉整步预算。批里只有它一个请求时不生效

调优的核心是围绕 max_num_batched_tokens 做取舍:

  • 偏吞吐:把预算调大,一步塞更多 Token,GPU 装得更满,系统吞吐更高,但长 Prefill 占的比重上升,Decode 的 TPOT 稳定性会下降。
  • 偏延迟稳定:把预算调小,每步计算量更可控,Decode 出字更平稳、TPOT 尾延迟更低,但单步能干的活变少,峰值吞吐可能受限。

vLLM 官方调优文档给出了一个参考刻度:较小的值(如 2048)能得到更好的 ITL(Inter-Token Latency,相邻两个输出 Token 的间隔,与 TPOT 基本同义),因为拖慢 Decode 的 Prefill 更少;较大的值能缩短 TTFT,因为一步能 Prefill 更多 Token;追求吞吐时建议设到 8192 以上,小模型跑在大显存 GPU 上时尤其如此。

💡 提示:没有放之四海皆准的最优值。正确做法是在你的目标负载(真实的 Prompt 长度分布、并发量)下压测,观察 TTFT、TPOT P95/P99 和吞吐三条曲线,找到满足 SLO 的最大吞吐点。具体调参会在第 3 章”vLLM 关键配置调优”里结合实例展开。


7. 权衡与适用场景#

✅ Chunked Prefill 收益明显的场景:

  • Prompt 长度差异大、且有超长 Prompt 混入(长文档问答、长上下文 RAG)
  • 对出字流畅度敏感、要压 TPOT 尾延迟的在线服务(如实时对话)
  • Prefill 和 Decode 请求混合的高并发在线负载

❌ 收益有限的场景:

  • 纯离线批量推理、只追求总吞吐而不在乎单请求延迟抖动——此时切块带来的调度开销未必划算
  • Prompt 都很短、本来就不会有长 Prefill 独占一步的情况

📌 关键点:Chunked Prefill 优化的是 Prefill 与 Decode 混跑时的相互干扰,它是”平稳性”优化而非”峰值吞吐”优化。判断要不要用、怎么调,关键看你的服务是否在乎 TPOT 的稳定性,以及负载里有没有长 Prompt。

⚠️ 注意:Chunked Prefill 与 Prefix Cache 是天然搭档——前缀命中先砍掉一部分 Prefill 计算,剩下未命中的部分再切块掺入 Decode。二者叠加,长 Prompt 场景下的 TTFT 和 TPOT 能同时得到照顾。


📝 总结#

  • 长 Prompt 的 Prefill 挤进一步会整体拉长该步耗时,导致同批 Decode 请求 TPOT 出现尖刺。
  • Chunked Prefill 把长 Prefill 切成多个 Chunk 分散到多步(vLLM 中块长由剩余预算决定),用少量 TTFT 增加换来 Decode 的平稳出字。
  • Token Budget 是统一的调度货币:Decode 每请求占 1 个 Token 额度,Prefill Chunk 占 chunk 长度,一步在预算内装箱。
  • vLLM V1 统一调度器用 {request_id: num_tokens} 抹平 Prefill/Decode 边界,一套框架涵盖 Chunked Prefill、Prefix Cache、投机解码。
  • 源码上,切块是 min(剩余需求, 剩余预算) 的自然结果,靠 num_computed_tokens 记账续块,无需独立的分块器。
  • 核心调参是 max_num_batched_tokens(吞吐↔TPOT 稳定性的权衡):官方建议 2048 左右偏 ITL、8192 以上偏吞吐,最终需在目标负载下压测确定;长 Prompt 多时可用 long_prefill_token_threshold 限制单个请求每步的 Prefill 量。

🎯 自我检验清单#

  • 能解释长 Prefill 为什么会拖慢同批 Decode 请求的 TPOT
  • 能描述 Chunked Prefill 如何把计算尖峰削平成多个小峰
  • 能说清 Chunked Prefill 用什么代价(TTFT)换来什么收益(TPOT 稳定)
  • 能解释 Token Budget 为什么能统一度量 Prefill 和 Decode
  • 能说明 vLLM V1 用 {request_id: num_tokens} 统一表示带来的好处
  • 能从源码角度说清”预算截断即切块”以及 num_computed_tokens 如何续块
  • 能描述 max_num_batched_tokens 调大/调小分别如何影响吞吐与 TPOT
  • 能判断自己的负载是否适合开启 Chunked Prefill

📚 参考资料#


内容来源

本文整理自开源项目 AIInfraGuide 的《Chunked Prefill 与统一 Token Budget 调度》(原文),内容版权归原作者所有。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
Hyflu
专注 AI Infra 与 Agent 技术分享
公告
博客搭建中,将分享 AI Infra 与 Agent 相关技术文章。
分类
标签
最新动态
站点统计
文章
12
分类
3
标签
27
总字数
45,635
运行时长
0 天
最后活动
0 天前
文章目录