实战踩坑:基于 Python + vLLM 搭建生产级大模型流式网关与并发调优

4次阅读
没有评论

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

在团队内部把开源大模型(如 Qwen2.5、Llama 3 等)落地到业务系统中时,很多同学最开始的做法非常直接:用单卡或双卡跑起 vLLM 的 OpenAI 兼容接口,直接在前端或后端代码里硬连这个 8000 端口。刚开始两三个人内测很爽,一旦上了并发测试,或者前端用户频繁刷新、中途掐断 SSE(Server-Sent Events)连接,推理引擎就很容易出现显存碎片激增、KV Cache 占满不释放、甚至整个进程被 OOM Killer 杀掉的情况。

单纯依靠模型推理引擎自带的 API Server 应对多变复杂的业务流量是靠不住的。我在上个月重构团队的 AI 基础设施时,用 FastAPI 配合 HTTPX 重写了一层轻量级异步流式网关。本文剥离了业务特有逻辑,把最硬核的高并发流式转发、背压控制(Backpressure)、客户端断连清理与 vLLM 引擎参数调优完整梳理出来。

一、为什么不能直连推理引擎?

vLLM 的 vllm serve 自带了基于 FastAPI/Uvicorn 的接口,但它本质上是一个 计算密集型 的批处理服务,而非 高弹性的边缘网关。当真实流量涌入时,你会遇到三个致命问题:

  1. 僵尸推理占用显存:客户端在收到第 5 个 Token 时点击了“停止生成”或直接关掉了网页,此时客户端 TCP 连接中断。如果网关层没有做生命周期监听,vLLM 内部并不知道请求已经取消,依然会疯狂跑完剩下的 2048 个 Token,白白霸占显存中的 PagedAttention 块。
  2. 连接池耗尽死锁:Python 标准库或初级异步封装如果不做严格的连接池上限限制,高并发长连接(Streaming SSE)会瞬间吃满底层 socket,造成新的短连接(如权限校验、Quota 查询)全部超时。
  3. 缺乏请求重试与降级缓冲:推理引擎处理不过来时会直接抛 503 Service Unavailable 或 429 Rate Limit,直接暴露给终端用户体验极差,必须在中间层做好排队与平滑削峰。

二、架构设计与异步流式网关核心实现

我们需要一个极度轻量但绝不阻塞的 Proxy。网关负责做鉴权、速率限制、分块背压,以及最重要的:下游断开时立刻向上游(vLLM)发送取消信号。

1. 生产级网关核心代码实现

这里给出基于 FastAPI + httpx.AsyncClient 的核心代理实现。请特别注意 StreamingResponse 中的异常捕获与上下文管理:

# 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")

# 配置推理后端地址(通常绑定在内网或本地 unix socket)UPSTREAM_VLLM_URL = "http://127.0.0.1:8000/v1/chat/completions"

# 严格配置异步连接池,防止连接泄露拖垮进程
client: httpx.AsyncClient = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global client
    limits = httpx.Limits(
        max_keepalive_connections=100, 
        max_connections=500, 
        keepalive_expiry=30.0
    )
    # 流式传输必须配置非常长的 read 超时,但 connect 超时必须紧凑
    timeout = httpx.Timeout(connect=5.0, read=300.0, write=5.0, pool=10.0)
    client = httpx.AsyncClient(limits=limits, timeout=timeout)
    yield
    await client.aclose()

app = FastAPI(title="LLM-Streaming-Gateway", lifespan=lifespan)

@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")

    # 检查是否为流式请求
    is_stream = body.get("stream", False)
    headers = dict(request.headers)
    headers.pop("host", None)
    headers.pop("content-length", None)

    if not is_stream:
        # 非流式直接转发,注意超时控制
        try:
            resp = await client.post(UPSTREAM_VLLM_URL, json=body, headers=headers)
            return resp.json()
        except httpx.RequestError as exc:
            logger.error(f"Upstream request failed: {exc}")
            raise HTTPException(status_code=502, detail="Upstream LLM unavailable")

    async def stream_generator():
        upstream_req = None
        try:
            # 建立上游长连接
            async with client.stream("POST", UPSTREAM_VLLM_URL, json=body, headers=headers) as upstream_response:
                if upstream_response.status_code != 200:
                    err_content = await upstream_response.aread()
                    yield f"data: {err_content.decode('utf-8')}\n\n".encode("utf-8")
                    return

                # 流式转发核心:iter_raw 规避 text 解析带来的额外 CPU 开销
                async for chunk in upstream_response.aiter_raw():
                    # 检查客户端是否已主动断开连接
                    if await request.is_disconnected():
                        logger.warning("Downstream client disconnected. Terminating upstream stream.")
                        break
                    yield chunk

        except asyncio.CancelledError:
            # 捕获 FastAPI 连接被取消的异常信号
            logger.info("Client cancelled task. Cleaning up upstream connection.")
            raise
        except Exception as e:
            logger.error(f"Error during streaming: {str(e)}")
            yield f"data: {{\"error\": \"Internal gateway error: {str(e)}\"}}\n\n".encode("utf-8")

    return StreamingResponse(stream_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no" # 解决 Nginx 缓冲导致流式卡顿的关键头
        }
    )

2. 为什么是 request.is_disconnected()?

在 Uvicorn 底层,当客户端关闭 TCP 握手时,ASGI 规范会向接收队列推送一条 http.disconnect 消息。上面代码中调用 await request.is_disconnected(),可以在网关每收到一块 Token 时探活一次。只要发现前端跑了,立即 break,async with 上下文会自动关闭上游的 HTTP/1.1 或 HTTP/2 传输流,vLLM 察觉到 socket EOF 后,就会从 Scheduler 队列中销毁对应的 sequence,瞬间释放显存。

三、上游 vLLM 引擎的几个深水区调优参数

写好了代理网关,很多朋友跑去压测,发现吞吐量还是上不去。我在部署 Qwen2.5-14B-Instruct(FP8 量化版)时,把 vLLM 的几个启动参数调校到最佳状态,性能直接翻了一倍:

# vLLM 生产启动配置片段
python3 -m vllm.entrypoints.openai.api_server \
  --model /data/models/Qwen2.5-14B-Instruct-GPTQ-Int8 \
  --tensor-parallel-size 1 \
  --gpu-memory-utilization 0.92 \
  --max-model-len 8192 \
  --max-num-seqs 128 \
  --max-num-batched-tokens 8192 \
  --enable-chunked-prefill \
  --disable-log-requests

重点参数解析与避坑经验:

  • --enable-chunked-prefill:非常关键! 如果你的业务场景经常有长上下文(例如上千字的 Prompt),vLLM 默认会在 prefill 阶段霸占整个 GPU 计算资源,导致其他正在 decode(打字机流式输出)的用户出现严重停顿(卡字)。开启 chunked-prefill 会把大 Prompt 拆块计算,把 P99 延迟平滑下来。
  • --gpu-memory-utilization 0.92:默认是 0.9。如果你单卡显存很紧张(比如 24G 的 4090 或 3090),留 8% 显存给 CUDA 运行时分配 context 已经足够,压榨出更多空间给 KV Cache 可以显著减少排队。
  • --disable-log-requests:线上务必加上!vLLM 默认每次请求打一行 log,在高并发 SSE 场景下,Python 控制台的同步 I/O 居然会成为 CPU 的瓶颈之一。

四、前端与反向代理(Nginx)排雷

不少同学搭好服务后,在本地 curl 看到是一字一字蹦出来,但换成浏览器通过 Nginx 访问时,页面总是发呆 10 秒钟,然后“哗”一下把整段文字全喷出来。这是典型的反向代理缓冲问题。

如果网关前面挡了一层 Nginx,必须在 location 块中显式关闭 Proxy 缓冲:

location /v1/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    
    # 彻底关闭 Proxy 缓冲,保证 SSE 即时推送
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;

    # 针对长思考模型(如 DeepSeek-R1)适当放宽超时
    proxy_read_timeout 600s;
    proxy_send_timeout 600s;
}

五、实测压测表现与对比

我在一台配备单张 RTX 4090D (24GB) 的自建服务器上,使用 locust 对直接暴露 vLLM 与挂载本文网关方案进行了 100 并发的阶梯压测(Prompt 长度约 300 tokens,生成 500 tokens,模拟 20% 用户在中途掐断连接):

架构方案 首字延迟 (TTFT P95) 每秒 Token 数 (TPS) 中途断连显存恢复速度 稳定性 / 报错率
直连 vLLM 默认配置 2.84s 312 tok/s 延迟 15~30s 释放 出现 5.4% 的 503 报错
网关 + Chunked Prefill + 探活断连 0.92s 486 tok/s < 500ms 立即释放 0% 报错(完全平滑排队)

实测数据非常明显:因为我们及时掐掉了无意义的客户端断连推理,宝贵的 GPU 算力没有被浪费在“无人认领”的 Token 生成上;配合 chunked-prefill,长 Prompt 不再把短 Prompt 挤得动弹不得,系统的整体吞吐量提升了超过 50%。

总结与避坑心得

把大语言模型跑起来只需要一行命令,但把它变成一个高可用、不崩坏的生产后端,考验的全是经典网络编程与微服务调优的功底。总结下来有三条铁律:

第一,绝对不要让业务端直接接触算力核心 ,中间的轻量代理层是保护 GPU 的护城河;第二, 流式场景务必严控网络断连与生命周期 ,算力是按毫秒计费的,提前释放 sequence 就是在省真金白银;第三, 重视 Pre-fill 和 Decode 阶段的算力竞争,合理配置推理引擎的切片参数。这套方案我已经在线上稳定跑了几个月,如果你也正在为私有化大模型的并发稳定性发愁,建议尽早把网关层与生命周期管理搭起来。

===EOF===

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