共计 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% (主动熔断无效负载) |
从数据可以明显看出:
- 开启 Chunked Prefill 略微牺牲了一丁点最大吞吐上限(从 286 tps 降到 274 tps),但将平均首字延迟从 1.3 秒直接压到了 400ms 左右,彻底消除了用户打字机卡死的感知。
- PagedAttention 的内存虚拟化机制彻底终结了 PyTorch 原生分配器在长文本高并发下的显存碎片问题,整机显存利用率稳定压在 92% 红线以内,连续高压跑满 24 小时无一例 OOM。
总结与避坑心得
在双卡 4090 或类似消费级卡上部署 32B 规模的开源模型,放弃单卡强推、选择 AWQ + TP2 是保证并发吞吐的最优解。踩坑过程中记住三点:第一,没有硬件 NVLink 就千万不要强上 70B 模型的多卡张量并行,PCIe 瓶颈会让你痛不欲生;第二,长短文本混杂的业务场景,--enable-chunked-prefill 是保证交互体验的必选参数;第三,生产环境切忌让客户端直接裸连底层推理引擎,加一层具备断连检测的流式网关,能帮你挽回至少 30% 浪费在僵尸请求上的算力。