共计 2813 个字符,预计需要花费 8 分钟才能阅读完成。
上周帮一个做内贸 SaaS 的朋友收拾他们公司的内网 AI 助手。场景很朴素:一台 4090 工作站,20 来号人共用,做合同摘要、SQL 生成和客服话术润色。原本跑的是 Ollama,单个人用着还行,一旦市场部三个人同时贴 3000 字的长文档进去,前端转圈能转到用户以为页面挂了。这种 ” 人一多就瘫 ” 的体验,是本地推理最常见的翻车现场,跟模型能力无关,纯粹是调度层的问题。
一、核心背景与痛点拆解
先把硬件和模型定死:单卡 RTX 4090 24G,模型选 Qwen2.5-14B-Instruct-AWQ(int4 量化后权重约 9.2GB)。选 14B 不选 32B 是因为 24G 显存扣掉权重后剩不下多少 KV Cache,32B 的 AWQ 也能塞进去,但 max-model-len 只能压到 4096 左右,处理长合同时直接截断,业务上不能接受。
Ollama 的问题在我实测之后非常明确:它默认的并行度低,队列是串行化的。我写了个 asyncio 脚本模拟 8 个客户端同时请求(输入 1024 token,输出 256 token),结果如下:
# 压测脚本核心逻辑(简化)# 8 个协程并发打 /v1/chat/completions,统计 TTFT 和总吞吐
python bench.py --url http://127.0.0.1:11434/v1 --concurrency 8 --input-len 1024
Ollama 的 8 并发总吞吐只有 55 tok/s 左右,P95 首 token 延迟飙到 9 秒多——基本等于 8 个人在排队。而 vLLM 靠 PagedAttention + continuous batching,同样 8 并发总吞吐能到 180 tok/s。这不是调优能追上的差距,是架构层面的差异:Ollama 底层是 llama.cpp,它的强项是单机单用户和跨平台,不是高并发服务化。
二、vLLM 部署与关键参数避坑
我用的官方镜像 vllm/vllm-openai:v0.6.3,和自己 pip 装相比省掉了 CUDA、flash-attn 版本对不上的折磨。启动命令是这样:
docker run -d --gpus all --shm-size 16g \
--name vllm-qwen \
-p 8000:8000 \
-v /data/models:/models:ro \
--restart unless-stopped \
vllm/vllm-openai:v0.6.3 \
--model /models/Qwen2.5-14B-Instruct-AWQ \
--served-model-name qwen14b \
--quantization awq \
--dtype float16 \
--max-model-len 16384 \
--gpu-memory-utilization 0.90 \
--max-num-seqs 32 \
--enable-prefix-caching \
--enable-chunked-prefill \
--disable-log-requests
几个参数我踩过坑,逐条说:
–max-model-len:这是最容易翻车的一个。Qwen2.5-14B 是 GQA 结构,40 层、8 个 KV head、head_dim 128,算下来每 token 的 KV Cache 约 160KB(fp16)。开 16384 长度,单条满长请求就要吃 2.6GB 显存。24G 卡按 0.90 利用率算可用 21.6G,减去权重 9.2G 和激活值、CUDA context 约 2G,剩 10G 左右,日志里会打印 maximum concurrency for 16384 tokens per request: 3.8x。这就是你的真实并发上限,别指望再高。我第一次手贱写了 32768,容器起来跑完 warmup 直接 OOM 退出,日志就一行 No available memory for the cache blocks。
–max-num-seqs:默认 256,在消费级卡上是灾难。请求全被吃进调度器,KV Cache 反复驱逐、prefill 排队,吞吐反而下降。24G 卡设 32 比较稳,压测下来 P95 延迟和总吞吐的平衡点就在这附近。
–enable-prefix-caching:对 ” 同一份长文档被多人反复提问 ” 这种场景收益极大。朋友那边的合同模板前缀完全相同,开了之后同一文档的第二个请求 prefill 时间几乎归零。但要注意它会额外占用显存存 hash 块,显存紧张时优先关掉它。
–shm-size 16g:默认 Docker 的 64MB 共享内存,vLLM 多进程加载权重时会直接报 Bus error,这个坑我排查了半小时,一度怀疑显卡坏了。
三、LiteLLM 网关与 Nginx 流式的 ” 假流式 ” 陷阱
团队里有 LangChain、有 Dify、还有几个直接调 OpenAI SDK 的老代码,全都要 OpenAI 兼容接口。所以在 vLLM 前面套一层 LiteLLM 做统一入口:负责虚拟 key 分发、用量统计、以及未来接第二张卡横向扩展的路由。
# /etc/litellm/config.yaml
model_list:
- model_name: qwen14b
litellm_params:
model: openai/qwen14b
api_base: http://127.0.0.1:8000/v1
api_key: "sk-vllm-internal"
timeout: 300
stream_timeout: 300
rpm: 120
router_settings:
routing_strategy: simple-shuffle
num_retries: 1
allowed_fails: 3
litellm_settings:
drop_params: true
request_timeout: 300
general_settings:
master_key: sk-nassky-xxxxxxxx
database_url: postgresql://litellm:[email protected]:5432/litellm
litellm --config /etc/litellm/config.yaml --port 4000 --num_workers 2
然后是 Nginx。这里有个让前端同事抓狂了两天的问题:接了 Nginx 之后,vLLM 日志明明在逐 token 输出,浏览器端却要等整段生成完才一次性刷出来。Nginx 默认开启 proxy_buffering,它会把上游的 SSE 数据攒在缓冲区里,攒够了才转发。表现就是 ” 假流式 ”,功能看着没错,体验完全没了。
location /v1/ {
proxy_pass http://127.0.0.1:4000/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;