把推理请求装进 动态车流
Day 31-32 已经跑通 vLLM 与 PagedAttention。Day 33 进入服务调度: 为什么传统静态 batching 不适合生成式模型,Continuous Batching 如何在每个 decode step 动态加入新请求, 以及 TGI、vLLM、SGLang 这类推理引擎如何围绕吞吐、TTFT、TPOT、公平性和 KV Cache 做取舍。
思维导图
为什么静态 Batching 不够用
传统深度学习服务常把请求攒成固定 batch,一起 forward,一起返回。 对分类、embedding、rerank 这种一次 forward 就结束的任务,这很自然。 但 LLM 生成是多轮 decode:每个请求 prompt 长度不同、输出长度不同、结束时间不同。 如果还用静态 batch,整批请求会被最长输出拖住,短请求已经完成也不能让新请求进来。
| 维度 | 静态 batching | Continuous / in-flight batching | 服务影响 |
|---|---|---|---|
| 组批时机 | 请求到达后等待窗口,收够一批再执行。 | 每个调度 iteration 都重新选活跃序列和等待请求。 | GPU 更少空转,吞吐通常更高。 |
| 请求生命周期 | 一批请求绑定到整段生成结束。 | 某条序列完成后立即释放 slot 与 KV blocks。 | 短请求不必被长输出拖住。 |
| 可变长度 | padding 和等待更明显。 | 按 token budget / sequence budget 动态控制。 | 对聊天场景更友好,但调度器更复杂。 |
| 公平性 | 批内公平,批间可能排队很久。 | 需要策略处理长 prompt、长输出、老请求和新请求。 | 调得不好会牺牲 TTFT 或尾延迟。 |
调度循环放在哪里
LLM serving 的执行循环可以简化为:接收请求,分词,为 prompt 做 prefill, 然后一次 decode 一个 token。Continuous batching 把调度器插在每个 iteration 前面: 它看 waiting queue、running set、KV Cache 预算、token budget、SLO 约束,再决定本轮 GPU 做什么。
一个最小调度伪代码
while server_is_running: new_requests = receive_http_requests() waiting_queue.extend(tokenize(new_requests)) # 释放已结束序列的 KV blocks,把 token 流式返回给客户端 finished = [seq for seq in running if seq.is_finished()] free_kv_blocks(finished) running.remove(finished) token_budget = max_num_batched_tokens seq_budget = max_num_seqs - len(running) # 选择一部分 waiting 请求做 prefill,也可能只做 chunked prefill batch = choose_prefill_requests(waiting_queue, token_budget, seq_budget) # 已经 prefill 完的 running 请求每轮通常 decode 1 个 token batch += choose_decode_requests(running, token_budget) outputs = model_forward(batch) update_kv_cache(outputs) stream_tokens(outputs)
Continuous batching 如果让 waiting queue 等太久,TTFT 会升高;如果频繁插入 prefill,又可能干扰已有 decode。
decode batch 变大通常提升吞吐,但单轮时间也会变长。线上要看 p95/p99 TPOT,不是只看总 tokens/s。
Prefill 与 Decode 的混排
Continuous batching 真正难的地方在于 prefill 和 decode 的资源画像不同: prefill 是大矩阵计算,一次处理 prompt 中很多 token;decode 每轮只生成一个 token, 但要反复读写 KV Cache,更容易受显存带宽和 cache 管理影响。 调度器必须决定:先让等待请求尽快拿到首 token,还是保护正在输出的请求保持稳定 TPOT。
长 prompt 会吃掉 token budget
一个 8K prompt 的 prefill 可能挤占整轮预算,让已经在 decode 的请求等待。chunked prefill 会把长 prompt 切成小块。
稳定输出依赖短 iteration
聊天流式体验主要看 token 是否持续出来。decode iteration 太长,用户会感觉输出一卡一卡。
调度不是只算 compute
每个活跃序列都占 KV Cache。新请求能不能进来,不只看 batch size,还看剩余 KV blocks。
| 策略 | 偏向 | 优点 | 风险 |
|---|---|---|---|
| Prefill-first | 尽快接纳新请求,降低 queue time。 | TTFT 可能更好,等待队列不容易堆积。 | 长 prompt 可能让已有 decode 变慢,TPOT 波动。 |
| Decode-first | 保护已开始输出的请求。 | 流式输出稳定,用户体感更顺。 | 新请求排队更久,TTFT 尾延迟上升。 |
| Chunked prefill | 把长 prompt 切成多个调度片段。 | 更好地混合 prefill 与 decode,降低长 prompt 对短请求的阻塞。 | 调度开销上升,不同引擎参数与默认行为不同。 |
| Preemption | KV 预算不足时暂停或重算部分序列。 | 避免整体 OOM,允许更多请求进入系统。 | 重算会浪费计算,尾延迟可能抖动。 |
TGI / vLLM / SGLang 调度器对比
RoadMap 要求横向比较三类引擎。不要把它们看成“谁绝对更快”, 而要看各自的抽象:怎样控制批大小,怎样处理等待队列,怎样混排 prefill/decode, 以及有没有围绕 KV Cache、prefix cache、结构化生成做额外优化。
| 引擎 | 调度关键词 | 常见旋钮 | 适合观察什么 |
|---|---|---|---|
| TGI | continuous batching、waiting/served ratio、batch token limits。 | --max-batch-prefill-tokens --max-batch-total-tokens --waiting-served-ratio --max-waiting-tokens | 观察排队窗口、prefill token 上限和等待请求插入时机如何影响 TTFT。 |
| vLLM | PagedAttention、token budget、sequence budget、chunked prefill、preemption。 | --max-num-seqs --max-num-batched-tokens --gpu-memory-utilization --enable-chunked-prefill | 观察 KV Cache blocks、running/waiting 队列、prompt/generation throughput 与 preemption。 |
| SGLang | runtime scheduler、Radix Cache、prefix-aware workload、chunked prefill。 | --schedule-policy --max-running-requests --max-total-tokens --chunked-prefill-size | 观察共享 prefix、高并发 agent/workflow 请求、cache 命中与调度策略的耦合。 |
参数语义直观
TGI 的 launcher 参数很适合学习 batching 上限与等待策略。重点记录 waiting queue 与 served batch 的关系。
KV 管理与调度深度绑定
vLLM 的调度效果必须结合 PagedAttention 看:KV blocks 是能否接纳新请求的硬约束。
程序化请求与前缀缓存
SGLang 在复杂 prompt/program、多轮调用、共享 prefix 场景下更值得关注调度与 cache 命中。
参数名会随版本变化
这一天的目标不是背参数,而是理解参数背后的资源:序列数量、token 数量、KV Cache、等待队列和 SLO。实际实验前以当前版本官方文档和 --help 为准。
用 vLLM 做一个小实验
延续 Day32 的 vLLM 服务。今天不追求模型质量,只观察调度:
同一模型、同一 prompt/output 分布下,改变 max-num-seqs、
max-num-batched-tokens 与 chunked prefill,记录 TTFT、TPOT、吞吐和队列日志。
启动三组服务配置
# A. 保守:低并发,更容易看清单请求延迟 vllm serve Qwen/Qwen2.5-7B-Instruct \ --served-model-name qwen2.5-7b \ --max-model-len 4096 \ --gpu-memory-utilization 0.85 \ --max-num-seqs 16 \ --max-num-batched-tokens 4096 # B. 吞吐:提高活跃序列上限 vllm serve Qwen/Qwen2.5-7B-Instruct \ --served-model-name qwen2.5-7b \ --max-model-len 4096 \ --gpu-memory-utilization 0.90 \ --max-num-seqs 64 \ --max-num-batched-tokens 8192 # C. 长 prompt:尝试 chunked prefill,参数名以当前 vLLM 版本为准 vllm serve Qwen/Qwen2.5-7B-Instruct \ --served-model-name qwen2.5-7b \ --max-model-len 8192 \ --gpu-memory-utilization 0.90 \ --max-num-seqs 64 \ --max-num-batched-tokens 8192 \ --enable-chunked-prefill
压测脚本:混合短/长 prompt
import asyncio import statistics import time from openai import AsyncOpenAI client = AsyncOpenAI(api_key="EMPTY", base_url="http://localhost:8000/v1") SHORT = "用三句话解释 continuous batching。" LONG = "请阅读以下模拟日志并总结调度瓶颈。" + (" waiting=12 running=48 kv=82%; " * 220) async def one(i, prompt, max_tokens): t0 = time.perf_counter() first = None chunks = 0 stream = await client.chat.completions.create( model="qwen2.5-7b", messages=[{"role": "user", "content": prompt}], temperature=0.0, max_tokens=max_tokens, stream=True, ) async for chunk in stream: delta = chunk.choices[0].delta.content or "" if delta: chunks += 1 if first is None: first = time.perf_counter() t1 = time.perf_counter() return { "id": i, "ttft_ms": (first - t0) * 1000 if first else None, "e2e_ms": (t1 - t0) * 1000, "chunks": chunks, "tpot_ms": ((t1 - first) * 1000 / max(chunks - 1, 1)) if first else None, } def pct(values, p): values = sorted(v for v in values if v is not None) return values[int((len(values) - 1) * p)] async def main(concurrency=32, long_ratio=0.25): tasks = [] for i in range(concurrency): is_long = (i / concurrency) < long_ratio tasks.append(one(i, LONG if is_long else SHORT, 160)) res = await asyncio.gather(*tasks) print({ "concurrency": concurrency, "p50_ttft_ms": pct([r["ttft_ms"] for r in res], 0.50), "p95_ttft_ms": pct([r["ttft_ms"] for r in res], 0.95), "p50_tpot_ms": pct([r["tpot_ms"] for r in res], 0.50), "p95_e2e_ms": pct([r["e2e_ms"] for r in res], 0.95), "avg_chunks": statistics.mean(r["chunks"] for r in res), }) asyncio.run(main(concurrency=32, long_ratio=0.25))
记录模板
experiment:
model: Qwen/Qwen2.5-7B-Instruct
gpu: "A10 24GB / L40S 48GB / A100 80GB ..."
engine: vllm
version: "..."
workload:
concurrency: [8, 16, 32, 64]
long_prompt_ratio: [0.0, 0.25, 0.5]
max_tokens: 160
server_args:
max_num_seqs: 64
max_num_batched_tokens: 8192
gpu_memory_utilization: 0.90
chunked_prefill: true
metrics:
p50_ttft_ms:
p95_ttft_ms:
p50_tpot_ms:
p95_e2e_ms:
output_tokens_per_s:
kv_cache_usage:
running_waiting:
preemption_count:常见调度症状
| 现象 | 可能原因 | 观察指标 | 处理方向 |
|---|---|---|---|
| tokens/s 很高,TTFT 也很高 | 调度器为了吞吐攒了太多请求或长 prompt 抢占 prefill 预算。 | queue time、waiting count、p95 prompt length。 | 降低等待窗口或 batch token 上限,启用/调小 chunked prefill。 |
| TTFT 尚可,TPOT 抖动 | prefill 插入过频繁,decode iteration 被长 prompt 打断。 | per-iteration latency、generation throughput。 | 限制单轮 prefill token,给 decode 留稳定预算。 |
| 频繁 preemption | KV Cache 不足,长上下文或高并发超过预算。 | KV cache usage、preemption count、recompute time。 | 降低 max model len / max running seqs,增加显存预算,评估 KV 量化。 |
| 短请求被长请求拖慢 | 缺少长度感知调度或 prefix/cache 预算被长请求占用。 | 按 prompt_len 分桶的 TTFT/E2E latency。 | 做负载分级、独立实例、短请求优先或 prefix-aware routing。 |
| GPU 利用率低但排队多 | 批 token 上限太低、网络/HTTP 层瓶颈、tokenizer 或客户端压测不足。 | GPU util、CPU util、request rate、batch size。 | 提高 token/seq budget,排除客户端瓶颈,增加 tokenizer worker 或多副本。 |
常见疑问
Q1 Continuous batching 和动态 batch size 是一回事吗? +
不完全是。动态 batch size 只是 batch 大小可变;Continuous batching 强调生成过程中每个 iteration 都能增删序列。请求不需要等当前整批全部结束,完成的序列释放资源后,新请求可以进入后续 iteration。
Q2 为什么 batch 越大 TTFT 可能越差? +
更大的 batch 往往意味着请求在队列里等更久,单轮 prefill/decode 也可能更长。吞吐提升和首 token 延迟之间存在经典取舍,线上要用 SLO 限制 batch 上限。
Q3 chunked prefill 一定会提升性能吗? +
不一定。它主要缓解长 prompt 对 decode 的阻塞,但会增加调度次数和一些额外开销。短 prompt 为主的负载里收益可能不明显,长上下文混合负载里更值得测试。
Q4 为什么调度器必须关心 KV Cache? +
LLM decode 的每个活跃序列都要保留历史 KV。调度器即使有 compute 空闲,如果没有足够 KV blocks 接纳新序列,也不能盲目放入请求,否则会 OOM 或触发昂贵的抢占/重算。
今日检查清单
- 能解释静态 batching 为什么不适合可变长度生成任务。
- 能画出 Continuous batching 的 iteration-level scheduling 循环。
- 能说明 prefill 与 decode 的资源画像差异。
- 能解释 TTFT、TPOT、E2E latency、tokens/s 分别反映什么。
- 能说明
max-num-seqs与max-num-batched-tokens的区别。 - 能说明 chunked prefill 解决什么问题,以及可能带来什么开销。
- 能对比 TGI、vLLM、SGLang 在调度参数和优化重点上的差异。
- 能设计短 prompt、长 prompt、混合负载三组压测。
- 能按 prompt 长度分桶分析 TTFT 与 E2E latency。
- 能写出一个调度排错 runbook:排队高、TPOT 抖动、preemption、GPU 低利用率。
复盘问题
- 用自己的话解释 static batching、dynamic batching、continuous batching 的区别。
- 为什么生成式服务不能只看平均 latency?TTFT 和 TPOT 各自对应用户体感中的哪一段?
- prefill-first 与 decode-first 策略分别会牺牲什么?
- 一个长 prompt 请求进入系统时,它会如何影响短 prompt 请求的 TTFT?
- 如果 vLLM 日志中 waiting 长期很高,你会优先检查哪些参数?
- 为什么 prefix cache/RadixAttention 会影响调度策略,而不是单纯的 cache 优化?
- 给定一个线上聊天服务 SLO,你会怎样设置吞吐优先和延迟优先两套调度配置?
推荐阅读
Orca: A Distributed Serving System for Transformer-Based Generative Models
理解 iteration-level scheduling 和 selective batching 的经典论文,适合把 Continuous Batching 的思想放回系统设计语境。
vLLM Engine Arguments
重点看 scheduler/cache 相关参数,如 max-num-seqs、max-num-batched-tokens、chunked prefill 与 KV 预算。
Text Generation Inference Launcher
查看 TGI 的 batching、waiting、prefill token 上限等服务参数,用来对比 vLLM 的调度抽象。