Jellow 编辑发布,AI 辅助生成并完成技术核对。 计算示例和实测结果会在文中分别说明。
同一个模型服务,调大批处理预算后,监控上的 output tokens/s 更高了,聊天界面却开始一顿一顿地输出。两个现象可以同时成立:GPU 每秒产出了更多 token,但某个用户的相邻两次输出隔得更久。
要理解这个取舍,不能只看一场压测的总吞吐。需要把镜头拉近到每次调度:这一轮让谁运行、允许带进来多少新输入、已经开始输出的人要等多久。
本文用一个几十行的调度模型解释连续批处理和分块 Prefill,再讨论怎样把测试目标从“最大 tokens/s”改成“在延迟要求内完成多少请求”。所有耗时都是教学用假设,没有对应的 GPU 实测成绩。
连续批处理解决的是批次更新问题
传统的固定批次可以让一组请求一起进入,整组完成后再换下一组。长短输出混在一起时,短请求已经结束,空出来的位置却未必马上能接新请求。
连续批处理把决策点推进到生成迭代之间:一轮结束后,移除已完成的请求,有资源时加入其他请求,再运行下一轮。Orca 的 iteration-level scheduling 是理解这一思路的一手资料;它还提出了 selective batching,不能把整个系统简化成“多加一个循环”。Orca 论文与会议页面说明了两者的分工。
但能在轮次之间换人,不代表一个很长的 Prefill 不会阻塞正在 Decode 的请求。调度器可以在下一轮重新选择,却通常不能任意切开一个已经提交执行的大计算片段。
因此这里有两个不同的问题:
| 机制 | 主要改变的事情 |
|---|---|
| 连续批处理 | 一轮与下一轮之间,哪些请求留在批次里 |
| 分块 Prefill | 长输入在某轮最多推进多少 token |
两者可以配合,但开启前者不会自动证明后者的预算适合当前负载。
把一次长 Prefill 放进时间轴
设想已有两条请求持续 Decode,观察期间它们都不会结束。现在来了一条含 4096 个输入 token 的新请求。
为了只看调度取舍,作如下假设:现有两条请求每轮的 Decode 总成本固定为 4 ms;新请求完整 Prefill 的成本为 64 ms,暂按输入 token 线性分摊;每轮另有 0.5 ms 固定开销。各项顺序执行、不重叠,已有请求的下一次输出统一在轮末交付。
如果一轮允许处理 c 个 Prefill token,则:
单轮耗时 = 4 + 64 × c / 4096 + 0.5 ms
最后一轮按实际剩余 token 数计算。在此模型里,已有请求的输出间隔就是这一轮耗时,新请求的 Prefill 完成时间则是这些轮次耗时之和。
| 每轮 Prefill token 上限 | 需要轮数 | 已有请求最大输出间隔 | 新请求 Prefill 完成时间 |
|---|---|---|---|
| 4096 | 1 | 68.5 ms | 68.5 ms |
| 1024 | 4 | 20.5 ms | 82.0 ms |
| 256 | 16 | 8.5 ms | 136.0 ms |
把一整块拆成四块后,已有用户等待下一次输出的最长间隔大幅缩短;新请求的 Prefill 却从 68.5 ms 后完成变成 82 ms 后完成。拆到十六块,已有请求更平滑,新请求还要等更久。
差出来的时间并不全是浪费。现有两条请求在更多轮次里继续生成了 token,只是这些工作让新请求更晚完成 Prefill。固定的每轮开销也付了更多次。
这里特意写“Prefill 完成时间”,没有把它直接叫 TTFT。真实首 token 延迟还包含排队、输出头、采样与回传等阶段;这个模型也没有模拟混合 batch 的矩阵乘效率、KV 随生成增长的开销或 CPU/GPU 流水重叠。
运行一下,再改变假设
下面代码只用 Python 标准库。把 chunk_tokens 改成不能整除 4096 的数,也可以检查最后一轮是否按剩余输入计时。
from math import ceil
def simulate(chunk_tokens, prompt_tokens=4096, decode_ms=4.0,
full_prefill_ms=64.0, overhead_ms=0.5):
if chunk_tokens <= 0 or prompt_tokens <= 0:
raise ValueError("token counts must be positive")
remaining = prompt_tokens
now = 0.0
gaps = []
while remaining:
take = min(chunk_tokens, remaining)
prefill_ms = full_prefill_ms * take / prompt_tokens
step_ms = decode_ms + prefill_ms + overhead_ms
now += step_ms
gaps.append(step_ms) # 已有 Decode 请求每轮末收到一个 token
remaining -= take
return len(gaps), max(gaps), now
print("chunk rounds max_gap_ms prefill_done_ms")
for chunk in (4096, 1024, 256):
rounds, gap, done = simulate(chunk)
print(f"{chunk:5d} {rounds:6d} {gap:10.1f} {done:15.1f}")
rounds, gap, done = simulate(1000)
assert rounds == ceil(4096 / 1000)
assert done == 64 + rounds * (4 + 0.5)
print("chunk_1000:", rounds, gap, done)
最后一行得到 5、20.125、86.5。五轮总 Prefill 工作仍为 64 ms,外加五次 Decode 和固定开销。读者可以把 decode_ms 或 overhead_ms 调高,观察继续缩小块长为什么会越来越昂贵。
真实服务里,Prefill 时间并非严格随 chunk token 数线性增长。后面的 chunk 要关注更长的历史,不同形状也会触发不同执行效率。所以这段代码适合解释因果关系,不适合据此宣布某个具体 token 预算最优。
在 vLLM 中,token 预算不是请求数
vLLM 的调优文档描述了分块 Prefill 下优先安排 Decode,再用剩余 token 预算安排 Prefill 的策略;放不下的长输入可以拆分处理。文档也指出,较小预算通常更有利于输出间隔,较大预算有利于推进 Prefill。vLLM 调优说明可以用来核对所用版本的实际行为。
这给出了调参方向,但没有给所有模型一个通用答案。尤其要区分 max_num_batched_tokens 和 max_num_seqs:前者约束一轮可调度的 token 数,后者限制一轮可处理的序列数,具体定义见引擎参数文档。一个长 Prefill 请求就可能消耗很多 token 预算;普通自回归 Decode 则通常每条序列每轮推进一个 token。
因此,本文模拟里的“每轮 Prefill 上限 1024”,不等于把实际引擎的总 token 预算直接设成 1024。后者还要容纳 Decode token,并受序列数、KV 空间和调度细节共同约束;推测解码、多模态输入等也会改变预算的解释。
比较时应记录实际引擎版本和完整启动参数。先固定序列数上限,只改变 token 预算,观察 Prefill/Decode 混合负载;不要同时改量化、并行度和请求长度,否则很难知道收益来自哪里。
两种压测,回答的是不同问题
固定并发压测常见的做法是:始终维持 N 个在途请求,完成一个再补一个。忽略思考时间,处于稳定状态时,到达率近似满足:
到达率 ≈ N / 平均完整请求耗时
假设 N 为 32,平均请求耗时是 2 秒,对应约 16 请求/秒。若服务变慢到 4 秒,压测工具实际补充请求的速率也降到约 8 请求/秒。这个闭环模型适合测固定在途负载,但客户端会随着服务变慢自动减速,不能单凭它证明服务能承受固定的外部到达率。
如果用户请求本来按独立节奏到来,服务变慢时,新请求不会自动减半。此时应补一场固定到达负载的测试,保留到达时刻、实际发出时刻和完成时刻,确认压测机或客户端并发上限没有悄悄改变计划。
两场测试都要保持输入与输出长度分布、采样参数和缓存冷热条件可比。某组输出更短,可能看起来请求完成更快;某组长请求超时后被丢出统计,尾延迟也可能虚假地变好。错误和超时必须单独记录,并保留在请求总数的分母里。
平均输出间隔,会藏起哪种停顿
假设一条回答有 100 个相邻 token 间隔,其中 99 个间隔是 10 ms,另一个是 500 ms。平均值为 14.9 ms,看上去并不惊人,但用户会清楚感受到中间停了半秒。
若采用最近秩定义,这 100 个间隔的 p99 还是 10 ms:排序后的第 99 个值仍在那 99 个短间隔里。采用插值的分位数实现可能得到别的数,因此连“p99”也必须带上统计口径。
更需要注意的是采样单位。一条请求的平均每 token 时间、所有流式响应间隔的分布、每条请求最大停顿的分布,是三组不同的数据。很多协议一次会回传多个 token,网络缓冲又会改变客户端观察到的间隔,不能把流式 chunk 不加区分地当成一个 token。
例如 GenAI-Perf 的指标定义会把相邻响应时间除以后一个响应中的生成 token 数,用于计算 ITL;它并不等于逐个 token 在 GPU 上完成的精确时间。其官方指标表可用来理解口径。此处引用定义,不要求读者选用某个压测工具。
对交互服务,除了 TTFT 和常规 ITL,可以额外记录每条请求最大的客户端停顿、超过某个停顿阈值的请求比例,并把网络传输层和引擎内部耗时放在一起排查。
用达标请求数决定配置
先把服务要求写清楚。比如人为设定:首 token 在 1 秒内到达、整条回答里最长输出停顿不超过 100 ms,且请求成功完成。阈值只是例子,应由实际产品体验决定。
将一个完整测试批次从开始发送到全部结束或超时的时间定义为 T,则这场测试的达标请求吞吐可以定义为:
goodput = 同时满足全部条件的请求数 / T
比较不同配置时,用同一套计时边界、超时规则和请求序列;如果采用稳态窗口,还要说明跨窗口请求如何归属。不要把达标比例、请求吞吐和输出 token 吞吐混成一个数。
例如同样 100 秒的两个假设测试,A 完成 1000 个请求,只有 600 个达标;B 完成 900 个请求,有 810 个达标。A 的完成吞吐更高,goodput 却是 6 请求/秒,低于 B 的 8.1 请求/秒。此时继续追求 A 的最大吞吐,很可能背离用户需要。
实际调参可以从一组保守配置开始,在相同到达负载下逐档增加 token 预算。每档同时记录 TTFT、停顿分布、goodput、失败率和 KV 压力。已有用户停顿超标时,检查是否被长 Prefill 拉长了轮次;首 token 太慢而 Decode 很平稳时,再看是否预算过小或等待过久。
如果两类延迟都随负载恶化,缩放 chunk 未必能解决总容量不足。它决定有限资源如何分配,不能凭空增加资源。此时需要判断是否该降低接入负载、增加副本,或重新安排不同服务等级的请求。
一个值得保留的配置,应当能解释它让谁更快、让谁多等,以及在什么负载下开始越过延迟要求。这样得到的吞吐数字,才与聊天窗口里的体验连得上。
技术资料核对日期:2026-09-05。示例为 CPU 上运行的调度模型与手算数据,不是 vLLM 内部调度器的复刻,也不是 GPU 基准测试。