说实话,双RTX 3090跑Qwen2.5-14B这件事,我是被预算逼出来的。手头有一张闲置的3090,想升级大模型服务又不想花几万块上A100,于是琢磨着再补一张3090,用vLLM的张量并行把模型切成两半,拼出一个“穷人版”的48GB显存推理服务器。跑通之后我挺意外的,这套组合的稳定性和吞吐完全能支撑小团队的生产级使用,而且总成本不到一张A100的零头。这篇文章我会把整个部署过程、我算过的显存账、踩过的坑全部摊开讲,适合想用消费级显卡搭本地大模型服务、又不满足于Ollama这种玩具级方案的人。
1. 为什么拿两张3090跑Qwen2.5-14B:先算清显存账
1.1 单卡24GB为什么连模型都装不下
先看硬数据。Qwen2.5-14B-Instruct的参数量约147亿,BF16精度下每个参数占2字节,光权重文件就接近29GB。一张3090只有24GB显存,这意味着不做量化的话,模型权重本身就已经超出单卡容量,更别提推理时还有KV Cache、激活值和CUDA context这些额外开销。
有人会问:量化到INT4不行吗?行,4bit权重大概能压到8GB左右,单卡确实能跑。但我在代码生成、数学推理这类任务上实测过,量化后的输出质量下降是能感知到的,尤其在需要精确计算和多步推理的场景里,一个符号错就能让整个结果崩掉。如果目标是做一个能真正交付给业务方使用的大模型服务,保留BF16精度的意义远大于省那点显存。所以我的结论很简单:不量化,上双卡。
1.2 双3090的性价比到底有多离谱
价格上,以我2025年初看到的市场行情,一张二手RTX 3090大概六七千块,两张合计不到一万五。对比A100 80G动辄小十万的价格,或者租云GPU一个月几大千的费用,双3090的性价比是碾压级的。而且我本身就是做本地部署的,这种方案等于一次性买断算力,长期跑服务根本不心疼。
当然,3090毕竟是消费级显卡,没有NVLink Switch这种数据中心互联方案,功耗还高(单卡瞬时功耗能上350W),和A100的HBM带宽、NVSwitch不是一个量级。但关键是,这些差距在14B这个参数规模的模型上并没有想象中那么致命。后续第四节我会详细说通信瓶颈的问题,这里先给结论:双3090跑Qwen2.5-14B,完全够用。
2. 环境准备:版本组合选对了,后面少踩一半坑
2.1 硬件清单和驱动检查
动手之前先确认三件事。第一,主板上要有两个PCIe x16插槽,至少x8+x8,插入两张3090时要注意间隔,否则散热会互相打架。第二,电源额定功率建议1200W以上,3090瞬时功耗高,两张卡同时撞功耗墙时弱电源会直接关机。第三,系统内存至少64GB,vLLM加载模型时需要先把权重从磁盘读入内存,28GB权重加上运行时开销,32GB内存会非常紧张。
驱动检查简单直接:
nvidia-smi看右上角CUDA Version是否在12.1以上,两张卡的显存是否都识别为24GB。驱动版本在535以上基本都能满足vLLM的要求。有个细节容易被忽略:主板的BIOS里要开启Resizable BAR和Above 4G Decoding,这两项不打开,NCCL在双卡通信时可能遇到P2P映射问题,后面避坑清单会详细说。
2.2 用虚拟环境安装vLLM:版本锁定很重要
vLLM的依赖环境比较敏感,我强烈建议用conda单独建环境:
conda create -n vllm python=3.10 -y conda activate vllm pip install vllm==0.7.3为什么锁0.7.3而不是直接装最新版?因为vLLM迭代太快,0.8.x系列虽然新增了不少特性,但对Ampere架构(3090的sm_86)支持反而没有0.7.x稳定。我在群里看到不少用40系和50系显卡的朋友追新版本没问题,但3090用户普遍反映0.8.x在启动时会有CUDA编译相关警告,甚至某些算子自动降级。Ampere架构的用户,稳定压倒一切。
装完后验证一下:
python -c "import vllm; print(vllm.__version__)"2.3 用ModelScope快速拉取模型权重
模型下载是很多人忽略的坑。直接到Hugging Face拉模型在国内网络环境下经常断流,文件不完整会让vLLM加载失败,且报错信息不直观。我这边用的方案是通过ModelScope(魔搭社区)下载,Qwen官方在那边有同步,速度快很多。
新版ModelScope提供命令行工具:
pip install modelscope modelscope download --model Qwen/Qwen2.5-14B-Instruct --local_dir /data/models/Qwen2.5-14B-Instruct习惯用Python的话也可以:
from modelscope import snapshot_download model_dir = snapshot_download('Qwen/Qwen2.5-14B-Instruct', cache_dir='/data/models') print(model_dir)下载完检查目录里是否包含config.json、model-00001-of-00004.safetensors(分片文件)、tokenizer.json、tokenizer_config.json。文件不全会导致加载失败,而且vLLM的报错不一定直接告诉你缺文件,所以我习惯先ls -lh看看文件大小是否符合预期。
3. vLLM启动参数逐项拆解:为什么不能无脑抄
3.1 一行命令启动服务
环境就绪后,启动服务其实就一行命令。这是我最终定型的启动脚本:
cd /data/models vllm serve /data/models/Qwen2.5-14B-Instruct \ --tensor-parallel-size 2 \ --gpu-memory-utilization 0.92 \ --max-model-len 8192 \ --max-num-seqs 8 \ --served-model-name qwen14b \ --port 8000看到日志输出Application startup complete和Uvicorn running on http://0.0.0.0:8000,服务就起来了。
3.2 每个参数背后的显存逻辑
--tensor-parallel-size 2是这个方案的核心。它告诉vLLM将模型按层内张量切成两块,分别加载到两张GPU上。这个参数不能超过GPU数量,且两张卡的显存最好一致,否则小卡会成为瓶颈。
--gpu-memory-utilization 0.92表示每张卡最多使用92%的显存。为什么不是1.0?因为vLLM还需要给CUDA context、cuDNN、显存碎片留一点余量,拉满容易在运行中途触发OOM。我实测0.92是稳定性和显存利用率的平衡点。如果业务并发很高,建议降到0.88左右,给KV Cache留出更多余量。
--max-model-len 8192控制最大上下文长度。Qwen2.5-14B-Instruct本身支持更长的上下文,但长上下文的代价是KV Cache占用成倍上涨。24GB显存下,8K是“安全且实用”的长度。硬开到32K也不是不行,但剩余显存根本跑不了几个并发请求,生成速度还会暴跌。
--max-num-seqs 8限制同时处理的请求数。vLLM采用连续批处理架构,能同时处理多个请求,但如果这个值设得太大,KV Cache总容量不够用,极端情况下依然会OOM。8对14B模型是个偏保守的起点,日常够用。
3.3 通过OpenAI兼容接口验证服务
服务起来后,最快验证方式是用curl发一个对话请求:
curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen14b", "messages": [{"role": "user", "content": "用一句话解释什么是张量并行"}], "max_tokens": 128, "temperature": 0.7 }'如果你要在代码里集成,推荐用OpenAI SDK:
from openai import OpenAI client = OpenAI( base_url="http://localhost:8000/v1", api_key="EMPTY" ) resp = client.chat.completions.create( model="qwen14b", messages=[{"role": "user", "content": "你好,介绍一下你自己"}], max_tokens=256 ) print(resp.choices[0].message.content)这里最容易踩的坑是model字段。如果你设置了--served-model-name qwen14b,API请求里的model必须填qwen14b;没设置的话,就要填完整的模型路径/data/models/Qwen2.5-14B-Instruct。我第一次对接时填错了,返回404排查了好久才发现是model字段和启动参数不一致。
4. 张量并行内部原理与显存账本:双卡不是简单拼显卡
4.1 张量并行到底怎么切模型
很多人的第一反应是“把模型前一半放卡1,后一半放卡2”,这个理解是错的。张量并行切的是每一层内部的矩阵。以Transformer里的线性层为例,权重矩阵是[out_features, in_features],张量并行会把这个矩阵按列切成两块,每张卡各持一半。前向计算时,每张卡用自己手头半边权重做矩阵乘法,得到部分结果,然后通过一次AllReduce通信把所有卡的部分结果相加,拼成完整输出。
这意味着每一层Transformer都需要一次卡间通信,而不是只在层与层之间通信。这也是为什么双卡方案的通信带宽如此重要——它直接决定张量并行跑得快不快。你买的每一张卡都在“边算边聊天”,聊天的效率某种程度上比算力本身还关键。
4.2 怎么检查有没有NVLink桥
RTX 3090是有NVLink接口的,但很多非公版把金手指省了。判断方法:
nvidia-smi nvlink -s如果能看到两张卡的link ID,说明NVLink桥接生效;如果提示不支持或者输出为空,说明两张卡之间没有NVLink连接,NCCL会自动走PCIe P2P通信。
没有NVLink,TP也能正常工作。vLLM底层用NCCL通信库,PCIe 4.0 x16单向带宽约32GB/s,虽然比NVLink低,但对付14B模型短上下文场景足够。我实测PCIe下的TP性能损失大约在10%~20%,取决于prefill和decode的比例。如果主板第二条插槽只有x8带宽,那损失会进一步扩大,有条件还是把两张卡放在能跑满x16带宽的槽位上。
还有个经典报错:启动时NCCL提示Peer mapping not supported或者The legacy P2P API failed to initialize。遇到这个,先别急着改环境变量,去BIOS确认Resizable BAR和Above 4G Decoding是否开启。我最初安装环境时被这个报错折腾了很久,打开这两项后问题直接消失。如果BIOS确实没有这两个选项,才考虑用环境变量强制降级:
export NCCL_P2P_DISABLE=1这会放弃GPU间的直接P2P映射,改用共享内存中转,虽然增加了一次CPU拷贝,但至少服务能跑起来。
4.3 KV Cache显存账本:算清楚才知道为什么会OOM
跑在vLLM上的大模型,显存大头除了模型权重就是KV Cache。KV Cache用来缓存历史token的key和value向量,每次新增token都要把整条序列的KV向量重新读一遍。Qwen2.5-14B有48层、8个KV头、每个头维度128,BF16精度下每个token每层需要2×8×128×2=4KB,48层合计约192KB。
这还只是一条请求的开销。我们做个简单的乘法:
- 8个并发、每个请求上下文2000 token,KV Cache总占用约 8 × 2000 × 192KB ≈ 3.1GB;
- 如果把上下文拉到8192、并发还是8,总占用直接变 8 × 8192 × 192KB ≈ 12.6GB;
- 张量并行双卡分摊后每卡约6.3GB,再加上每卡15GB的权重,已经是21.3GB,逼近22GB的可用上限。
所以显存优化本质是在“权重 + KV Cache + 激活值”三个变量之间找平衡。权重是固定的,激活值比较小,真正弹性最大的是KV Cache,它取决于同时处理的序列数和上下文长度。这就是为什么我把--max-model-len和--max-num-seqs都控得比较保守。vLLM启动日志里会打印类似Maximum concurrency for 8192 tokens per request: 5.7x的信息,意思是这个配置下最多只能容纳5.7个满8K上下文的请求。你要是把--max-num-seqs设成8,那极端情况下OOM几乎是必然的。
5. 实测性能:双3090在14B模型上到底能跑多快
5.1 我的测试方法与观测工具
部署完我跑了大概一周的线上流量,另外用脚本做了几组基准测试。测试环境是双RTX 3090(PCIe 4.0 x16,无NVLink桥)、vLLM 0.7.3、BF16权重、max-model-len 8192、gpu-memory-utilization 0.92。
测试方法是用OpenAI SDK写一个多线程脚本,按不同并发和输入输出长度统计首token延迟(TTFT)和生成速度。同时用nvidia-smi dmon监控两张卡的实时显存和SM利用率,这个工具比watch nvidia-smi更好用,能同时看多张卡的动态数据。
5.2 基准数据
| 并发 | 输入长度 | 输出长度 | 平均TTFT | 平均生成速度 | 总吞吐 |
|---|---|---|---|---|---|
| 1 | 512 | 256 | 0.8s | 52 tokens/s | 52 tokens/s |
| 4 | 512 | 256 | 1.3s | 48 tokens/s | 192 tokens/s |
| 8 | 512 | 256 | 2.1s | 41 tokens/s | 328 tokens/s |
| 4 | 2048 | 512 | 2.8s | 39 tokens/s | 156 tokens/s |
这些数据是我这台机器上的实测值,不同主板、不同PCIe配置会有浮动,但量级可以参考。对比单卡跑量化版14B模型(大概35~45 tokens/s),双卡BF16的性能并不差,精度还完整保留了。
5.3 从数据反推参数调优思路
从表里能读出两个规律。第一,并发从1提升到4,总吞吐接近线性增长;从4提升到8,吞吐增长开始放缓,说明调度开销和KV Cache竞争开始显现。第二,输入变长之后,TTFT明显上升,生成速度也轻微下降,因为prefill阶段的计算量增加,同时KV Cache占用变多导致可用batch变小。
所以参数怎么调,取决于你的业务场景。如果是实时对话,对延迟敏感,输出也短,并发设4比较合适,TTFT能控制在1.5秒以内,体感很流畅。如果是离线批量处理,对延迟不敏感、追求总吞吐,可以尝试--max-model-len 4096配--max-num-seqs 16的组合。我的实测里,这个配置总吞吐能到500 tokens/s以上,比保守配置高一截。
6. 避坑清单:我摸爬滚打总结的八个关键问题
6.1 踩坑:gpu-memory-utilization拉到0.95,服务一跑就OOM
第一次我图省事抄别人的命令,直接--gpu-memory-utilization 0.95。模型加载时日志一切正常,一有请求进来就报CUDA out of memory。原因是CUDA graph捕获阶段会额外占用显存,再加上激活值和临时缓冲区,0.95把最后一点安全余量挤没了。改成0.92之后问题消失。建议新手从0.90起步,观察一整天运行情况再加,别一上来就拉满。
6.2 踩坑:max-model-len和max-num-seqs是绑定的
我看到不少人把--max-model-len设成32768,心想支持长文本不是更好吗,结果KV Cache直接爆掉。长上下文和并发数必须一起考虑。你想开32K上下文,--max-num-seqs就不能超过2,否则显存铁定不够。我的经验做法是把长文本和短文本拆成两个独立服务实例,分别给不同配置,而不是试图在一个实例里满足所有场景。
6.3 踩坑:Docker部署忘了加--shm-size
用容器跑vLLM时,Docker默认的/dev/shm只有64MB,而NCCL通信和DataLoader都要用共享内存,vLLM启动后一加载权重就会报No space left on device。解决方法是加参数:
docker run --gpus all --shm-size=10g ...我第一次用docker compose部署时没配shm_size,被这个报错卡了半小时,把系统内存都检查了一遍,最后才发现是共享内存配额问题。
6.4 踩坑:升级vLLM后命令变了,老脚本直接废掉
vLLM更新速度极快,0.6.x的python -m vllm.entrypoints.openai.api_server在0.7.x里换成了vllm serve,部分参数名也有调整。网上大量教程还停留在旧版本,照着抄容易出问题。我的建议是安装时就固定版本号,比如vllm==0.7.3;升级前先看release note,别盲目pip install -U vllm。特别是3090这样的Ampere卡,追最新版本不一定有收益,反而可能遇到算子兼容问题。
6.5 踩坑:系统内存不足,模型加载到一半被kill
vLLM加载模型时会先把权重读进内存再搬到GPU。一台只有16GB内存的服务器,加载29GB的BF16权重时大概率触发OOM killer,日志里看到Killed字样就晚了。模型文件如果还在磁盘page cache里,内存占用会超过29GB。我给的建议是系统内存至少是模型权重的两倍,64GB起步。别在这上面省钱,内存便宜,排查OOM的时间成本可一点都不便宜。
6.6 踩坑:接口报404或400,多半是model字段填错
这个问题在API对接阶段特别常见。用--served-model-name qwen14b启动后,OpenAI SDK的model字段必须填qwen14b,填本地路径会返回404。没设置这个参数的话,就填完整模型路径/data/models/Qwen2.5-14B-Instruct。另外,请求里的max_tokens不能超过服务端的--max-model-len,否则直接400。之前有同事调了一天接口没通,最后发现是两个字段都对不上,这破事真容易耽误时间。
6.7 踩坑:3090别盲目追新FlashAttention实现
vLLM默认会用FlashAttention加速,这对40系很友好,但3090是Ampere架构,部分新版FlashAttention实现里针对Ampere的优化并不理想。我遇到过升级vLLM后日志里出现flash_attn初始化异常,服务虽然能起,但prefill速度明显变慢。后来发现是vLLM对sm_86的kernel没有编译完整,退回0.7.3恢复正常。如果你想手动指定,可以通过设置VLLM_ATTENTION_BACKEND环境变量来切换后端,但这属于高级操作,普通用户不推荐折腾。
6.8 踩坑:没有守护进程,服务挂了不能自愈
这事儿不算vLLM专属,但本地部署很容易忽略。vLLM偶尔会因为显存压力或者底层NCCL连接问题崩溃,没有守护进程的话,服务就悄悄下线了。我用systemd写了一个简单的unit file,加上Restart=always,再配合Health Check接口做探活。vLLM自带/health端点,用curl http://localhost:8000/health就能判断服务是否存活,配合定时任务或者监控系统非常方便。
最后说点个人体会。这套双3090加vLLM加Qwen2.5-14B的组合,我在公司内部已经跑了两个多月,承载了知识库问答、代码片段生成、会议纪要整理三个业务,稳定性比我预想的好很多。买不起A100不是做不了事的借口,至少14B这个档位,双3090是真的能顶上。再分享一个实用小技巧:日常调试阶段把--max-model-len设成4096,能在相同显存下开更高并发,明显缩短测试排队时间;真正上生产时再拉回8192。如果你手头正好有两张闲置的3090,或者正打算低成本搭一个本地大模型服务,按这套流程走一遍,应该比我当初顺利得多。