实战踩坑:基于 vLLM + Ray 构建低延迟流式高并发 LLM 推理网关

1次阅读
没有评论

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

最近在为内部团队的自动化 Agent 平台重构推理底座时,我们遇到了一个经典痛点:当 10 个以上并发任务同时触发多轮对话与长上下文检索时,直接使用传统 Hugging Face 管道甚至简单的封装服务,首字延迟(TTFT, Time To First Token)会从 200ms 直线上升到 4 秒以上,显存更是频繁因 KV Cache 碎片化直接被打爆(OOM)。

为了榨干手头两张 RTX 4090 和 A100 的每一寸显存带宽,我花了整整一周时间,在 Ubuntu 22.04 环境下对 vLLM 底层调度进行了深度调优,并手写了一套轻量级的 FastAPI 高并发流式反向代理。这篇文章不讲空泛理论,只讲能在终端跑通的配置、压测实测数据以及踩坑教训。

一、核心痛点:为什么你的推理集群一并发就崩?

在自建 LLM 服务的过程中,很多开发者误以为并发能力只取决于 GPU 的算力(TFLOPS),实际上长文本对话与流式输出是典型的 内存带宽受限(Memory-Bound)场景。瓶颈通常卡在以下三个地方:

  • KV Cache 碎片化:传统框架在序列初始化时会静态预分配最大上下文长度(如 4096 或 8192),导致高达 60%~80% 的显存实质上处于闲置浪费状态。
  • 首字计算阻塞生成:长 Prompt 的 Prefill(预填充阶段)计算量极大,若没有分块处理机制,新请求一进来就会霸占计算资源,导致正在 Decode(解码流式输出)的其他请求全部瞬间停顿卡顿。
  • 代理层同步阻塞:后端推得快,但前端网关如果用了同步 HTTP 客户端或未针对 Server-Sent Events (SSE) 做缓冲区清空,网络 I/O 就会把 Python 事件循环拖垮。

二、vLLM 生产级启动姿势与关键参数调优

别直接跑 python3 -m vllm.entrypoints.openai.api_server --model ...,默认参数只适合本地试玩,根本扛不住生产并发。针对 Qwen2.5-7B/14B 或 Llama-3 系列模型,我调优后的启动脚本如下:

#!/usr/bin/env bash
# 生产环境推荐:锁定显存分配、开启分块 Prefill 与前缀缓存

export CUDA_VISIBLE_DEVICES=0,1
export NCCL_DEBUG=INFO

python3 -m vllm.entrypoints.openai.api_server \
    --model /models/Qwen2.5-14B-Instruct \
    --tensor-parallel-size 2 \
    --host 0.0.0.0 \
    --port 8000 \
    --gpu-memory-utilization 0.92 \
    --max-model-len 8192 \
    --max-num-batched-tokens 4096 \
    --enable-chunked-prefill \
    --enable-prefix-caching \
    --swap-space 16 \
    --disable-log-requests

必须死磕的核心参数拆解:

  • --gpu-memory-utilization 0.92:默认是 0.9。对于 24GB 显存卡,留 8% 显存给 CUDA 运行时上下文和通信缓冲区即可,剩下的全部塞给 PagedAttention 分配 KV Blocks。
  • --enable-chunked-prefill:关键低延迟开关!它把长 Prompt 切成若干批次,将 Decode 与 Prefill 均匀塞入同一个 Batch 中执行。实测在 50 并发下,TTFT 峰值直接削减了 65%。
  • --enable-prefix-caching:对 Agent 系统(系统提示词固定、包含大量工具描述)有奇效。相同的前缀 KV Cache 会直接复用,不需要重复计算,省显存又提速。
  • --disable-log-requests:高并发下每秒几百条请求输出到终端,Python 的 sys.stdout 会成为意想不到的 CPU 锁瓶颈,务必关掉。

三、构建高并发流式代理(FastAPI + AsyncIO)

如果直接把 vLLM 裸露给公网或微服务,缺乏鉴权、动态限流和连接生命周期监控。我们在中间架设一层轻量异步网关,核心代码必须保证全链路异步零拷贝:

import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse

app = FastAPI()
VLLM_BACKEND_URL = "http://127.0.0.1:8000/v1/chat/completions"

# 维持一个长连接池,避免每次请求频繁三次握手与销毁连接
limits = httpx.Limits(max_keepalive_connections=200, max_connections=500)
client = httpx.AsyncClient(limits=limits, timeout=httpx.Timeout(60.0, connect=5.0))

@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
    try:
        req_body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid JSON body")

    async def stream_generator():
        # 流式向后端 vLLM 转发,避免大响应堆积内存
        async with client.stream(
            "POST",
            VLLM_BACKEND_URL,
            json=req_body,
            headers={"Content-Type": "application/json"}
        ) as response:
            if response.status_code != 200:
                yield f"data: {{\"error\": \"Upstream error {response.status_code}\"}}\n\n"
                return

            async for chunk in response.aiter_raw():
                # 显式刷出缓冲区,确保客户端实时平滑收到 Token 打字机效果
                yield chunk

    return StreamingResponse(stream_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no" # 解决 Nginx 默认缓存 SSE 流的问题
        }
    )

四、实操踩坑与排障实录

1. 踩坑:Nginx 转发导致客户端“一顿一顿”地吐字

排查思路:代码里明明写了流式生成,但在客户端界面看到的内容却是隔 2 秒突然蹦出一整段。原因在于前面挂了 Nginx 做 HTTPS 终结时,默认开启了 proxy_buffering。

解决方案:在 Nginx 反向代理 location 块中加入强制刷新指令:

location /v1/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
}

2. 踩坑:多卡 Tensor Parallelism (TP) 导致的 NCCL 挂死

在双卡 4090 上配置 --tensor-parallel-size 2 时,偶尔在模型加载完毕的瞬间报 NCCL error: unhandled system error。这是因为消费级显卡没有 NVLink,走 PCIe 拓扑通信时,P2P 读写访问容易被系统安全策略拦截。

解决方案:在启动命令前强制禁用 P2P 通信,改为走系统共享内存(虽然损失一点微秒级延迟,但能保证服务稳定性):

export NCCL_P2P_DISABLE=1
export NCCL_IB_DISABLE=1

五、实测基准对比数据

我们在双路 RTX 4090(48GB 显存总量)环境下,对 Qwen2.5-14B-Instruct 进行了 50 并发压测(输入平均 1500 tokens,输出 300 tokens):

架构方案 平均 TTFT (首字延迟) TPS (每秒生成 Token 数) 显存 OOM 概率
默认配置(未分块 Prefill) 2840 ms 165 tokens/s 并发冲高时有 12% 几率崩溃
本文调优方案(vLLM + Chunked) 420 ms 380 tokens/s 0%(稳定运行 48 小时)

总结与避坑心得

搭建私有高可用 AI 推理节点,绝不仅仅是把模型权重 load 进显存那么简单。显存分配粒度(PagedAttention)、前缀复用率以及 Prefill/Decode 混合调度,才是决定你的服务是“PPT 玩具”还是“工业级底座”的胜负手。

如果你手头只有单卡或者中小型显存,优先开启 --enable-chunked-prefill 和 --enable-prefix-caching,这几乎是不花钱换来吞吐量翻倍的最优解法。后续如果需要进一步做横向扩容,可以在网关前侧挂载轻量的 Round-Robin 调度器,将压力均匀打散到多台 vLLM 实例节点上。

===END===

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