自动前缀缓存(Automatic Prefix Caching)

前缀缓存 KV-cache 块是大模型推理中常见的优化方法,可以避免重复的 prompt 计算。核心思想很简单——我们缓存已处理请求的 KV-cache 块,当新请求前缀与已有请求一致时,重用缓存块。由于前缀缓存几乎无副作用且不会改变模型输出,已被许多公有端点(如 OpenAI、Anthropic 等)和大多数开源 LLM 推理框架(如 SGLang)广泛采用。

实现前缀缓存有多种方法,vLLM 采用了基于哈希的方式。具体来说,我们为每个 KV-cache 块根据块内 token 和块前的前缀 token 进行哈希:

                    Block 1                  Block 2                  Block 3
         [A gentle breeze stirred] [the leaves as children] [laughed in the distance]
Block 1: |<--- block tokens ---->|
Block 2: |<------- prefix ------>| |<--- block tokens --->|
Block 3: |<------------------ prefix -------------------->| |<--- block tokens ---->|

如上例,第一块 KV cache 可唯一用 token “A gentle breeze stirred” 标识。第三块可用块内 token “laughed in the distance” 和前缀 token “A gentle breeze stirred the leaves as children” 唯一标识。因此,块哈希可构造为 hash(tuple[components]),其中 components 包括:

  • 父块哈希值:父块的哈希值。
  • 块 token:此块内的 token 元组。包含具体 token 可减小哈希冲突概率。
  • 额外哈希:如 LoRA ID、多模态输入哈希(见下例)、cache salt 等,确保块唯一性,支持多租户环境下缓存隔离。

!!! note “注意 1”
只缓存完整块。

!!! note “注意 2”
上述哈希结构理论上并非 100% 无冲突。为避免多租户下哈希碰撞,建议用 SHA256 替代默认内置 hash
vLLM v0.8.3 起支持 SHA256,可通过命令行参数启用。性能开销约每 token 100-200ns(如 5 万 token 约 6ms)。

多模态输入的哈希示例
以下示例说明多模态输入(如图片)如何支持前缀缓存。假设请求如下:

messages = [
    {"role": "user",
     "content": [
         {"type": "text",
          "text": "What's in this image?"
         },
         {"type": "image_url",
          "image_url": {"url": image_url},
         },
    ]},
]

处理后 prompt 为:

Prompt:
    <s>[INST]What's in this image?\n[IMG][/INST]

Tokenized prompt:
    [1, 3, 7493, 1681, 1294, 1593, 3937, 9551, 10, 4]

带占位符的 prompt (<P>):
    [1, 3, 7493, 1681, 1294, 1593, 3937, 9551, <P>, <P>, ..., <P>, 4]

如上,分词后 [IMG] 被替换为一系列占位符 token,预填充时由图片 embedding 替换。前缀缓存支持此场景的难点在于需区分占位符与图片。解决方法是编码前端图片处理器生成的图片哈希。例如,假设块大小16且有41个占位符 token,各块哈希如下:

Block 0
    父哈希: None
    Token ID: 1, 3, 7493, 1681, 1294, 1593, 3937, 9551, <p>, ..., <p>
    额外哈希: <image hash>
Block 1
    父哈希: Block 0 hash
    Token ID: <p>, ..., <p>
    额外哈希: <image hash>
Block 2
    父哈希: Block 1 hash
    Token ID: <p>, ..., <p>
    额外哈希: <image hash>
Block 3
    父哈希: Block 2 hash
    Token ID: <p>, ..., <p>, 4
    额外哈希: <image hash>

下文将介绍 vLLM v1 的前缀缓存数据结构、主要 KV cache 操作(如分配、追加、释放、驱逐)流程,并以实例说明端到端流程。

安全隔离(Cache Isolation)
为增强多租户环境下隐私,vLLM 支持通过请求级 salt 隔离前缀缓存复用。请求中加 cache_salt,该值注入到首块哈希,确保只有相同 salt 的请求可复用缓存 KV 块,防止通过时延差推断缓存内容的攻击。此举提升安全性且无性能损失。

{
  "messages": [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Here is a document with details about the world series: ..."},
    {"role": "user", "content": "Who won the world series in 2020?"}
  ],
  "cache_salt": "your-cache-salt"
}

如此,只有同 salt 的用户或请求可共享缓存,实现“信任组”内复用,对外隔离。

!!! note
V0 引擎不支持缓存隔离。

数据结构

vLLM v1 的前缀缓存由 KV cache manager 管理,核心为“Block”数据类(简化版):

class KVCacheBlock:
    # 块 ID(不可变)
    block_id: int
    # 块哈希(块满时赋值,被驱逐时重置)
    block_hash: BlockHash
    # 当前使用该块的请求数
    ref_cnt: int

    # 用于空闲队列的双向链表指针
    prev_free_block: Optional["KVCacheBlock"] = None
    next_free_block: Optional["KVCacheBlock"] = None

两点设计说明:

  1. KVCacheBlock 块初始化时全部分配为块池,避免 Python 对象创建开销,便于全程追踪所有块。
  2. 在 KVCacheBlock 中直接引入双向链表指针,可直接构造空闲队列,带来两大好处:
    • O(1)时间将任意位置元素移到队尾。
    • 避免引入额外 Python 队列(如 deque),减少包装层。

KV cache manager 初始化时包含如下组件:

  • 块池:KVCacheBlock 列表。
  • 空闲块队列:仅存头尾块指针以便操作。
  • 缓存块:哈希键到块 ID 映射。
  • 请求块:请求 ID 到分配块 ID 映射。

操作流程

块分配

新请求: 调度器为新请求分配 KV cache 块流程:

  1. 调度器调用 kv_cache_manager.get_computed_blocks(),根据请求的 prompt token 做哈希查找缓存块。
  2. 调度器调用 kv_cache_manager.allocate_slots(),步骤如下:
    1. 计算所需新块数,无足够块则返回失败。
    2. “Touch”已命中的缓存块,增加块引用计数,若块未被其他请求使用则从空闲队列移除,避免被驱逐(见下文例子)。
    3. 从空闲队列头部分配新块,若头块已缓存,则“驱逐”该块,使其不再被其他请求复用。
    4. 若新分配块已满,则立即加入缓存块,供同批次其他请求复用。

运行中请求: 调度器为运行中请求分配 KV cache 块流程:

  1. 调度器调用 kv_cache_manager.allocate_slots(),步骤如下:
    1. 计算所需新块数,无足够块则返回失败。
    2. 从空闲队列头部分配新块,若头块已缓存,则“驱逐”该块。
    3. 向已有块和新块追加 token ID,若块满则加入缓存块。

重复块
假设块大小为4,请求1(Request 1)prompt为 ABCDEF,解码长3:

Prompt: [A, B, C, D, E, F]
Output: [G, H, I]

Time 0:
  Tokens: [A, B, C, D, E, F, G]
  Block Table: [0 (ABCD), 1 (EFG)]
  Cache Blocks: 0
Time 1:
  Tokens: [A, B, C, D, E, F, G, H]
  Block Table: [0 (ABCD), 1 (EFGH)]
  Cache Blocks: 0, 1
Time 2:
  Tokens: [A, B, C, D, E, F, G, H, I]
  Block Table: [0 (ABCD), 1 (EFGH), 2 (I)]
  Cache Blocks: 0, 1

现在块0和块1已缓存,重复发送同样请求(Request 2),若贪婪采样输出一致:

Prompt: [A, B, C, D, E, F]
Output: [G, H, I]

Time 0:
  Tokens: [A, B, C, D, E, F, G]
  Block Table: [0 (ABCD), 3 (EFG)]
  Cache Blocks: 0, 1
Time 1:
  Tokens: [A, B, C, D, E, F, G, H]
  Block Table: [0 (ABCD), 3 (EFGH)]
  Cache Blocks: 0, 1, 3

可见,块3为新满块并缓存,但与块1内容冗余。v0 检测到块3重复时,会释放块3并让 Request 2 用块1,块表变为 [0, 1]。但 vLLM v1 的块表只能追加,不能从 [0, 3] 改为 [0, 1],因此会产生重复块,待请求释放时消除。

释放

请求结束后,若其块未被其他请求使用(引用计数为0),则释放所有块。例如释放请求1及相关块2、3、4、8,释放块按逆序加入空闲队列。原因是请求最后一块哈希更多 token,被复用概率低,应优先驱逐。

驱逐(LRU)

当空闲队列头(最近最少使用块)已缓存时,须驱逐,防止被其他请求使用。驱逐步骤:

  1. 从空闲队列头弹出块(LRU块)。
  2. 从缓存块移除该块ID。
  3. 移除块哈希。

示例

假设块大小为4,KV-cache manager总共10块。

时刻1:缓存为空,新请求到来。 分配4块,其中3块已满并缓存,第4块只填了3个token。

时刻3:请求0使块3满,需新块继续解码。 缓存块3,分配块4。

时刻4:请求1带来14个 prompt token,前10个与请求0一致。 仅前2块(8个token)命中缓存,第3块只匹配2个token。

时刻5:请求0完成释放。 块2、3、4逆序加入空闲队列(但块2、3仍缓存)。块0、1因被请求1用未加入空闲队列。

时刻6:请求1完成释放。

时刻7:请求2带来29个 prompt token,前12个与请求0一致。 即使空闲队列顺序为 7 - 8 - 9 - 4 - 3 - 2 - 6 - 5 - 1 - 0,命中缓存块(0、1、2)会在分配前被 touch 并移出队列,分配后队列为 7 - 8 - 9 - 4 - 3 - 6 - 5,分配块为0(缓存)、1(缓存)、2(缓存)、7、8、9、4、3(驱逐)。

原文地址:https://docs.vllm.ai/en/latest/design/prefix_caching.html
当前更新时间:2025-10-08

Logo

更多推荐