Prefix caching:相同的开头,只算一次
系统提示词反复出现,它的 KV 也可以反复使用。
按 → 推进 · 动画可播放、拖动和倒回
2026-10-20 · 图中的容量、时长与 token 均为教学示意
开头一样,为什么每次都从头算?
很多请求带着相同的系统提示词、工具说明或文档开头。普通 prefill 会把它们反复算一遍。
第一次请求先正常计算这段前缀,留下各层的 K、V。
后续请求找到同一段已计算前缀后,可以直接接上它的 KV,只处理后面的新内容。
图中文字只表示提示词片段;匹配发生在分词后的 token 和模型执行上下文上。
中间改了一个 token,后面也会跟着变
这里先逐 token 看匹配。缓存是 A B C D E F,新请求是 A B X D E F。
从开头连续匹配,A、B 一样,走到第三个 token 就停。
D、E、F 虽然字面相同,它们看到的历史已从 C 变成 X,对应的 KV 也会改变。
标准因果 attention 中,某个 token 的表示依赖它之前的整个前缀;这里的逐 token 匹配用于解释原理。
复用成立的条件,是同一个计算状态
相同文本只是起点。缓存池必须绑定模型版本、数值格式和位置/attention 配置;请求身份还可能包括 LoRA、图像内容与隔离域。
通常放进池的 namespace
权重修订版本、KV dtype/布局、位置编码配置、tokenizer 与模板版本。
通常随请求进入 hash
实际 token IDs、adapter 身份、多模态内容摘要、cache salt。
字段可以由池边界固定,也可以进入 key;关键是不同计算状态不能落到同一可复用身份。
命中 H 个 token,省掉的是哪些张量?
普通 GQA:每层前缀 K、V 各为 [H, Hkv, d]。对于后缀 query,仍需把命中前缀与新后缀的 K、V 一起读取。
对前缀的投影、attention、MLP 不再重算;后缀仍经过全部层。因果掩码必须保留原来的绝对位置,不能把后缀重新编号为从 0 开始。
共享前缀也共享物理 KV 块,活跃请求持有引用;执行 attention 时按各自的 block table 访问。
用规范序列化,把“前面是谁”写进 key
parent = sha256(serialize(namespace, salt))
for block in complete_token_blocks:
payload = serialize(parent, block.token_ids, extras)
key = sha256(payload)
parent = key
yield key不要直接拼接字符串:("ab", "c") 与 ("a", "bc") 会变成相同字节串。带类型和长度的稳定序列化能避免这种歧义。
SHA-256 降低碰撞风险,但字段遗漏、错版本、缓存越权都不是换 hash 算法能修复的。
用一条哈希链,记住“这个块之前是什么”
实际缓存常按满块管理。每块的键不仅包括本块 token,还包括它前面的前缀。
第一块从起点算出 h0。第二块用 h0 和 EFGH 算出 h1,把此前的历史接进来。
这样,相同的 EFGH 只要前文不同,缓存键就不同。其他执行条件也必须一致,才能共享结果。
H 表示哈希;vLLM 还把 LoRA、图像内容标识、cache salt 等必要条件纳入键。模型实例定义复用边界。
共同开头长 5,块大小 4,为什么只省 4?
按从 0 开始的位置编号,两条 13-token prompt 在位置 5 首次不同。共有位置 0–4,但完整相同块只有 [0,1,2,3]。
第二块即使前三个 token 相同,块身份仍不同。后面 token 值即使完全一样,parent hash 已改变,也不能直接复用后续块。
一个 token 改动,沿哈希链把后面一起改掉
继续向后找“局部相同块”会破坏因果语义。查找的是从起点连续命中的前缀,遇到第一个 miss 就停止。
整段 prompt 都在缓存里,首字 logits 在哪?
KV 保存了注意力需要的历史,并不自动保存最后一层 hidden state 或下一 token 的 logits。
若接口只按完整块复用,N=12、B=4 时,可把前 8 个 token 直接接上,再处理最后 4 个。N=13 时可复用 12 个,再处理最后 1 个。
支持更细粒度边界,或另外缓存最终状态的实现,可以有不同处理方式。评估“100% 命中”时先看它统计的是已有块还是实际跳过的计算位置。
命中以后,先保护块,再把地址交给请求
hits = longest_contiguous_cached_prefix(keys)
needed = blocks_for_new_suffix(request, hits)
if not enough_reclaimable_blocks(needed):
wait_or_preempt()
else:
pin(hits) # 引用 +1,从空闲队列取走
tail = allocate(needed)
request.block_table = hits + tail
schedule_uncached_suffix(request)生产实现需要把“检查、pin、分配”协调起来,防止刚命中的块被另一条请求驱逐。CPU 返回一个 hash 命中还不等于 GPU 上的 KV 已可用。
直接接上 KV,只计算没见过的后缀
块大小为 4,缓存中已有前 8 个 token 的计算结果。新请求总共 10 个 token。
前两块命中:新请求的 block table 直接引用 P5、P2,不需要再跑这 8 个 token 的 prefill。
GPU 计算最后 2 个 token,同时读取已有前缀的 KV。省的是前缀计算,后缀依然需要理解整个历史。
满块缓存示意;为了得到首个输出的 logits,提示词全命中时,引擎仍可能保留最后一个 token 做计算。
两条请求,读同一份 KV
A 正在使用 P5,引用计数是 1。已计算的前缀只读,可以安全地被多条请求共用。
B 命中 P5,让自己的 block table 也指向它。引用计数变成 2,显存中的 KV 仍只有一份。
A 结束后计数减回 1。因为 B 还在用,P5 必须保留。
请求生成的不同后缀各用自己的块;共享前缀保持只读。
三条相同长前缀请求,真正省了多少显存?
每 token KV=128 KiB,块大小 16。三条请求各有 1,024-token 公共前缀与 128-token 私有后缀;所有长度都恰好对齐块。
省 2,048 × 128 KiB = 256 MiB。物理 KV 从 432 MiB 降至 176 MiB;每个请求自己的逻辑长度仍是 1,152。
共享比例算的是物理块,不能只把各请求 block table 的长度加起来。表中多个条目可能指向同一物理块。
缓存隔离的边界,要和服务的信任边界一致
同一公共文档可在授权用户组内共享;不同私有租户使用不同 namespace 或由服务端管理的 salt。
为什么需要域
命中会改变延迟。跨租户共享不仅有地址权限问题,还可能让另一方推测某段前缀是否被使用过。
域里放什么
模型版本 + 租户/信任组身份;salt 稳定到足以复用,又不能让未授权客户端随意冒用。
图像占位 token 相同,不代表图像相同;多模态缓存必须包含真实输入内容或其稳定摘要。LoRA 变化也必须失效。
没人使用,可以先留着,也可以被替换
当所有请求都结束,P5 的引用计数归零。它已经可以回收,但缓存内容还可以暂时留在显存中。
如果另一个请求及时命中,就重新使用它;如果新请求需要空闲块,分配器可以选中这个闲置块。
驱逐时先移除旧缓存键,再覆盖 P5。旧前缀下次到来会 miss,重新 prefill 后再建立缓存。
vLLM 通过空闲队列管理可回收块;仍被活跃请求引用的块不能被直接覆盖。
refcount 为 0,说明可驱逐,不说明必须立刻擦掉
| 块 | 活跃引用 | 状态 | 当前能否回收 |
|---|---|---|---|
| P1 | 2 | 两个请求正在读 | 不能 |
| P2 | 0 | 保留 hash 与旧 KV | 能,先删除旧身份 |
| P3 | 0 | 无缓存内容 | 能直接分配 |
若需要两块,先使用 P3,再驱逐 P2。用 P2 写新数据前,必须从旧 hash→块映射中移除它,否则下一次“命中”会读到别人的内容。
LRU 一类策略只在可回收块集合上选择。活跃请求正在读的块,哪怕很久没被新请求命中,也不能直接覆盖。
后面的块还在,为什么也可能省不了计算?
请求有块链 A→B→C。A 被驱逐了,B 与 C 还在池中。新请求不能从 B 开始跳过前缀:计算前面的 query 时,缺少所需的前序 KV。
重算 A 以后,系统可以重新评估后续复用;简单的入场查找通常在第一个 miss 就停止。本实验采用这个明确的规则。
驱逐策略可考虑共同前缀的覆盖价值。大量只命中冷门长后缀的块,可能不如一个大家都使用的短公共前缀有用。
稳定的内容放前面,可变的内容放后面
相同 token 的前缀越长,能复用的计算越多。把时间戳、随机标识放在开头,会让后面的公共内容也失去匹配。
典型命中
重复系统提示词;同一长文档上的多轮提问。
典型 miss
前缀 token 改变;内容已被驱逐;模型或其他必要上下文不同。
省 76.8% 输入 token,TTFT 不会自动快 4.3 倍
教学模型:N=4,000,命中 H=3,072;prefill 每 token 成本 0.05 ms,排队、tokenization、网络等合计固定 55 ms。
prefill token 减少 76.8%,TTFT 只缩短 60.24%,加速比约 2.515×。固定开销越大,省掉计算对首字延迟的影响越小。
这是线性近似;真实 prefill 成本与长度、batch、attention 类型、硬件有关。用模型解释趋势,再用实测替换系数。
把固定开销和可省的计算分开看
如果队列已经堆积,缓存还可能通过减少总工作量降低其他请求的排队;这个系统效应要在同样到达率下测。
后缀仍要读前缀:attention 工作量这样数
N=4,096,H=3,072:完整 8,390,656 对;剩余 3,670,528 对。跳过 75% 的 query token,只省约 56.25% 的 query-key 对。
后面的 query 更长,每一个都要看之前的前缀。投影/MLP 的成本更接近按跳过的 token 数下降,整体收益要按各算子占比加权。
命中率高,可能只是命中了很多很短的请求
| 指标 | 定义 | 能回答什么 |
|---|---|---|
| 请求命中率 | 有任意命中的请求 / 请求总数 | 有多少请求受益 |
| token 命中率 | 跳过 token 总数 / 输入 token 总数 | 省掉多少输入位置 |
| TTFT 分布 | 按输入长度和命中长度分组 | 用户实际快了多少 |
| 重用距离 / 驱逐量 | 两次使用间隔 / 回收块数 | 工作集是否超过池容量 |
100 条 10-token 请求全命中,加上一条 10,000-token 请求全 miss:请求命中率约 99%,token 命中率仅约 9.1%。
看起来相同的 prompt,为什么一次也没命中?
| 教学观测 | 检查方向 |
|---|---|
| 每次位置 2 的 token ID 都变 | 时间戳 / request ID 放在共同前缀前面 |
| token IDs 一致,adapter 字段不同 | 计算身份不同,miss 是正确行为 |
| 刚结束时能命中,过一会儿全部 miss | 工作集过大或池压力导致驱逐 |
| token 命中率高,TTFT 变化小 | 排队 / CPU / 网络占主导,或统计边界错 |
打印首个不同 token 的位置与块边界,核对 namespace,再看驱逐日志。不要先把 key 里的字段删掉来追求命中率。
更快开始回答,后面的生成仍要继续
省 prefill
跳过已计算前缀,减少到首个输出 token 的等待。共享块还可省重复的 KV 空间。
仍然需要 decode
每个新 token 仍要做模型计算、读历史 KV。缓存前缀不等于缓存整段答案。
Continuous batching 管“这一轮算谁”
PagedAttention 管“KV 放在哪里” · Prefix caching 管“哪段已经算过”
完整前缀长 35,块大小 16,能省多少?
展开参考解答
命中 H=16×floor(35/16)=32;重算 80−32=48。
token 命中率=32/80=40%。
日期放在最前面且每次变化,会使第一个块和后续 hash 链全部变化,H=0。稳定系统说明放前面、变化字段后置,前提是保持应用语义。
缓存值得多占 100 MiB 吗?
展开参考解答
公共前缀占 50×2=100 MiB。热 prefill=50−40=10 ms;热 TTFT=60+10=70 ms。
冷 TTFT=110 ms,加速比=110/70≈1.571×。20 次合计省 800 ms prefill 计算。
是否保留还要比较被挤走请求的收益与命中概率;800 ms 不是保证能直接兑现的墙钟缩短。
同样的图像占位 token,能不能共用 KV?
展开参考解答
两组都可能错误命中:图像 embedding 与模型 adapter 都影响 KV。把图像内容摘要、adapter 身份纳入 key 或独立 namespace。
测试:保持文本 token 不变,只换图像 / adapter,应该 miss;完全相同输入和身份则 hit;更换租户隔离域也应 miss。再与禁用缓存的输出做数值对照。
改变一个位置,观察整条 hash 链
下载代码后,在保存代码的目录运行:
python3 lab.py python3 lab.py --change-position 9 python3 lab.py --block-size 8
默认改位置 5:reused=4,recompute=9。改位置 9:reused=8,recompute=5。B=8、改位置5:reused=0。
先预测每种情况的命中边界。代码测试跨租户和跨模型失效、前缀缺块停止、整段命中时的末位置处理。再实现有限容量 LRU,重放重复与独有文档混合的轨迹,比较请求命中率和 token 命中率。
下载 lab.py · Python 3 标准库缓存的是计算过的历史
- vLLM:Automatic Prefix Caching:满块哈希、引用计数、空闲队列与驱逐流程。
- vLLM:Automatic Prefix Caching 的使用与场景:长文档问答、多轮对话与 prefill 收益。
- PagedAttention 论文:块级 KV 共享的基础。