Skip to content

Prefix Caching:KV block 跨请求复用

源码版本v0.25.1

职责

很多请求会共享一段相同的前缀:system prompt、chat template、函数定义、少样本示例 (few-shot examples)。这些 token 的 K/V 已经算过了,如果下一条请求能直接复用,就省掉一整段 prefill。前缀缓存 (prefix caching) 就是这件事:把每个 KV block 按「内容 hash」做指纹,下一条请求来了先查 hash,命中就把已有 block 引用过来,不重算。

启用开关是 CacheConfig.enable_prefix_caching,默认 True(cache.py:93)。EngineCore.__init__enable_prefix_caching 或有 KV connector 时构造 request_block_hasher(core.py:210-219),它本质是 get_request_block_hasher(kv_cache_utils.py:673)返回的闭包,对 request 的 token sequence 按 hash_block_size 切片、链式哈希。每个 block 的 hash 把「父 block 的 hash + 当前 block 的 token ids + extra_keys(LoRA / 多模态)」一起喂给哈希函数(kv_cache_utils.py:577-604),所以一个 block 的指纹就是「从开头到这个 block 的整条前缀」的指纹。

KVCacheManager 把命中查询委托给 KVCacheCoordinator.find_longest_cache_hit(kv_cache_coordinator.py:364),coordinator 再下发给单类型 manager。命中后 allocate_slots 复用这些 block,只对未命中部分分配新 block。Scheduler.schedule 在调度每个请求时先调 get_computed_blocks(kv_cache_manager.py:206),命中就少分配 token 预算。

设计动机

  • block 级而不是 token 级复用:KV cache 按 PagedAttention 的 block 切分,自然以 block 为最小单位,hash 也按 block 算,粒度大、查表快。
  • 链式哈希保证前缀语义:hash_block_tokens(parent_hash, tokens, extra_keys)(kv_cache_utils.py:577)让每个 block 的指纹隐含整条前缀,不会出现「中间不同但末尾相同」的误命中。
  • 多种 hash 算法可选:prefix_caching_hash_algo 支持 sha256 / sha256_cbor / xxhash / xxhash_cbor(cache.py:95-110),xxhash 更快但不是密码学安全的,多租户场景要谨慎。
  • 关闭路径有专门 coordinator:KVCacheCoordinatorNoPrefixCache(kv_cache_coordinator.py:377)让 find_longest_cache_hit 直接返回 ((), 0),避免 enable_caching=False 时还在做 hash 计算。
  • 多模态 / LoRA extra_keys:generate_block_hash_extra_keys(kv_cache_utils.py:714)把多模态输入和 LoRA ID 作为额外哈希键,避免不同图片占同一 block 槽位。
  • 非因果注意力层强制关闭:EngineCore._initialize_kv_caches 检测到 non_causal=True 的 spec 时把 enable_prefix_cachingFalse(core.py:255-269),因为 Prefix LM 的双向注意力会让 prefix 命中语义错乱。
  • 跳过读取 prefix cache:Request.get_skip_reading_prefix_cache(request.py:266-277)让 prompt logprobs / pooling all 请求显式不读 prefix,因为它们需要完整重算。
  • watermark 防抖动:KVCacheManager.watermark_blocks(kv_cache_manager.py:164-167)留一小撮空闲 block,避免高频 preemption。

关键文件

数据流

请求进来时 Scheduler 先调 get_computed_blocks,这一步在 coordinator 内部走 find_longest_cache_hit,按 block_hashes 列表逐个查 BlockPool 是否已有同样 hash 的 block:

python
# vllm/v1/core/kv_cache_manager.py L206-L246
def get_computed_blocks(self, request: Request) -> tuple[KVCacheBlocks, int]:
    """Get the computed (cached) blocks for the request.
    Note that the computed blocks must be full.
    """
    # We skip finding the prefix cache hit when prefix caching is
    # disabled or the request is marked as skipping kv cache read
    # (which happens when the request requires prompt logprobs
    # or calls a pooling model with all pooling).
    if not self.enable_caching or request.skip_reading_prefix_cache:
        return self.empty_kv_cache_blocks, 0

    # NOTE: When all tokens hit the cache, we must recompute the last token
    # to obtain logits. Thus, set max_cache_hit_length to prompt_length - 1.
    # This can trigger recomputation of an entire block, rather than just
    # the single last token, because allocate_slots() requires
    # num_computed_tokens to be block-size aligned. Removing this limitation
    # could slightly improve performance in the future.
    max_cache_hit_length = request.num_tokens - 1
    computed_blocks, num_new_computed_tokens = (
        self.coordinator.find_longest_cache_hit(
            request.block_hashes, max_cache_hit_length
        )
    )

    if self.log_stats:
        assert self.prefix_cache_stats is not None
        self.prefix_cache_stats.record(
            num_tokens=request.num_tokens,
            num_hits=num_new_computed_tokens,
            preempted=request.num_preemptions > 0,
        )

    return self.create_kv_cache_blocks(computed_blocks), num_new_computed_tokens

每个 block 的 hash 是在 request 自身的 update_block_hashes 里算的,request_block_hasher 是闭包,只对满 block 算 hash,父 hash 串起来:

python
# vllm/v1/core/kv_cache_utils.py L687-L728
def request_block_hasher(request: Request) -> list[BlockHash]:
    start_token_idx = len(request.block_hashes) * hash_block_size
    num_tokens = request.num_tokens

    if start_token_idx + hash_block_size > num_tokens:
        # Early stop when there no new full blocks created.
        return []

    curr_mm_idx = 0
    if start_token_idx > 0:
        # Set curr_mm_idx = -1 to indicate the last mm input.
        # Note that since we reach to this branch only when the block is
        # completed with generated tokens, we only need to consider the
        # last mm input.
        curr_mm_idx = -1

    prev_block_hash_value = (
        request.block_hashes[-1] if request.block_hashes else None
    )
    new_block_hashes: list[BlockHash] = []
    while True:
        end_token_idx = start_token_idx + hash_block_size
        if end_token_idx > num_tokens:
            # We only hash full blocks
            break

        # MM and LoRA requests need extra keys for block-hash computation.
        extra_keys, curr_mm_idx = generate_block_hash_extra_keys(
            request, start_token_idx, end_token_idx, curr_mm_idx
        )

        # Compute the hash of the current block
        block_tokens = request.all_token_ids[start_token_idx:end_token_idx]
        block_hash = hash_block_tokens(
            caching_hash_fn, prev_block_hash_value, block_tokens, extra_keys
        )

        new_block_hashes.append(block_hash)
        start_token_idx += hash_block_size
        prev_block_hash_value = block_hash

    return new_block_hashes

Scheduler 拿到命中数后,把 num_new_tokens 减掉命中部分,allocate_slots 在 block pool 里只取未命中的 block。命中 block 引用计数 +1,但 token 不会重新计算。

边界与失败

  • 非因果注意力层强制关 prefix caching:EngineCore._initialize_kv_caches 检测到 non_causal=True 时把 enable_prefix_caching = False(core.py:265-269),否则双向注意力的 prefix 命中会读到错误的 KV。
  • Prompt logprobs / pooling 跳过命中:Request.get_skip_reading_prefix_cache 返回 True 时 get_computed_blocks 直接返回空(kv_cache_manager.py:222-223),因为这些场景必须重新计算 prompt 的 KV。
  • 全命中也要重算最后一个 token:max_cache_hit_length = request.num_tokens - 1(kv_cache_manager.py:231),否则没有 logits 可采。
  • xxhash 不是密码学安全:prefix_caching_hash_algo = "xxhash" 速度更快,但理论上碰撞概率更高,多租户场景可能泄漏前缀信息(cache.py:101-108)。
  • 关闭路径有专门 stub:KVCacheCoordinatorNoPrefixCache.find_longest_cache_hit 返回 ((), 0)(kv_cache_coordinator.py:416-424),让 enable_caching=False 时不浪费 hash 计算。
  • BlockPool LRU 淘汰:命中复用的 block 引用计数 +1,请求结束才 -1,长期没人引用的 block 被 LRU 回收给新请求(kv_cache_manager.py:508)。

小结

前缀缓存是 vLLM 高吞吐的关键机制之一:把 KV block 按「父 hash + token ids + extra_keys」链式哈希,跨请求复用相同前缀的计算结果。EngineCore 在初始化时根据 enable_prefix_caching 决定要不要构造 hasher,Scheduler.schedule 每次调 get_computed_blocks 查命中。复用的 block 在 /kv-cache/kv-cache-manager 里管,BlockPool 细节见 /kv-cache/coordinator-blockpool,引擎核心的串联见 /engine/engine-core

对照官方资料:vLLM 文档 · README