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 也完全相同,可以只算一次,大家共用。
这就是前缀缓存(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 好几百毫秒,期间其他用户的回答全部停住。下一篇讲怎样把长输入切开:分块预填充。
