共计 4476 个字符,预计需要花费 12 分钟才能阅读完成。
很多朋友在本地跑大模型时,第一反应是用 Ollama。开发体验确实丝滑,一条命令就拉起来了。但在团队内部提供共享 API,或者挂在生产级业务后端跑批量长文本并发时,Ollama 很容易出现排队延迟激增、吞吐断崖式下跌的问题。前阵子我们在内网跑自动化 Agent 工作流,十几个并发 Agent 同时做 RAG 检索扩写,原本单请求 3 秒搞定的任务硬生生排到了 40 多秒。
解决这个痛点的标准解法是直接上 vLLM。借助 PagedAttention、连续批处理(Continuous Batching)以及张量并行能力,吞吐量往往能直接翻个 5 到 10 倍。不过,从“能跑起来”到“稳定抗并发”,中间还隔着显存碎裂、上下文溢出以及前置反向代理 SSE 流式粘包等一堆实操暗坑。这篇文章整理自我实测调试后的生产配置模板,不搞虚头巴脑的理论,只讲实打实的参数与踩坑记录。
一、基准环境与部署配置:榨干每 1MB 显存
我们以最常见的配置为例:单张 RTX 4090(24GB 显存)或单卡 A100(80GB),部署 Qwen2.5-7B/14B 或 DeepSeek-R1-Distill-Qwen 系列。为了环境隔离和避免 CUDA 版本冲突,强烈推荐直接使用官方 Docker 镜像起容器。
1. 启动容器与核心参数配置
下面是我在自建节点上稳定运行的 docker-compose.yml 核心段落:
version: '3.8'
services:
vllm-engine:
image: vllm/vllm-openai:latest
container_name: vllm-server
runtime: nvidia
restart: always
environment:
- CUDA_VISIBLE_DEVICES=0
- NCCL_DEBUG=INFO
volumes:
- /data/huggingface/hub:/root/.cache/huggingface/hub
ports:
- "8000:8000"
ipc: host
command: >
--model Qwen/Qwen2.5-14B-Instruct-AWQ
--served-model-name qwen-14b
--quantization awq
--dtype half
--gpu-memory-utilization 0.92
--max-model-len 8192
--max-num-seqs 64
--enforce-eager
--trust-remote-code
--disable-log-requests
2. 关键参数实操避坑解析
--gpu-memory-utilization 0.92:默认是 0.9。我在 24GB 的 4090 上跑 14B-AWQ 时,权重大概吃掉 9GB,剩下的显存全留给 KV Cache。设为 0.92 是一个临界甜点值;如果设到 0.95 以上,CUDA Runtime 分配临时 Buffer 时极易偶发 OOM 崩溃。--max-model-len 8192:千万不要偷懒使用模型的原生 32k/128k 上限!vLLM 会预先分配 slot,如果没有限制上下文长度,一旦并发打满,显存里的 KV Cache 块瞬间被掏空,导致并发数(Concurrent Requests)骤降。业务没有超长文档场景,锁死 8192 或 4096 是保障高吞吐的绝招。--max-num-seqs 64:定义同一个 batch 里面最多同时推进多少个生成序列。如果是做文本分类或短答案抽取,可以拉到 128;如果是长文生成,设为 32~64 能明显减少客户端首字延迟(TTFT,Time to First Token)。--enforce-eager:如果使用消费级显卡(如 3090/4090),CUDA Graph 预编译有时会卡死或吃掉 1~2GB 显存,打开此选项会略微增加调度开销,但排除了很多玄学编译报错,稳定性极佳。专业卡(A10/A100)可以去掉它以获取极限吞吐。
二、高并发流式网关:解决 SSE 缓冲与超时暗坑
很多开发者把 vLLM 裸跑在 8000 端口,前端或上层网关一连,立刻踩两个大坑:第一是打字机效果失效(等一两秒后文字一大坨直接吐出来);第二是复杂长逻辑推理时,连接在第 60 秒被前置网关无情断开。
这是因为标准的反向代理(如 Nginx)默认开了 Proxy Buffering,把 SSE(Server-Sent Events)的分块数据当普通 HTTP 响应给缓存了。解决这套流式问题,需要在网关层针对性处理。
生产级 Nginx 反向代理配置
upstream vllm_backend {
server 127.0.0.1:8000 max_fails=3 fail_timeout=10s;
keepalive 64; # 保持长连接池,避免频繁 TCP 握手开销
}
server {
listen 80;
server_name api.nassky.internal;
# 客户端上传大 Prompt 时避免报 413
client_max_body_size 64M;
location /v1/ {
proxy_pass http://vllm_backend;
proxy_http_version 1.1;
# 核心:彻底关闭缓冲,保障 SSE 逐字流式打字机体验
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection "";
chunked_transfer_encoding on;
# 超时时间调整:长文本推理或高负荷排队时避免断连
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# 标准代理头透传
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
配完执行 nginx -t && nginx -s reload。实测中,加上 proxy_buffering off; 与 keepalive 64; 后,客户端打字机的平滑度肉眼可见地提升,同时长文本输出阶段彻底告别 504 Gateway Time-out。
三、实测压测对比:从数据看真相
很多文章只讲“理论提速”,我们直接用官方推荐的压测脚本跑数据。测试硬件环境:单卡 RTX 4090 24G,驱动 550.54.14,CUDA 12.4。测试模型均为 Qwen2.5-14B-Instruct-AWQ,输入长度设定在 1024 tokens 左右,输出长度约 512 tokens。
| 框架与调度方案 | 并发数 (Concurrency) | 首字延迟 (TTFT P95) | 每秒吐字数 (Total Tokens/s) | OOM 崩溃率 |
|---|---|---|---|---|
| Ollama 默认配置 | 8 | 1.45s | 118.2 tok/s | 0% |
| Ollama 默认配置 | 32 | 6.82s | 132.5 tok/s | 排队严重阻塞 |
| vLLM + 连续批处理 | 8 | 0.38s | 342.1 tok/s | 0% |
| vLLM + 连续批处理 | 32 | 0.92s | 896.4 tok/s | 0% |
| vLLM (max-model-len 32k 滥用) | 32 | 2.80s | 520.1 tok/s | 偶发显存碎片警告 |
压测数据非常明显:在 32 并发的情况下,vLLM 的总吐字吞吐直接达到了 890+ tokens/s,将单张 4090 的算力利用率拉满,首字延迟依然压制在 1 秒以内。而没有经过显存规划、任由模型吃满长上下文的配置,不仅吞吐下降接近 40%,还极易遭遇 KV Cache 分配饥饿。
四、Python 异步客户端:优雅消费流式输出
服务端搭好后,业务侧调用怎么写?不要再用旧的 requests 同步阻塞调用了。这里给一个轻量健壮的 httpx 异步流式客户端 demo,自带异常恢复与流式输出消费:
import asyncio
import json
import httpx
async def fetch_llm_stream(prompt: str):
url = "http://api.nassky.internal/v1/chat/completions"
headers = {"Content-Type": "application/json"}
payload = {
"model": "qwen-14b",
"messages": [{"role": "user", "content": prompt}],
"stream": True,
"temperature": 0.7,
"max_tokens": 512
}
# 超时时间放宽到 120s,适配高负载排队
async with httpx.AsyncClient(timeout=120.0) as client:
async with client.stream("POST", url, headers=headers, json=payload) as response:
if response.status_code != 200:
print(f"请求失败 HTTP {response.status_code}")
return
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue
data_str = line.replace("data:", "").strip()
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
content = chunk["choices"][0]["delta"].get("content", "")
print(content, end="", flush=True)
except json.JSONDecodeError:
continue
print("\n--- 流式传输结束 ---")
if __name__ == "__main__":
asyncio.run(fetch_llm_stream("写一段高效的 Python 快速排序算法并解释其原理。"))
总结与避坑心得
对于个人极客或小团队来说,搞私有化大模型不要迷信庞大的集群编排。单张消费级卡通过合理选型 AWQ/GPTQ 量化模型 + 精确锁死 max-model-len + vLLM PagedAttention,足够应付几十个开发人员或微型业务的高并发调用。
最后总结三条血泪经验:
- 显存别贪满:
gpu-memory-utilization留 8%~10% 的余量,绝不会出现跑着跑着 CUDA Out of Memory 导致整个服务挂掉的惨剧。 - 关掉 Nginx 缓冲:永远记住给 SSE 代理加上
proxy_buffering off;,否则你的前端打字机会变成极其别扭的“便秘式输出”。 - 监控首字延迟(TTFT):在衡量大模型服务质量时,每秒吐字数是给机器看的,用户体验的核心指标是 TTFT。一旦并发上来后 TTFT 飙过 3 秒,就必须立刻在网关层做排队限流(Rate Limiting)。