扛住高并发流式LLM:基于FastAPI与uvloop构建异步SSE网关实战

4次阅读
没有评论

共计 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===

正文完
 0
评论(没有评论)