共计 4956 个字符,预计需要花费 13 分钟才能阅读完成。
最近在为团队的一套多 Agent 协同工作流搭建集中式推理网关。后端挂了三台双卡 4090 跑 vLLM,前端对外暴露标准 OpenAI 兼容的 SSE(Server-Sent Events)接口。刚上线内测没几天,监控告警就炸了:前端用户稍微点快两下、刷新页面,FastAPI 服务节点的内存就呈阶梯式上涨,紧接着出现大量 502/504 超时,后端的显卡算力明明空载率高达 60%,中间转发网关却彻底锁死了。
排查了一整天日志和抓包分析,发现问题根本不在大模型推理速度,而在于绝大多数 Python 开发者在处理“长连接流式代理(Streaming Proxy)”时,把常规 HTTP 短连接的开发习惯生搬硬套了过来。今天这篇博客,我把这套经过压测验证、单机轻松抗住数千并发长连接的异步 SSE 网关方案及关键避坑代码完整拆解出来。
一、为什么常规 FastAPI 转发流式请求会崩?
用 Python 做大模型流式代理,最常见的“毒代码”往往长这样:
# 典型错误示范:看似异步,实则隐患重重
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import httpx
app = FastAPI()
@app.post("/v1/chat/completions")
async def chat_proxy(payload: dict):
client = httpx.AsyncClient() # 坑点 1:每次请求新建客户端,耗尽文件描述符
req = client.build_request("POST", "http://backend-vllm/v1/chat/completions", json=payload)
r = await client.send(req, stream=True)
return StreamingResponse(r.aiter_raw(), media_type="text/event-stream") # 坑点 2:客户端断开无法感知,后端持续空转
这套逻辑在本地单个请求调试时丝滑无比,一上高并发就会踩到三个致命暗坑:
- 连接池耗尽与 TIME_WAIT 泛滥:每次请求临时创建
AsyncClient,不仅握手耗时拉长,还会瞬间打爆 Linux 文件句柄(ulimit -n),导致系统级端口耗尽。 - 孤儿请求(Zombie Requests)吞噬显存:用户在浏览器端点击“停止生成”或直接关掉标签页,由于没有监听客户端断开事件,网关依然在死命向 vLLM 拉取 Token,原本紧张的显卡算力全被废弃请求占满。
- 默认 asyncio 事件循环吞吐瓶颈:CPython 默认的 asyncio event loop 在处理超高并发长连接 IO 分发时,CPU 开销非常显著,响应延迟会出现剧烈抖动。
二、架构重构:高性能异步 SSE 网关实现
要构建生产可用的流式网关,核心思路是:全局连接池复用 + uvloop 事件循环加速 + 异步管道生命周期监听 + 断联主动熔断后端任务。
1. 依赖与底层运行时优化
首先确保使用 uvloop 替代标准库事件循环,并选型高性能 HTTP 引擎:
pip install fastapi uvicorn[standard] uvloop httpx orjson
2. 核心网关代码(带断开感知与资源释放)
下面是我目前在生产节点上跑的网关核心骨架:
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
import httpx
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("sse-gateway")
# 全局共享的异步 HTTP 客户端
http_client: httpx.AsyncClient = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global http_client
# 配置长连接保活和连接池上限
limits = httpx.Limits(max_keepalive_connections=500, max_connections=2000)
# 流式传输必须把 read 超时设为 None 或较长时间,避免模型吐首字过慢导致断连
timeout = httpx.Timeout(connect=5.0, read=None, write=10.0, pool=5.0)
http_client = httpx.AsyncClient(limits=limits, timeout=timeout)
logger.info("HTTPX 连接池初始化完成")
yield
await http_client.aclose()
logger.info("HTTPX 连接池已安全关闭")
app = FastAPI(lifespan=lifespan)
async def sse_event_generator(request: Request, upstream_response: httpx.Response):
"""流式转发生成器:具备下游断连嗅探与异常清理机制"""
try:
async for chunk in upstream_response.aiter_raw():
# 关键排错逻辑:检测下游前端 / 客户端是否提前关闭了连接
if await request.is_disconnected():
logger.warning("客户端已主动断开连接,正在熔断上游推理流...")
break
yield chunk
except asyncio.CancelledError:
logger.info("检测到任务被异步取消 (CancelledError)")
raise
except Exception as e:
logger.error(f"流式传输异常中断: {str(e)}")
raise
finally:
# 必须显式关闭上游响应流,触发上游连接回收
await upstream_response.aclose()
@app.post("/v1/chat/completions")
async def proxy_chat(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
# 构造发往后端大模型服务的请求
upstream_url = "http://192.168.10.50:8000/v1/chat/completions"
headers = {
"Authorization": "Bearer sk-internal-key",
"Content-Type": "application/json"
}
req = http_client.build_request("POST", upstream_url, json=body, headers=headers)
try:
upstream_response = await http_client.send(req, stream=True)
except httpx.ConnectError:
raise HTTPException(status_code=503, detail="后端推理集群无法连接")
if upstream_response.status_code != 200:
error_content = await upstream_response.aread()
return StreamingResponse(iter([error_content]),
status_code=upstream_response.status_code,
media_type="application/json"
)
# 以流式方式返回给终端客户端
return StreamingResponse(sse_event_generator(request, upstream_response),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # 极其关键:防止 Nginx 层进行 buffer 缓冲导致流式卡顿
}
)
三、生产落地避坑指南(实战血泪经验)
1. 反向代理的“双刃锁”:Nginx 缓冲机制
很多老哥把 Python 代码写对了,但是中间隔了一层 Nginx 或者 Traefik,结果前端依然是一坨一坨地出字,甚至卡到整个请求结束才一次性吐出全部回答。核心原因在于反向代理的缓冲区。
在 Nginx 的反向代理配置块中,务必加上以下参数:
location /v1/chat/completions {
proxy_pass http://127.0.0.1:8080;
# 彻底关闭代理缓冲,确保每个 token 实时透传
proxy_buffering off;
proxy_cache off;
# 支持 HTTP/1.1 以启用 KeepAlive
proxy_http_version 1.1;
proxy_set_header Connection "";
# 延长超时时间,防止复杂 Prompt 思考时间过长引发 504
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
注意看我们在 Python 代码中注入的响应头X-Accel-Buffering: no,只要 Nginx 开启了配置支持,它会自动遵从该指令禁用单次请求的缓存。
2. 启动脚本与 Linux 内核调优
启动服务时,绝不能用 uvicorn main:app --reload 这种开发命令,建议通过 Gunicorn 托管 Uvicorn Worker,并启用 uvloop:
gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8080 \
--backlog 4096 \
--keep-alive 65
对于单机承受上千并发长连接的机器,务必调整系统连接上限:
# 编辑 /etc/security/limits.conf
* soft nofile 65535
* hard nofile 65535
# 优化 TCP 连接释放与复用
sysctl -w net.ipv4.tcp_tw_reuse=1
sysctl -w net.core.somaxconn=4096
四、实测压测数据对比
为了验证这套网关的鲁棒性,我在单台 4 核 8G 虚拟机(Ubuntu 22.04)上使用 Locust 发起了压测模拟。后端模拟一个每隔 50ms 产出 1 个 Token(共 50 个 Token)的推理服务,对比优化前后的网关表现:
| 方案指标 | 未优化版本(每次 new Client) | 本方案(池化 +uvloop+ 断联清理) |
|---|---|---|
| 并发长连接数 (Connections) | ~350(再往上频繁抛 Too many open files) | 3000+(稳定维持) |
| 首字延迟抖动 (TTFT P99) | 1420ms | 112ms |
| 内存占用(稳定期) | 持续上升直至 OOM(>2.8GB) | 平稳维持在 210MB 左右 |
| 客户端异常断开显卡回收 | 失败(后端继续跑完) | 毫秒级熔断上游 |
总结与避坑心得
处理大模型流式接口不同于传统的 CRUD。它具备 “高持续时长”、“单请求带宽低但状态重” 的典型特点。只要在开发中咬死两点:绝不在热路径上反复创建连接客户端 、 时刻检查客户端断联状态并及时 close 上游迭代器,Python 借助 FastAPI 与 uvloop 完全能以极低的资源消耗,挑起数千并发 AI 长连接网关的大梁。
下一步如果流量进一步增加,可以考虑在 Python 网关前置 Envoy 做流量切分与熔断限流,但在中轻量级或中小业务矩阵中,本文这套方案已经足够稳定地跑上很长一段时间了。
===END===