单卡跑满千级并发:vLLM + Ray 生产级推理网关架构与踩坑调优实战

4次阅读
没有评论

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

很多朋友在本地服务器或公司自建机房折腾开源大语言模型(LLM)时,第一步往往是拉起一个 Ollama 或者直接跑 HuggingFace 的 Transformers 原生接口。用来自己单人玩玩确实省事,但只要接入自动化 Agent 工作流,或者前端稍微挂上十几二十个人同时发请求,服务延迟就会瞬间拉爆,甚至直接抛出 CUDA out of memory。

上周我在为小团队搭建一套统一的内部 LLM 推理网关,目标是用单张 RTX 4090(24GB)或者单张 A100(80GB)稳定支撑业务层千级高并发调用,同时做到端到端流式响应(SSE)延迟尽可能低。在对比了 TGI、llama.cpp 和 vLLM 后,最终基于 vLLM + 异步 FastAPI 代理层 搞定了一套低延迟、高吞吐的架构。今天把这套方案的底层原理、配置脚本以及我踩过的三个深坑毫无保留地整理出来。

一、为什么原生部署撑不住并发?

在动手改配置前,必须弄明白高并发下 GPU 到底卡在哪里。传统的推理框架有两大硬伤:

  1. KV Cache 静态分配导致显存碎片化:原生 PyTorch 机制需要提前为请求的最大输出长度分配连续显存。如果设了 max_tokens=4096,哪怕用户只输出了 20 个字,显存依然被霸占,导致批处理(Batching)根本塞不下多少并发请求。
  2. Naive 批处理与动态序列长度冲突:常规的批处理需要 Padding 到最长序列,无效计算极其严重。而 vLLM 的核心杀手锏就是 PagedAttention,它借鉴了操作系统的虚拟内存分页思想,将显存切成离散的 Block,彻底消除了显存内部碎片。

二、生产级 vLLM 启动参数实战微调

很多人直接运行 vllm serve Qwen/Qwen2.5-7B-Instruct 就完事了,这完全浪费了硬件性能。以下是我压测数天后总结出的黄金启动脚本,以 Qwen2.5-7B 运行在单张 24GB 显存设备为例:

#!/usr/bin/env bash
export CUDA_VISIBLE_DEVICES=0
export NCCL_IGNORE_DISABLED_P2P=1

# 启动高吞吐、低碎片化的 vLLM 实例
python3 -m vllm.entrypoints.openai.api_server \
    --model /data/models/Qwen2.5-7B-Instruct \
    --served-model-name qwen-7b-chat \
    --host 0.0.0.0 \
    --port 8000 \
    --gpu-memory-utilization 0.92 \
    --max-model-len 8192 \
    --block-size 16 \
    --swap-space 4 \
    --max-num-batched-tokens 8192 \
    --max-num-seqs 256 \
    --enable-prefix-caching \
    --disable-log-requests

核心参数避坑解析:

  • --gpu-memory-utilization 0.92:默认是 0.90。如果显卡只跑模型,可以拉到 0.92。千万别拉到 0.98 以上,因为 CUDA 运行时分配上下文、动态激活值需要极小余量,设得太满在长请求峰值时必崩 CUDA 核心。
  • --block-size 16:PagedAttention 的分页粒度。在长文本推理中,设为 16 或 32 是吞吐最优解;小显卡建议 16,大显卡(如 A100)可以设为 32 提升内存传输效率。
  • --enable-prefix-caching:强烈建议开启! 如果你在做 Agent、多轮对话或者 RAG 检索,系统 Prompt(System Prompt)和历史检索块基本相同。开启后,命中相同前缀的 KV Cache 直接重用,TTFT(首字延迟)直接从几百毫秒骤降到几毫秒。
  • --disable-log-requests:高并发场景下,每一条请求的文本如果都打进 stdout,Python 解释器的控制台 I/O 会成为隐性瓶颈,甚至锁住事件循环。

三、构建高并发异步代理流转网关

直接把 vLLM 的 8000 端口暴漏给业务端并不明智,缺乏鉴权、动态限流、超时重试以及上游连接池复用。我们在业务前端套了一层轻量化的异步代理服务,采用 FastAPI + HTTPX 连接池,避免流式响应时客户端意外断开导致 GPU 空转算力。

# gateway.py
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse

app = FastAPI()

# 维持持久连接池,避免每次请求频繁进行 TCP/TLS 握手
client = httpx.AsyncClient(
    base_url="http://127.0.0.1:8000",
    timeout=httpx.Timeout(connect=5.0, read=60.0, write=5.0, pool=60.0),
    limits=httpx.Limits(max_keepalive_connections=500, max_connections=1000)
)

@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
    payload = await request.json()
    
    # 强制加上针对后端的流式标识处理
    is_stream = payload.get("stream", False)
    
    headers = {
        "Content-Type": "application/json",
        "Authorization": request.headers.get("Authorization", "")
    }

    if is_stream:
        async def stream_generator():
            try:
                async with client.stream("POST", "/v1/chat/completions", json=payload, headers=headers) as upstream_resp:
                    if upstream_resp.status_code != 200:
                        yield f"data: {{\"error\": \"Upstream status {upstream_resp.status_code}\"}}\n\n"
                        return
                    
                    async for chunk in upstream_resp.aiter_raw():
                        # 业务层可在此处做敏感词拦截、Token 计数等
                        yield chunk
            except httpx.RequestError as exc:
                yield f"data: {{\"error\": \"Proxy error: {str(exc)}\"}}\n\n"

        return StreamingResponse(stream_generator(), 
            media_type="text/event-stream"
        )
    else:
        resp = await client.post("/v1/chat/completions", json=payload, headers=headers)
        return resp.json()

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

四、压测实测对比数据与避坑经验

我使用 Locust 对原生 HuggingFace 部署(FastAPI + ThreadPoolExecutor)与本次优化的 vLLM 架构进行了单卡 RTX 4090 上的并发压力对比。测试模型均为 Qwen2.5-7B-Instruct,输入 Prompt 约 500 tokens,要求模型生成约 150 tokens:

部署方案 并发连接数 (Concurrency) 首字延迟 TTFT (p95) 整体吞吐量 (Tokens/s) 显存崩溃率
HF Transformers + Pipeline 16 2,450 ms 42.8 并发达 24 时 OOM
vLLM(默认参数无调优) 64 420 ms 430.5 0%
vLLM + Prefix Cache + 调优网关 128 68 ms 1,120.4 0%

踩坑实录:三个生产中极易致命的配置点

  1. Linux 系统 File Descriptor 耗尽:高并发下如果不改 /etc/security/limits.conf,很容易报 Too many open files。在启动 vLLM 和代理前,务必执行 ulimit -n 65535,并在 systemd 服务文件中明确写入 LimitNOFILE=65535。
  2. 网络中断未注销导致的“显卡僵尸算力”:HTTP 客户端(如 Web 页面刷新的用户)断开后,如果代理网关没有中断与上游 vLLM 的连接,vLLM 默认会把这 1000 个 Token 默默生成完。FastAPI 中必须使用原生异步流,只要下游连接中断,立即触发 upstream_resp.aclose()。
  3. 长上下文突发引发 swap 抖动 :如果你将 --swap-space 设得太大(如默认 4GB 甚至 16GB),当显存撑不住时,vLLM 会把 KV Cache 换入主机的 CPU RAM。一旦发生频繁 Swap,吞吐会断崖式暴跌 90% 以上。 宁愿在网关层做 429 降级或排队,也尽量不要让显存频繁依赖 CPU Swap 救场。

五、选型与落地方案建议

对于个人开发者、独立极客或是微型团队,不要盲目去上一套厚重的 Kubernetes + KServe 体系。单纯靠 vLLM 的内置引擎 + 一层轻量异步代理,在一张消费级显卡(如 RTX 4090 / 3090)上跑 7B~14B 的 Q4 或 AWQ 量化版本,就已经能轻松应付内部几十个 Agent 和自动化爬虫流水线的实时调用。

如果未来要扩容多张显卡,只需要在 vLLM 参数中加入 --tensor-parallel-size 2(TP)或者利用 Ray 跑多实例分布式调度。先用这套精简的参数跑通性能基线,才是把算力压榨到极限的最佳姿势。

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