单机单卡跑满千级并发:基于 vLLM + Ray 的轻量生产级大模型推理网关实战

4次阅读
没有评论

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

很多同学在搭建自己的本地 LLM 接入服务或者小团队 AI Agent 基础设施时,往往直接拉起一个 Ollama 跑脚本。玩具阶段确实香,但一旦挂上几个自动化工作流或者稍微上点并发,首字延迟(TTFT)立马飙到数秒,显存稍有波动就直接 OOM 崩溃退出。如果硬着头皮上 Triton 或上云端托管集群,维护成本又直接劝退个人开发者与小团队。

上个月我把工作室的一台单卡 RTX 4090(24G)机器改造成内部高并发推理节点,给 20+ 个并发 Agent 提供纯流式接口。经过几轮压测和排障,最终敲定了一套基于 vLLM + 异步 FastAPI 流式反代 + 动态并发熔断 的方案。今天把整套落地踩坑实操、生产级配置和核心排障记录整理出来,拿走就能直接抄作业。

一、为什么 Ollama 不行,而 vLLM 是单卡吞吐天花板?

在讨论调优前,先搞清大模型单卡推理的两个物理限制: 显存带宽受限(Memory Bound) 和 KV Cache 碎片化 。

常规方案(如早期 HuggingFace Transformers / llama.cpp 的默认并发模式)分配显存是连续预分配的。只要并发一上来,KV Cache 很快就会因为大量不连续的碎片化内存导致虽然显存看着还有剩,但新请求根本塞不进去。此外,静态 Batching 机制必须等待同一个批次里最长的那个句子生成完才释放资源,短请求全被拖死。

vLLM 解决这个问题的杀手锏非常简单粗暴:

  • PagedAttention:借鉴操作系统的虚拟内存分页机制,将 KV Cache 切成离散的小 Page,显存利用率拉到接近 96% 以上,几乎零碎片。
  • Continuous Batching(连续批处理):每产生一个 Token 就动态调整 Batch 队列,执行完的请求立即离场,新请求无需等待全局对齐无缝插队。

二、生产级 Docker 编排与 vLLM 核心参数避坑

很多教程直接 python -m vllm.entrypoints.openai.api_server 就开跑,结果在实际生产跑几天后发现:要么显存刚启动就爆了,要么长上下文稍微多来几个就触发 CUDA OOM。下面是我反复微调后的核心启动参数配置。

以部署 Qwen2.5-7B-Instruct(4-bit GPTQ 或 AWQ 量化版,或 BF16 原生版)为例,编写 docker-compose.yml:

version: '3.8'

services:
  vllm-engine:
    image: vllm/vllm-openai:latest
    container_name: vllm-engine
    runtime: nvidia
    environment:
      - CUDA_VISIBLE_DEVICES=0
      - NCCL_DEBUG=INFO
    volumes:
      - /data/huggingface/hub:/root/.cache/huggingface
    ports:
      - "8000:8000"
    ipc: host
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    command: >
      --model Qwen/Qwen2.5-7B-Instruct-AWQ
      --quantization awq
      --served-model-name qwen2.5-chat
      --gpu-memory-utilization 0.90
      --max-model-len 8192
      --max-num-seqs 256
      --enable-prefix-caching
      --trust-remote-code
    restart: unless-stopped

几个致命参数的实操心得:

  1. --gpu-memory-utilization 0.90:千万不要设成 0.98 或者 1.0!vLLM 会根据这个比例预先瓜分绝大多数显存给 KV Cache。如果你留给 CUDA 运行时的动态缓冲显存不足,后续一旦调用复杂的 Logits 算子或者长文本输入,立刻会扔出 torch.cuda.OutOfMemoryError。给系统保留 10% 显存是底线。
  2. --enable-prefix-caching(前缀缓存):做 Agent 或 RAG 系统的必开选项!多个请求常常带有完全相同的 System Prompt 或知识库 Prompt。开启后这部分相同的 Token 不需要重复计算 KV Cache,首字延迟直接暴降 60%~80%。
  3. --max-num-seqs 256:单批次最大并发序列数。如果显存小,别设太大,避免单次迭代中计算量过饱和导致流式输出卡顿(Jitter)。
  4. ipc: host:必须加。PyTorch 多进程通信依赖共享内存,Docker 默认 64MB 共享内存会导致多卡或并发拉满时瞬间崩溃。

三、构建高并发异步流式网关(FastAPI + SSE)

虽然 vLLM 自带了 OpenAI 兼容接口,但直接将它暴露给外网或业务集群是极其危险的。vLLM 内部的调度器没有鉴权、没有动态限流,一旦遭遇恶意突发流量,连接池耗尽会导致正常业务也 hang 住。

我们需要在前面挡一层轻量级的 Python 异步网关,专门处理连接保活、背压(Backpressure)控制与 SSE 流式转发:

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

app = FastAPI(title="LLM Edge Gateway")

VLLM_INTERNAL_URL = "http://127.0.0.1:8000/v1/chat/completions"
# 针对单卡设置并发信号量,避免过载堆积
MAX_CONCURRENT_REQUESTS = 64
semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)

# 复用全局异步客户端连接池
client = httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=120.0, write=5.0, pool=10.0),
    limits=httpx.Limits(max_keepalive_connections=100, max_connections=200)
)

@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
    if semaphore.locked():
        # 超过负荷立即快速失败,避免把显卡直接冲垮
        raise HTTPException(status_code=429, detail="Engine Busy, Retry Later")

    body = await request.json()
    
    # 强制开启流式以降低网关内存持有
    body["stream"] = True

    async def stream_generator():
        async with semaphore:
            async with client.stream("POST", VLLM_INTERNAL_URL, json=body) as response:
                if response.status_code != 200:
                    yield f"data: {{\"error\": \"Upstream returned {response.status_code}\"}}\n\n"
                    return
                async for chunk in response.aiter_bytes():
                    yield chunk

    return StreamingResponse(stream_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no" # 防止 Nginx 缓存流式数据
        }
    )

if __name__ == "__main__":
    import uvicorn
    # 生产启动推荐用 4-8 个 worker
    uvicorn.run("gateway:app", host="0.0.0.0", port=8080, workers=4)

在这层网关中,有三个核心工程细节:

  • X-Accel-Buffering: no:如果最外层挂了 Nginx,必须显式下发这个响应头,否则 Nginx 会默认缓冲 4KB 数据才吐给前端,流式打字机效果直接变成卡顿“块状吐出”。
  • 全局单例 httpx.AsyncClient:切忌在每个请求里 async with httpx.AsyncClient(),高并发下会导致严重的 TIME_WAIT 套接字耗尽。
  • 信号量防御 :利用 asyncio.Semaphore 设定动态硬阈值,宁可返回 429 也坚决不让后端排队队列撑爆显存。

四、实测压测与吞吐对比

硬件环境:AMD 7950X / 64G DDR5 / 单卡 RTX 4090 24G。
测试工具:自编写异步 Python Locust 脚本,模拟多并发下的短 Prompt 输入(约 150 Tokens)和中长文本输出(约 300 Tokens)。测试模型为 Qwen2.5-7B-Instruct-AWQ。

部署方案 并发并发数 (CC) 平均首字延迟 (TTFT) 生成吞吐量 (Tokens/s) 显存利用率
Ollama 默认配置 16 1480 ms 112 tok/s ~68%(易发生排队)
Ollama 默认配置 32 超时 / 报错 — OOM Crash
vLLM + PagedAttn 32 185 ms 1050 tok/s 89.5%(平稳)
vLLM + 前缀缓存优化 64 92 ms 1680 tok/s 90.0%(满载无丢包)

实测数据非常明显:在 64 并发的高压下,vLLM 配合前缀缓存(Prefix Caching)跑出了 1680 Tokens/s 的总吞吐量,平均单个 Token 的生成时延被均匀摊平,首字响应几乎在 100ms 左右完成,这对于 Agent 交互和实时聊天体验是质的飞跃。

总结与避坑心得

在大模型端侧落地这个领域,从“跑通 Demo”到“支撑并发”中间隔了一条巨大的工程鸿沟。总结三个关键结论:

  1. 量化选型认准 AWQ / GPTQ:在有 Tensor Core 加速的消费级显卡(如 3090/4090)上,AWQ 格式相比 GGUF 在 GPU 显存内更能榨干并发计算性能,显存节省近半但精度几乎不掉。
  2. 长上下文是显存刺客 :单张 24G 卡部署 7B 模型,一定要限制 --max-model-len(通常 8k~16k 足够)。如果允许 32k 且同时进来 10 个请求,再强的 PagedAttention 也救不了显存。
  3. 一定要加前置网关防线 :绝不要拿推理引擎直面公网或直接丢给高频调用方。几行简单的异步并发限制与缓冲区配置,是让节点 7×24 小时稳定不崩的核心底气。

===END===

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