引言
训练一个大模型很贵,但推理才是持续烧钱的那一环——每一次用户请求都要跑一遍完整的前向计算。一个 7B 模型在单张 A100 上,朴素实现每秒只能服务几个请求;同样的硬件换成 vLLM,吞吐能提升十几倍。差距不在模型,而在推理引擎的调度与显存管理。
本文从最核心的 KV Cache 讲起,解释它为什么既是加速的关键、又是显存的负担;然后拆解 PagedAttention、连续批处理这两项把吞吐拉起来的工程创新;接着覆盖量化部署与投机解码两种「模型侧」优化;最后给出 vLLM/TGI/SGLang 的选型建议与压测、容量规划方法。全文代码基于 vLLM 与 HuggingFace 生态。
前置:模型量化与压缩原理见 https://plumephp.com/ml-model-compression-quantization/;微调后权重如何部署见 https://plumephp.com/ml-llm-finetuning-practice/;服务上线的完整链路见 https://plumephp.com/ml-model-deployment/;上线后的监控见 https://plumephp.com/ml-model-monitoring-drift/。
目录
- 1. 大模型推理为什么特殊
- 2. KV Cache 与显存账本
- 3. PagedAttention 与 vLLM
- 4. 连续批处理与调度
- 5. 量化部署:INT8、FP8、AWQ、GPTQ
- 6. 投机解码与采样加速
- 7. vLLM、TGI、SGLang 选型
- 8. 压测与容量规划
- 9. 总结
- 延伸阅读
1. 大模型推理为什么特殊
1.1 自回归生成:一次一个 token
LLM 的生成是自回归的:给定 prompt,模型预测下一个 token,把它拼回输入,再预测下一个,循环往复。这意味着生成 500 个 token 就要跑 500 次前向,而每次前向的计算量都很小(只有一个 token),GPU 大部分时间在等,而不是在算。
prompt: "今天天气" → 模型 → "真" → "今天天气真" → 模型 → "好" → ...
| 阶段 | 计算特征 | 瓶颈 |
|---|---|---|
| Prefill(预填充) | 一次处理整个 prompt | 算力密集(compute-bound) |
| Decode(解码) | 每次只算一个 token | 显存带宽密集(memory-bound) |
1.2 两大优化方向
理解这张表就理解了全部推理优化:Prefill 要压算力、Decode 要压带宽。
- 减少重复计算 → KV Cache(缓存已算过的注意力键值);
- 提高并行度 → 批处理、连续批处理;
- 减少数据搬运 → 量化(权重变小,带宽需求降低);
- 减少前向次数 → 投机解码(一次验证多个 token)。
一句话:LLM 推理慢的根源是「自回归 + 逐 token」,Decode 阶段是显存带宽瓶颈而非算力瓶颈——所有优化都在围绕「少算、少搬、多并行」做文章。
1.3 指标定义先行
讨论优化前必须统一口径:
| 指标 | 含义 | 关注方 |
|---|---|---|
| TTFT | 首 token 延迟(Time To First Token) | 用户感知 |
| TPOT | 每 token 输出时间 | 用户感知 |
| Throughput | 每秒总 token 数 / 请求数 | 服务成本 |
| Latency | 端到端总耗时 | 用户感知 |
吞吐和延迟是矛盾的:批越大吞吐越高,但单请求延迟越长。
2. KV Cache 与显存账本
2.1 为什么需要 KV Cache
注意力计算中,每个 token 的 Key 和 Value 一旦算出来就不会变(不像 Query 需要和后续 token 交互)。如果不缓存,生成第 N 个 token 时要重算前 N-1 个 token 的 K、V,计算量是平方级。KV Cache 把已算的 K、V 存下来,把生成复杂度从 O(N²) 降到 O(N)。
# 简化示意:有无 KV Cache 的差异
# 无缓存:每步重新计算全部历史 token 的 K、V
for t in range(seq_len):
k, v = compute_kv(tokens[:t+1]) # 重复计算,越来越慢
out = attention(q_t, k, v)
# 有缓存:只算新 token,历史 K/V 从 cache 读取
cache = KVCache()
for t in range(seq_len):
k_t, v_t = compute_kv(tokens[t:t+1]) # 只算一个
cache.append(k_t, v_t)
out = attention(q_t, cache.k, cache.v)
2.2 KV Cache 的显存公式
KV Cache 的大小可以精确估算:
KV 显存 = 2 × num_layers × num_kv_heads × head_dim × seq_len × batch × dtype_bytes
以 LLaMA-2-7B(32 层、32 头、head_dim 128、fp16)为例,单个 token 的 KV 约 2 × 32 × 32 × 128 × 2 = 512KB。一条 4096 token 的请求就要 2GB——一个 batch 塞 16 条就把 32GB 的显存吃满。
| 模型 | 每 token KV | 4K 上下文单请求 | 说明 |
|---|---|---|---|
| 7B | 0.5 MB | 2 GB | 32 层 |
| 13B | 0.8 MB | 3.2 GB | 40 层 |
| 70B (GQA) | 0.32 MB | 1.3 GB | 分组查询降低 KV |
GQA(Grouped Query Attention)通过让多个 Query 头共享一组 KV 头,直接把 KV 显存降低 4-8 倍,是当前大模型的标配。
2.3 显存碎片:朴素实现的浪费
传统实现为每个请求预分配最大长度的连续显存。如果按 4096 分配、实际只用了 200,浪费率高达 95%;不同请求长度不一,还会产生大量外部碎片,最终明明有空闲显存却分配不出连续块。实测朴素方案的显存有效利用率往往只有 20-40%。
一句话:KV Cache 把生成从平方复杂度降到线性,代价是每请求数 GB 的显存;GQA 缓解了容量问题,但「按最大长度预分配」造成的碎片浪费,要靠分页管理来解决。
3. PagedAttention 与 vLLM
3.1 操作系统分页的类比
vLLM 的核心创新 PagedAttention,直接借鉴了操作系统的虚拟内存分页:把 KV Cache 切成固定大小的 block(如 16 个 token 一块),不要求连续存放,用一张 block table 记录逻辑位置到物理块的映射。
逻辑序列:[token 0..15][16..31][32..47] ...
物理块 : block#7 block#2 block#9 (不连续,无所谓)
block table 负责翻译
这样显存按需分配,浪费率从 60-80% 降到 4% 以内,同一块显存能塞下更多并发请求。
3.2 前缀共享与 Copy-on-Write
分页还带来一个额外收益:相同前缀的请求可以共享物理块。多个请求用同一个 system prompt 时,那段 KV 只存一份,引用计数管理;只有当某个请求要写入时,才复制出新块(Copy-on-Write)。这对「固定系统提示 + 多变用户输入」的场景收益巨大。
3.3 vLLM 上手
pip install vllm
# 启动 OpenAI 兼容服务
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-2-7b-chat-hf \
--dtype bfloat16 \
--max-model-len 4096 \
--gpu-memory-utilization 0.90 \
--tensor-parallel-size 1
from vllm import LLM, SamplingParams
llm = LLM(model="meta-llama/Llama-2-7b-chat-hf", dtype="bfloat16")
params = SamplingParams(temperature=0.7, top_p=0.9, max_tokens=256)
# 传入一批 prompt,vLLM 自动做连续批处理
outputs = llm.generate(["解释什么是 KV Cache", "写一个快排"], params)
for o in outputs:
print(o.outputs[0].text)
gpu-memory-utilization 是 vLLM 最重要的参数:它决定 vLLM 预留多少显存给 KV Cache 池,直接决定并发上限。
一句话:PagedAttention 用「分页 + 块表」把 KV Cache 从连续大块改成按需小块,显存浪费从 60%+ 降到 4%,再加上前缀共享,同一张卡能服务的并发请求数翻了几倍。
4. 连续批处理与调度
4.1 静态批处理的浪费
传统批处理是静态的:凑齐一个 batch,一起跑完,再收下一批。问题是每个请求生成长度不同——短的早早结束却要等长的跑完(拖尾),新的请求又必须等整批结束才能进来(排队)。GPU 利用率被拖尾和空窗双重浪费。
4.2 连续批处理:请求级流水
连续批处理(Continuous Batching) 以「一次前向」为调度粒度:每一步迭代结束后,完成的请求立刻退出、排队的请求立刻加入,batch 始终是满的。
静态批:[req A B C D] —— 等最长的 D 跑完 —— [req E F G H]
连续批:[A B C D] → [B C D E] → [C D E F] → [D E F G] ...(每步动态调整)
效果:在混合长度负载下,吞吐通常提升 2-5 倍,且平均延迟反而更低。这是 vLLM、TGI、SGLang 的共同基础。
4.3 调度策略与 chunked prefill
连续批处理的调度器要在「加新请求(prefill 重)还是继续解码(decode 轻)」之间权衡。Chunked Prefill 把长 prompt 的 prefill 切成小块,与 decode 混在同一批里,避免长 prompt 阻塞所有解码请求:
# vLLM 中开启 chunked prefill
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
enable_chunked_prefill=True,
max_num_batched_tokens=8192, # 单批 token 上限,决定 chunk 大小
)
max_num_batched_tokens 是关键旋钮:调大→吞吐高但 TTFT 差;调小→延迟好但吞吐低。
一句话:连续批处理把批处理从「批次级」细化到「请求级」,让 GPU 每步都满载;chunked prefill 再解决长 prompt 阻塞解码的问题,二者共同构成现代推理引擎的调度骨架。
5. 量化部署:INT8、FP8、AWQ、GPTQ
5.1 为什么量化对推理特别有效
Decode 阶段是显存带宽瓶颈,而权重读取占了大部分带宽。把权重从 fp16 压到 int4,理论带宽需求直接降到 1/4,解码速度可提升 2-3 倍,同时显存占用减半,能塞下更大的 KV Cache 池。
| 方案 | 位宽 | 精度损失 | 是否需校准 | 特点 |
|---|---|---|---|---|
| FP16/BF16 | 16 | 无 | 否 | 基线 |
| INT8 (W8A8) | 8 | 很小 | 是 | 通用、成熟 |
| FP8 (E4M3) | 8 | 很小 | 是 | H100 原生支持 |
| GPTQ | 4 | 小 | 是 | 逐层量化、推理快 |
| AWQ | 4 | 更小 | 是 | 保护重要通道 |
5.2 AWQ / GPTQ 的量化实践
from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer
model_path = "meta-llama/Llama-2-7b-hf"
model = AutoAWQForCausalLM.from_pretrained(model_path)
tokenizer = AutoTokenizer.from_pretrained(model_path)
quant_config = {"zero_point": True, "q_group_size": 128, "w_bit": 4, "version": "GEMM"}
model.quantize(tokenizer, quant_config=quant_config)
model.save_quantized("./llama-2-7b-awq")
用 vLLM 直接加载量化权重:
python -m vllm.entrypoints.openai.api_server \
--model ./llama-2-7b-awq \
--quantization awq \
--dtype float16 \
--max-model-len 4096
5.3 量化带来的权衡
量化不是免费午餐,要注意:
- 精度损失在长上下文和推理任务上更明显,务必用自己的评测集验证,别只看 perplexity;
- AWQ/GPTQ 是 weight-only,激活仍是 fp16,对带宽瓶颈的解码阶段收益最大,对 prefill 帮助有限;
- KV Cache 量化(如 fp8 KV)能进一步省显存,但可能影响长文本一致性。
一句话:量化对推理的收益是「显存减半、解码提速」的双重红利,4bit 权重 + fp16 激活是当前性价比最高的组合;但精度损失必须用自己的业务评测集验证,不能只看通用榜单。
6. 投机解码与采样加速
6.1 核心思想:用便宜模型猜,用大模型验
Decode 阶段算力大量闲置(memory-bound),投机解码(Speculative Decoding)正是利用这份闲置算力:让一个小而快的草稿模型一次生成 K 个候选 token,再让大模型一次前向并行验证这 K 个。凡是猜对的就直接接受,猜错的位置回退重来。
草稿模型:生成 [t1 t2 t3 t4 t5]
大模型 :一次前向验证,接受 [t1 t2 t3],拒绝 t4
结果 :一次大模型前向产出 3 个 token(原本要 3 次)
关键性质:输出分布与直接用大模型采样完全一致(数学上等价),所以它是「无损加速」。
6.2 变体与收益
| 方法 | 草稿来源 | 适用 |
|---|---|---|
| Draft Model | 独立小模型 | 有配套小模型时 |
| Medusa | 额外预测头 | 无需小模型 |
| EAGLE | 特征级草稿 | 当前 SOTA |
| N-gram / Prompt Lookup | 从 prompt 里找 | 摘要、改写类任务 |
加速比取决于接受率:接受率 0.8 时理论加速约 2.5-3 倍,接受率 0.5 时只有 1.5 倍左右。任务越可预测(代码补全、摘要、格式化输出),收益越大。
6.3 vLLM 中启用
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-2-13b-chat-hf \
--speculative-model meta-llama/Llama-2-7b-chat-hf \
--num-speculative-tokens 5
一句话:投机解码用「小模型猜 + 大模型一次验证」把闲置算力换成 token,输出分布无损;收益与草稿接受率强相关,任务越可预测加速越明显。
7. vLLM、TGI、SGLang 选型
7.1 三大引擎对比
| 维度 | vLLM | TGI | SGLang |
|---|---|---|---|
| 出身 | UC Berkeley | HuggingFace | LMSYS |
| 核心创新 | PagedAttention | 生产化集成 | RadixAttention 前缀树 |
| 易用性 | 极简、OpenAI 兼容 | 与 HF 生态无缝 | 强在前缀复用 |
| 结构化输出 | 支持 | 支持 | 强(约束解码) |
| 适合场景 | 通用首选 | HF 重度用户 | 多轮/Agent 前缀共享 |
7.2 SGLang 的 RadixAttention
SGLang 的差异化在于 RadixAttention:用前缀树(radix tree)在所有请求间自动复用 KV Cache 前缀。对于多轮对话、few-shot、Agent 反复带同一段上下文的场景,命中率极高,吞吐可再翻倍。
import sglang as sgl
@sgl.function
def multi_turn(s, question):
s += sgl.system("你是一个严谨的技术助手。")
s += sgl.user(question)
s += sgl.assistant(sgl.gen("answer", max_tokens=256))
runtime = sgl.Runtime(model_path="meta-llama/Llama-2-7b-chat-hf")
sgl.set_default_backend(runtime)
7.3 选型建议
通用对话/API 服务 → vLLM(生态最广、坑最少)
已深度使用 HF 生态 → TGI
多轮/Agent/前缀重复高 → SGLang
需要极致结构化输出 → SGLang(约束解码)
一句话:vLLM 是通用首选,TGI 适合 HF 重度用户,SGLang 在多轮与 Agent 场景靠前缀树复用取胜;三者都支持连续批处理与 OpenAI 兼容接口,切换成本低。
8. 压测与容量规划
8.1 压测的正确姿势
压测要模拟真实的请求长度分布,而不是固定长度——长度分布直接决定批处理效率与显存占用。
# 用 vLLM 自带 benchmark,指定输入/输出长度分布
python benchmarks/benchmark_serving.py \
--backend vllm \
--model meta-llama/Llama-2-7b-chat-hf \
--dataset-name sharegpt \
--num-prompts 1000 \
--request-rate 20 \
--max-concurrency 64
关键观测指标:吞吐(tokens/s)、TTFT P99、TPOT P99、显存占用、失败率。一定要看 P99 而非均值,用户对尾部延迟最敏感。
8.2 容量规划公式
从目标倒推需要多少卡:
单卡可承载并发 ≈ (GPU显存 × 利用率 - 模型权重 - 激活) / 每请求 KV 显存
所需卡数 ≈ 峰值 QPS × 平均输出 token 数 / 单卡 tokens/s 吞吐
举例:目标 50 QPS、平均输出 300 token,单卡实测吞吐 3000 tokens/s → 需要 50 × 300 / 3000 = 5 张卡,再乘 1.5 倍冗余应对峰值。
8.3 吞吐-延迟权衡曲线
| 配置 | 吞吐 | TTFT | 适用 |
|---|---|---|---|
| max_num_seqs=8 | 低 | 最好 | 交互式对话 |
| max_num_seqs=64 | 中 | 中 | 通用 API |
| max_num_seqs=256 | 高 | 差 | 离线批量推理 |
# 离线批处理场景:拉满吞吐
python -m vllm.entrypoints.openai.api_server \
--model ./model --max-num-seqs 256 --gpu-memory-utilization 0.95
一句话:容量规划的本质是「用目标吞吐除以单卡实测吞吐」,但必须留冗余、看 P99;吞吐与延迟不可兼得,交互场景保延迟、离线场景拉吞吐。
9. 总结
9.1 优化收益优先级
第一优先:换推理引擎(vLLM/TGI/SGLang) → 吞吐 ×5~10
第二优先:连续批处理 + 调 max_num_seqs → 吞吐 ×2~5
第三优先:量化(AWQ/GPTQ 4bit) → 解码 ×2~3 + 显存减半
第四优先:投机解码 → 再 ×1.5~3(看接受率)
第五优先:张量并行跨卡 → 突破单卡显存
9.2 关键决策点
| 问题 | 选择 |
|---|---|
| 显存不够装 KV Cache | 降 max_model_len 或上 GQA 模型 |
| 长 prompt 阻塞解码 | 开 chunked prefill |
| 多轮/Agent 前缀重复 | SGLang RadixAttention |
| 精度要求高、怕量化掉点 | 只用 fp16/bf16,靠引擎优化 |
| 追求极低延迟 | 小 max_num_seqs + 投机解码 |
| 离线批量吞吐 | 大 max_num_seqs + 大 batch |
9.3 一句话心法
大模型推理优化的核心矛盾是「显存带宽」而非「算力」——先换引擎拿到数量级收益,再用批处理和量化榨干硬件,最后才考虑投机解码这类锦上添花的技巧。
延伸阅读
- https://plumephp.com/ml-llm-finetuning-practice/ — 微调权重如何合并与部署推理
- https://plumephp.com/ml-model-compression-quantization/ — 量化、剪枝与蒸馏的通用原理
- https://plumephp.com/ml-model-deployment/ — 模型上线的服务化与版本管理
- https://plumephp.com/ml-model-monitoring-drift/ — 上线后的性能与质量监控
- LLM 应用开发专题 — RAG、Agent 与提示工程
- vLLM 官方文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。