AI 技术
#vLLM#前缀缓存#KV Cache#块哈希#LoRA

vLLM 前缀缓存的块哈希:不同请求的 KV 块为何会被误判为可复用

在线服务开启自动前缀缓存后,两个前缀不同的请求有时会命中同一批 KV 块。文章从块哈希的链式计算讲起,说明哈希键遗漏模型、适配器身份或发生碰撞时错误复用的路径,并给出校验点、可观测指标与隔离方案的成本对比。

一个不该命中的命中

设想一台跑在线推理的 GPU 服务器,vLLM 开启了自动前缀缓存,同时挂载了多个 LoRA 适配器。客服机器人和代码助手共用同一个基座模型,两个租户的请求交替到达。某天值班同学发现,代码助手对一段 Python 代码的续写里出现了客服话术的措辞。日志显示这次请求的 prefill 阶段几乎瞬间完成,缓存命中率异常地高。

基线直觉是:前缀缓存只复用 token 序列完全相同的前缀,不同业务的前缀不一样,不该命中。这个直觉依赖一个前提,哈希键能唯一标识一段前缀对应的 KV 状态。一旦哈希键漏掉了影响 KV 值的因素,缓存就会把“看起来一样”当成“算出来一样”,把别的请求算好的物理块直接挂到当前请求的块表上。

下面沿着一条请求的路径走一遍:块哈希怎么算、命中后怎么挂块、哪些信息可能不在键里,以及工程上怎么把这类错误命中挡在门外。

块哈希如何链式覆盖整段前缀

vLLM 的自动前缀缓存以块为单位工作。KV Cache 被切成固定大小的块,每个块容纳若干个 token 的注意力键值。只有被 token 填满的块才会进入缓存,半满的块不参与复用,因为它的内容还会随解码继续变化。

按 vLLM 设计文档的描述,每个块的哈希由三部分构成:父块的哈希、本块内 token 的元组,以及一组额外哈希值。父块哈希把前面的上下文折叠进当前键,块内 token 保证本块内容参与区分,额外哈希用来补充那些不体现在 token ID 里的信息。

这种结构形成一条链。第 1 个块的父哈希为空,键只由本块 token 决定;第 2 个块的键包含第 1 个块的哈希,于是间接覆盖了前两个块的 token;第 3 个块再往前折叠。任何位置的 token 发生变化,都会改变该位置所在块的哈希,并沿着链传到后面所有块。因此一个块的哈希实际上代表“从序列开头到本块结尾”这一整段前缀。

用公式表达更清楚。设第 i 个块的 token 元组为 (T_i),额外信息为 (E_i),则:

[ H_i = \text{hash}(H_{i-1},\ T_i,\ E_i),\quad H_0 = \text{hash}(\varnothing,\ T_0,\ E_0) ]

这里的 (E_i) 是工程上最关键的一项。它承担的任务是:把“token 序列相同但计算出的 KV 不同”的情况区分开。多模态输入就是一个例子。图像在 token 化后变成一串占位符 token,两张不同的图片可能产生完全相同的占位符序列。设计文档给出的做法是把前端图像处理器生成的图像哈希放进额外哈希,让占位符相同的块因为图片不同而得到不同的键。

命中之后,物理块怎样被挂到新请求上

调度器为新请求分配块时,先调用 get_computed_blocks(),对提示 token 逐块计算哈希并查询缓存表。缓存表是一个从块哈希到块 ID 的映射。命中的块会被“触碰”:引用计数加一,如果它原本在空闲队列里,就从队列中摘除,防止被驱逐。

随后 allocate_slots() 计算还缺多少块,从空闲队列头部弹出。若弹出的块本身是缓存块,先执行驱逐:从缓存表移除该块 ID,清空它的哈希。新分配的块在填满 token 后立即写入缓存表,这样同一批次里的其他请求也能复用。

请求结束时,引用计数归零的块按逆序回到空闲队列尾部。逆序的理由是最后一个块哈希覆盖的 token 最多,最不可能被别的请求复用,应该优先被淘汰。缓存满时按 LRU 从空闲队列头部驱逐。

整个过程里,缓存表只认哈希。它不知道这个哈希是谁算的、用什么权重算的、在哪个适配器下算的。只要键相等,块就被视为可复用。

flowchart TD
    A[新请求到达] --> B[按块切分提示 token]
    B --> C[逐块计算链式哈希]
    C --> D{缓存表中有相同键?}
    D -- 否 --> E[从空闲队列分配新块]
    E --> F[prefill 计算并填满块]
    F --> G[块写入缓存表]
    D -- 是 --> H[命中块引用计数加一]
    H --> I[挂入请求块表]
    I --> J[只对剩余 token 做 prefill]
    J --> K[解码输出]
    G --> K
    K --> L[请求结束释放块]
    L --> M[引用计数归零回到空闲队列]

图中的关键转折在 D 分支。命中判断只看键,不看键背后的计算条件。错误复用就发生在这里:键相等,但块里的 KV 值来自另一套计算条件。

哈希键漏掉身份信息时的错误复用

回到开头的场景。客服机器人和代码助手共享基座模型,各自挂一个 LoRA 适配器。如果两个请求的提示前缀恰好有相同的 token 序列,比如都以同一段系统提示开头,那么前几个块的 token 和父哈希完全一致。此时额外哈希里若没有 LoRA 身份,两个请求就会算出相同的键。

代码助手的请求命中了客服机器人留下的块。这些块里的 KV 是在客服 LoRA 的权重下算出来的。当前请求的块表指向这些物理块,prefill 跳过这段前缀,直接在其后继续计算。后续注意力读到的是一段与当前适配器不匹配的历史表示,输出开始带上另一个业务的风格。

设计文档明确把 LoRA ID 列为额外哈希的组成部分,说明官方实现已经意识到这个风险。工程上的问题在于,这类身份信息是否在所有路径上都被完整地传进了哈希计算。可以推断,凡是影响 KV 值的条件,只要有一条路径没有进入额外哈希,就存在错误复用的窗口。常见的候选包括:适配器版本在热更新后变化、同一模型的不同量化配置、不同张量并行度下重算的块、以及跨实例共享缓存时未对齐的模型标识。

这类问题的隐蔽性在于它不报错。请求正常返回,延迟甚至更低,只有输出质量悄悄偏移。

哈希碰撞与多租户下的键空间

第二类风险来自哈希函数本身。设计文档指出,上述键结构并非 100% 无冲突,理论上不同的前缀 token 仍可能得到相同的哈希值。默认使用 Python 内置哈希时,碰撞概率与键空间大小相关;在多租户环境里,恶意构造的输入可能主动寻找碰撞。

碰撞的后果是错误命中:两个不同前缀共享同一个键,后到的请求拿到前者的块。文档给出的缓解手段是改用 SHA256 作为哈希函数,自 vLLM v0.8.3 起支持,需要通过命令行参数显式启用。代价是每个 token 约 100~200 ns 的额外开销,50k token 的上下文大约多花 6 ms。这个量级对长上下文 prefill 来说通常可以接受,对极短请求的高 QPS 场景则需要单独评估。

需要区分两类问题。身份信息缺失是键的设计问题,换哈希函数解决不了;碰撞是哈希函数的性质问题,SHA256 只能把概率压到可忽略,不能证明为零。生产上对这两类问题要用不同的手段。

三种方案的质量与成本对比

面对错误复用,工程上有三条路:全量重算、按请求或租户隔离缓存、以及保留共享缓存但加强键与校验。下表按质量、延迟、显存和实现复杂度做定性比较。

方案输出正确性prefill 延迟显存占用实现复杂度适用场景
全量重算(关闭前缀缓存)最高,无复用风险每次都要算完整前缀只保留运行中请求的块最低正确性优先、前缀复用率低的场景
按请求或租户隔离高,键空间天然不交叉同租户内仍可命中随租户数增长,重复前缀各存一份中等,需要缓存分区与生命周期管理多租户、适配器差异大、合规要求隔离
共享缓存加身份键与校验取决于键覆盖是否完整跨租户也能命中最省,前缀只存一份最高,需要维护键的完整性前缀高度重复、租户间模型配置一致

隔离方案的本质是把“键是否足够唯一”这个问题变成“键空间是否分区”。每个租户或每个适配器一份缓存表,跨分区的键不会相遇,错误复用被结构性地排除。代价是相同前缀在不同分区各存一份 KV,显存利用率下降。租户越多、前缀越相似,浪费越明显。

共享缓存加身份键的方案把正确性押在键的完整性上。它最省显存,也最依赖实现细节。一旦某条路径漏传了身份信息,问题就会以静默的方式出现。

校验点与可观测指标

要在生产里发现这类问题,先要让它留下痕迹。

第一类校验点在键的构造处。检查额外哈希是否覆盖了所有影响 KV 的条件:模型标识与权重版本、适配器 ID 与版本、量化配置、多模态输入哈希、张量并行与流水并行的拓扑。任何一项在部署中可变,就必须进入键,或者在变更时清空缓存。

第二类校验点在命中路径上。可以在调试模式下对命中的块做抽样校验:用当前请求的计算条件重算一小段前缀的 KV,与缓存块比对。全量比对成本太高,抽样足以发现系统性的键缺失。

第三类校验点在输出层。对同一提示在不同适配器下分别请求,比较输出分布。如果两个本应不同的适配器给出高度相似的输出,且缓存命中率异常高,就值得怀疑。

可观测指标方面,vLLM 暴露了前缀缓存相关的统计,包括查询块数、命中块数和命中率。工程上应把命中率按租户、适配器和模型版本拆开看。全局命中率上升不一定是好事,如果它来自跨租户的错误命中,指标会先于人工发现异常。同时关注 prefill 耗时分布:错误命中会让某些请求的 prefill 异常地短,形成双峰分布。

日志里应记录每次命中的块哈希前缀和对应的身份字段,便于事后回溯。块被驱逐时的哈希清空也要记录,避免把“已驱逐”误读为“从未存在”。

失效边界与仍未解决的问题

前缀缓存的收益依赖前缀重复。短请求、前缀差异大的场景,命中率本来就低,加强键与校验的复杂度可能不划算。反过来,长系统提示、多轮对话、RAG 固定模板这类前缀高度重复的场景,收益最大,也最值得投入校验。

隔离方案在租户数量可控时最稳。租户数量大、前缀又高度相似时,显存浪费会抵消缓存收益,此时共享缓存加完整身份键是更现实的选择,前提是键的完整性有测试保障。

尚未解决的问题有几个。跨实例共享 KV 块时,模型标识和适配器版本如何在键里稳定表达,目前没有统一约定。适配器热更新后,旧块是全部作废还是按版本号区分,取决于实现是否把版本纳入键。哈希碰撞在理论上无法消除,SHA256 只是把概率压低,多租户下的对抗性输入仍需额外的准入控制。

最实际的一步是先确认当前部署的键里到底有什么。把额外哈希的构造路径读一遍,对照部署中所有可变的计算条件,缺什么补什么。补不进去的,就在变更时清空缓存。

资料来源

  1. vLLM Automatic Prefix Caching 设计文档
  2. vLLM 源码:KV cache manager 与前缀缓存块哈希
  3. vLLM 源码:block_pool 前缀缓存实现
  4. 自动前缀缓存 | vLLM 中文站