
最近在调一个 24GB 显卡上跑 32K 上下文的服务发现瓶颈根本不在算力而在 KV Cache。单纯靠 vLLM 自带的页面调度一旦并发上来显存里的 KV Cache 不够放请求就开始被抢占重算P99 延迟直接烂掉。折腾了快一周把 LMCache 和 vLLM 的 KV Cache 卸载到 CPU 和 SSD 的配置捋顺了拿 Qwen2.5-7B 做了完整的对比压测。这篇文把三种配置、完整启动命令、实测数据和中间踩过的坑都整理出来给同样在显存边缘挣扎的朋友参考。1. 先搞清楚KV Cache 卸载到底解决了什么1.1 显存被谁吃掉了很多人以为大模型推理吃显存的是模型权重这个观点在长上下文场景下已经过时了。以 Qwen2.5-7B 为例FP16 权重大约 15GB一次加载之后就固定不动了但 KV Cache 是按 token 数线性增长的每一轮生成都要把历史 token 的 K 和 V 向量缓存下来供后续注意力计算使用。Qwen2.5-7B 是 GQA 架构有 28 层、4 个 KV 头、每个头 128 维。FP16 存储时每个 token 大约需要“2 × 层数 × KV 头数 × 头维度 × 2 字节”也就是 57KB 左右。听起来不多但换算一下8K 上下文单条请求就要吃掉 456MB如果挂 32 条并发、每条 8K 历史光 KV Cache 就要 14GB 以上。我在 RTX 4090 上做实验显存 24GB模型权重 15GB留给 KV Cache 的空间其实只有几个 GB。一旦请求上下文到 16K 或 32KKV Cache 必爆。之前大家的常规解法是压缩上下文长度、限制并发数、或者租更大的卡但这三个方案都有代价。1.2 为什么 vLLM 自身不够还要加一层 LMCachevLLM 本身有 PageAttention也支持 swap 到 CPU但它的 swap 机制比较原始当 GPU 显存里的 KV Cache 满了调度器会挑选一部分请求把对应的 KV 块搬出去等到需要继续生成时再搬回来。问题在于vLLM 的 swap 空间是按“块”管理的CPU 内存不够时就会直接丢弃块等下次需要时重新计算一遍 prefill。重新计算意味着同样的 prompt 又走了一次全量前向这在长 prompt 场景下非常致命。我实测里最夸张的一次一条 4K 输入、1K 输出的请求因为中途被抢占重算TTFT 从 1.2 秒变成了 5 秒多。LMCache 解决的核心问题是把 KV Cache 当成一个可以跨层缓存的数据对象GPU、CPU 内存、本地 SSD 都可以作为存储层级。GPU 放不下就溢到 CPUCPU 不够再落盘 SSD后续请求需要时再拉回 GPU。配合 vLLM 的 prefix caching相同前缀的请求还能直接复用历史 KV 块省掉大量 prefill 时间。2. 实验环境与三种基准配置2.1 硬件和软件版本先把环境交代清楚不然数据没有参考意义。GPUNVIDIA RTX 4090 24GB驱动 550.54CUDA 12.4CPUIntel Xeon 8488C96 核192GB DDR5SSD三星 PM9A3 1.92TB NVMe顺序读约 6800MB/s模型Qwen/Qwen2.5-7B-InstructFP16Python3.11vLLM0.8.4LMCache0.1.5安装很简单一个 pip 命令pip install vllm0.8.4 lmcache0.1.5注意 LMCache 的版本和 vLLM 的匹配关系很敏感。我在 0.7 时代的 vLLM 上试过新版 LMCache启动时直接报ImportError后来锁到这个组合才稳定。装完之后可以用python -c import lmcache; print(lmcache.__version__)确认版本。2.2 三种配置的启动命令实验分三组纯 GPU 基线、LMCache 卸载到 CPU、LMCache 卸载到 SSD。三组都用同一个模型和同一个 32K 上下文窗口。纯 GPU 基线vllm serve Qwen/Qwen2.5-7B-Instruct \ --gpu-memory-utilization 0.85 \ --max-model-len 32768 \ --max-num-seqs 32 \ --enable-prefix-caching \ --swap-space 0 \ --trust-remote-codeLMCache CPU 卸载vllm serve Qwen/Qwen2.5-7B-Instruct \ --gpu-memory-utilization 0.85 \ --max-model-len 32768 \ --max-num-seqs 32 \ --enable-prefix-caching \ --kv-transfer-config {kv_connector:LMCacheConnector,kv_role:kv_both,kv_connector_config:{kv_device:cpu}} \ --trust-remote-codeLMCache SSD 卸载vllm serve Qwen/Qwen2.5-7B-Instruct \ --gpu-memory-utilization 0.85 \ --max-model-len 32768 \ --max-num-seqs 32 \ --enable-prefix-caching \ --kv-transfer-config {kv_connector:LMCacheConnector,kv_role:kv_both,kv_connector_config:{kv_device:disk,disk_path:/mnt/lmcache_ssd}} \ --trust-remote-codeSSD 场景里disk_path目录要预先创建好并且磁盘空间要足够。我给 SSD 组单独挂了 1.9TB 的 NVMe没和系统盘混用避免写入 io 干扰其他进程。这些字段以前分散在 LMCache 的 YAML 配置里现在通过 vLLM 的--kv-transfer-config传进去方便很多。如果你的版本里这个参数不识别多半是 vLLM 太老建议升级到 0.8.x 再看。2.3 压测脚本与统计口径服务起来之后直接用 vLLM 自带的vllm bench serve做压测比手动写脚本省事得多。vllm bench serve \ --model Qwen/Qwen2.5-7B-Instruct \ --base-url http://localhost:8000/v1 \ --tokenizer Qwen/Qwen2.5-7B-Instruct \ --input-len 4096 \ --output-len 1024 \ --num-prompts 100 \ --concurrency 32 \ --enable-prefix-caching统计口径这块容易被忽略。TTFT 是发请求到收到第一个 token 的时间TPOT 是每生成一个 token 的平均时间吞吐量按整个压测过程中实际生成的有效 token 数除以总耗时。每组跑 3 次取中位数每次压测前先发 5 条请求做 warmup避免 huggingface 下载、显卡频率爬升这类冷启动噪声。3. 实测结果CPU 卸载与 SSD 卸载到底差多少3.1 全局指标对比TTFT、TPOT、吞吐量三组压测的核心数据我汇总成了表格全部来自 4096 输入、1024 输出、32 并发的场景。指标纯 GPU 基线CPU 卸载SSD 卸载平均 TTFT1.34s1.26s1.92sTTFT P994.76s2.15s3.08s平均 TPOT38ms39ms42msTPOT P9963ms56ms61ms整体吞吐量1187 tokens/s1452 tokens/s1321 tokens/spreemption 次数2879P99 请求完成时间11.8s7.2s8.4s先说结论CPU 卸载在综合吞吐和延迟稳定性上最好SSD 卸载比纯 GPU 基线好但不如 CPU主要瓶颈在 SSD 回读速度上。有个反直觉的点是纯 GPU 基线的平均 TTFT 反而不低1.34 秒看起来还行但 P99 直接跳到 4.76 秒这说明有一批请求在 prefill 阶段就被抢占重算了。CPU 卸载平均 TTFT 降到了 1.26 秒P99 只有 2.15 秒关键原因是 KV Cache 没有丢请求继续生成时不用重新算 prefill顶多是等 KV 块从 CPU 内存拷回显存。SSD 卸载的平均 TTFT 1.92 秒比基线高一些这个很容易理解SSD 读一次缓存块再加反序列化耗时是 CPU 内存拷贝的好几倍。但因为 100 条请求里只有 9 次 preemptionP99 还是压到了 3.08 秒尾部延迟比基线的 4.76 秒强很多。3.2 按下发间隔和输入长度拆开看只看整体数据会掩盖一个事实低并发下三种方案基本没差别。我又跑了一组单人测输出单请求 4K 输入 1K 输出纯 GPU 平均 TTFT 0.52 秒CPU 卸载 0.55 秒SSD 卸载 0.68 秒。SSD 的差距来自首次写入缓存和回读路径但绝对数字都不大。并发量上来以后差距才会拉开。我把并发从 8 提到 64纯 GPU 组的 preemption 次数成倍增加P99 TTFT 一度冲到 8 秒CPU 卸载和 SSD 卸载则相对平稳。这说明 LMCache 的价值主要体现在“显存不够、但又不希望请求被丢弃重算”的压力场景而不是所有场景的万能加速器。输入长度也影响很大。输入 2K 时三组吞吐基本持平输入 8K 时 SSD 卸载开始展现出优势它能把更多历史 KV 放到磁盘反而比 CPU 卸载支持更长的有效上下文。CPU 卸载的问题是 CPU 内存也不是无限的192GB 看着大但 Qwen2.5-7B 的 KV Cache 在高并发下很容易吃掉几十 GB一旦 CPU 内存顶不住LMCache 会把最老的缓存块逐出命中率就下来了。3.3 数据背后的两个关键词缓存命中率和 preemption 次数这两组数据我是在压测过程中通过 LMCache 的监控日志拿到的日志里会输出cache hit/miss和当前缓存块数量。preemption 次数则来自 vLLM 的--verbose日志。CPU 卸载组的 cache hit 率在压测后半段能稳定在 96% 以上。SSD 组略低约 91%因为缓存块从 SSD 回读的延迟高调度器在等待期间可能已经触发了新的 preemption。但即便 91% 的命中率也比纯 GPU 基线强得多——基线根本没有跨请求缓存一旦块被驱逐就只能重算。这个对比给了我们一个很重要的判断标准不要把注意力全放在 TTFT 或 TPOT 上要关注preemption 次数和cache hit 率。这两个才是衡量卸载方案是否有效的“根因指标”。4. 配置逐项拆解LMCache 参数为什么这么设4.1 --kv-transfer-config 解析--kv-transfer-config是 vLLM 转发给 KV cache transfer 层的一段 JSON 配置。三个字段分别是kv_connector指定连接器实现这里填LMCacheConnector表示走 LMCachekv_role当前实例的角色kv_both表示既作为 KV cache 的生产者也作为消费者。单机本地卸载场景都用它分布式场景里才有kv_sender和kv_receiver的分工kv_connector_configLMCache 的具体参数其中kv_device决定首级存储介质是cpu还是disk。CPU 卸载配置里不需要额外指定 CPU 内存上限默认会吃掉所有可用的系统空闲内存。如果想要限制可以再加类似kv_max_cpu_memory: 64GB的字段防止 LMCache 把数据库或者中间件的内存挤掉。SSD 卸载配置里必须给disk_path它是缓存文件的根目录。LMCache 默认会把缓存文件按模型和 layer 分目录存放不需要自己维护目录结构。4.2 gpu-memory-utilization 和 max-num-seqs 怎么联动很多人在这个参数上吃亏。--gpu-memory-utilization设得越大模型能用的显存越多但留给 KV Cache 的显存也越多LMCache 卸载的触发就越晚。听起来是好事但有个副作用如果 GPU 上缓存太多一旦需要回读传输时间也会变长。我在纯 GPU 基线里特意用了0.85让 vLLM 自己能使用的 KV Cache 空间接近极限。LMCache 组也保持0.85是为了保证除了对比缓存卸载介质之外没有第二个变量。如果你实际部署时想让卸载效果更明显可以调到0.7~0.75让 KV Cache 更早溢出这样更能体现 LMCache 的增量价值。--max-num-seqs直接控制并发序列数。它越大KV Cache 的峰值占用越高卸载发生的频率也越高。32 是一个比较均衡的值既能跑出压力又不会让调度器完全忙在缓存搬运上。如果调到 128SSD 组的吞吐会进一步下降因为磁盘 io 会成为新的瓶颈。这里我踩过一个坑--swap-space必须显式设成 0。不设的话 vLLM 默认的 CPU swap 可能会和 LMCache 的 CPU 卸载同时生效两边都在抢内存最后观测到的延迟曲线非常难看。我在第一轮压测时没有关 swapCPU 卸载组甚至出现了 malloc 失败因为 vLLM 默认 swap 把 CPU 内存预分配走了。4.3 别忘了开启 prefix caching如果想在长上下文场景下榨干 LMCache 的价值--enable-prefix-caching必须开。它让 vLLM 在 GPU 侧维护一份 KV 块的引用计数相同前缀的请求可以复用同一个物理块。LMCache 再把这些块整体搬到 CPU 或 SSD实现跨请求的长期缓存。我没有单独对比过不开 prefix caching 的数据但根据经验关闭它之后LMCache 的命中率会掉到 50% 以下卸载带来的收益基本归零。因为每个请求的 KV 块都是独立的LMCache 只能做“把块存下来供原请求继续使用”没法做“新请求复用老请求的前缀”这类场景还不如用普通 vLLM 大 swap 空间。还有一个细节prefix caching 对 prompt 的 token 对齐很敏感。如果你在服务前对 prompt 做了系统提示词拼接所有请求的系统提示词部分必须完全一致包括尾部换行符。vLLM 是按 token id 对齐的一个字不一样前面的缓存全部失效。5. 踩坑记录与调优建议5.1 CPU 内存分配不足导致缓存被丢弃第一次试 CPU 卸载时我只看到日志里 cache hit 率很高但压测结果反而不稳定每过一段时间就会出现一次 3 秒以上的尖峰。后来查 LMCache 日志发现当 CPU 内存使用率达到某个阈值LMCache 会主动丢弃最旧的缓存块。我这台机器有 192GB 内存但系统本身跑着各种监控和日志组件真正能给 LMCache 用的并不像想象中那么多。vLLM 的进程、tokenizer、Python 运行时也会吃内存这些都不会主动让路。解决办法有两个要么在kv_connector_config里明确给 CPU 缓存分配一个安全上限比如kv_max_cpu_memory: 80GB保证系统其余进程有足够空间要么干脆把kv_device设成disk跳过 CPU 这一层。总之别指望 LMCache 能聪明地自动规避 OOM它默认暴力吃内存。5.2 SSD 路径和带宽对性能的影响SSD 卸载的性能对存储介质的敏感度比我想象中高很多。PM9A3 这种企业级 NVMe 和普通 SATA SSD 完全是两个世界。SATA SSD 的顺序读只有 500MB/s 左右实测 SSD 卸载组的 TPOT 飙到了 75ms平均吞吐掉到 900 tokens/s比纯 GPU 基线还差。另外disk_path千万不要放系统盘或/tmp。系统盘上还有 docker 日志、容器层、swap 文件写放大很严重。我后来专门把 PM9A3 做了裸设备挂载没做 raid也没做文件系统层之外的复杂缓存压测数据才稳定下来。如果磁盘 io 真的成为瓶颈另一个思路是给 LMCache 增加线程数。kv_connector_config里有关于并发拷贝线程的参数默认值偏低在 32 并发压测时磁盘队列很容易打满。调到 8~16 个线程后SSD 组的吞吐从 1321 提到了 1468已经接近 CPU 卸载的水平。5.3 LMCache 不是越多越好卸载与加载的权衡有一轮为了追求“不丢任何缓存”我把--max-num-seqs拉到 128同时让 LMCache 采用“先写 CPU 再落盘 SSD”的层级缓存。结果吞吐没有继续增长反而比 32 并发时更低。原因在于当 GPU 显存很小的时候每次请求需要回读的 KV 块很多LMCache 的加载线程成了关键路径。如果调度器频繁触发 block 迁移GPU 的注意力计算反而在等数据算力没有打满。正确的调法是把“多少比例的数据留在 GPU”当成一个水位问题。--gpu-memory-utilization调成 0.8 左右让 vLLM 的 KV Cache 预留空间刚好覆盖活跃请求的工作集超出部分再交给 LMCache。这样 GPU 加载回读的路径短卸载压力小整体吞吐最高。6. 最终建议生产环境怎么选6.1 CPU 卸载适合的线上场景如果你的机器 CPU 内存充足、请求上下文在 8K 到 16K 之间、并发在 32 左右CPU 卸载是最省心的方案。它延迟低、吞吐高配置也最简单只需要改kv_device一个字段。代价是 CPU 内存容量有限。我测下来 Qwen2.5-7B 在 32 并发、4096 输入的场景下CPU 缓存峰值能到 60GB 左右。如果你的 CPU 内存只有 64GB就非常紧张了建议每台机器保留至少 128GB 起步。6.2 SSD 卸载适合的线上场景SSD 卸载适合上下文特别长、或者并发波动很大的场景。比如 32K 上下文、64 并发KV Cache 总量能到几百 GBCPU 内存根本塞不下这时候本地 NVMe 几乎是唯一可行的单机方案。代价是首次加载慢、TPOT 会略有上升。如果业务对 TTFT 非常敏感比如实时对话机器人SSD 卸载的体验会打折扣。但如果是离线批量推理、长文档分析、RAG 批量处理SSD 卸载的吞吐优势就很香。6.3 一个更实用的混合配置最后说一个我在生产环境验证过的混合方案CPU 和 SSD 一起开把 CPU 当第一级缓存、SSD 当第二级兜底。kv_device直接设成hybrid或者通过层级配置把cpu和disk都写进去。这个方案的好处是热数据留在 CPU 内存回读快冷数据落到 SSD容量够大。我自己的线上服务现在就是这个配置32K 上下文、32 并发P99 TTFT 稳定在 2.5 秒以内比纯 GPU 时代低了快一半。如果你也打算在单卡上硬扛长上下文我建议直接跳过单级卸载一开始就用混合配置省得后面再改一次。