用 FastAPI 搭建生产级大模型流式网关:连接池、背压与防泄漏实战

7次阅读
没有评论

共计 4888 个字符,预计需要花费 13 分钟才能阅读完成。

上个月给团队内部搭建一套聚合大模型 Gateway,把本地私有部署的 vLLM、Ollama 与外部 OpenAI 兼容接口打通。初期写得非常顺手,用 FastAPI 的 StreamingResponse 配合 httpx.AsyncClient,几十行代码就跑通了流式输出(SSE)。

但在灰度压测阶段,直接给我上了一课:并发压到 150 个长连接时,网关服务的内存曲线单调递增,后端 vLLM 显卡风扇狂转但吞吐暴跌;客户端一旦频繁取消请求(比如用户看了一眼开头就点“停止生成”或关闭网页),Python 网关并没有通知后端中断,后端依然在疯狂推理,直接把算力吃满。

如果你也在用 Python 做大模型中间层,下面的踩坑记录和架构设计,能帮你少走很多弯路。

一、流式代理的三个隐形杀手

普通 RESTful 请求是“请求 - 等待 - 返回”的短命模型,而 LLM 的流式输出(Server-Sent Events)属于 长生命周期、慢吞吐、IO 密集型 连接。简单的异步转发在这个场景下会暴露致命弱点:

  • 连接池震荡与耗尽:如果在每次请求中动态 async with httpx.AsyncClient(),不仅有频繁握手 /TLS 开销,而且系统会在短时间内堆积成千上万个 TIME_WAIT 套接字。
  • 下游断开无感知(Ghost Generation):用户在前端关闭标签页,下游 TCP 已经断开,但 FastAPI 默认并不会立即终止内部的 async generator,导致 Python 继续向上游拉取 Token,上游继续占用 GPU 算力。
  • 背压(Backpressure)失控:当上游模型每秒吐出 100 个 Token,而下游移动端网络较差只能收 20 个时,中间的差异数据全被缓存进 Python 进程内存中,一旦并发上来,极易遭遇 OOM(Out Of Memory)。

二、高并发流式代理架构核心实现

我们直接上核心方案。整个代理架构依托 FastAPI 的 lifespan 管理全局 HTTP 连接池,并在生成器中注入 Request.is_disconnected() 监听机制,实现真正的双向级联取消。

# gateway.py
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("llm-gateway")

# 全局客户端容器
state = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    # 调优连接池参数:保持长连接,控制最大并发数
    limits = httpx.Limits(
        max_keepalive_connections=200,
        max_connections=500,
        keepalive_expiry=30.0
    )
    # 针对 LLM 流式特点调整超时:连接超时要短,读取超时要放宽
    timeout = httpx.Timeout(
        connect=5.0,
        read=60.0,
        write=10.0,
        pool=5.0
    )
    state["client"] = httpx.AsyncClient(limits=limits, timeout=timeout)
    logger.info("HTTPX 异步连接池已初始化")
    yield
    await state["client"].aclose()
    logger.info("HTTPX 异步连接池已释放")

app = FastAPI(lifespan=lifespan)

UPSTREAM_LLM_URL = "http://10.0.0.88:8000/v1/chat/completions"

async def stream_proxy_generator(req_body: dict, client_request: Request):
    client: httpx.AsyncClient = state["client"]
    
    # 向上游发起流式请求
    try:
        req = client.build_request("POST", UPSTREAM_LLM_URL, json=req_body)
        response = await client.send(req, stream=True)
        
        if response.status_code != 200:
            error_detail = await response.aread()
            yield f"data: {{\"error\": \"Upstream returned {response.status_code}\", \"detail\": {error_detail.decode('utf-8')}}}\n\n"
            return

        async with response:
            async for chunk in response.aiter_bytes():
                # 关键:检查客户端连接是否依然存活(实现背压与主动中断)if await client_request.is_disconnected():
                    logger.warning("客户端已主动断开,立即终止向上游拉取 Token")
                    break
                
                # 原样转发 SSE Chunk
                yield chunk

    except httpx.ConnectTimeout:
        logger.error("连接上游模型服务超时")
        yield "data: {\"error\": \"Gateway timeout connecting to upstream model\"}\n\n"
    except httpx.ReadTimeout:
        logger.error("上游模型吐字超时")
        yield "data: {\"error\": \"Model inference response timeout\"}\n\n"
    except Exception as e:
        logger.error(f"代理流异常: {str(e)}")
        yield f"data: {{\"error\": \"Proxy internal error: {str(e)}\"}}\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 payload")

    # 如果是非流式请求,走标准转发逻辑(略),这里只拦截流式
    if not body.get("stream", False):
        raise HTTPException(status_code=400, detail="Only stream=True is supported on this endpoint")

    return StreamingResponse(stream_proxy_generator(body, request),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no", # 禁用 Nginx 缓冲区(极为关键)}
    )

三、三个极其隐蔽的生产翻车点

1. 反向代理层(Nginx)强制缓冲导致“首字延迟(TTFT)爆炸”

很多同学在本地通过 uvicorn gateway:app 测得好好的,上游一出词,终端立刻刷出。部署到线上 Nginx 后面后,前端必须等大模型把整段话吐完才瞬间展示,体验直接退化成非流式。

原因在于 Nginx 默认启用了 proxy_buffering,它会尽量填满内部 Buffer(通常是 4k 或 8k)才给客户端下发一个 TCP 包。解决办法有两个:

# 方案 A:在 Nginx 配置中针对该 location 显式关闭
location /v1/chat/completions {
    proxy_pass http://127.0.0.1:8000;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 300s;
    chunked_transfer_encoding on;
}

方案 B 就是我在 Python 代码中注入的响应头 "X-Accel-Buffering": "no"。只要 Nginx 开启了允许上游覆盖缓冲区特性,这个头能生效,避免改动全局 Nginx 配置。

2. Uvicorn 异步事件循环过载与文件描述符限制

高并发长连接状态下,单机打开的 Socket 连接数激增。如果没调 Linux 系统层和工作进程参数,你会在并发爬升时看到:OSError: [Errno 24] Too many open files。

请确保启动脚本与系统层解除限制:

# 提升系统最大打开文件句柄
ulimit -n 65535

# 生产环境启动方案:使用 Gunicorn 托管 Uvicorn 工作进程
# 绑定 uvloop,单 worker 轻松抗下数千长连接
gunicorn gateway:app \
  --workers 4 \
  --worker-class uvicorn.workers.UvicornWorker \
  --bind 0.0.0.0:8000 \
  --backlog 2048 \
  --timeout 120 \
  --keep-alive 5

3. 绝不能在流式端点中使用 Gzip 中间件

如果你在 FastAPI 应用全局挂了 GZipMiddleware,它会尝试捕获整个响应体压缩后再下发,流式传输会彻底失效,内存会直接把整个 Token 序列聚齐,且必须等上游结束才会吐给下游。如果必须对全站启用压缩,务必排除该路径,或者在流式生成端点直接使用 raw 流转发。

四、性能验证与压测表现

我在局域网内搭建了测试集群:8 核 16G 节点运行该网关,后端对接部署在 A100 上的 vLLM 服务。使用 hey 压测工具模拟高频请求与随机中断场景:

# 发起 200 并发长连接流式请求测试
hey -n 2000 -c 200 -m POST \
  -H "Content-Type: application/json" \
  -d '{"model":"qwen2.5-7b","messages": [{"role":"user","content":" 写一篇 3000 字关于火星殖民的科幻小说 "}],"stream": true}' \
  http://127.0.0.1:8000/v1/chat/completions

对比调优前后的性能指标:

  • TTFT(首字延迟):未优化(动态 Client + Nginx 缓冲)均值 2800ms;优化后(连接复用 + 禁用缓冲)降至 320ms。
  • 网关内存占用:持续 200 并发压测 30 分钟,优化前由于断开无感知导致 Python 堆积未完成 Coroutine,内存爬升至 1.8GB;优化后稳定在 140MB 左右,垃圾回收正常,无 Handle 泄漏。
  • 后端 GPU 利用率:客户端在随机 2 秒后强制掐断连接,后端 vLLM 收到网关的主动断连通知,当前 Batch 终止解码,推理算力节省了 60% 以上。

总结与避坑心得

用 Python 做 AI Agent 网关是绝大多数团队的第一选择,但从“写一个 Demo”到“抗住生产流量”,核心转变就在于 资源的确定性管理。

长连接环境下,绝对不要对连接的建立和销毁放任不管。锁死 HTTPX 连接池大小、给 Read Timeout 保留足够冗余、利用 request.is_disconnected() 监听前端动作建立反向熔断,再把反向代理的缓冲区剥离干净——做好这四件事,Python 完全能以极低资源开销支撑起极其丝滑的流式体验。

如果未来业务规模进一步膨胀到单机万级并发,可以考虑用 Rust(比如 Axum)重写网关核心转发路径,但对于

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