共计 3009 个字符,预计需要花费 8 分钟才能阅读完成。
上个月给一个 12 人的小团队搭内部知识库问答,图省事直接 ollama run qwen2.5:14b 跑在机房那台 4090 上,单人测试丝滑得不行。结果上线第二天就被投诉 ” 卡到怀疑人生 ”——我抓了日志才发现,十几个人同时提问时 Ollama 基本在排队串行处理,排在后面的人光等首字就要二十几秒。被迫转向 vLLM 之后,同一张卡的输出吞吐直接涨了七倍多。这篇把整个过程里的命令、算账、报错和压测数据全部摊开讲。
一、Ollama 到底卡在哪儿:瓶颈拆解
先说结论:Ollama 不是慢,是 请求调度策略不适合多用户并发。它默认按显存自动推算 OLLAMA_NUM_PARALLEL,在 24G 卡上跑 14B 通常只给到 1~2,剩下的请求全部进队列。ollama ps 能看到 PARALLEL 列,你可以手动调高,但同时它会按并行数均分上下文长度,长文档 RAG 场景下很容易被截断。
而 vLLM 的核心是 PagedAttention + Continuous Batching:请求不再按批次攒满再跑,而是每生成一个 token 就重新调度一次,短请求生成完立刻把显存块还给新来的长请求。对 ” 多人同时问、回答长度参差 ” 的问答场景,这个差异是数量级的。
我这台机器的基础环境:
# 显卡:RTX 4090 24G(无 NVLink,单卡)nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv
# NVIDIA GeForce RTX 4090, 24564 MiB, 550.107.02
# 系统:Ubuntu 22.04.4 LTS / Docker 27.1 / nvidia-container-toolkit 1.16
二、启动配置与参数逐个解析
模型我选的是 Qwen2.5-14B-Instruct-AWQ,4bit 量化后权重约 9.6GB,留出足够的 KV Cache 空间。用官方镜像,版本必须锁死,vLLM 的小版本之间参数行为变过好几次:
docker run -d --gpus all \
--shm-size=16g \
-p 8000:8000 \
-v /data/models:/models:ro \
--restart unless-stopped \
--name vllm-qwen14b \
vllm/vllm-openai:v0.6.6 \
--model /models/Qwen2.5-14B-Instruct-AWQ \
--quantization awq_marlin \
--dtype float16 \
--max-model-len 16384 \
--gpu-memory-utilization 0.92 \
--max-num-seqs 64 \
--enable-prefix-caching \
--served-model-name qwen14b
几个参数值得单独说,全是我踩过坑的地方:
--shm-size=16g:Docker 默认 /dev/shm 只有 64MB,vLLM 的多进程 worker 加载权重时会直接总线错误退出,报错信息极其误导(RuntimeError: DataLoader worker (pid xxx) is killed by signal: Bus error)。这条不加,后面全白搭。--quantization awq_marlin:4090 是 Ada 架构(SM 8.9),Marlin 内核能吃满 INT4 的 Tensor Core。写awq也能跑,但实测 TPOT 差 15% 左右。--quantization其实可以省略让 vLLM 自检,但我建议显式写,出问题时排查路径更清晰。--max-model-len 16384:这是启动时最容易失败的一项,见下节算账。--enable-prefix-caching:RAG 场景必开。同一份知识库前缀被反复命中,实测能砍掉 40% 以上的 TTFT。
算一笔 KV Cache 的账
Qwen2.5-14B 是 GQA 结构,40 层、8 个 KV Head、head_dim 128、fp16 存储。单个 token 的 KV Cache 占用:
2 (K+V) × 40 层 × 8 head × 128 dim × 2 Bytes = 163,840 Bytes ≈ 160 KB / token
24GB × 0.92 ≈ 22.1GB 显存池,减去 9.6GB 权重和约 1GB 的激活 /CUDA Context 开销,剩给 KV Cache 大约 11.5GB,折合 约 7.3 万 token 的总容量。
这意味着:--max-model-len 32768 是绝对起不来的,启动阶段就会给你甩这段报错:
ValueError: The model's max seq len (32768) is larger than the maximum
number of tokens that can be stored in KV cache (73536 tokens).
注意这个报错里的数字是 ” 总量 ”,它只校验单条序列能不能塞下。真正的并发上限是总量除以平均序列长度——16K 上下文 + 64 并发理论上要 100 万 token,远超容量。所以 --max-num-seqs 64 只是调度器的队列上限,实际跑到长文本时会被 KV 块限流,请求开始排队。这也是为什么压测里并发 32 之后 TTFT 曲线陡升。
三、并发压测:真实数据长什么样
压测脚本用 aiohttp 直接打 OpenAI 兼容接口,测三个指标:TTFT(首 token 延迟)、TPOT(每 token 输出耗时)、稳态输出吞吐。请求固定 max_tokens=512,prompt 长度控制在 300~500 token 区间模拟真实问答。
import asyncio, time, aiohttp, statistics
URL = "http://127.0.0.1:8000/v1/chat/completions"
async def one(session, sem, res):
async with sem:
payload = {"model": "qwen14b", "stream": True, "max_tokens": 512,
"messages": [{"role": "user", "content": PROMPT}]}
t0 = time.perf_counter(); ttft = None; n = 0
async with session.post(URL, json=payload) as r:
async for line in r.content:
if not line.startswith(b"data:"): continue
if line.strip() == b"data: [DONE]": break
if ttft is None: ttft = time.perf_counter() - t0
n += 1
total = time.perf_counter() - t0
res.append((ttft, (total - ttft) / max(n - 1, 1), n / (total - ttft)))
async def bench(concurrency):
sem, res = asyncio.Semaphore(concurrency), []
async with aiohttp.ClientSession() as s:
t = time.perf_counter()
await asyncio.gather(*[one(s, sem, res) for _ in range(concurrency * 4)])
wall = time.perf_counter() - t
print(f" 并发 {concurrency}: