共计 3467 个字符,预计需要花费 9 分钟才能阅读完成。
很多开发者在搭建本地或私有化大模型服务时,首选往往是 Ollama。个人单并发测试时确实开箱即用,体验极佳。但上周我在给团队内部知识库和自动化 Agent 做接入压测时,并发仅仅拉到 8 个,Ollama 的响应延迟就直接雪崩,首字延迟(TTFT)飙升到 6 秒以上,显存更是被静态上下文塞满后频繁报 OOM。
对于多用户、多 Agent 并发的生产场景,基于 llama.cpp 单任务调度逻辑的方案很难扛住压力。我花了三天时间,将底层的推理引擎彻底切换为 vLLM,并在前端挂了一层轻量级的 FastAPI 异步流式反向网关 ,单张 RTX 4090(24G)跑 Qwen2.5-14B-Instruct-AWQ,在 20 并发下吞吐量整整提升了 3.8 倍。这篇记录下完整调优细节与避坑指南。
一、痛点拆解:为什么 Ollama 不适合多并发业务
在决定重构前,我用真实业务 Prompt(输入 1.2k tokens,输出 300 tokens)对 Ollama 进行了基准压测。问题集中暴露在以下两点:
- 缺乏高效的连续批处理(Continuous Batching): 当请求 A 刚生成第 10 个 token 时,请求 B 到达,传统的批处理必须等 A 结束或将两者强行对齐 Padding,白白浪费显卡算力;
- 内存碎片与无 PagedAttention: 随着上下文拉长,显存预分配机制导致碎片化极其严重,单卡 24G 跑 14B 模型,并发稍微抖动就会触发显存溢出;
- 流式响应断开不释放: 前端用户点“停止生成”,后端进程依然在闷头推理,白白吃满 GPU。
二、vLLM 底层引擎部署与显存压榨
在单卡 24G 显存的物理限制下,跑 FP16 浮点精度的 14B 模型显存直接吃满(14 × 2 = 28GB,必然装不下)。因此我们选用 AWQ 4-bit 量化版 (Qwen2.5-14B-Instruct-AWQ),模型权重静态占用约 9.5GB,剩下的 14GB 显存全部留给 KV Cache。
1. 关键参数排雷
我在启动 vLLM 时踩过一个大坑:默认配置下 enforce-eager 关闭,CUDA Graph 会预热分配大量显存,直接挤压 KV Cache 的可用池。最终调试出的最优启动命令如下:
# 生产级 vLLM 启动脚本
python3 -m vllm.entrypoints.openai.api_server \
--model /models/Qwen2.5-14B-Instruct-AWQ \
--served-model-name qwen-14b \
--quantization awq \
--dtype float16 \
--gpu-memory-utilization 0.92 \
--max-model-len 4096 \
--max-num-batched-tokens 8192 \
--max-num-seqs 64 \
--disable-log-requests \
--port 8000
2. 几个极其关键的参数解释:
--gpu-memory-utilization 0.92:留出 8% 显存给 PyTorch 运行时上下文和驱动,防止 CUDA OOM 导致进程 Hard Crash。--max-model-len 4096:必须根据业务实际硬顶,如果放开默认的 32k,vLLM 会为每个 Slot 预估极大的最大槽位,极大地减少并发槽位总量。--max-num-seqs 64:允许进入连续批处理的最大并发序列数,单卡 4090 设为 32~64 是甜点区间。
三、生产级流式网关实现:FastAPI + SSE 异步代理
千万不要把 vLLM 的 8000 端口直接暴露给公网。生产环境必须有一层 API Gateway,处理 Token 鉴权、Prompt 注入过滤、Rate-limiting 以及最重要的流式背压与取消监听 。
以下是我编写的高性能异步代理核心代码,基于 httpx.AsyncClient 实现 Server-Sent Events (SSE) 透传,并优雅处理客户端断开:
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
import httpx
import logging
app = FastAPI()
VLLM_API_URL = "http://127.0.0.1:8000/v1/chat/completions"
# 必须复用全局 AsyncClient 连接池,避免频繁握手消耗系统 fd
client = httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=60.0, write=5.0, pool=30.0))
@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
# 强制将业务端请求设为流式传输
body["stream"] = True
async def stream_generator():
try:
async with client.stream(
"POST",
VLLM_API_URL,
json=body,
headers={"Authorization": "Bearer internal-vllm-secret"}
) as response:
if response.status_code != 200:
yield f"data: {{\"error\": \"vLLM upstream error {response.status_code}\"}}\n\n"
return
# 处理流式分块
async for chunk in response.aiter_raw():
# 检测客户端连接状态:如果用户在前端点击了“停止生成”,立刻掐断上游请求
if await request.is_disconnected():
logging.warning("Client disconnected prematurely. Aborting upstream generation.")
break
yield chunk
except httpx.RequestError as exc:
yield f"data: {{\"error\": \"Gateway error: {str(exc)}\"}}\n\n"
return StreamingResponse(stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # 解决 Nginx 默认缓冲导致的流式吐字假死问题
}
)
代码中有一个暗坑:如果你的网关前置了 Nginx,必须添加 X-Accel-Buffering: no 响应头。否则 Nginx 会默认等待缓冲区填满(通常为 4KB/8KB)才往浏览器推数据,瞬间把“打字机流式输出”变成“大段卡顿后崩出一大坨”。
四、实测性能对比与压测数据
我们采用开源压测脚本,模拟 20 个用户并发向接口发送 Prompt,统计 5 分钟内的性能表现:
| 评测指标 | Ollama (默认配置) | vLLM + 本套代理方案 | 提升幅度 |
|---|---|---|---|
| 并发生成吞吐 (TPS) | 43 tokens/s | 164 tokens/s | +281% (3.8x) |
| 首字延迟 P95 (TTFT) | 6.82 s | 0.84 s | 降低 87.6% |
| 显存溢出率 (OOM Rate) | 18.4% (高并发下) | 0% (平稳排队) | 稳定性显著提升 |
| 客户端主动取消响应 | 后端继续计算无感知 | 20ms 内终止 KV 占用 | 无算力浪费 |
总结与避坑心得
将大模型部署从玩具级推进到生产级,工具选型是第一步。总结下来有三条铁律:
1. 开发环境玩票选 Ollama,多人高频接入必须上 vLLM / SGLang。 PagedAttention 对并发场景下显存碎片的利用率是压倒性的。
2. 算准模型上下文与显存余量。 14B AWQ 模型在 24G 显卡上,上下文锁死在 4k 到 8k 是最稳妥的,不要盲目贪大把 max-model-len 拉到最大,那会成倍挤占批处理 Slot 数量。
3. 网关层必须做断开拦截。 Agent 应用中往往有大量失败重试或用户截断,反向代理通过 request.is_disconnected() 掐断生成,是保住显卡算力不被幽灵请求榨干的最后防线。