推理挑战
在线推理有三座大山:显存(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,单卡根本放不下。这就是为什么显存管理策略本身就是性能问题,而不只是工程细节。
generate() 按 max_length 预留连续显存,一个实际只生成 80 token 的请求,也可能占着 2048 token 的坑。真实业务里 60% 到 80% 的 KV 显存是被这样浪费掉的。vLLM 核心
vLLM 用两项技术破解上述难题:
- PagedAttention:像操作系统分页一样管理 KV Cache,消除显存碎片,显存利用率大幅提升。
- 连续批处理(Continuous Batching):不再等一个批次凑齐,而是请求完成即补位,GPU 几乎不空转。
PagedAttention 的关键是逻辑连续、物理离散:把 KV Cache 切成固定大小的块(默认 16 个 token 一块),每个请求维护一张「块表」记录自己用了哪些物理块。请求要多少给多少,不预留、不浪费,还能让多个请求共享同一份前缀块。
图 1:KV Cache 从「整段预留」到「分页按需」的转变
上图展示了分页带来的显存效率提升。再深入一层,看逻辑块到物理块的映射过程和关键参数:
图 2: PagedAttention 逻辑到物理的块映射架构与关键参数
连续批处理则解决时间维度的浪费。静态批处理要等整批里最慢的那条生成完才能收工,vLLM 在每一步解码后都重新调度:谁结束了就腾出块,队列里的新请求立刻补进当前批次,GPU 不留空档。
图 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 自带 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
但只选一种量化方式还不足以做决策。量化方案的选择需要在显存、速度、精度之间权衡,不同硬件下结论可能完全相反。下面用同一份脚本在三种量化方式下跑同一个模型,直接出对比数字:
# 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 调小可再省显存")
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 并发的典型对比。数值随硬件与请求分布浮动,但量级关系是稳定的:
图 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 利用率也低 -> 上游请求不够,问题不在推理侧
生产上线
单实例跑通只是第一步。生产环境需要多副本、统一入口、可观测与优雅重启。典型拓扑如下:
图 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;
}
}
weight。不要想当然地写相同权重。上线前的检查清单,逐条打勾再放量:
- 健康检查与就绪探针接入,启动宽限期覆盖权重加载耗时。
- 开启
--api-key,网关层再加一道限流,避免单用户打满集群。 - 客户端设置超时与重试上限,重试要带退避,否则雪崩时会自我加压。
- 指标接入告警:
num_requests_waiting持续升高、gpu_cache_usage_perc长期贴顶都要报警。 - 准备降级方案:主模型不可用时切到量化小模型或缓存答案。
- 过一遍 安全与对齐 的防护清单,输入输出都要有过滤层。
动手练习
三个练习按难度递进,每个都有明确验收标准。建议至少完成前两个,第三个在有 24GB 及以上显存时做。
练习一:起一个 7B 服务并完成冒烟验证
用 vllm serve 或 docker 启动 Qwen2.5-7B-Instruct(显存不足可换 Qwen2.5-1.5B-Instruct),开启 --api-key,然后用 OpenAI SDK 跑通同步与流式两种调用。
- 验收 1:
curl /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_waiting与gpu_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 条业务问题集跑推理,从显存、速度、准确率三个维度量化对比。
- 准备:下载三份量化模型到本地缓存。社区已有现成的 AWQ 和 GPTQ 版本,FP8 版可用
AutoFP8ForCausalLM校准或直接拉 HuggingFace 上的预量化版。显存不够时优先跑 AWQ 和 GPTQ,FP8 可跳过。 - 部署:用
vllm serve分别起三个服务,端口 8001/8002/8003,记录每个实例的nvidia-smi稳态显存。 - 推理:用第一节中的
bench_sweep.py,分别在三个端口跑 100 条请求,记录吞吐和 TTFT p95,存到bench_report.json。 - 准确率:从 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 字的选择建议,明确「什么场景选哪种量化」,引用你实测的数字而非泛泛的「更快更省」。