vLLM 部署高并发推理:PagedAttention 原理与生产实战
vLLM 是目前生产环境使用最广泛的开源推理框架,核心优势是 PagedAttention 技术——把 KV Cache 管理方式从”预分配连续显存”改为”分页动态分配”,使同等显存下并发吞吐量提升 2–5 倍。如果你需要部署一个能支撑真实用户访问的推理服务,vLLM 是当前的首选。
PagedAttention:为什么 vLLM 吞吐量更高
传统推理框架为每个请求预分配一块连续显存来存放 KV Cache,问题在于:
- 请求实际长度不确定,必须按最大长度预留,造成大量内部碎片
- 不同请求的 KV Cache 无法共享,前缀重复(如 system prompt)的显存被重复占用
PagedAttention 借鉴操作系统的虚拟内存分页思想,将 KV Cache 切成固定大小的 block(通常 16 token/block),按需动态分配,并支持多请求共享相同前缀的 block。结果是:
| 指标 | 传统框架 | vLLM (PagedAttention) |
|---|---|---|
| KV Cache 利用率 | 60–80% | 95%+ |
| 同等显存并发数 | 基准 | 提升 2–5× |
| 前缀共享支持 | 否 | 是(减少重复计算) |
| 显存碎片化 | 较严重 | 极低 |
这个机制的底层实现,其实跟你电脑里操作系统管内存的思路一模一样,理解了这层就知道为什么它能同时省显存又提吞吐。vLLM 内部维护一张 block table,每个请求的 KV Cache 不再是一块连续显存,而是若干个物理 block 的指针集合,跟虚拟内存的页表是同一个套路。请求生成新 token 时按需申请一个新 block,而不是一开始就按 max-model-len 预留满;请求提前结束(比如遇到 EOS 提前退出),剩下没用到的 block 直接释放回显存池,不会像传统方式那样”预留了就锁死”。
前缀共享这块更值得说一说,因为很多人第一次听到”KV Cache 还能共享”会觉得不可思议——KV Cache 不是跟每个请求的具体输入强相关吗?答案是:只要前缀 token 完全相同(比如你的 system prompt 固定不变,或者 few-shot 示例是模板化的),这部分前缀对应的 attention key/value 数值就是完全一样的,vLLM 用类似 copy-on-write 的方式让多个请求的 block table 指向同一批物理 block,只有当某个请求要在这段前缀之后继续生成分叉内容时,才会触发实际的 block 复制。这也是为什么开了 --enable-prefix-caching 之后,如果你的业务场景是”固定 system prompt + 用户输入频繁变化”(客服机器人、Agent 工具调用这类场景最典型),吞吐量能再涨一截,因为省下来的不只是显存,还有这部分 prefill 阶段的重复计算量。
安装与快速启动
安装(推荐用 pip,需要 CUDA 12.x + Python 3.9+):
pip install vllm
这一步看着简单,但实际踩坑的人不少,提前说清楚能少走弯路。vLLM 对 PyTorch 版本、CUDA 版本、甚至 Python 小版本都有比较严格的对应关系,pip install vllm 拉下来的依赖里通常会带一个特定编译好的 torch 版本,如果你的环境里已经装了别的 torch(比如为了跑训练装的),直接装 vLLM 很可能把已有的 torch 覆盖掉,导致你原来能跑的训练脚本突然报 CUDA 版本不匹配。稳妥的做法是给 vLLM 单独开一个 conda 或 venv 环境,不要跟训练环境混用。另外国内环境装的时候经常因为要从 GitHub 拉编译好的 wheel 而卡住或超时,可以加 -i 换成清华源,或者直接用官方 Docker 镜像 vllm/vllm-openai:latest——生产环境我更建议走 Docker,版本锁定、跟宿主机驱动隔离,出问题回滚也方便,pip install 更适合本地先跑通验证。
启动 OpenAI 兼容 API 服务器(以 Qwen2.5-7B 为例):
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-7B-Instruct \
--served-model-name qwen2.5-7b \
--port 8000 \
--tensor-parallel-size 1 # 多卡时改为卡数
启动后即可用标准 OpenAI SDK 访问:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="token-abc")
resp = client.chat.completions.create(
model="qwen2.5-7b",
messages=[{"role": "user", "content": "你好"}],
)
print(resp.choices[0].message.content)
关键部署参数
| 参数 | 作用 | 建议值 |
|---|---|---|
--tensor-parallel-size | 多卡张量并行,等于使用的 GPU 数 | 单卡填 1,多卡等于卡数 |
--gpu-memory-utilization | vLLM 使用显存比例 | 0.90(留 10% 给框架开销) |
--max-model-len | 最大上下文长度,影响 KV Cache 显存上限 | 按实际业务需求设,别盲目拉满 |
--max-num-seqs | 最大并行请求数(批处理窗口) | 默认 256,显存不足时降低 |
--quantization | 量化方式:awq / gptq / fp8 | 显存紧张时用 awq |
--enable-prefix-caching | 开启前缀 KV Cache 共享 | 有固定 system prompt 时必开 |
这几个参数里最容易被误解的是 --gpu-memory-utilization 和 --max-num-seqs 的关系,很多人以为调大 max-num-seqs 就能提升并发,结果发现服务直接启动失败或者跑着跑着就 OOM。真实的逻辑是:gpu-memory-utilization 决定了 vLLM 总共能用多少显存来存权重 + KV Cache,max-model-len 决定单条请求最坏情况下 KV Cache 能占多大,两者一乘再除以单个 block 的大小,才算出实际能撑得住的并发上限——max-num-seqs 只是一个”批处理窗口的软上限”,如果显存算下来撑不住这么多并发,vLLM 会自动排队等待,不会真的硬撑到把显存打爆(除非你把 gpu-memory-utilization 设得离谱地高,把系统本身需要的显存也挤占了)。所以调优的正确顺序是:先按业务场景估算 max-model-len 到底需要多长(客服问答可能 4K 够用,长文档摘要可能要 32K),再看显存卡型号定 gpu-memory-utilization,最后 max-num-seqs 基本可以交给 vLLM 自己去动态调度,不用死磕这个数字。
量化部署:显存不够怎么办
如果你的 GPU 显存无法加载 FP16 权重,可以配合量化模型:
# AWQ 量化模型(显存约为 FP16 的 1/2)
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-7B-Instruct-AWQ \
--quantization awq \
--port 8000
AWQ 量化对精度影响最小,是 vLLM 场景的首推方案。GPTQ 也支持但推理速度略慢。量化选型详见 模型量化 GGUF/AWQ/GPTQ 怎么选。
高并发实战:流式、异步、重试退避
生产环境跟你本地跑 demo 最大的区别,是要面对大量并发用户同时在线、网络抖动、下游偶发超时这些真实情况,光跑通一个 chat.completions.create 远远不够。
流式输出是几乎所有面向用户的场景都该开的,不然用户要盯着空白等模型把整段话生成完才能看到第一个字,体验很差:
stream = client.chat.completions.create(
model="qwen2.5-7b",
messages=[{"role": "user", "content": "写一段产品介绍"}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
后端要真正扛住并发,光靠同步客户端一个个发请求效率很低,尤其是你的应用本身也是 Web 服务时,同步阻塞会把整个进程的吞吐拖垮。用异步客户端配合 asyncio.gather 批量发请求,才能把 vLLM 服务端的批处理能力真正吃满:
import asyncio
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="token-abc")
async def ask(prompt: str):
resp = await client.chat.completions.create(
model="qwen2.5-7b",
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content
async def main():
prompts = ["解释一下光合作用", "写一首关于秋天的短诗", "帮我列个购物清单"]
results = await asyncio.gather(*(ask(p) for p in prompts))
for r in results:
print(r)
asyncio.run(main())
网络抖动、GPU 瞬时打满导致的偶发超时不可避免,直接扔给用户看到一个报错很粗糙,正确做法是加指数退避重试,并且给重试设上限,避免雪崩式重试把服务打得更死:
import time
import random
from openai import APIConnectionError, APITimeoutError
def ask_with_retry(client, prompt, max_retries=3):
for attempt in range(max_retries):
try:
resp = client.chat.completions.create(
model="qwen2.5-7b",
messages=[{"role": "user", "content": prompt}],
timeout=30,
)
return resp.choices[0].message.content
except (APIConnectionError, APITimeoutError):
if attempt == max_retries - 1:
raise
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(wait)
这里 timeout=30 也是个容易被忽略的细节——默认的客户端超时时间往往偏长(甚至几分钟),一旦服务端排队严重,用户请求会一直卡在那里占着连接不释放,反而加剧拥堵。生产环境应该按你的业务场景(比如客服场景用户可接受等待通常不超过 15-30 秒)明确设置超时,超时了就快速失败走重试或降级,而不是死等。
真实报错排查:从报错文案定位根因
下面这几条是自己部署 vLLM 大概率会遇到的报错,直接给你报错文案和根因,省得你对着日志猜半天。
ValueError: The model's max seq len (32768) is larger than the maximum number of tokens that can be stored in KV cache (16384). Try increasing gpu_memory_utilization or decreasing max_model_len.
这是启动阶段就会挂掉的报错,说明你设置的 --max-model-len 超过了当前显存 + gpu-memory-utilization 配置下 KV Cache 能容纳的 token 总量。两条路:要么把 --gpu-memory-utilization 调高一点(前提是显存还有余量),要么老老实实把 --max-model-len 降下来,别设成模型支持的最大值,按你业务实际用到的长度来。
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate ...
运行时才炸的 OOM,通常发生在并发突然上涨、或者某个请求带了超长上下文的时候。先看是不是 --max-num-seqs 设太高导致理论峰值超了显存,其次检查有没有单条超长请求把 KV Cache 一下吃满,实在扛不住就换量化模型或者加卡。
服务启动后请求一直 Connection refused 或者 requests.exceptions.ConnectionError
不是端口没开,是模型还在加载。7B 模型加载权重 + 编译 CUDA kernel 通常要几十秒到几分钟,--served-model-name 打印出启动日志之前请求过去自然连不上,加一个健康检查轮询等它真正就绪再打流量。
返回 400,提示上下文超长(类似 This model's maximum context length is 8192 tokens)
说明 messages 拼起来的 token 数超过了 --max-model-len。要么在应用层做历史消息截断/摘要压缩,要么评估是否真的需要更长上下文,盲目调大 max-model-len 会直接拉高显存开销,不是免费的。
vLLM、TGI、SGLang 怎么选
同类推理框架不止 vLLM 一个,选型前列个表看清楚各自的定位:
| 框架 | 核心优势 | 适合场景 |
|---|---|---|
| vLLM | PagedAttention 显存利用率高,社区生态最大,OpenAI 兼容做得最完善 | 通用生产部署首选,尤其是需要快速对接现有 OpenAI SDK 代码的场景 |
| TGI(Hugging Face) | 与 HF 生态(Transformers/模型 Hub)集成最紧密 | 团队已深度绑定 HF 工具链、需要用小众模型架构时 |
| SGLang | 结构化生成(JSON/正则约束输出)和多轮对话前缀复用做得更细 | Agent 工具调用、需要严格 JSON 格式输出的场景 |
没有绝对的”最优”,如果你不确定选什么,vLLM 是风险最低的默认选择——生态最成熟、遇到问题网上能搜到的案例最多,出问题不至于叫天天不应。
自建 vs 调 API:什么时候值得自己搭
自己搭一套 vLLM 服务不是没有成本的:GPU 服务器本身的费用、显卡驱动和依赖版本的维护、模型更新迭代要自己跟进、还有半夜服务挂了要有人起来处理。这些隐性成本经常被”开源免费”四个字掩盖掉。一个简单的判断依据:如果你的请求量还没有稳定到能把一张卡的显存和算力打满(比如日均调用量不到几万次),先用 API 按量付费往往比自建更划算,因为自建的固定成本(服务器 + 运维人力)不会因为你调用量低就变少;等业务量真正稳定增长到自建能摊薄成本的规模,再迁移到自己的 vLLM 集群也不迟。不想一开始就折腾服务器和显卡采购的,可以直接 加入候补,直接调用 API。
监控与健康检查
上生产前至少把这两个端点接起来,不然服务出问题只能靠用户投诉才知道。vLLM 自带 /health 端点,负载均衡器和 K8s 的存活探针直接打这个接口就行;/metrics 端点暴露 Prometheus 格式的指标,包括排队请求数、KV Cache 占用率、平均生成速度这些关键数据,接入 Grafana 之后能实时看到显存是不是快撑满了、请求是不是开始排队变多,这些信号往往比等到 OOM 报错才发现问题要提前得多。日常巡检也别忘了 nvidia-smi dmon 盯一眼 GPU 利用率和显存曲线,如果利用率长期上不去但延迟又很高,大概率是批处理没吃满,回头检查 max-num-seqs 和实际并发量是不是没对上。
常见问题
vLLM 和 Ollama 有什么本质区别?
Ollama 主打”一行命令本地跑模型”,适合开发者个人测试,不针对并发优化。vLLM 专注生产级高并发推理,有完善的批处理调度和 KV Cache 管理,两者受众不同。详见 Ollama 本地快速跑模型。
vLLM 部署后报 CUDA OOM 怎么处理?
先降低 --gpu-memory-utilization 到 0.85,再降低 --max-model-len,最后考虑换用量化模型或加卡。OOM 通常是 KV Cache 撑爆显存,不是权重问题。
多卡张量并行效果怎么样?
权重显存近似线性叠加,吞吐量通常达到单卡的 1.5–2× 每卡(受卡间通信开销影响)。NVLink 互联的多卡效率远好于 PCIe 互联。
vLLM 支持哪些模型架构?
Llama 系列、Qwen 系列、Mistral、Gemma、Phi、ChatGLM 等主流开源架构均支持,详见 vLLM 官方文档的 Supported Models 列表。
延伸阅读:
- 算力基础全景:大模型算力基础指南
- 量化方案对比:模型量化 GGUF/AWQ/GPTQ 怎么选
- 本地开发测试:Ollama 本地快速跑模型
- 算力专题 Hub:算力专题
- 不想自己运维 GPU 服务器?加入候补,直接调用 API