单卡4090跑满吞吐:vLLM多并发流式代理架构与KV Cache避坑实战

6次阅读
没有评论

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

很多朋友在本地服务器或 NAS 配了一张 24G 显存的 RTX 4090,跑 Ollama 单人聊天确实很顺畅。但只要接入沉浸式翻译、IDE 代码补全或者开放给团队几个人同时跑 Agent 工作流,接口就会迅速卡死:首字延迟(TTFT)从 200ms 暴增至十几秒,并发请求直接排队超时,甚至在并发突增时直接抛出显存分配失败错误。单任务推理与高并发服务的工程架构,本质上完全是两码事。

一、为什么本地多并发必须抛弃原生 Transformers?

用原生的 HuggingFace Transformers 配合 FastAPI 跑并发推理,瓶颈从来不在显卡的计算核心(Tensor Core),而是在 内存带宽 与KV Cache 显存碎片。大模型自回归生成的机制决定了每个 Token 都要读写前面所有上下文生成的 Key 和 Value 缓存。

  • 内存浪费与连续空间分配:原生框架必须预先为请求分配连续的显存空间(按最大上下文长度分配,例如 4096 或 8192)。哪怕用户只输入了 10 个字,大量显存也只能空置挂着,单卡跑 3 个并发就直接 OOM。
  • 首字计算与生成计算争抢:新请求进入时的预填充(Prefill,计算密集型)会无情阻断正在生成(Decode,显存带宽密集型)的流式输出,导致已经连接的客户端出现严重的卡顿与断流。

在生产级架构中,解决这个问题的杀手锏是vLLM。它引入了操作系统的分页内存机制(PagedAttention),将 KV Cache 切分成不连续的物理块,显存利用率直接从不足 40% 提升到 90% 以上。

二、架构设计:vLLM + FastAPI 异步网关 + Nginx SSE 流式透传

直接将 vLLM 的 8000 端口暴露给业务层不是明智之举。生产环境中我们需要一个三层架构:前置 Nginx 负责 SSL 终止、长连接复用与缓冲禁用,中间层 FastAPI 做鉴权、Token 用量统计与动态排队,底层 vLLM 负责纯粹的高性能推理计算。

1. 启动 vLLM 后端引擎

以 Qwen2.5-14B-Instruct-AWQ 量化模型为例,单卡 24G 显卡完全可以吃下 14B 的 4bit 模型,并预留足够的显存作为 KV Cache 池:

# 启动 vLLM 推理引擎,限制显存占用并启用分块预填充
vllm serve Qwen/Qwen2.5-14B-Instruct-AWQ \
    --host 127.0.0.1 \
    --port 8000 \
    --gpu-memory-utilization 0.90 \
    --max-model-len 8192 \
    --max-num-seqs 64 \
    --enable-chunked-prefill \
    --trust-remote-code

关键参数避坑说明:

  • --gpu-memory-utilization 0.90:显存利用率上限。切勿设为 1.0,必须为 CUDA Context 和 PyTorch 临时计算预留 5%~10% 的缓冲空间。
  • --enable-chunked-prefill:极为关键的低延迟开关。它会将长输入的 Prefill 阶段拆解为多个分块,穿插在 Decode 生成阶段之间,彻底消除了新请求进入导致其他并发流卡死的问题。
  • --max-num-seqs 64:允许并发处理的最大序列数,结合显存大小动态约束。

2. 异步流式代理网关(Python / FastAPI)

使用 httpx.AsyncClient 实现真正的非阻塞异步流转发。这里有一个深坑:如果客户端中途断开连接,后端的推理必须立即取消,否则显卡会持续空转浪费算力。

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

app = FastAPI()
VLLM_API_URL = "http://127.0.0.1:8000/v1/chat/completions"

# 维持持久连接池,避免每次请求频繁进行 TCP 握手
http_client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=5.0),
    limits=httpx.Limits(max_keepalive_connections=50, max_connections=200)
)

@app.post("/v1/chat/completions")
async def proxy_chat(request: Request):
    body = await request.json()
    body["stream"] = True  # 强制流式输出

    async def stream_generator():
        try:
            req = http_client.build_request("POST", VLLM_API_URL, json=body)
            res = await http_client.send(req, stream=True)
            async for chunk in res.aiter_bytes():
                # 检查客户端是否已经断开连接
                if await request.is_disconnected():
                    await res.aclose()
                    break
                yield chunk
        except Exception as e:
            yield f"data: {{\"error\": \"{str(e)}\"}}\n\n".encode("utf-8")

    return StreamingResponse(stream_generator(), 
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no" # 通知 Nginx 不要做缓冲区暂存
        }
    )

3. Nginx 核心反向代理配置

流式响应最常见的问题是文字不是“吐出来”的,而是等了三四秒突然“喷出一大段”。这通常是 Nginx 开启了 proxy_buffering 导致的。修改你的 Nginx 站点配置:

upstream agent_backend {
    server 127.0.0.1:8080; # FastAPI 服务端口
    keepalive 32;
}

server {
    listen 80;
    server_name api.lan.nassky.top;

    location /v1/ {
        proxy_pass http://agent_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        
        # 核心:关闭反向代理缓冲区,实现打字机效果
        proxy_buffering off;
        proxy_cache off;
        chunked_transfer_encoding on;

        # 长连接超时时间,针对长文本生成场景
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
        
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

三、实测压测数据对比

我在单张 RTX 4090 24GB 上,对 Qwen2.5-14B-Instruct-AWQ 进行了压测对比。测试场景为:提示词输入 512 tokens,生成输出 256 tokens,使用 wrk 与自定义并发测试脚本循环打入 32 个并发流:

方案架构 首字延迟 (TTFT P95) 系统总吞吐量 (tokens/s) 显存占用率 并发错误率 (32 并发)
Ollama 默认部署 4,820 ms 184.2 22.8 GB 12.5% (显存排队超时)
vLLM 基础配置 640 ms 612.8 21.6 GB 0%
vLLM + Chunked Prefill + 异步代理 210 ms 785.4 21.6 GB 0%

实测数据非常明显:开启 Chunked Prefill 后,首字延迟直接从几秒压到了 200 毫秒出头,几乎消除了突发请求带来的抖动。吞吐量相比原生部署提升了 4 倍以上,24G 显存被压榨到了极限但没有发生溢出。

四、踩坑排障实录

1. 显存碎片导致偶发 CUDA OOM

现象:服务刚启动正常,运行几个小时后突然报torch.cuda.OutOfMemoryError。
排查与解决:这是因为 Linux 系统的共享内存(shm)或 PyTorch 本身的显存池碎片未释放。如果是在 Docker 中运行,启动容器时 必须显式增加 shm 大小:--shm-size="16gb"。同时在容器环境变量中加入export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,让 PyTorch 动态扩展段而不是强求连续内存。

2. 客户端取消请求后显卡空转

现象:用户网页刷新了,后台显卡依然在呼呼狂转十几秒生成剩下的 Token。
解决:务必在 FastAPI 代理中监听request.is_disconnected(),一旦断开立刻向 vLLM 的流连接发送 Abort 信号。vLLM 本身具备请求中止的 RPC 通知能力,中断后会立刻回收该序列占用的 KV Cache 块。

总结与部署建议

在个人工作站或 NAS 私有云环境中跑 AI Agent,别再用裸奔的 Python 脚本启动了。将推理核心与网络网关解耦,使用vLLM 处理 PagedAttention 与批处理调配,上层配合FastAPI + Nginx 流式透传,不仅能够让你的单卡显存利用率最大化,更能为多 Agent 协作提供毫秒级响应的高可靠 API 服务。

如果手头显存极度紧张(例如 16G 显存跑 14B 量化),可以适当调低 --max-model-len 至 4096,并调整 gpu_memory_utilization 为 0.85,优先保障系统进程与显存回收的稳定性。

===EOF===

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