最近在折腾长上下文推理的时候,被显存逼得有点头疼。模型权重明明不大,但并发一上来,KV cache直接把显存撑爆。后来我把目光放到 LMCache 和 vLLM 的组合上,试了一把把 KV cache 从 GPU 卸载到 CPU 内存和 SSD 上的方案,效果比我预想中实用不少。这篇博客就把我这次踩坑和验证的完整过程记录下来,包括为什么需要这么干、原理大概是什么、具体怎么配、以及跑起来之后有哪些坑。
这篇文章适合正在用 vLLM 部署大模型、需要处理超长上下文或高并发请求、或者手里只有单卡但想省显存的人。如果你只是想跑通 demo,可能用不上;但如果你在线上被 OOM 折磨过,这篇内容应该能给你一条很清晰的路。
1. 为什么要把 KV cache 卸到 CPU 和 SSD
1.1 先算一笔账:KV cache 到底有多占显存
很多人部署 vLLM 的时候,只盯着模型权重的显存占用,觉得“8B 模型 fp16 也就 16GB,24GB 显存足够”,结果一跑长上下文就 OOM。问题就出在 KV cache 上。
KV cache 就是 Transformer 在做自回归生成时,缓存下来的历史 token 对应的 Key 和 Value 矩阵。它的计算方式大概是:
KV cache 大小 = 2 × 层数 × KV 头数 × head_dim × 序列长度 × batch_size × 每个元素字节数
以 LLaMA-3-8B 为例,32 层,8 个 KV 头,head_dim 128,每个元素 fp16 占 2 字节。那么:
每个 token 的 KV cache = 2 × 32 × 8 × 128 × 2 = 131,072 字节 = 128KB
如果用户输入 32K 上下文,单条请求的 KV cache 就是:
128KB × 32768 = 4GB
再开 8 个并发请求,光 KV cache 就要 32GB。而模型权重本身已经占了 16GB,单张 24GB 显卡根本放不下。这也是为什么很多人用 8B 模型也能把 A100 撑爆。
LLaMA-2-7B 这种模型更夸张,因为没有用 GQA,每 token KV cache 能到 512KB 左右,32K 上下文单请求就要 16GB。所以 KV cache 早就不是“附带品”,而是推理时最大的显存消耗源。
1.2 卸载之后能换来什么
既然显存放不下,最容易想到的方案就是把 KV cache 挪到更低成本的存储上。CPU 内存比显存便宜得多,SSD 更是“白菜价”。LMCache + vLLM 这套方案,就是把 KV cache 按热度分成三层来放:
- GPU 显存:放最热的 KV 块
- CPU 内存:放稍旧的 KV 块
- SSD/memmap:放冷数据
这样做的直接好处有三点:
第一,能跑超长上下文。以前单卡 24GB 跑 32K 上下文都吃力,卸载到 CPU 和 SSD 之后,上下文窗口的主要瓶颈从“显存容量”变成了“CPU 内存 + SSD 容量”,只要磁盘够大,就能继续往后叠。
第二,能提高并发。KV cache 不占满显存之后,vLLM 的调度器可以在显存里保留更多 active sequence,batch size 可以开得更大。吞吐量提升非常明显,尤其是离线批量推理场景。
第三,省钱。与其为了长上下文去买 A100/H100,不如把预算花在大容量内存和 NVMe SSD 上。对于很多内部工具、RAG 查询、长文档总结这类延迟不敏感的场景,这个性价比是很真实的。
当然,卸载是有代价的。CPU 和 SSD 的访问带宽远低于 HBM,KV cache 换进换出会有额外耗时,所以它更适合“高吞吐、容忍一定延迟”的场景,而不是“每个请求都要毫秒级响应”的在线交互服务。
2. LMCache 与 vLLM 的配合原理
2.1 LMCache 到底是干什么的
LMCache 是一个面向 LLM 推理的 KV cache 管理组件,专门做 KV cache 的存储、索引、调度和预取。它不是一个独立推理引擎,而是嵌到 vLLM 这种推理框架里干活。
它的核心思路是:把 KV cache 从“GPU 显存独享”变成“多级存储系统管理”。LMCache 会负责决定哪些 KV 块继续留在 GPU 上,哪些被驱逐到 CPU 内存,哪些冷数据可以被序列化到 SSD,以及当某个前缀被重复请求时,如何直接从缓存里读取 KV 块,避免重新计算整个前缀。
换句话说,它做的不是简单的“offload”,而是带策略的缓存管理。
另一个很实用的能力是跨请求复用。传统 vLLM 的 prefix caching 只能在单实例内生效,实例重启就没了。LMCache 可以把 KV cache 落地到本地磁盘,所以即使在并发请求之间,只要 prompt 前缀相同,就能直接复用之前计算好的 KV 结果。
2.2 vLLM 为什么需要这么一层
vLLM 本身已经有 PagedAttention,显存利用率比 transformers 原生实现高很多。但它对 KV cache 的管理还是以 GPU 显存为绝对核心,并没有真正做好“分层存储”和“跨实例共享缓存”。具体来说:
- vLLM 的 KV cache 默认都在 GPU,显存不够就 OOM
- 虽然新版 vLLM 支持一些 CPU offload 能力,但调度和存储策略比较简单
- 多实例部署时,每台机器的 KV cache 是互相独立的,重复计算浪费严重
LMCache 看上了 vLLM 暴露出来的 KV connector 接口,通过这个口子把 vLLM 的 KV 块分配、释放、复制等动作截获下来,再让 KV cache 落到自定义的存储层。vLLM 不需要大改,只要启动时配置好连接器,就能被 LMCache 接管。
整体结构可以理解为:vLLM 负责调度计算,LMCache 负责 KV cache 的存储和复用。两者各管一摊,配合起来非常干净。
2.3 CPU/SSD 分层的调度逻辑
LMCache 的存储层级,我一般喜欢用“热、温、冷”来理解:
- 热数据:正在被当前请求访问、或者高频复用的小块 KV,留在 GPU 显存,走 PagedAttention 原生的快速路径
- 温数据:曾经热过、可能后续还会用到的块,在 GPU 内存压力上来后,先被挪进 CPU 内存
- 冷数据:长时间没有被访问的块,从 CPU 内存序列化到 SSD,需要时再读回内存/显存
它用的是类似 LRU 的淘汰策略,但会根据 chunk 的访问频率做更精细的判断。从 GPU 往 CPU 迁移的时候不只是“整段丢过去”,而是按预设的 chunk 粒度去切分,这样可以更细粒度地控制缓存有效性。
读回时也有优化,LMCache 支持 CPU 到 GPU 的分块预取,也就是说 vLLM 还没算到某个 token 的时候,LMCache 可能已经提前把对应的 KV 块加载好了。如果你的请求模式有比较明显的规律,预取对延迟的帮助会很大。
贴上我自己画的一个简易分层逻辑,方便大家理解:
推理请求进来 → vLLM PagedAttention 先查 GPU KV 块 → 没命中,查询 LMCache 的 CPU 层 → 还没命中,查询 SSD 层 → 都没有,重新计算 KV 并缓存这套逻辑看起来简单,实际工程上要处理磁盘写入格式、并发访问锁、内存对齐等问题,但用起来确实很省心,因为大部分细节都被 LMCache 封装好了。
3. 部署实录:我从零搭了一套 CPU/SSD 卸载方案
3.1 环境准备
我这次实验的机器是:
- GPU:单张 RTX 4090 24GB
- CPU:Intel Xeon 8375C,32 核
- 内存:128GB DDR4
- SSD:1TB NVMe
- 系统:Ubuntu 22.04
- Python:3.10
- CUDA:12.1
软件层面,我用的是 vLLM 加 LMCache 的组合,安装方式非常直接:
pip install vllm pip install lmcache这里有一个重要提醒:LMCache 对 vLLM 的版本有兼容要求,装之前最好去 LMCache 官方文档看一眼兼容矩阵。如果版本不匹配,可能出现“LMCache 初始化成功但实际不生效”或者直接 import 报错的情况。我自己一开始就吃过这个亏,装了一个新版本 LMCache 配老版本 vLLM,启动时一直报 connector 不存在的错误。
3.2 拉起一个可用的推理服务
我这次用一个开源的 8B 模型做测试,先跑一个不带 LMCache 的 vLLM 服务作为基线:
vllm serve meta-llama/Llama-3.1-8B-Instruct \ --max-model-len 65536 \ --gpu-memory-utilization 0.8 \ --dtype bfloat1624GB 的 4090,光模型权重就占掉了 16GB 左右,留给 KV cache 的只有 6-7GB,跑 64K 上下文基本不可能很宽裕。如果想要更多 KV cache 空间,就得把--gpu-memory-utilization调低一点,让 vLLM 少预占一些显存,但这个参数调太低会导致 KV 池更小,很容易 OOM。
之后我在 vLLM 的启动参数里加上 LMCache 的配置:
vllm serve meta-llama/Llama-3.1-8B-Instruct \ --max-model-len 65536 \ --gpu-memory-utilization 0.5 \ --kv-transfer-config '{ "kv_connector": "LMCacheConnector", "kv_role": "kv_both", "lmcache_config": { "enabled": true, "chunk_size": 256, "cpu_threshold": 0.7, "ssd_path": "/mnt/nvme/lmcache" } }' \ --dtype bfloat16解释一下几个关键参数的实际作用:
chunk_size: 表示 KV cache 分块的大小,单位是 token 数。256 是一个比较稳的配置,太小会导致 I/O 次数太多,太大又会让缓存淘汰的粒度太粗。cpu_threshold: 当 CPU 内存里的缓存使用率达到这个比例时,LMCache 会把更冷的数据往 SSD 迁移。0.7 意味着 CPU 层用到 70% 左右就开始把数据刷到 SSD。ssd_path: 指定 SSD 缓存存放目录。建议用一个单独的高速分区或者目录,避免和其它读写抢 IO。gpu_memory_utilization: 我从 0.8 调低到 0.5,故意给 KV cache 留出的 GPU 余量变小,这样 LMCache 才有动力把 KV 块往 CPU/SSD 卸载。如果你显存本身就比较充裕,也可以不调这么低。
启动日志里如果看到类似LMCache connector initialized的信息,就说明 LMCache 已经接管了 vLLM 的 KV transfer,配置成功。
3.3 CPU/SSD 卸载实测与效果
服务启动后,我写了一个压测脚本去验证卸载效果。脚本很简单,分两种场景测:
- 场景 A:一个 32K 的长 prompt,模拟长文档问答
- 场景 B:32 个并发短 prompt,模拟高并发小请求
上一步配置完成后,我用nvidia-smi、htop、iostat同时监控显存、CPU 内存和磁盘 IO。
场景 A 的结果很有意思。之前纯 vLLM 在 32K prompt 下,24GB 显存几乎被吃满,而且一旦再加几条并发请求就 OOM。开 LMCache 之后,显存占用明显下降,CPU 内存开始有波动,SSD 目录里也出现了缓存文件。第一个请求因为要重新计算全部 KV cache,耗时比较长,但第二个相同前缀的请求快了很多,因为 KV 直接从 CPU/SSD 读回来了。
场景 B 是纯并发短请求,显存压力反而没那么大,LMCache 的收益更多体现在跨请求复用上。如果 32 个请求共享同一套系统提示词,采用 LMCache 后,公共前缀的 KV cache 不用重复计算,显存占用和算力消耗都降了一截。
我把三类存储的表现整理成一张表:
| 存储层级 | 典型访问时延(相对) | 容量 | 适用场景 |
|---|---|---|---|
| GPU 显存 | 1x | 小 | 正在活跃使用的 KV 块 |
| CPU 内存 | 10-30x | 中等 | 温数据,近期可能复用 |
| NVMe SSD | 100x 以上 | 大 | 冷数据,长周期复用 |
这里强调一下,上面的 100x 是数量级概念,具体要看 SSD 型号。好的 NVMe 读取带宽能到 3-7GB/s,但 CPU 内存带宽能到几十 GB/s,显存带宽则是 TB/s 级别,差距依然明显。
所以我的结论是:SSD 在 LMCache 里更像“保险”,保证 KV cache 不会无限膨胀到 OOM;真正日常跑任务吃得最多的还是 GPU 和 CPU 内存这两层。
4. 常见问题与排查技巧
这个方案毕竟不是开箱即用的黑盒,实际操作中问题不少。下面我把这次探索中遇到过的典型问题整理成一份速查表,后面部署的人可以少走弯路。
4.1 版本不匹配 / ImportError
症状:vLLM 启动时报No module named lmcache,或者 kv connector 找不到LMCacheConnector。
排查思路:
- 先确认 vLLM 和 LMCache 版本是否在官方兼容矩阵内
- 确认 LMCache 是否真的被装进当前 Python 环境
- 如果用的是容器,注意别把包装到 base 环境但运行环境是另一个 venv
我在部署时踩过一次:vLLM 用的最新版,LMCache 还在兼容老版本,结果启动时 vLLM 压根不认这个 connector,日志也不报错,只是配置没生效。后来钉版本到兼容组合才正常。
4.2 配置了但显存还是被占满
症状:确认 LMCache 已经启动,但nvidia-smi显示显存还是接近满,CPU/SSD 缓存文件也没怎么增长。
排查思路:
- 检查
--kv-transfer-config里的 JSON 是否写对了,逗号、双引号、布尔值大小写都能让配置失效 - 确认
lmcache_config.enabled是不是 true - 看启动日志里是否有
LMCacheConnector关键信息 - 如果显存本身是
gpu_memory_utilization预占的,把该值再调低一些,逼迫 vLLM 的 KV block 进入 LMCache 管理范围
有时候日志不会直接报错,需要自己加--verbose或者看 vLLM 的初始化输出,确认到底有没有进入kv_both模式。
4.3 卸载后吞吐反而更低
症状:显存确实降了,但吞吐量比纯 vLLM 低很多,尤其第一个长请求非常慢。
排查思路:
- 先分清是“首次计算”慢还是“缓存读取”慢。首次计算本来就要重新算,不要把它算作 LMCache 的额外开销
- 检查 SSD 的写入路径,是不是和系统盘挤在一起,IO 等待严重
- 调整
chunk_size,如果 chunk 太小会有大量小文件读写,磁盘 IOPS 可能成为瓶颈 - 换一个思路:增加 CPU 内存容量,尽量让 KV cache 在 CPU 层处理,而不是频繁落 SSD
如果你的场景对响应时间敏感,可以把cpu_threshold调高到 0.9 以上,让 KV 块尽量留在 CPU 层,少走一次磁盘。
4.4 缓存命中率特别低
症状:跑了很多请求,但 LMCache 缓存文件只是缓慢增长,同一请求第二次访问也没有加速。
排查思路:
- LMCache 的 KV 复用依赖于 prompt 前缀一致,如果每个请求的 prompt 前缀都不同,命中率自然低
- 检查聊天模板是否统一,有些框架会在 prompt 前拼不同 system prompt,导致前缀不一致
- 如果是多轮对话场景,建议把历史上下文放在 prompt 的固定位置,让 LMCache 能复用历史部分的 KV cache
这里有一个实用技巧:RAG 场景下,尽量把系统提示词、知识库前缀放到 prompt 的最前面,因为 KV cache 是严格的“前缀复用”,后面的差异不会影响前面公共部分的缓存收益。
4.5 OOM 还是会出现
症状:即使启用了 LMCache,显存还是会偶尔 OOM。
排查思路:
gpu_memory_utilization调太低会导致 vLLM 分配的 KV block 池太小,反而让并发调度受限- 用
--max-num-seqs限制最大并发序列数,避免瞬时并发把 KV 池打爆 - 确认 LMCache 是否接管了所有 KV transfer,如果 vLLM 还在用默认的显存 KV 分配,两者的缓存池是脱离的
我在实测中把--max-num-seqs从默认值调小了一点,OOM 频率明显下降。对于需要高吞吐的场景,可以在批次大小和显存占用之间找一个平衡点。
5. 我的最终建议与经验分享
LMCache + vLLM 这套方案,我实际用下来的感觉是:它不是一个“把所有 KV cache 都塞到 SSD”的工具,而是一个“让 KV cache 在 GPU、CPU、SSD 之间流动”的调度系统。用得好的人,是在保证延迟可接受的情况下,把显存压力转移给更便宜的存储。
从部署策略上看,我建议这样安排:
- 热路径在 GPU:高频使用、正在生成的 active KV 块,必须留在显存
- 温数据在 CPU:扩大缓存容量,降低重复计算概率
- 冷备份在 SSD:兜底,保底不 OOM,也能跨实例复用
如果你的机器 CPU 内存很大,建议把cpu_threshold调高,尽量让 KV cache 留在 CPU 层,少走磁盘。如果你用的是普通 SATA SSD,不建议把大量 KV cache 放上去,IO 会成为瓶颈。
最后分享一个小技巧。调试 LMCache 最有效的办法不是看显存,而是看htop里的内存趋势和 SSD 目录下的文件变化。如果 CPU 内存占用在涨、SSD 有文件写入,说明卸载路径在正常工作;如果 CPU 内存和显存都很安静,那大概率配置没生效,先去查版本。
这套方案我现在已经用在内部的长文档问答服务上了。和纯 vLLM 相比,单卡能支撑的上下文总长度翻了不止一倍,相同显存下能开的并发请求也多了不少。虽然 SSD 冷读的延迟比显存命中慢很多,但对于批量离线场景来说,省钱又能跑得动,才是最重要的。