那天晚上十点多,线上DeepSeek-R1服务突然开始大面积返回503,GPU显存占用率全部飙到100%。团队第一反应是有人发了超长Prompt,但从日志看只是正常的用户请求。后来才发现,根本原因是KVCache在显存里野蛮生长,没有任何驱逐与分层策略,只要上下文稍长就直接撑爆显存。这不是个例,大模型推理服务开始规模化落地时,KVCache几乎是最先踩到的雷区。

线上模型服务被KVCache逼停的那个凌晨

当时我们上线了一个长文档摘要助手,Prompts长度动辄几万tokens。最初的实现是直接沿用vLLM默认配置,--gpu-memory-utilization 设到了0.9,看着显存很充裕就没有多想。上线第二天夜间,积压了近200个长序列请求,每个请求的KVCache占用十几GB,显存碎片化严重,分配器直接OOM重启。监控图上,显存使用量是一条几乎垂直向上的斜线,恢复后又快速攀升,典型的「雪崩-重启-再雪崩」循环。

这就是KVCache最凶狠的地方:它不像显存占用稳定的模型参数,而是随请求动态膨胀,越是长的上下文,膨胀越大。除非你提前设计一套完整的分层缓存和驱逐机制,否则总会撞上那个极限。

为什么传统的Redis缓存思路在这里失效了

做后端久了,直觉是把中间结果丢进Redis,通过SETEX加个过期时间,简单有效。但是KVCache完全不是这个路子。传统KV缓存解决的是读多写少、静态结果的加速问题;Transformer模型的KVCache却是每生成一个新token,就要更新Key和Value张量,而且更新规模极大。拿Llama-3-70B举例,处理32K长度的上下文,单层的KVCache就可能超过1GB,一个请求遍历几十层,总缓存轻松窜上几十GB。这种体量下,即使Redis Cluster也撑不住读写吞吐,TCP序列化开销更是直接掐灭低延迟的可能。

更致命的是访问模式:自注意力计算要求K、V向量以Block形式在GPU核心间高速传输,延迟必须控制在亚毫秒级。即便是基于RDMA的内存池,一跳数十微秒的延迟累积起来也会让解码步骤慢如蜗牛。因此推理场景下的KVCache必须有自己专属的存储架构——带拓扑亲和性的分布式多级缓存。

推理的两张面孔:Prefill与Decode对缓存需求的撕裂

再深入一层,你会发现推理本身也在左右互搏。Prefill阶段读入全部输入token,并行计算生成第一个token,过程中对KVCache是爆发式写入,追求极致的写带宽;Decode阶段则每次只生成一个新token,需要从缓存中读取全部历史token的K、V矩阵,变为高度随机的碎片化读取,追求超低读取延迟。同一个存储系统很难同时满足两种需求。

这就是为什么现代推理引擎(vLLM、SGLang)纷纷拥抱Prefill-Decode分离架构:将Prefill和Decode任务分给不同GPU甚至不同服务器,并通过高速网络传输KVCache。此时对缓存系统的要求又加码了——不仅要存得快、读得低延迟,还要能跨节点高效共享,避免重复计算与数据孤岛。百度千帆KV Cache系统、阿里云Tair和火山引擎EIC的实践都印证了这一点:只有把KVCache当作一项独立的基础服务来设计,才能应付分离式推理的复杂调度。

显存-内存-SSD:三级缓存分层的落地权衡

鉴于显存昂贵且容量有限,业界几乎一致采用 L1显存 → L2 CPU内存 → L3 NVMe SSD 分层的方案。请求到来时,先查L1,命中率最高;未命中则依次下沉,并触发数据提升:把L2命中的数据写回L1,L3命中的数据逐级回填。这种做法依赖热度衰减算法动态清理冷数据,百度千帆的自动Cache模式能够令吞吐量平均提升61%,首Token延迟降低37%,在对话场景中吞吐最高提升252%。

落地这一层时,有一个极易被忽略的细节:提升策略必须区分读写路径。如果每命中一次L3就盲目写入L1,大量冷块可能挤出真正的热数据。我们踩过的坑是,给回填路径加了「访问频次计数器」和「最近两次访问时间间隔」双重判定,只有被重复访问且时间密集的块才升级到上层。下边是一个简化的三级查找伪代码,真实场景还需要处理并发和淘汰。

def get_kv_cache(key):
    # L1: GPU显存
    cache = gpu_cache.get(key)
    if cache: return cache
    # L2: CPU内存
    cache = cpu_cache.get(key)
    if cache:
        promote_to_gpu(key, cache)
        return cache
    # L3: SSD
    cache = ssd_cache.get(key)
    if cache:
        promote_to_cpu(key, cache)
        promote_to_gpu(key, cache)
        return cache
    # 所有缓存均未命中,触发重计算
    return recompute(key)

PagedAttention带来块管理革命,却制造了新的I/O噩梦

vLLM通过PagedAttention将KVCache切分为固定大小的Block(通常16KB或32KB),彻底解决了连续显存分配导致的碎片化问题,让Batch Size可以拉伸到5-10倍。然而,当把KVCache卸载到SSD时,这套分块机制反而成了小I/O的噩梦源头:原本连续的KVCache被切成大量微小的Block,每次Decode步骤都要按随机顺序读取几十甚至几百个Block。

一块4KB的NVMe随机读,物理延迟约80-100μs。对于GPU来说,这100μs的空等足以毁掉Token间延迟(ITL)指标。如果一次解码需要加载200个Block且无法完全并行,总延迟将累积到20ms,用户明显感受到卡顿。腾讯云的深度分析指出,小I/O瓶颈的本质是队列深度不足和分块粒度失配,单纯堆NVMe带宽并不能解决问题,必须从调度和预取角度破局。

分布式KVCache池化:突破单机内存墙还不够,还要解决调度亲和性

单机内存再大也有上限,分布式池化成了必由之路。火山引擎EIC将GPU集群的闲置内存和SSD统一池化,单客户端可达百GB级吞吐与亚毫秒级响应,其核心是感知GPU与网卡的拓扑亲和性:Mem0节点优先使用R0网卡传输,通过NUMA亲和绑定,多网卡并行传输轻松突破单机100GB/s带宽。阿里云Tair KVCache同样把分布式内存池配合PD分离,复用历史KV Cache可将TTFT缩短90%。

这里有一个让人头大的现实问题:跨节点调度时,如果KVCache频繁在不同GPU之间迁移,不仅带宽损耗严重,缓存命中率也会直线下滑。解决之道是采用 KVCache亲和路由——通过哈希分片将会话绑定到固定节点,配合动态副本扩容应对热点,避免缓存反复无效搬运。没有亲和路由的分布式缓存,在高并发下几乎等同于没有缓存。

工程配置避坑:从块大小到驱逐策略的几条血泪经验

块大小的选择直接影响碎片率与I/O粒度。大上下文场景(1M tokens)建议用32KB甚至64KB的大块,减少随机读取的Block数量;短对话场景则用8KB-16KB,提升显存分配灵活性。vLLM中可通过 --block-size 设定,下面是一个生产使用的启动参数片段。

# vLLM 关键参数配置
--block-size 32 
--gpu-memory-utilization 0.95 
--max-model-len 131072 
--swap-space 16 
--enforce-eager

LRU驱逐策略虽然经典,但直接用于KVCache容易出现“当前正在使用的块被误驱逐”的惨案。必须结合引用计数,确保只有完全释放的块才能进入驱逐链表。此外,碎片整理不要设置得太过频繁,最好在系统负载低谷期触发,否则整理过程自身会与请求争抢I/O带宽,进一步恶化延迟。

监控不可缺失的指标:命中率、延迟分位数与碎片率

上线KVCache分层后,第一件事就是补齐监控。按L1/L2/L3分别统计命中率,如果L3命中率超过15%,说明上层容量不足或提升策略保守,需要扩容显存或调整升级阈值。P99首Token延迟和Token间延迟必须与缓存命中率一起看,延迟突增往往伴随着某些层级的命中率骤降。

碎片率与块利用率也要盯牢。块利用率低于60%说明块大小设置过大,产生浪费;碎片率超过30%则说明显存分配器压力大,可能很快触发OOM。监控系统可以结合Prometheus与Grafana,下边是一个统计各层级缓存命中的PromQL示例,配合节点Exporter可构成基本报警规则。

# L1、L2、L3命中计数查询
rate(kvcache_hit_total{level='L1'}[1m]) / (rate(kvcache_hit_total{level='L1'}[1m]) + rate(kvcache_miss_total{level='L1'}[1m]))

参考资料

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐