一、为什么大模型推理越来越慢?

大模型生成文本的过程并不是一次完成的,而是典型的自回归生成:

输入:人工智能正在改变
生成:人工智能正在改变 -> 世界
生成:人工智能正在改变世界 -> 的
生成:人工智能正在改变世界的 -> 运行方式

每生成一个 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 和 Value
  • KV 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")

这个实验通常能够说明两个问题:

  1. 上下文越长,KV Cache 的收益越明显。
  2. 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.850.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、上下文长度和并发规模下,最终结果可能存在数量级差异。

更多推荐