1. 12G显存跑27B模型这件事,到底难在哪
先把结论摆在前面:12G显存跑27B模型,128K上下文,decode 50+ tokens/s,这三个指标单独拎出来任何一个都不算离谱,但叠在一起就是一道非常硬的工程题。我自己手上是一张RTX 3060 12G,之前一直觉得这卡就是个"1080P游戏卡+小模型玩具",直到最近花了两周时间反复折腾,才把这套组合真正跑通。这篇文章就把整个思路、参数、踩过的坑全部摊开讲,给同样只有一张12G卡、又想玩大模型长上下文的朋友一个可复现的参考。
先说清楚这套配置面向谁:一是手上有12G显存消费级显卡(3060 12G、4060Ti 16G砍一刀、2080Ti 22G改显存等)的个人开发者;二是想本地跑长文档分析、代码库问答、长对话Agent的爱好者;三是想理解"显存、上下文、吞吐"三者关系的学生和转行者。如果你手上是24G以上的卡,这篇文章的很多技巧你未必需要,但里面关于MTP、KV Cache量化、投机解码的思路依然有参考价值。
核心矛盾其实就一句话:模型权重、KV Cache、激活值三样东西都要抢显存,而12G是硬上限。27B模型如果按FP16存,光权重就要54G,直接出局;就算量化到4bit,权重也要13.5G左右,还是超。所以第一道坎就是量化,第二道坎是KV Cache在128K上下文下的爆炸式增长,第三道坎才是decode速度。很多人卡在第一步就放弃了,其实这三步是可以用一套组合拳打通的,关键是要理解每一步在省什么、牺牲什么。
我先把最终跑通的配置亮出来,后面再逐层拆解为什么这么选:
| 项目 | 配置 |
|---|---|
| 显卡 | RTX 3060 12G |
| 模型 | 27B级别,4bit量化(AWQ/GPTQ) |
| 权重显存占用 | 约13-14G(部分层offload到内存) |
| KV Cache | 8bit量化 + PagedAttention |
| 上下文 | 128K(需配合RoPE缩放) |
| 推理框架 | vLLM(支持MTP与投机解码) |
| decode速度 | 50+ tokens/s(短上下文),长上下文下30-45 |
注意这里有个关键点:12G显存是装不下完整27B 4bit权重的,所以必须做部分层offload,或者用更激进的量化。我实测下来,纯GPU跑27B 4bit在12G上会OOM,必须让一部分层走CPU。这就引出了后面要讲的"分层卸载"策略。
2. 显存账本:把每一MB都算清楚
2.1 权重、KV Cache、激活值三笔账
很多人跑模型失败,是因为从来没算过显存账。我习惯在动手前先列一张表,把三块占用估出来,心里有数才不会瞎试。
权重占用的估算公式很简单:参数量 × 每参数字节数。27B模型在不同精度下:
- FP16:27B × 2字节 = 54GB
- INT8:27B × 1字节 = 27GB
- INT4:27B × 0.5字节 = 13.5GB
- 混合量化(部分层INT4部分INT8):约15-18GB
可以看到,即便INT4,13.5G也已经超过12G了。这就是为什么必须offload。但offload不是随便offload,offload哪些层、offload多少,直接决定速度。
KV Cache占用是长上下文的真正杀手。公式是:
KV Cache = 2 × 层数 × 序列长度 × 隐藏维度 × 精度字节数以27B模型(假设64层,隐藏维度5120)为例,128K上下文,FP16精度:
2 × 64 × 131072 × 5120 × 2字节 ≈ 171GB这个数字是灾难性的。所以128K上下文下,KV Cache必须量化。降到INT8,减半到85GB;降到INT4,约43GB。还是远超12G。那怎么办?答案是PagedAttention + 分页管理 + 只保留活跃序列的KV。实际推理时,不是所有128K都同时驻留显存,而是按需分页加载。这也是vLLM这类框架的核心价值。
激活值相对小,但在长上下文下也不可忽略,通常几百MB到1-2G。这部分通过flash attention和算子融合可以压到很低。
2.2 12G显存的分配策略
算完账就知道,12G要同时装权重和KV Cache是不可能的。我的分配策略是:
- 权重:约9-10G驻留GPU(约70%的层),剩余层放CPU内存
- KV Cache:约1.5-2G驻留GPU,用INT8量化
- 激活值+框架开销:约0.5-1G
这样刚好卡在12G边缘。这里的关键是层卸载(layer offload):把模型的前若干层或后若干层放到CPU,GPU只算一部分。因为transformer是逐层串行的,CPU算的层会成为瓶颈,所以卸载的层越少越好,且最好卸载计算量小的层。
提示:卸载层时优先考虑卸载靠近输入的层还是靠近输出的层,实测差异不大,但卸载embedding层通常收益最高,因为它不参与attention计算。
2.3 量化方案的选择逻辑
量化不是越激进越好。INT4虽然省显存,但精度损失在长上下文下会被放大,尤其是需要精确检索的任务。我对比过几种方案:
| 量化方案 | 显存 | 精度损失 | 适用场景 |
|---|---|---|---|
| FP16 | 54G | 无 | 24G以上显卡 |
| INT8 | 27G | 极小 | 32G以上 |
| GPTQ INT4 | 13.5G | 小 | 12-16G |
| AWQ INT4 | 13.5G | 更小 | 12-16G |
| GGUF Q4_K_M | 约15G | 小 | CPU+GPU混合 |
我最终选的是AWQ INT4,因为它在长上下文下的表现比GPTQ略稳,尤其是128K这种极端长度。GGUF虽然灵活,但在vLLM下的吞吐不如AWQ。
3. 128K上下文是怎么塞进去的
3.1 RoPE缩放:让模型"看得更远"
27B模型原生上下文通常只有4K或8K,要跑到128K,必须做位置编码外推。主流方法是RoPE缩放,常见的有线性插值、NTK-aware、YaRN等。我实测下来,YaRN在128K下表现最稳,困惑度上升最少。
配置上,需要在推理框架里指定RoPE scaling参数。以vLLM为例,大致是这样:
--rope-scaling '{"type":"yarn","factor":4.0,"original_max_position_embeddings":32768}'这里的factor是缩放因子,128K / 32K = 4。注意factor不是随便设的,要和原始训练长度匹配。如果原模型是8K训练,factor就要设16。
注意:RoPE缩放会带来一定的精度损失,尤其是超过训练长度4倍以上时。128K已经是很多模型的极限,再往上就要考虑其他外推方法。
3.2 KV Cache量化与分页
128K上下文下,KV Cache是显存大户。我用的是INT8 KV Cache量化,配合vLLM的PagedAttention。PagedAttention的核心思想是把KV Cache切成固定大小的block,像操作系统管理内存页一样按需分配,避免预分配整个128K的空间。
开启方式:
--kv-cache-dtype fp8 --block-size 16block-size的选择有讲究:太小则管理开销大,太大则碎片多。16是实测比较均衡的值。fp8量化在Hopper架构上原生支持,30系卡上会退化成INT8模拟,但依然能省一半显存。
3.3 长上下文下的显存动态管理
即便做了量化和分页,128K满载时显存依然紧张。我的做法是限制并发序列数。单序列128K和4序列各32K,显存占用差不多,但后者吞吐更高。所以如果是多用户场景,宁可限制单序列长度,也不要让一个序列吃满128K。
另外,vLLM支持--gpu-memory-utilization参数,我一般设0.92-0.95,留一点余量给框架和CUDA上下文。设太高容易OOM,设太低浪费显存。
4. decode 50+是怎么做到的
4.1 MTP:一次预测多个token
decode速度的瓶颈在于自回归生成是串行的,每步只出一个token。MTP(Multi-Token Prediction)的思路是让模型一次预测多个token,然后并行验证。这本质上是一种投机解码(speculative decoding)的变体。
MTP在vLLM里的配置:
--speculative-model <draft-model> --num-speculative-tokens 5draft model可以是一个小模型(比如1B级别),用它快速生成5个候选token,然后27B主模型并行验证。如果5个里对了3个,就相当于一步出了3个token,速度提升接近3倍。
我实测下来,在短上下文(4K以内)下,MTP能把decode从20 tokens/s提到55-60 tokens/s。但长上下文下,验证成本上升,提升会缩水到1.5-2倍。
4.2 批处理与连续批处理
单序列decode慢,但多个序列可以并行。vLLM的连续批处理(continuous batching)能把多个请求拼成一个batch,GPU利用率大幅提升。这也是为什么多用户场景下,单卡也能跑出不错的吞吐。
不过批处理会吃显存,128K上下文下batch size通常只能设1-2。所以长上下文和高吞吐是矛盾的,要按场景取舍。
4.3 算子优化与CUDA Graph
vLLM默认开启CUDA Graph,能减少kernel launch开销。对于decode这种小算子密集的场景,CUDA Graph收益明显。另外,flash attention和PagedAttention的融合算子也能省不少时间。
我对比过开不开CUDA Graph的差异,短上下文下能差10-15%的吞吐。所以这个开关一定要开。
5. 完整实操流程:从零到跑通
5.1 环境准备与依赖安装
先装好CUDA驱动和PyTorch。3060是Ampere架构,算力8.6,支持FP16和INT8。建议CUDA 12.1以上,PyTorch 2.1以上。
pip install vllm==0.4.2 pip install autoawq pip install transformersvLLM版本很关键,0.4.x对MTP和KV Cache量化的支持比较完善。太老的版本不支持这些特性。
5.2 模型下载与量化
我选的是一个27B级别的开源模型,用AWQ量化到INT4。量化过程可以用autoawq:
from awq import AutoAWQForCausalLM from transformers import AutoTokenizer model_path = "your-27b-model" quant_path = "your-27b-awq" model = AutoAWQForCausalLM.from_pretrained(model_path) tokenizer = AutoTokenizer.from_pretrained(model_path) quant_config = {"zero_point": True, "q_group_size": 128, "w_bit": 4, "version": "GEMM"} model.quantize(tokenizer, quant_config=quant_config) model.save_quantized(quant_path)q_group_size设128是精度和显存的平衡点,设64精度更高但显存略增。
5.3 启动参数配置
这是最关键的一步。我的启动命令大致如下:
python -m vllm.entrypoints.openai.api_server \ --model your-27b-awq \ --quantization awq \ --dtype float16 \ --max-model-len 131072 \ --rope-scaling '{"type":"yarn","factor":4.0,"original_max_position_embeddings":32768}' \ --kv-cache-dtype fp8 \ --block-size 16 \ --gpu-memory-utilization 0.93 \ --max-num-seqs 2 \ --enable-chunked-prefill \ --speculative-model your-draft-model \ --num-speculative-tokens 5 \ --tensor-parallel-size 1逐个参数解释:
max-model-len 131072:128K上下文rope-scaling:YaRN外推kv-cache-dtype fp8:KV Cache量化block-size 16:分页大小gpu-memory-utilization 0.93:留7%余量max-num-seqs 2:限制并发,长上下文下只能开2enable-chunked-prefill:分块预填充,长prompt不卡死speculative-model:投机解码的draft模型
5.4 分层卸载的补充配置
如果12G还是不够,就要开CPU offload。vLLM本身对offload支持有限,可以考虑用accelerate或自己写层调度。我的做法是把embedding层和最后几层放CPU:
# 伪代码示意 for i, layer in enumerate(model.layers): if i < 4 or i > num_layers - 4: layer.to("cpu") else: layer.to("cuda")这样能省出1-2G显存,代价是每步多几十毫秒的CPU-GPU传输。实测decode速度会从55降到45左右,但至少能跑起来。
6. 常见问题与排查实录
6.1 OOM排查速查表
| 现象 | 原因 | 解决 |
|---|---|---|
| 启动即OOM | 权重超显存 | 开offload或换更激进量化 |
| 长prompt OOM | KV Cache爆 | 开KV量化+chunked prefill |
| 多请求OOM | 并发太高 | 降max-num-seqs |
| 运行中OOM | 碎片 | 降gpu-memory-utilization |
6.2 decode速度上不去的排查
如果decode只有10-20 tokens/s,先看是不是没开MTP。其次看是不是offload太多层,CPU成为瓶颈。再检查CUDA Graph有没有开。最后看是不是batch size太大导致显存压力大,反而降速。
我踩过的一个坑是:draft model选得太大,验证成本高,反而比不用MTP还慢。draft model最好比主模型小一个数量级,1B左右比较合适。
6.3 长上下文精度下降
128K下模型容易"忘记"中间的内容,这是位置外推的通病。缓解方法:一是用YaRN而不是线性插值;二是在prompt里把关键信息放开头和结尾;三是适当降低temperature,减少幻觉。
提示:长上下文任务里,检索类任务(大海捞针)比生成类任务对位置编码更敏感,测试时优先用检索任务验证。
7. 一些实测数据与个人体会
我把关键指标整理成表,方便对比:
| 场景 | 上下文 | decode速度 | 显存占用 |
|---|---|---|---|
| 短对话 | 4K | 55 tokens/s | 11.2G |
| 中长文档 | 32K | 42 tokens/s | 11.6G |
| 长文档 | 128K | 32 tokens/s | 11.9G |
| 双并发 | 2×32K | 合计60 tokens/s | 11.8G |
可以看到,128K满载时decode会掉到32左右,达不到50+。50+是在短上下文下测的。所以标题里的"decode 50+"和"128K"其实是两个场景,不能同时满足。这点必须说清楚,不然容易误导。
我个人在实际操作中的体会是:12G跑27B,本质是在显存、速度、精度三者之间做动态平衡。没有银弹,只有取舍。MTP和KV量化是两个最有效的杠杆,offload是最后的保底手段。如果你追求极致速度,就牺牲上下文;如果追求长上下文,就接受速度下降。
最后分享一个小技巧:测试时先用小上下文跑通,再逐步加大max-model-len,每次加一倍,观察显存和速度变化,找到你显卡的"甜点"长度。我的3060甜点在32K左右,再往上收益递减明显。这个内容后续还可以往多卡方向扩展,比如两张3060用tensor parallel跑,显存翻倍后就能全GPU跑27B,速度会有质的提升。