大模型推理服务与优化:KV Cache、PagedAttention、连续批处理与量化部署

把大模型高效地服务出去:KV Cache 的显存账本与碎片问题、PagedAttention 的分页管理、连续批处理如何把吞吐拉高数倍、vLLM/TGI/SGLang 的选型对比、INT8/FP8/AWQ/GPTQ 量化部署、投机解码的加速原理、吞吐与延迟的权衡曲线,以及压测方法与容量规划公式。

引言

训练一个大模型很贵,但推理才是持续烧钱的那一环——每一次用户请求都要跑一遍完整的前向计算。一个 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. 大模型推理为什么特殊

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 KV4K 上下文单请求说明
7B0.5 MB2 GB32 层
13B0.8 MB3.2 GB40 层
70B (GQA)0.32 MB1.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/BF1616无否基线
INT8 (W8A8)8很小是通用、成熟
FP8 (E4M3)8很小是H100 原生支持
GPTQ4小是逐层量化、推理快
AWQ4更小是保护重要通道

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 三大引擎对比

维度vLLMTGISGLang
出身UC BerkeleyHuggingFaceLMSYS
核心创新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 官方文档

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「ml」更多文章

  1. 联邦学习与隐私保护机器学习:FedAvg、非 IID、DP-SGD 与安全聚合
  2. 语音与音频机器学习:MFCC、CTC/RNN-T、Whisper、TTS 与声码器
  3. 扩散模型与生成式建模:DDPM、U-Net、潜在扩散与 LoRA 微调实战