拒绝 Ollama 吞吞吐吐:在 NAS/HomeLab 用 vLLM 跑满单卡高并发流式推理踩坑记

4次阅读
没有评论

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

很多折腾本地大模型的朋友起手式都是 Ollama。坦白讲,单人拿着终端通过命令行跟 Qwen 聊两句,Ollama 确实开箱即用。但当我在自己的 HomeLab 服务器(Ubuntu 22.04 + 单卡 RTX 3090 24G)上接入 Dify、Open-WebUI,并挂了两个后台爬虫 Agent 跑总结时,问题立马暴露了:并发请求一上来,Ollama 底层的 llama.cpp 调度逻辑直接让响应延迟从 200ms 飙升到 8 秒以上,甚至偶尔直接报 CUDA OOM(显存溢出)崩溃。

根本原因在于显存碎片化以及缺少工业级的持续批处理(Continuous Batching)调度机制。为了榨干这块 24G 显存,我花了两个周末把整个推理栈切到了 vLLM,并手写了一层流式代理网关。这套方案跑了快两个月,稳如老狗。今天把整套实战配置、踩坑代码和压测数据毫无保留地复盘出来。

一、为什么抛弃 Ollama 转投 vLLM?

在决定折腾前,我们先看两者的底层架构差异:

  • Ollama (llama.cpp):显存预分配较死板,KV Cache 在并发处理时没有做到类似操作系统虚拟内存的分页管理。遇到不同上下文长度的并发请求时,极易产生显存碎片,排队机制相对简陋。
  • vLLM:核心在于 PagedAttention 算法。它把 KV Cache 切分成物理块(Block),按需动态分配,允许非连续显存存储。这意味着显存浪费率能从 60%~80% 压到 4% 以下,极度适合高并发、多长文本混合的流式推理。

二、宿主机与 Docker Compose 生产级配置

在 NAS 或 Linux 工作站上跑 vLLM,最稳妥的方案是直接走官方 Docker 镜像。但千万别直接 docker run 一行搞定,显存和共享内存的坑多得吓人。

首先确保宿主机安装好了驱动和 NVIDIA Container Toolkit:

# 验证 NVIDIA 容器运行时是否就绪
docker run --rm --gpus all nvidia/cuda:12.1.1-base-ubuntu22.04 nvidia-smi

接下来是我的核心 docker-compose.yml 文件。这里以运行 Qwen/Qwen2.5-14B-Instruct-AWQ 或 deepseek-ai/DeepSeek-R1-Distill-Qwen-14B 为例,模型已经预先下载在本地 NAS 路径:

version: '3.8'

services:
  vllm-service:
    image: vllm/vllm-openai:v0.6.3.post1
    container_name: vllm-server
    restart: always
    ipc: host  # 必须配置,防止 PyTorch DataLoader / NCCL 共享内存溢出
    shm_size: '16gb'  # 兜底给足共享内存
    environment:
      - CUDA_VISIBLE_DEVICES=0
      - HF_HUB_OFFLINE=1
      - VLLM_ATTENTION_BACKEND=FLASHINFER  # 如果驱动和卡支持,首选 FLASHINFER 或 FLASH_ATTN
    volumes:
      - /mnt/storage/ai-models/Qwen2.5-14B-Instruct-AWQ:/models/qwen2.5-14b
    ports:
      - "8000:8000"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    command: >
      --model /models/qwen2.5-14b
      --served-model-name qwen-14b
      --quantization awq
      --dtype half
      --max-model-len 8192
      --gpu-memory-utilization 0.90
      --max-num-seqs 16
      --enable-chunked-prefill
      --enforce-eager

关键参数避坑解析:

  1. --gpu-memory-utilization 0.90:很多人第一次用 vLLM,看到刚启动显卡显存就吃了 90%,以为泄露了。 其实这是 vLLM 的机制 :它预先把 90% 显存锁定,扣除模型本身的权重后,剩下的几 GB 全部分配给 KV Cache 池子。别调到 0.98,给 CUDA Runtime 和 Context 预留 10% 缓冲,否则请求突发时必崩。
  2. --enable-chunked-prefill:极度关键!老版本 vLLM 在并发请求时,如果有长 Prompt 进来,会完全阻塞其他用户的实时输出。开启分块预填充后,长 Prompt 会被拆分批次计算,保证多用户流式输出(TTFT)依然顺畅。
  3. --enforce-eager:在 3090/4090 消费级显卡上,关掉 CUDA Graph(开启 eager 模式)能省下近 1GB 专用于 Graph Capture 的显存,同时规避不少因动态 Batch 引发的奇怪 CUDA 报错。

三、自建 FastAPI 流式反代:解决中断悬挂与访问控制

生产环境绝不能把 vLLM 的 8000 端口直接丢给内网甚至公网。我遇到过一个恶心的问题:前端 UI(如 Chat 界面)如果用户直接点击“停止生成”或关闭网页,OpenAI 兼容接口如果没有正确处理断开信号,vLLM 后端依然会在后台把整串 Token 吐完,显存和算力全白白浪费。

我在中间架设了一层轻量级 FastAPI 异步代理,负责鉴权、SSE 断开监听与流量打底:

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

app = FastAPI()

VLLM_UPSTREAM_URL = "http://127.0.0.1:8000/v1/chat/completions"

@app.post("/v1/chat/completions")
async def chat_proxy(request: Request):
    # 1. 简易鉴权处理
    auth_header = request.headers.get("Authorization")
    if not auth_header or not auth_header.startswith("Bearer sk-nassky-"):
        raise HTTPException(status_code=401, detail="Invalid API Key")

    payload = await request.json()
    
    # 强制开启流式以节省并发占用
    payload["stream"] = True

    async def event_generator():
        client = httpx.AsyncClient(timeout=120.0)
        try:
            async with client.stream(
                "POST", 
                VLLM_UPSTREAM_URL, 
                json=payload, 
                headers={"Content-Type": "application/json"}
            ) as response:
                if response.status_code != 200:
                    yield f"data: {{\"error\": \"Upstream returned {response.status_code}\"}}\n\n"
                    return

                async for line in response.aiter_lines():
                    # 关键逻辑:检测客户端断开连接,及时掐断上游请求
                    if await request.is_disconnected():
                        # 中断生成,释放 vLLM 内部 sequence 资源
                        break
                    
                    if line:
                        yield f"{line}\n\n"
        finally:
            await client.aclose()

    return StreamingResponse(event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no" # 解决 Nginx 缓冲导致无法实时流式打字的问题
        }
    )

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

四、实测压测数据对比:Ollama vs vLLM

我用自写的 Python 异步压测脚本,针对 Qwen2.5-14B-Instruct(Ollama 采用 Q4_K_M,vLLM 采用 AWQ-4bit)进行了多轮并发压测。输入设定为 500 Tokens 上下文,输出限制 300 Tokens。

正文完
 0
评论(没有评论)
Copyright©2013-2025 Nas的天空 nassky.top 版权所有
 Theme by Puock
测试引擎 并发并发数 (Concurrency) 首字延迟 (TTFT P95) 总吞吐量 (Total Tokens/s) 显存占用状态
Ollama (llama.cpp) 1 并发 0.42s 46.2 tok/s 11.5 GB(固定)
4 并发 3.85s 58.1 tok/s 16.2 GB
8 并发 9.21s (卡顿感严重) 61.4 tok/s 频繁爆显存 OOM
vLLM (PagedAttention) 1 并发 0.48s 43.8 tok/s 21.6 GB(预分配)
4 并发 0.68s 142.6 tok/s 21.6 GB(池化复用)