单卡榨干并发极限:vLLM与SGLang本地推理实战与流式网关压测避坑指南

2次阅读
没有评论

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

很多开发者在搭建本地大模型服务时,起手式通常是 ollama run。对于本地命令行尝鲜或写个单线程 Demo,Ollama 的体验确实丝滑;但只要把并发打到 10 以上,或者把服务挂载到内网团队的 Agent、沉浸式翻译和知识库流水线里,延迟会直接雪崩,首字生成时间(TTFT, Time To First Token)常常瞬间飙升到 10 秒以上,紧接着就是 Broken Pipe 连接中断。

上周我接手了一个内部知识库与代码补全网关的重构任务,硬件环境仅有一台配备单卡 RTX 4090(24GB)的 Ubuntu 边缘服务器。目标是在维持低延迟的前提下,抗住 20~30 个工程师并发调用的流式输出需求。本文记录了将底层推理框架从纯玩具方案迁移至 vLLM 与 SGLang 的实战过程,包含真实的显存压榨配置、流式反向代理踩坑以及压测对比数据。

一、推理引擎架构选型:vLLM vs SGLang

生产环境部署 LLM,首要任务就是丢弃桌面端封装工具,回归工业级推理引擎。目前开源阵营里最值得投入的两个项目是 vLLM(基于 PagedAttention)和 SGLang(基于 RadixAttention)。

  • vLLM:生态极其成熟,兼容 OpenAI API 几乎零成本迁移,Chunked Prefill(分块预填充)特性在长文本输入场景表现稳定,但对于大量重复前缀(如复杂 System Prompt、多轮对话上下文)的复用率相对保守。
  • SGLang:专注于结构化输出与前缀缓存(RadixAttention)。它将 KV Cache 组织成一棵基数树(Radix Tree),在多轮对话、Agent 工具调用和高重合度 System Prompt 场景下,缓存命中率惊人,TTFT 相比 vLLM 甚至能进一步压缩 30%~50%。

鉴于业务中包含了大量包含固定知识库上下文的 Prompt,我选用了 Qwen/Qwen2.5-14B-Instruct-AWQ 进行 4-bit 量化部署,显存占用约 10GB,留下约 12GB 显存专供 KV Cache 吞吐。

二、关键配置与生产级启动命令

部署大模型不是把参数拉满就行,盲目拉大 max-model-len 只会直接触发 CUDA OOM。以下是我在实操中调试出的最佳参数组合。

1. vLLM 启动脚本(注重通用与稳定性)

python3 -m vllm.entrypoints.openai.api_server \
    --model /models/Qwen2.5-14B-Instruct-AWQ \
    --quantization awq_marlin \
    --served-model-name qwen2.5-14b \
    --host 0.0.0.0 \
    --port 8000 \
    --trust-remote-code \
    --max-model-len 8192 \
    --gpu-memory-utilization 0.92 \
    --enable-prefix-caching \
    --enable-chunked-prefill=True \
    --max-num-seqs 64 \
    --swap-space 4

避坑要点:

  • --quantization awq_marlin:如果量化包支持 Marlin 算子,务必开启,推理吞吐直接提升约 20%。
  • --enable-chunked-prefill:极为关键!它能将长 Prompt 切块与 Decode Token 混合调度,避免长 Prompt 抢占显卡算力导致其他流式请求卡顿。
  • --gpu-memory-utilization 0.92:单卡环境下不要设为 0.98,给 PyTorch 激活值预留至少 1~2GB 显存,否则极易发生非受控的爆显存崩溃。

2. SGLang 启动脚本(注重前缀缓存与极致响应)

python3 -m sglang.launch_server \
    --model-path /models/Qwen2.5-14B-Instruct-AWQ \
    --quantization awq \
    --port 8000 \
    --host 0.0.0.0 \
    --context-length 8192 \
    --mem-fraction-static 0.88 \
    --schedule-policy lpm \
    --enable-mixed-chunk

SGLang 的 --schedule-policy lpm(Longest Prefix Match)能最大化命中 KV Cache,非常适合多 Agent 频繁共享 Prompt 的场景。

三、前端流量拦截:配置高并发 SSE 流式代理

在推理服务前面,通常需要架设 Nginx 作为统一网关。刚上线时很多朋友反馈:“请求是打通了,但流式输出(Server-Sent Events, SSE)完全失效,接口总是一下子憋出整篇文字,体验极差。”

这是因为 Nginx 默认开启了反向代理缓冲区(Proxy Buffering),它把模型的逐 Token 流式吐字攒成一大包才吐给前端。以下是经过生产检验的 Nginx 核心配置:

server {
    listen 80;
    server_name llm-internal.nassky.top;

    client_max_body_size 64M;

    location /v1/ {
        proxy_pass http://127.0.0.1:8000/v1/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # 核心设置:关闭流式传输缓冲
        proxy_buffering off;
        proxy_cache off;
        proxy_set_header Connection '';
        proxy_http_version 1.1;
        chunked_transfer_encoding on;

        # 调整超时时间,长文本生成时防止被 Nginx 掐断
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
        proxy_connect_timeout 60s;
    }
}

四、压测实录:并发场景下的性能表现

为了探究真实工况下的瓶颈,我写了一个异步并发压测脚本,模拟 30 个并发客户端,每个请求携带固定 1500 tokens 的上下文,要求生成 200 tokens,持续压测 5 分钟。测试指标包括:TTFT(首字延迟)、ITL(Token 间延迟) 和 总吞吐量(Tokens/s)。

框架与配置方案 并发数 平均 TTFT 平均 ITL 整机总吞吐量
Ollama (Qwen2.5-14B-Q4_K_M) 30 8420 ms 112 ms 48 tok/s (频繁排队)
vLLM (AWQ + Chunked Prefill) 30 680 ms 32 ms 345 tok/s
SGLang (AWQ + RadixAttention) 30 240 ms 29 ms 390 tok/s

实测数据一目了然:在固定上下文复用场景下,SGLang 的基数树缓存发挥了统治级优势,TTFT 降到了 240ms。这意味着终端用户几乎在按下回车的一瞬间,屏幕就已经开始打印输出,完全消除了等待焦虑。

五、必须小心的血泪排障记录

1. 客户端中途断开导致的显存空转(Abort 处理)

在流式传输中,用户经常会手动点击“停止生成”或直接关掉网页标签。如果你的反向代理或网关没有传递 Abort 信号,后端推理引擎会固执地把剩下的 Token 生成完毕,白白浪费单卡宝贵的算力。

如果使用 Python 编写中间层转发网关,切记要捕获 RequestDisconnected 异常,显式通知下游关闭 SSE 通道,避免模型盲目迭代:

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

app = FastAPI()

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

    async def event_generator():
        try:
            async with client.stream("POST", "http://127.0.0.1:8000/v1/chat/completions", json=body) as response:
                async for chunk in response.aiter_bytes():
                    # 检查客户端是否已经断开
                    if await request.is_disconnected():
                        # 中断请求流,通知底层的 vLLM 终止此 Request ID
                        break
                    yield chunk
        finally:
            await client.aclose()

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

2. 动态分配导致的显存碎片灾难

在持续跑了 48 小时后,我曾观察到 vLLM 突然抛出 CUDA out of memory,排查后发现,当并发请求的长短差异极大时(有的输入 50 tokens,有的输入 6000 tokens),频繁的内存分配可能导致 CUDA 显存碎片化。解决方式是在系统层面调教内存分配器:

# 在启动脚本前配置环境变量
export PYTORCH_CUDA_ALLOC_CONF="expandable_segments:True"

这行配置允许 PyTorch 分配器在遇到显存压力时按需扩展段,极大地减少了因连续内存不足而导致的误报性 OOM。

总结与选型决策树

单张消费级显卡(如 4090)不是不能扛生产负载,关键在于摆脱桌面思维,合理配置显存与推理算子。对于个人极客或小团队,给出的落地建议如下:

  1. 如果业务侧重于代码补全、Agent 多步调用、长 System Prompt 知识库 :毫不犹豫选择 SGLang,利用基数树缓存将首字延迟压缩至极限。
  2. 如果追求生态兼容性、支持多模态(
正文完
 0
评论(没有评论)