榨干双卡RTX 4090:vLLM部署Qwen-2.5-32B与长文本流式网关架构实战

3次阅读
没有评论

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

上个月帮团队内网搭建代码辅助与私有知识库集群,最开始为了图省事直接拉了个 Ollama。单人本地调试体感尚可,但只要群里三四个人同时发起代码补全或长文档总结,接口的 TTFT(首字延迟,Time to First Token)瞬间从 800ms 飙到 15 秒以上,显存更是频繁因动态上下文碎片直接 OOM 崩溃。对于需要承载团队并发、长上下文调用的场景,玩具级的推理后端根本撑不住生产负载。

我们最终将架构重构为:底座基于双卡 RTX 4090 运行 vLLM(Tensor Parallelism 并行切分),前端自研一套基于 FastAPI 的 防雪崩异步流式代理网关。本文把整套落地过程中的显存压榨技巧、参数调优对比和避坑脚本完整记录下来。

一、硬件拓扑与环境基线踩坑

实测平台为单机双卡 RTX 4090(24GB × 2),CPU 为 AMD EPYC 7302,主板 PCIe 通道拆分为 Gen4 x8 + x8。这里必须明确:消费级 4090 缺乏硬件 NVLink 支持,跨卡通信完全依赖 PCIe 总线走 NCCL。如果配置不当,卡间张量并行交换权重时会严重堵塞总线,推理速度甚至不如单卡量化版。

系统安装 Ubuntu 22.04 LTS,CUDA Toolkit 锁定在 12.4。双卡环境部署前,必须在系统级规避 P2P 寻址的经典深坑:

# 检查 PCIe 链路与 P2P 状态
nvidia-smi topo -m

# 关键排错:4090 在大部分主板上无法开启硬件 P2P 读写
# 若 NCCL 报错 "Peer access not supported between GPUs",必须在环境变量中注入:export NCCL_P2P_DISABLE=1
export NCCL_IB_DISABLE=1
# 开启异步显存分配以减少显存碎片
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True

二、vLLM 生产级部署与参数极限压榨

模型选型采用 Qwen/Qwen2.5-32B-Instruct-AWQ。32B 是当前平衡代码逻辑、上下文理解与运行成本的黄金尺寸,AWQ 4-bit 量化后权重占用约 18.5GB 显存,双卡切分后单卡仅吃 9.25GB,剩余 14.7GB 显存可完全留给 PagedAttention 作为 KV Cache 动态池,轻松支持 32K 乃至 64K 上下文并发。

1. 启动命令与关键参数解析

python3 -m vllm.entrypoints.openai.api_server \
    --model /data/models/Qwen2.5-32B-Instruct-AWQ \
    --quantization awq \
    --tensor-parallel-size 2 \
    --gpu-memory-utilization 0.92 \
    --max-model-len 32768 \
    --max-num-seqs 64 \
    --max-num-batched-tokens 8192 \
    --enable-chunked-prefill true \
    --trust-remote-code \
    --host 0.0.0.0 \
    --port 8000

2. 核心参数调优逻辑

  • --tensor-parallel-size 2:将模型权重沿注意力头横向切分到两张卡。在没有 NVLink 的情况下,32B 模型是 PCIe 4.0 x8 的承受极限,70B 强行 TP2 会导致卡间通信时间直接吃掉推理加速收益。
  • --enable-chunked-prefill true:极其关键。默认配置下,如果一个用户扔进来一篇 20K token 的论文做总结,vLLM 会暂停所有正在生成 token 的并发请求优先处理 Prefill,导致其他客户端遭遇长达数秒的“卡顿假死”。开启分块预填充后,大 prompt 会被切碎为小 chunk 与 Decode 阶段交织计算,将整体 TTFT 抖动降低 70% 以上。
  • --gpu-memory-utilization 0.92:预留 8% 物理显存给 PyTorch 运行时上下文与 CUDA Graph 捕获,避免推理峰值时直接报 CUDA out of memory 击垮进程。

三、自研高性能流式代理:解决显存悬挂与背压问题

直接将 vLLM 的 8000 端口暴露给业务侧存在严重的工程隐患:当客户端(如 Web 页面或终端 Copilot)中断请求或刷新浏览器时,vLLM 原生并不能总是敏锐捕捉到 TCP 断开信号,底座模型仍会在后台完整执行推理解析,白白霸占宝贵的显存和算力达数秒甚至几十秒。

我们用 FastAPI 搭配 httpx.AsyncClient 编写了一套轻量流式反向代理。网关不仅拦截鉴权,更核心的机制是 实时检测客户端断连信号并主动向下游 vLLM 发送终止信号,顺带处理客户端背压(Backpressure)。

# proxy_gateway.py
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"

# 维持连接池复用,禁用下游无谓的重试
client = httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=60.0, write=5.0, pool=10.0),
    limits=httpx.Limits(max_keepalive_connections=50, max_connections=200)
)

@app.post("/v1/chat/completions")
async def proxy_chat(request: Request):
    headers = dict(request.headers)
    headers.pop("host", None)
    headers.pop("content-length", None)
    
    body = await request.body()

    async def stream_generator():
        try:
            async with client.stream(
                "POST", 
                VLLM_BACKEND_URL, 
                headers=headers, 
                content=body
            ) as backend_res:
                
                if backend_res.status_code != 200:
                    yield await backend_res.aread()
                    return

                # 逐块转发下游 SSE 流
                async for chunk in backend_res.aiter_bytes():
                    # 侦测客户端是否已经挂断
                    if await request.is_disconnected():
                        # 主动跳出循环,触发 context manager 退出,强行掐断下游 socket
                        break
                    yield chunk
        except (httpx.RequestError, GeneratorExit):
            # 捕获异常流中断,释放连接
            pass

    return StreamingResponse(stream_generator(), 
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
    )

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8080, log_level="warning")

代码中 X-Accel-Buffering: "no" 极为必要,如果代理前置架设了 Nginx,该响应头可防止 Nginx 自动对流式输出执行 4KB 缓冲,确保打字机效果毫秒级触达前端。

四、实测吞吐与压测数据对比

我们使用 vllm/benchmarks/benchmark_serving.py 对重构前后的链路进行真实压测。测试集采用 ShareGPT 数据集,模拟并发请求数 16,输入平均 1024 tokens,输出平均 256 tokens:

方案 首字延迟 (TTFT Mean) 单字输出延迟 (ITL Mean) 总吞吐量 (Tokens/s) 长上下文显存崩溃率
Ollama (Qwen-2.5-32B-Q4) 4820 ms 58 ms 42.1 tps 18% (动态分配显存碎片)
vLLM (无 Chunked Prefill) 1320 ms 19 ms 286.4 tps 0% (PagedAttention)
vLLM + Chunked Prefill + 代理网关 410 ms 21 ms 274.8 tps 0% (主动熔断无效负载)

从数据可以明显看出:

  1. 开启 Chunked Prefill 略微牺牲了一丁点最大吞吐上限(从 286 tps 降到 274 tps),但将平均首字延迟从 1.3 秒直接压到了 400ms 左右,彻底消除了用户打字机卡死的感知。
  2. PagedAttention 的内存虚拟化机制彻底终结了 PyTorch 原生分配器在长文本高并发下的显存碎片问题,整机显存利用率稳定压在 92% 红线以内,连续高压跑满 24 小时无一例 OOM。

总结与避坑心得

在双卡 4090 或类似消费级卡上部署 32B 规模的开源模型,放弃单卡强推、选择 AWQ + TP2 是保证并发吞吐的最优解。踩坑过程中记住三点:第一,没有硬件 NVLink 就千万不要强上 70B 模型的多卡张量并行,PCIe 瓶颈会让你痛不欲生;第二,长短文本混杂的业务场景,--enable-chunked-prefill 是保证交互体验的必选参数;第三,生产环境切忌让客户端直接裸连底层推理引擎,加一层具备断连检测的流式网关,能帮你挽回至少 30% 浪费在僵尸请求上的算力。

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