Skip to content

约 7 分钟阅读

10. 前缀缓存与 RadixAttention ​

资料助手的每个请求都以同样的内容开头:一段系统提示,加上几千个 token 的员工制度,最后才是各人不同的问题。前面几篇的做法是,每个请求都把这几千个 token 从头 Prefill 一遍。这一篇讨论怎样把算过的前缀留下来,让后来的请求直接接着用。读完你应该能说清前缀缓存命中的条件,理解 vLLM 的哈希链和 SGLang 的基数树两种实现,并知道怎样写提示词才更容易命中。

大家都在重复读同一份制度 ​

设系统提示加制度共 3000 个 token,用户的问题平均 30 个 token。没有前缀缓存时,100 个人提问,Prefill 一共要处理约 30.3 万个 token,其中 29.7 万个是在重复计算同一段开头。

KV Cache 本来只在一个请求内部复用。第 3 篇说过,一个位置的 K、V 只取决于它和它前面的内容。所以只要两个请求开头的 token 完全相同,这部分的 K、V 也完全相同,可以只算一次,大家共用。

多个请求共享系统提示与制度的 KV,只为各自不同的问题计算新的 KV

这就是前缀缓存(prefix caching):请求结束后,不急着丢掉它的 KV,而是留在显存里;新请求进来时,先查一查开头有多少 token 已经算过,命中的部分直接引用,只为剩下的部分做 Prefill。

在资料助手的例子里,第一个请求处理 3030 个 token;之后每个请求只需处理约 30 个新 token,Prefill 的计算量减少了约 99%,TTFT 也随之大幅下降。

命中的条件:从头开始逐 token 相同 ​

前缀缓存有一个严格的条件:必须从第一个 token 开始,逐个 token 完全相同。

  • 只有末尾不同:前面相同的部分都能命中。
  • 开头改了一个字:后面即使内容完全一样,也一个都命中不了,因为每个位置的 K、V 都依赖它前面的全部内容。
  • 文字看起来一样,但 token 不同:比如多了一个空格、换了一种换行符,分词结果就可能不同,同样无法命中。

下面把输入写成 token 编号,先改末尾,再改开头,看命中范围怎样变化:

找到可复用的前缀显式 token ID 演示,最多显示 40 个
已缓存ABCDEFGHIJKL
新请求ABCDEFGXIJKL

相同前缀 7 token → 完整页可复用 4 → 至少重算 8

绿色是可复用页,黄色是相同但未凑成完整页的位置。首次不同时,后面即使再次相同也不能直接续用。此处假设模型、LoRA、位置及其他状态标识全部兼容;两个引擎的实际粒度还受模型与后端限制。

由此可以得出写提示词的一条实用规则:稳定的内容放前面,变化的内容放后面。 系统提示、工具说明、参考资料放在最前;用户的问题、当前时间、请求编号放在最后。一个常见的反例是在系统提示开头写“当前时间:2026-10-26 10:31:05”,每秒都在变,整个缓存永远命中不了。

多轮对话天然适合前缀缓存:第二轮的输入是“第一轮的全部内容 + 新问题”,第一轮已经算过的部分直接命中。Agent 反复调用工具、每次带着越来越长的历史,也是同样的道理。不少模型 API 对命中缓存的输入 token 收取更低的费用,原因就在这里。

vLLM 的做法:给每个块算一个哈希 ​

vLLM 的前缀缓存(Automatic Prefix Caching)建立在 PagedAttention 的块上。每个写满的块都会算一个哈希值,作为这个块内容的“身份证”:

text
块的哈希 = hash(前一个块的哈希, 本块的 token 编号, 额外标识)

关键在于哈希里包含了前一个块的哈希。这样一个块的哈希相同,就意味着从第一个 token 到这个块结尾的全部内容都相同,而不只是这 16 个 token 相同。“额外标识”用来区分 token 相同但状态不同的情况,比如使用了不同的 LoRA 适配器(在原模型上叠加的一小组微调参数),或者输入里有不同的图片。

块哈希链:每个块的哈希包含前一个块的哈希,任一块不同,其后所有块的哈希都不同

新请求进来时,引擎从第一个块开始,逐块计算哈希,到全局的“哈希 → 物理块”表里查找,直到第一个查不到的块为止。查到的块直接加入这个请求的块表,引用计数加一。

nano-vllm 的实现只有几行,用的是 xxhash:

python
@classmethod
def compute_hash(cls, token_ids: list[int], prefix: int = -1):
    h = xxhash.xxh64()
    if prefix != -1:
        h.update(prefix.to_bytes(8, "little"))
    h.update(np.array(token_ids).tobytes())
    return h.intdigest()

有几个细节值得注意:

  • 只有写满的块才能命中。 最后一个没写满的块没有哈希。块大小为 16 时,命中长度会向下取整到 16 的倍数。
  • 至少要留一些 token 真正计算。 模型需要对最后一个位置做前向计算,才能输出第一个 token 的概率。即使整个输入都命中了,最后一部分也要重新算。
  • 请求结束后,块不会立刻清空。 引用计数归零的块回到空闲列表,但保留着哈希和内容。只要还没被别的请求覆盖,新请求仍然可以命中它。空闲块不够用时,按“最久没被使用”(LRU)的顺序回收。

SGLang 的做法:一棵前缀树 ​

SGLang 的 RadixAttention 用另一种数据结构组织缓存:基数树(radix tree,也叫压缩前缀树)。

把所有缓存过的 token 序列放进一棵树里:从根节点出发,每条边上是一段 token,共同的开头只存一份,从分叉的地方长出不同的分支。每个节点记录这段 token 对应的 KV 存在哪里。

三个请求在前缀树中共享开头,在不同位置分叉

新请求进来时,沿着树往下走,找到与它最长匹配的路径,匹配到的部分直接复用。请求结束后,它新生成的部分作为新的分支插入树中。如果新请求在某条边的中间分叉,就把这条边拆成两段。

回收时,从叶子节点开始,按最近最少使用的顺序删除。正在被某个请求使用的节点会被锁住(引用计数大于零),不能回收;还有子节点的父节点也不会先于子节点被回收。

两种实现的效果接近,各有侧重:

vLLM:块哈希SGLang:基数树
匹配粒度完整的块可以细到单个 token(实际受页大小限制)
查找方式逐块算哈希、查哈希表沿树向下匹配
组织方式平铺的哈希表,靠哈希链隐含前缀关系显式的树,前缀关系一目了然
配套调度先来先服务,可设优先级默认先来先服务,可选“最长前缀匹配优先”

前缀缓存省了什么,没省什么 ​

前缀缓存省掉的是命中部分的 Prefill,所以主要改善 TTFT 和 Prefill 的计算量。它不改变 Decode:每一步仍要读完整的历史 KV,出字速度不会因为命中缓存而变快。

它也有代价:

  • 占用显存。 留着的缓存块和正在运行的请求争夺同一个块池。缓存留得多,能同时运行的请求就可能少一些。好在空闲的缓存块可以随时回收,活跃请求优先。
  • 多实例时命中率会下降。 每个推理实例只有自己的缓存。同样的请求被负载均衡随机分到 8 台机器上,每台都要先冷启动一次。解决办法是缓存感知路由:网关尽量把前缀相同的请求发往同一个实例。第 21 篇再展开。
  • 命中率要看对口径。 请求命中率(有多少请求命中了一点)意义不大,要看 token 命中率:输入 token 中有多少比例是从缓存读的,以及因此节省了多少 Prefill 时间。

还有一个安全上的考虑:多个租户共享同一个推理服务时,命中缓存的请求返回得更快,理论上可以被用来探测别人是否发送过某段内容。需要隔离的场景,可以给不同租户的请求加上不同的“盐”(vLLM 的 cache_salt 参数),让它们的哈希不可能相同。

显存放不下时:分层缓存 ​

GPU 显存有限,热门前缀一多就会互相挤占。一个自然的延伸是把缓存分层:显存放不下的 KV 块,先移到 CPU 内存,再不够就放到本地磁盘或远端存储,需要时再加载回来。SGLang 的 HiCache、vLLM 配合 LMCache 等组件,都在做这件事。

分层缓存的前提是:从下层加载回来,比重新 Prefill 更快。 以 8192 个 token 的前缀为例,它的 KV 是 1 GiB。重新 Prefill 要约 131 万亿次运算,在 H100 上下界约 130 毫秒;从 CPU 内存经 PCIe 5.0 x16(单向理论带宽约 64 GB/s)搬回来,下界约 17 毫秒,明显更划算。换成每秒只有几 GB 的网络存储,加载就可能比重算还慢。是否划算,取决于前缀的长度、存储层的实际带宽和当时 GPU 的忙闲。第 17 篇讨论 KV 在机器之间传输时,还会再算这笔账。

前缀缓存利用了 KV 只依赖前文的性质:开头逐 token 相同的请求,可以共用已经算好的 KV,只为不同的部分做 Prefill。vLLM 用块哈希链实现,SGLang 用基数树实现;两者都只省 Prefill,不省 Decode,并且要和活跃请求争夺显存。写提示词时,把稳定的内容放在前面。

命中缓存能让一部分请求的 Prefill 变短,但总会有不命中的长输入。一个几万 token 的 Prefill 会占用 GPU 好几百毫秒,期间其他用户的回答全部停住。下一篇讲怎样把长输入切开:分块预填充。

别急,先让缓存热一下。