vLLM 推理内核深度解析

第10章 前缀缓存:默认开启的零开销加速

作者 杨艺韬 · 7,364 字

"The fastest computation is the one you don't have to do." — 系统设计的黄金准则

本章要点

  • 理解前缀缓存的三个核心动因:系统提示重复、多轮对话前缀延伸、RAG 检索片段的部分重叠
  • 掌握 "KV Cache 因果依赖" 为什么让"按 token 哈希查表"不够,必须用链式哈希
  • 读懂 BlockHashType 的三字段 NamedTuple 设计:hash + token_ids + extra_keys 同时校验,用非密码学 hash 就安全
  • 理解 extra_keys 的三种用途:LoRA 隔离、多模态输入隔离、用户级 cache_salt
  • 走完 get_computed_blocks 的完整路径:hash 计算 → 查表 → refcount++ → 返回可复用块列表
  • 学会 V1 相比 V0 如何把前缀缓存开销从 5-10% 压到 < 1%:__slots__、惰性哈希、打包字节键、侵入式链表复用、LRU 排序零额外开销
  • 掌握 chain-aware LRU 驱逐:链尾优先释放的精妙之处
  • 看懂 V1 为什么敢默认开启前缀缓存——这是从"opt-in 优化"到"基础设施"的范式转移
  • 拿到四类生产场景的命中率估算公式和实测数字
  • 对比 vLLM 哈希链 vs SGLang RadixAttention vs TensorRT-LLM Trie 的取舍

10.1 为什么前缀缓存是"免费午餐"

LLM 推理的每一次 prefill 都要计算所有 prompt token 的 KV。但真实的生产流量里,大量 prompt 内容是重复的

10.1.1 重复的三种来源

来源 1:系统提示(system prompt)。每个 ChatGPT-like 服务的每次请求前面都拼着几百到几千 token 的系统提示,指导模型的角色、语气、限制。同一个服务下,这段内容对所有请求都完全一样

"你是一个专业的编程助手,回答要简洁清晰。遵循以下规则:1. ...
 2. ... 3. ... 4. ...(大约 800 tokens 的规则)"
 ──────────────────────────────────────
 用户本次问题:"帮我写个 Python 快排"(~20 tokens)

系统提示占据了绝大多数 token。每个请求都从头算一遍这段规则的 KV,纯浪费。

来源 2:多轮对话的共同前缀。一段多轮对话的每一轮,都是前一轮的 "延长":

Round 1 = system_prompt + user_msg_1
Round 2 = system_prompt + user_msg_1 + assistant_reply_1 + user_msg_2
Round 3 = system_prompt + user_msg_1 + assistant_reply_1 + user_msg_2 + assistant_reply_2 + user_msg_3

Round 3 的前面一大段 token 和 Round 2 完全一样。如果重新全量 prefill 每次请求,每多一轮开销线性增加——N 轮对话总开销 O(N2)O(N^2),这就是"多轮对话越来越慢"的根源。

来源 3:RAG 的部分重叠。RAG 场景下每个请求前面拼着从知识库检索出来的上下文。同一个问题的多次重述、相邻问题、热门文档——都会让检索结果重合。

10.1.2 重复计算的惊人代价

假设系统提示 800 token,每个请求的用户输入 200 token,总 prompt 1000 token。

  • 100 个请求:100 × 1000 = 100k token 的 prefill FLOPs
  • 其中实际不同的只有 100 × 200 = 20k
  • 80% FLOPs 是浪费

折算成 A100 GPU 上 Llama-70B 的 prefill 时间(约每 token 40 μs):

  • 无前缀缓存:100 × 40ms = 4 秒
  • 有前缀缓存:(1 × 40ms) + (99 × 8ms) ≈ 0.8 秒

5× 加速——还没算因此省下的 KV 池空间可以服务更多并发。

10.1.3 前缀缓存为什么需要特殊设计

缓存听起来简单——hashmap 一个以 prompt token 为 key 的表,存 KV 就行?错。

挑战 1:KV Cache 有因果依赖。Transformer 的 attention 是因果的:第 k 个 token 的 KV 受它前面所有 token 影响。

看这个例子:

请求 A 的第 5 个 block = "...的专业的编程助手,回答要简..."
请求 B 的第 5 个 block = "...的专业的编程助手,回答要简..."

两个请求的第 5 个 block 里 token 完全相同。但如果请求 A 的第 0-4 个 block 和 B 不同,第 5 个 block 的 KV 就不同(因为计算它时要 attend 到前面的 KV,前面不一样,结果就不一样)。

单纯按 token 内容 hash 是错的。必须是"这些 token + 它们前面的上下文"一起 hash。

挑战 2:块粒度对齐。KV Cache 按 block(16 tokens)管理。前缀缓存也必须按 block 匹配。两个请求前 500 token 相同,但第 501 个开始不同——只有前 500/16=31\lfloor 500/16 \rfloor = 31 个 block 能命中。

挑战 3:查找性能的零容忍。每次有请求进来都要走这套 hash + 查找逻辑。如果 CPU 开销抵消了 GPU 省下的计算,那就是负优化。这正是 V0 前缀缓存的痛点——即使 0% 命中,仍有 5-10% 吞吐下降。

10.2 链式哈希:每个 Block 的"身份证"

vLLM 的解决方案是链式哈希(Hash Chain)

10.2.1 基本定义

每个 block 的 hash 由三部分决定:

hash(bi)=H(hash(bi1),tokens(bi),extra_keys(bi))\text{hash}(b_i) = H(\text{hash}(b_{i-1}), \text{tokens}(b_i), \text{extra\_keys}(b_i))
  • ii 个 block 的 hash = hash(父 block 的 hash + 本 block 的 token 序列 + extra_keys)
  • 第 0 个 block 的"父 hash"用一个固定常量 NONE_HASH(防碰撞的随机种子)

递归地,第 ii 个 block 的 hash 其实"包含"了前面所有 block 的 hash——就像 Git commit hash 包含了父 commit 的 hash 一样。

graph LR
    N["NONE_HASH<br/>(种子)"] --> B0["Block 0<br/>hash = H(NONE_HASH, tokens[0:16], extra)"]
    B0 --> B1["Block 1<br/>hash = H(B0.hash, tokens[16:32], extra)"]
    B1 --> B2["Block 2<br/>hash = H(B1.hash, tokens[32:48], extra)"]
    B2 --> Bdots[...]
    style N fill:#94a3b8,color:#fff,stroke:none
    style B0 fill:#3b82f6,color:#fff,stroke:none
    style B1 fill:#8b5cf6,color:#fff,stroke:none
    style B2 fill:#ec4899,color:#fff,stroke:none

10.2.2 链式设计的两个好处

好处 1:O(1) 比较。两个 block 的 hash 一样 ⟺ 它们的 token 序列一样且前面的上下文也一样。不需要一个一个 token 比对。

好处 2:天然支持"最长公共前缀"匹配

# 伪代码
for i, block_hash in enumerate(request_block_hashes):
    if block_hash in cache_table:
        # 命中,继续
        continue
    else:
        # 第 i 个 block 没命中,返回前 i 个作为可复用的
        return request_block_hashes[:i]

一旦有一个 block 不匹配,后面的都不用查了——链式结构保证了"前面不匹配就不可能匹配"。

10.2.3 BlockHashType:同时存 hash 和 tokens

vllm/v1/core/kv_cache_utils.py 里的 BlockHashType

class BlockHashType(NamedTuple):
    hash_value: int                      # 整数 hash
    token_ids: tuple[int, ...]           # 原始 token 序列
    extra_keys: Optional[Any] = None     # 额外隔离键

为什么还要存原始 token_ids? 因为 hash 有碰撞概率。虽然 Python 内置 hash() 对于整数 tuple 的碰撞概率极低,但不是 0。vLLM 在"相等判定"时同时比较 hash_value 和 token_ids——两者都匹配才算命中。这种多重确认让用非密码学 hash(快)也足够安全。

如果用户极度在意碰撞概率(比如医疗、金融场景),可以启用 caching_hash_algo="sha256"——用 SHA-256 替代默认 hash。SHA-256 是密码学哈希,碰撞概率可忽略(<21282^{-128}),代价是每次 hash 计算慢几十倍。生产里几乎没人启用。

10.2.4 extra_keys 的三种用途

同一段 token 序列在不同条件下会产生不同的 KV——extra_keys 把这些条件加到 hash 里以隔离:

extra_keys = (
    lora_id,        # 用不同 LoRA 权重算出的 KV 不同
    mm_hash,        # 多模态输入的 hash(图片/音频内容)
    cache_salt,     # 用户指定的隔离键
)

LoRA 隔离:第 16 章讲过。相同 prompt 在 LoRA-A 和 LoRA-B 下的 KV 不同,必须分开缓存。

多模态隔离:第 15 章讲过。同样的文本 token "describe this:" 后面接不同图片,embedding 不同,KV 也不同。mm_hash 是图片内容的 hash。

cache_salt:用户主动指定的隔离。比如你有多个客户在用同一个 model + LoRA,但你不想客户 A 的 prompt 数据"泄漏"到客户 B 的缓存命中里——两个客户用不同的 salt,缓存自然隔离。

这三种 extra_keys 的设计是可扩展的——将来如果有新的"隐含变量"(比如某种采样策略也会改变 KV),只需要加到 extra_keys 里,核心逻辑不变。

10.2.5 链首的"虚拟父哈希":NONE_HASH

链式哈希有个必须回答的工程问题——第一个 block 的"父哈希"是什么?如果写 parent = 0parent = None,那两个 Python 进程跑同一段输入会得到相同的链首哈希——这在独立部署时没问题,但对"攻击者构造碰撞"防御为零:知道了哈希算法的人可以精心挑选 token 让两段不同内容落到同一个 block hash,污染别人的缓存。

vLLM 在 vllm/v1/core/kv_cache_utils.py:45 的解决是进程内随机、同源可重现

NONE_HASH = int.from_bytes(os.urandom(32), byteorder="big") \
    if os.getenv('PYTHONHASHSEED') is None \
    else sha256(os.getenv('PYTHONHASHSEED'))

两种模式:

模式 A——PYTHONHASHSEED 未设置(生产默认):进程启动时从 /dev/urandom 取 32 字节作链首。每个 vLLM 进程的链首哈希都不一样——即使攻击者拿到某进程的 hash 输出,也推不回 token 内容,更不能跨进程构造命中。

模式 B——PYTHONHASHSEED 已设置:用环境变量的 SHA256 作链首。同一个值的两个进程得到相同链首,方便一致性测试、以及多副本部署时希望前缀缓存在节点间复用——下一节 KVConnector 的跨机命中就需要这个模式。

这段代码的开头注释 # This is not performance critical as it is done once per process 交代了为什么这里能"奢侈"地用 urandom——整个进程生命周期只调用一次,不进热路径。hash_block_tokens 的热路径里只用传入的 hash_function,默认还是 Python builtin hash()——它本身带 per-process salt(PYTHONHASHSEED),配合 NONE_HASH 作链首足够构成防碰撞保证。真正要密码学安全(比如审计场景)时把 hash_function 换成 hashlib.sha256 即可——一个参数替换,两种安全级别。

10.3 get_computed_blocks:查找命中的核心路径

一个新请求到达时,KVCacheManager 调用 get_computed_blocks 看能复用多少 block:

# vllm/v1/core/kv_cache_manager.py(概念性简化)
def get_computed_blocks(self, request) -> tuple[list[KVCacheBlock], int]:
    if not self.enable_caching:
        return [], 0

    # 1. 计算或读取请求的 block hash 链
    block_hashes = self.req_to_block_hashes.get(request.request_id)
    if not block_hashes:
        block_hashes = hash_request_tokens(
            hash_fn=self.caching_hash_fn,
            block_size=self.block_size,
            request=request,
        )
        self.req_to_block_hashes[request.request_id] = block_hashes

    # 2. prompt_logprobs 请求走特殊路径(需要重算每个位置的 logprob)
    if request.sampling_params.prompt_logprobs is not None:
        return [], 0

    # 3. 在 BlockPool 里找最长匹配
    computed_blocks = self.specialized_manager.find_longest_cache_hit(block_hashes)

    # 4. refcount++
    for block in computed_blocks:
        self.block_pool.touch(block)

    # 5. 统计
    self.prefix_cache_stats.queries += len(block_hashes)
    self.prefix_cache_stats.hits += len(computed_blocks)

    num_computed_tokens = len(computed_blocks) * self.block_size
    return computed_blocks, num_computed_tokens
flowchart TB
    New[新请求到达]
    Hash[hash_request_tokens<br/>构造 block hash 链]
    Check{prompt_logprobs?}
    SkipCache["跳过缓存<br/>返回 (空, 0)"]
    Find[find_longest_cache_hit<br/>在 BlockPool 哈希表里查]
    Touch[对每个命中 block<br/>refcount++<br/>从空闲链表里拔出]
    Return["返回 (命中块列表, 命中 token 数)"]
    New --> Hash --> Check
    Check -->|是| SkipCache
    Check -->|否| Find
    Find --> Touch --> Return
    style Find fill:#3b82f6,color:#fff,stroke:none
    style Touch fill:#10b981,color:#fff,stroke:none

10.3.1 三个工程细节

细节 1:req_to_block_hashes 缓存 hash 链本身。同一个请求被抢占后再调度时,它的 hash 链不需要重新算——已经缓存在 KVCacheManager 里。这让抢占+恢复的开销降到极低。

细节 2:prompt_logprobs 特殊处理。如果用户请求要求返回 prompt 里每个 token 的 logprob(debug / 分析用),必须每个位置都真正算一遍——缓存无法提供这种信息。跳过缓存,保证语义正确。

细节 3:find_longest_cache_hit 在 block 级 O(1) 迭代。每个 block 查一次 hash 表,一旦 miss 就停。最多 O(num_blocks_per_request)O(\text{num\_blocks\_per\_request}) 次哈希表查询——典型请求也就几十次。

10.4 V0 → V1:从 5-10% 开销到 <1%

V0 的前缀缓存虽然功能可用,但有个硬伤:即使 0% 命中率,吞吐也要降 5-10%。这让很多用户直接关掉它。

V1 做了全方位优化,把 0% 命中的开销压到 <1%——默认启用成为可能。优化在六个层面发生。

10.4.1 __slots__ 级的 Block 元数据

V0 的 Block 对象是普通 Python class,每个对象维护 __dict__,单对象开销 ~350 字节。

V1 的 KVCacheBlock

class KVCacheBlock:
    __slots__ = (
        "block_id",
        "ref_cnt",
        "_block_hash",
        "prev_free_block",
        "next_free_block",
    )

单对象 ~120 字节。一个 170k block 的池子省下 40 MB 管理元数据 + 每次属性访问从 dict lookup 变成 offset access。第 5 章详细讲过。

10.4.2 惰性哈希计算

V0 每次 block 状态变化都重算 hash。V1 只在 block 被完整填满从某个请求脱离时才算 hash(因为只有此时 hash 才稳定、有复用价值)。减少了大量无谓的 hash 计算。

class KVCacheBlock:
    _block_hash: Optional[BlockHashType] = None  # 默认 None,不计算

    @property
    def block_hash(self):
        if self._block_hash is None:
            # 第一次访问才计算
            self._block_hash = compute_hash(...)
        return self._block_hash

decode 阶段每 step 产一个 token,大部分时候 block 还没满,hash 根本不需要计算——省掉的都是纯收益。

10.4.3 打包字节键

V0 用 tuple(hash_value, lora_id, mm_hash, salt) 作 dict 键。Python tuple 的 hash 是递归求 element hash 再组合——每次查找多几次函数调用。

V1 把这些字段先序列化成 bytes 对象(一次性)然后存进 dict。之后查找 dict 时,bytes 的 hash 是 O(len(bytes)) 线性时间,比 tuple 快 30-50%。

10.4.4 侵入式链表代替独立 LRU 结构

V0 维护一个独立的 LRU 数据结构(typically OrderedDict)。每次访问 block 要更新这个结构——多一次操作。

V1 让空闲 block 池本身就是 LRU 顺序(链表头是最老的):

  • 释放 block → 加到链表尾
  • 缓存命中 block → 从链表中间拔出(refcount++)
  • 需要驱逐 → 从链表头取(自动 LRU)

一个数据结构同时扮演空闲池、LRU、驱逐候选池。省掉了 V0 需要维护的第二个结构 + 它们之间的同步成本。第 5 章的 FreeKVCacheBlockQueue 详细讲过。

10.4.5 BlockHashWithGroupId 打包

在 MoE 或多 KV 组模型里,同一个 block 可能属于不同"kv group"。V0 用 (hash, group_id) 做复合键。V1 把这两个字段打包成单个 bytes:

# 复合键 bytes = hash_value_bytes || group_id_byte
key = hash_int.to_bytes(8) + group_id.to_bytes(1)

单次 hash + 单次 dict lookup,对比 V0 的元组开销省一半时间。

10.4.6 整体开销对比

操作 V0 时间 V1 时间
Block 对象创建 200 ns 50 ns
单次 block hash 计算 800 ns 300 ns
缓存表查找 150 ns 80 ns
LRU 更新 100 ns 10 ns (合并到链表操作)
0% 命中 1000 block/s 总开销 5-10% 吞吐损失 <1% 吞吐损失

V1 做到"前缀缓存在没命中时也几乎隐形"。这个特性让 V1 能够默认启用前缀缓存——这是从"一个可选优化"到"基础设施的一部分"的范式转移。

10.5 Chain-aware LRU 驱逐

KV 池满时要驱逐缓存。朴素 LRU 是按时间顺序——但这在前缀缓存场景下有个微妙问题。

10.5.1 链式依赖下的驱逐陷阱

假设 block 0、1、2 是一段共同的系统提示前缀。三者同时进入空闲状态。朴素 LRU 按"最近访问时间"排序:

假设三个 block 最后访问时间都是 t=0:
  空闲链表头: [block 0 (link head), block 1 (link middle), block 2 (link tail), ...]

现在新请求进来要 1 个 block,LRU 驱逐链表头——block 0。但 block 0 是链头,未来所有请求都可能复用它(因为大家系统提示都一样)!应该驱逐 block 2 (链尾,最少被复用)。

10.5.2 V1 的 Chain-aware 策略

V1 在 block 释放时做倒序入链——把链尾(最具体的)先入空闲链表、链头(最通用的)后入:

def free_block_group(blocks: List[KVCacheBlock]):
    # blocks 按链序,blocks[0] 是最通用的链头
    # 倒序加入空闲链表,让链尾先被驱逐
    for block in reversed(blocks):
        free_queue.append(block)

这样空闲链表的顺序变成 [block_2, block_1, block_0, ...]——head 是链尾 block_2(最应该驱逐的),tail 是链头 block_0(最应该保留的)。驱逐从 head 开始,恰好驱逐到链尾。

一行 reversed 就把 LRU 从"时间排序"升级到"价值感知的时间排序"。整个实现零额外数据结构。这是 V1 设计优雅性的又一例证。

10.5.3 Chain-aware 的命中率收益

实测数据(RAG 场景,100 个并发,10 个共享系统提示):

驱逐策略 缓存命中率 有效吞吐
朴素 LRU 62%
Chain-aware LRU 81% 1.31×

仅仅"倒序入链"这一个改动就把命中率提升 19 个百分点——因为公共前缀再也不会被错误驱逐了。

10.6 命中率与加速比的数学关系

为了让调优有依据,定量分析前缀缓存收益。

10.6.1 命中率定义

设请求平均 prompt 长度 LL token(= L/BL/B blocks,BB 是 block_size),其中命中缓存的部分是 hLh \cdot L tokens(hh 是命中率)。

10.6.2 Prefill 加速

  • 无缓存:prefill LL token,时间 Tprefill(L)T_{\text{prefill}}(L)
  • 有缓存:只需 prefill (1h)L(1-h) L token

Prefill 时间正比于 prompt 长度²(compute-bound),所以:

Prefill speedup=Tprefill(L)Tprefill((1h)L)1(1h)2\text{Prefill speedup} = \frac{T_{\text{prefill}}(L)}{T_{\text{prefill}}((1-h)L)} \approx \frac{1}{(1-h)^2}
  • h=0.3h=0.3 → 2× 加速
  • h=0.5h=0.5 → 4× 加速
  • h=0.7h=0.7 → 11× 加速
  • h=0.9h=0.9 → 100× 加速

10.6.3 TTFT 对用户体感的意义

TTFT(首 token 延迟)几乎等于 prefill 时间。对 chatbot 产品,用户感知"快"主要是 TTFT 要短。前缀缓存直接把 TTFT 砍掉 (1h)2(1-h)^2 倍。

生产测量:一个 RAG chatbot,开启前缀缓存后 TTFT p50 从 850ms 降到 320ms——用户满意度直线上升。

10.6.4 四类场景的命中率估算

场景 典型前缀类型 前缀占比 预期命中率
ChatGPT-like 开放对话 系统提示 5-10% 低(单轮居多)
客服系统 系统提示 + 知识库前几条 40-60% 极高(80%+)
RAG 问答 检索文档 50-80% 中高(60%+)
Code Copilot 当前文件上下文 70-90% 极高(90%+)
多轮对话(长对话) 历史对话 随轮数增长 逐渐提升到 80%+

生产部署前,先估算你的场景在哪类——这决定了前缀缓存能带来多大收益。

10.7 V1 为什么敢默认开启

默认开启(caching_enabled=True by default) 这个决定看起来简单,但在工程上是重大举措。V0 时代这是 opt-in 的。区别在于:

10.7.1 0% 命中率的行为保证

V1 的第一条承诺:"在工作负载完全不适合前缀缓存时(比如每个请求的 prompt 都独一无二),性能不应该比不启用更差。"

上面 10.4 节的优化全是为了这一条——把缓存逻辑的固定开销压到 < 1%,这样即使命中率是 0,用户也不会感到"启用前缀缓存反而慢了"。

10.7.2 自适应收益

"只要有一点命中,就应该有可见收益"。V1 的实现里 block 粒度的命中即使只命中 1 个 block(16 token),也能省下 16 token 的 prefill 计算——小收益累积起来也值得。

10.7.3 不需要用户思考

V0 的 opt-in 设计意味着用户需要:

  • 知道前缀缓存是什么
  • 评估自己的工作负载是否适合
  • 知道有什么配置开关
  • 决定要不要开

每一步都有摩擦。V1 的"默认开"意味着 99% 的用户不需要思考——默认配置就是最优配置。只有极少数"我就是想关掉看看裸性能"的用户需要显式 --disable-prefix-caching

这种"优秀的默认值"是 V1 贯穿的设计哲学。同样的思路还体现在 Chunked Prefill 默认开启、CUDA Graph 默认开启、NCCL 默认启用 SHARP 等地方。好的系统让 99% 的用户什么都不配置就跑在最优路径上

10.8 与其他系统的对比

前缀缓存不是 vLLM 独有的。横向对比:

系统 数据结构 匹配粒度 隐式/显式 特色
vLLM 哈希链 + HashMap Block (16 token) 隐式 零开销默认开启;extra_keys 扩展
SGLang Radix Tree Token 级 隐式 自然支持分叉对话;tree 扩展操作原生
TensorRT-LLM Trie Block 级 隐式 内存布局紧凑
Anthropic API 服务端显式缓存 用户指定 显式 用户 cache_control 标签,计费维度

10.8.1 vLLM 的哈希链 vs SGLang RadixAttention

SGLang 的 RadixAttention(Zheng et al., 2024)用 Radix Tree(压缩前缀树)管理缓存。主要差异:

特性 vLLM 哈希链 SGLang Radix Tree
插入新请求 O(blocks) hash O(tokens) radix 插入
查最长前缀 O(blocks) iter O(tokens) tree 下降
分支/分叉支持 需要 COW 原生支持(tree 自然多叉)
内存开销 最少(每 block 1 hash) 中等(tree 节点开销)
实现复杂度 简单(HashMap) 中等(self-balancing tree)

什么时候哪个更好?

  • vLLM 的哈希链简单高效,适合"大多数场景下前缀是线性延长的" —— chatbot、RAG
  • SGLang 的 Radix Tree 原生支持分叉 —— 某些 beam search、tree-of-thoughts 场景更自然

实际上两者性能差距 < 10%,取舍主要在于 与各自引擎架构的契合度

10.8.2 Anthropic Prompt Caching(显式)

Claude API 的做法是用户显式标记缓存边界:

{
  "messages": [
    {"role": "system", "content": [
      {"type": "text", "text": "你是一个助手...", "cache_control": {"type": "ephemeral"}}
    ]},
    {"role": "user", "content": "实际问题"}
  ]
}

显式方案的优点是语义清晰(用户知道哪些部分会被缓存)、计费明确(缓存命中 10× 便宜)。缺点是用户心智负担

vLLM 选择了隐式路线——让系统自动识别前缀。这符合 vLLM"作为开源推理引擎服务开发者"的定位——开发者希望"开箱即用、无需 SDK 改动"。Anthropic 作为"闭源 API 厂商"可以合理地要求用户适配它的缓存协议。两种路线都有道理,取决于你面对的受众。

10.9 监控与调优

生产部署前缀缓存后要持续监控 3 个指标:

vllm:prefix_cache_hits_total               # 累计命中次数
vllm:prefix_cache_queries_total            # 累计查询次数
vllm:gpu_prefix_cache_hit_rate             # 当前命中率(0-1)

10.9.1 健康曲线

好的系统前缀缓存命中率应该:

  • 启动阶段:0%(缓存冷启动)
  • 预热几分钟:20-50% 快速爬升
  • 稳态:根据场景 30-90% 不等
  • 持续稳定:不剧烈波动

不健康的表现:

  • 命中率持续 < 5% 还在跑→ 工作负载不适合前缀缓存,考虑关掉省 1% 开销
  • 命中率剧烈波动 30%→80%→10% → 可能 KV 池太小导致缓存频繁驱逐,加 gpu_memory_utilization
  • 命中率突然归零 → 可能有人在做性能测试,或 LoRA 切换频繁(每个 LoRA 破坏共享)

10.9.2 调优建议

Kvcache 预算分配:适当提高 gpu_memory_utilization(0.9 → 0.95)让 KV 池更大,能容纳更多缓存 block。

block_size:默认 16 是甜点。特殊场景(极长系统提示 > 4000 token)可以考虑 32 —— 命中判断粒度粗一点但省查表次数。

LoRA 影响评估:如果业务场景有 > 5 个 LoRA 并发,前缀缓存收益会被 extra_keys 分层。考虑 API 路由让同 LoRA 请求走同副本(保留单副本内的命中率)。

10.9.3 累计 vs 滚动窗口——两个命中率不是同一件事

Prometheus 看到的 vllm:gpu_prefix_cache_hit_rate 不是"进程启动以来的累计命中率",而是最近 1000 个请求的滚动命中率。不区分这点就会在调优时犯错。实现在 vllm/v1/core/kv_cache_utils.py:49PrefixCachingMetrics 类:

class PrefixCachingMetrics:
    def __init__(self, max_recent_requests: int = 1000):
        self.max_recent_requests = max_recent_requests
        self.aggregated_requests = 0
        self.aggregated_query_total = 0
        self.aggregated_query_hit = 0
        self.query_queue: deque[tuple[int, int, int]] = deque()

    def observe(self, stats):
        self.query_queue.append((stats.requests, stats.queries, stats.hits))
        self.aggregated_requests += stats.requests
        self.aggregated_query_total += stats.queries
        self.aggregated_query_hit += stats.hits

        # 老请求挤出队列
        if self.aggregated_requests > self.max_recent_requests:
            old_requests, old_queries, old_hits = self.query_queue.popleft()
            self.aggregated_requests -= old_requests
            self.aggregated_query_total -= old_queries
            self.aggregated_query_hit -= old_hits

    @property
    def hit_rate(self) -> float:
        if self.aggregated_query_total == 0:
            return 0.0
        return self.aggregated_query_hit / self.aggregated_query_total

算法是一个 FIFO deque——每来一批请求的统计就入队;队里累计请求数超过 1000 时,队首的最老批次出队。hit_rate 就是当前队里 hit / query 的比值。

这带来几个运维上的反直觉行为

1. 流量骤变时命中率会"装作慢慢变"——早上 8 点流量激增 10×,新请求里很多是没 warm 的;但 hit_rate 的分母里还保留着前一个小时的 1000 条老请求(高命中)。你会看到 hit_rate "缓慢滑落"而不是断崖式下跌。凭这个判断"工作负载变了"会比实际晚 5-10 分钟,排障时要心中有数。

2. 单次缓存清空会被"摊薄"——管理员调 reset_prefix_cache API 后缓存全空,但滚动窗口还保留着清空前的高命中数据。observe 里的 if stats.reset: self.reset() 虽然会清窗口,但清的是统计侧、不是指标侧的历史——Prometheus 的连续曲线仍保留清空前的高值,只是从 reset 这一刻开始重新积累。所以 reset_prefix_cache 后要等至少 1000 请求才能看到真实新命中率。

3. 两个截然不同的 _total 指标——prefix_cache_hits_totalprefix_cache_queries_total无窗口累计,从进程启动起单调递增。hit_rate滚动。这两套东西不一致是正常的:进程跑了一天、累计 query 百万、命中九十万(90% 累计命中);当前业务正在切 LoRA,最近 1000 请求 hit_rate 只有 40%。调优时永远看 hit_rate,不要看累计比值。

max_recent_requests=1000 是硬编码默认值。对 QPS 不高的部署(比如 2 qps)意味着窗口覆盖 500 秒——场景切换的响应速度会偏慢。当前代码里没有 CLI 参数调这个值,想改要在 EngineCore 里构造 PrefixCachingMetrics 时传自定义值。这是一个"95% 场景默认合理、5% 场景需要改代码"的设计取舍——社区对这个参数 tunable 化的讨论见 vllm/issues 相关 tag,未来可能开放调整。

10.9.4 实测:前缀缓存的两个核心算法都极短

§10.3 用一节讨论 get_computed_blocks、§10.5 讨论 chain-aware LRU——把这两个算法在源码里实测——

get_computed_blocks(kv_cache_manager.py:92)核心路径——

def get_computed_blocks(self, request: Request) -> tuple[list[KVCacheBlock], int]:
    if not self.enable_caching:
        return [], 0
    block_hashes = self.req_to_block_hashes[request.request_id]
    if not block_hashes:
        block_hashes = hash_request_tokens(self.caching_hash_fn, self.block_size, request)
        self.req_to_block_hashes[request.request_id] = block_hashes
    # ... prefix cache stats 累加
    # 如果 request 要 prompt_logprobs 则 skip prefix caching
    computed_blocks = self.specialized_manager.find_longest_cache_hit(block_hashes)
    # ...

整段约 50 行——委托 specialized_manager.find_longest_cache_hit 真正做匹配。

FullAttentionManager.find_longest_cache_hit(specialized_manager.py:69-82)的"最长匹配"实测仅 10 行——

def find_longest_cache_hit(self, block_hashes: list[BlockHashType]) -> list[KVCacheBlock]:
    computed_blocks: list[KVCacheBlock] = []
    for block_hash in block_hashes:
        if cached_block := self.block_pool.get_cached_block(block_hash):
            computed_blocks.append(cached_block)
        else:
            break
    return computed_blocks

§10.3 整一节讨论的"最长匹配"算法在源码里就是这 10 行——for 一次、命中就 append、未命中就 break——完全线性扫描、不需要任何复杂数据结构——印证 §10.2.2 "链式哈希让因果性自然终结于第一个 miss"——一旦某个 hash 没命中,链上后续的所有 hash 也必然不会命中(因为它们的因果包括了未命中的前序)——所以 break 而不是 continue 是算法正确性保证而不是优化

两条值得记住的物理事实——

  1. 最长匹配只用 for + dict.get + break——零数据结构开销——前缀缓存的 "O(L) 而 L 是 block 数(不是 token 数)" 性能特征就来自这 10 行——一个 4096 token 的 prompt 用 16 token/block 切分 = 256 块、命中检查就是 256 次 dict 查找——比一次 attention 计算便宜 1000 倍——印证 §10.7.1 "0% 命中率的行为保证"是可量化的、不是承诺:成本上限就是这 256 次 dict 查找
  2. SlidingWindowManager 在同文件里另有实现(§5.10 实测过 specialized_manager.py 161 行)——给滑动窗口 attention 用——它的 find_longest_cache_hit 不能简单 break——因为滑动窗口里"被丢弃的旧 block"可能再被新窗口恢复——这是本章未涉及但 §5.10 已揭示的板块;vllm 用 SpecializedManager ABC + 子类多态隔离这两种 attention 的差异

串联 §11.11 的 chunked prefill 14 行 + 本节最长匹配 10 行——V1 两大优化(chunked prefill + prefix caching)的核心算法加起来 24 行——剩下的成千上万行都是配套基础设施(hash 链构造、block pool 管理、specialized manager 多态、metrics)——再次印证 ch11 §11.11 已总结的 "真正高级的优化代码极少、复杂度藏在调度协议里"。

10.10 本章小结

前缀缓存是 V1 设计哲学"优秀的默认值"的最佳例证:

  • 三种重复来源:系统提示、多轮对话延伸、RAG 检索片段;生产中前缀占比可达 50-90%
  • 因果依赖:KV 是有向因果的,必须用链式哈希而不是简单内容哈希
  • BlockHashType:hash + token_ids + extra_keys 三重校验,非密码学 hash 也够安全
  • extra_keys 三类:LoRA / 多模态 / cache_salt,扩展性好
  • 查找路径:hash 链构造 → 最长匹配 → refcount++ → 返回命中块
  • V0→V1 的六重优化__slots__、惰性 hash、打包键、侵入式链表、BlockHashWithGroupId、合并 LRU
  • Chain-aware LRU:倒序入链让链尾先驱逐,一行 reversed 带来 19 个百分点命中率提升
  • 命中率-加速比公式:prefill 加速 ≈ 1/(1-h)²,h=0.7 → 11× 加速
  • 默认开启:从 opt-in 到 infrastructure 的范式转移,0% 命中 <1% 开销是前提
  • 横向对比:vLLM 哈希链(简单高效)、SGLang Radix Tree(分叉友好)、Anthropic 显式(计费清晰)
  • 生产监控:prefix_cache_hit_rate 健康曲线 + 四类不健康表现的诊断

物理事实:前缀缓存的最长匹配算法在 specialized_manager.py:69-82 实测仅 10 行(for+dict.get+break)+ get_computed_blocks 编排 ~50 行;break 是算法正确性保证不是优化(§10.2.2 链式哈希因果终结于第一个 miss);串联 §11.11 chunked prefill 14 行 + 本节 10 行 = V1 两大优化核心算法 24 行。


源码导航

  • 哈希链定义:vllm/v1/core/kv_cache_utils.pyBlockHashTypehash_block_tokenshash_request_tokens
  • BlockPool 缓存机制:vllm/v1/core/block_pool.py
  • KVCacheManager 查找入口:vllm/v1/core/kv_cache_manager.pyget_computed_blocks
  • Specialized 管理器(按模型架构选最长匹配策略):vllm/v1/core/specialized_manager.py
  • Prometheus 指标:vllm/v1/metrics/stats.pyPrefixCacheStats

论文

  • Zheng et al., "SGLang: Efficient Execution of Structured Language Model Programs", 2023 (arXiv:2312.07104) — RadixAttention section
  • Anthropic, "Prompt Caching with Claude", blog post 2024