Python + FastAPI 搭建高性能 LLM 流式代理:异步分块、Token 计费与内存防爆实战

2次阅读
没有评论

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

上个月我把公司内部的知识库 Agent 接入国产 DeepSeek 和线上 OpenAI 时,网关服务连续两天在下午业务高峰期莫名其妙地出现 Worker 假死。查看监控,CPU 利用率并不高,但内存曲线呈阶梯状一路狂飙直到 OOM,同时大量的客户端开始报 504 Gateway Timeout。

很多开发者用 Python 写大模型中继代理时,逻辑通常非常简单:用 httpx 发请求,把得到的响应塞进 FastAPI 的 StreamingResponse。如果你在本地写 Demo 或者单并发测试,这种写法丝滑无比;但在 50+ 并发、长上下文(32k+ tokens)、耗时普遍在 10 秒以上的流式连接冲击下,这种“玩具级”写法会迅速把服务器拖垮。

一、玩具代码背后的三大致命死穴

先看一段最容易在网上见到的“标准”流式转发代码:

# 典型反面教材:千万不要直接扔到生产环境
@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
    payload = await request.json()
    
    async def event_generator():
        client = httpx.AsyncClient()
        async with client.stream("POST", UPSTREAM_URL, json=payload) as resp:
            async for chunk in resp.aiter_bytes():
                yield chunk
                
    return StreamingResponse(event_generator(), media_type="text/event-stream")

这段代码在真实生产流量下有三个足以致命的缺陷:

  1. 客户端生命周期失控(Client Disconnect):前端用户关掉标签页或点击“停止生成”时,默认情况下 FastAPI 的生成器并不会立刻中断上游连接。上游大模型还在继续吐字计费,下游代理还在傻傻缓冲,直接浪费算力和带宽。
  2. 连接池与 Client 频繁创建销毁:在生成器内部实例化 httpx.AsyncClient(),高并发下会导致大量处于 TIME_WAIT 的 TCP 连接,端口迅速耗尽。
  3. 无法在流式中无感计费与审计:流式传输直接转发了原始字节,若想提取返回文本统计 Token 消耗量,传统做法要么让前端上报,要么在内存中完整拼接整个字符串,这在长回复时极易造成内存驻留峰值。

二、工业级异步流式网关实现

针对上述痛点,我们需要设计一个包含 全局连接池复用 、 下游断开实时检测 、 流式 Chunk 无额外内存分配解析 的代理中继。以下是提炼自生产环境的完整核心模块:

import json
import logging
from typing import AsyncGenerator
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
import tiktoken

app = FastAPI()
logger = logging.getLogger("llm_proxy")

# 1. 生产环境严禁临时创建 Client,维护全局单例并合理配置超时与连接池
limits = httpx.Limits(max_keepalive_connections=100, max_connections=500, keepalive_expiry=30.0)
timeout = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=10.0)
http_client = httpx.AsyncClient(limits=limits, timeout=timeout)

@app.on_event("shutdown")
async def shutdown_event():
    await http_client.aclose()

async def stream_processor(
    request: Request,
    upstream_response: httpx.Response,
    encoding_name: str = "cl100k_base"
) -> AsyncGenerator[bytes, None]:
    """
    流式代理核心:- 监听下游断开信号
    - 逐块产出 SSE
    - 统计 Output Tokens
    """
    total_tokens = 0
    enc = tiktoken.get_encoding(encoding_name)
    
    try:
        async for raw_line in upstream_response.aiter_lines():
            # 关键:检查客户端是否已经断开连接
            if await request.is_disconnected():
                logger.warning("Downstream client disconnected. Aborting upstream task.")
                break
                
            if not raw_line:
                continue

            # 透传原始数据流
            yield f"{raw_line}\n\n".encode("utf-8")

            # 仅处理 SSE 规范的数据行(避免全量 JSON decode 开销)if raw_line.startswith("data:"):
                data_str = raw_line[6:].strip()
                if data_str == "[DONE]":
                    break
                try:
                    chunk_json = json.loads(data_str)
                    choices = chunk_json.get("choices", [])
                    if choices:
                        delta = choices[0].get("delta", {})
                        content = delta.get("content", "")
                        if content:
                            # 增量计算 Token,不拼接全局长文本,避免大堆内存驻留
                            total_tokens += len(enc.encode(content, disallowed_special=()))
                except json.JSONDecodeError:
                    continue

    except Exception as e:
        logger.error(f"Error during stream forwarding: {str(e)}")
        raise
    finally:
        # 在这里执行异步审计或数据库扣费写入
        logger.info(f"Stream finished. Total output tokens metered: {total_tokens}")
        # 显式关闭上游流式连接
        await upstream_response.aclose()

@app.post("/v1/chat/completions")
async def proxy_chat(request: Request):
    body = await request.body()
    
    # 构建转发请求(剥离不必要的 Host 头)headers = dict(request.headers)
    headers.pop("host", None)
    headers.pop("content-length", None)
    
    # 建立上游长连接
    req = http_client.build_request(
        method="POST",
        url="https://api.openai.com/v1/chat/completions",
        headers=headers,
        content=body
    )
    
    try:
        upstream_resp = await http_client.send(req, stream=True)
    except httpx.ConnectError:
        raise HTTPException(status_code=502, detail="Upstream LLM Provider Unreachable")
    except httpx.PoolTimeout:
        raise HTTPException(status_code=503, detail="Proxy Worker Exhausted")

    # 如果上游直接报错(例如 401、429),直接回抛结构化错误,不要继续走流式
    if upstream_resp.is_error:
        error_content = await upstream_resp.aread()
        return StreamingResponse(iter([error_content]),
            status_code=upstream_resp.status_code,
            media_type="application/json"
        )

    return StreamingResponse(stream_processor(request, upstream_resp),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no" # 关掉 Nginx 缓冲区,保证打字机实时体验
        }
    )

三、踩坑记录:高并发下必须修复的三个底层细节

1. Nginx 反向代理层吞掉首字延迟(TTFT)

在本地调试很正常,一旦部署到 Linux 服务器、前面顶了一层 Nginx,很多同学就会发现流式输出变成了“一段一段吐”,甚至等到模型生成了四五百个字才突然哗啦一下全部显示出来。

这是因为 Nginx 默认开启了 proxy_buffering。大模型的 SSE 传输本质是小分块 HTTP 报文,Nginx 会尝试把它攒满一个 proxy_buffer_size(通常是 4k/8k)才发给前端。除了在应用层加上 X-Accel-Buffering: no 头之外,在 Nginx 对应 Location 块中必须显式指定:

location /v1/chat/completions {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    
    # 强制关闭缓冲,保障极低首字时延
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
}

2. Uvicorn Worker 数量与事件循环饥饿

很多小伙伴习惯用 gunicorn -w 4 -k uvicorn.workers.UvicornWorker。但在 LLM 这种纯粹的 I/O 密集、高长连接占比场景下,每个 Worker 的事件循环中挂载了上千个可能持续几十秒的连接。

如果在生成器内部进行了耗时的 CPU 密集操作(比如同步执行高耗时的正则脱敏,或者在同一线程中使用 tiktoken 编码长达几千字的 prompt),会直接把当前 Worker 的 Event Loop 堵死,导致其他所有正在流式输出的连接瞬间卡顿。

实操原则:

  • 所有的分词计量必须分片化,绝对不要在流式循环中一次性加载大文本。
  • 如果有敏感词过滤或内容安全审查,必须交给 asyncio.to_thread 放到单独线程池,或者通过 Redis 队列转交给独立 Worker 处理。

3. `request.is_disconnected()` 的轮询代价与陷阱

在 FastAPI 中,request.is_disconnected() 是一个异步方法,它底层依赖 Starlette 的接收队列。如果你的客户端主动断开连接,只要你没有调用 await request.receive(),该状态有时无法立即更新。

在上面的代码实现中,我们在 async for raw_line in upstream_response.aiter_lines() 的每一次迭代中调用该方法。由于上游模型持续吐出 token(频率大约每秒 20~80 次),这恰好构成了天然的心跳检测探针。实测在断开连接后的 50ms 内即可探测到底层套接字断开,及时执行 break 进而触发 finally 中的 upstream_response.aclose(),直接为 GPU 推理后端释放了一个 Batch Slot。

四、实测压测数据对比

我们使用 Locust 模拟 200 个并发长连接持续请求大模型中继,每个请求要求输出约 1000 个 Tokens(耗时约 12 秒),连续压测 10 分钟:

方案 200 并发吞吐失败率 首字延迟 (TTFT P95) 网关内存占用 (峰值)
未优化的基础代码(每次实例化 Client) 18.4% (大量的 ConnectTimeout) 3800ms 1.8 GB (且回落极慢)
优化后方案(全局池化 + 实时防爆) 0.0% 420ms 180 MB (平稳不抖动)

总结与避坑心得

在 Python 异步生态中,大模型中继网关绝对不是一个简单的“requests 替换为 httpx”就能搞定的活。处理 SSE 流式传输的核心只有八个字:连接受控、资源闭环。

上线前务必在终端用 netstat -an | grep 8000 | awk '{print $6}' | sort | uniq -c 观察连接状态,如果看到数千个 CLOSE_WAIT 或 TIME_WAIT,就说明你的上游或下游资源没有正确在上下文退出时调用 aclose()。把本文的连接池配置和断开监测放到你的代码里,把流式数据当成流水而不是蓄水池,你的网关服务才能扛得住业务洪峰。

===EOF===

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