面试知识库
高 困难

vLLM与高吞吐推理服务#

一句话答案#

vLLM 的高吞吐来自两件事:PagedAttention 把 KV Cache 切成固定大小的块、用块表按需分配,显存浪费只剩每个序列最后一块的零头;Continuous Batching 按”每一步迭代”调度,请求随到随进、生成完立刻退出,GPU 上的 batch 一直是满的。显存不够时靠抢占(recompute/swap)腾块,长 prompt 靠 chunked prefill 切片,和 decode 混在同一步里跑,避免卡住所有人的出字。

核心要点

五大优化的概览和 KV Cache 公式推导见 LLM推理优化,量化与部署栈对比见 模型量化与本地部署。本篇只讲 vLLM 这类服务引擎内部怎么调度显存和请求。

1. prefill 与 decode:两个阶段,两种瓶颈#

阶段做什么瓶颈决定哪个指标
prefill把整段 prompt 一次性过模型,算出所有 token 的 KV 并写入 cache,产出第 1 个 token计算瓶颈:N 个 token 做矩阵乘,算术强度高,GPU 算力能吃满TTFT(首 token 延迟)
decode每步只喂 1 个新 token,读全部权重 + 全部历史 KV,产出下 1 个 token访存瓶颈:每步读的字节数巨大、计算量很小TPOT/ITL(每 token 间隔)

推论:decode 单条请求时 GPU 算力大量闲置,把很多条请求的 decode 拼成一个 batch,读一遍权重服务多条请求,吞吐几乎线性上升,直到 KV Cache 显存或算力成为上限。所以服务引擎的核心问题就变成”显存里能同时放下多少条请求的 KV”和”怎么让 batch 一直满”——前者由 PagedAttention 解决,后者由 Continuous Batching 解决。

2. PagedAttention:块表、按需分配、碎片#

传统做法的问题:为每条请求按 max_model_len 预留一段连续显存放 KV。实际输出多长事先不知道,于是产生三种浪费:

  • 预留浪费:按最大长度预留,实际只用了一小部分;
  • 内部碎片:请求结束前,预留空间里还没写到的部分别人用不了;
  • 外部碎片:不同请求长度不一,释放后留下大小不一的空洞,拼不出一段新的连续空间。

PagedAttention 的做法(类比 OS 虚拟内存分页):

  • 把 KV Cache 显存切成固定大小的物理块(block,每块存 block_size 个 token 的 KV,GPU 上默认 16);
  • 每条序列维护一张块表(block table):逻辑块号 → 物理块号。逻辑上连续,物理上可以东一块西一块;
  • 按需分配:生成的 token 写满当前块才申请下一块,所以每条序列最多浪费最后一块里没写满的 < block_size 个 token 的位置;
  • 注意力 kernel 改写成”按块表去各个物理块取 K/V 再算”,这就是 PagedAttention 这个名字的来源;
  • 共享 + 写时复制:并行采样(n > 1)、beam search 时多个候选共享同一段 prompt 的物理块,块上有引用计数;某个候选要往共享块里写新 token 时才复制一份(copy-on-write)。同一个机制后来演化成跨请求的前缀缓存,见 [前缀缓存与KV Cache复用](/topics/llm-serving/前缀缓存与KV Cache复用)。
序列 A 块表: [逻辑0→物理7] [逻辑1→物理2] [逻辑2→物理9(写了5/16)]
序列 B 块表: [逻辑0→物理4] [逻辑1→物理11(写了12/16)]
空闲块池:   {0,1,3,5,6,8,10,...}   ← 谁需要就从这里拿,不要求连续
plaintext

代价:kernel 要做一次间接寻址,单次 attention 比连续内存略慢;但换来的是同一张卡能放下的并发数大幅增加,吞吐是净收益。

3. Continuous Batching:按迭代调度#

Static batching:凑齐一批请求一起跑,要等批里最长的那条生成完,整批才结束、下一批才能进。短请求早早结束却占着位置空转,新请求在门外排队——GPU 利用率和延迟都差。

Continuous batching(Orca 论文提出的 iteration-level scheduling):调度粒度从”一批请求”降到”一步 forward”。每一步前调度器重新决定这一步跑哪些请求:

  1. 上一步生成了 EOS / 达到 max_tokens 的请求立即移出、释放它的 KV 块;
  2. 空出来的预算立刻让等待队列里的新请求进来;
  3. 这一步里有的请求在 decode、有的在 prefill,拼在一起跑。
flowchart LR
    W[waiting 队列] -->|有空闲块 + token 预算| R[running]
    R -->|每步 forward 后| C{结束?}
    C -->|EOS / max_tokens| F[释放 KV 块,返回结果]
    C -->|否| R
    R -->|块不够,被抢占| P[preempted:释放块,稍后 recompute]
    P --> W

4. Chunked prefill:长 prompt 不再卡住 decode#

没有 chunked prefill 时,一条 32K token 的 prompt 进来,它的 prefill 要独占一整步、耗时很长,这一步里正在 decode 的几百条请求全部停顿——表现为其他用户的出字突然卡一下(ITL 尖刺)。

chunked prefill:每一步设一个 token 预算(max_num_batched_tokens),调度器优先放 decode(每条 decode 只占 1 个 token),剩余预算再切一段长 prompt 的 prefill 塞进来,这条 prompt 分好几步做完。效果是:

  • decode 请求每步都有份,ITL 平稳;
  • prefill 的计算和 decode 的访存在同一步里互补,GPU 更满;
  • 代价是这条长 prompt 自己的 TTFT 变长一点(被切成多步)。

max_num_batched_tokens 调大 → prefill 快、TTFT 好,但 ITL 波动大;调小 → ITL 稳,TTFT 变差。vLLM 的 V1 引擎(v0.11.0 移除了 V0,V1 成为唯一引擎)默认开启 chunked prefill,调度器不再区分”prefill 步”和”decode 步”,只看每条请求”还差多少 token 没算”,统一在 token 预算里分配(以官方文档为准)。

更进一步是 PD 分离(prefill/decode disaggregation):prefill 和 decode 放在不同的 GPU 实例上,KV 算完通过高速网络传给 decode 实例,两边独立扩缩、互不干扰,代价是 KV 传输和系统复杂度。vLLM、SGLang 都有相关支持,成熟度以各自文档为准。

5. 调度与抢占:显存不够时怎么办#

decode 过程中序列不断变长、不断申请新块,总会有某一步空闲块不够分。调度器的做法是抢占:挑优先级最低的(默认 FCFS 下就是最晚到的)请求,释放它的 KV 块让给别人,之后再恢复。恢复有两种方式:

方式做法代价适合
recompute直接丢弃 KV,恢复时把”prompt + 已生成的 token”当作新 prompt 重新 prefill重复计算,但 prefill 是计算密集、很快默认选择;V1 引擎只保留这种(以官方文档为准)
swap把 KV 块拷到 CPU 内存,恢复时拷回占 PCIe 带宽和 CPU 内存旧版 V0 引擎才有(V0 已在 v0.11.0 移除),序列很长、重算太贵时

抢占频繁是配置出问题的信号:日志里出现 preemption 警告,说明 max_num_seqs 太大或 gpu_memory_utilization 太小,KV 块不够这么多并发分,应该降并发或加卡,而不是放任它反复重算。

6. 并行:TP、PP、DP#

方式切什么通信用在哪
TP(张量并行)每层的矩阵按列/行切到多卡(Megatron 式:QKV、up/gate 按列切,o_proj、down 按行切),注意力头也分到各卡每层约 2 次 all-reduce,通信频繁,要 NVLink 级带宽单机多卡;模型单卡放不下或想降单请求延迟
PP(流水线并行)按层切:前 N 层在卡 1,后 N 层在卡 2只在 stage 边界传激活,带宽要求低跨机;与 TP 组合(机内 TP、机间 PP)
DP(数据并行/多副本)不切模型,起多个完整副本副本间不通信模型单卡/单机放得下时,扩吞吐最简单
EP(专家并行)MoE 的专家分到不同卡all-to-all大 MoE 模型,见 MoE混合专家架构

TP 下 KV Cache 也按 KV 头切分,每张卡只存自己那部分头的 KV,所以 TP 同时扩大了 KV 容量;注意 KV 头数(GQA)要能被 TP 数整除,否则部分头要复制。经验是:能单卡放下就多副本 DP,放不下再上 TP,跨机才加 PP。

7. 显存估算:先算 KV 能放多少 token#

def kv_bytes_per_token(layers, kv_heads, head_dim, dtype_bytes=2):
    # K 和 V 两份,每层每个 KV 头一个 head_dim 向量
    return 2 * layers * kv_heads * head_dim * dtype_bytes

# Llama-3-8B:32 层,GQA 8 个 KV 头,head_dim 128,BF16
per_tok = kv_bytes_per_token(32, 8, 128)            # 131072 B = 128 KB/token

gpu_mem   = 80 * 1024**3                            # A100/H100 80GB
util      = 0.90                                     # --gpu-memory-utilization(最新文档默认 0.92)
weights   = 16 * 1024**3                             # 8B × 2 字节
act_peak  = 4 * 1024**3                              # 激活峰值 + CUDA graph 等,粗估
kv_budget = gpu_mem * util - weights - act_peak      # ≈ 52 GB
max_tokens_in_cache = kv_budget // per_tok           # ≈ 42 万 token
print(max_tokens_in_cache // 4096)                   # 平均 4K 上下文时约能并发 100 条
python

vLLM 启动时会跑一次 profiling:用 总显存 × gpu_memory_utilization − 权重 − 激活峰值 得到 KV 可用显存,日志里会打印可用 KV block 数和对应的最大并发。如果 max_model_len 比 KV Cache 能放下的 token 总数还大,启动直接报错,要么调小 max_model_len,要么调大 gpu_memory_utilization、加卡或把 KV 量化成 FP8(--kv-cache-dtype fp8)。GQA/MLA 如何压 KV 见 注意力机制变体与位置编码。

8. OpenAI 兼容 server 部署要点#

vllm serve Qwen/Qwen2.5-7B-Instruct \
  --served-model-name qwen-7b \
  --gpu-memory-utilization 0.90 \
  --max-model-len 16384 \
  --max-num-seqs 128 \
  --max-num-batched-tokens 8192 \
  --tensor-parallel-size 1 \
  --api-key $VLLM_API_KEY
# prefix caching 在 V1 默认开启,不用再加 --enable-prefix-caching;要关掉用 --no-enable-prefix-caching
bash
  • 参数含义:gpu-memory-utilization 是本实例可用的显存占整卡的比例(不是剩余显存的比例),最新文档默认 0.92,是单实例上限,同卡两个实例可以各设 0.5;想直接按字节指定 KV 大小可以用 --kv-cache-memory-bytes(设置后忽略 gpu-memory-utilization);max-model-len 是单请求上下文上限,决定每条请求最多占多少块;max-num-seqs 是同一步最多并发的序列数;max-num-batched-tokens 是每步 token 预算,影响 chunked prefill 切片;prefix caching 在 V1 默认开启;
  • 默认值(尤其 max_num_seqs、max_num_batched_tokens)随版本和硬件变化,以对应版本的官方文档和 vllm serve --help 为准;
  • max_model_len 别贪大:模型支持 128K 不代表要开 128K,按业务真实长度设,否则单条长请求会吃掉大量块;
  • 一张卡上跑两个实例时,两个实例的 gpu_memory_utilization 之和不能超过 1;
  • 客户端直接用 OpenAI SDK 改 base_url 即可,调用细节见 [LLM API调用与流式输出](/topics/llm-serving/LLM API调用与流式输出);多个 vLLM 副本前面挂网关做路由和限流,见 LLM网关与多供应商路由;
  • 监控看 /metrics(Prometheus):running/waiting 请求数、KV Cache 使用率、TTFT/ITL 直方图、抢占次数(指标名随版本变化)。waiting 持续堆积 + KV 使用率接近满 = 该扩容了。

9. 与 SGLang / TGI / TensorRT-LLM 的取舍#

引擎强项代价
vLLM模型和硬件覆盖最广、社区最大、OpenAI 兼容完整某些场景的极致延迟不如编译型引擎
SGLangRadixAttention 前缀复用、调度开销低、结构化输出快,多轮/Agent 负载表现好;也是常用的 RL rollout 后端(verl、slime 等训练框架有原生集成)冷门模型支持可能慢半拍
TensorRT-LLMNVIDIA 上内核最优、FP8 支持成熟,常配 Triton要先编译引擎,换模型/改参数成本高,只支持 NVIDIA
TGI与 Hugging Face 生态集成已进入维护模式,GitHub 仓库已归档;Hugging Face 官方推荐改用 vLLM、SGLang(本地用 llama.cpp、MLX),新项目不建议选

选型别看公开 benchmark 的单一数字,要用自己的负载分布(prompt/输出长度、并发、前缀共享比例)压测 TTFT、ITL 和满足 SLO 的吞吐(goodput)。

面试回答(2分钟版)

vLLM 解决的是”一张卡怎么同时服务尽量多的请求”。先说原理:decode 阶段是访存瓶颈,读一遍权重只算一个 token,所以要把很多请求拼成 batch,这时上限变成 KV Cache 显存。传统做法按最大长度给每个请求预留连续显存,预留浪费加内外碎片很严重。PagedAttention 借鉴虚拟内存分页,把 KV 切成 16 token 一块,每个序列一张块表映射到不连续的物理块,写满一块才申请下一块,浪费只剩最后一块的零头;并行采样还能共享前缀块,写时复制。第二是 continuous batching,调度粒度是每一步 forward,结束的请求立刻释放块、新请求立刻补进来,batch 一直是满的。第三是 chunked prefill,长 prompt 的 prefill 按 token 预算切片,和 decode 混在同一步跑,避免一个长请求把所有人的出字卡住。显存不够时调度器会抢占最晚到的请求,新版默认用 recompute 重算。部署上我会先按”权重 + 每 token KV × 并发 × 上下文”算账,再调 gpu_memory_utilization、max_model_len、max_num_seqs,看日志里有没有抢占、监控里 waiting 是否堆积。并行上能单卡放下就多副本,放不下机内 TP、跨机 PP。取舍上 vLLM 覆盖最广,多轮 Agent 前缀多可以看 SGLang,NVIDIA 上抠极致延迟看 TensorRT-LLM,TGI 已进入维护模式不再推荐。RL 训练的 rollout 采样也常单独起一个 vLLM 或 SGLang 服务,这类 prompt 公共前缀占比高,开 prefix caching 收益明显。结合项目时可以讲:引擎参数怎么定的、用什么压测数据验证吞吐和延迟。

追问与易错

追问方向:

  • “PagedAttention 的块大小怎么取?太大太小各有什么问题?” → 块太大,每条序列最后一块的零头浪费变多、前缀缓存按整块命中的粒度也变粗;块太小,块表变长、kernel 间接寻址和调度开销变大。GPU 上默认 16,一般不用改。
  • “continuous batching 和 dynamic batching 有什么区别?” → dynamic batching 是在请求粒度上”攒一小段时间凑一批”,批内仍要等最长的结束;continuous batching 是在每步 forward 粒度上增删请求,结束一个就补一个。
  • “为什么 chunked prefill 能降低 ITL 尖刺?” → 长 prompt 的 prefill 不再独占一步,而是按 max_num_batched_tokens 预算切成多段,每一步先保证所有 decode 请求各出 1 个 token,剩余预算才给 prefill。
  • “recompute 听起来很浪费,为什么反而是默认?” → prefill 是计算密集型、并行度高,重算一段 KV 往往比经 PCIe 来回拷贝 KV 更快,而且不占 CPU 内存、实现简单。
  • “vLLM 启动报 max_model_len 超过 KV Cache 容量,怎么处理?” → 说明 KV 可用显存连一条满长度请求都放不下;调小 --max-model-len、调大 --gpu-memory-utilization、开 --kv-cache-dtype fp8、加 TP 卡数或换量化权重腾显存。
  • “TP=2 时 KV Cache 怎么放?” → KV 按头切分,每卡只存一半 KV 头的 cache,单卡 KV 占用减半;KV 头数要能被 TP 整除,GQA 头少时 TP 开太大会出现头复制。
  • “吞吐上去了但 TTFT 变差,可能是什么原因?” → max_num_seqs 太大导致排队和抢占、chunked prefill 预算太小导致长 prompt 被切太多步、或者 waiting 队列本身在堆积(需要扩副本)。
  • “多副本 vLLM 前面怎么做负载均衡?” → 普通轮询会打散前缀缓存;按会话或前缀哈希做亲和路由能保住命中率,同时看各副本 waiting 数做负载感知,见 [前缀缓存与KV Cache复用](/topics/llm-serving/前缀缓存与KV Cache复用)。

易错点:

  • ❌ “gpu_memory_utilization=0.9 是剩余显存的 90%” → 是本实例能用的整卡显存比例;它是单实例上限,两个实例共卡时各自设比例,之和要 ≤ 1,还要给同卡上的其他进程留出显存。
  • ❌ “PagedAttention 让注意力计算更快” → 它省的是显存(从而提高并发和吞吐),单次 kernel 因为间接寻址反而略慢。
  • ❌ “batch 越大越好” → 超过 KV 容量就会频繁抢占重算,TTFT 和整体吞吐都会变差;上限由显存和延迟 SLO 共同决定。