共计 4120 个字符,预计需要花费 11 分钟才能阅读完成。
上个月我把团队内部的代码助手与知识库底座从云端 API 切换到了本地双卡 RTX 4090 工作站。刚开始图省事直接跑 Ollama,结果 DeepSeek-R1 的推理链(Thinking Process)经常动辄吐出几千字,并发只要超过 3,显存直接被打满,Time-to-First-Token(TTFT)暴涨到 10 秒以上,前端页面甚至频繁遇到 HTTP 504 流式超时断连。
家用消费级显卡没有 NVLink,显存带宽只有 384-bit,面对 32B 这种体量的模型,如果配置不当,推理延迟会让你怀疑人生。这篇文章记录了我用 vLLM 榨干双卡 4090 算力、跑通 DeepSeek-R1-Distill-Qwen-32B-AWQ 的完整实操链路,以及解决长思考链推理时配套反向代理网关的工程方案。
一、为什么放弃 Ollama 切换到 vLLM?
对于个人尝鲜,Ollama 开箱即用确实方便;但如果你打算将其集成进团队的自动化 Agent、Open-WebUI 或 IDE 插件,Ollama 的底层架构(基于 llama.cpp)在面对复杂场景时短板非常明显:
- 并发 Prefill 极其低下:多用户同时发送超长系统 Prompt 时,llama.cpp 很容易造成显卡计算单元排队等待。
- 缺少高效的前缀缓存(Prefix Caching):DeepSeek-R1 类的推理模型严重依赖历史 Prompt 和思考上下文。vLLM 可以在显存中用树状结构缓存已计算过的 KV Cache(通过 PagedAttention),相同 System Prompt 的二次请求 Prefill 耗时能直接从秒级骤降至几毫秒。
- 缺乏细粒度的显存水位管控:vLLM 允许精准设置
--gpu-memory-utilization,压榨显存极限的同时避免 CUDA OOM。
二、双卡无 NVLink 环境下的 NCCL 避坑指南
RTX 4090 砍掉了 SLI/NVLink 物理金手指,两张卡通信完全依赖主板的 PCIe 通道。我在刚拉起两张卡做张量并行(Tensor Parallelism, TP=2)时,启动脚本直接卡死在 Initializing distributed environment。
问题在于 Linux 内核与 NCCL 默认尝试在不支持 P2P 的主板拓扑上走直接内存访问(P2P over PCIe),导致内核死锁。在启动任何分布式推理前,必须在系统环境层面抹平这个坑:
# 编辑 /etc/environment 或在运行环境加入以下环境变量
export NCCL_DEBUG=INFO
export NCCL_IB_DISABLE=1 # 消费级硬件无 InfiniBand,强制关闭
export NCCL_P2P_DISABLE=1 # 核心关键:禁止 PCIe P2P 直接传输,走共享内存回环
export CUDA_DEVICE_ORDER=PCI_BUS_ID
export CUDA_VISIBLE_DEVICES=0,1
如果你的主板支持 PCIe 拆分且跑在完整 PCIe 4.0 x8 + x8 以上,可以尝试安装带有修复补丁的第三方内核开启 P2P,但在绝大多数 B650/Z790 消费级主板上,直接加 NCCL_P2P_DISABLE=1 是最稳妥的保命配置,其吞吐损耗对 TP=2 来说完全在可接受范围内。
三、生产级 vLLM 部署配置实录
DeepSeek-R1-Distill-Qwen-32B 原始 FP16 权重需要约 65GB 显存,双卡 4090(总共 48GB)根本吃不下,必须走量化。我实测下来,AWQ 4-bit 量化 在保留模型逻辑严谨度与长文本能力上综合表现最佳,显存占用压到了 20GB 左右,留下近 26GB 空间专门做 KV Cache。
以下是我目前稳定运行在生产 Docker 容器中的启动命令:
docker run -d \
--name vllm-deepseek-32b \
--runtime nvidia \
--gpus '"device=0,1"' \
-v /data/models:/root/.cache/huggingface \
-p 8000:8000 \
--ipc=host \
vllm/vllm-openai:v0.7.2 \
--model casperhansen/deepseek-r1-distill-qwen-32b-awq \
--tensor-parallel-size 2 \
--gpu-memory-utilization 0.90 \
--max-model-len 16384 \
--enable-prefix-caching \
--enforce-eager \
--max-num-seqs 16 \
--trust-remote-code
关键参数硬核拆解:
--enable-prefix-caching:必须开启! DeepSeek-R1 的 System Prompt 通常很长,开启 APC(Automatic Prefix Caching)后,连续追问时无需重复计算 Prompt 的 KV Cache。--max-model-len 16384:根据业务需求动态划定。虽然模型支持更长,但 R1 的 Thinking 过程通常占用 2k-6k tokens,锁在 16k 可以平衡显存分配,防止极端情况下单次请求把 KV Cache 显存池挤爆。--enforce-eager:4090 在启用 CUDA Graph(默认模式)时可能会在某些特殊 context 长度下报显存分配碎片化错误,加上这个选项虽然会牺牲一点点极限吞吐,但能换来极高的稳定性,彻底告别CUDA error: out of memory during graph capture。--ipc=host:多卡 PyTorch 分布式通信必须使用主机 IPC 命名空间,否则极易由于共享内存不足而报错退出。
四、编写抗断连的 SSE 流式网关
很多开发者直接把 vLLM 的 8000 端口暴露给客户端,这在局域网内部署时存在一个严重的性能隐患:用户取消请求(关闭网页或取消补全)时,vLLM 后端依然在默默把推理结果生成完。
DeepSeek-R1 动辄输出几千字的思维链,如果前端取消了但后台继续算,显卡算力就会被这些“僵尸任务”白白耗尽。我用 Python FastAPI 写了一个反向代理中间件,专门处理 SSE 流式传输时的反压与客户端断连熔断:
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx
import asyncio
app = FastAPI()
VLLM_API_URL = "http://127.0.0.1:8000/v1/chat/completions"
@app.post("/v1/chat/completions")
async def proxy_chat(request: Request):
client_payload = await request.json()
# 强制将流式状态开启
client_payload["stream"] = True
async def event_generator():
client = httpx.AsyncClient(timeout=120.0)
try:
async with client.stream(
"POST",
VLLM_API_URL,
json=client_payload,
headers={"Authorization": request.headers.get("Authorization", "")}
) as response:
async for chunk in response.aiter_raw():
# 检查客户端是否已经断开连接
if await request.is_disconnected():
print("[Gateway] 客户端已主动断开,立刻终止后端请求上游!")
break
yield chunk
except asyncio.CancelledError:
print("[Gateway] 异步任务被取消,释放推理资源")
finally:
await client.aclose()
return StreamingResponse(event_generator(),
media_type="text/event-stream"
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080, log_level="warning")
这个网关虽然代码不长,但借助 request.is_disconnected() 机制,一旦用户在前端点了“Stop Generating”,网关会自动掐断与 vLLM 上游的 HTTP 长连接。vLLM 识别到客户端 socket 关闭后,会立即终止当前的 Prefill/Decode 序列,立刻腾出 KV Cache 给队列中的下一个请求使用。
五、实测性能与选型建议
在双卡 4090(PCIe 4.0 x8 + x8)环境下,使用上述配置拉起 DeepSeek-R1-Distill-Qwen-32B-AWQ 进行压测,实测数据如下:
| 指标项目 | 单并发(Single Stream) | 4 并发压力测试 | 8 并发压力测试 |
|---|---|---|---|
| Time-to-First-Token (TTFT) | 0.12s (Cache 命中) / 1.3s (冷启动) | 1.8s | 3.5s |
| Decode 速度 (单流) | 46.2 tokens/s | 31.5 tokens/s | 19.8 tokens/s |
| 系统总吞吐量 (System Throughput) | 46.2 tokens/s | 126.0 tokens/s | 158.4 tokens/s |
| 显存占用率 (GPU 0 / GPU 1) | 21.8 GB / 21.8 GB | 22.5 GB / 22.5 GB | 23.2 GB / 23.2 GB |
总结与避坑心得
在消费级硬件上跑大参数量推理模型,必须学会在显存、量化精度和上下文窗口之间做动态取舍:
- 模型规格克制:双 4090 运行 32B-AWQ 是黄金平衡点;不要尝试在 48G 显存下强上 70B(即使是 4-bit 量化),否则留给 KV Cache 的空间不足 4GB,上下文一旦拉长就会直接崩溃。
- 防显存碎片化:如果遇到不明原因的段错误,把
--gpu-memory-utilization从 0.95