本地部署LLM,最开始两天我都在跟两个问题搏斗:推理慢到让人怀疑人生,显存像是被什么东西偷偷挖走一样,动不动就OOM。后来一步步把Transformers原生推理换成量化加载,再切到vLLM批处理,又折腾了Ollama的混合部署方案,总算把这个"推理慢、显存炸"的组合问题梳理出了一套完整的实操解法。这篇文章就是把我踩过的坑、验证过的代码、以及每一步背后的原理一次性讲清楚,尤其适合手头只有8G或12G显存显卡,又想本地跑7B甚至14B模型的同学。
很多人觉得本地部署LLM就是装个环境然后调用模型这么简单,但实际上推理性能和显存占用之间是强耦合的。你单独调推理速度,显存可能会进一步爆炸;你为了省显存去压缩模型,速度又有可能下降。真正要解决的问题,不是某一个点,而是整条链路。下面我会从显存的真实消耗模型开始讲起,然后给出三个可以直接上手的实操方案,每个方案都带完整代码,最后再附上我在实际部署中排查OOM、调参的完整链路和避坑清单。
1. 显存到底被谁吃掉了:推理开销的完整拆解
在动手处理前,先把账算清楚。你必须知道一次LLM推理过程中,显存被哪些东西占用,否则你只能在"又爆了"和"换更好的显卡"之间反复横跳。
1.1 模型权重:最直观的大头
模型权重是推理前加载进显存的基础部分,也是最容易估算的部分。一个7B参数模型,如果以FP16精度加载,每个参数占2字节,那就是7×10^9×2约等于14GB。如果是BF16,数值大小和FP16一样是2字节,所以也是约14GB。
这里有个很多人忽略的点:FP32精度每个参数占4字节,同一个7B模型会占28GB,普通消费级显卡根本扛不住。这也是为什么我建议直接把加载精度默认设为FP16或BF16,别再用FP32了。
对于不同的模型规模,一张表可以看得很清楚:
| 模型参数量 | FP32(4字节/参数) | FP16/BF16(2字节/参数) | 8bit量化(1字节/参数) | 4bit量化(约0.5字节/参数) |
|---|---|---|---|---|
| 1.5B | 6GB | 3GB | 1.5GB | 约0.8GB |
| 7B | 28GB | 14GB | 7GB | 约4GB |
| 13B | 52GB | 26GB | 13GB | 约7GB |
| 32B | 128GB | 64GB | 32GB | 约17GB |
所以当你只有8G显存,却直接以FP16跑7B模型时,连权重都装不下,还没开始推理就已经OOM了。
1.2 KV Cache:隐蔽的显存杀手
权重只是基础,真正的暗坑是KV Cache。Transformer模型在生成每个token时,需要把历史token的Key和Value缓存下来用于注意力计算,这部分的显存占用会随着序列长度线性增长。
计算公式大致是:2(K和V两个矩阵)× 模型层数 × 隐藏层维度 × 序列长度 × 批量大小 × 每个元素字节数。
举个例子,一个7B模型,假设32层、隐藏层维度4096、FP16精度,那么每个token每个层的缓存是2×4096×2字节约等于16KB,32层就是512KB。如果上下文长度是4096,那么单条序列的KV Cache是512KB×4096约等于2GB;上下文拉到8192,KV Cache直接翻倍到4GB。
这就是为什么模型刚开始推理时显存余量看着还行,生成到后面就越来越卡、最终炸掉。很多人只盯着权重算显存,忽略KV Cache,这是"显存炸"最典型的误判点。
1.3 CUDA上下文、激活值和临时张量
除了权重和KV Cache,还有几项零散开销:CUDA context本身在程序初始化时会占用一部分显存,通常在几百MB到1GB不等;激活值和中间计算结果在推理过程中也会临时占用显存;PyTorch的CUDA缓存分配器还会因为申请和释放产生内存碎片。
这几项加起来,虽然单看不吓人,但叠加到大模型场景下,就成了压垮8G显卡的最后一根稻草。我在跑7B模型FP16时,仅仅CUDA context和激活值就吃掉了2GB显存,留给我做推理的空间只剩6GB,而权重就要14GB,于是稳定溢出。
搞清楚这几项之后,方案就清晰了。三个实操方向分别是:压缩模型权重(量化)、优化推理框架(vLLM批处理)、以及更低显存的混合部署(Ollama/llama.cpp的CPU+GPU组合)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案一:权重量化,从FP16降到INT4的实操代码
对于显存不够的用户,量化是投入产出比最高的第一步。它的核心思路很直接:用更少的位数来表示权重,虽然牺牲一点精度,但换来成倍的显存释放。
2.1 FP16、BF16、INT8、INT4到底怎么选
- FP16:动态范围小,训练时容易溢出,推理时基本够用,显存占用减半(相对于FP32)。
- BF16:动态范围和FP32一样大,精度低,但推理时对精度不敏感,硬件支持良好(Ampere以上架构都支持),是我在NVIDIA显卡上做推理的首选。
- INT8:每参数1字节,显存再降一半。精度损失可通过激活值校准来缓解,适合对生成质量要求较高的场景。
- INT4:每参数约0.5字节,显存占用最低,但精度损失最明显。好在实际测试中,如果不要求输出严格符合格式,普通问答场景下质量下降可感知但可接受。
我的建议是:显存能装下FP16/BF16就先别量化;装不下再INT8,还装不下就INT4。不要一上来就无脑量化,因为量化的确会影响输出质量,特别是指定JSON格式输出、长文本逻辑推理等场景。
BIAS: 如果你用的是RTX 30系或40系显卡,BF16在推理阶段基本没有额外开销,优先用BF16。但要注意,bitsandbytes的4bit量化中,计算精度(compute_dtype)通常设置成FP16即可,存储用NF4量化,能做到存储小、计算快。
2.2 用Transformers + bitsandbytes做4bit量化加载
下面这段代码是我在本地部署Qwen2.5-7B-Instruct时验证过的,显卡是RTX 4060 8G,加载后显存占用大约5.5GB,可以正常推理。
python复制import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
model_id = "Qwen/Qwen2.5-7B-Instruct"
# 定义4bit量化配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 启用4bit加载
bnb_4bit_quant_type="nf4", # 使用NF4量化类型,比FP4更稳
bnb_4bit_compute_dtype=torch.float16, # 计算时用FP16,保证速度
bnb_4bit_use_double_quant=True, # 双层量化,进一步减少显存
)
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto", # 自动分配到GPU/CPU
trust_remote_code=True,
)
# 推理测试
prompt = "用一句话解释什么是张量"
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
outputs = model.generate(
**inputs,
max_new_tokens=256,
do_sample=True,
temperature=0.7,
)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))
这一段代码跑完之后,显存占用可以从原来FP16加载的14GB以上,直接降到6GB以内。8G显卡终于有了余量。
2.3 量化后的实测效果与注意点
我用同一个模型、同一个问题分别跑了FP16(在A100上模拟)和4bit(在4060上实测),结果比较有意思:显存从14GB降到5.5GB左右,但生成速度从大约28 tokens/s降到了16-18 tokens/s。原因很简单,4bit权重在计算时要反量化到FP16再进行矩阵乘法,这中间多了一步转换,所以速度会有损失,但远没有到不能用的程度。
如果你对速度也敏感,建议把 bnb_4bit_compute_dtype 设置为 torch.bfloat16(如果你的卡支持),某些模型上BF16反量化比FP16略快。另外,bnb_4bit_use_double_quant 这个参数默认False,我建议显存紧张时打开,它能在几乎不损失质量的情况下再省几百MB到1GB。
量化方案最大的局限性在于:它只能解决权重占显存的问题,KV Cache和长上下文的显存开销依旧存在。如果你需要处理超长文档(比如上万token的上下文),单靠量化是不够的,还要配合下面的方案。
3. 方案二:vLLM批处理加速,吞吐量成倍提升
如果你做本地部署不仅是为了自己玩,还要作为服务给多个请求用,那么原生Transformers的生成方式会让你崩溃。它每次只处理一个序列,而且没有高效的显存复用机制,吞吐量极其低下。vLLM是解决这条链路的关键。
3.1 为什么原生Transformers推理那么慢
Transformers库的 model.generate() 每一步推理都要经过Python级别的前向传播,逐个token生成,没有跨请求的批处理。如果你同时开多个请求,显卡其实只在一个个排队处理,利用率非常低。
另外,原生框架在长序列生成时,KV Cache是动态分配的,很容易产生显存碎片,导致显存越用越乱,最后被迫提前OOM。对比来看,vLLM做了两件关键的事:PagedAttention,把KV Cache分页管理,像操作系统的内存分页一样减少碎片;Continuous Batching,把不同请求的动态长度合并批量处理,最大化GPU利用率。
3.2 安装与启动vLLM服务
vLLM安装需要注意Python版本和CUDA版本。我使用的是Python 3.10 + CUDA 12.1 + PyTorch 2.1,直接安装就可以:
bash复制pip install vllm
如果是更早的CUDA环境,建议去官方看对应的预编译wheel,别硬装。安装完成后,启动一个OpenAI兼容的API服务,一行命令就够:
bash复制vllm serve Qwen/Qwen2.5-7B-Instruct \
--gpu-memory-utilization 0.9 \
--max-model-len 8192 \
--dtype float16 \
--enforce-eager
这里要注意几个参数:
--gpu-memory-utilization 0.9限制vLLM最多使用90%显存,避免资源占满导致其他程序崩掉。--max-model-len 8192控制最大上下文长度。这个值越大,KV Cache预留显存越多。如果你上下文不会用到8000多token,大胆调小,比如4096,能省下很多显存。--enforce-eager在低显存机器上是救命参数,它禁用CUDA Graph优化(减少显存预分配),代价是稍微慢一点,但在8G卡上不这么设根本启动不了。
如果你的模型是AWQ或GPTQ量化版,可以加上 --quantization awq 或 --quantization gptq,vLLM对这两种量化支持得很好。实测AWQ模型在vLLM里跑,速度和显存都能兼顾。
3.3 客户端调用与性能对比
启动后,可以用标准OpenAI SDK进行调用:
python复制from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="EMPTY",
)
chat_completion = client.chat.completions.create(
model="Qwen/Qwen2.5-7B-Instruct",
messages=[{"role": "user", "content": "讲一个关于程序员的笑话"}],
max_tokens=200,
temperature=0.7,
)
print(chat_completion.choices[0].message.content)
我在相同硬件上做了一个简单对比:原生Transformers加载同样模型,单个请求的生成速度大概稳定在16 tokens/s,而vLLM在连续发出20个请求的压力下,单个请求的生成速度不仅没有崩,整体吞吐量反而能达到80-120 tokens/s,这是并发批处理的功劳。同时还因为PagedAttention,显存碎片减少了,长上下文时候不容易突然OOM。
这个方案适合你已经决定提供API服务、需要多路请求的场景。如果只是自己在终端里问问题,vLLM带来的收益反而不明显,因为单请求场景下它的主要优势(批处理)无法完全发挥。
4. 方案三:Ollama与llama.cpp的低显存混合部署
对于显卡显存只有6G、8G,甚至是核显的机器,前面两个方案只能说勉强能用,真正让低配机器也能跑大模型的,是Ollama和llama.cpp这套CPU+GPU混合部署方案。
4.1 GGUF量化与层卸载(Offload)原理
llama.cpp的作者把模型权重打包成GGUF格式,这是一种高度优化的量化格式,支持从2bit到8bit多种量化级别。更重要的是,基于GGUF的推理可以逐层控制哪些层放到GPU上、哪些层放到CPU上,这就是层卸载(layer offloading)。
原理上,LLM的Transformer由几十层相同的层堆叠而成,推理时数据流是逐层经过的。你完全可以把前20层放在GPU上加速,后12层放在CPU内存里计算,每一层计算完再把中间结果传给下一层。这样做的效果是:显存不够时,模型照样能跑,只是部分层走CPU,速度会慢一些,但总比完全跑不起来强得多。
我实测用一台32G内存、集显的笔记本跑Qwen2.5-7B-Instruct的Q4_K_M量化版,GPU卸载12层,生成速度大概6-8 tokens/s,虽然不快,但已经能正常对话了。如果把全部层都压到CPU,速度会降到2-3 tokens/s,那就有点难用了。
4.2 Ollama部署:一条命令跑通
Ollama本质上是对 llamacpp 的封装,把模型管理、量化下载、服务启动全变成了傻瓜化操作。安装之后,拉取模型再运行:
bash复制ollama run qwen2.5:7b
这会自动下载Q4_K_M量化版并运行。如果你需要精确控制显存和上下文,有几个环境变量很重要。
在启动Ollama服务前设置:
bash复制# Linux/macOS
export OLLAMA_MAX_LOADED_MODELS=1 # 同一时间只保持一个模型
export OLLAMA_NUM_PARALLEL=1 # 并发请求数改为1,节省显存
export OLLAMA_KEEP_ALIVE=5m # 模型在显存中的驻留时间
ollama serve
Windows用户在系统环境变量中同样配置。其中 OLLAMA_NUM_PARALLEL 默认是并发4个请求,这在低显存机器上是致命的,它会为每个并发请求预留KV Cache,导致显存瞬间爆炸。把并行设为1,几乎是最有效的降低显存手段。
如果你用 llama.cpp 的纯命令行方式,可以自己控制 -ngl 参数:
bash复制./main -m Qwen2.5-7B-Instruct-Q4_K_M.gguf \
-ngl 24 \
-c 4096 \
-n 256 \
--prompt "你好,介绍一下你自己"
4.3 上下文长度与批大小:低显存部署的调优开关
实测中最容易被忽略的调优开关是上下文长度(num_ctx)。Ollama默认给模型分配2048或4096 token的上下文,如果你跑长对话,上下文会自动膨胀,显存占用随之飙升。手动限制上下文长度能显著降低显存:
bash复制ollama run qwen2.5:7b --num-ctx 2048
如果嫌命令行麻烦,可以在Ollama的配置文件中写。llama.cpp里对应参数是 -c 2048。记住,上下文长度每减少一半,KV Cache的显存占用也跟着减少一半。对大多数日常问答和文档摘要场景,2048已经够用,不必非要维持4096或8192。
另一个可以调的参数是批大小(batch size),llama.cpp里是 -b。默认值偏大,会导致初始显存分配较高,显存小的机器可以调成 -b 256 或更低,代价是生成速度略有下降。这个参数在Ollama里不直接暴露,但通过环境变量 OLLAMA_NUM_PARALLEL 也能间接控制并发的显存开销。
这套方案最适合的定位是:个人电脑上快速、零配置地跑模型,尤其是在Windows和macOS上体验极佳。它不像vLLM那样追求极限吞吐,但胜在简单稳定,几乎不会遇到兼容性问题。
5. 实测避坑清单:报错排查与显存监控的完整链路
不管用哪个方案,你大概率会遇到各种报错和显存爆掉的情况。下面是我实际部署中反复遇到的几类问题,以及完整的排查链路。
5.1 CUDA Out of Memory的完整排查链路
第一次遇到OOM,千万别急着换显卡。按这个顺序排查:
第一步,监控显存。每次跑程序前打开一个终端,持续输出显存占用:
bash复制nvidia-smi -l 1
这样每秒刷新一次,能看到进程占用的显存曲线。如果发现某个进程持续占着几GB不释放,大概率是之前跑挂的程序没清干净。 kill -9 PID 之后重试,经常能解决问题。
第二步,在代码里打印精确的显存分配情况。不要只看nvidia-smi,因为PyTorch有缓存分配器,nvidia-smi显示的是显存总量,而PyTorch内部只用了其中一部分。打印这几项能看得更清楚:
python复制import torch
print("当前已分配显存:", round(torch.cuda.memory_allocated() / 1024**3, 2), "GB")
print("当前预留显存:", round(torch.cuda.memory_reserved() / 1024**3, 2), "GB")
print("历史峰值显存:", round(torch.cuda.max_memory_allocated() / 1024**3, 2), "GB")
第三步,确认是权重放不下,还是KV Cache放不下。我见过很多人在8G卡上跑7B模型,权重用4bit量化后只有5GB,但推理了1000多token后照样OOM。这种情况不是量化不够,是KV Cache超过了余量。解决方法是限制 max_new_tokens 或 max_model_len,或者用vLLM的PagedAttention自动管理。
第四步,释放显存碎片。PyTorch生成过程中产生的中间变量不会立刻释放,在触发OOM后可以尝试:
python复制torch.cuda.empty_cache()
这只会清空未被使用的缓存,不会影响模型本身,但有时能给后续小批量的推理腾出空间。
5.2 常见报错与对应解决方式
| 报错信息 | 大概率原因 | 解决办法 |
|---|---|---|
| CUDA out of memory | 权重或KV Cache超限 | 量化、调小上下文、调小批大小 |
| bitsandbytes报错没有检测到CUDA | CUDA版本与bitsandbytes不匹配 | 重装对应CUDA版本的bitsandbytes |
| vLLM提示model architecture不支持 | 模型架构未被vLLM支持 | 换用标准架构模型,如Qwen/Llama,或改用Transformers |
| Ollama下载模型中断 | 网络不稳 | 用 ollama pull 重试,校验在本地完成,无需重下 |
| TypeError: BFloat16 is not supported on this GPU | 显卡太老不支持BF16 | 改用FP16或INT8量化 |
5.3 不同显存档位的选型路线图
最后给一个直观的选型建议,是我在不同显卡上实测后的总结:
- 6G显存:优先Ollama + Q4量化小模型(1.5B-7B),上下文限制在2048以内,能流畅跑日常问答。vLLM在这个档位发挥空间不大。
- 8G显存(RTX 4060/3070等):Transformers 4bit量化跑7B,或者Ollama跑7B Q4/Q5量化,这是最稳的组合。想上服务,用vLLM加
--gpu-memory-utilization 0.85。 - 12G显存:可以FP16/BF16跑7B,也可以4bit跑14B;vLLM跑7B很舒适,吞吐量充足。
- 16G以上:FP16跑13B/14B问题不大,可以同时开多个模型或引入embedding模型组成本地RAG链路。
个人在实际部署中最大的感受是:不要一上来就追求最底层的技术方案,而是从自己的显卡显存和实际使用场景倒推选择。如果只是在自己电脑上偶尔提问,Ollama是最省心的方式;如果要把模型服务化给多个应用调用,vLLM值得花时间配置;如果模型太大塞不进显存,量化加层卸载双管齐下总能挤出一点空间。
最后再补充一个我踩过几次坑之后的习惯:每次部署新模型前,先记录一下基线显存占用和生成速度(比如上面的torch.cuda.max_memory_allocated()),调整参数后再对比一次。这能帮你快速判断某个改动是变好了还是变坏了,不至于凭感觉调参调了半天还找不到方向。本地LLM部署的门槛其实没有想象中那么高,关键是先把账算明白,再按方案一步步操作,剩下的就是多试几次。
