上个月我把翻出来的RTX 3060 12G从游戏机房里重新装好,给自己定了个有点离谱的目标:跑一个27B量级的MoE模型,开满128K上下文,生成速度稳住50+ tokens/s。当时群里几个朋友的第一反应都是“12G显存跑27B?你在想什么”,我自己心里也没底,但折腾完发现,这个组合确实能跑,而且跑出来的体验远超预期。这篇就把完整的思路、命令、参数和踩过的坑都整理出来,给同样手里只有12G到16G显存、想跑大参数MoE模型的人一份可以直接照抄的作业。
先说清楚一件事:这里说的decode 50+,是LLM生成token时的速度,也就是每秒输出50个token以上,跟浏览器里图片解码失败那个decode没有任何关系。下文所有“decode”都指自回归生成阶段的解码速度。
1. 27B模型不是显存黑洞:先搞清楚它在“吃”什么
1.1 别被参数量唬住:总参数和激活参数是两回事
很多人一看到27B,第一反应是“27B×2字节=54GB显存”,然后直接放弃。这个算法本身没错,但它隐含的前提是——模型权重必须全部常驻显存,并且每个token都要让全部参数参与计算。对小显存玩家来说,这个前提恰好是可以绕开的。
先说权重常驻的问题。无论什么模型,27B的权重确实都要加载,FP16下就是54GB,这个逃不掉。所以“显存不够”的第一反应应该是量化,把权重从2字节压到0.5字节左右,27B就能压到14到18GB。但12G还是不够,于是就有了MoE架构这张牌。
再说激活参数。传统Dense模型每个token都会激活全部270亿参数,但MoE模型不一样。以Qwen3-30B-A3B这种“假30B”为例,总参数30.5B,但每次推理只会激活约3.3B参数,其余参数通过路由器只挑相关的专家计算。打个比方:一家两千多人的公司,真正每天跑业务的只有几十个人,剩下的人平时就坐那待命。MoE让小显存跑大模型成为可能,但它并没有解决权重全量加载的问题——待命的员工也在占工位。
1.2 下一个问题:选什么样的27B
12G显存想跑得动,首选就是MoE架构,而且要选“总参数在27B附近、激活参数比较小”的那种。市面上比较典型的有Qwen3-30B-A3B、MiniMax-M1-27B这类,都是原生支持128K长上下文的MoE模型。它们的共同特征是:attention部分相对轻,FFN专家部分被切成很多个小专家,路由后只有少量专家参与计算。
这类模型特别适合“GPU只保留核心结构,专家层放内存”的玩法——只让大概率被路由到的专家留在GPU,或者干脆全部专家放内存,反正每次只算少数几个。这也是后面所有优化能成立的基础。
1.3 量化等级怎么选:不是越省越好
权重量化有很多档位,GGUF格式里常见的Q2_K、Q3_K、Q4_K_M、IQ4_XS、Q8_0。12G显存跑27B,理论上一顿猛压是可以塞进去的,但不能只看能不能塞。长上下文场景下,量化等级太激进,输出质量会肉眼可见地下降,说话前言不搭后语的情况会明显增加。
我的实际建议是:IQ4_XS或Q4_K_M是底线,尽量不要低于Q4。这两种量化等级下,27B权重大概在15到19GB之间,GPU虽然放不下,但配合后面的offload策略可以在内存里装得很稳。Q8_0虽然质量最好,但同样27B要30GB左右,内存和显存都会很紧张,除非你的机器内存有64GB以上,否则不建议。
1.4 重新算一笔显存账
真正跑一个模型时,显存消耗主要有三块:权重、KV Cache、临时激活。三者不是并列关系,权重和KV是常驻的,临时激活是每个请求算完就释放的。12G显存里要同时塞下权重和KV,就必须做取舍:
| 方案 | 权重位置 | KV Cache位置 | 12G显存能否搞定 |
|---|---|---|---|
| 全部塞GPU | GPU | GPU | 基本不可能,27B权重量化后也要15GB以上 |
| 全部放内存 | CPU内存 | CPU内存 | 能跑但慢,decode往往只有个位数 |
| 专家层放内存,attention层放GPU | 部分GPU+部分内存 | GPU | 推荐,这也是本文的核心方案 |
我最后选的就是第三种。Attention层权重不大,加上量化后的KV Cache,12G完全够用;而占大头的专家层放内存,每token只计算其中几个专家,内存带宽虽然比不上显存,但胜在计算量小,速度反而不差。这个方案的具体参数和命令,第三章会详细给。
2. 128K上下文的隐藏成本:KV Cache把显存吃干抹净
2.1 KV Cache到底是什么,为什么128K这么“刺客”
很多第一次跑长上下文的人,把模型加载好之后,发现显存占用明明不高,一但把上下文长度调到128K,立刻就报显存不足。原因就出在KV Cache上。
自回归生成时,模型每输出一个新token,都要参考前面所有历史token的Key和Value向量。为了不每次都重新算一遍历史,推理引擎会把这些向量缓存下来,这个缓存就叫KV Cache。它的大小跟上下文长度成正比——上下文越长,缓存的历史越多,显存就越大。更关键的是,KV Cache的增长是动态的,不像权重那样加载完就固定。
2.2 给一个具体模型算算账
拿一个中型MoE模型举例:48层Transformer、8个KV头、每个头128维。128K上下文的KV Cache显存占用可以用这个公式估算:
KV Cache大小 = 2(K和V)× 层数 × KV头数 × head_dim × 上下文长度 × 每个元素字节数
按FP16(2字节)算: 2 × 48 × 8 × 128 × 131072 × 2 = 25.8GB
是的,光一个128K上下文的KV Cache,FP16下就能吃掉25.8GB显存,比27B模型量化的权重还大。这也是为什么很多人觉得“12G跑128K是做梦”——单看这段账,确实像是在做梦。
但如果把KV Cache做量化,情况就完全不一样了:
| KV Cache精度 | 每元素字节数 | 128K上下文占用 |
|---|---|---|
| FP16 | 2字节 | 约25.8GB |
| Q8_0 | 1字节 | 约12.9GB |
| Q4_0 | 0.5字节 | 约6.4GB |
| Q8_K + Q4_V混合 | 平均0.75字节 | 约9.7GB |
kv cache量化已经是很成熟的技术,llama.cpp里直接用--cache-type-k q8_0 --cache-type-v q4_0就能混合量化:K用8bit保留召回精度,V用4bit省显存。实际效果上,长上下文场景下K比V对精度更敏感,所以K用8bit、V用4bit这个组合是性价比比较高的选择。
2.3 更隐蔽的一点:prefill阶段和decode阶段的显存曲线不一样
很多人调好KV Cache之后,发现decode跑得很稳,但一输入长文本就直接爆显存。这是因为prefill阶段和decode阶段的显存增长完全不同。
- prefill阶段(模型一次性读完输入):要并行计算所有输入位置之间的attention,临时激活值会短暂飙升,跟上下文长度直接相关。128K的提示词,哪怕KV Cache被压缩了,prefill的临时计算量也可能会瞬间吃满显存。
- decode阶段(逐个token生成):同一时刻只需要处理1个新token,显存增量平缓,主要是KV Cache的逐步增长。
所以判断能不能跑128K,不能只看decode,还要看prefill阶段会不会OOM。这个我在第五章的踩坑实录里会具体说。
2.4 三条压缩长上下文成本的实际手段
KV Cache量化是最直接的手段,但还有几条路可以配合:
- 上下文滑动(rolling context):不是所有历史都必须完整保留,可以只保留最近N个token的历史,更早的内容做摘要压缩。这样做128K就成了“名义支持”,实际显存按滚动窗口算。
- 子上下文分片:把超长文本拆成多个片段,分段处理后再合并,避免一次性蓄满KV Cache。
- 减少KV头数带来的收益:GQA架构已经帮了大忙,8个KV头比32个KV头的KV Cache小4倍。选模型时优先选GQA/MQA架构,而不是纯多头注意力。
一句话:12G显存要跑128K上下文,KV Cache的精度和分配策略比模型权重本身更值得花心思。
3. 真正跑起来的配置:12G显存上的拆分与参数清单
3.1 工具选型:为什么我选了llama.cpp
现在跑本地模型的主要选项有llama.cpp、Ollama、LM Studio、vLLM。Ollama和LM Studio上手简单,但对“MoE专家层放CPU、attention层放GPU”这种细粒度控制支持有限,尤其是想要精确控制KV Cache量化时,很多选项都被藏起来了。vLLM性能很强,但对显存要求高,12G上跑27B MoE经常因为算子碎片化导致OOM,调起来很痛苦。
llama.cpp虽然界面朴素,但胜在一句话能说清楚所有东西:权重放哪、KV Cache用什么精度、Flash Attention开不开、线程用多少,全部是命令参数控制。所以我推荐直接用llama.cpp的llama-server,它自带OpenAI兼容API,配合任何客户端都能用。
3.2 我的配置文件(可直接抄作业)
先说清楚,llama.cpp不同版本的参数语义有变化,尤其是--n-cpu-moe这个参数,我在第五章会细说。下面是基于我当时使用的版本,一套完整可跑的启动命令:
llama-server \ -m /models/qwen3-30b-a3b-iq4_xs.gguf \ --ctx-size 131072 \ --n-cpu-moe 1 \ --cache-type-k q8_0 \ --cache-type-v q4_0 \ --flash-attn \ --threads 16 \ --threads-batch 16 \ --mlock逐个拆开解释:
| 参数 | 作用 | 为什么这么设 |
|---|---|---|
-m qwen3-30b-a3b-iq4_xs.gguf | 指定模型文件 | 30B总参MoE,IQ4_XS量化,文件约17GB,放内存可以接受 |
--ctx-size 131072 | 设置上下文长度为128K | 目标就是128K |
--n-cpu-moe 1 | 把所有MoE专家层放到CPU | 核心操作:GPU只保留attention层,专家层全在内存 |
--cache-type-k q8_0 | K矩阵用8bit量化 | 保留召回精度 |
--cache-type-v q4_0 | V矩阵用4bit量化 | 大幅降低KV Cache显存占用 |
--flash-attn | 开启Flash Attention | 减少attention计算和显存,decode速度提升明显 |
--threads 16 | CPU线程数 | 按物理核数设,别用超线程数 |
--mlock | 锁内存 | 防止内存swap导致速度暴跌 |
注意:
--n-cpu-moe 1在部分构建版本里表示“把1层专家放CPU”,而不是“所有专家放CPU”。不同llama.cpp提交对它的语义定义确实不一样。我的经验是查一下llama-server --help里的说明:如果描述是“offload all MoE layers to CPU”,那传1就对了;如果描述是“number of MoE layers on CPU”,那就要按模型层数传,比如48。
3.3 权重文件从哪里来
GGUF格式的量化文件,Hugging Face和ModelScope上都能找到,搜模型名加GGUF就行。优先选社区维护的量化版本,比如Qwen3-30B-A3B的GGUF。下载后建议先看下文件大小和md5sum,免得拉到损坏文件浪费一晚上。
如果实在找不到合适的IQ4_XS,可以用Q4_K_M替代,效果接近。不要用Q2_K或者Q3_K,长文本下文字组织能力下降太明显,特别是你要跑128K这种超长上下文,模型本身质量不够就很难受。
3.4 如何判断真的跑起来了
启动之后,重点看日志里的三行:
- model loaded:模型加载成功
- KV buffer size:比如显示约10GB,说明KV Cache已经按预期分配
- offloaded layers:确认有多少层在GPU
然后打开nvidia-smi看显存占用,训练时那种90%以上占用大概率是正常的。更稳的验证方式是用OpenAI兼容接口发一个长文本请求:
curl http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen3-30b-a3b", "messages": [{"role": "user", "content": "给我讲一段关于显存优化的话,至少500字"}], "max_tokens": 512 }'响应里的timings字段会直接给出prompt_per_second和predicted_per_second,后者就是decode速度。我第一次跑通时看到predicted_per_second在30多,心里已经比较满意了,后面又花了一晚上调优,把速度拉到了50+。
4. decode 50+的四项调优:从30 t/s到55 t/s
4.1 先理解瓶颈在哪:不是GPU算力不够,而是数据在跑
当专家层全部放在CPU后,decode过程实际上是一个“流水线”:GPU负责attention层,CPU负责专家FFN层,每个token每经过一层,中间结果都要在PCIe总线上来回传一次。真正卡住速度的不是计算,而是这个来回传输的延迟。
好消息是,MoE模型每次只激活少数专家,需要搬的数据量并不大。以hidden size 2048为例,一个token的中间结果大概几千字节,即便48层全部来回跑一遍,总传输量也就几百KB级别。所以瓶颈被限制在“每一层都要等一次PCIe往返”的延迟上。基于这个理解,优化思路就很清晰:能少搬就少搬,能并行就并行,别让任何环节空等。
4.2 第一板斧:Attention必须留在GPU,并且开Flash Attention
如果attention层也被放到了CPU,那每一层都要在CPU算注意力,速度会掉到10 t/s以下。保持--n-cpu-moe的拆分逻辑,GPU只做attention,这是速度能跑起来的前提。
在这个基础上,--flash-attn一定要开。Flash Attention不光是省显存,它还能显著减少attention计算对显存带宽的占用,换来的收益在长上下文场景里尤其明显。开和不开的差距,我实测在20%到30%左右。
4.3 第二板斧:CPU线程数要“克制”
专家层在CPU计算,线程数很关键。我之前想当然地把--threads设成了32,结果速度不升反降。原因很简单:超线程带来的虚拟核心对矩阵乘法的并行效率帮助有限,线程一多反而频繁切换,引入额外开销。
正确做法是看物理核心数。我的CPU是8核16线程,于是设--threads 16让满线程跑批量计算,--threads-batch也设成16,保持prefill阶段不吃亏。如果你用12核以上的CPU,建议从物理核数开始试,再往上加2到4个线程观察速度拐点,找到最佳值。
4.4 第三板斧:内存别被交换出去
当专家层在内存里,最怕的就是mlock没开,操作系统把模型权重的一部分交换到swap。一旦发生,速度直接断崖式下跌,表现为“前50个token很快,后面突然一顿一顿”。--mlock的作用就是把这些内存页锁在物理内存里,强制不让系统换出。
这里还要提醒一句:内存不够时千万别硬上。模型文件17GB,加上KV Cache和系统其他应用,至少准备32GB物理内存,最好有64GB。内存紧张时即使mlock也可能出问题,轻则速度不稳,重则进程被杀。
4.5 第四板斧:并发设置别贪多
llama-server默认允许一个模型实例服务多个请求,--parallel参数控制并发序列数。很多人误以为并发越多越好,但在12G显存+CPU专家层的架构下,并发请求会把attention和CPU专家层的时间片切碎,每个请求的速度都会下降。
我的实测是:--parallel 1下单请求decode能稳定在52到55 t/s;如果--parallel 2,两个并发请求会各自降到30到35 t/s;再往上,提速效果微乎其微,反而每个请求都卡。如果你只是自己用,保持--parallel 1就好。如果要服务多人,可以接受“人均速度下降”的代价,但别期望它像vLLM在大型GPU上那样接近线性并发。
4.6 一个小工具:用评测接口看真实速度
llama.cpp自带的llama-bench可以测不同配置下的性能,但它是模拟场景,跟真实聊天交互有差距。我更推荐直接用真实请求看timings。用上面curl命令里的返回内容:
"timings": { "predicted_per_second": 54.23, "prompt_per_second": 187.4 }prompt_per_second是prefill速度,predicted_per_second就是decode速度。有些评测软件只报prefill,有些只报decode,别搞混了。我当时调优完看到54附近稳定输出,终于觉得“12G显存跑27B、128K上下文、decode 50+”这句话算是立住了。
5. 踩坑实录:几次差点放弃的问题与排查链路
5.1 问题一:启动明明成功,一推理就OOM
表现是llama-server启动日志一切正常,模型加载完,但第一次发请求就报llama_kv_cache_unified: failed to allocate buffer。
我的排查链路是这样的:
- 先看
nvidia-smi,显存占用几乎满,内存还有不少空闲; - 确认KV Cache已经按128K和q8K/q4V分配,占了大几十GB(内存)和约5GB显存;
- 再看attention层权重,发现也被塞进GPU占了2GB多;
- 最后发现是系统为KV Cache预留的buffer比预想的大,显存被预分配撞满了。
解决方法是把KV Cache精度再降一档,--cache-type-k q4_0 --cache-type-v q4_0,让KV占显存降到约4GB,给attention权重和其他临时变量留出余量。如果还是不够,就把上下文降到96K,先跑通再考虑慢慢拉长。
这个问题的关键在于:llama.cpp有时会一次性把整个KV buffer按上下文上限预分配,即使当前请求只用了100K,它也按131072去占空间。所以显存余量要按“满上下文”算,不能按“实际输入长度”乐观预估。
5.2 问题二:为什么“512 token的上下文”跑得很顺,128K却卡成PPT
这个坑特别容易让人误判。我一开始用小上下文测,decode速度很漂亮,但把上下文顶到128K后,速度直线掉到个位数。查下来有两个原因叠加:
一是Flash Attention没有真正生效。llama.cpp要求Flash Attention对应的注意力层全部在GPU上,而我某次手动调整参数时,把部分attention层也放到了CPU,导致--flash-attn失效。日志里会有一个很不起眼的警告,我当时没注意。排查方法是在日志里搜flash_attn相关输出,同时逐层确认attention层都在GPU。
二是KV Cache跨设备碎片化。当显存不够时,KV Cache的一部分在GPU、一部分在CPU,解码时每次都要跨设备查历史状态,128K长上下文的跨设备查找频率很高,速度自然崩掉。解决办法是主动缩减上下文让KV Cache完整落在GPU内,或者牺牲上下文长度换速度。我的最终选择是KV Cache尽量留GPU、专家层全CPU,让跨设备通信只发生在中间结果上,而不是发生在KV查找上。
5.3 问题三:--n-cpu-moe参数在不同版本里“性格”不一样
这个问题前面提过一次,但它确实是最容易让人怀疑人生的坑。我第一次用某个llama.cpp编译版本时,文档写的是“把所有MoE层放到CPU”,传了--n-cpu-moe 1,功能正常。后来换了个更新版本,同一个写法却只放了一层专家到CPU,结果模型加载完GPU直接爆掉。
排查链路:
- 对比两次命令,唯一区别是llama.cpp版本;
- 打开
llama-server --help,发现新版对--n-cpu-moe的描述变成了“number of MoE layers to offload to CPU”; - 改为
--n-cpu-moe 48后恢复正常。
网格排查后其实就一句话:换版本前先看看help文本,别默认参数语义不变。如果你用的是Ollama或LM Studio这类封装好的工具,这个参数更是完全透不出来,只能回到llama.cpp。
5.4 问题四:量化后模型“变笨”了,是量化的问题吗
有段时间我把模型从IQ4_XS换成Q4_K_M,发现长文本质量下降,以为是量化等级不够。后来做了对照测试才发现,真正的问题是采样参数。
llama.cpp的默认采样参数(temperature、top_p)偏向保守,在长上下文场景下容易导致重复和保守短句。把--temp 0.7 --top-p 0.9这类参数调回去之后,即使KV Cache用了Q8K+Q4V,输出质量也恢复了正常。KV量化对质量的影响确实存在,尤其在需要精确复述细节的任务上,但对一般对话和长文本续写,影响没有想象中大。
重要建议:想判断模型质量是否被量化影响,一定要做对照实验。同一个提示词,同一版量化模型,只改采样参数,分别跑一次,看差异。别一上来就怀疑量化档位不够。
5.5 一处容易被忽略的系统级配置:PCIe链路和电源
如果条件允许,给GPU换一个直连CPU的PCIe插槽,速度和稳定性都会有改善。RTX 3060如果是PCIe 3.0 x16,带宽理论12GB/s左右,实际跑大模型时这个带宽基本够用。但如果显卡插在南桥芯片组的PCIe通道上,或者被其他设备挤占了带宽,decode会明显受影响。这不是参数能解决的,属于硬件层面的排查项。
另外,电源供电不稳会导致GPU降频,decode速度也会跟着波动。跑长时间任务时可以用nvidia-smi -q -d CLOCK看下当前频率和功耗是否正常,如果频率被压在很低的位置,多半是电源或者散热的问题。
最后聊点实际操作里的体会
折腾完这套配置,我最大的感受是:12G显存跑27B模型,真正的瓶颈从来不是“显存不够”,而是“怎么分配显存最值”。把最强的注意力计算留在GPU,把重但稀疏的专家计算放到CPU,再用KV量化把长上下文的成本压到可控范围——这三步想通了,剩下的都是参数调试。
如果看完这篇你也想复现,我的建议是先别急着把上下文拉到128K。先用8K上下文把整套流程跑通,确认decode速度能到50左右,再逐步往64K、128K拉。每一步只改一个变量,出问题的时候你才知道是哪个环节没配合好。长上下文推理和常规短文本推理完全是两种体验,但一旦搞定,你会发现12G显存能做超出预期的事情。