共计 3812 个字符,预计需要花费 10 分钟才能阅读完成。
很多折腾本地大模型的朋友起手式都是 Ollama。坦白讲,单人拿着终端通过命令行跟 Qwen 聊两句,Ollama 确实开箱即用。但当我在自己的 HomeLab 服务器(Ubuntu 22.04 + 单卡 RTX 3090 24G)上接入 Dify、Open-WebUI,并挂了两个后台爬虫 Agent 跑总结时,问题立马暴露了:并发请求一上来,Ollama 底层的 llama.cpp 调度逻辑直接让响应延迟从 200ms 飙升到 8 秒以上,甚至偶尔直接报 CUDA OOM(显存溢出)崩溃。
根本原因在于显存碎片化以及缺少工业级的持续批处理(Continuous Batching)调度机制。为了榨干这块 24G 显存,我花了两个周末把整个推理栈切到了 vLLM,并手写了一层流式代理网关。这套方案跑了快两个月,稳如老狗。今天把整套实战配置、踩坑代码和压测数据毫无保留地复盘出来。
一、为什么抛弃 Ollama 转投 vLLM?
在决定折腾前,我们先看两者的底层架构差异:
- Ollama (llama.cpp):显存预分配较死板,KV Cache 在并发处理时没有做到类似操作系统虚拟内存的分页管理。遇到不同上下文长度的并发请求时,极易产生显存碎片,排队机制相对简陋。
- vLLM:核心在于 PagedAttention 算法。它把 KV Cache 切分成物理块(Block),按需动态分配,允许非连续显存存储。这意味着显存浪费率能从 60%~80% 压到 4% 以下,极度适合高并发、多长文本混合的流式推理。
二、宿主机与 Docker Compose 生产级配置
在 NAS 或 Linux 工作站上跑 vLLM,最稳妥的方案是直接走官方 Docker 镜像。但千万别直接 docker run 一行搞定,显存和共享内存的坑多得吓人。
首先确保宿主机安装好了驱动和 NVIDIA Container Toolkit:
# 验证 NVIDIA 容器运行时是否就绪
docker run --rm --gpus all nvidia/cuda:12.1.1-base-ubuntu22.04 nvidia-smi
接下来是我的核心 docker-compose.yml 文件。这里以运行 Qwen/Qwen2.5-14B-Instruct-AWQ 或 deepseek-ai/DeepSeek-R1-Distill-Qwen-14B 为例,模型已经预先下载在本地 NAS 路径:
version: '3.8'
services:
vllm-service:
image: vllm/vllm-openai:v0.6.3.post1
container_name: vllm-server
restart: always
ipc: host # 必须配置,防止 PyTorch DataLoader / NCCL 共享内存溢出
shm_size: '16gb' # 兜底给足共享内存
environment:
- CUDA_VISIBLE_DEVICES=0
- HF_HUB_OFFLINE=1
- VLLM_ATTENTION_BACKEND=FLASHINFER # 如果驱动和卡支持,首选 FLASHINFER 或 FLASH_ATTN
volumes:
- /mnt/storage/ai-models/Qwen2.5-14B-Instruct-AWQ:/models/qwen2.5-14b
ports:
- "8000:8000"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
command: >
--model /models/qwen2.5-14b
--served-model-name qwen-14b
--quantization awq
--dtype half
--max-model-len 8192
--gpu-memory-utilization 0.90
--max-num-seqs 16
--enable-chunked-prefill
--enforce-eager
关键参数避坑解析:
--gpu-memory-utilization 0.90:很多人第一次用 vLLM,看到刚启动显卡显存就吃了 90%,以为泄露了。 其实这是 vLLM 的机制 :它预先把 90% 显存锁定,扣除模型本身的权重后,剩下的几 GB 全部分配给 KV Cache 池子。别调到 0.98,给 CUDA Runtime 和 Context 预留 10% 缓冲,否则请求突发时必崩。--enable-chunked-prefill:极度关键!老版本 vLLM 在并发请求时,如果有长 Prompt 进来,会完全阻塞其他用户的实时输出。开启分块预填充后,长 Prompt 会被拆分批次计算,保证多用户流式输出(TTFT)依然顺畅。--enforce-eager:在 3090/4090 消费级显卡上,关掉 CUDA Graph(开启 eager 模式)能省下近 1GB 专用于 Graph Capture 的显存,同时规避不少因动态 Batch 引发的奇怪 CUDA 报错。
三、自建 FastAPI 流式反代:解决中断悬挂与访问控制
生产环境绝不能把 vLLM 的 8000 端口直接丢给内网甚至公网。我遇到过一个恶心的问题:前端 UI(如 Chat 界面)如果用户直接点击“停止生成”或关闭网页,OpenAI 兼容接口如果没有正确处理断开信号,vLLM 后端依然会在后台把整串 Token 吐完,显存和算力全白白浪费。
我在中间架设了一层轻量级 FastAPI 异步代理,负责鉴权、SSE 断开监听与流量打底:
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
app = FastAPI()
VLLM_UPSTREAM_URL = "http://127.0.0.1:8000/v1/chat/completions"
@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
# 1. 简易鉴权处理
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer sk-nassky-"):
raise HTTPException(status_code=401, detail="Invalid API Key")
payload = await request.json()
# 强制开启流式以节省并发占用
payload["stream"] = True
async def event_generator():
client = httpx.AsyncClient(timeout=120.0)
try:
async with client.stream(
"POST",
VLLM_UPSTREAM_URL,
json=payload,
headers={"Content-Type": "application/json"}
) as response:
if response.status_code != 200:
yield f"data: {{\"error\": \"Upstream returned {response.status_code}\"}}\n\n"
return
async for line in response.aiter_lines():
# 关键逻辑:检测客户端断开连接,及时掐断上游请求
if await request.is_disconnected():
# 中断生成,释放 vLLM 内部 sequence 资源
break
if line:
yield f"{line}\n\n"
finally:
await client.aclose()
return StreamingResponse(event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # 解决 Nginx 缓冲导致无法实时流式打字的问题
}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080, workers=2)
四、实测压测数据对比:Ollama vs vLLM
我用自写的 Python 异步压测脚本,针对 Qwen2.5-14B-Instruct(Ollama 采用 Q4_K_M,vLLM 采用 AWQ-4bit)进行了多轮并发压测。输入设定为 500 Tokens 上下文,输出限制 300 Tokens。
| 测试引擎 | 并发并发数 (Concurrency) | 首字延迟 (TTFT P95) | 总吞吐量 (Total Tokens/s) | 显存占用状态 |
|---|---|---|---|---|
| Ollama (llama.cpp) | 1 并发 | 0.42s | 46.2 tok/s | 11.5 GB(固定) |
| 4 并发 | 3.85s | 58.1 tok/s | 16.2 GB | |
| 8 并发 | 9.21s (卡顿感严重) | 61.4 tok/s | 频繁爆显存 OOM | |
| vLLM (PagedAttention) | 1 并发 | 0.48s | 43.8 tok/s | 21.6 GB(预分配) |
| 4 并发 | 0.68s | 142.6 tok/s | 21.6 GB(池化复用) |