前几篇把"一个人用"的链路走完了:第 3 篇的 llama.cpp 提供了最小推理内核,第 4 篇的 Ollama 在它上面包了一层易用外壳。但一个人用一个模型,和把模型当服务提供给几十上百个用户,是两种工程问题——后者的瓶颈往往不是算力,而是显存怎么分。从本篇起,系列进入生产级推理,主角是 NVIDIA GPU 服务端的事实标准 vLLM。
云原生系列第 10 篇介绍过 vLLM 是什么,本系列接下来两篇讲透为什么与怎么用。本文是《本地大模型部署实战》系列第 5 篇(原理篇):先看传统推理为什么把 60%~80% 的 KV Cache 显存白白浪费掉,再拆解 PagedAttention 如何借操作系统的虚拟内存思路解决它,然后算清收益,讲完写时复制、连续批处理、前缀缓存三个配套机制,最后划清它与 llama.cpp 的边界并装好环境,为下一篇实战篇铺路。
1、连续预分配的浪费
第 2 篇算过:KV Cache 每 token 固定占几十到几百 KB,随上下文线性增长。麻烦在于,HF Transformers 这类传统框架为请求分配 KV 显存时,按的是最大序列长度而不是实际用量——因为注意力要求 KV 在显存里连续存放。请求一来,先按上下文上限划出一整块;实际对话只用到一半甚至十分之一,划出去的也收不回来。
这产生两种浪费。内部碎片:预留 8K 只写 2K,多出的 6K 闲着但借不出去;外部碎片:请求长短不一、来去不定,显存被切碎成不连续的空洞,新请求要一整块连续空间,放不进去就只能拒绝。vLLM 论文(SOSP 2023)实测,这类系统的 KV 显存浪费达 60%~80%。示意图如下。
GPU 显存(按最大长度 8K 预分配:■ 已写入 □ 预留未用)
请求 A [■■■■□□□□] 实际 2K / 预留 8K → 6K 内部碎片
请求 B [■■■■■■□□] 实际 5K / 预留 8K → 3K 内部碎片
请求 C [■■□□□□□□] 实际 1K / 预留 8K → 7K 内部碎片
空闲区 [ 3K 空洞 ][ 8K 空洞 ] → 不连续,凑不出整块 = 外部碎片
解读:三行预留槽位的有效载荷平均只有四分之一,底部两个空洞合计 11K,比任何新请求需要的都多,却因不连续而完全不可用。KV 显存有效利用率只剩 20%~40%,能同时服务的请求数被压死,batch 上不去,GPU 大部分时间在给三五个请求搬权重——decode 是带宽瓶颈,batch 小意味着每步带宽开销只服务了极少 token。
2、虚拟内存的分页类比
PagedAttention 的解法是把操作系统的虚拟内存机制搬进显存。进程从不要求物理内存连续:虚拟地址配一张页表,物理页散在哪里无所谓。vLLM 照此办理——把每个序列的 KV Cache 切成固定大小的块(block,默认 16 个 token),序列眼中自己的 KV 依然逻辑连续,实际存放靠一张块表把逻辑块映射到显存里的物理块池,用到第几块才申请第几块。映射关系如下。
解读:A 的三个物理块散布在池中、互不相邻,完全不影响计算——块表就是显存版的页表。为支持这种寻址,注意力算子被改造成按块表逐块读取、分块计算 KV,这正是"PagedAttention"名字的由来:分页的不只是存储,注意力本身按页工作。浪费上限随之变成每条序列最后一块的空位:2K 上下文最多浪费 15 个 token,约 0.7%,论文口径下整体浪费不到 4%。外部碎片则直接消失——所有空闲块等大,谁要新块,池子里随便挑一个。
3、收益的数学
把第 2 篇的公式拿来重算,收益立刻具体。场景:24 GB 的 4090 跑 Qwen2.5-7B Q4 模型。权重 4.1 GB,框架与激活缓冲留 2 GB,再留 10% 余量,KV 预算约 16 GB;该模型 GQA(Grouped-Query Attention)4 个 KV 头,每 token KV 为 56 KB。
传统预留式:上下文上限设 8K,每请求预分配 56 KB × 8192 ≈ 0.44 GB,16 GB 只能开 36 个槽位。假设会话实际平均 2K token,每条真实 KV 约 0.11 GB,36 条实际只占 4 GB——KV 池利用率 25%,正落在论文的浪费区间里。
分页式:不预留,按实际用量逐块分配,16 GB ÷ 0.11 GB ≈ 140 路并发,接近原来的 4 倍。换个花法也行:维持 36 路,把平均上下文从 2K 放宽到 8K,长尾请求还能在池子有余量时冲 32K。同一张卡、同一个模型、同一个公式,KV 池的可用容量从 25% 提到 96% 以上,扣掉块表与调度预留,显存有效利用率落在 85%~92%——这正是云原生系列调研给出的生产区间。
并发翻几倍为什么等于吞吐翻几倍?decode 每步都要把全部权重搬一遍,batch=3 与 batch=100 的搬运成本相同,服务 100 路等于把带宽成本摊薄了 30 多倍。论文实测:吞吐对比当时的 FasterTransformer、Orca 提升 2~4 倍,对比 HF Transformers 最高 24 倍。增益的尽头是算力——batch 大到矩阵乘从"喂不饱"变成"算不完",曲线才放缓。
4、写时复制与前缀共享
分页还送了传统框架做不到的能力:物理块可以被多条序列共享。典型场景是并行采样——beam search、n>1 多候选生成(Agent 里"采样多条轨迹再投票"就是它)。同一 prompt 派生的 n 条序列,前缀 KV 逐位相同,物理上只需一份:前缀块引用计数加一,n 张块表指向同一批物理块。
分歧发生在各自 decode 之后:新 token 的 KV 写进新分配的块,各写各的,共享部分只读不动。难点在 beam 分叉——两条 beam 从某块中途分家,共享块还剩空位,一方要往里追加写怎么办?vLLM 用写时复制(copy-on-write):谁要写,先把这块复制一份再写;不写就永远共享。论文里 beam search 场景的显存占用因此最多降约 55%。
对 Agent 开发者还有一层含义。多轮对话的第 k 轮 prompt 完整包含前 k-1 轮历史,若每轮都从头 prefill,成本随轮数平方增长。块共享解决的是"同一时刻、同一请求"的复用;跨请求、跨轮次的复用由第 6 节的机制接管——两者拼起来,才覆盖 Agent 工作负载的全部前缀形态。
5、连续批处理调度
显存问题解决后,第二个瓶颈浮出水面:批怎么组。静态批处理是"一批进、一批出":凑 N 个请求同时开工,全批最长的生成完,整批才结束。短请求早就输出完了,槽位空转到批尾;排队的新请求只能干等下一批。对比如下。
解读:调度粒度从"请求"降到"一步 decode"。每生成一个 token 检查一次:有序列吐出 EOS(End of Sequence)就立刻出队、归还全部块,等待队列的队首立刻插入。这个思路出自 OSDI 2022 的 Orca 论文,vLLM 把它与 PagedAttention 组合——没有分页,中途插入的请求要的是连续大块,碎片让"随时插入"形同虚设;有了分页,腾出的块立刻可用。两个机制互为前提,构成 vLLM 吞吐数量级领先的两个轮子:显存装得多,槽位转得快。
6、跨请求前缀缓存
第三个机制面向最常见的生产负载:成千上万请求带着同一个 system prompt 打进来,它们的前缀 KV 完全相同,逐块重算纯属浪费。vLLM 的前缀缓存给每个 KV 块按 token 内容计算哈希,新请求 prefill 时逐块查表,命中即复用现成物理块——prefill 计算整个跳过,首 token 延迟明显下降。V1 引擎起该功能默认开启(见发布公告),老版本需手动加 --enable-prefix-caching。
这与上下文工程系列第 6、14 篇讲的 API prompt caching 是同一个思想的两层实现:API 层按前缀命中给计费折扣,vLLM 层在自家显存里直接免掉重算。工程结论一致——稳定内容放前缀、可变内容放尾部:system prompt、工具定义、few-shot 样例组成固定前缀,用户输入接在后面,命中率才高。Agent 多轮对话受益最直接:每轮请求完整命中上一轮的历史块,只有新增部分需要计算,长会话越聊越便宜。
7、vLLM 的能力边界
原理篇的最后泼一盆冷水:vLLM 不是万金油,它的设计目标是服务端吞吐,离开这个前提,优势会变成负担。
| 维度 | llama.cpp / Ollama | vLLM |
|---|---|---|
| 目标场景 | 单机单用户、桌面与离线 | 高并发在线服务 |
| 硬件 | CPU 到消费级 GPU 全覆盖 | NVIDIA/AMD GPU 为主 |
| 权重格式 | GGUF 全档位量化 | safetensors 加 AWQ/GPTQ/FP8 |
| 并发定位 | 1~8 路,延迟优先 | 数十到数百路,吞吐优先 |
| 环境开销 | 静态二进制,几十 MB | GB 级 Python 栈与 CUDA 运行时 |
单用户的账要算清楚:batch=1 时分页只省显存、不提速度——decode 的带宽瓶颈依旧,省下的空间不过是塞更长上下文。vLLM 的固定开销也不低:CUDA graph、激活缓冲、调度器常驻,24 GB 卡上这些开销比 llama.cpp 明显;CPU 推理虽有后端但从来不是重点,低端硬件与离线场景仍是 llama.cpp 的地盘。
一句话划界:个人桌面、离线批处理、一台 Mac 搞定一切,用 Ollama;对外提供 API、并发两位数起步、显卡是 A/H/40 系,用 vLLM。两者互补,不是替代。
8、安装环境准备
原理讲完,环境装好,下一篇直接开跑。版本事实先对齐:2026 年 10 月当前稳定版 v0.32.0(10 月 18 日发布);要求 Python 3.11~3.14;预编译轮子覆盖 CUDA 12.8、12.9、13.0;Blackwell 架构(B200/GB200)最低 CUDA 12.8,对应 NVIDIA 驱动不低于 570.26。pip 轮子自带 CUDA 运行时,本机无需安装 CUDA Toolkit,驱动够新就行——这点比源码编译 llama.cpp 省事得多。
官方推荐用 uv 管理环境,完整安装四步。
# 1. 安装 uv
pip install --upgrade uv
# 2. 创建虚拟环境(Python 3.12)
uv venv --python 3.12 --seed
source .venv/bin/activate
# 3. 安装 vLLM,按本机驱动自动选 CUDA 轮子
uv pip install vllm --torch-backend=auto
# 4. 预下载编译好的 kernel,避免首次启动现编译
vllm download-kernels
解读:--torch-backend=auto 探测本机驱动,自动挑选 cu128/cu129/cu130 轮子;要锁死 CUDA 版本就显式传 --torch-backend=cu128。装完用一行命令验证。
python -c "import vllm; print(vllm.__version__)"
输出 0.32.0 即就绪。Docker 镜像、源码编译、各硬件后端的完整矩阵见官方 GPU 安装文档。
9、总结
回顾本篇的原理链条:传统框架按最大长度连续预分配 KV Cache,内部与外部碎片浪费六到八成显存;PagedAttention 借虚拟内存思路把 KV 切成 16 token 的块、靠块表映射按需分配,浪费压到 4% 以内;同显存并发约翻两番,或等比例拉长上下文;写时复制支撑并行采样共享前缀,连续批处理让槽位 token 级进出,前缀缓存让公共 system prompt 只算一次。边界同样清楚:单用户与 CPU 场景仍是 llama.cpp 与 Ollama 的地盘。
留三道练习:
- 练习一:用第 2 篇公式算 Llama 3.1 8B(每 token 128 KB)在 24 GB 卡上的差距——实际会话 4K、预留 8K 时传统方案开几槽?分页后几路?(答案:预算约 16 GB,传统 16 槽,分页 32 路)
- 练习二:块大小为什么不是越小越好?(提示:块越小浪费上限越低,但块表越长,按块寻址的 kernel 开销也越大)
- 练习三:盘点你业务里可复用的前缀——system prompt 与工具定义各占多少 token,挪进稳定前缀后预计省多少 prefill 计算。
对于准备把本地模型变成服务的读者,建议先把本篇环境装好、跑通验证命令;下一篇我们用 vLLM 起一个 OpenAI 兼容服务,把 gpu-memory-utilization、max-model-len 这些参数放到本篇原理的映照下逐个调明白。

