共计 4177 个字符,预计需要花费 11 分钟才能阅读完成。
上个月我们的知识库 Agent 业务上线灰度测试,原本内测跑得顺畅的几台本地推理服务器,在并发一冲上 50 QPS 时直接崩了:先是显存碎片迅速吃光引发 CUDA OOM 连环雪崩,紧接着网关连接池打满,客户端前端的流式输出要么卡死在首字,要么直接 504 Gateway Timeout。更要命的是,前端用户一旦关掉网页重试,后台推理进程还在吭哧吭哧空转,宝贵的算力全被僵尸任务占满了。
传统的 Python + Transformers + Flask 架构在单并发 Demo 里跑得很爽,但在生产级高并发场景下根本不够看。经过两周的排查重构,我把底层推理栈整体切到了 vLLM,并在其上用 FastAPI 搓了一层带有细粒度生命周期管理的高性能流式代理网关。单台双卡 RTX 6000 Ada(48GB×2)的并发吞吐直接从 8 req/s 飙到了 62 req/s,TTFT(首字延迟)缩短了近 68%。
一、为什么传统推理架构在流式高并发下必死?
排查过程中,我发现导致服务雪崩的不是模型推理算力不够,而是两个底层工程缺陷:
- KV Cache 显存碎片化与预分配机制致命: 传统 HuggingFace 推理在处理变长上下文时,往往需要按照
max_model_len预分配连续显存块。当几十个用户同时提问不同长度的问题时,显存碎片会高达 60%~80%。明明还有 20GB 显存,却连一个新请求都分配不出来。 - HTTP 长连接断开无法感知: 大模型流式输出采用 Server-Sent Events (SSE) 协议。当用户失去耐心关闭浏览器窗口或刷新页面时,传统的后台生成生成器(Generator)并没有监听网络套接字的断开状态,依然会把完整的 2048 个 token 全部推理完才罢休,白白浪费算力。
要解决这两个问题,核心思路是:用 PagedAttention 把 KV Cache 离散分页管理,同时在网关层建立强生命周期的任务断开取消机制。
二、vLLM 引擎初始化与生产级显存调优参数
很多同学在启动 vLLM 时往往只给一个 --model 路径就完事了,这在生产环境里极其危险。以下是我多次压测验证后的核心启动配置(以 Qwen2.5-72B-Instruct 为例,双卡张量并行):
# vLLM 服务端启动脚本 run_vllm.sh
python3 -m vllm.entrypoints.openai.api_server \
--model /data/models/Qwen2.5-72B-Instruct \
--served-model-name qwen-72b \
--tensor-parallel-size 2 \
--gpu-memory-utilization 0.92 \
--max-model-len 8192 \
--max-num-seqs 128 \
--max-num-batched-tokens 8192 \
--swap-space 16 \
--disable-log-requests \
--port 8000
核心参数踩坑解析:
--gpu-memory-utilization 0.92:默认是 0.90。实测留出 8% 显存作为 CUDA runtime 缓冲最稳,设太高(如 0.98)偶尔遇到极长 Prompt 时会直接触发底层 NCCL 通信显存溢出导致崩盘。--max-num-seqs 128:单实例允许的最大并发请求批处理数。对于 72B 级别模型,设太大不仅无助于吞吐,还会导致上下文首字延迟(TTFT)呈指数级恶化。--swap-space 16:为每个 GPU 分配 16GB 的 CPU 内存作为 Cache 换出空间。当突发并发到来时,暂时无法处理的请求 KV 缓存会被缓存在系统内存中,而不是直接抛出 OOM 异常。
三、生产级 FastAPI 流式网关:解决 SSE 孤儿任务泄漏
直接把 vLLM 暴露给前端是极其不安全的,我们需要在中间加一层 API Gateway:负责鉴权、流式转发、频率限制以及 客户端断开检测。很多开发者不知道的是,如果不显式监听 request.is_disconnected(),FastAPI 的 StreamingResponse 即使网络连接断开也会继续向后端请求数据。
下面是我编写并落地在生产环境中的高性能异步流式代理实现:
import httpx
import json
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
app = FastAPI(title="LLM Streaming Proxy")
VLLM_BACKEND_URL = "http://127.0.0.1:8000/v1/chat/completions"
# 维持长连接连接池,避免每次请求频繁创建 TCP 握手与 TLS 开销
client = httpx.AsyncClient(limits=httpx.Limits(max_keepalive_connections=200, max_connections=500),
timeout=httpx.Timeout(connect=5.0, read=60.0, write=5.0, pool=10.0)
)
async def stream_generator(request: Request, payload: dict):
"""流式转发生成器,并严格监控客户端连接状态"""
try:
async with client.stream("POST", VLLM_BACKEND_URL, json=payload) as response:
if response.status_code != 200:
err_text = await response.aread()
yield f"data: {json.dumps({'error': err_text.decode()})}\n\n"
return
async for chunk in response.aiter_lines():
# 关键点:每次 yield 之前检测客户端是否已经主动关闭页面 / 断连
if await request.is_disconnected():
# 记录日志,主动打断下游生成,释放显存与算力
print("[Proxy] Client disconnected. Cancelling upstream stream.")
break
if chunk:
yield f"{chunk}\n\n"
except httpx.RequestError as exc:
yield f"data: {json.dumps({'error': f'Upstream error: {str(exc)}'})}\n\n"
@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")
# 强制流式输出模式
body["stream"] = True
return StreamingResponse(stream_generator(request, body),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # 如果经过 Nginx,必须禁用缓冲,否则流式无法实时逐字吐出
}
)
@app.on_event("shutdown")
async def shutdown_event():
await client.aclose()
这里有几个关键工程细节:
X-Accel-Buffering: no: 如果你在前端和 FastAPI 之间架了 Nginx,必须带上这个响应头。否则 Nginx 会默认等待收集满一个缓冲区(如 4KB)才向客户端推送,用户在前端感知到的就是“卡死几秒然后瞬间喷出一大段”,毫无打字机效果。- 全局 httpx 连接池: 使用带高并发上限配置的
AsyncClient复用底层 TCP 连接,避免在每秒数百次并发时系统抛出Too many open files或本地可用端口耗尽。
四、实战压测与数据对比
我们使用 Locust 模拟实际业务交互对重构前后的两套系统进行阶梯式压测(硬件环境:双卡 RTX 6000 Ada 48GB,CPU 32 核 64 线程,内存 256GB;模型:Qwen2.5-72B-Instruct)。测试场景为模拟真实对话:Prompt 长度约 1024 tokens,生成长度约 512 tokens。
| 架构方案 | 并发用户数 | 首字延迟 (TTFT P95) | 平均生成速度 (Tokens/s) | 请求失败率 (OOM/504) |
|---|---|---|---|---|
| 旧架构 (HF+FastAPI) | 20 | 4.82s | 14.2 | 8.5% |
| 旧架构 (HF+FastAPI) | 50 | 21.3s | 3.1 | 56.0% (显存频繁 OOM) |
| 新架构 (vLLM+Proxy) | 20 | 0.48s | 48.5 | 0.0% |
| 新架构 (vLLM+Proxy) | 50 | 0.89s | 41.2 | 0.0% |
| 新架构 (vLLM+Proxy) | 100 | 1.95s | 33.8 | 0.2% |
从数据可以看出,引入 PagedAttention 的动态连续批处理(Continuous Batching)之后,哪怕并发达到 100,系统也完全没有发生显存崩塌,而是通过请求排队与显存换页稳健地处理流量。首字延迟维持在 2 秒以内,完全能满足线上业务的交互需求。
总结与避坑心得
在大模型落地的过程中,算法调优只占三成,剩下的七成全是系统工程层面的硬骨头。基于这套架构的实践,我总结出三条最核心的落地原则:
- 绝不在生产环境裸跑原生 Transformer 推理: 无论是 vLLM、TGI 还是 SGLang,现代推理引擎提供的连续批处理机制是支撑并发的底线。
- 永远要对长流式连接做防御性编程: 大模型推理成本极其高昂,网关层必须具备连接断开主动拦截的能力,别让已经关闭网页的请求继续烧显卡。
- 留出足够的动态显存安全裕度: 不要把
gpu-memory-utilization堆满到 0.99,特别是在开启长上下文任务时,保留 8%~10% 的显存缓冲是防止服务突发性 OOM 重启的最划算保险。