1. 从一个反直觉的现象说起
你有没有遇到过这种情况:监控面板上 KV Cache 的命中率已经飙到 95% 以上,理论上大部分请求都应该直接复用缓存、跳过重复的 Prefill 计算,但实际端到端延迟就是下不来。首 Token 延迟(TTFT)忽高忽低,吞吐量也上不去,GPU 利用率看着也不饱和,但用户就是觉得慢。
这个问题我在几个推理服务项目里都踩过。一开始大家的直觉都是“缓存命中了就应该快”,但真实情况是——KV Cache 命中只是省掉了计算,并不等于省掉了时间。数据从哪来、放在哪、怎么搬,这一整条链路上的开销,往往比那点计算量还大。
这篇文章就围绕这个反直觉现象展开。我会从显存和内存的层级差异讲起,拆解 KV Cache 命中之后到底还慢在哪,然后聊到以 Mooncake 为代表的分离式架构是怎么重新设计这条数据通路的。内容偏工程实战,适合正在做推理服务优化、或者被 TTFT 和吞吐指标折磨过的同学。看完你至少能搞清楚三件事:命中缓存后时间到底花在哪、显存和内存的带宽差距有多致命、以及分离式 KV 缓存架构解决的是什么层面的问题。
2. 先搞清楚 KV Cache 命中到底省了什么
2.1 Prefill 和 Decode 是两个完全不同的阶段
要理解这个问题,得先把大模型推理的两个阶段分清楚。
Prefill 阶段处理的是你输入的整段 prompt,所有 token 一次性并行计算,生成对应的 KV 向量。这个阶段是计算密集型的,矩阵乘法占主导,GPU 的算力单元基本能跑满。Decode 阶段是逐 token 生成的,每生成一个新 token,都要拿它和之前所有的 KV 做注意力计算。这个阶段是访存密集型的,因为每步只算一个 token,算力用不上多少,但要把整个 KV Cache 读一遍。
KV Cache 的本质,就是把 Prefill 阶段算出来的 Key 和 Value 向量存下来,Decode 的时候直接复用,不用重新算。所以“命中 KV Cache”省掉的是重复的 Prefill 计算。
2.2 命中缓存省的是算力,不是时间
这里就是第一个认知陷阱。很多人默认“省了计算 = 省了时间”,但推理服务的延迟是由最慢的那个环节决定的,不是由计算量决定的。
举个具体的例子。假设一个请求的 prompt 有 2000 个 token,Prefill 计算本身在 A100 上大概需要几十毫秒。如果 KV Cache 命中了,这几十毫秒的计算确实省了。但接下来 Decode 阶段要读这 2000 个 token 对应的 KV,如果这些 KV 不在当前 GPU 的显存里,而是在别的机器的内存或者远端存储里,你就得先把它们搬过来。搬运这 2000 个 token 的 KV 需要多久?这就取决于数据在哪、走什么通道了。
我实测过一个场景:KV 存在同机内存里,通过 PCIe 搬到显存,2000 token 的 KV 大概 100MB 出头(取决于模型层数和 head 维度),PCIe 4.0 x16 的理论带宽是 32GB/s,实际能跑到 20GB/s 左右,光搬运就要 5ms 上下。如果 KV 在远端机器,走网络,那延迟直接上到几十毫秒甚至更高。这时候你省下的那几十毫秒 Prefill 计算,全被数据搬运吃回去了,甚至还不够。
关键结论:KV Cache 命中降低的是 GPU 的计算负载,但引入了一条新的数据搬运路径。这条路径的开销如果大于省下的计算,端到端延迟反而可能变差。
2.3 为什么监控上看不出来
更麻烦的是,这个问题在常规监控上很难发现。你看 GPU 利用率,Prefill 省了之后算力占用确实降了;你看缓存命中率,指标很漂亮;但 TTFT 的 P99 就是高。因为瓶颈从 GPU 算力转移到了数据搬运带宽上,而带宽这个指标很多团队的监控里根本没细看。
我在一个项目里做过对比:同一批请求,缓存全命中的情况下,GPU 计算时间从 45ms 降到 8ms,但端到端 TTFT 从 60ms 涨到了 90ms。多出来的 30 多毫秒,全花在从内存往显存搬 KV 上了。这个数据当时让团队里所有人都愣了一下——原来我们优化了半天计算,瓶颈早就不在计算上了。
3. 显存、内存、SSD 的带宽鸿沟有多夸张
3.1 一张表看清各层级的差距
要理解为什么搬运这么慢,得先对存储层级的带宽有个量级概念。下面这张表是我根据常见硬件整理的实际可用带宽,不是理论峰值:
| 存储层级 | 典型带宽 | 相对显存倍数 | 访问延迟 |
|---|---|---|---|
| HBM(显存) | 1.5 - 3 TB/s | 1x | 百纳秒级 |
| 主机内存 DDR5 | 50 - 100 GB/s | 约 1/30 | 百纳秒级 |
| PCIe 4.0 x16 | 20 - 25 GB/s | 约 1/100 | 微秒级 |
| 本地 NVMe SSD | 5 - 7 GB/s | 约 1/400 | 几十微秒 |
| 万兆网络 | 1 - 1.2 GB/s | 约 1/2000 | 毫秒级 |
这张表的信息量很大。显存和内存之间差了大概 30 倍,和网络之间差了 2000 倍。KV Cache 一旦离开显存,每往下一层,带宽就掉一个数量级。
3.2 带宽差距怎么转化成延迟
拿一个具体的模型配置来算。假设是 70B 级别的模型,80 层,GQA 配置下 KV head 数是 8,head 维度 128,那么每个 token 的 KV 大小是:
2(K和V)× 80(层)× 8(head)× 128(维度)× 2(FP16字节) = 327680 字节 ≈ 320 KB/token这个数字很关键。每个 token 的 KV 大约 320KB。那么:
- 2000 token 的 prompt,KV 总量约 640MB
- 8000 token 的 prompt,KV 总量约 2.5GB
现在算搬运时间。640MB 的 KV,从内存经 PCIe 搬到显存,按 20GB/s 算,需要 32ms。从远端网络搬,按 1GB/s 算,需要 640ms。而同样的 2000 token Prefill 计算,在 A100 上也就 40-50ms。
看出来了吗?从内存搬 KV 的时间和重新算一遍 Prefill 差不多,从网络搬 KV 的时间是重算的十倍以上。这就是为什么“命中了缓存反而更慢”——你省了 45ms 的计算,却花了 640ms 去搬数据。
实操心得:判断要不要用缓存,先算一笔账。KV 搬运时间 = KV 大小 / 实际带宽。如果这个时间大于重算 Prefill 的时间,那缓存命中就是负优化。这个计算在选型阶段一定要做,别看到命中率就高兴。
3.3 为什么显存这么贵还这么小
有人会问,那为什么不把 KV 全放显存里?答案很简单:显存容量有限且昂贵。一张 80GB 的卡,模型权重可能就占了 40-50GB,留给 KV Cache 的空间也就 20-30GB。按每 token 320KB 算,也就存 6-9 万 token 的 KV。一个稍大的并发场景,几个长上下文请求就把显存撑满了。
所以现实就是:显存装不下所有 KV,必须往内存甚至更下层放。而一旦放下去,再读回来就要付出带宽代价。这是整个问题的物理根源,不是软件优化能绕过去的。
4. 命中缓存后,时间到底花在哪了
4.1 拆解一次请求的完整时间线
我把一次命中缓存的请求拆成几个阶段,你就能看清时间去哪了:
- 请求路由:请求到达调度器,查询 KV 在哪个节点上,决定把请求发到哪。这一步涉及元数据查询,通常几毫秒。
- KV 定位与拉取:从目标位置把 KV 读出来。如果在本地内存,走 PCIe;如果在远端,走网络。这是大头。
- KV 加载到显存:数据到了本机还得进显存才能参与计算,又是一次 PCIe 搬运。
- Decode 计算:真正用上 KV 做注意力计算,这部分很快。
- 结果返回:生成 token 返回给用户。
你会发现,第 2 和第 3 步是纯开销,而且往往是串行的。数据没到齐,Decode 就没法开始。这两步加起来的时间,经常比第 4 步大一个数量级。
4.2 串行等待是延迟的放大器
更糟的是,这些步骤在朴素实现里是串行的。请求必须等 KV 全部加载完才能开始 Decode。假设 KV 有 2.5GB,从内存搬要 125ms,这 125ms 里 GPU 就在那干等着,算力完全浪费。
我见过一个团队的做法是分块加载、边加载边计算,但实现起来很复杂,因为注意力计算需要完整的 KV 序列,分块加载要处理依赖关系。大部分框架还是老老实实等全部加载完。
注意:如果你的服务 P99 延迟高但 P50 正常,很可能就是长上下文请求触发了大块 KV 搬运,把尾部延迟拉高了。这时候要看的是 KV 大小分布,不是平均值。
4.3 一个真实的排查案例
之前有个项目,TTFT 的 P99 一直在 500ms 以上,但平均只有 80ms。团队一开始怀疑是网络抖动,查了半天网络没问题。后来把请求按 prompt 长度分组统计,发现 P99 全部来自 prompt 超过 6000 token 的请求。
这些请求的 KV 大概 2GB,命中缓存后从内存搬,PCIe 实际带宽被其他请求挤占,只能跑到 10GB/s,搬运就要 200ms。再加上加载到显存、调度开销,500ms 就出来了。而短请求 KV 小,搬运快,延迟正常。
解决方案不是优化搬运,而是控制长请求的调度策略——把长请求单独排队,避免它们和短请求抢 PCIe 带宽。改完之后 P99 降到了 150ms。这个案例说明,很多时候问题不在单个环节,而在资源竞争。
5. Mooncake 这类分离式架构在解决什么
5.1 传统架构的瓶颈在哪
传统推理服务里,计算和 KV 存储是绑在一起的。KV 存在哪台机器的显存或内存里,请求就得路由到那台机器。这带来两个问题:
第一,资源无法独立扩展。你想加显存存更多 KV,就得加整台 GPU 机器,算力也跟着冗余了。反过来想加算力,又被迫加了一堆用不上的存储。
第二,KV 搬运路径长。请求和 KV 不在同一台机器时,要先从远端拉到本机内存,再从内存进显存,两次搬运。而且远端拉取走的是通用网络,带宽和延迟都不理想。
5.2 分离式架构的核心思路
Mooncake 这类架构的核心,是把KV Cache 的存储和计算解耦。具体来说:
- KV 存储池化:用专门的存储节点(可以是 CPU 内存 + SSD 的层级)集中管理 KV,不再和 GPU 绑死。
- 高速传输通道:用 RDMA 这类高带宽低延迟的网络,把 KV 从存储节点直接传到计算节点的显存,绕过主机内存这一跳。
- 以 KV 为中心调度:调度器根据 KV 的位置来分配计算资源,尽量让计算发生在 KV 附近,减少搬运。
这个思路的本质,是承认“KV 搬运是主要开销”,然后围绕这个开销去优化整条链路,而不是只盯着计算优化。
5.3 它解决了什么,没解决什么
Mooncake 这类架构解决的是大规模、长上下文场景下的 KV 复用效率问题。当你的服务有大量共享前缀的请求(比如同一个系统提示词、同一份文档的多次问答),KV 复用的收益很大,这时候把 KV 集中管理、高速传输,能显著降低整体成本。
但它没解决的是物理带宽的硬约束。RDMA 再快,也就 100-400Gbps,换算下来 12-50GB/s,还是比显存带宽低一两个数量级。所以分离式架构不是让搬运变免费,而是让搬运更高效、更可控。
实操心得:分离式架构适合 KV 复用率高、请求量大的场景。如果你的服务请求之间没什么共享前缀,KV 复用率低,上这套架构的收益可能覆盖不了它的复杂度。选型前先统计一下实际的前缀复用率。
6. 工程上怎么判断和优化
6.1 先做一次带宽账本
在动手优化之前,先算清楚你的场景下这笔账:
| 项目 | 计算方式 | 示例值 |
|---|---|---|
| 单 token KV 大小 | 2 × 层数 × KV head × head 维度 × 字节数 | 320 KB |
| 平均 prompt KV 总量 | 单 token KV × 平均 prompt 长度 | 640 MB |
| 重算 Prefill 时间 | 实测或估算 | 45 ms |
| 搬运时间(内存) | KV 总量 / PCIe 实际带宽 | 32 ms |
| 搬运时间(网络) | KV 总量 / 网络实际带宽 | 640 ms |
把这张表填出来,你就能判断缓存命中到底是正收益还是负收益。如果搬运时间 > 重算时间,缓存就是负优化。
6.2 几个可落地的优化方向
方向一:控制 KV 的存放层级。热数据尽量留在显存,温数据放内存,冷数据才下沉到 SSD 或远端。用 LRU 之类的策略管理,别让所有 KV 一视同仁。
方向二:减少 KV 本身的大小。用量化(比如 KV 用 INT8 存)能把 KV 体积砍一半,搬运时间也跟着砍半。代价是精度可能略有损失,需要评估。
方向三:优化搬运路径。能用 RDMA 就别走 TCP,能直传显存就别绕主机内存。每一跳都是延迟。
方向四:调度层面隔离。长请求和短请求分开调度,避免长请求的大块搬运挤占短请求的带宽。
6.3 常见问题速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 命中率高但 TTFT 高 | KV 搬运开销大 | 看 KV 大小分布和搬运耗时 |
| P99 高 P50 正常 | 长请求拖尾 | 按 prompt 长度分组统计 |
| GPU 利用率低 | 在等 KV 加载 | 看加载阶段 GPU 是否空闲 |
| 吞吐上不去 | PCIe 或网络带宽打满 | 监控实际带宽利用率 |
| 缓存命中反而更慢 | 搬运时间 > 重算时间 | 做带宽账本对比 |
7. 我在实际项目里踩过的坑
第一个坑是只看命中率不看搬运量。早期我们优化缓存,盯着命中率从 60% 提到 90%,结果延迟没降反升。后来才发现,提升的命中里很多是长上下文请求,搬运量巨大。命中率这个指标本身有误导性,得配合 KV 字节数一起看。
第二个坑是忽略了 PCIe 的共享竞争。同一台机器上多个请求同时搬 KV,PCIe 带宽是共享的,实际每个请求分到的带宽远低于理论值。我们按理论带宽算的搬运时间,实际要乘 2 到 3 倍。后来在调度上做了限流,保证单个请求的搬运不被过度挤占。
第三个坑是以为 RDMA 能解决一切。上了 RDMA 之后,网络延迟确实降了,但发现瓶颈转移到了显存加载这一步。数据到了本机,进显存还是要走 PCIe,这一步没优化,整体提升有限。优化要看整条链路,不能只优化其中一段。
最后一个体会是,KV Cache 这个问题,本质上是个数据搬运问题,不是计算问题。所有的优化思路,都应该围绕“怎么让数据少搬、快搬、就近搬”来展开。想清楚这一点,很多方案的选择就顺理成章了。至于具体用不用分离式架构,取决于你的复用率和规模,别为了架构而架构。