大模型推理加速实战:从 KV Cache 到 vLLM 调度机制的深度解析
一、为什么大模型推理越来越慢?
大模型生成文本的过程并不是一次完成的,而是典型的自回归生成:
输入:人工智能正在改变
生成:人工智能正在改变 -> 世界
生成:人工智能正在改变世界 -> 的
生成:人工智能正在改变世界的 -> 运行方式
每生成一个 Token,模型都需要执行一次完整的前向计算。
对于长度为 T 的序列,如果每次都重新计算全部历史 Token,注意力计算会产生大量重复工作。理想情况下,我们希望:
历史 Token 的 Key 和 Value 只计算一次
后续生成过程直接复用历史计算结果
这就是 KV Cache 的核心思想。
大模型推理通常包含两个阶段:
| 阶段 | 计算对象 | 主要瓶颈 |
|---|---|---|
| Prefill | 一次处理完整输入提示词 | GPU 计算能力 |
| Decode | 每次生成一个新 Token | 显存带宽和 KV Cache |
Prefill 阶段适合批量矩阵计算,通常 GPU 利用率较高。Decode 阶段每次只生成一个 Token,但需要读取模型权重和历史 KV Cache,因此往往是典型的显存带宽受限任务。
二、KV Cache 到底缓存了什么?
Transformer 的自注意力计算可以表示为:
Q = XWq
K = XWk
V = XWv
Attention(Q, K, V) = softmax(QK^T / sqrt(d))V
在生成新 Token 时,新 Token 只会产生新的 Query、Key 和 Value。
历史 Token 的 Key 和 Value 不会发生变化,因此可以缓存:
K_cache = [K1, K2, K3, ..., Kt]
V_cache = [V1, V2, V3, ..., Vt]
生成第 t + 1 个 Token 时,只需要计算:
Q(t+1), K(t+1), V(t+1)
然后将新的 Key 和 Value 追加到缓存中。
如果没有 KV Cache,每一步都要重新计算历史 Token 的 K 和 V,推理速度会随着上下文增长快速下降。
KV Cache 的显存计算
KV Cache 的理论显存占用可以通过以下公式估算:
显存 = 2 × 层数 × Batch Size × 序列长度
× KV Head 数量 × Head Dim × 每个元素字节数
其中:
2表示 Key 和 ValueKV Head 数量在 GQA 或 MQA 模型中通常小于 Query Head 数量Head Dim是每个注意力头的维度- FP16 和 BF16 通常占用 2 字节
- FP32 占用 4 字节
下面的代码可以估算一个模型的 KV Cache 显存。
from transformers import AutoConfig
def estimate_kv_cache_memory(
model_name: str,
sequence_length: int,
batch_size: int = 1,
dtype_bytes: int = 2,
):
config = AutoConfig.from_pretrained(model_name)
num_layers = config.num_hidden_layers
num_attention_heads = config.num_attention_heads
# GQA/MQA 模型存在 num_key_value_heads
num_kv_heads = getattr(
config,
"num_key_value_heads",
num_attention_heads,
)
head_dim = getattr(
config,
"head_dim",
config.hidden_size // num_attention_heads,
)
total_bytes = (
2
* num_layers
* batch_size
* sequence_length
* num_kv_heads
* head_dim
* dtype_bytes
)
gib = total_bytes / 1024 / 1024 / 1024
print(f"模型:{model_name}")
print(f"层数:{num_layers}")
print(f"KV Head:{num_kv_heads}")
print(f"Head Dim:{head_dim}")
print(f"序列长度:{sequence_length}")
print(f"Batch Size:{batch_size}")
print(f"KV Cache:{gib:.3f} GiB")
estimate_kv_cache_memory(
model_name="Qwen/Qwen2.5-7B-Instruct",
sequence_length=8192,
batch_size=1,
)
需要特别注意,模型权重经过 INT4 量化后,显存占用会明显下降,但这并不意味着 KV Cache 也自动变成 INT4。
很多推理服务的显存主要不是被模型权重占满,而是被长上下文、大 Batch 的 KV Cache 占满。
三、GQA 为什么能够降低推理成本?
传统多头注意力中,Query、Key、Value 的头数相同:
Query Heads = Key Heads = Value Heads
而在 GQA 中:
Query Heads > Key/Value Heads
例如:
Query Heads = 32
KV Heads = 8
这样可以在保留较强表达能力的同时,将 KV Cache 降低到原来的四分之一。
KV Cache 的内存大小与 KV Head 数量 成正比:
KV Cache ∝ KV Head 数量
因此,模型结构本身就会直接影响推理成本。
这也是为什么在部署阶段,不能只关注参数量。两个同样是 7B 参数的模型,由于层数、KV Head 数量和上下文长度不同,实际推理显存可能存在显著差异。
四、Transformers 中使用 KV Cache
Hugging Face Transformers 默认通常会启用 KV Cache,但建议在代码中显式指定。
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
MODEL_ID = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(
MODEL_ID,
trust_remote_code=True,
)
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
torch_dtype="auto",
device_map="auto",
trust_remote_code=True,
)
model.eval()
prompt = "请解释大模型推理中的 KV Cache,并分析它对显存和延迟的影响。"
inputs = tokenizer(
prompt,
return_tensors="pt",
).to(model.device)
@torch.inference_mode()
def generate(use_cache: bool):
return model.generate(
**inputs,
max_new_tokens=128,
do_sample=False,
use_cache=use_cache,
)
# 预热
generate(use_cache=True)
torch.cuda.synchronize()
start = time.perf_counter()
output = generate(use_cache=True)
torch.cuda.synchronize()
elapsed = time.perf_counter() - start
text = tokenizer.decode(
output[0],
skip_special_tokens=True,
)
print(f"耗时:{elapsed:.3f} 秒")
print(text)
可以使用下面的代码对比启用和禁用 KV Cache 的差异。
def benchmark(use_cache: bool, repeat: int = 3):
times = []
for _ in range(repeat):
torch.cuda.synchronize()
start = time.perf_counter()
generate(use_cache=use_cache)
torch.cuda.synchronize()
times.append(time.perf_counter() - start)
return sum(times) / len(times)
with_cache = benchmark(use_cache=True)
without_cache = benchmark(use_cache=False)
print(f"启用 KV Cache:{with_cache:.3f} 秒")
print(f"禁用 KV Cache:{without_cache:.3f} 秒")
print(f"加速比:{without_cache / with_cache:.2f}x")
这个实验通常能够说明两个问题:
- 上下文越长,KV Cache 的收益越明显。
- KV Cache 用显存换取计算量和延迟。
但 Transformers 的默认生成方式并不适合高并发生产服务,因为它通常需要为每个请求维护一套连续的缓存,并且缺乏高效的请求调度机制。
五、传统推理服务的三个问题
1. KV Cache 连续分配导致显存浪费
假设最大上下文长度设置为 8192:
请求 A:实际使用 512 Token
请求 B:实际使用 2048 Token
请求 C:实际使用 7000 Token
如果服务按照最大长度提前分配空间,大量显存会处于空闲状态。
此外,不同请求的结束时间不同,显存会产生碎片:
已分配 | 空闲 | 已分配 | 空闲 | 已分配
即使剩余显存总量足够,也可能因为缺乏连续空间而无法接收新请求。
2. 静态 Batch 无法适应请求变化
传统静态 Batch 通常需要等待一批请求全部完成:
请求 A:生成 20 Token
请求 B:生成 200 Token
请求 C:生成 50 Token
如果按照最长请求等待,A 和 C 完成后,GPU 仍然需要为它们保留 Batch 位置。
这会造成:
- GPU 计算资源浪费
- 短请求延迟增加
- 吞吐量下降
3. 长提示词会阻塞短请求
如果一个请求包含几十万 Token 的长文档,另一个请求只有一句话,那么两者共用一个调度队列时,长 Prefill 可能长时间占用 GPU。
因此,推理优化不仅是 Kernel 优化,更是:
显存管理 + 请求调度 + 批处理策略
六、vLLM 的核心设计
vLLM 主要通过以下机制提升推理性能:
PagedAttention
Continuous Batching
高效 KV Cache 管理
Prefix Caching
Chunked Prefill
其中最关键的是 PagedAttention 和 Continuous Batching。
安装 vLLM:
pip install vllm
启动 OpenAI 兼容服务:
vllm serve Qwen/Qwen2.5-7B-Instruct `
--host 0.0.0.0 `
--port 8000 `
--dtype auto `
--gpu-memory-utilization 0.90 `
--max-model-len 8192 `
--enable-prefix-caching
Windows PowerShell 中使用反引号换行,Linux 或 macOS 中使用反斜杠。
发送请求:
curl http://localhost:8000/v1/chat/completions `
-H "Content-Type: application/json" `
-d '{
"model": "Qwen/Qwen2.5-7B-Instruct",
"messages": [
{
"role": "user",
"content": "解释 PagedAttention 和普通 Attention 的区别。"
}
],
"temperature": 0.2,
"max_tokens": 256,
"stream": false
}'
Python 客户端代码如下:
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="EMPTY",
)
response = client.chat.completions.create(
model="Qwen/Qwen2.5-7B-Instruct",
messages=[
{
"role": "user",
"content": "请从显存管理角度解释 PagedAttention。",
}
],
temperature=0.2,
max_tokens=256,
)
print(response.choices[0].message.content)
七、PagedAttention 如何解决显存碎片?
PagedAttention 的设计思想类似于操作系统的虚拟内存。
传统方式通常将一个请求的 KV Cache 存储在一块连续显存中:
Request A -> 连续物理内存
PagedAttention 则将 KV Cache 切分为固定大小的 Block:
逻辑 Block 0 -> 物理 Block 17
逻辑 Block 1 -> 物理 Block 4
逻辑 Block 2 -> 物理 Block 29
逻辑上仍然是一段连续序列,但物理显存可以分散存放。
每个请求维护一个 Block Table:
Request A:
[17, 4, 29, 11]
Request B:
[8, 12, 31]
当请求新增 Token 时,只需要申请新的物理 Block,而不需要重新申请一整块连续空间。
一个简化版的 Block 管理器如下:
class KVBlockManager:
def __init__(self, total_blocks: int, block_size: int):
self.block_size = block_size
self.free_blocks = list(range(total_blocks))
self.block_tables = {}
def allocate(self, request_id: str, token_count: int):
required = (
token_count + self.block_size - 1
) // self.block_size
if len(self.free_blocks) < required:
raise RuntimeError("KV Cache 显存不足")
physical_blocks = [
self.free_blocks.pop()
for _ in range(required)
]
self.block_tables[request_id] = physical_blocks
return physical_blocks
def append_token(self, request_id: str, token_count: int):
blocks = self.block_tables[request_id]
used_tokens = token_count
capacity = len(blocks) * self.block_size
if used_tokens >= capacity:
if not self.free_blocks:
raise RuntimeError("没有可用 KV Block")
blocks.append(self.free_blocks.pop())
def release(self, request_id: str):
blocks = self.block_tables.pop(request_id, [])
self.free_blocks.extend(blocks)
这段代码只展示了基本思想,真实 vLLM 还需要处理:
- Block 引用计数
- Prefix Cache 共享
- Copy-on-Write
- 请求结束后的回收
- 多 GPU 下的缓存管理
- 不同请求的 Block Table 映射
PagedAttention 的主要收益是减少了预分配浪费和外部碎片。
如果 Block Size 过大,最后一个 Block 可能浪费更多空间。如果 Block Size 过小,Block Table 和调度管理开销会增加,因此实际系统需要在显存利用率和管理开销之间取平衡。
八、Continuous Batching 的工作方式
静态 Batch 的执行方式类似:
Batch 1:请求 A、B、C
等待 A、B、C 全部完成
Batch 2:请求 D、E
Continuous Batching 则在每一步重新调整 Batch:
第 1 步:A、B、C
第 2 步:A、B、C、D
第 3 步:A、C、D
第 4 步:A、D、E
请求 B 完成后,可以立即释放它的 KV Cache,并将新请求加入正在运行的 Batch。
简化调度逻辑如下:
running_requests = []
waiting_requests = []
while True:
# 将等待队列中的请求加入运行队列
while can_admit_new_request(running_requests):
request = waiting_requests.pop(0)
running_requests.append(request)
# 每个请求只生成一个或一小组 Token
results = model.decode_step(running_requests)
finished = []
for request, result in results:
request.append(result)
if request.is_finished():
finished.append(request)
# 释放已经完成请求的 KV Cache
for request in finished:
running_requests.remove(request)
kv_cache_manager.release(request.id)
真实系统还需要考虑调度优先级:
- 等待时间
- 请求长度
- 当前 KV Cache 占用
- 最大 Batch Token 数
- Prefill 和 Decode 的公平性
- 是否启用 Prefix Cache
Continuous Batching 的关键并不是简单地“把更多请求放进 Batch”,而是在每一个调度周期内尽可能提高 GPU 的有效工作量。
九、Prefill 和 Decode 为什么需要不同的调度策略?
Prefill
Prefill 一次处理用户输入的全部 Token,例如:
输入长度:4096 Token
它主要执行大规模矩阵运算,计算密度较高,适合充分利用 GPU。
Decode
Decode 一次通常只生成一个 Token:
输入长度:4096 Token
生成长度:1 Token
此时模型仍然需要读取大量权重和 KV Cache,但实际计算量相对较小,通常受显存带宽限制。
如果一个超长 Prefill 请求长时间占用 GPU,Decode 请求就会出现明显的首 Token 延迟。因此高性能推理引擎通常会对 Prefill 进行切分,这就是 Chunked Prefill 的基本思想:
4096 Token Prefill
拆分为:
1024 + 1024 + 1024 + 1024
这样可以让系统在处理长输入的同时,穿插执行已有请求的 Decode,改善整体延迟。
需要区分两个指标:
TTFT:Time To First Token,首 Token 延迟
TPOT:Time Per Output Token,后续 Token 平均延迟
Chunked Prefill 通常有助于改善多请求场景下的 TTFT 公平性,但也可能增加调度复杂度,需要通过压测确定最佳参数。
十、Prefix Caching 如何进一步减少重复计算?
很多业务请求具有相同的前缀:
系统提示词
公司知识库说明
固定格式约束
统一安全策略
例如下面两个请求:
请求 A:
[相同系统提示词] + 用户问题 A
请求 B:
[相同系统提示词] + 用户问题 B
如果每次都重新执行相同前缀的 Prefill,会产生重复计算。
Prefix Caching 可以按照前缀 Token 序列计算哈希:
hash(prefix_tokens) -> KV Cache Blocks
后续请求发现相同前缀后,可以直接复用已经计算好的 KV Block。
适合使用 Prefix Caching 的场景:
- 长系统提示词
- 多轮对话
- 代码仓库分析
- 固定文档模板
- 批量处理同一份上下文
不适合的场景:
- 每个请求前缀都完全不同
- 前缀非常短
- 请求生命周期很短
- GPU 显存非常紧张
Prefix Caching 主要减少 Prefill 计算,不会消除用户问题部分和新生成 Token 的 Decode 计算。
十一、使用 vLLM 进行离线批量推理
如果不需要 HTTP 服务,也可以直接使用 vLLM 的离线接口。
from vllm import LLM, SamplingParams
model_name = "Qwen/Qwen2.5-7B-Instruct"
llm = LLM(
model=model_name,
dtype="auto",
gpu_memory_utilization=0.90,
max_model_len=8192,
enable_prefix_caching=True,
)
sampling_params = SamplingParams(
temperature=0.2,
top_p=0.9,
max_tokens=256,
)
prompts = [
"解释 KV Cache 的工作原理。",
"解释 PagedAttention 如何减少显存碎片。",
"解释 Continuous Batching 的调度过程。",
]
outputs = llm.generate(
prompts,
sampling_params,
)
for output in outputs:
print("=" * 60)
print(f"输入:{output.prompt}")
print(output.outputs[0].text)
离线推理适合:
- 数据集批量生成
- 自动摘要
- 离线评测
- 文档分类
- 合成训练数据
在线服务更关注:
TTFT、P95 延迟、并发数
离线推理更关注:
总吞吐、平均 Token 成本、GPU 利用率
二者的最优配置并不完全相同。
十二、如何正确进行性能测试?
不能只执行一次请求,然后用总耗时判断性能。
一个有效的推理压测至少需要记录:
- 首 Token 延迟 TTFT
- 每个输出 Token 延迟 TPOT
- 完整请求延迟
- P50、P95、P99 延迟
- 输入 Token 数
- 输出 Token 数
- 每秒生成 Token 数
- GPU 显存使用量
- GPU 利用率
- 并发请求数
下面是一个简化的流式压测脚本:
import json
import time
import statistics
from concurrent.futures import ThreadPoolExecutor
import requests
URL = "http://127.0.0.1:8000/v1/chat/completions"
MODEL = "Qwen/Qwen2.5-7B-Instruct"
PROMPT = "请从工程角度解释大模型推理优化,要求包含 KV Cache 和批处理调度。"
def one_request(_):
body = {
"model": MODEL,
"messages": [
{
"role": "user",
"content": PROMPT,
}
],
"temperature": 0,
"max_tokens": 128,
"stream": True,
}
start = time.perf_counter()
first_token_time = None
with requests.post(
URL,
json=body,
stream=True,
timeout=300,
) as response:
response.raise_for_status()
for line in response.iter_lines():
if not line or not line.startswith(b"data:"):
continue
payload = line[5:].strip()
if payload == b"[DONE]":
break
json.loads(payload)
if first_token_time is None:
first_token_time = time.perf_counter()
end = time.perf_counter()
return {
"ttft_ms": (
(first_token_time - start) * 1000
if first_token_time
else None
),
"e2e_ms": (end - start) * 1000,
}
def percentile(values, p):
values = sorted(values)
index = int((len(values) - 1) * p)
return values[index]
def main():
request_count = 20
concurrency = 4
with ThreadPoolExecutor(max_workers=concurrency) as pool:
results = list(pool.map(one_request, range(request_count)))
ttft = [
item["ttft_ms"]
for item in results
if item["ttft_ms"] is not None
]
e2e = [item["e2e_ms"] for item in results]
print(f"请求数:{request_count}")
print(f"并发数:{concurrency}")
print(f"TTFT 平均值:{statistics.mean(ttft):.2f} ms")
print(f"TTFT P95:{percentile(ttft, 0.95):.2f} ms")
print(f"E2E 平均值:{statistics.mean(e2e):.2f} ms")
print(f"E2E P95:{percentile(e2e, 0.95):.2f} ms")
if __name__ == "__main__":
main()
压测时必须保证以下条件一致:
相同模型
相同量化方式
相同输入长度
相同输出长度
相同采样参数
相同 GPU
相同并发数
否则对比结果没有实际意义。
十三、常用参数如何调整?
1. gpu_memory_utilization
--gpu-memory-utilization 0.90
该参数控制 vLLM 可以使用的 GPU 显存比例。
过低会导致 KV Cache 容量不足,过高可能影响其他 CUDA 操作或导致显存不足。
一般可以从 0.85 到 0.92 之间逐步测试。
2. max-model-len
--max-model-len 8192
该参数越大,理论上支持的上下文越长,但 KV Cache 占用也会随之增加。
如果业务实际只需要 4096 Token,就没有必要设置为 32768。
3. max-num-seqs
--max-num-seqs 64
它限制并发序列数量。
并发并不是越高越好。当显存、带宽或调度开销达到瓶颈后,继续增加并发可能导致 P95 延迟恶化。
4. max-num-batched-tokens
该参数影响单次调度中允许处理的 Token 总量。
较大值通常有利于提高吞吐,但可能增加短请求的等待时间。在线对话场景应该同时观察吞吐和 TTFT。
5. Tensor Parallel
当模型无法放入单张 GPU 时,可以使用张量并行:
vllm serve Qwen/Qwen2.5-72B-Instruct `
--tensor-parallel-size 4
但多卡并行会引入 GPU 间通信开销。模型规模较小时,盲目增加 GPU 数量可能反而降低单请求性能。
十四、FlashAttention 和 PagedAttention 不是同一个东西
这两个概念经常被混淆。
FlashAttention
FlashAttention 主要优化注意力 Kernel:
减少 HBM 与片上 SRAM 之间的数据读写
降低 Attention 中间矩阵的显存占用
它解决的是计算 Kernel 的访存效率问题。
PagedAttention
PagedAttention 主要优化 KV Cache 的存储和分配:
减少连续显存分配要求
降低 KV Cache 内部碎片
支持动态请求调度
它解决的是推理服务中的缓存管理问题。
二者可以同时使用:
FlashAttention:提升单次 Attention 计算效率
PagedAttention:提升多请求 KV Cache 利用率
一个偏 Kernel,一个偏系统调度。真正的高性能推理系统需要两者协同。
十五、常见误区
误区一:模型量化后,KV Cache 也会自动降低
不一定。
权重量化主要降低模型参数显存。KV Cache 是否量化,取决于推理框架和具体配置。
误区二:增加 Batch Size 一定提高吞吐
Batch 增大后,GPU 利用率可能提高,但也会带来:
- KV Cache 占用增加
- 请求排队时间增加
- P95 延迟上升
- 显存不足风险增加
应该通过压测寻找平衡点。
误区三:流式输出会减少推理耗时
流式输出主要改善用户体验,让用户更早看到第一个 Token。
它不会减少模型实际计算量。真正影响推理计算的因素包括:
模型结构
KV Cache
Batch 调度
Kernel
量化
显存带宽
误区四:设置更大的上下文窗口更保险
更大的最大上下文长度会预留或占用更多缓存资源。
正确做法是根据真实业务分布设置:
P50 输入长度
P95 输入长度
最大允许输入长度
而不是简单地把最大上下文设置到模型理论上限。
十六、优化思路总结
大模型推理优化可以抽象成四个层次:
第一层:减少计算
- 启用 KV Cache
- 使用 GQA 或 MQA
- 使用更小模型
- 使用量化
- 使用投机采样
第二层:减少显存占用
- PagedAttention
- KV Cache Block 管理
- Prefix Caching
- KV Cache 量化
- 合理限制最大上下文
第三层:提高 GPU 利用率
- Continuous Batching
- 合理增加并发
- Chunked Prefill
- 高效 Attention Kernel
- 合理设置 Batch Token 上限
第四层:优化服务质量
- 监控 TTFT
- 监控 TPOT
- 监控 P95 和 P99
- 区分短请求和长请求
- 控制排队时间
- 进行动态限流
最终的优化目标不是单纯追求某一个指标,而是在以下目标之间取得平衡:
吞吐量
响应延迟
显存占用
服务稳定性
单 Token 成本
结语
KV Cache 解决的是重复计算问题,PagedAttention 解决的是 KV Cache 的高效存储问题,Continuous Batching 解决的是多请求调度问题。
三者之间的关系可以概括为:
KV Cache:
避免重复计算历史 Token
PagedAttention:
提高 KV Cache 的显存利用率
Continuous Batching:
让不同生命周期的请求共享 GPU 计算资源
如果只是本地验证模型效果,Transformers 已经足够使用。
如果需要面向真实业务提供高并发推理服务,就必须从模型结构、缓存管理、请求调度和硬件资源四个维度进行整体优化。
只有完成完整压测,才能知道系统究竟提升了多少性能。不同模型、GPU、上下文长度和并发规模下,最终结果可能存在数量级差异。
更多推荐


所有评论(0)