共计 4422 个字符,预计需要花费 12 分钟才能阅读完成。
很多同学在搭建自己的本地 LLM 接入服务或者小团队 AI Agent 基础设施时,往往直接拉起一个 Ollama 跑脚本。玩具阶段确实香,但一旦挂上几个自动化工作流或者稍微上点并发,首字延迟(TTFT)立马飙到数秒,显存稍有波动就直接 OOM 崩溃退出。如果硬着头皮上 Triton 或上云端托管集群,维护成本又直接劝退个人开发者与小团队。
上个月我把工作室的一台单卡 RTX 4090(24G)机器改造成内部高并发推理节点,给 20+ 个并发 Agent 提供纯流式接口。经过几轮压测和排障,最终敲定了一套基于 vLLM + 异步 FastAPI 流式反代 + 动态并发熔断 的方案。今天把整套落地踩坑实操、生产级配置和核心排障记录整理出来,拿走就能直接抄作业。
一、为什么 Ollama 不行,而 vLLM 是单卡吞吐天花板?
在讨论调优前,先搞清大模型单卡推理的两个物理限制: 显存带宽受限(Memory Bound) 和 KV Cache 碎片化 。
常规方案(如早期 HuggingFace Transformers / llama.cpp 的默认并发模式)分配显存是连续预分配的。只要并发一上来,KV Cache 很快就会因为大量不连续的碎片化内存导致虽然显存看着还有剩,但新请求根本塞不进去。此外,静态 Batching 机制必须等待同一个批次里最长的那个句子生成完才释放资源,短请求全被拖死。
vLLM 解决这个问题的杀手锏非常简单粗暴:
- PagedAttention:借鉴操作系统的虚拟内存分页机制,将 KV Cache 切成离散的小 Page,显存利用率拉到接近 96% 以上,几乎零碎片。
- Continuous Batching(连续批处理):每产生一个 Token 就动态调整 Batch 队列,执行完的请求立即离场,新请求无需等待全局对齐无缝插队。
二、生产级 Docker 编排与 vLLM 核心参数避坑
很多教程直接 python -m vllm.entrypoints.openai.api_server 就开跑,结果在实际生产跑几天后发现:要么显存刚启动就爆了,要么长上下文稍微多来几个就触发 CUDA OOM。下面是我反复微调后的核心启动参数配置。
以部署 Qwen2.5-7B-Instruct(4-bit GPTQ 或 AWQ 量化版,或 BF16 原生版)为例,编写 docker-compose.yml:
version: '3.8'
services:
vllm-engine:
image: vllm/vllm-openai:latest
container_name: vllm-engine
runtime: nvidia
environment:
- CUDA_VISIBLE_DEVICES=0
- NCCL_DEBUG=INFO
volumes:
- /data/huggingface/hub:/root/.cache/huggingface
ports:
- "8000:8000"
ipc: host
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
command: >
--model Qwen/Qwen2.5-7B-Instruct-AWQ
--quantization awq
--served-model-name qwen2.5-chat
--gpu-memory-utilization 0.90
--max-model-len 8192
--max-num-seqs 256
--enable-prefix-caching
--trust-remote-code
restart: unless-stopped
几个致命参数的实操心得:
--gpu-memory-utilization 0.90:千万不要设成 0.98 或者 1.0!vLLM 会根据这个比例预先瓜分绝大多数显存给 KV Cache。如果你留给 CUDA 运行时的动态缓冲显存不足,后续一旦调用复杂的 Logits 算子或者长文本输入,立刻会扔出torch.cuda.OutOfMemoryError。给系统保留 10% 显存是底线。--enable-prefix-caching(前缀缓存):做 Agent 或 RAG 系统的必开选项!多个请求常常带有完全相同的 System Prompt 或知识库 Prompt。开启后这部分相同的 Token 不需要重复计算 KV Cache,首字延迟直接暴降 60%~80%。--max-num-seqs 256:单批次最大并发序列数。如果显存小,别设太大,避免单次迭代中计算量过饱和导致流式输出卡顿(Jitter)。ipc: host:必须加。PyTorch 多进程通信依赖共享内存,Docker 默认 64MB 共享内存会导致多卡或并发拉满时瞬间崩溃。
三、构建高并发异步流式网关(FastAPI + SSE)
虽然 vLLM 自带了 OpenAI 兼容接口,但直接将它暴露给外网或业务集群是极其危险的。vLLM 内部的调度器没有鉴权、没有动态限流,一旦遭遇恶意突发流量,连接池耗尽会导致正常业务也 hang 住。
我们需要在前面挡一层轻量级的 Python 异步网关,专门处理连接保活、背压(Backpressure)控制与 SSE 流式转发:
# gateway.py
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
import asyncio
app = FastAPI(title="LLM Edge Gateway")
VLLM_INTERNAL_URL = "http://127.0.0.1:8000/v1/chat/completions"
# 针对单卡设置并发信号量,避免过载堆积
MAX_CONCURRENT_REQUESTS = 64
semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
# 复用全局异步客户端连接池
client = httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=120.0, write=5.0, pool=10.0),
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200)
)
@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
if semaphore.locked():
# 超过负荷立即快速失败,避免把显卡直接冲垮
raise HTTPException(status_code=429, detail="Engine Busy, Retry Later")
body = await request.json()
# 强制开启流式以降低网关内存持有
body["stream"] = True
async def stream_generator():
async with semaphore:
async with client.stream("POST", VLLM_INTERNAL_URL, json=body) as response:
if response.status_code != 200:
yield f"data: {{\"error\": \"Upstream returned {response.status_code}\"}}\n\n"
return
async for chunk in response.aiter_bytes():
yield chunk
return StreamingResponse(stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # 防止 Nginx 缓存流式数据
}
)
if __name__ == "__main__":
import uvicorn
# 生产启动推荐用 4-8 个 worker
uvicorn.run("gateway:app", host="0.0.0.0", port=8080, workers=4)
在这层网关中,有三个核心工程细节:
X-Accel-Buffering: no:如果最外层挂了 Nginx,必须显式下发这个响应头,否则 Nginx 会默认缓冲 4KB 数据才吐给前端,流式打字机效果直接变成卡顿“块状吐出”。- 全局单例
httpx.AsyncClient:切忌在每个请求里async with httpx.AsyncClient(),高并发下会导致严重的 TIME_WAIT 套接字耗尽。 - 信号量防御 :利用
asyncio.Semaphore设定动态硬阈值,宁可返回 429 也坚决不让后端排队队列撑爆显存。
四、实测压测与吞吐对比
硬件环境:AMD 7950X / 64G DDR5 / 单卡 RTX 4090 24G。
测试工具:自编写异步 Python Locust 脚本,模拟多并发下的短 Prompt 输入(约 150 Tokens)和中长文本输出(约 300 Tokens)。测试模型为 Qwen2.5-7B-Instruct-AWQ。
| 部署方案 | 并发并发数 (CC) | 平均首字延迟 (TTFT) | 生成吞吐量 (Tokens/s) | 显存利用率 |
|---|---|---|---|---|
| Ollama 默认配置 | 16 | 1480 ms | 112 tok/s | ~68%(易发生排队) |
| Ollama 默认配置 | 32 | 超时 / 报错 | — | OOM Crash |
| vLLM + PagedAttn | 32 | 185 ms | 1050 tok/s | 89.5%(平稳) |
| vLLM + 前缀缓存优化 | 64 | 92 ms | 1680 tok/s | 90.0%(满载无丢包) |
实测数据非常明显:在 64 并发的高压下,vLLM 配合前缀缓存(Prefix Caching)跑出了 1680 Tokens/s 的总吞吐量,平均单个 Token 的生成时延被均匀摊平,首字响应几乎在 100ms 左右完成,这对于 Agent 交互和实时聊天体验是质的飞跃。
总结与避坑心得
在大模型端侧落地这个领域,从“跑通 Demo”到“支撑并发”中间隔了一条巨大的工程鸿沟。总结三个关键结论:
- 量化选型认准 AWQ / GPTQ:在有 Tensor Core 加速的消费级显卡(如 3090/4090)上,AWQ 格式相比 GGUF 在 GPU 显存内更能榨干并发计算性能,显存节省近半但精度几乎不掉。
- 长上下文是显存刺客 :单张 24G 卡部署 7B 模型,一定要限制
--max-model-len(通常 8k~16k 足够)。如果允许 32k 且同时进来 10 个请求,再强的 PagedAttention 也救不了显存。 - 一定要加前置网关防线 :绝不要拿推理引擎直面公网或直接丢给高频调用方。几行简单的异步并发限制与缓冲区配置,是让节点 7×24 小时稳定不崩的核心底气。
===END===