前阵子帮团队把一套基于 8B 开源模型的服务化推理部署到一张 RTX 4090 上,显存就 24 GiB,任务要求说起来很简单:模型权重得装进去,同时还要服务 4 路并发请求,每一路都完整支持 32K 上下文。我一开始觉得这配置很宽裕——8B 模型 FP16 权重也就 16 GB 出头,24 GiB 怎么都够了吧?结果第一轮压力测试就狠狠打了脸:权重装进去只是第一步,KV Cache 才是那个真正把显存吃干抹净的隐形大户。
这篇文章就围绕“权重装进了 24 GiB,四路 32K 上下文还装得下吗?”展开。我会把显存账单拆开逐项计算,给出 KV Cache 的手算公式和真实数字,再对比权重量化、KV Cache 量化、推理框架调度这几条可行路线,最后附上我实测通过的 vLLM 配置参数和几段踩坑记录。适合谁看?手头只有一张 24 GB 卡、想跑 7B/8B 模型对外服务的同学,以及正被 OOM 折磨、想知道显存到底被谁吃掉的同行。
1. 先算总账:24 GiB 被谁分走的
1.1 显存里的四张账单
很多人对“显存占用”的理解停留在模型权重上,实际上推理服务在 GPU 上花的钱至少有四笔:
- 模型权重:静态占用,加载后常驻,FP16 下约等于参数量 × 2 字节。
- KV Cache:随并发数和上下文长度动态增长,是目前服务化部署里最大的变量。
- 激活值与临时缓冲区:前向计算过程中的中间张量,prefill 阶段尤其凶猛。
- CUDA 上下文、图捕获、框架运行时:vLLM 启用 CUDA Graph 后会有几百 MB 到 2 GB 左右的隐性开销,经常被忽略。
这四笔里,前两笔是“固定资产”,后两笔是“流动资金”。24 GiB 看着不小,但固定资产稍微配置不当,流动资金就没了着落。
1.2 权重档位与真实占用
先看权重。以 8B 量级模型为例,不同精度下的静态占用差别很大:
| 精度 | 8B 模型权重占用 | 7B 模型权重占用 | 备注 |
|---|---|---|---|
| FP16/BF16 | 约 16 GB | 约 14 GB | 精度最好,但占地最大 |
| INT8 | 约 8 GB | 约 7 GB | 精度损失可忽略 |
| INT4/INT8 混合(AWQ、GPTQ) | 约 4.2 GB | 约 3.8 GB | 实测精度损失在 1% 以内 |
这里的计算逻辑很简单:10 亿参数在 FP16 下占 2 GB,在 INT8 下占 1 GB,在 INT4 下占 0.5 GB。所以“权重装进 24 GiB”这句话,在 8B 模型下无论哪个精度都能做到,甚至 13B 模型 INT4 也能勉强塞进去。问题从来不在权重本身。
1.3 权重装进去之后,还剩下什么
24 GiB 扣掉权重,才是真正的战场。以 FP16 权重为例,8B 模型吃掉 16 GB 后,账面上还剩 8 GB。听起来还能再塞点东西,但别忘了:
- CUDA Graph 和运行时通常要预留 1~2 GB;
- 激活值在最坏情况下(batch 大、序列长)能瞬间吃出几个 GB;
- 剩下的才是 KV Cache 的预算。
也就是说,FP16 权重的 8B 模型在 24 GiB 卡上,KV Cache 能用的空间大概只有 5~6 GB。这一路 32K 上下文需要多少 KV Cache?我们接着算。
提示:显存计算永远按“加载后实测”为准,厂商标的 24 GiB 实际可用会略少,驱动、显示输出、CUDA 初始化都会占掉一部分。
2. KV Cache:藏得最深的隐形大户
2.1 为什么推理必须存 KV
自回归生成是逐 token 进行的,每生成一个新 token,注意力层都要重新计算它与前面所有 token 的注意力分数。如果没有缓存,每步都要从头把整段历史重新算一遍,复杂度是 O(n²),根本没法做服务。
所以业界做法是:把已经算过的 Key 和 Value 向量存下来,新 token 来了只用它的 Query 去和缓存的 Key 做点积,再把新的 Key/Value 追加进缓存。这个缓存就是 KV Cache。它的体积和三个东西成正比:模型层数、注意力头配置、当前序列的累计 token 数。
可以用生活类比理解:KV Cache 就像聊天软件里的聊天记录。对话越长,记录越多,每次新消息都要翻一遍旧记录来“回忆上下文”。记录越多,占的磁盘(显存)越大。
2.2 一路 32K 的 KV Cache 手算过程
KV Cache 的公式可以写成:
每 token 占用 = 2 × 层数 × KV 头数 × head_dim × 字节数其中 2 代表 Key 和 Value 两份。以 Llama 3 8B 为例:
- 层数 32;
- KV 头数 8(GQA 结构,32 个 Query 头共享 8 个 KV 头);
- head_dim 128;
- FP16 下每元素 2 字节。
代入:
2 × 32 × 8 × 128 × 2 = 131,072 字节 = 128 KiB也就是说,这个模型每处理 1 个 token,就要新增 128 KiB 的 KV Cache。32K 上下文就是 32,768 个 token:
32,768 × 128 KiB = 4,194,304 KiB = 4 GiB注意,这只是一路请求、一个上下文。4 路并发同时跑满 32K,就是 16 GiB。现在你明白为什么 FP16 权重的 8B 模型在 24 GiB 卡上必挂了吧:16 GB 权重 + 16 GB KV Cache,已经 32 GB 了,还没算激活值。
2.3 GQA 和 MHA 的差距不是一点点
上面算的 128 KiB/token 是建立在 GQA(分组查询注意力)基础上的。如果换成一个没有 GQA 的 MHA(多头注意力)模型,情况会完全失控。
以 Llama 2 7B 为例,它是 32 层、32 个 KV 头:
2 × 32 × 32 × 128 × 2 = 524,288 字节 = 512 KiB/token一路 32K 上下文就要 16 GiB,4 路是 64 GiB。别说 24 GiB 卡,80 GiB 的 A100 都扛不住四路满长上下文。所以选模型时,KV 头数这个参数比总参数量重要得多。
再看 GQA 做得更极端的 Qwen2.5-7B:28 层、4 个 KV 头,每 token 占用:
2 × 28 × 4 × 128 × 2 = 57,344 字节 = 56 KiB一路 32K 只要 1.75 GiB,四路也才 7 GiB。这就是为什么同样 7B 参数,不同模型的显存命运完全不同。
| 模型 | 参数量 | 层数 | KV 头数 | 每 token KV 占用 | 一路 32K | 四路 32K |
|---|---|---|---|---|---|---|
| Llama 2 7B | 7B | 32 | 32 | 512 KiB | 16 GiB | 64 GiB |
| Llama 3 8B | 8B | 32 | 8 | 128 KiB | 4 GiB | 16 GiB |
| Mistral 7B | 7B | 32 | 8 | 128 KiB | 4 GiB | 16 GiB |
| Qwen2.5-7B | 7B | 28 | 4 | 56 KiB | 1.75 GiB | 7 GiB |
2.4 四路并发后的真实压力
这里还要提醒一点:KV Cache 是按序列分配的,而序列的实际 token 数在生成过程中一直在增长。四路请求如果都是从零开始生成,那显存占用是一路一路涨上去的;但如果四路请求各自带着长文档进来,第一轮 prefill 就把缓存打满了。
更隐蔽的问题是:请求结束、释放缓存之后,显存池里留下的碎片能不能被后续请求复用。传统 PyTorch 推理里,KV Cache 经常是预先按最大长度分配的,四路 32K 就是四块固定大小的 4 GiB 区域,哪怕实际只用 1K 也占着。这也是为什么我在下一节要专门讲显存池化和分页管理。
3. 组合拳:把四路 32K 塞进 24 GiB 的可行路线
3.1 路线 A:权重量化,先腾出地基
最直接的做法是把权重从 FP16 降到 INT8 或者 INT4。同样是 8B 模型:
- INT8 权重 8 GB,比 FP16 省出 8 GB;
- INT4 权重约 4.2 GB,比 FP16 省出近 12 GB。
省出来的空间全部可以划给 KV Cache。INT4 权重下,24 GiB 卡扣除 CUDA 开销和激活值预留后,KV Cache 预算大概有 17~18 GB,足够装四路 32K 的 FP16 KV Cache(16 GB),甚至还留有富余。
量化对质量的影响,8B 模型用 AWQ 或 GPTQ 的 4-bit 量化,实测在通用任务上掉点基本在 1% 以内;如果你跑的是代码、数学这类高严谨性任务,建议先用 INT8 试,不行再降 INT4。我个人的原则是:能 INT8 就不 INT4,质量优先。
3.2 路线 B:KV Cache 量化,直接砍掉一半
权重量化是“节流”,KV Cache 量化则是“直接把最大头砍半”。把 KV Cache 从 FP16 降到 INT8/FP8,每 token 占用从 128 KiB 降到 64 KiB,四路 32K 就从 16 GiB 变成 8 GiB。
vLLM 里对应是kv_cache_dtype="fp8",TensorRT-LLM 里是--kv_cache_dtype=fp8。量化后的 KV Cache 精度损失在短上下文下几乎感知不到,但在非常长的上下文、高重复内容的场景里会有累积误差,所以线上服务我一般建议只量化到 FP8,不要上 INT4 的 KV Cache。
组合效果:8B 模型 INT4 权重(4.2 GB)+ FP8 KV Cache(四路 32K 共 8 GB),总固定占用约 12.2 GB,加上运行时和激活值,24 GiB 卡还能剩 8 GB 以上给动态波动。这套组合是我这次实测的最终方案。
3.3 路线 C:PagedAttention 与显存池化
vLLM 的核心创新就是 PagedAttention,它把 KV Cache 切成固定大小的块(block),按需分配,用完释放。这个概念直接借鉴了操作系统里的分页机制,效果是显存不再被“按最大长度预分配”浪费掉。
举个例子:四路请求,每路虽然支持 32K,但实际平均只用 8K。传统预分配方案照样占 4 × 16 GiB = 64 GiB(如果是 FP16 模型),分页方案只分配实际用到的块,四路合计也就 4 × 2 GiB = 8 GiB 左右。这就是为什么同样的卡,用 vLLM 和用裸 PyTorch 部署,能服务的并发数完全不是一个量级。
PagedAttention 还支持跨请求共享物理块。多路请求如果共享同一个系统提示词前缀,这个前缀对应的 KV Cache 可以只存一份,四路直接共享读。对聊天机器人这种“系统提示词很长、用户内容很短”的场景,省下的空间非常可观。
3.4 路线 D:调度策略,chunked prefill 与连续批处理
内存问题不只是“总量”问题,还有“峰值”问题。四路请求同时进来,如果每路都做 32K 的完整 prefill,激活值峰值会非常吓人。以 8B 模型为例,隐藏层 4096,FFN 中间层 14336,单路 32K 的 prefill 激活值最坏情况下能把十几个 GB 瞬间打满。
解决办法是 chunked prefill:把超长 prompt 切成小块(比如每块 512 token),逐块计算。这样激活值峰值被压在百 MB 级别,代价是 prefill 吞吐略有下降。配合 continuous batching(连续批处理),decode 阶段的请求可以插进 prefill 的空隙里执行,GPU 始终处于满负荷状态。这也是 vLLM 打开enable_chunked_prefill=True后能稳定服务长上下文的核心原因。
这部分的经验是:不要迷信“最大吞吐”,服务化部署首要目标是稳定不 OOM,吞吐只需要满足业务水位即可。chunked prefill 牺牲的那点吞吐,换来的稳定性绝对值。
3.5 选型对照:哪种组合最适合你
把上面的路线整理成一张对照表,方便你对号入座:
| 组合方案 | 权重占用 | KV 占用(四路 32K) | 总固定占用 | 24 GiB 卡可行性 |
|---|---|---|---|---|
| FP16 权重 + FP16 KV | 16 GB | 16 GB | 32 GB+ | 不可行 |
| INT8 权重 + FP16 KV | 8 GB | 16 GB | 24 GB+ | 极限,几乎没有余量 |
| INT4 权重 + FP16 KV | 4.2 GB | 16 GB | 20.2 GB | 可行,余量一般 |
| INT4 权重 + FP8 KV | 4.2 GB | 8 GB | 12.2 GB | 很宽裕,推荐 |
| INT8 权重 + FP8 KV | 8 GB | 8 GB | 16 GB | 可行,质量最好 |
我的结论很明确:想在 24 GiB 卡上稳定扛四路 32K,至少要同时做权重量化和 KV Cache 量化,并且后端必须用带分页管理的推理框架。单靠某一条路,要么卡得死死的,要么余量小到一次长文本请求就把服务拖垮。
4. 实测:vLLM 配置与调参全记录
4.1 环境与模型清单
这次实测的环境:
- GPU:RTX 4090 24 GiB,驱动版本 550 系列,CUDA 12.4;
- 框架:vLLM 0.6.x 分支,使用 PagedAttention + chunked prefill;
- 模型:Llama 3 8B Instruct 的 AWQ 4-bit 量化版;
- 请求:4 路并发,每路最大 32K 上下文,系统提示词约 2K token,用户输入从 1K 到 28K 不等。
选 Llama 3 8B 的原因很简单:GQA 8 头让它每 token 的 KV 占用只有 128 KiB,是 24 GiB 卡上“能打”的上限模型。更大参数的模型即便量化后权重塞得下,四路 32K 的 KV 也会挤爆剩余空间。
4.2 关键参数逐项讲解
下面是最终稳定运行的 vLLM 配置核心参数:
from vllm import LLM, SamplingParams llm = LLM( model="TheBloke/Llama-3-8B-Instruct-AWQ", quantization="awq", dtype="float16", gpu_memory_utilization=0.92, max_model_len=32768, max_num_seqs=8, max_num_batched_tokens=2048, enable_chunked_prefill=True, kv_cache_dtype="fp8", trust_remote_code=True, )逐项解释为什么这么设:
gpu_memory_utilization=0.92:vLLM 会按这个比例预留显存给 KV Cache 池。0.92 意味着一共可用的 24 GiB 里,92% 由 vLLM 管理。我试过 0.95,能多挤一点缓存池,但 CUDA Graph 捕获阶段偶尔会失败,最后退回 0.92。这 8% 的余量是给框架、驱动、动态峰值买的保险。max_model_len=32768:这是框架允许的最大上下文长度。四路请求只要有一路超过这个长度,vLLM 会拒绝请求而不是截断,这是明确的行为,别和“支持 32K”混淆。max_num_seqs=8:这是框架内部最多同时处理的序列槽位数,不是“并发上限”。请求排队是由下游网关控制的,vLLM 只是负责一批一批地消化。设成 8 是为了给 4 路业务请求额外的缓冲槽位,避免 decode 和 prefill 互相阻塞。max_num_batched_tokens=2048:chunked prefill 时每一批次最多合计多少个 token。设小了 prefill 慢,设大了激活值高。2048 在 4090 上是甜点值。enable_chunked_prefill=True:超长 prompt 分段处理,防止 prefill 峰值把显存打爆。这是能不能稳定扛 32K 的关键开关。kv_cache_dtype="fp8":KV Cache 降到 8 bit,四路 32K 的缓存占用从 16 GiB 降到 8 GiB,立省 50%。
4.3 压测结果分析
用四路客户端同时发起请求,每路输入长度 28K token,要求生成 512 token。最终稳定指标:
- 总固定占用(含权重、KV、CUDA Graph):约 15 GB;
- 峰值占用:稳定在 20~22 GB 之间,没有触发 OOM;
- 聚合 decode 吞吐:约 55~60 token/s;
- 每路平均生成速度:约 14 token/s,首 token 延迟在 2~4 秒(chunked prefill 分段执行的正常水平)。
这个 14 token/s 单路速度,聊天场景完全够用,但对流式输出要求高的场景会感觉偏慢。原因也很清楚:decode 阶段每生成 1 个 token,GPU 都要把全部权重(INT4 约 4.2 GB)和全部 KV Cache(FP8 四路共 8 GB)读一遍,总共约 12 GB,4090 的显存带宽约 1 TB/s,理论下限就是 12 ms 左右一代。这是硬件物理限制,不是软件调优能突破的。
4.4 从 24 GiB 里再多抠一点
如果你希望把余量再做大,或者干脆把目标放到四路 64K,这几招实测有效:
- 压缩系统提示词:把 2K 的系统提示词压到 500 token,KV 占用直接少一截。这里本质是上下文工程的问题,prompt 设计不是只影响质量,还影响显存账单。
- 开启 prefix caching:vLLM 的自动前缀缓存(
enable_prefix_caching=True)会让多路请求共享系统提示词部分的 KV 块,四路共用一份系统提示词缓存。 - 限制单路最大生成长度:
max_tokens设成业务实际需要的值。很多人默认让模型“想生成多少生成多少”,结果每路多生成了几百上千 token,KV 跟着涨。 - 模型换 Qwen2.5-7B 这类 4 KV 头的小 GQA 模型:每 token KV 占用从 128 KiB 降到 56 KiB,四路 32K 一共只要 7 GiB,空间直接翻倍。
有一说一,fp8 KV Cache 在部分老显卡上不支持,如果你用的是 3090,需要先确认驱动和 vLLM 版本是否带 FP8 支持;不行就退回 INT8 的 KV 量化方案,效果也差不了太多。
5. 常见问题与避坑实录
5.1 OOM 是最诚实的老师
我这次踩过最大的坑是:权重量化成 INT4 后,FP16 KV Cache 方案在压测前十分钟一切正常,第十一分钟 OOM。原因后来查清楚了——四路请求里的其中一路输入特别长,触发了 prefill 激活值峰值,把预留的 KV 池空间临时挤爆了。
这个教训换成配置的话就是:gpu_memory_utilization 别贪心,max_num_batched_tokens 别往大调。以后凡是遇到偶发 OOM,第一反应不要是“把模型再量化低一点”,而是先看激活值峰值是不是把池子击穿了。用nvidia-smi盯显存曲线,能看到典型的长方形高台,那就是 prefill 峰值。
5.2 上下文长度不等于实际占用
“支持 32K 上下文”说的是max_model_len=32768,表示框架能接收这么长的输入。但 KV Cache 是按实际 token 数分配的——请求只传 1K token,就只占 1K 的缓存。这本来是好消息,但隐患在于:用户的上限不是你定的,是模型卡定的。
业务方如果只看到“支持 32K”,就可能把 30K 的长文档直接丢进来。四路请求同时丢长文档,峰值立刻起飞。所以我在网关层加了一道:超过 16K 的请求降级到单路独享,或者干脆排队。这不是怂,是让服务在业务波动下不挂。
另外提醒:token 数不等于字符数。中文大约 1~1.5 个 token 一个字,英文可能一个词拆成两三个 token。所谓“32K 上下文”,在中文场景大概对应 2 万字出头的文本,别跟用户宣传“能读 3 万字”。
5.3 并发路数与 max_num_seqs 的关系
很多新手以为max_num_seqs=4就能扛四路并发,这是误解。max_num_seqs是框架内部同时持有的序列槽位数,不直接等于外部并发。外部并发由网关/负载均衡控制,请求到了 vLLM 只会排队。
但槽位也不是越大越好。槽位多意味着 KV 池要被更多序列瓜分,每个序列可用缓存变少。实测下来,max_num_seqs设成“业务并发数的 1.5~2 倍”比较合理,既能抗抖动,又不会把池子切得太碎。这次四路业务我设 8,跑起来很稳。
5.4 监控与诊断工具箱
最后分享几个排查显存问题的工具,都是免费的:
nvidia-smi -l 1:看整卡显存曲线,区分固定占用和波动峰值;vllm日志里的GPU KV cache size:启动时会打印当前 KV 池大小和剩余预算,这个数字比 nvidia-smi 更精确地告诉你池子有多大;- vLLM 的
/metrics端点:里面有vllm:num_requests_running、vllm:cache_usage等指标,cache_usage接近 1 就说明池子快满了,该扩容或者限流了; - 如果你想看每一层、每一个张量的显存细分,用 PyTorch 的
torch.cuda.memory快照工具,能在 OOM 现场抓到是谁在申请内存。
我在实际监控中发现的一个规律是:服务一旦运行超过半天,KV 池的使用率会稳定在一个平台期,上下波动很小。这时候如果cache_usage长期超过 0.85,我会主动把max_num_seqs降一挡,宁可让请求排队,也不要把池子逼到极限。
最后再分享一个从这次部署里得出来的体会:显存这件事,永远不要拿“理论上够”当依据。理论账是静态的,真实流量是动态的,只有把权重、KV Cache、激活值、运行时这四笔账全部拆开,按最坏情况压测一遍,你才知道 24 GiB 到底装得下什么。我这次把四路 32K 成功塞了进去,靠的不是某一个神级参数,而是权重量化、KV 量化、分页管理、分块调度四件事同时做对。如果哪天你也要在类似配置上做部署,建议就从这四条路线同时下手,别只盯着某一项优化。