Skip to Content
LLM Infra 工程实战vLLM:工业级推理引擎深度剖析

第 5 章 vLLM:工业级推理引擎深度剖析

vLLM 目前是开源 LLM 推理引擎中部署最广泛的一个。从 2023 年 UC Berkeley 的一篇论文起步,到现在(截至 2026 年初为 v0.19+),已经是多数公司跑 LLM 服务的默认选择。项目地址:GitHub · 官方文档。这一章讲清楚它为什么快、怎么用、怎么调。

本章操作只在 Linux GPU 服务器执行。 vLLM 不支持 macOS(依赖 CUDA,NVIDIA GPU 通用并行计算平台,PyTorch 等深度学习框架都在它之上跑核),Mac 本地想做接口验证可以用第 6 章的 Ollama 代替,API 形态一致。

5.1 为什么需要专门的推理引擎

用 HuggingFace Transformers(HuggingFace 出品的 Python 模型库,提供统一的模型加载 / 推理 / 训练接口,类似 LLM 领域的 lodash + axios 合体)的 model.generate() 跑推理,能用,但上不了生产。

朴素推理的问题

# 最朴素的推理方式 from transformers import AutoModelForCausalLM, AutoTokenizer model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2-7B-Instruct") tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2-7B-Instruct") inputs = tokenizer("Hello", return_tensors="pt").to("cuda") outputs = model.generate(**inputs, max_new_tokens=100)

看起来很简单,但有三个致命问题:

1. KV Cache 显存浪费严重

KV Cache(Key-Value Cache,自回归生成时把每一层 attention 算出来的 K、V 向量缓存下来,避免每生成一个 token 都重算历史)的概念已在第 3 章细讲,这里只看它在服务化场景下的麻烦。HF generate 为每个请求预分配最大长度的 KV Cache。如果 max_length=4096,即使实际只生成了 50 个 token,也占着 4096 个 token 的显存。对一个 7B 模型,一个请求的 KV Cache 在 4096 长度时约需 1.6 GB 显存。

KV Cache 的容量公式很直接:

KV Cache = 2 (K + V) × num_layers × num_heads × head_dim × seq_len × bytes_per_element

以 Qwen2-7B 为例(28 层 × 28 头 × head_dim 128,FP16):

2 × 28 × 28 × 128 × 4096 × 2 bytes ≈ 1.6 GB

80 GB 的 A100(NVIDIA 上一代旗舰数据中心 GPU,80GB HBM2e 显存)扣掉 14 GB 权重之后,剩下的显存按 1.6 GB/请求算最多同时只能容纳几十个请求。

2. 静态 Batching 吞吐量低

Batching(批处理,把多个请求合并成一次 GPU 计算来摊薄启动开销)有静态和动态两种做法。HF generate 用 static batching(静态批处理):一个 batch 里的所有请求必须等最长的那个生成完,才能开始下一个 batch。短请求被长请求拖累。

Static Batching: 请求 A: ████████████░░░░░░░░ (12 token, 等到 20 token 的位置才释放) 请求 B: ████████████████████ (20 token) 请求 C: ██████░░░░░░░░░░░░░░ (6 token, 浪费 14 个位置的计算) ──────────────────── 所有请求必须等 B 结束

3. 无法高并发

没有请求队列、没有异步调度、没有动态 batch。100 个并发请求打过来,要么 OOM(Out Of Memory,显存耗尽,CUDA kernel 直接抛错),要么排队一个一个处理。

吞吐量差异

实测数据(Llama-3-8B,A100-80GB,输入 256 token,输出 256 token):

方案吞吐量 (requests/s)并发请求数显存利用率
HF generate(batch=1)~2-31~25%
HF generate(batch=8)~8-128~60%
vLLM~40-60256+~90%

vLLM 在高并发场景下的吞吐量是 HF generate 的 5-15 倍

5.2 PagedAttention

PagedAttention(分页注意力):vLLM 的核心创新,把 KV Cache 切成固定大小的「页」(block),按需分配、不要求物理连续,从而把传统连续分配方案 60-80% 的显存浪费压到 4% 以下。思路完全是借操作系统的虚拟内存(virtual memory)和页表(page table)那套搬过来的——前端开发者可以把它类比成浏览器把一个长列表用「虚拟滚动 + 分块缓存」管理起来。论文:Efficient Memory Management for Large Language Model Serving with PagedAttention(Kwon et al., SOSP 2023)。

传统 KV Cache 的问题

KV Cache 存储每一层、每个 attention head(注意力头,多头注意力中并行计算的一个子空间)的 Key 和 Value 向量。随着序列增长,KV Cache 线性增长。

传统方案的做法:为每个请求预分配一块连续的显存,大小等于 max_sequence_length。

GPU 显存: ┌────────────────────────────────────────────────────────┐ │ 请求 A 的 KV Cache [████████░░░░░░░░░░░░] 50% 浪费 │ │ 请求 B 的 KV Cache [██░░░░░░░░░░░░░░░░░░] 90% 浪费 │ │ 请求 C 的 KV Cache [████████████████░░░░] 20% 浪费 │ │ ░░░░░░░░░░░░░░░░░░░ 无法分配新请求(碎片化) │ └────────────────────────────────────────────────────────┘

实际测量:传统方案中 KV Cache 的有效利用率只有 20-40%。超过一半的显存被浪费在”预留但未使用”的空间上。

借鉴操作系统的虚拟内存

PagedAttention 的核心思想:把 KV Cache 拆成固定大小的”页”(block,KV Cache 在物理显存里的最小分配单元,默认存 16 个 token 的 K/V),按需分配,不要求物理连续。

逻辑视图(每个请求看到的连续 KV Cache): 请求 A: [Block 0][Block 1][Block 2][Block 3] 物理视图(GPU 显存中的实际存储,不连续): ┌──────────────────────────────────────────────┐ │ [A-B2] [B-B0] [A-B0] [C-B1] [A-B3] [B-B1] │ │ [C-B0] [A-B1] [Free] [Free] [C-B2] [Free] │ └──────────────────────────────────────────────┘ Block Table(block table,逻辑块 → 物理块的映射表,等价于操作系统的页表 page table): 请求 A: {0→2, 1→7, 2→0, 3→4} 请求 B: {0→1, 1→5} 请求 C: {0→6, 1→3, 2→10}

每个 block 存储固定数量的 token(默认 16 个)的 KV 向量。新 token 生成时追加到最后一个 block,block 满了再分配新 block。

显存利用率提升

PagedAttention 带来的改进:

  • 内部碎片(internal fragmentation,已分配但没用满的空间):只有最后一个 block 可能有未填满的空间,浪费 < 4%(传统方案浪费 60-80%)
  • 外部碎片(external fragmentation,空闲块零散导致放不下大对象):block 是固定大小的,没有外部碎片
  • 有效利用率:从 20-40% 提升到 >96%
  • 并发量:同样显存下能同时处理的请求数增加 2-4 倍

还有一个附带好处:共享前缀的请求可以共享 block。 比如 100 个请求用同一个 system prompt(系统提示词,对话最开始用来定义模型角色 / 规则的那段固定文本),这些请求的 KV Cache 前面部分指向相同的物理 block,只需要一份存储。这就是 Prefix Caching(前缀缓存,把多个请求共享的 prompt 前缀的 KV Cache 复用一份,避免重复 prefill)的基础。

5.3 Continuous Batching

PagedAttention 解决显存问题,Continuous Batching(连续批处理,又叫 in-flight batching / iteration-level batching,每个 decode step 都重新组装一次 batch,请求一完成就立刻让位)解决吞吐量问题。

Static Batching 的浪费

时间 → Static Batch 1: 请求 A: ████████ (done) 请求 B: ████████████████████ (done) 请求 C: ████ (done, 但要等 B) ────────────────────── batch 结束,才能处理新请求 Static Batch 2: 请求 D: ██████████████ (done) 请求 E: ██ (done, 等 D)

请求 C 在第 4 步就生成完了,但 GPU 上它的”座位”空着,直到 B 生成完。

Continuous Batching

请求完成后立刻退出 batch,空出的位置立刻被等待中的新请求填入。

时间 → Step 1: [A][B][C] ← 三个请求同时处理 Step 2: [A][B][C] Step 3: [A][B][C] Step 4: [A][B][D] ← C 完成,D 立刻加入 Step 5: [A][B][D] Step 6: [E][B][D] ← A 完成,E 立刻加入 Step 7: [E][B][D] Step 8: [E][F][D] ← B 完成,F 立刻加入 ...

GPU 始终在满负荷处理请求,没有”等待”的浪费。

实际效果:同样硬件下,Continuous Batching 相比 Static Batching 可以提升 2-5x 的吞吐量。长短请求混合的场景下差距更大。

vLLM 的调度器(scheduler,每一步决定哪些请求进 batch、给谁分配 block、要不要抢占别人的中央组件)在每个 decode step 都会检查:

  1. 有没有请求完成了?释放其 KV Cache block
  2. 有没有等待中的请求?为其分配 block,加入 batch
  3. 当前显存够不够?不够就 preempt(preemption,抢占,OS 调度借词,把一个跑得动的请求强行从 batch 里踢出去让出资源)低优先级请求——把它的 KV Cache swap(swapping,把显存里的 KV Cache 暂时换到 CPU 内存,等需要时再换回来,等价于 OS 的页面换入换出)到 CPU 内存,等显存空出来再换回来继续 decode;如果 CPU 内存也满了,就直接丢弃该请求的 KV Cache,后续重新跑一遍 prefill 重建状态

5.4 部署实战

安装

# 推荐用 pip,需要 CUDA 12.1+ pip install vllm # 验证安装 python -c "import vllm; print(vllm.__version__)"

vLLM 对环境要求:

  • Python 3.9+
  • CUDA 12.1+(推荐 12.4+)
  • GPU 算力 7.0+(V100 及以上;GPU 算力 / compute capability 是 NVIDIA 给每代架构编的版本号,7.0 = Volta,8.0 = Ampere,9.0 = Hopper)
  • 足够的 GPU 显存(模型大小 + KV Cache)

国内模型下载

国内直接从 HuggingFace(HuggingFace Hub,全球最大的开源模型 / 数据集托管平台,相当于 LLM 领域的 npm registry)下载模型速度很慢,推荐两种方案:

方案 1: HF 镜像

export HF_ENDPOINT=https://hf-mirror.com huggingface-cli download Qwen/Qwen2-7B --local-dir ./models/Qwen2-7B

方案 2: ModelScope(阿里出品的国内模型平台,等同国内版 HuggingFace Hub)

pip install modelscope python -c "from modelscope import snapshot_download; snapshot_download('Qwen/Qwen2-7B', cache_dir='./models')"

下载完后启动 vLLM 时指向本地路径:

python -m vllm.entrypoints.openai.api_server --model ./models/Qwen2-7B

单卡部署 Qwen2-7B

# 启动 OpenAI 兼容的 API 服务(OpenAI Compatible API:复刻 OpenAI /v1 端点的协议,让任何 OpenAI SDK 都能直接对接 vLLM) vllm serve Qwen/Qwen2-7B-Instruct \ --host 0.0.0.0 \ --port 8000 \ --max-model-len 4096 \ --gpu-memory-utilization 0.9

模型会自动从 Hugging Face 下载。首次启动需要几分钟,后续启动约 30-60 秒。

启动成功后:

# 测试 curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "Qwen/Qwen2-7B-Instruct", "messages": [{"role": "user", "content": "Hello!"}], "max_tokens": 100 }'

多卡部署:Tensor Parallelism

模型太大放不进单卡?用 tensor parallelism(TP,张量并行,把每一层的权重矩阵按 head 或列切到多卡,每步算完做一次 all-reduce 同步;第 3 章在显存预算里提过 TP,第 11 章会讲实现细节)把模型切到多张卡上。

# 2 卡部署 —— 每张卡装一半模型 vllm serve Qwen/Qwen2-72B-Instruct \ --tensor-parallel-size 2 \ --max-model-len 4096 \ --gpu-memory-utilization 0.9 # 4 卡部署 vllm serve Qwen/Qwen2-72B-Instruct \ --tensor-parallel-size 4 \ --max-model-len 8192 # 跨机器部署(8 卡 × 2 机器) # TP=8(每机器内),PP=2(跨机器) vllm serve deepseek-ai/DeepSeek-V3 \ --tensor-parallel-size 8 \ --pipeline-parallel-size 2

Tensor Parallelism 的经验法则:

  • tensor-parallel-size 必须能整除模型的 attention head 数
  • 通常设为单机 GPU 数量(2/4/8)
  • 跨机器用 Pipeline Parallelism(PP,流水线并行,按层把模型切到多机),因为 TP 需要高带宽的 NVLink

补一个 TP 和 PP 的区别:TP 在同一层内把权重按 head 或 column 切到多卡,每一步都要做 all-reduce(集合通信原语,多卡把各自的部分结果汇总成全局结果再分发回去)同步中间结果,对卡间带宽要求高(所以一般只在 NVLink 同机内用);PP 是按切——前 N 层放机器 A,后 M 层放机器 B,机器之间像流水线一样传递激活值(activation,神经网络一层的中间输出),每步只有一次小数据量的点对点通信,跨机器跑得动。

关键启动参数

vllm serve <model> \ # 基础配置 --host 0.0.0.0 \ --port 8000 \ --served-model-name my-model \ # API 中的模型名称 --api-key sk-xxx \ # 设置 API key # 性能参数 --max-model-len 8192 \ # 最大上下文长度 --gpu-memory-utilization 0.90 \ # GPU 显存利用率上限 --max-num-seqs 256 \ # 最大并发请求数 --max-num-batched-tokens 8192 \ # 一次 prefill 的最大 token 数 # 量化 --quantization awq \ # 使用 AWQ(Activation-aware Weight Quantization,激活感知权重量化,主流 4bit 量化算法之一,第 7 章细讲)量化模型 --dtype auto \ # 数据类型,auto 会自动选择 # 并行 --tensor-parallel-size 2 \ # Tensor 并行度 --pipeline-parallel-size 1 \ # Pipeline 并行度 # 高级优化 --enable-prefix-caching \ # 开启 Prefix Caching --enable-chunked-prefill # 开启 Chunked Prefill(把长 prefill 切片,和 decode 交织执行)

5.5 性能调优

gpu-memory-utilization

控制 vLLM 使用多少比例的 GPU 显存。默认 0.9(90%)。

GPU 显存分配: ┌──────────────────────────────────────┐ │ 模型权重 (固定) ~40% │ ├──────────────────────────────────────┤ │ KV Cache (动态) ~50% │ ← gpu-memory-utilization 控制的部分 ├──────────────────────────────────────┤ │ 预留 (CUDA context + 碎片) ~10% │ ← CUDA context: 每个进程绑定到 GPU 上的运行时上下文,存放 stream、module 等 └──────────────────────────────────────┘
  • 0.9:默认值,适合大多数场景
  • 0.95:激进,显存紧张时可以尝试,但可能 OOM
  • 0.7-0.8:保守,适合同一张卡还要跑其他任务时

KV Cache 越大 → 能同时处理的请求越多 → 吞吐量越高。

max-model-len

限制模型能处理的最大序列长度(输入 + 输出)。

# 模型原始支持 32768,但实际业务用不到那么长 # 降低 max-model-len 可以减少 KV Cache 预分配,腾出显存给更多并发 vllm serve Qwen/Qwen2-7B-Instruct --max-model-len 4096

设置策略:

  • 分析实际请求的 token 长度分布,取 P99(P99 延迟 / 长度,统计学的 99 百分位,意思是 99% 的请求都小于这个值)作为 max-model-len
  • 比如 99% 的请求都在 4096 token 以内,就设 4096 而不是默认的 32768
  • 减少 max-model-len 从 32K 到 4K,同等显存下并发量可以提升 4-8 倍

Prefix Caching

多个请求共享相同前缀时(相同的 system prompt),可以复用 KV Cache。

vllm serve Qwen/Qwen2-7B-Instruct --enable-prefix-caching

典型场景:

  • 所有请求带同一个 system prompt(Agent 场景很常见)
  • 多轮对话中前面的轮次相同
  • RAG(检索增强生成,先从知识库捞相关文档再喂给模型,已在 ch01 介绍)场景中多个请求查询相同的文档片段

效果:对于有长 system prompt 的 Agent 应用,Prefix Caching 可以降低 TTFT 30-70%。

TTFT(Time to First Token):从客户端发出请求到收到第一个输出 token 的时间,等于一次完整的 prefill 耗时 + 调度排队。它决定用户感知的”响应速度”,流式输出场景下尤其关键。第 8 章会一起把 TTFT、TPOT、TPS 三个指标讲完。

Chunked Prefill

Chunked Prefill(分块预填充):把长 prompt 的 prefill 拆成多个小块(chunk),和其他请求的 decode 交替执行,避免一个超长 prefill 阻塞其他请求的 decode。

vllm serve Qwen/Qwen2-7B-Instruct --enable-chunked-prefill

每个 chunk 的大小由 --max-num-batched-tokens 控制(默认 8192)。一个超过这个值的 prefill 会被切成多步,每步和当前 batch 里其他请求的 decode 交织执行。切分不会破坏 KV Cache 的完整性:每个 chunk 算完都会把结果追加到对应请求的 block table 上,下一个 chunk 进来时能直接看到前面已经计算好的 K/V,不需要重做。

在混合长短请求的场景下,Chunked Prefill 显著改善短请求的延迟,避免被长请求”卡住”。

5.6 OpenAI Compatible API

vLLM 提供完全兼容 OpenAI API 的接口,这意味着你已有的代码几乎不用改。

支持的接口

POST /v1/chat/completions ← 对话补全 POST /v1/completions ← 文本补全 POST /v1/embeddings ← 文本嵌入 GET /v1/models ← 模型列表

直接替换 base_url

from openai import OpenAI # OpenAI 官方 Python SDK,等价于 npm 包 openai 的 Python 版 # 原来调 OpenAI # client = OpenAI(api_key="sk-xxx") # 改成调 vLLM,只需要改 base_url client = OpenAI( base_url="http://localhost:8000/v1", api_key="not-needed", # vLLM 默认不需要 key ) response = client.chat.completions.create( model="Qwen/Qwen2-7B-Instruct", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Explain PagedAttention in 3 sentences."}, ], temperature=0.7, # temperature: 采样温度,越高输出越发散,0 等同贪心解码 max_tokens=200, stream=True, ) for chunk in response: content = chunk.choices[0].delta.content if content: print(content, end="", flush=True)

对接 Agent 应用

对于用 OpenAI SDK 构建的 Agent 应用,切换到 vLLM 只需要:

// TypeScript / Node.js import OpenAI from 'openai'; const client = new OpenAI({ baseURL: 'http://your-vllm-server:8000/v1', apiKey: 'not-needed', }); // 后面的代码完全不用改 const response = await client.chat.completions.create({ model: 'Qwen/Qwen2-7B-Instruct', messages: [{ role: 'user', content: 'Hello' }], });

vLLM 支持的 OpenAI 兼容特性:

  • stream: true — Streaming(流式)输出,token 一边生成一边通过 SSE 推回客户端
  • tools — 函数调用 / 工具调用
  • response_format: { type: "json_object" } — JSON 模式(强制输出合法 JSON 的约束解码模式)
  • logprobs — 返回 token 的 log 概率(每个候选 token 的对数概率值,常用于调试和评估)
  • n — 一次生成多个回复
  • stop — 自定义停止词

不兼容的地方:

  • seed 参数(随机种子,理论上锁定后输出可复现)在分布式推理时不保证完全确定性
  • 部分模型的 tool calling 格式可能与 OpenAI 有细微差异
  • 不支持 OpenAI 特有的 gpt-4-vision-preview 等模型名

Tool Calling

Tool Calling(工具调用 / 函数调用,模型按约定 schema 输出一段 JSON,外部代码据此调用真实工具——Agent 系统能跑起来的基础)是 Agent 工程师最关心的功能。vLLM 支持 OpenAI 格式的 tool calling,前提是模型本身支持(Qwen2、Llama 3.1+ 等):

from openai import OpenAI client = OpenAI(base_url="http://localhost:8000/v1", api_key="na") tools = [ { "type": "function", "function": { "name": "get_weather", "description": "获取指定城市的天气信息", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称"}, }, "required": ["city"], }, }, } ] response = client.chat.completions.create( model="Qwen/Qwen2-7B-Instruct", messages=[{"role": "user", "content": "北京今天天气怎么样?"}], tools=tools, tool_choice="auto", ) # 模型会返回 tool_calls message = response.choices[0].message if message.tool_calls: call = message.tool_calls[0] print(f"调用工具: {call.function.name}") print(f"参数: {call.function.arguments}")

启动 vLLM 时需要指定 tool calling 模式:

python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2-7B-Instruct \ --enable-auto-tool-choice \ --tool-call-parser hermes

--tool-call-parser 的选择取决于模型——hermes 指 NousResearch Hermes 格式(Qwen2 默认采用这套),llama3_json 对应 Meta Llama 3.1 的格式。完整支持列表见 vLLM Tool Calling 文档


本章小结

vLLM 通过 PagedAttention 解决显存碎片问题,通过 Continuous Batching 解决吞吐量问题,通过 OpenAI 兼容 API 解决接入成本问题。这三个特性是它成为行业标准的关键。生产部署时,最重要的调优参数是 max-model-len(根据实际业务设置)和 enable-prefix-caching(Agent 场景必开)。

示例代码:examples/ch05-vllm/


本章来自《LLM Infra 从入门到实践》开源版 · 作者「递归客」
在线阅读完整书系:inferloop.dev
源码仓库:github.com/diguike/book-llm-infra

本书资源

继续阅读 · 同作者其他书

Last updated on