LLM 推理系统 · 复用

Prefix caching:相同的开头,只算一次

系统提示词反复出现,它的 KV 也可以反复使用。

按 → 推进 · 动画可播放、拖动和倒回

2026-10-20 · 图中的容量、时长与 token 均为教学示意

01 · 重复工作

开头一样,为什么每次都从头算?

三个问题,共用同一段系统提示词 你是助教,请用直观例子回答…… 问题 A 你是助教,请用直观例子回答…… 问题 B 你是助教,请用直观例子回答…… 问题 C 算 复用 复用

很多请求带着相同的系统提示词、工具说明或文档开头。普通 prefill 会把它们反复算一遍。

第一次请求先正常计算这段前缀,留下各层的 K、V。

后续请求找到同一段已计算前缀后,可以直接接上它的 KV,只处理后面的新内容。

图中文字只表示提示词片段;匹配发生在分词后的 token 和模型执行上下文上。

02 · 精确前缀

中间改了一个 token,后面也会跟着变

已缓存的 token A B C D E F 新请求的 token A B X D E F 可复用:A B 从 X 开始,后面的 KV 都重新计算

这里先逐 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 匹配用于解释原理。

工程细节

复用成立的条件,是同一个计算状态

KV(prefix) = f(权重, adapter, 输入, 位置, attention 规则)

相同文本只是起点。缓存池必须绑定模型版本、数值格式和位置/attention 配置;请求身份还可能包括 LoRA、图像内容与隔离域。

通常放进池的 namespace
权重修订版本、KV dtype/布局、位置编码配置、tokenizer 与模板版本。

通常随请求进入 hash
实际 token IDs、adapter 身份、多模态内容摘要、cache salt。

字段可以由池边界固定,也可以进入 key;关键是不同计算状态不能落到同一可复用身份。

工程细节

命中 H 个 token,省掉的是哪些张量?

普通 GQA:每层前缀 K、V 各为 [H, Hkv, d]。对于后缀 query,仍需把命中前缀与新后缀的 K、V 一起读取。

后缀 Q:[N−H, Hq, d];可见 KV:[N, Hkv, d]

对前缀的投影、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 算法能修复的。

03 · 查找

用一条哈希链,记住“这个块之前是什么”

按块建立键:每块 4 个 token A B C D E F G H I J K L h0 = H(起点, ABCD) h1 = H(h0, EFGH) h2 = H(h1, IJKL)

实际缓存常按满块管理。每块的键不仅包括本块 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 数 H = B × floor(C / B)
B = 4,C = 5 ⇒ H = 4;需重算 N−H = 9

第二块即使前三个 token 相同,块身份仍不同。后面 token 值即使完全一样,parent hash 已改变,也不能直接复用后续块。

计算动画 · 满块边界与依赖

一个 token 改动,沿哈希链把后面一起改掉

位置 0–3:相同位置 4–7:含改动位置 8–11:值相同h₀ 保持不变h₁ → h₁′h₂ → h₂′复用 token:0重算 token:13

继续向后找“局部相同块”会破坏因果语义。查找的是从起点连续命中的前缀,遇到第一个 miss 就停止。

工程细节

整段 prompt 都在缓存里,首字 logits 在哪?

KV 保存了注意力需要的历史,并不自动保存最后一层 hidden state 或下一 token 的 logits。

仅缓存 KV 的接口:至少保留一个输入位置重算

若接口只按完整块复用,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 已可用。

04 · 命中

直接接上 KV,只计算没见过的后缀

新请求:10 个 token;前 8 个已经缓存 0–3 4–7 8–9 已有 KV:P5、P2 GPU prefill 8 命中 2 待计算

块大小为 4,缓存中已有前 8 个 token 的计算结果。新请求总共 10 个 token。

前两块命中:新请求的 block table 直接引用 P5、P2,不需要再跑这 8 个 token 的 prefill。

GPU 计算最后 2 个 token,同时读取已有前缀的 KV。省的是前缀计算,后缀依然需要理解整个历史。

满块缓存示意;为了得到首个输出的 logits,提示词全命中时,引擎仍可能保留最后一个 token 做计算。

05 · 共享

两条请求,读同一份 KV

请求 A → P5 请求 B → P5 共享的前缀 KV 1 引用计数 A 结束,B 仍在读取这块

A 正在使用 P5,引用计数是 1。已计算的前缀只读,可以安全地被多条请求共用。

B 命中 P5,让自己的 block table 也指向它。引用计数变成 2,显存中的 KV 仍只有一份。

A 结束后计数减回 1。因为 B 还在用,P5 必须保留。

请求生成的不同后缀各用自己的块;共享前缀保持只读。

工程细节

三条相同长前缀请求,真正省了多少显存?

每 token KV=128 KiB,块大小 16。三条请求各有 1,024-token 公共前缀与 128-token 私有后缀;所有长度都恰好对齐块。

不共享:3 × (1,024+128) = 3,456 个物理位置
共享:1,024 + 3 × 128 = 1,408 个物理位置

省 2,048 × 128 KiB = 256 MiB。物理 KV 从 432 MiB 降至 176 MiB;每个请求自己的逻辑长度仍是 1,152。

共享比例算的是物理块,不能只把各请求 block table 的长度加起来。表中多个条目可能指向同一物理块。

工程细节

缓存隔离的边界,要和服务的信任边界一致

同一公共文档可在授权用户组内共享;不同私有租户使用不同 namespace 或由服务端管理的 salt。

为什么需要域
命中会改变延迟。跨租户共享不仅有地址权限问题,还可能让另一方推测某段前缀是否被使用过。

域里放什么
模型版本 + 租户/信任组身份;salt 稳定到足以复用,又不能让未授权客户端随意冒用。

图像占位 token 相同,不代表图像相同;多模态缓存必须包含真实输入内容或其稳定摘要。LoRA 变化也必须失效。

06 · 生命周期

没人使用,可以先留着,也可以被替换

A、B 都已结束 P5:缓存内容仍在 refcount = 0 refcount = 1 新请求 C P5:改存 C 的 KV 旧缓存键已移除,下次找它就是 miss

当所有请求都结束,P5 的引用计数归零。它已经可以回收,但缓存内容还可以暂时留在显存中。

如果另一个请求及时命中,就重新使用它;如果新请求需要空闲块,分配器可以选中这个闲置块。

驱逐时先移除旧缓存键,再覆盖 P5。旧前缀下次到来会 miss,重新 prefill 后再建立缓存。

vLLM 通过空闲队列管理可回收块;仍被活跃请求引用的块不能被直接覆盖。

工程细节

refcount 为 0,说明可驱逐,不说明必须立刻擦掉

块活跃引用状态当前能否回收
P12两个请求正在读不能
P20保留 hash 与旧 KV能,先删除旧身份
P30无缓存内容能直接分配

若需要两块,先使用 P3,再驱逐 P2。用 P2 写新数据前,必须从旧 hash→块映射中移除它,否则下一次“命中”会读到别人的内容。

LRU 一类策略只在可回收块集合上选择。活跃请求正在读的块,哪怕很久没被新请求命中,也不能直接覆盖。

工程细节

后面的块还在,为什么也可能省不了计算?

请求有块链 A→B→C。A 被驱逐了,B 与 C 还在池中。新请求不能从 B 开始跳过前缀:计算前面的 query 时,缺少所需的前序 KV。

可跳过长度 = 从起点连续命中的块数 × B

重算 A 以后,系统可以重新评估后续复用;简单的入场查找通常在第一个 miss 就停止。本实验采用这个明确的规则。

驱逐策略可考虑共同前缀的覆盖价值。大量只命中冷门长后缀的块,可能不如一个大家都使用的短公共前缀有用。

07 · 实际提示词

稳定的内容放前面,可变的内容放后面

系统提示词 → 固定工具 / 文档 → 用户问题

相同 token 的前缀越长,能复用的计算越多。把时间戳、随机标识放在开头,会让后面的公共内容也失去匹配。

典型命中

重复系统提示词;同一长文档上的多轮提问。

典型 miss

前缀 token 改变;内容已被驱逐;模型或其他必要上下文不同。

工程细节

省 76.8% 输入 token,TTFT 不会自动快 4.3 倍

教学模型:N=4,000,命中 H=3,072;prefill 每 token 成本 0.05 ms,排队、tokenization、网络等合计固定 55 ms。

冷:55 + 4,000 × 0.05 = 255 ms
热:55 + 928 × 0.05 = 101.4 ms

prefill token 减少 76.8%,TTFT 只缩短 60.24%,加速比约 2.515×。固定开销越大,省掉计算对首字延迟的影响越小。

这是线性近似;真实 prefill 成本与长度、batch、attention 类型、硬件有关。用模型解释趋势,再用实测替换系数。

计算动画 · 省 token 到省时间

把固定开销和可省的计算分开看

冷请求:55 ms 固定 + 200 ms prefill0热请求:55 ms 固定 + 46.4 ms 后缀 prefill0同样的固定等待仍然存在;缓存减少的是 prefill 部分。

如果队列已经堆积,缓存还可能通过减少总工作量降低其他请求的排队;这个系统效应要在同样到达率下测。

工程细节

后缀仍要读前缀:attention 工作量这样数

每个 query 头:完整因果 attention 对数 = N(N+1)/2
命中 H 后剩余 = [N(N+1) − H(H+1)] / 2

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 里的字段删掉来追求命中率。

08 · 省在哪里

更快开始回答,后面的生成仍要继续

省 prefill

跳过已计算前缀,减少到首个输出 token 的等待。共享块还可省重复的 KV 空间。

仍然需要 decode

每个新 token 仍要做模型计算、读历史 KV。缓存前缀不等于缓存整段答案。

练习 1 · 先计算,再展开

完整前缀长 35,块大小 16,能省多少?

两条 prompt 都长 80;从 0 开始的第 35 个位置首次不同,也就是共同前缀有 35 个 token。求满块命中、重算长度、token 命中率;若在位置 0 插入日期,会怎样?
展开参考解答

命中 H=16×floor(35/16)=32;重算 80−32=48。
token 命中率=32/80=40%。
日期放在最前面且每次变化,会使第一个块和后续 hash 链全部变化,H=0。稳定系统说明放前面、变化字段后置,前提是保持应用语义。

练习 2 · 先计算,再展开

缓存值得多占 100 MiB 吗?

每块 2 MiB。一个公共前缀占 50 块,未来 20 次请求都能复用它,每次省 40 ms prefill。若固定开销 60 ms,冷 prefill 50 ms,求热 TTFT、单请求加速比与总省下的计算时间。
展开参考解答

公共前缀占 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 不是保证能直接兑现的墙钟缩短。

练习 3 · 先计算,再展开

同样的图像占位 token,能不能共用 KV?

两条请求 token IDs 完全相同,但图片内容不同;另一对请求图片相同、LoRA adapter 不同。程序只 hash token IDs 和 parent hash。指出问题并设计测试。
展开参考解答

两组都可能错误命中:图像 embedding 与模型 adapter 都影响 KV。把图像内容摘要、adapter 身份纳入 key 或独立 namespace。
测试:保持文本 token 不变,只换图像 / adapter,应该 miss;完全相同输入和身份则 hit;更换租户隔离域也应 miss。再与禁用缓存的输出做数值对照。

动手实验 · CPU 教学模拟

改变一个位置,观察整条 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 标准库
参考与延伸阅读

缓存的是计算过的历史