共计 3877 个字符,预计需要花费 10 分钟才能阅读完成。
上个月给业务团队搭了一套基于 70B 模型的专属 Agent 推理集群,刚上线那会儿风平浪静,结果市场部做活动,并发一瞬间冲上 120。原本平均 300ms 的首字延迟(TTFT)直接飙升到 8 秒,显卡直接抛出 CUDA out of memory,整台 Pod 瞬间瘫痪被 Kubelet 强杀。当时我就意识到:传统的 FastAPI 简单包一层 HuggingFace Pipelines 或单实例 Ollama,在真实的生产业务流面前完全不堪一击。
很多开发者在做本地大模型落地时,容易把模型在终端里“跑通单次对话”等同于“具备服务能力”。面对多轮对话上下文膨胀、连续批处理(Continuous Batching)以及流式输出中途断开导致的显存僵尸占用,必须动用生产级推理引擎与流式网关架构。这篇硬核实战,记录了我用 vLLM + Ray 构建自建高并发推理网关的全部关键配置与压测踩坑笔记。
一、痛点拆解:为什么原生方案并发一高就挂?
在深入调优前,先把导致显存雪崩的三个核心根因剖析清楚:
- KV Cache 静态碎片化: Transformer 生成 Token 时依赖历史注意力缓存(KV Cache)。如果按最大上下文长度(如 8k、32k)预分配连续内存,哪怕用户只发了两个字,剩余显存也被死锁;如果是动态重分配,内存碎片又会引发显存段错误。
- 长尾推理阻塞全局: 传统批处理(Static Batching)必须等当前批次中最长的一个请求生成结束,才能开始下一批。短请求被长请求无限拖慢。
- 客户端断开连接导致的算力黑洞: 用户在前端点击“停止生成”或直接关掉网页,后端如果没捕捉到 HTTP 断开信号,GPU 依然会勤勤恳恳地把剩下的几百个 Token 算完,浪费昂贵的浮点算力。
二、底层引擎:vLLM 的关键参数极限调优
业内现在首选 vLLM,关键在于其借鉴虚拟内存分页思想的 PagedAttention 机制,将 KV Cache 切成离散的 Block,显存利用率能直接从 20% 拉升到 90% 以上。但这玩意儿开箱默认配置并不适合多租户生产环境,必须手动捏死几个核心参数。
下面是我在双卡 RTX 4090 / A100 环境下沉淀下来的启动脚本配置:
#!/usr/bin/env bash
# 启动 vLLM 服务端实例
python3 -m vllm.entrypoints.openai.api_server \
--model /models/Qwen2.5-14B-Instruct \
--served-model-name qwen2.5-14b \
--tensor-parallel-size 2 \
--pipeline-parallel-size 1 \
--gpu-memory-utilization 0.92 \
--max-model-len 8192 \
--max-num-batched-tokens 16384 \
--max-num-seqs 256 \
--block-size 16 \
--swap-space 8 \
--disable-log-requests \
--port 8000
核心参数排障与取舍解析:
--gpu-memory-utilization 0.92:千万不要设成 0.98 以上。模型权重加载完毕后,剩余显存全部被 PagedAttention 瓜分。必须预留 5%~8% 的显存缓冲给 CUDA 运行时堆栈,否则在处理特长 Prompt 时极易触发硬件层面的上下文分配崩溃。--max-num-seqs 256:最大并发处理序列数。对于 14B~32B 级别的模型,单机设为 256 是一个兼顾吞吐与单请求时延的甜点值。--swap-space 8:划出 8GB 系统内存作为换出区(CPU Swap)。当突发流量导致 GPU KV Cache 暂时耗尽时,vLLM 会把不活跃块暂存到宿主机内存,而不是直接报错拒流。
三、生产级网关:FastAPI 流式代理与主动断流机制
直接把 vLLM 暴露给前端是极其危险的。我们需要在前面架设一层轻量级网关,负责 API Key 鉴权、限流、请求分发,以及最关键的 客户端连接断开检测。
我在早期踩过的大坑是:FastAPI 使用常规的 StreamingResponse 时,如果前端取消请求,Python 的异步迭代器并不会立即退出,底层的 HTTPX 连接继续挂在 vLLM 后端,GPU 依然在疯狂空转。正确做法是监听客户端连接的 is_disconnected 状态并主动触发 aclose():
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
import httpx
import json
app = FastAPI(title="LLM Gateway")
VLLM_BACKEND_URL = "http://127.0.0.1:8000/v1/chat/completions"
# 维持长连接池,降低握手开销
client_pool = 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_pool.stream("POST", VLLM_BACKEND_URL, json=payload) as response:
if response.status_code != 200:
error_body = await response.aread()
yield f"data: {json.dumps({'error': error_body.decode()})}\n\n"
return
async for chunk in response.aiter_raw():
# 关键排障:主动检测下游客户端是否已经断开(如前端用户点击取消)if await request.is_disconnected():
# 立即跳出循环,触发 context manager 正常关闭后端流,通知 vLLM 释放显存
break
yield chunk
except httpx.RequestError as exc:
yield f"data: {json.dumps({'error': f'Network error: {str(exc)}'})}\n\n"
@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
payload = await request.json()
# 强制开启流式以降低前端等待焦虑
payload["stream"] = True
return StreamingResponse(stream_generator(request, payload),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # 禁用 Nginx 默认缓冲,防止流式输出卡顿
}
)
四、压测实证:Locust 压测下的真实数据表现
为了检验这套架构的真实极限,我使用 Locust 编写了压测脚本,模拟并发用户发送 100~300 字的问题,并期望生成 200 字回答。测试环境为两张 A100-SXM4-80GB,部署 Qwen2.5-32B 模型。
| 架构方案 | 并发请求数 | 平均首字延迟 (TTFT) | 吞吐量 (Tokens/sec) | 失败率 (OOM/Timeout) |
|---|---|---|---|---|
| 原生 HF + FastAPI | 30 | 4820 ms | 142 | 38.4% (严重 OOM) |
| 默认配置 vLLM | 100 | 840 ms | 1180 | 3.2% (个别超时) |
| 本文调优版 + 网关控流 | 150 | 312 ms | 2450 | 0.00% |
实测数据非常直观:经过参数收敛和连续批处理接管后,在 150 并发下系统的吞吐能力翻了将近两倍,且 TTFT 控制在了 300ms 左右的可用范围,显存占用稳定在 91.5%,没有出现一次 CUDA OOM。
总结与避坑心得
自建 LLM 服务的核心,从来不是把模型权重量化后跑起来,而是在并发倾泻而来时保持系统的健壮性。在这套架构推向生产的过程中,我踩出的三个最痛教训包括:
- 反向代理别开缓冲:如果网关外层挂了 Nginx,必须在 location 块加上
proxy_buffering off;,同时响应头加上X-Accel-Buffering: no,否则 Nginx 会把流式 Token 攒满 4KB 才吐给前端,你的流式响应就彻底变成了“憋大招”。 - NUMA 绑核不容忽视:如果是多路 CPU + 多 GPU 服务器,启动网关与推理服务时一定要用
numactl --cpunodebind=X --membind=X绑定到 GPU 挂载的对应 NUMA 节点,跨 Socket 访问内存会引入 15%~20% 的延迟损耗。 - 长 Prompt 限制必须前置:不要信任用户的输入长度,网关层一定要用 tiktoken/transformers 分词器粗筛一次 Token 长度。超过模型上限的请求必须在网关层直接拒绝(400),绝不能放进 GPU 的调度队列里去耗费宝贵的 PagedAttention 显存块。
把算力留给真正的计算,把阻塞与废请求卡在网关之外,这才是稳定部署大模型服务的终极逻辑。
===END===