vLLM 推理内核深度解析

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

作者 杨艺韬 · 7,361 字

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

“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 ns50 ns
单次 block hash 计算800 ns300 ns
缓存表查找150 ns80 ns
LRU 更新100 ns10 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 个共享系统提示):

驱逐策略缓存命中率有效吞吐
朴素 LRU62%
Chain-aware LRU81%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哈希链 + HashMapBlock (16 token)隐式零开销默认开启;extra_keys 扩展
SGLangRadix TreeToken 级隐式自然支持分叉对话;tree 扩展操作原生
TensorRT-LLMTrieBlock 级隐式内存布局紧凑
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) hashO(tokens) radix 插入
查最长前缀O(blocks) iterO(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 个请求的滚动命中率。不区分这点就会在调优时犯错。实现在 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.1 实测:前缀缓存的两个核心算法都极短

§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.9.1 实测过 specialized_manager.py 161 行)——给滑动窗口 attention 用——它的 find_longest_cache_hit 不能简单 break——因为滑动窗口里”被丢弃的旧 block”可能再被新窗口恢复——这是本章未涉及但 §5.9.1 已揭示的板块;vllm 用 SpecializedManager ABC + 子类多态隔离这两种 attention 的差异

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

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.10.1 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