资讯动态

KV Cache分级存储:长上下文推理显存优化与调度策略

发布时间:2026/10/27 22:50:52 来源:尧图企业网站定制
1. 从一次显存爆炸说起KV Cache 到底吃掉了什么第一次在单卡上跑长上下文推理的人大概率都经历过这个场景模型权重明明只占了一小半显存batch size 也没开多大结果一跑到几千 token 的上下文显存就啪地一下爆了报错信息里赫然写着 CUDA out of memory。你盯着nvidia-smi发呆权重加载完还剩一大半空间怎么突然就没了答案基本都指向同一个东西KV Cache。它是自回归生成过程中绕不开的一环也是长上下文推理里最容易被低估的显存黑洞。很多人做大模型部署时注意力全放在量化、权重加载、算子优化上却忽略了 KV Cache 才是决定你能同时服务多少请求、能撑多长上下文的关键变量。这篇内容我想聊的是 KV Cache 的分级存储思路。所谓分级说白了就是不再把整个 KV Cache 一股脑塞进 GPU 显存而是按照访问频率、生命周期、重要性把它拆到不同层级的存储介质上——显存、主机内存、甚至更慢的存储层再配合调度策略在层与层之间搬运。这套思路直接对应了几个现实问题长上下文怎么省显存、多轮对话怎么复用历史、高并发下怎么提高吞吐。适合读这篇的人有三类一是正在做本地部署、被显存卡脖子的工程师二是想搞懂推理框架内部到底在干什么的开发者三是准备做私有化部署、需要给硬件成本算账的技术负责人。我会从 KV Cache 的本质讲起把分级存储的动机、层级划分、调度策略、实操中的坑都拆开说清楚尽量让你看完能自己动手改一版调度逻辑。先给一个直觉KV Cache 就像你写长文档时手边摊开的草稿纸。每写一个新字你都要回头看一眼前面写过的内容注意力机制如果每次都重新翻整本笔记速度慢到无法接受。所以你把已经算过的 Key 和 Value 缓存下来下次直接用。问题是文档越长草稿纸摊得越大桌子显存就放不下了。分级存储要解决的就是桌子放不下时哪些草稿放抽屉、哪些放书架、哪些直接归档的问题。2. KV Cache 的显存账本为什么它比你想的更贵2.1 一个能算清楚的显存公式很多人对 KV Cache 的体量没有概念觉得不就是缓存嘛。我们直接上公式把它算明白。单个 token 的 KV Cache 占用以 FP16 为例每元素 2 字节每 token 每层 KV 字节数 2 (K和V) × 2 (字节) × num_kv_heads × head_dim注意这里用的是num_kv_heads而不是num_attention_heads因为现在主流模型普遍采用 GQA分组查询注意力或 MQA多查询注意力KV 头数远小于查询头数。这是省 KV Cache 的第一道天然红利。拿一个典型的 7B 级别模型举例32 层、hidden size 4096、32 个注意力头、head_dim 128如果用的是 GQAKV 头数为 8。那么每 token 每层 2 × 2 × 8 × 128 4096 字节 4 KB 整模型每 token 4 KB × 32 层 128 KB看起来不多那算 32K 上下文128 KB × 32768 4 GB单个请求、32K 上下文KV Cache 就要 4 GB。如果你要并发 8 个请求就是 32 GB——这还没算权重本身。一张 24G 的卡权重占 14G剩下 10G 连两个 32K 请求都撑不住。这就是为什么长上下文和高并发在显存上是天然矛盾的。2.2 为什么不能简单地把 KV Cache 丢掉重算有人会想显存不够那我不用缓存每次重新算注意力不就行了理论上可行实际上不可接受。自回归生成是逐 token 的生成第 N 个 token 时需要它和前面 N-1 个 token 的注意力。如果不缓存每生成一个 token 就要把前面所有 token 的 K、V 重新算一遍。生成第 N 个 token 的计算量正比于 N生成整个序列的总计算量就是 O(N²) 的重复计算。而有了缓存每步只需要算新 token 的 K、V总计算量降到 O(N)。这个差距在长序列上是灾难性的。32K 上下文下重算方案的计算量可能是缓存方案的几百上千倍。所以 KV Cache 不是优化项而是必需品。既然必须存那问题就变成了存在哪里、怎么存、什么时候搬。这正是分级存储要回答的。2.3 显存、内存、存储三层的速度与容量鸿沟要理解分级存储先得认清三层介质的物理特性差异。这不是随便分的而是由硬件带宽和容量决定的硬约束。层级典型介质带宽量级容量量级访问延迟单位成本L1 显存HBM1-3 TB/s24-80 GB极低极高L2 主机内存DDR50-200 GB/s256 GB-1 TB中等中等L3 外部存储NVMe SSD3-7 GB/sTB 级高低这张表是分级存储所有设计决策的出发点。显存快但贵且小内存大但慢一个数量级SSD 更大但慢两个数量级。分级存储的核心思想就是把最常访问、最影响延迟的部分留在显存把不常访问的部分下沉到内存甚至 SSD用带宽换容量。关键在于KV Cache 的访问模式并不是均匀的。最近生成的 token 几乎每个 step 都要用到而很久以前的历史 token 可能只在特定注意力模式下才被访问。这种局部性就是分级存储能成立的前提。如果访问完全随机均匀分级就毫无意义——你只是把慢介质插进了快路径。3. 分级存储的层级设计谁留在显存谁被下沉3.1 按注意力距离划分冷热数据最直观的分级依据是 token 距离当前生成位置的距离。自回归生成有个特点越靠近当前位置的 token被访问的频率越高。因为每一步新生成的 token 都要和前面所有 token 做注意力但实际计算中近期 token 的注意力权重往往更集中尤其在局部注意力或滑动窗口机制下。所以一个自然的策略是保留最近 W 个 token 的 KV 在显存热区更早的 KV 下沉到内存温区极早的、几乎不再被重点关注的 KV 放到 SSD冷区。W 的取值取决于模型是否用了滑动窗口注意力。如果模型本身是全局注意力那所有历史 token 理论上都可能被访问这时候冷热就要靠实际注意力分数统计来判断而不是单纯看距离。这里有个实操细节滑动窗口注意力如 Mistral 系列用的 SWA天然适合分级因为窗口外的 token 本来就不参与计算直接下沉甚至丢弃都不影响正确性。而全局注意力模型做分级就要小心下沉的 KV 在需要时必须能及时调回否则会算错。3.2 按请求生命周期划分多轮对话的复用价值第二种划分维度是请求的生命周期。多轮对话场景下系统提示词system prompt和早期对话轮次的 KV 是高度可复用的——每个新请求进来前面那一大段历史几乎不变只有最后几个 token 是新的。这类前缀 KV非常适合做分级把公共前缀的 KV 常驻在显存或高速内存里做成可共享的缓存池。新请求进来时直接复用不用重算。这就是业界常说的Prefix Caching或RadixAttention思路。它的价值不只是省显存更是省算力——重算一个 8K 的 system prompt 可能要几百毫秒复用缓存几乎零成本。分级在这里的体现是高频复用的前缀放显存低频复用的放内存长期不用的淘汰。判断高频靠的是引用计数和最近使用时间LRU 类策略。这块和操作系统的页缓存管理思路几乎一模一样做过系统编程的人会觉得很亲切。3.3 按精度重要性划分量化也是一种分级还有一种容易被忽略的分级维度是精度。KV Cache 不一定非要全 FP16。可以把热区的 KV 保持 FP16 甚至 BF16 保证精度把冷区的 KV 量化到 INT8 甚至 INT4 存储需要时再反量化。这本质上也是一种分级——用精度换空间。实测下来冷区 KV 量化到 INT8 对生成质量的影响通常很小因为冷区 token 的注意力权重本来就低量化误差被注意力分数自然压制了。但热区量化就要谨慎近期 token 的注意力权重高量化误差会直接放大到输出上。把这三个维度组合起来一个完整的分级策略可能是热区最近 W tokenFP16 存显存 温区前缀和中期历史FP16 存内存 冷区远期历史INT8 存内存或 SSD。具体怎么组合取决于你的硬件配置和业务对延迟的容忍度。4. 调度策略分级存储真正的难点在搬4.1 预取在需要之前就把数据搬到位分级存储最怕的就是要用的时候数据还在慢介质上。如果每次注意力计算都要同步等待 SSD 读取那延迟会高到无法接受。解决办法是预取prefetch根据访问模式预测下一步可能需要哪些 KV提前异步搬到快层。预取的准确性直接决定效果。预取太多浪费带宽还挤占显存预取太少还是会卡。一个实用的做法是基于注意力模式的统计规律——比如观察历史若干 step 中哪些位置的 token 被重点关注据此预测。另一个做法是利用生成的位置单调递增特性按固定窗口提前搬运。提示预取一定要异步。同步预取等于没预取因为你在等数据的时候 GPU 已经空转了。用独立的 CUDA stream 或后台线程做搬运让计算和数据传输重叠起来。4.2 淘汰显存不够时先扔谁淘汰策略决定了分级存储的稳定性。显存满了必须把一些 KV 下沉或丢弃扔错了就会导致后续频繁回搬形成抖动thrashing性能反而比不分级还差。常见的淘汰依据LRU最近最少使用最通用适合访问模式相对平稳的场景。注意力分数加权统计每个 token 累积的注意力权重权重低的优先淘汰。这个更贴合 KV Cache 的实际价值但统计本身有开销。距离优先离当前位置最远的优先淘汰简单高效适合滑动窗口类模型。引用计数多请求共享的前缀引用计数为 0 才淘汰避免误删公共缓存。实操中往往是组合使用。比如先用引用计数保护共享前缀再在私有部分用距离或注意力分数淘汰。我见过不少实现只用了纯 LRU结果在长上下文场景下抖动严重因为 LRU 无法区分很久没用但很重要和很久没用且不重要的 token。4.3 回搬冷数据被访问时怎么快速拉回淘汰之后如果某个冷 token 突然又被注意力关注到了就得把它从慢层拉回快层。这个回搬过程要尽量平滑不能阻塞主计算流。一个工程上的技巧是分块回搬 计算掩盖。把 KV 按 block 组织回搬时以 block 为单位同时让当前 step 的计算先跑那些已经在显存里的 block等回搬完成再补算缺失部分。这样即使有回搬也不会让整个 step 卡死。当然这要求注意力计算支持分块累加实现复杂度不低。另一个思路是容忍近似对于极冷的 token如果回搬成本太高干脆用一个近似值代替比如用相邻 token 的 KV 插值或者直接跳过。这在很多场景下对最终输出的影响微乎其微但能省掉大量搬运开销。是否可接受取决于你的业务对精度的要求。5. 动手改一版从零实现一个简化分级缓存5.1 数据结构设计理论讲完来点能跑的。下面是一个简化版分级 KV Cache 的核心结构用 Python 伪代码表达重点是逻辑而非性能。class TieredKVCache: def __init__(self, gpu_capacity, cpu_capacity, window_size): self.gpu_store {} # 热区token_id - (K, V) self.cpu_store {} # 温区token_id - (K, V) self.gpu_capacity gpu_capacity self.cpu_capacity cpu_capacity self.window_size window_size self.access_count {} # 访问统计用于淘汰决策 self.ref_count {} # 引用计数保护共享前缀 def put(self, token_id, k, v): # 新 token 一律先进热区 self.gpu_store[token_id] (k, v) self.access_count[token_id] 0 self.ref_count[token_id] 1 if len(self.gpu_store) self.gpu_capacity: self._evict_from_gpu() def get(self, token_id): self.access_count[token_id] self.access_count.get(token_id, 0) 1 if token_id in self.gpu_store: return self.gpu_store[token_id] if token_id in self.cpu_store: # 命中温区回搬到热区 kv self.cpu_store.pop(token_id) self.gpu_store[token_id] kv if len(self.gpu_store) self.gpu_capacity: self._evict_from_gpu() return kv return None # 未命中需要重算这个结构把热区和温区分开管理get时如果命中温区就自动回搬。真实实现里gpu_store和cpu_store对应的是显存张量和 pinned memory搬运要走异步拷贝。5.2 淘汰逻辑的关键取舍def _evict_from_gpu(self): # 候选引用计数为 1非共享且距离最远的 token candidates [ tid for tid in self.gpu_store if self.ref_count.get(tid, 0) 1 ] if not candidates: return # 全是共享前缀不能淘汰 # 按访问次数升序访问少的先淘汰 candidates.sort(keylambda t: self.access_count.get(t, 0)) victim candidates[0] kv self.gpu_store.pop(victim) if len(self.cpu_store) self.cpu_capacity: self.cpu_store[victim] kv # 否则直接丢弃下次访问时重算这里有两个关键决策点。第一共享前缀绝不淘汰靠引用计数保护否则多请求场景下会反复重算公共部分得不偿失。第二淘汰时优先下沉到内存而不是直接丢弃因为重算的成本通常高于一次内存搬运。只有当内存也满了才考虑真正丢弃。5.3 和推理框架对接时要注意什么如果你是在 vLLM、TensorRT-LLM 这类框架上做二次开发直接改 KV Cache 管理要非常小心因为框架内部对 KV 的内存布局、block 管理有自己的假设。比较稳妥的切入点是优先用框架已经暴露的prefix caching或paged attention接口它们本身就是分级思想的工程化实现。如果框架支持自定义 block manager可以在 block 粒度上做冷热标记和下沉。不要轻易改动 KV 的物理布局否则会破坏 CUDA kernel 的向量化访问假设性能可能断崖式下跌。注意PagedAttention 把 KV Cache 切成固定大小的 block 管理这本身就是为分级存储铺路——block 是天然的搬运单位。理解这一点你就理解了为什么现代推理框架都往 paged 方向走。6. 实测中的坑分级不是免费的午餐6.1 抖动分级存储最隐蔽的性能杀手我踩过最深的坑就是抖动。当时为了省显存把窗口设得很小结果模型在生成时频繁访问窗口外的 token导致 KV 在显存和内存之间来回搬GPU 利用率掉到 30% 以下吞吐还不如不分级。根因是窗口大小和模型的实际注意力分布不匹配。解决办法是先做一轮 profiling统计注意力权重的实际分布看看多少比例的注意力落在最近 W 个 token 上。如果这个比例不够高比如低于 90%说明窗口设小了或者这个模型根本不适合按距离分级。6.2 搬运开销被低估很多人算分级存储的收益时只算了省下的显存没算搬运的带宽成本。一次 PCIe 搬运的带宽远低于 HBM如果搬运量太大省下的显存会被搬运延迟吃掉。经验值是只有当被下沉数据的访问频率低于某个阈值时分级才划算。粗略估算如果一块 KV 在它的生命周期里被访问少于 2-3 次下沉到内存是赚的如果访问很频繁下沉反而亏。这个阈值和你的 PCIe 带宽、内存带宽直接相关需要实测标定。6.3 精度损失在长上下文下会累积冷区 KV 量化省空间很诱人但在超长上下文下量化误差会随着层数累积。我实测过一个 64K 上下文的场景冷区用 INT8 量化短输出没问题但生成长文本时出现了明显的重复和逻辑漂移。后来调整策略只对最冷的、注意力权重最低的那部分 KV 做量化且量化后保留一个 FP16 的锚点用于校准。这样精度损失可控空间也省下来了。所以量化分级一定要配合注意力权重统计不能一刀切。6.4 多请求共享的引用计数容易漏多请求共享前缀时引用计数如果管理不当会出现该保护的被淘汰或该淘汰的永远不释放两种极端。前者导致性能抖动后者导致显存泄漏。我的做法是给每个共享前缀块维护一个显式的生命周期请求结束时显式减引用并定期做一次一致性校验扫描引用计数为 0 但仍在缓存里的块清理掉。这个校验开销很小但能避免长期运行后的内存泄漏。7. 这套思路还能往哪延伸KV Cache 分级存储本质上是一个缓存分层 调度的问题和 CPU 的 cache hierarchy、数据库的 buffer pool、CDN 的边缘缓存是同一类问题。理解了这一层很多优化思路可以互相借鉴。比如数据库里的LRU-K策略考虑最近 K 次访问就比纯 LRU 更适合 KV Cache因为它能更好地区分偶发访问和持续热点。再比如 CDN 的预取 淘汰组合策略也可以迁移过来。往大了说随着上下文长度继续往 128K、1M 走KV Cache 的显存压力只会越来越大分级存储会从优化项变成必选项。现在很多推理框架已经在往这个方向演进paged attention、prefix caching、KV 量化都是分级思想的局部实现。谁能把这套调度做细谁就能在同样的硬件上服务更长的上下文、更高的并发。我自己在实际项目里的体会是不要一上来就追求完美的分级策略先把热区 温区两层做扎实把抖动和搬运开销控制住收益就已经很明显了。冷区、量化、SSD 这些是锦上添花等两层跑稳了再往上加。分级存储的复杂度主要不在存储本身而在调度——而调度策略的优劣最终只能靠真实负载的 profiling 来验证拍脑袋设参数十有八九要翻车。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑