第 45 章

vLLM 高并发推理服务

第45章 vLLM 高并发推理服务

导语

Ollama 解决了"能不能跑"的问题,vLLM 解决的是"能不能撑住"的问题。当你的 Hermes Agent 需要同时服务 50 个、500 个甚至 5000 个并发请求时,Ollama 会让你的用户排队到天荒地老,而 vLLM 的 PagedAttention 机制可以将吞吐量提升 10-30 倍。本章从原理到实战,带你掌握 vLLM 的高并发推理服务。


45.1 vLLM 安装与 GPU 环境配置

系统要求

组件 最低版本 推荐版本
CUDA 11.8 12.1+
Python 3.8 3.10-3.11
GPU 架构 Ampere(30系) Hopper(H100)
驱动 520.xx 550.xx+

安装步骤

# 1. 创建虚拟环境
python3.11 -m venv venv-vllm
source venv-vllm/bin/activate

# 2. 安装 vLLM(自动匹配 CUDA 版本)
pip install vllm

# CUDA 12.1 专用版本(更快)
pip install vllm --extra-index-url https://download.pytorch.org/whl/cu121

# 3. 验证安装
python -c "import vllm; print(vllm.__version__)"
python -c "from vllm import LLM; print('GPU:', LLM._get_and_verify_max_len.__module__)"

# 4. 安装辅助工具
pip install httpx rich typer  # 测试和监控工具

# 5. 验证 GPU
python -c "
import torch
print(f'CUDA 可用: {torch.cuda.is_available()}')
print(f'GPU 数量: {torch.cuda.device_count()}')
for i in range(torch.cuda.device_count()):
    props = torch.cuda.get_device_properties(i)
    print(f'  GPU {i}: {props.name}, {props.total_memory // 1024**3} GB')
"

Docker 方式安装(推荐生产环境)

# 拉取官方镜像
docker pull vllm/vllm-openai:latest

# 带 GPU 支持运行
docker run --runtime nvidia --gpus all \
    -v ~/.cache/huggingface:/root/.cache/huggingface \
    -p 8000:8000 \
    --ipc=host \
    vllm/vllm-openai:latest \
    --model NousResearch/Hermes-3-70B \
    --dtype bfloat16 \
    --tensor-parallel-size 2 \
    --max-model-len 65536

45.2 启动 Hermes 4 推理服务的完整命令

基础启动命令(单卡)

# 使用 HuggingFace Hub 模型(自动下载)
python -m vllm.entrypoints.openai.api_server \
    --model NousResearch/Hermes-3-Llama-3.1-70B \
    --dtype bfloat16 \
    --max-model-len 65536 \
    --gpu-memory-utilization 0.90 \
    --port 8000 \
    --host 0.0.0.0

# 使用本地模型目录
python -m vllm.entrypoints.openai.api_server \
    --model /models/hermes-4-70b \
    --dtype bfloat16 \
    --max-model-len 65536 \
    --gpu-memory-utilization 0.92 \
    --port 8000

生产环境完整启动命令

#!/bin/bash
# start_vllm_hermes.sh

set -e

MODEL_PATH="${MODEL_PATH:-NousResearch/Hermes-3-Llama-3.1-70B}"
GPU_COUNT=$(nvidia-smi --query-gpu=name --format=csv,noheader | wc -l)

echo "检测到 ${GPU_COUNT} 块 GPU"

python -m vllm.entrypoints.openai.api_server \
    --model "$MODEL_PATH" \
    \
    `# 精度和量化` \
    --dtype bfloat16 \
    --quantization None \
    \
    `# 上下文和内存` \
    --max-model-len 65536 \
    --gpu-memory-utilization 0.92 \
    --max-num-seqs 256 \           # 最大并发序列数
    --max-num-batched-tokens 65536 \ # 每批最大 token 数
    \
    `# 并行配置(多卡)` \
    --tensor-parallel-size "$GPU_COUNT" \
    \
    `# 调度配置` \
    --scheduler-delay-factor 0.1 \  # 批处理等待时间(秒)
    --use-v2-block-manager \        # 使用 v2 块管理器
    \
    `# API 配置` \
    --host 0.0.0.0 \
    --port 8000 \
    --api-key "${VLLM_API_KEY:-}" \   # 可选 API Key
    \
    `# 日志` \
    --log-level info \
    2>&1 | tee /var/log/vllm-hermes.log

GGUF 模型加载(vLLM 0.5+ 支持)

# vLLM 0.5+ 支持直接加载 GGUF 格式
python -m vllm.entrypoints.openai.api_server \
    --model /models/hermes-4-70b-q4_k_m.gguf \
    --tokenizer NousResearch/Hermes-3-Llama-3.1-70B \
    --quantization gguf \
    --dtype float16 \
    --max-model-len 32768 \
    --port 8000

45.3 PagedAttention 原理

传统 KV Cache 的问题

在传统推理框架中,KV Cache 需要预先分配连续的内存块

传统方法(预分配):

序列 A: [████████████████████░░░░░░░░░░░░]  预留 2048 tokens,实际用 800
                      ↑ 浪费了 60% 的显存

序列 B: [████████████████████████████░░░░]  预留 2048 tokens,实际用 1400

序列 C: 等待...(虽然 GPU 有空余碎片显存,但无法分配)

碎片化问题:已分配但未使用的内存(内部碎片)+ 无法分配的小块空闲内存(外部碎片)= 浪费 30-60% 显存。

PagedAttention:虚拟分页管理

PagedAttention 借鉴了操作系统的虚拟内存分页思想:

PagedAttention 内存模型:

物理 KV Block(固定大小,如 16 tokens/块):
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ B0 │ B1 │ B2 │ B3 │ B4 │ B5 │ B6 │ B7 │  物理内存块
└────┴────┴────┴────┴────┴────┴────┴────┘

逻辑 KV 视图(每个序列按需申请块):
序列 A: [B0] → [B3] → [B7]          (3 块,非连续)
序列 B: [B1] → [B2] → [B4] → [B6]  (4 块,非连续)
序列 C: [B5]                         (1 块,按需分配)

核心优势

  1. 零内部碎片:按 Block 精确分配,最后一块有少量碎片
  2. 零外部碎片:非连续的物理块通过页表映射为连续逻辑视图
  3. 内存共享:多个请求共享相同 Prompt 的 KV Cache(Prefix Caching)

PagedAttention 吞吐量提升原理

# 吞吐量对比的直觉解释
def throughput_comparison():
    """
    传统方法 vs PagedAttention 的内存利用率对比
    """
    gpu_vram_gb = 80  # A100 80GB
    model_size_gb = 40  # Hermes 70B Q4

    available_for_kv = gpu_vram_gb - model_size_gb  # 40 GB

    # 传统方法:预分配 max_len
    # 每个序列预分配 65536 tokens 的 KV Cache
    # FP16: 65536 * 0.25 GB/K = 16.4 GB 每序列
    traditional_seq_size_gb = 65536 / 1024 * 0.25  # ~16 GB
    traditional_max_concurrent = int(available_for_kv / traditional_seq_size_gb)

    # PagedAttention:按实际使用分配
    # 平均序列长度通常 1000-3000 tokens
    avg_actual_seq_len = 2048  # tokens
    paged_seq_size_gb = avg_actual_seq_len / 1024 * 0.25  # ~0.5 GB
    paged_max_concurrent = int(available_for_kv / paged_seq_size_gb)

    print(f"传统方法最大并发: {traditional_max_concurrent} 序列")
    print(f"PagedAttention 最大并发: {paged_max_concurrent} 序列")
    print(f"吞吐量提升: {paged_max_concurrent / traditional_max_concurrent:.1f}x")
    # 传统: 2 序列,PagedAttention: ~80 序列 → 40x 提升

throughput_comparison()

Prefix Caching(前缀共享)

vLLM 的 Prefix Caching 让多个请求共享相同前缀的 KV Cache:

# 共享系统提示词的场景
SYSTEM_PROMPT = "你是 Hermes,一个专业的 AI 助手..." * 100  # 假设很长的系统提示

# 请求 1: system_prompt + user_query_1
# 请求 2: system_prompt + user_query_2
# 请求 3: system_prompt + user_query_3

# 传统方法:每个请求都重新计算 system_prompt 的 KV Cache(×3 计算量)
# Prefix Caching:system_prompt 只计算一次,三个请求共享

启用方式:

python -m vllm.entrypoints.openai.api_server \
    --model NousResearch/Hermes-3-Llama-3.1-70B \
    --enable-prefix-caching \    # 启用前缀缓存
    --max-model-len 65536

45.4 吞吐量 vs 延迟权衡配置

核心权衡矩阵

配置策略 吞吐量 延迟(P50) 延迟(P99) 适用场景
吞吐量优先 极高 极高 批量 API、异步处理
延迟优先 极低 交互式应用、IDE 插件
平衡模式 中高 通用 API 服务

吞吐量优先配置

# 最大化吞吐量配置
python -m vllm.entrypoints.openai.api_server \
    --model NousResearch/Hermes-3-Llama-3.1-70B \
    --max-num-seqs 512 \              # 允许大量并发序列
    --max-num-batched-tokens 131072 \ # 大批次处理
    --scheduler-delay-factor 0.5 \   # 等待更多请求聚合(延迟换吞吐)
    --enable-prefix-caching \
    --gpu-memory-utilization 0.95    # 激进的内存利用

延迟优先配置

# 最小化延迟配置
python -m vllm.entrypoints.openai.api_server \
    --model NousResearch/Hermes-3-Llama-3.1-70B \
    --max-num-seqs 32 \              # 限制并发,减少排队
    --max-num-batched-tokens 8192 \  # 小批次,快速响应
    --scheduler-delay-factor 0.0 \   # 不等待,立即处理
    --gpu-memory-utilization 0.85    # 留余量给 KV Cache

动态批处理调优

# vllm_tuning.py
"""
自动寻找吞吐量和延迟的最优平衡点。
"""

import asyncio
import time
import httpx
import statistics

async def send_request(client: httpx.AsyncClient, prompt: str) -> tuple[float, float]:
    """返回 (首token延迟, 总时间)"""
    start = time.time()
    first_token_time = None
    
    async with client.stream(
        "POST",
        "http://localhost:8000/v1/completions",
        json={
            "model": "NousResearch/Hermes-3-Llama-3.1-70B",
            "prompt": prompt,
            "max_tokens": 200,
            "stream": True,
            "temperature": 0.1
        },
        headers={"Authorization": "Bearer your-api-key"}
    ) as response:
        async for line in response.aiter_lines():
            if line.startswith("data: ") and line != "data: [DONE]":
                if first_token_time is None:
                    first_token_time = time.time() - start
    
    total_time = time.time() - start
    return first_token_time or total_time, total_time


async def benchmark_vllm(
    num_requests: int = 50,
    concurrency: int = 10,
    prompt: str = "请写一段关于人工智能的200字介绍:"
) -> dict:
    """并发压测 vLLM 服务"""
    
    async with httpx.AsyncClient(timeout=180) as client:
        # 预热
        await send_request(client, "Hello")
        
        # 正式测试
        start = time.time()
        semaphore = asyncio.Semaphore(concurrency)
        
        async def bounded_request():
            async with semaphore:
                return await send_request(client, prompt)
        
        tasks = [bounded_request() for _ in range(num_requests)]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        elapsed = time.time() - start
        
        # 过滤错误
        valid = [(ttft, total) for r in results if isinstance(r, tuple) for ttft, total in [r]]
        
        ttfts = [r[0] for r in valid]
        totals = [r[1] for r in valid]
        
        return {
            "total_requests": num_requests,
            "successful": len(valid),
            "concurrency": concurrency,
            "elapsed_seconds": round(elapsed, 2),
            "throughput_rps": round(len(valid) / elapsed, 2),
            "ttft_p50_ms": round(statistics.median(ttfts) * 1000, 0),
            "ttft_p99_ms": round(sorted(ttfts)[int(len(ttfts) * 0.99)] * 1000, 0),
            "total_p50_ms": round(statistics.median(totals) * 1000, 0),
            "total_p99_ms": round(sorted(totals)[int(len(totals) * 0.99)] * 1000, 0),
        }


# 运行测试
async def main():
    print("=== vLLM 性能基准测试 ===\n")
    
    for concurrency in [1, 5, 10, 20, 50]:
        result = await benchmark_vllm(num_requests=concurrency * 5, concurrency=concurrency)
        print(f"并发={concurrency:3d}: "
              f"吞吐={result['throughput_rps']:5.1f} RPS, "
              f"TTFT P50={result['ttft_p50_ms']:5.0f}ms, "
              f"TTFT P99={result['ttft_p99_ms']:6.0f}ms")

asyncio.run(main())

45.5 并发压测(wrk / ab)

使用 wrk 压测

# 安装 wrk
apt-get install wrk  # Ubuntu
brew install wrk     # macOS

# 创建 wrk Lua 脚本(用于 POST 请求)
cat > vllm_wrk.lua << 'EOF'
wrk.method = "POST"
wrk.headers["Content-Type"] = "application/json"
wrk.headers["Authorization"] = "Bearer your-api-key"
wrk.body = [[{
  "model": "NousResearch/Hermes-3-Llama-3.1-70B",
  "prompt": "用一句话解释量子计算的核心原理。",
  "max_tokens": 100,
  "temperature": 0.1
}]]
EOF

# 运行压测
# 持续 30 秒,10 个线程,100 个并发连接
wrk -t 10 -c 100 -d 30s \
    -s vllm_wrk.lua \
    http://localhost:8000/v1/completions

# 典型输出:
# Running 30s test @ http://localhost:8000/v1/completions
# 10 threads and 100 connections
# Thread Stats   Avg      Stdev     Max   +/- Stdev
#   Latency    2.45s   890.23ms   8.12s    78.34%
#   Req/Sec     8.20      4.55    23.00    65.12%
# 2456 requests in 30.05s, 892.15KB read
# Requests/sec:    81.74
# Transfer/sec:     29.69KB

使用 ab 压测

# 创建请求体文件
cat > request_body.json << 'EOF'
{
  "model": "NousResearch/Hermes-3-Llama-3.1-70B",
  "prompt": "请写一个 Python 排序函数。",
  "max_tokens": 200,
  "temperature": 0.1
}
EOF

# 100 个请求,10 个并发
ab -n 100 -c 10 \
    -p request_body.json \
    -T "application/json" \
    -H "Authorization: Bearer your-api-key" \
    http://localhost:8000/v1/completions

监控 GPU 使用率

# 实时监控(每秒刷新)
watch -n 1 nvidia-smi

# 详细监控(所有指标)
nvidia-smi dmon -s pumcet -d 1

# 字段说明:
# p = power usage  u = GPU utilization
# m = memory util  c = SM clock
# e = temperature  t = PCIe throughput

# Python 监控脚本
python3 << 'EOF'
import subprocess
import time

while True:
    result = subprocess.run(
        ["nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu",
         "--format=csv,noheader,nounits"],
        capture_output=True, text=True
    )
    for i, line in enumerate(result.stdout.strip().split("\n")):
        util, mem_used, mem_total, temp = line.split(", ")
        print(f"GPU {i}: 利用率={util}%, 显存={mem_used}/{mem_total}MB, 温度={temp}°C")
    print()
    time.sleep(2)
EOF

45.6 与 Hermes Agent 的集成

OpenAI 兼容接口集成

vLLM 提供 OpenAI 兼容 API,可直接替换 OpenAI 客户端:

# vllm_hermes_agent.py
from openai import AsyncOpenAI
import asyncio

# 直接使用 OpenAI SDK 连接 vLLM
client = AsyncOpenAI(
    base_url="http://localhost:8000/v1",
    api_key="your-api-key-or-dummy"  # vLLM 可配置为不验证
)

async def chat_with_hermes_vllm(
    messages: list[dict],
    max_tokens: int = 2048,
    temperature: float = 0.1
) -> str:
    """通过 vLLM OpenAI 接口调用 Hermes"""
    
    response = await client.chat.completions.create(
        model="NousResearch/Hermes-3-Llama-3.1-70B",
        messages=messages,
        max_tokens=max_tokens,
        temperature=temperature,
        stream=False
    )
    
    return response.choices[0].message.content


async def streaming_chat(messages: list[dict]) -> str:
    """流式输出版本"""
    full_content = ""
    
    stream = await client.chat.completions.create(
        model="NousResearch/Hermes-3-Llama-3.1-70B",
        messages=messages,
        max_tokens=2048,
        temperature=0.1,
        stream=True
    )
    
    async for chunk in stream:
        delta = chunk.choices[0].delta
        if delta.content:
            print(delta.content, end="", flush=True)
            full_content += delta.content
    
    print()
    return full_content


# Hermes Agent 配置(切换后端只需修改 base_url)
HERMES_VLLM_CONFIG = {
    "provider": "openai_compatible",
    "base_url": "http://localhost:8000/v1",
    "model": "NousResearch/Hermes-3-Llama-3.1-70B",
    "api_key": "your-api-key",
    "max_tokens": 4096,
    "temperature": 0.1,
    "context_window": 65536
}

负载均衡多实例

# nginx.conf — vLLM 多实例负载均衡
upstream vllm_hermes {
    # 轮询(默认)
    server 127.0.0.1:8000 weight=1;
    server 127.0.0.1:8001 weight=1;
    server 127.0.0.1:8002 weight=1;
    
    # 或使用 least_conn(最少连接数,更适合长请求)
    # least_conn;
    
    keepalive 32;  # 保持长连接
}

server {
    listen 80;
    
    location /v1/ {
        proxy_pass http://vllm_hermes;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header X-Real-IP $remote_addr;
        
        # 流式输出不缓冲
        proxy_buffering off;
        proxy_cache off;
        
        # 长超时(LLM 推理需要)
        proxy_read_timeout 300s;
        proxy_connect_timeout 10s;
    }
}

本章小结

vLLM 是生产环境 Hermes 部署的首选推理引擎:

特性 vLLM 优势
并发吞吐 PagedAttention 实现近无碎片内存,吞吐比传统方法高 10-30x
API 兼容性 OpenAI 兼容,切换成本几乎为零
功能丰富 Prefix Caching、量化、多卡并行开箱即用
监控 内置 Prometheus 指标,完善的可观测性

关键配置三要素--max-num-seqs(并发上限)、--scheduler-delay-factor(批处理等待)、--gpu-memory-utilization(内存利用率)。

思考题

  1. PagedAttention 中 Block 大小的选择(默认 16 tokens/块)对性能有什么影响?Block 太小或太大各有什么问题?

  2. vLLM 的 Prefix Caching 在 Hermes Agent 场景中最能发挥价值的是哪种情况?请给出一个具体的业务场景,并估算缓存命中率。

  3. 在"吞吐量优先"和"延迟优先"两种配置下,当 GPU 利用率达到 95% 时,服务会出现什么行为差异?如何设计熔断机制来保护服务质量?

本章评分
4.7  / 5  (3 评分)

💬 留言讨论