MODULE 08 · 部署

部署与推理优化:把延迟和账单压下来

模型训好只是开始,能稳定、便宜地服务才是交付。本模块以 vLLM 为主线,讲清 PagedAttention、连续批处理、量化与性能调优,并给出可以直接复制运行的启动命令、客户端代码与压测脚本。

14 节 约 100 分钟 更新于 2026-07

推理挑战

在线推理有三座大山:显存(KV Cache 随序列增长)、吞吐(短请求被长请求拖慢)、并发(请求到来时间不一)。朴素逐条推理利用率极低,账单却很高。

三座大山里,显存是最容易被低估的一座。权重是固定开销,KV Cache 却是随请求数与序列长度线性增长的动态开销。先把它算清楚,后面所有参数才有依据:

# kv_estimate.py  估算 KV Cache 显存占用
# 每 token 字节数 = 2(K 与 V) * 层数 * KV 头数 * head_dim * 单元素字节

def kv_bytes_per_token(layers, kv_heads, head_dim, dtype_bytes=2):
    return 2 * layers * kv_heads * head_dim * dtype_bytes

# Qwen2.5-7B-Instruct: 28 层, GQA 只有 4 个 KV 头, head_dim=128, bf16
per_token = kv_bytes_per_token(28, 4, 128, 2)
print(f"每 token: {per_token / 1024:.1f} KB")          # 56.0 KB

seq_len, concurrency = 2048, 64
total = per_token * seq_len * concurrency
print(f"2048 长度 x 64 并发: {total / 1024**3:.1f} GiB")  # 7.0 GiB

# 结论: 7B 权重 bf16 约 14 GiB, 在 24GB 卡上只剩约 8 GiB 给 KV Cache,
# 也就是说 64 并发 x 2K 上下文已经贴着上限, 再长就会开始排队或抢占。

如果没有 GQA(比如老一代 32 个 KV 头的模型),同样条件下 KV Cache 会膨胀到 56 GiB,单卡根本放不下。这就是为什么显存管理策略本身就是性能问题,而不只是工程细节。

朴素实现的浪费:Hugging Face generate()max_length 预留连续显存,一个实际只生成 80 token 的请求,也可能占着 2048 token 的坑。真实业务里 60% 到 80% 的 KV 显存是被这样浪费掉的。

vLLM 核心

vLLM 用两项技术破解上述难题:

  • PagedAttention:像操作系统分页一样管理 KV Cache,消除显存碎片,显存利用率大幅提升。
  • 连续批处理(Continuous Batching):不再等一个批次凑齐,而是请求完成即补位,GPU 几乎不空转。

PagedAttention 的关键是逻辑连续、物理离散:把 KV Cache 切成固定大小的块(默认 16 个 token 一块),每个请求维护一张「块表」记录自己用了哪些物理块。请求要多少给多少,不预留、不浪费,还能让多个请求共享同一份前缀块。

朴素预分配:按 max_len 占坑 PagedAttention:按块按需分配 请求 A 请求 B 请求 C 灰色区域已被占住却用不上 显存浪费约 66%,新请求只能排队 吞吐上限 = 显存 / max_len 长上下文场景下并发数被死死卡住 A 块表 B 块表 C 块表 物理块池 显存浪费低于 4%,空闲块随时复用 相同前缀的块可跨请求共享,命中即免算

图 1:KV Cache 从「整段预留」到「分页按需」的转变

上图展示了分页带来的显存效率提升。再深入一层,看逻辑块到物理块的映射过程和关键参数:

PagedAttention 块映射架构 block_size=16 num_blocks=256 请求 A 逻辑块 0,1,2 请求 B 逻辑块 3,7,9,12 Block Table A: [0, 1, 2] B: [3, 7, 9, 12] 物理块池 0 1 2 3 7 9 12 传统连续分配 vs PagedAttention 非连续分页 传统: 显存连续预留 请求 A (实际用 60%) 空闲浪费 PA: 按块分配 A 3 块, B 1 块, 剩余空闲可复用 显存浪费从 60%+ 降至 4% 以下, 相同前缀块可跨请求共享, 命中即免算 prefill block_size 越大, 碎片越少但预分配越粗; num_blocks 由显存除以 (block_size * per_token_bytes) 决定

图 2: PagedAttention 逻辑到物理的块映射架构与关键参数

连续批处理则解决时间维度的浪费。静态批处理要等整批里最慢的那条生成完才能收工,vLLM 在每一步解码后都重新调度:谁结束了就腾出块,队列里的新请求立刻补进当前批次,GPU 不留空档。

连续批处理时间调度 GPU 利用率 30% -> 90%+ T0 T1 T2 T3 T4 请求 1 (短) Pfill 完成 请求 2 (长) Prefill 完成 请求 3 (动态) 等待 插入 完成 动态插入: 请求 1 释放块后立即补位 静态批处理 vs 连续批处理 静态: 等齐整批 短请求等长请求: GPU 空转 连续: 谁结束谁补位 新请求 再插入 GPU 利用率从约 30% 提升到 90%+,吞吐提升 3 到 4 倍,TTFT 随排队减少而显著下降

图 3: 连续批处理调度时序与吞吐提升原理

先用离线批推理确认模型与环境没问题,再上服务,这是最省时间的顺序:

# offline_batch.py  离线批推理,验证模型 / 显存 / 精度
from vllm import LLM, SamplingParams

llm = LLM(
    model="Qwen/Qwen2.5-7B-Instruct",
    dtype="bfloat16",
    gpu_memory_utilization=0.90,   # 允许 vLLM 使用 90% 显存
    max_model_len=4096,             # 显存紧张时先压上下文长度
)

sampling = SamplingParams(temperature=0.7, top_p=0.8, max_tokens=256)

conversations = [
    [{"role": "user", "content": "用三句话解释 PagedAttention。"}],
    [{"role": "user", "content": "写一个快速排序的 Python 实现。"}],
    [{"role": "user", "content": "把「服务已恢复」翻译成英文和日文。"}],
]

# chat() 会自动套用模型自带的 chat template,不用手写特殊 token
outputs = llm.chat(conversations, sampling)
for i, out in enumerate(outputs):
    print(f"--- 第 {i + 1} 条 ---")
    print(out.outputs[0].text.strip())
    print("生成 token 数:", len(out.outputs[0].token_ids))
收益:在相同硬件下,vLLM 的吞吐通常数倍于原生 Hugging Face 推理,且不影响输出质量。质量不变、吞吐变高,本质上是把「等待」和「浪费」挤掉了,不是靠牺牲精度换的。

部署步骤

vLLM 自带 OpenAI 兼容的 API 服务,迁移成本极低。先装环境,两条路径任选其一:

# 路径一:pip 安装(需要 CUDA 12.x 驱动,Python 3.9 到 3.12)
python -m venv .venv && source .venv/bin/activate
pip install "vllm==0.9.1"

# 校验:能打印版本说明 CUDA 扩展编译 / 加载正常
python -c "import vllm, torch; print(vllm.__version__, torch.cuda.get_device_name(0))"

# 路径二:官方镜像(推荐,省掉 CUDA 版本地狱)
docker pull vllm/vllm-openai:latest

然后启动 OpenAI 兼容服务。vllm serve 是新版推荐入口,等价于老写法 python -m vllm.entrypoints.openai.api_server

# 启动兼容 OpenAI 的推理服务(每个参数都有实际作用,不要照抄默认值)
vllm serve Qwen/Qwen2.5-7B-Instruct \
  --served-model-name qwen2.5-7b \      # 对外暴露的模型名,客户端用它
  --host 0.0.0.0 --port 8000 \            # 监听地址与端口
  --tensor-parallel-size 1 \             # 张量并行,单卡填 1,4 卡填 4
  --gpu-memory-utilization 0.90 \        # 留 10% 给碎片与激活值峰值
  --max-model-len 8192 \                 # 上下文上限,直接决定 KV 显存
  --max-num-seqs 128 \                   # 同时在批次里的最大请求数
  --dtype bfloat16 \                    # A100/H100 用 bf16,T4/V100 用 float16
  --api-key sk-local-demo               # 开启鉴权,生产必须开

# 完全等价的老写法(脚本里如果已经写死可以不改)
python -m vllm.entrypoints.openai.api_server \
  --model Qwen/Qwen2.5-7B-Instruct \
  --tensor-parallel-size 1 \
  --gpu-memory-utilization 0.90 \
  --port 8000

容器方式更适合服务器长跑,注意挂载模型缓存目录,避免每次重启都重新下载几十 GB 权重:

# docker run 启动,镜像 ENTRYPOINT 已经是 api_server,直接追加参数即可
docker run -d --name vllm-qwen \
  --runtime nvidia --gpus '"device=0"' \
  --ipc=host \                                       # 必须,否则共享内存不足会崩
  -v ~/.cache/huggingface:/root/.cache/huggingface \ # 复用宿主机权重缓存
  -e HF_ENDPOINT=https://hf-mirror.com \             # 国内加速下载,可按需删除
  -p 8000:8000 \
  --restart unless-stopped \
  vllm/vllm-openai:latest \
  --model Qwen/Qwen2.5-7B-Instruct \
  --served-model-name qwen2.5-7b \
  --gpu-memory-utilization 0.90 \
  --max-model-len 8192 \
  --api-key sk-local-demo

# 跟踪加载日志,看到 "Application startup complete" 才算起来
docker logs -f vllm-qwen

起来之后先做三步冒烟验证,不要直接改业务代码:

# 1) 健康检查,返回 HTTP 200 且 body 为空
curl -i http://localhost:8000/health

# 2) 模型列表,确认 served-model-name 生效
curl -s http://localhost:8000/v1/models \
  -H "Authorization: Bearer sk-local-demo" | python -m json.tool

# 3) 真实对话请求
curl -s http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk-local-demo" \
  -d '{
    "model": "qwen2.5-7b",
    "messages": [{"role": "user", "content": "一句话介绍 vLLM"}],
    "max_tokens": 128,
    "temperature": 0.3
  }' | python -m json.tool
三个高频启动报错:一是 CUDA out of memory,先降 --max-model-len 再降 --gpu-memory-utilization;二是 The model's max seq len is larger than KV cache,说明剩余显存放不下声明的上下文,同样调 max-model-len;三是容器里报共享内存不足,检查是否漏了 --ipc=host

客户端接入

启动后即可用原版 OpenAI SDK 直连,把 base_url 指到本地端口即可,业务代码几乎零改动:

# client_basic.py  pip install openai
from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8000/v1",  # 只改这一行就能从云端切到本地
    api_key="sk-local-demo",                # 与启动时 --api-key 一致
)

resp = client.chat.completions.create(
    model="qwen2.5-7b",
    messages=[
        {"role": "system", "content": "你是简洁的技术助手,回答不超过 100 字。"},
        {"role": "user", "content": "连续批处理和静态批处理的区别是什么?"},
    ],
    temperature=0.3,
    max_tokens=256,
)

print(resp.choices[0].message.content)
print("输入 token:", resp.usage.prompt_tokens,
      "输出 token:", resp.usage.completion_tokens)

面向终端用户的场景一定要开流式,首 token 出现得早,体感延迟能降一个数量级。顺手把 TTFT 量出来:

# client_stream.py  流式输出并测量首 token 延迟(TTFT)
import time
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="sk-local-demo")

t0 = time.perf_counter()
ttft = None
n_chunks = 0

stream = client.chat.completions.create(
    model="qwen2.5-7b",
    messages=[{"role": "user", "content": "用 200 字讲清楚 KV Cache 是什么。"}],
    max_tokens=400,
    stream=True,
    stream_options={"include_usage": True},  # 最后一个 chunk 带 usage 统计
)

for chunk in stream:
    if not chunk.choices:
        continue
    delta = chunk.choices[0].delta.content
    if delta:
        if ttft is None:
            ttft = time.perf_counter() - t0
        n_chunks += 1
        print(delta, end="", flush=True)

total = time.perf_counter() - t0
print(f"\n\nTTFT {ttft * 1000:.0f} ms | 总耗时 {total:.2f} s "
      f"| 解码速度 {n_chunks / (total - ttft):.1f} tok/s")

需要程序消费的结果,用 vLLM 的引导解码强制约束 JSON 结构,比事后正则解析稳得多:

# client_json.py  用 guided_json 保证输出可解析
import json
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="sk-local-demo")

schema = {
    "type": "object",
    "properties": {
        "intent": {"type": "string", "enum": ["退款", "物流", "其他"]},
        "urgency": {"type": "integer", "minimum": 1, "maximum": 5},
        "summary": {"type": "string"},
    },
    "required": ["intent", "urgency", "summary"],
}

resp = client.chat.completions.create(
    model="qwen2.5-7b",
    messages=[{"role": "user", "content": "我的包裹三天没动了,急用,帮我看看。"}],
    temperature=0,
    extra_body={"guided_json": schema},  # vLLM 扩展字段,走状态机约束采样
)

data = json.loads(resp.choices[0].message.content)
print(data)  # {'intent': '物流', 'urgency': 4, 'summary': '包裹三天未更新物流'}

量化部署

显存不够时用量化:AWQ(激活感知权重量化)与 GPTQ(训练后量化)都能把 16-bit 权重压到 4-bit,显存减半、速度更快,质量损失可控。配合 KV Cache 量化效果更明显。

7B 模型 bf16 权重约 14 GiB,AWQ 4-bit 后约 4.5 GiB,意味着 24GB 消费级卡能从「勉强跑起来」变成「留出 18 GiB 给 KV Cache」,并发直接翻几倍。先做量化:

# quantize_awq.py  pip install autoawq
from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

model_path = "Qwen/Qwen2.5-7B-Instruct"
quant_path = "./qwen2.5-7b-awq"

quant_config = {
    "zero_point": True,
    "q_group_size": 128,   # 每 128 个权重共享一组缩放因子
    "w_bit": 4,           # 4-bit 权重
    "version": "GEMM",   # 批量大时 GEMM 更快,单请求可选 GEMV
}

model = AutoAWQForCausalLM.from_pretrained(model_path, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)

# 校准过程会跑一小批语料统计激活分布,7B 大约需要 10 到 20 分钟
model.quantize(tokenizer, quant_config=quant_config)

model.save_quantized(quant_path)
tokenizer.save_pretrained(quant_path)
print("量化完成:", quant_path)
# 用量化权重起服务,awq_marlin 内核比原始 awq 内核快 20% 到 60%
vllm serve ./qwen2.5-7b-awq \
  --served-model-name qwen2.5-7b-awq \
  --quantization awq_marlin \
  --dtype float16 \
  --kv-cache-dtype fp8 \          # KV Cache 再压一半,长上下文收益最大
  --max-model-len 16384 \        # 省下来的显存换更长上下文
  --gpu-memory-utilization 0.92 \
  --port 8000

# 社区已量化好的权重可以直接拉,省掉本地校准
vllm serve Qwen/Qwen2.5-7B-Instruct-AWQ --quantization awq_marlin --port 8000
量化不是免费的:4-bit 在通用问答上损失通常在 1 个百分点以内,但在数学推理、长链工具调用、代码生成这类任务上可能明显掉点。上线前务必用 评估 模块的方法跑一遍业务测试集,对比量化前后的分数,不要只看能不能跑。

但只选一种量化方式还不足以做决策。量化方案的选择需要在显存、速度、精度之间权衡,不同硬件下结论可能完全相反。下面用同一份脚本在三种量化方式下跑同一个模型,直接出对比数字:

# quant_compare.py  三种量化方案横向对比
import time, json
import torch
from vllm import LLM, SamplingParams

MODELS = {
    "GPTQ":  "Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4",
    "AWQ":   "Qwen/Qwen2.5-7B-Instruct-AWQ",
    "FP8":   "Qwen/Qwen2.5-7B-Instruct-FP8",   # 需要 H100 / 800 系列以上架构
}

TEST_PROMPTS = [
    {"role": "user", "content": "用 Python 实现快速排序。"},
    {"role": "user", "content": "解释 PagedAttention 的块映射机制。"},
    {"role": "user", "content": "把'服务已恢复'翻译成英文和日文。"},
    {"role": "user", "content": "写一个二叉树的层序遍历。"},
    {"role": "user", "content": "请用 200 字介绍连续批处理。"},
]

def get_vram_used():
    if torch.cuda.is_available():
        return torch.cuda.max_memory_allocated(0) / 1024**3
    return 0

def bench_model(name, model_path, kv_dtype="auto"):
    torch.cuda.reset_peak_memory_stats()
    torch.cuda.empty_cache(); torch.cuda.synchronize()

    kwargs = {"model": model_path, "dtype": "auto",
              "gpu_memory_utilization": 0.90, "max_model_len": 4096}
    if kv_dtype != "auto":
        kwargs["kv_cache_dtype"] = kv_dtype

    llm = LLM(**kwargs)
    torch.cuda.synchronize()
    vram_peak = get_vram_used()

    sampling = SamplingParams(temperature=0, max_tokens=256)

    # 预热一次
    llm.chat([TEST_PROMPTS[0]], sampling)
    torch.cuda.synchronize()

    t0 = time.perf_counter()
    outputs = llm.chat(TEST_PROMPTS, sampling)
    torch.cuda.synchronize()
    elapsed = time.perf_counter() - t0

    total_tokens = sum(len(o.outputs[0].token_ids) for o in outputs)
    del llm; torch.cuda.empty_cache()

    return {
        "name": name,
        "vram_peak_gib": round(vram_peak, 2),
        "total_time_s": round(elapsed, 2),
        "tok_per_s": round(total_tokens / elapsed, 1),
        "total_tokens": total_tokens,
    }

results = []
for name, path in MODELS.items():
    print(f"正在评估 {name} ({path}) ...")
    try:
        r = bench_model(name, path)
        results.append(r)
        print(f"  {name}: 峰值显存 {r['vram_peak_gib']:.1f} GiB, 速度 {r['tok_per_s']:.0f} tok/s")
    except Exception as e:
        print(f"  {name} 加载失败: {e}")

# 输出 Markdown 对比表格
print("\n| 方案 | 峰值显存 | 推理速度 | 输出 Token 数 |")
print("|------|----------|----------|---------------|")
for r in results:
    print(f"| {r['name']} | {r['vram_peak_gib']:.1f} GiB | {r['tok_per_s']:.0f} tok/s | {r['total_tokens']} |")

# 输出选择建议
if results:
    best = max(results, key=lambda x: x["tok_per_s"])
    smallest = min(results, key=lambda x: x["vram_peak_gib"])
    print(f"\n速度最快: {best['name']} ({best['tok_per_s']:.0f} tok/s)")
    print(f"显存最小: {smallest['name']} ({smallest['vram_peak_gib']:.1f} GiB)")
    print("\n选择建议:")
    print("  消费级显卡 (RTX 4090 / 3090): 优先 AWQ, 兼容性最好, 社区量化模型最丰富")
    print("  H100 / H800 集群: 优先 FP8, 精度损失最小, 原生支持无需校准")
    print("  A100 / T4: GPTQ 或 AWQ 均可, GPTQ 的 group_size 调小可再省显存")
运行前提:三种量化模型都需先下载到 HuggingFace 缓存目录。GPTQ 需 pip install auto-gptq,AWQ 需 pip install autoawq,FP8 需要 CUDA 12.5+ 与 H100 系列以上 GPU。如果硬件不支持某一种量化,脚本会跳过并标记失败。

压测与对比

调优之前必须先有数字。三个核心指标要分开看,它们经常此消彼长:

  • TTFT(Time To First Token):首 token 延迟,决定交互体感,主要受 prefill 阶段与排队影响。
  • TPOT(Time Per Output Token):每个输出 token 的间隔,决定「打字速度」是否流畅。
  • 吞吐(tokens/s 与 requests/s):决定单位成本,是批量场景最该盯的指标。

vLLM 自带压测子命令,一条命令就能出完整报告:

# 官方压测工具(vLLM 0.8+ 提供 vllm bench 子命令)
vllm bench serve \
  --backend openai-chat \
  --base-url http://localhost:8000 \
  --endpoint /v1/chat/completions \
  --model qwen2.5-7b \
  --dataset-name random \
  --random-input-len 512 \      # 模拟 512 token 输入
  --random-output-len 256 \     # 模拟 256 token 输出
  --num-prompts 200 \          # 总请求数
  --max-concurrency 32 \       # 并发上限
  --percentile-metrics ttft,tpot,e2el \
  --save-result --result-dir ./bench_out

# 老版本仓库脚本写法(等价)
python benchmarks/benchmark_serving.py --backend vllm --model qwen2.5-7b \
  --num-prompts 200 --request-rate 10

但真实业务的请求分布往往和随机数据集不同。自己写一个几十行的压测脚本,用自己的 prompt 打,结论才可信:

# bench_concurrent.py  用 asyncio 做并发压测,统计 TTFT / 吞吐 / 分位延迟
import asyncio, time, statistics
from openai import AsyncOpenAI

client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="sk-local-demo")

MODEL       = "qwen2.5-7b"
PROMPT      = "请用 300 字介绍 PagedAttention 的核心思想与工程收益。"
CONCURRENCY = 32    # 同时在飞的请求数
TOTAL       = 128   # 总请求数
MAX_TOKENS  = 256

async def one_request(sem, stats):
    async with sem:
        t0, ttft, n_tok = time.perf_counter(), None, 0
        stream = await client.chat.completions.create(
            model=MODEL,
            messages=[{"role": "user", "content": PROMPT}],
            max_tokens=MAX_TOKENS, temperature=0.7, stream=True,
        )
        async for chunk in stream:
            if not chunk.choices:
                continue
            if chunk.choices[0].delta.content:
                if ttft is None:
                    ttft = time.perf_counter() - t0
                n_tok += 1   # 流式下一个 chunk 约等于一个 token
        stats.append({"ttft": ttft or 0.0,
                      "e2e": time.perf_counter() - t0,
                      "tok": n_tok})

def pct(values, p):
    idx = min(int(len(values) * p), len(values) - 1)
    return sorted(values)[idx]

async def main():
    sem, stats = asyncio.Semaphore(CONCURRENCY), []
    wall0 = time.perf_counter()
    await asyncio.gather(*[one_request(sem, stats) for _ in range(TOTAL)])
    wall = time.perf_counter() - wall0

    ttfts = [s["ttft"] for s in stats]
    e2es  = [s["e2e"] for s in stats]
    toks  = sum(s["tok"] for s in stats)

    print(f"并发 {CONCURRENCY} | 请求 {TOTAL} | 墙钟 {wall:.1f}s")
    print(f"输出吞吐  {toks / wall:8.1f} tok/s")
    print(f"请求吞吐  {TOTAL / wall:8.2f} req/s")
    print(f"TTFT      p50 {statistics.median(ttfts)*1000:.0f} ms   p95 {pct(ttfts, 0.95)*1000:.0f} ms")
    print(f"端到端    p50 {statistics.median(e2es):.2f} s    p95 {pct(e2es, 0.95):.2f} s")

asyncio.run(main())

下图是同一张 A100 80GB 上、Qwen2.5-7B-Instruct、512 输入 / 256 输出、32 并发的典型对比。数值随硬件与请求分布浮动,但量级关系是稳定的:

HF Transformers 原生 generate vLLM(PagedAttention + 连续批处理) 318 2410 输出吞吐 tok/s 越高越好,约 7.6 倍 4260 470 TTFT p95 毫秒 越低越好,约 1/9 8 128 不 OOM 的最大并发 越高越好,约 16 倍 测试条件:A100 80GB / Qwen2.5-7B-Instruct bf16 / 输入 512 输出 256 / 并发 32 / 各柱按自身指标独立缩放

图 4:原生推理与 vLLM 的吞吐、延迟、并发能力对比

读图要点:吞吐提升来自连续批处理(GPU 不空转),并发提升来自 PagedAttention(显存不浪费),而 TTFT 下降是前两者的连带结果,因为请求不用再排长队等一个批次凑齐。

但单次压测只能得到一个点,调参决策需要看不同并发下的趋势曲线。下面的脚本做并发梯度扫描,自动跑 1/4/16/64/128 档并发,并输出可读报告:

# bench_sweep.py  并发梯度扫描,带延迟分位与吞吐曲线
import asyncio, time, statistics, json, sys
from openai import AsyncOpenAI

BASE_URL  = "http://localhost:8000/v1"
API_KEY   = "sk-local-demo"
MODEL     = "qwen2.5-7b"
PROMPT    = "用 300 字介绍连续批处理的核心思想与工程收益。"
MAX_TOK   = 256
REQUESTS  = 128
LEVELS    = [1, 4, 16, 64, 128]  # 并发梯度

async def one_req(client, sem, stats):
    async with sem:
        t0, ttft, n_tok = time.perf_counter(), None, 0
        stream = await client.chat.completions.create(
            model=MODEL, temperature=0.7, max_tokens=MAX_TOK,
            messages=[{"role": "user", "content": PROMPT}],
            stream=True,
        )
        async for chunk in stream:
            if not chunk.choices:
                continue
            if chunk.choices[0].delta.content:
                if ttft is None:
                    ttft = time.perf_counter() - t0
                n_tok += 1
        e2e = time.perf_counter() - t0
        stats.append({"ttft": ttft or 0.0, "e2e": e2e, "tok": n_tok})

def pct(vals, p):
    s = sorted(vals)
    idx = min(int(len(s) * p), len(s) - 1)
    return s[idx]

report = {}
async def run_level(c):
    client = AsyncOpenAI(base_url=BASE_URL, api_key=API_KEY, timeout=300)
    sem = asyncio.Semaphore(c)
    stats = []
    wall0 = time.perf_counter()
    await asyncio.gather(*[one_req(client, sem, stats) for _ in range(REQUESTS)])
    wall = time.perf_counter() - wall0
    await client.close()

    ttfts, e2es, toks = [s["ttft"] for s in stats], [s["e2e"] for s in stats], sum(s["tok"] for s in stats)
    return {
        "concurrency": c,
        "wall_time_s": round(wall, 1),
        "throughput_tok_s": round(toks / wall, 1),
        "throughput_req_s": round(REQUESTS / wall, 2),
        "ttft_p50_ms": round(pct(ttfts, 0.50) * 1000, 0),
        "ttft_p95_ms": round(pct(ttfts, 0.95) * 1000, 0),
        "ttft_p99_ms": round(pct(ttfts, 0.99) * 1000, 0),
        "e2e_p50_s":  round(pct(e2es,  0.50), 2),
        "e2e_p95_s":  round(pct(e2es,  0.95), 2),
        "e2e_p99_s":  round(pct(e2es,  0.99), 2),
    }

async def main():
    print("=" * 78)
    print(f"{'并发':<6}{'吞吐 tok/s':<12}{'TTFT p50':<10}{'TTFT p95':<10}{'TTFT p99':<10}{'E2E p95':<10}{'E2E p99':<10}")
    print("-" * 78)
    for c in LEVELS:
        print(f"\n正在测试并发={c} ...", end=" ", flush=True)
        r = await run_level(c)
        print(f"完成 ({r['throughput_tok_s']:.0f} tok/s)")
        print(f"{c:<6}{r['throughput_tok_s']:<12}{r['ttft_p50_ms']:<10.0f}{r['ttft_p95_ms']:<10.0f}{r['ttft_p99_ms']:<10.0f}{r['e2e_p95_s']:<10.2f}{r['e2e_p99_s']:<10.2f}")
        report[str(c)] = r
        await asyncio.sleep(8)  # 等上一批 KV 块回收干净

    # 输出总结报告
    print("\n" + "=" * 78)
    print("总结报告")
    best_thru = max(report.values(), key=lambda x: x["throughput_tok_s"])
    print(f"吞吐拐点: 并发 {best_thru['concurrency']}, {best_thru['throughput_tok_s']:.0f} tok/s")
    print(f"建议 max-num-seqs: {best_thru['concurrency']} (吞吐最大且 p95 延迟可接受)")
    print(f"最高并发档 p99 TTFT: {report[str(LEVELS[-1])]['ttft_p99_ms']:.0f} ms")

    with open("bench_report.json", "w") as f:
        json.dump(report, f, indent=2, ensure_ascii=False)
    print("报告已写入 bench_report.json")

asyncio.run(main())
跑压测时务必同时开监控:另开终端执行 curl -s http://localhost:8000/metrics | grep -E "num_requests_waiting|gpu_cache_usage_perc",在最高并发档观察 waiting 是否持续增长。如果 waiting > 0 且吞吐不再提升,当前档位就是吞吐拐点,--max-num-seqs 设在此档右侧一档即可。

性能调优

  • tensor-parallel-size:多卡时按卡数设,单卡设 1。注意它必须能整除模型的注意力头数。
  • gpu-memory-utilization:预留余量给碎片与突发,别拉满。0.85 到 0.92 是安全区间。
  • max-num-seqs:并发上限,过高反而排队变慢,需要配合压测找拐点。
  • max-num-batched-tokens:单步最大批处理 token 数,调大偏吞吐,调小偏 TTFT。
  • enable-prefix-caching:系统提示或长文档被反复复用时开启,prefill 可以直接命中已有块。
  • 监控:跟踪首 token 延迟(TTFT)与吞吐(tokens/s),结合 评估 模块看线上质量。

把常用的加速开关组合成一条启动命令,逐项开、逐项压测,不要一次全开:

# 面向高吞吐场景的调优组合
vllm serve Qwen/Qwen2.5-7B-Instruct \
  --served-model-name qwen2.5-7b \
  --tensor-parallel-size 2 \                # 双卡张量并行
  --gpu-memory-utilization 0.92 \
  --max-model-len 8192 \
  --max-num-seqs 256 \
  --max-num-batched-tokens 8192 \          # 分块 prefill 的单步预算
  --enable-prefix-caching \                  # 复用相同前缀的 KV 块
  --swap-space 8 \                          # 抢占时换出到 8 GiB 主机内存
  --disable-log-requests                     # 高 QPS 下关掉逐请求日志

# 投机解码:小模型起草、大模型校验,低并发下 TTFT 与 TPOT 收益明显
vllm serve Qwen/Qwen2.5-7B-Instruct \
  --speculative-config '{"model": "Qwen/Qwen2.5-0.5B-Instruct", "num_speculative_tokens": 3}' \
  --port 8000

vLLM 原生暴露 Prometheus 指标,上线后靠这几条判断瓶颈在哪:

# 查看关键运行指标
curl -s http://localhost:8000/metrics | grep -E "vllm:num_requests_running|vllm:num_requests_waiting|vllm:gpu_cache_usage_perc"

# 判读方法:
#   waiting 长期大于 0 且 gpu_cache_usage_perc 接近 1  -> 显存瓶颈,降 max-model-len 或上量化
#   waiting 长期大于 0 但 cache 使用率不高             -> max-num-seqs 设小了,可调大
#   running 一直很低而 GPU 利用率也低                  -> 上游请求不够,问题不在推理侧
调优顺序:先定上下文长度(业务需求),再定显存占用比例(稳定性),然后压测扫并发找拐点(吞吐),最后才考虑量化与投机解码(成本)。顺序反了会反复推翻结论。

生产上线

单实例跑通只是第一步。生产环境需要多副本、统一入口、可观测与优雅重启。典型拓扑如下:

业务客户端 OpenAI SDK 网关 Nginx 鉴权 / 限流 最少连接负载均衡 vLLM 副本 A GPU 0 · 端口 8001 vLLM 副本 B GPU 1 · 端口 8002 权重缓存 只读挂载卷 Prometheus /metrics 滚动升级:先从 upstream 摘掉一个副本,等其 running 请求归零再重启,全程不中断服务

图 5:双副本 vLLM 的生产部署拓扑

# docker-compose.yml  双副本 + 网关
services:
  vllm-a:
    image: vllm/vllm-openai:latest
    ipc: host
    volumes: ["~/.cache/huggingface:/root/.cache/huggingface"]
    command: >
      --model Qwen/Qwen2.5-7B-Instruct
      --served-model-name qwen2.5-7b
      --gpu-memory-utilization 0.90 --max-model-len 8192
      --api-key sk-local-demo --port 8000
    deploy:
      resources:
        reservations:
          devices: [{driver: nvidia, device_ids: ["0"], capabilities: [gpu]}]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      start_period: 300s   # 权重加载慢,启动宽限期一定要给足

  vllm-b:
    extends: {service: vllm-a}
    deploy:
      resources:
        reservations:
          devices: [{driver: nvidia, device_ids: ["1"], capabilities: [gpu]}]

  gateway:
    image: nginx:alpine
    ports: ["8000:80"]
    volumes: ["./nginx.conf:/etc/nginx/conf.d/default.conf:ro"]
    depends_on: [vllm-a, vllm-b]
# nginx.conf  流式响应必须关缓冲,超时要放长
upstream vllm_pool {
    least_conn;                 # 按活跃连接数分发,比轮询更适合长请求
    server vllm-a:8000 max_fails=3 fail_timeout=30s;
    server vllm-b:8000 max_fails=3 fail_timeout=30s;
}

server {
    listen 80;
    location /v1/ {
        proxy_pass http://vllm_pool;
        proxy_buffering off;              # 关键:否则 SSE 流式会被攒成一坨
        proxy_read_timeout 600s;          # 长文本生成可能跑几分钟
        proxy_set_header Connection "";
    }
    location /health { proxy_pass http://vllm_pool; }
}

上面是最简配置,生产环境通常需要更细粒度的策略。下面是一套完整的多模型 / 多实例负载均衡方案,含健康检查、权重轮询和会话保持:

# nginx.conf  生产级负载均衡:最少连接 + 健康检查 + 备节点
upstream vllm_qwen_7b {
    least_conn;    # 按活跃连接数分发,长请求场景优于轮询
    server 192.168.1.10:8000 weight=10 max_fails=3 fail_timeout=60s;  # A100 主力
    server 192.168.1.11:8000 weight=10 max_fails=3 fail_timeout=60s;  # A100 主力
    server 192.168.1.20:8000 weight=5  max_fails=3 fail_timeout=60s;  # 4090 辅助,权重视低
    server 192.168.1.21:8000 backup;       # 备节点:仅当所有主节点不可用时才上
}

upstream vllm_qwen_32b {
    least_conn;
    server 192.168.1.100:8000 weight=1  slow_start=120s;  # 大模型慢启动,分步放量
    server 192.168.1.101:8000 weight=1  slow_start=120s;
}

limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s;  # 单 IP 限流 30 req/s

server {
    listen 80;
    server_name llm-api.internal;

    # 代理缓冲必须在 vLLM 层关闭,SSE 流式响应才能逐字到达客户端
    proxy_buffering off;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # 超时:长文本生成可能需要数分钟
    proxy_read_timeout 600s;
    proxy_send_timeout 60s;

    # 限流
    limit_req zone=api_limit burst=20 nodelay;
    limit_req_status 429;

    # 病态请求限制:请求体不超过 5 MB
    client_max_body_size 5m;

    # 健康检查端点转发
    location /health {
        proxy_pass http://vllm_qwen_7b/health;
        access_log off;  # 健康检查不打日志,减少 I/O 噪声
    }

    # 7B 模型 API(默认路由)
    location /v1/ {
        proxy_pass http://vllm_qwen_7b;
    }

    # 32B 模型 API(通过 URL 前缀路由到不同 upstream)
    location /v1/32b/ {
        rewrite ^/v1/32b/(.*) /v1/$1 break;  # 去掉 /32b 前缀再转发
        proxy_pass http://vllm_qwen_32b;
    }
}

server {
    # 健康检查专用端口,供负载均衡器 / Kubernetes 探针使用
    listen 8080;
    location / {
        return 200 "OK\n";
        add_header Content-Type text/plain;
    }
}
权重轮询的陷阱:同一台机器上的多个 vLLM 副本共享 GPU 算力,给它们均分权重不一定等于吞吐翻倍。必须分别压测每个副本的饱和吞吐,再按实际能力分配 weight。不要想当然地写相同权重。

上线前的检查清单,逐条打勾再放量:

  1. 健康检查与就绪探针接入,启动宽限期覆盖权重加载耗时。
  2. 开启 --api-key,网关层再加一道限流,避免单用户打满集群。
  3. 客户端设置超时与重试上限,重试要带退避,否则雪崩时会自我加压。
  4. 指标接入告警:num_requests_waiting 持续升高、gpu_cache_usage_perc 长期贴顶都要报警。
  5. 准备降级方案:主模型不可用时切到量化小模型或缓存答案。
  6. 过一遍 安全与对齐 的防护清单,输入输出都要有过滤层。

动手练习

三个练习按难度递进,每个都有明确验收标准。建议至少完成前两个,第三个在有 24GB 及以上显存时做。

练习一:起一个 7B 服务并完成冒烟验证

vllm serve 或 docker 启动 Qwen2.5-7B-Instruct(显存不足可换 Qwen2.5-1.5B-Instruct),开启 --api-key,然后用 OpenAI SDK 跑通同步与流式两种调用。

  • 验收 1curl /health 返回 200,/v1/models 中能看到你设置的 served-model-name
  • 验收 2:流式脚本打印出 TTFT 与解码速度,单请求 TTFT 低于 1500 毫秒。
  • 验收 3:故意把 --max-model-len 调到显存放不下的值,复现启动报错并写下你的排查结论。

练习二:并发扫描找吞吐拐点

用上面的 bench_concurrent.py,把 CONCURRENCY 依次设为 1、4、8、16、32、64,每档跑 64 个请求,记录输出吞吐与 TTFT p95。用下面的脚本自动跑一轮:

# sweep.sh  并发扫描,输出可直接贴进表格
for c in 1 4 8 16 32 64; do
  echo "=== concurrency=$c ==="
  CONC=$c python - <<'PY'
import os, runpy
# 复用 bench_concurrent.py,只覆盖并发数与总量
src = open("bench_concurrent.py").read()
src = src.replace("CONCURRENCY = 32", f"CONCURRENCY = {os.environ['CONC']}")
src = src.replace("TOTAL       = 128", "TOTAL       = 64")
exec(compile(src, "bench", "exec"), {"__name__": "__main__"})
PY
  sleep 5   # 让服务把上一轮的块回收干净
done
  • 验收 1:产出一张六行的数据表,含并发数、输出吞吐、TTFT p50/p95、端到端 p95。
  • 验收 2:指出吞吐增长开始变平的那一档(拐点),并给出你推荐的 --max-num-seqs 取值及理由。
  • 验收 3:在压测过程中并行采集 /metrics,用 num_requests_waitinggpu_cache_usage_perc 佐证你的拐点判断。

练习三:量化前后三维对比

拉一份 Qwen2.5-7B-Instruct-AWQ(或自己用 autoawq 量化),与 bf16 原版在同一台机器上分别起服务,用同一份压测脚本与同一份 50 条业务问题集做对比。

  • 验收 1:记录两者的显存占用(nvidia-smi)、输出吞吐、TTFT p95,量化版显存降幅应达到 50% 以上。
  • 验收 2:用 评估 模块的方法给 50 条回答打分,输出量化前后的分数差,并判断是否在可接受范围(建议阈值:平均分下降不超过 3%)。
  • 验收 3:写一段 200 字的结论,明确回答「这个业务该不该上量化」,理由要引用你自己的数字,不能只写「速度更快」。

练习做完,你就拥有了一份属于自己硬件的性能基线。之后每次换模型、换版本、调参数,都拿它来回归对比,这比任何经验之谈都可靠。

练习四:对比量化方案

在单张 24GB 显卡上用 GPTQ、AWQ、FP8 三种量化方式分别部署 Qwen2.5-7B-Instruct,用同一份 100 条业务问题集跑推理,从显存、速度、准确率三个维度量化对比。

  1. 准备:下载三份量化模型到本地缓存。社区已有现成的 AWQ 和 GPTQ 版本,FP8 版可用 AutoFP8ForCausalLM 校准或直接拉 HuggingFace 上的预量化版。显存不够时优先跑 AWQ 和 GPTQ,FP8 可跳过。
  2. 部署:用 vllm serve 分别起三个服务,端口 8001/8002/8003,记录每个实例的 nvidia-smi 稳态显存。
  3. 推理:用第一节中的 bench_sweep.py,分别在三个端口跑 100 条请求,记录吞吐和 TTFT p95,存到 bench_report.json
  4. 准确率:从 100 条中随机抽 30 条,每条用 评估 模块的评分方法分别打 bf16 原版和各量化版的分数。
# quant_bench.sh  一键跑三轮量化对比
# 要求三份模型已经下载到 ~/.cache/huggingface/hub
# 注意 pip install autoawq auto-gptq 先装好依赖

for quant in awq gptq fp8; do
    case $quant in
        awq)  model="Qwen/Qwen2.5-7B-Instruct-AWQ"; qflag="--quantization awq_marlin"; port=8001;;
        gptq) model="Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4"; qflag="--quantization gptq"; port=8002;;
        fp8)  model="Qwen/Qwen2.5-7B-Instruct-FP8"; qflag=""; port=8003;;
    esac

    echo "=== 启动 $quant 量化服务 ==="
    vllm serve $model $qflag \
      --port $port --gpu-memory-utilization 0.90 \
      --max-model-len 4096 --api-key sk-local-demo &
    sleep 120  # 等模型加载完成

    # 记录显存
    echo "$quant 显存占用:"
    nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits

    # 跑 100 条推理
    python -c "
import runpy, sys
sys.argv = ['bench_sweep.py', '--base-url', 'http://localhost:$port/v1']
runpy.run_module('bench_sweep', run_name='__main__')
"

    # 停服务
    kill %1; wait %1 2>/dev/null; sleep 20

    echo "=== $quant 完成 ==="
done
  • 验收 1:产出三行对比表,含量化方式、显存占用(GiB)、输出吞吐(tok/s)、TTFT p95(ms)。
  • 验收 2:与 bf16 原版对比,AWQ 和 GPTQ 显存降幅应达到 50% 以上,FP8 约 25% 到 30%。
  • 验收 3:准确率对比中,4-bit 量化的平均分降幅不超过 3%。若降幅过大,检查是否在某些数学/代码类题目上集中失分,并结合业务特点判断是否可接受。
  • 验收 4:写一段 200 字的选择建议,明确「什么场景选哪种量化」,引用你实测的数字而非泛泛的「更快更省」。
已复制。