生产级高并发 LLM 推理网关实战:vLLM 与 SGLang 压测选型与长上下文显存避坑

2次阅读
没有评论

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

上个月我把团队内部的知识库 Agent 推理集群从 vLLM 全量切到了 SGLang。起因很简单:业务层接入了上百个并发的长上下文 RAG 会话,原本运行平稳的 8×A100 节点开始频繁出现首字延迟(TTFT)飙升到 15 秒以上,甚至有几张卡直接报 CUDA OOM 挂掉。

很多开发者在搭建本地或内网模型服务时,习惯直接无脑拉起 vLLM 的 Docker 镜像,调一下 --gpu-memory-utilization 0.95 就觉得万事大吉。但在真实生产场景下,只要遇到长 System Prompt、工具调用循环或多轮对话,显存碎片的回收效率与前缀缓存机制会直接决定你的服务是健步如飞还是原地宕机。这篇文章把我过去半个月在架构重构与调优过程中踩过的坑完整盘一遍。

一、机制差异:PagedAttention 遇上多轮对话的死穴

vLLM 核心的 PagedAttention 解决的是显存不连续分配导致的内部碎片问题,把 KV Cache 切成定长的 Block 放在分页表中映射,这在单轮问答、短上下文场景表现极其优异。

但当业务变成 多轮对话与复杂 Agent 时,情况完全变了:

  • Prompt 重合度极高:每次用户提问,前面往往粘带着 8k~16k 的检索文档、格式化 JSON Schema 和系统指令。
  • vLLM 的 Prefix Caching:虽然 vLLM 后来支持了 --enable-prefix-caching,但它的树形匹配和淘汰机制较为粗暴,当请求并发冲高、长短文本混杂时,缓存命中率很容易发生抖动,导致大量的重复 Prefill。
  • SGLang 的 RadixAttention:SGLang 直接在运行时维护了一颗前缀基数树(Radix Tree)。不仅可以在多轮对话间复用 KV Cache,还能跨不同的并发请求共享相同的上下文。即便内存不够触发 Eviction,它的 LRU 策略也是基于整棵树的节点来进行动态释放,显存回收颗粒度要细腻得多。

二、8×A100 实机压测:数据见真章

测试硬件环境:单节点 8 张 NVIDIA A100-SXM4-80GB,NVLink 互联,模型使用 Qwen2.5-72B-Instruct(16-bit 权重,Tensor Parallel = 4)。

测试工具采用定制的异步并发压力脚本,模拟 128 并发请求,输入 Prompt 固定包含 6,000 Tokens 的上下文,输出 512 Tokens,分别测试两者的首字延迟(TTFT)和端到端 Token 吞吐率(Tokens/s)。

# vLLM 0.6.3 启动命令
vllm serve Qwen/Qwen2.5-72B-Instruct \
  --tensor-parallel-size 4 \
  --gpu-memory-utilization 0.92 \
  --max-model-len 16384 \
  --enable-prefix-caching \
  --disable-log-requests \
  --port 8000

# SGLang 0.4.1 启动命令
python3 -m sglang.launch_server \
  --model-path Qwen/Qwen2.5-72B-Instruct \
  --tp 4 \
  --mem-fraction-static 0.88 \
  --context-length 16384 \
  --schedule-policy lpm \
  --port 8000

压测核心数据对比

指标维度 (128 并发) vLLM (0.6.3) SGLang (0.4.1) 实测结论与差距
平均 TTFT (首字延迟) 6.42 秒 1.83 秒 SGLang 的 Radix 树缓存命中率超 85%,Prefill 耗时大幅压减
吞吐量 (Total Tokens/s) 1,420 t/s 2,180 t/s SGLang 高并发调度吞吐提升接近 53%
峰值显存占用稳定性 偶发抖动 OOM 极其平稳 SGLang 静态显存预留 + Chunked Prefill 表现更抗压

在面对大量共享长 Prefix 的请求时,SGLang 的优势几乎是碾压性的。vLLM 在面对高并发瞬间涌入时,容易因为 Prefill 阶段把计算单元占满,导致其他并发请求的 Decode 停滞,造成显著的卡顿感。

三、生产级高并发流式反向代理架构避坑

模型服务跑起来只是第一步,真实系统里通常会在前面架一层 FastAPI 或 Nginx 作为 API 网关,负责鉴权、流式中转(SSE)和断流保活。在这个环节,我遇到了两个恶心的问题。

1. 幽灵推理与显存泄漏:必须捕获客户端断开连接

Web 前端用户在生成长文本时,经常中途关闭标签页或取消生成。如果你的反向代理只是傻傻地把 upstream 的 SSE 流转发,而没有监听客户端的连接状态,vLLM 和 SGLang 就会继续在后台把剩下的 2000 个 Token 算完才释放显存。这在并发场景下会白白吃掉巨额算力。

在 FastAPI 网关层,必须借助 asyncio 监听 HTTP 断开事件并主动向后端取消任务:

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

app = FastAPI()

@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
    payload = await request.json()
    client = httpx.AsyncClient(timeout=120.0)

    async def stream_generator():
        try:
            async with client.stream(
                "POST", 
                "http://127.0.0.1:8000/v1/chat/completions", 
                json=payload
            ) as response:
                async for chunk in response.aiter_raw():
                    # 关键:检查客户端是否已经主动掐断了 TCP 连接
                    if await request.is_disconnected():
                        # 中断生成器,触发 context manager 退出,通知后端推理引擎
                        break
                    yield chunk
        finally:
            await client.aclose()

    return StreamingResponse(stream_generator(), media_type="text/event-stream")

2. Nginx 缓冲区背刺:为什么我的流式变成打字机“憋大招”?

在生产环境套 Nginx 反向代理时,最常遇到的诡异现象是:后台日志已经在快速输出 Token,但前端一直空白,直到 10 秒后一口气喷出来一整段文字。这完全违背了流式交互的初衷。

原因出在 Nginx 默认开启了 proxy_buffering,它会试图凑满一个 chunk(通常是 4k 或 8k)再发送给客户端。在 Nginx 的 location 块中必须强制关闭缓冲:

location /v1/ {
    proxy_pass http://llm_backend;
    
    # 关键设置:关闭代理缓冲,确保 SSE 流实时推送到客户端
    proxy_buffering off;
    proxy_cache off;
    
    # 保持长连接
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    
    # 防止大模型长思考时读超时被断掉
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
    
    # 传递真实客户端 Host 和 IP
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

四、参数调优避坑军规

为了保证多实例在 Kubernetes 或 Docker Compose 环境下稳定驻留,以下几条启动参数建议直接记死:

  1. Chunked Prefill 必开:如果不开启分块预填充,当一个 16k 上下文的请求进来时,GPU 会全力计算这一个大任务,其他几十个并发连接的 Decode 就会全部暂停(Jitter 飙升)。SGLang 默认处理较好,vLLM 务必显式开启 --enable-chunked-prefill。
  2. 显存预留不要贪满:很多人为了塞下更长的上下文,把 --gpu-memory-utilization 设到 0.98。这极度危险!CUDA Context、PyTorch 临时激活值(Activation Memory)以及某些特殊 Kernel 的 Workspace 稍有波动就会引发宿主机级别的显存崩溃。给生产系统留出至少 8%~10% 的缓冲余量(设为 0.90~0.92)是稳定运行的底线。
  3. TP(Tensor Parallel)切分要对齐物理拓扑:不要跨 NUMA 节点乱拉 TP。如果单机只有 4 张卡走 NVLink,剩下的走 PCIe,宁可起两个 TP=4 的独立服务实例在前面做负载均衡,也绝对不要硬拉一个跨 PCIe 的 TP=8,否则带宽瓶颈会直接把吞吐腰斩。

总结与避坑心得

没有一套参数能通吃所有大模型推理场景。如果你的业务纯粹是单轮问答、短文本翻译,vLLM 的社区支持和多硬件平台适配度依然是稳健之选;但如果你的核心业务重度依赖长上下文知识库检索、多轮 Agent 交互以及高并发流式接口,SGLang 凭借 RadixAttention 的显存复用能力能够实打实地帮你节省近一半的算力成本。

部署大模型服务绝不仅仅是拉起一个开箱即用的推理容器,从底层的显存分配策略,到网络层流式断流控制,再到反代网关的缓冲穿透,整个链路必须做到闭环观测,才能在用户量激增时不至于手忙脚乱地到处擦屁股。

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