大模型推理加速实战:从 KV Cache 到连续批处理的全链路优化

cover

一、Token 延迟与吞吐困境:大模型推理的性能瓶颈全景

大模型推理的性能瓶颈,远不止"GPU 算力不够"这么简单。在生产环境中部署 LLM 服务时,核心矛盾集中在两个维度:首 Token 延迟(TTFT)生成吞吐量(Tokens/s)。一个 70B 参数模型在单卡 A100 上做自回归解码,如果不做任何优化,每秒只能产出约 15-20 个 Token,而并发 10 路请求时,延迟会线性膨胀到不可接受的程度。

根本原因在于自回归解码的固有特征:每一步生成都需要将前面所有 Token 的 Key/Value 向量重新参与注意力计算。这意味着序列长度每增加 1,计算量就多一轮完整的 Attention,而 GPU 的计算单元在 Decode 阶段大量时间处于访存瓶颈(Memory-Bound),算力利用率常常不到 10%。

更严峻的是,当多路请求并发时,不同请求的序列长度差异巨大。如果采用朴素批处理,短序列必须等待长序列完成才能释放资源,导致 GPU 利用率进一步坍塌。这就是大模型推理中经典的"长度填充浪费"问题。

二、KV Cache 复用与连续批调度:推理引擎的核心加速机制

要突破上述瓶颈,推理引擎层面有两项关键优化:KV CacheContinuous Batching。二者的协同工作构成了现代 LLM 推理引擎的性能基石。

sequenceDiagram
    participant Client as 客户端请求
    participant Scheduler as 连续批调度器
    participant KVPool as KV Cache 池
    participant GPU as GPU 计算单元

    Client->>Scheduler: 请求A (prefill, seq_len=128)
    Client->>Scheduler: 请求B (decode, seq_len=512)
    Scheduler->>Scheduler: 组装微批次 [A_prefill, B_decode]
    Scheduler->>KVPool: 分配 A 的 KV 槽位
    Scheduler->>GPU: 提交 Attention 计算
    GPU-->>KVPool: 写入 A 的 KV 向量
    GPU-->>Scheduler: 返回 A 的首个 Token, B 的下一个 Token
    Scheduler->>Scheduler: 检测 B 已完成 → 立即释放 B 的 KV 槽位
    Client->>Scheduler: 请求C (prefill, seq_len=64)
    Scheduler->>Scheduler: 新微批次 [A_decode, C_prefill] (复用 B 释放的槽位)
    Scheduler->>GPU: 提交计算

2.1 KV Cache:用空间换时间的核心策略

KV Cache 的原理并不复杂:在自回归解码的每一步,只需计算当前 Token 的 Query,与缓存中所有历史 Key/Value 做 Attention 即可。这避免了重复计算前面所有层的 KV 投影,将每步的计算复杂度从 O(n*d) 降到 O(d)(d 为隐藏维度),但代价是显存占用随序列长度线性增长。

以 LLaMA-2-70B 为例,FP16 精度下每个 Token 的 KV Cache 占用约 2.5MB(70 层 x 2 x 8192 维度 x 2 字节)。一个 2048 长度的序列就需要约 5GB 显存仅用于 KV Cache,这还不包括模型权重本身。因此,KV Cache 的显存管理直接决定了最大并发数。

2.2 Continuous Batching:消灭填充浪费

传统的 Static Batching 要求同一批次内所有请求同时开始、同时结束,短请求被迫填充到最长请求的长度。Continuous Batching(也称 In-Flight Batching)打破了这一限制:调度器在每个解码步动态决定哪些请求参与计算,已完成的请求立即被移出批次,新请求随时插入。

这种"迭代级调度"使得 GPU 在每个步都能保持满载,吞吐量提升可达 2-4 倍。vLLM 的实测数据显示,在并发 64 路请求的场景下,Continuous Batching 相比 Static Batching 的吞吐提升约 3.2 倍。

三、生产级推理引擎配置与性能调优代码

以下代码展示了基于 vLLM 的生产级推理服务配置,重点关注 KV Cache 管理和批处理参数调优:

from vllm import LLM, SamplingParams
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.async_llm_engine import AsyncLLMEngine
import asyncio
from dataclasses import dataclass
from typing import List, Optional


@dataclass
class InferenceConfig:
    """推理引擎核心配置——每个参数都直接影响吞吐与延迟的平衡点"""
    model: str = "meta-llama/Llama-2-70b-chat-hf"
    # GPU 利用率阈值:0.95 意味着预留 5% 显存给临时张量,
    # 过高会导致 OOM,过低则浪费算力
    gpu_memory_utilization: float = 0.95
    # 最大序列长度:必须与业务最长输入对齐,
    # 过大会浪费 KV Cache 预分配空间
    max_model_len: int = 4096
    # 最大并发批次数:受限于 KV Cache 显存池大小
    max_num_seqs: int = 256
    # PagedAttention 块大小:4 是 vLLM 默认值,
    # 增大可减少页表开销但增加碎片
    block_size: int = 4
    # 交换空间:当 KV Cache 超出 GPU 显存时,
    # 溢出到 CPU 内存,避免直接 OOM
    swap_space: int = 16  # GB


async def create_engine(config: InferenceConfig) -> AsyncLLMEngine:
    """构建异步推理引擎,关键在于显存预算的精确分配"""
    engine_args = AsyncEngineArgs(
        model=config.model,
        gpu_memory_utilization=config.gpu_memory_utilization,
        max_model_len=config.max_model_len,
        max_num_seqs=config.max_num_seqs,
        block_size=config.block_size,
        swap_space=config.swap_space,
        # 启用前缀缓存:对共享 system prompt 的多轮对话场景,
        # 可复用公共前缀的 KV Cache,节省 30-50% 重复计算
        enable_prefix_caching=True,
        # 分块预填充:将长序列的 prefill 拆分为多个小块,
        # 与 decode 请求混合调度,避免长 prefill 阻塞短请求
        enable_chunked_prefill=True,
    )
    return AsyncLLMEngine.from_engine_args(engine_args)


async def batch_inference(
    engine: AsyncLLMEngine,
    prompts: List[str],
    max_tokens: int = 512,
    temperature: float = 0.7,
) -> List[str]:
    """批量推理入口——通过异步流式处理实现高吞吐"""
    sampling_params = SamplingParams(
        max_tokens=max_tokens,
        temperature=temperature,
        # 重复惩罚:防止生成退化,但过高会损害多样性
        repetition_penalty=1.05,
    )

    results = []
    request_ids = []

    # 异步提交所有请求,引擎内部自动做 Continuous Batching
    for i, prompt in enumerate(prompts):
        request_id = f"req-{i}"
        request_ids.append(request_id)
        engine.generate(prompt, sampling_params, request_id)

    # 流式收集结果
    finished_set = set()
    while len(finished_set) < len(prompts):
        # 非阻塞轮询引擎输出
        result = await engine.generate(None, None, None)
        if result and result.finished:
            finished_set.add(result.request_id)
            results.append(result.outputs[0].text)

    return results


# 基准测试:量化验证配置参数对吞吐的影响
def benchmark_throughput(config: InferenceConfig, num_requests: int = 100):
    """
    吞吐基准测试——用数据说话,不靠猜测调参。
    固定输入长度 1024 Token,输出 256 Token,
    测量不同并发度下的 Tokens/s 和 TTFT P99。
    """
    llm = LLM(
        model=config.model,
        gpu_memory_utilization=config.gpu_memory_utilization,
        max_model_len=config.max_model_len,
        max_num_seqs=config.max_num_seqs,
    )

    # 生成固定长度的测试 prompt
    test_prompts = ["Benchmark test prompt. " * 64] * num_requests
    sampling_params = SamplingParams(max_tokens=256, temperature=0.0)

    import time
    start = time.perf_counter()
    outputs = llm.generate(test_prompts, sampling_params)
    elapsed = time.perf_counter() - start

    total_tokens = sum(len(o.outputs[0].token_ids) for o in outputs)
    throughput = total_tokens / elapsed
    print(f"Throughput: {throughput:.1f} tokens/s, "
          f"Elapsed: {elapsed:.2f}s, "
          f"Total tokens: {total_tokens}")

四、显存墙与延迟抖动:推理加速的架构权衡

任何优化都不是免费的。KV Cache 和 Continuous Batching 在提升吞吐的同时,引入了以下必须正视的 Trade-off:

显存与并发的不可能三角:模型参数、KV Cache、激活值三者竞争同一块 GPU 显存。增大 gpu_memory_utilization 可以容纳更多并发请求,但一旦出现突发长序列,KV Cache 溢出触发 Swap 到 CPU 内存,延迟会从毫秒级飙升到秒级。生产环境中,P99 延迟比平均吞吐更重要,因此必须预留足够的显存余量。

PagedAttention 的碎片开销:vLLM 采用类似操作系统虚拟内存的 PagedAttention 机制管理 KV Cache,解决了显存碎片问题,但引入了间接寻址开销。在极短序列(< 32 Token)场景下,页表管理的开销占比反而超过计算本身,此时 PagedAttention 的收益为负。

Continuous Batching 的调度延迟:每个解码步都需要调度器重新评估批次组成,这个决策过程本身消耗约 0.1-0.3ms。在极低延迟要求(TTFT < 50ms)的场景下,这个开销不可忽略。对于实时对话类应用,可能需要限制最大批次大小来换取更稳定的延迟。

前缀缓存的失效风险:Prefix Caching 依赖请求间共享相同的前缀(如 System Prompt)。一旦 System Prompt 发生变化,缓存全部失效。在多租户场景下,不同租户的 Prompt 差异大,前缀缓存命中率可能低于 20%,此时开启该功能反而增加管理开销。

五、总结

大模型推理加速的核心思路可以归纳为三个层次:减少重复计算(KV Cache)、消除空闲等待(Continuous Batching)、精细化资源管理(PagedAttention + 前缀缓存)。三者缺一不可,且必须根据实际负载特征进行参数调优。

落地路线建议:

  1. 基线测量先行:部署前先用基准测试工具测量裸模型的 TTFT 和 Tokens/s,建立性能基线。
  2. 显存预算规划:根据模型大小和目标并发数,反推 KV Cache 可用显存,设置合理的 gpu_memory_utilization(建议 0.90-0.95)。
  3. 渐进式调参:从默认配置开始,逐步调整 max_num_seqsblock_sizeswap_space,每次只改一个参数,用数据验证效果。
  4. 监控 P99 延迟:生产环境中,P99 延迟比平均吞吐更能反映真实用户体验,务必建立延迟分位数的实时监控。
  5. 负载特征匹配:短序列为主的服务侧重降低调度开销,长序列为主的服务侧重 KV Cache 显存管理,混合负载则需要分块预填充。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐