☰
vLLM 深度实战:PagedAttention 显存优化与高吞吐推理部署
2026/10/1 5:51:30 网站建设 项目流程

先说结论:vLLM 不是一个新的模型,也不是训练框架,但今天做大模型推理服务的人,几乎绕不开这个项目。我最早接触它,是线上推理服务的显存总在压测的时候爆掉,一压就 OOM,后来把服务切到 vLLM,同样一套 A100 上,吞吐直接翻了好几倍。这篇文章我从实战视角出发,把 vLLM 从 PagedAttention 这个核心机制,到生产环境的 Docker 部署、调度参数、模型兼容性,整个链路拆开讲一遍。适合两类人看:一类是准备把开源模型接到生产环境、想知道怎么部署的工程同学;另一类是已经用上 vLLM,但遇到显存不稳、调度逻辑搞不清楚、版本兼容问题踩坑的人。

1. 为什么我盯上 vLLM:一个让我“显存总不够”的故事

我在刚开始做大模型服务化的时候,用的是最朴素的推理方式:写一个 Python 服务,接住请求,加载模型,一次一个请求地跑。单请求延迟看着还行,一旦并发上来,问题就大了。有一次评审压测报告,64 路并发连续对话的场景下,显存占用直接拉满,P99 延迟翻了三倍,随后服务开始频繁重启。当时第一反应是“换更大的卡”,但仔细一算,发现显存根本不够用。

1.1 大模型推理,真正吃显存的是什么

很多人把 GPU 显存占用和模型大小画等号,这是最大的误区。模型权重确实是固定吃一大块显存,但更可怕的是生成过程中不断膨胀的 KV Cache(键值缓存)。

用大白话说:模型每生成一个 token,都要依赖之前所有 token 的键和值信息来算注意力。这些键和值会被缓存下来,供后续 token 复用,而且随着生成长度线性增长。我列过一个很直观的估算公式:KV Cache 大小 = 2 × 层数 × KV 头数 × 每头维度 × 2 字节 × 序列长度。

拿一个常见的 7B 模型举例,32 层、8 个 KV 头、每头 128 维、FP16 精度。单 token 的 KV 占用是 2 × 32 × 8 × 128 × 2 字节,等于 128KB。如果上下文长度是 4096,那么一个请求的 KV Cache 就是 0.5GB。听起来还不算太吓人?64 路并发同时开跑,光 KV Cache 就要 32GB,比模型本身的 14GB 还大一倍多。

传统推理框架的做法更浪费:为了不让运行时动态分配显存出问题,通常会按最大序列长度给每个请求预留一块完整空间。一个只聊三句话就结束的请求,也会占满一整个 8192 token 长度的预留块。这种按最大长度预分配的逻辑,在小并发下勉强能用,大并发直接让显存碎片化、闲置率高到离谱。

1.2 vLLM 是什么,以及它适合谁

vLLM 是加州大学伯克利分校等机构开源的一个大模型推理引擎,主打高吞吐、生产可用。它最核心的贡献,就是把操作系统里的虚拟内存思路搬到了 GPU 显存管理上,具体技术叫 PagedAttention。简单理解:传统方案像酒店大堂一排排的大包间,每个客人进来就整间分配;PagedAttention 像是按需按位的储物柜,每个 token 的键值缓存只占一个小格子,不够了随时加格子。

vLLM 还提供 OpenAI 兼容的 HTTP 接口,这意味着你原来写的 Chat Completion 调用代码,几乎不用改就能切到本地私有模型。对开发者来说这是很大的友好性。它很适合在中大型模型的私有化部署、企业内部知识库、客服机器人、智能体服务这些场景里当推理底座。

2. PagedAttention:从操作系统“偷师”的显存管理机制

PagedAttention 是 vLLM 的灵魂,理解它,也就理解了 vLLM 为什么能在同样的 GPU 上跑出几倍吞吐。我第一次看它的设计时,第一反应是“这不就是操作系统的分页存储管理吗?”确实,它把内存管理那一套成熟思想嫁接到了 GPU 上。

2.1 先搞清楚 KV Cache 为什么这么“占地方”

除了上文算过的空间占用,KV Cache 还有一个特性:它是动态增长的。一个请求刚开始的时候,KV Cache 很小,随着对话进行越来越大。不同请求的长度差异可以非常大,有的几十个 token 就结束,有的要反复生成上千 token。

传统方案对这个动态特性的支持很差。要么预留最大空间,导致大部分空间闲置;要么频繁地在显存里搬来搬去,容易产生外部碎片。我见过一些服务在长连接和高并发下,显存明明还有剩余,但因为碎片无法给新请求分配连续空间,最终触发 OOM。这个问题跟传统数据库存储引擎里的页分裂、堆分配问题非常像。

2.2 PagedAttention 的设计原理

PagedAttention 的核心概念很简单:把 KV Cache 切成固定大小的块(Block),每个块里可以放固定数量 token 的键值数据。vLLM 默认一个块放 16 个 token 的 KV 数据。这些块在物理上不一定连续,通过一张块表(Block Table)记录逻辑块到物理块的映射。

推理时,注意力计算需要通过块表找到每一个物理块的位置,然后分段计算。等于说,KV Cache 不再是“一整块内存”,而是一组可以按需申请、按需释放的小内存页。关键的好处是:只需要给请求分配实际用到的块,而不是按最大长度预分配。

我做过一个粗略计算:64 路并发、每路 4096 token,块大小 16 token,大概需要 16000 多个物理块。每个块的大小约 2MB(16 个 token 乘上前面算出的每 token 128KB)。这些块按需申请,请求结束立刻回收,显存使用率可以做到非常接近真实需求。

2.3 它带来的三个实际收益

第一个收益是显存利用率大幅提升。因为没有预分配,短请求不会白白占用长块,显存可以承载更多并发请求。

第二个收益是碎片问题基本消失。因为物理块不要求连续,所以不存在“空了一块连续空间但不够大”的尴尬。就像硬盘上的小文件碎片,分页机制天然无视这种碎片。

第三个收益是支持前缀共享。多轮对话、beam search 这类场景里,多个序列可能有完全相同的系统提示词或公共历史,比如每个请求都带一大段固定的业务提示。PagedAttention 支持多个逻辑序列映射到同一组物理块,共享相同前缀的 KV 数据,通过写时复制(Copy-on-Write)在修改时才分出独立空间。我实测在固定长系统提示词的客服场景里,这功能直接省掉了一大截 prefill 开销。

3. 调度逻辑与连续批处理:高吞吐背后的架构核心

PagedAttention 解决的是显存怎么省的问题,吞吐量提升的另一半功劳来自调度器。vLLM 的调度器设计非常精妙,它借鉴了 Orca 论文里连续批处理的思想,并把调度粒度从“请求级别”细化到了“token 级别”。

3.1 迭代级调度:调度器每一轮都在干什么

很多框架的调度是“请求级别”的:一批请求排队,等这批全部生成完,再调度下一批。这样每个请求实际有大量时间在空等,因为慢的请求拖着整个批次。

vLLM 的迭代级调度(Iteration-level Scheduling)则不同。它的循环在每个解码迭代都会执行一次调度:当前正在推理的请求继续推进;新到的请求如果显存块足够,就插入当前批次做 prefill;某些请求如果已经完成,会立即让出 GPU 资源。

这个过程每迭代都在发生,不是等整批结束。你可以理解为一条高速路上的动态车道:不是等一批车都到终点才放行下一批,而是每过一个路口就放几辆新车进来,同时让已经到终点的车立刻驶出。调度器在每一轮都要做三件事:判断哪些请求能继续跑,哪些需要暂停,哪些新请求能加入。如果显存不够,它还会把某些请求的 KV Cache 换出到 CPU 内存,腾出 GPU 显存给新请求做 prefill。

3.2 连续批处理为什么比静态批处理快

静态批处理时代,吞吐量的单位是“请求/秒”,但有大量请求实际上在等别人跑完。动态批处理虽然能做一定的合并,但粒度还停留在“等待多个请求攒齐”。连续批处理把粒度打到了每一步。

我压测时对比过:同一张 A100 上,朴素逐条推理大概只能跑到 30 token/s 的整卡吞吐,vLLM 开启连续批处理后能跑到 120 token/s 以上,提升主要来自两点:一是 GPU 计算单元在任意时刻几乎都在做有效计算;二是调度器可以随时用新请求填满刚空出来的计算槽位。

这个特性在真实业务里的价值非常明显。比如企业内部问答系统,用户提问长度差距大,回答长度也差异大。没有连续批处理时,整个系统被少数的超长回答拖慢;有了连续批处理,短请求几乎不会感知到长请求的存在。

3.3 前缀缓存、抢占机制与并行策略

前缀缓存(Prefix Caching)是另一个容易被忽略的调度层能力。它会缓存 token 序列前缀的 KV Cache,第二次遇到相同前缀时直接跳过 prefill 计算。如果你经常在处理长文档、固定角色设定,这个功能几乎必开。我见过一个 RAG 场景,所有请求都带相同的基础指令和几段公共资料,开启前缀缓存后首 token 延迟从 2 秒降到 300 毫秒级别。

调度器还需要处理抢占(Preemption)。显存紧张时,它可以选择把某个请求暂时挂起,甚至丢弃它的一部分 KV Cache,之后重算。虽然重算会浪费一点算力,但总比整个服务 OOM 强。这个机制相当于给系统上了一层保险。

并行策略方面,vLLM 通过 tensor-parallel-size 参数支持张量并行,把一个大模型切到多张卡上。MoE 模型还会用到 expert parallelism。部署时这个参数决定了显存的规模上限。7B 模型单卡能跑,70B 模型可能就得 4 卡甚至 8 卡了。

4. 生产级部署实操:Docker 选型、启动参数与接入客户端

理论讲了一堆,总归要落到部署。vLLM 官方提供了 Docker 镜像,大部分团队也是以容器方式运行的。我在部署过程中踩过不少坑,这里最关键的是镜像选型、启动参数和客户端接入方式。

4.1 镜像版本怎么选,镜像里有没有模型

先说一个最容易误解的问题:vllm/vllm-openai镜像里不带任何模型权重。镜像里只有 Python 环境、CUDA 运行库、vLLM 本体和 OpenAI 兼容服务端代码。第一次用的人常以为镜像体积几个 GB 就直接能用,实际上模型必须另外下载,并通过卷挂载方式提供给容器读取。

这也解释了为什么部署文档里总会有-v /models:/models这一行。我的习惯是把模型权重集中放在宿主机/data/models目录下,启动时挂载到容器/models,这样模型更新权重时不需要重新构建镜像。

版本怎么选?原则很简单:跟着你的模型走,别追求最新镜像。如果模型是 Qwen2.5 这种成熟系列,随便挑一个稳定镜像就行;如果模型是刚发布的新架构,多半需要新版本的 vLLM 才支持。有些团队会在私有仓库里维护自己打的标签,我见过有人拿vllm/vllm-openai:v0.27.1这类标签加载 qwen3-embedding-0.6b 的,具体版本环境对不对,要实测过才知道。我的建议是优先用官方发布版本里的稳定线,改镜像标签要慎重,因为标签对应的二进制可能跟模型结构不匹配。

4.2 一条完整的 Docker 启动命令

下面是我在单卡 A100 上部署 Qwen2.5-7B-Instruct 的典型命令:

docker run --runtime nvidia --gpus all --ipc=host \ -v /data/models:/models \ -p 8000:8000 \ vllm/vllm-openai:v0.9.3 \ --model /models/qwen2.5-7b-instruct \ --served-model-name qwen2.5-7b \ --max-model-len 8192 \ --gpu-memory-utilization 0.92 \ --tensor-parallel-size 1 \ --port 8000

这里每个参数都有自己的讲究。--ipc=host很重要,vLLM 在并行时会用到共享内存,不设置的话多进程会莫名崩掉。--gpu-memory-utilization控制 KV Cache 可以占用显存的上限,我一般设 0.85 到 0.92,不会设满。因为 CUDA 上下文、模型权重、激活值都要占显存,设太高反而会在分配 KV 块时触发显存边缘错误。

--max-model-len需要按业务实际情况算。8K 是一个比较稳的默认值,但如果你的业务都是短文本,设成 4K 能省下大量 KV 显存,让并发能力提升一截。反过来如果你要处理长文档问答,这个值要设大,否则超出后服务直接报错。

--tensor-parallel-size决定用几张 GPU 切分模型。单卡就是 1,超过 1 时模型会被切到多张卡上,需要保证多卡显存都有富余。这个参数不能乱改,改完模型结构变化,显存占用和性能表现都会跟着变。

启动之后,日志里会打印模型加载完成、端口监听成功,看到Uvicorn running on http://0.0.0.0:8000就算成功了。此时移除本套模型换别的模型,需要重新走一遍加载流程。

4.3 验证服务:从 curl 到 Chatbox 接入

服务起来后,先用一个简单的 curl 确认接口是否正常:

curl http://localhost:8000/v1/models

返回 JSON 里应该包含你设置的模型名qwen2.5-7b。然后发一个最小请求:

curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen2.5-7b", "messages": [{"role": "user", "content": "你好"}], "max_tokens": 100 }'

如果返回正常,说明推理链路已经通了。我日常习惯直接用 Chatbox 这类桌面客户端配置 OpenAI 兼容地址,填入http://localhost:8000/v1,模型名填qwen2.5-7b,就能像用 ChatGPT 一样和本地模型对话。这个接入方式对团队的测试同学特别友好,不需要写代码就能发请求看效果。

4.4 嵌入模型这类特殊任务怎么部署

不是所有模型都是生成模型。现在很多知识库方案会用到 embedding 模型,比如 qwen3-embedding-0.6b 这种专门算文本向量的模型。用 vLLM 部署它和部署 Chat 模型不一样,必须显式指定任务类型:

docker run --runtime nvidia --gpus all --ipc=host \ -v /data/models:/models \ -p 8000:8000 \ vllm/vllm-openai:合适版本 \ --model /models/qwen3-embedding-0.6b \ --task embedding \ --max-model-len 32768

老版本镜像可能不认识--task embedding参数,所以部署嵌入模型前我习惯先确认镜像版本。vLLM 对 embedding 任务的支持是在后续版本完善的,拿早期镜像硬跑会报unsupported task之类的错误。我自己在部署这个模型时,总喜欢先把--task参数核实一遍,避免起服务后发现接口不支持而浪费时间。另外,embedding 服务暴露的接口是/v1/embeddings,不是/v1/chat/completions,调用端别搞混。

5. 模型兼容性排查:同镜像、不同模型为何报错

部署 vLLM 最烦的问题,不是显存不足,而是镜像版本和模型架构不匹配。模型越新,这个问题越突出。常见的报错包括KeyError、ValueError: Unsupported layer、NotImplementedError,看起来像代码 bug,实际上多半是 vLLM 还没适配这个模型的结构。

5.1 模型结构与任务类型要匹配

首先要确认你部署的是什么类型的模型。大方向上有三类:因果语言模型(Chat/续写)、嵌入模型(Embedding)、多模态模型。它们对 vLLM 版本的支持程度差异很大。

拿 qwen3-embedding-0.6b 来说,它是纯嵌入模型,不能用默认的“生成”模式加载。你如果不加--task embedding,报错几乎是必然的。反过来,如果你拿一个生成模型当嵌入模型用,服务可能起来了,但算出来的向量质量一塌糊涂。

“GLM 系列该用哪个 vLLM 版本”这类问题也常见。这类模型的架构会随版本迭代变化,没有一个固定答案。正确做法是查模型发布方的部署说明,看他们推荐哪个 vLLM 版本范围,而不是自己猜。我在给团队搭环境时会先建一个兼容索引表,把模型名、架构特点、需要的 vLLM 最低版本、已验证过的镜像标签记下来,避免每次换模型都从头试错。

5.2 版本对应关系怎么查

查对应关系有几个靠谱的路径。第一,看模型的 Hugging Face 模型卡,很多模型会直接贴出 vLLM 部署命令和测试过的版本号。第二,看 vLLM 官方 Release Notes,每个版本都会列新增支持的模型和架构。第三,如果你从社区看到一个镜像标签但不确定支持什么,最快的方式是启动后用docker logs看 vLLM 打印的版本信息和模型加载日志。

这里要注意版本号不是按发布时间猜就行的。同理,“最新的镜像”不等于“最适合你的模型”的镜像,有些新版本会改默认行为,反而让旧模型表现异常。

5.3 一个典型排查流程记录

有一次我拿一个稍微旧一点的镜像部署 qwen3-embedding-0.6b,启动时报了一串 TypeError。当时的处理过程可以给大家参考。

第一步,docker logs拿到完整异常堆栈。第二步,从堆栈里找最有关键的报错词,常见的是unsupported、not implemented、unknown task。第三步,对照排查:如果是unknown task,检查有没有加--task embedding;如果是unsupported layer,基本就是 vLLM 版本不够新,需要升级镜像。我当时遇到的问题就是镜像版本不支持嵌入任务,升级到支持--task embedding的版本后,服务正常起来,/v1/embeddings接口也通了。

这个排查过程看起来简单,但很多人一上来就重装环境,折腾半天发现是版本问题。我的经验是先看版本,再调参数,最后才考虑重装。

6. 线上调优与踩坑实录

部署起来只是开始,生产环境能稳定扛住流量才是目的。vLLM 的参数很多,我实际最常调的就几个,调好它们,性能和稳定性基本就有保障了。

6.1 我最常调整的几个关键参数

--max-num-seqs决定了一个批次最多能容纳多少序列,这个值不是越大越好。开太大,调度器会无脑塞请求,GPU 计算行来不及处理,排队延迟反而上升。我的习惯是从 32 起步,压测看显存和延迟曲线再往上调。

--gpu-memory-utilization在显存充足时不宜设太低,否则 KV Cache 空间太小,一旦并发上来就容易频繁抢占,触发换入换出。我一般让模型权重和 KV Cache 大概各占一半,再留一点余量,用nvidia-smi观察实际占用。

--max-model-len跟业务直接挂钩,能小则小。长文本场景 32K 也不是问题,但短对话场景设 8K 就是在浪费显存。这个参数直接影响 KV Cache 的预留空间,值得花时间按业务真实请求长度来压。

--enable-prefix-caching在固定系统提示词、RAG 重复前缀的场景下几乎是必开的。它带来的是 prefill 计算量的减少,首 token 延迟下降非常明显。

6.2 问题速查与解决清单

我把部署和压测中遇到的高频问题整理成一个速查表,每次踩坑回来我都会往里面补一条。

现象可能原因解决方向
显存不足 OOMgpu-memory-utilization 过高降到 0.85 左右,观察 nvidia-smi 实际占用
请求全部排队,延迟飙升max-num-seqs 过大减小并发序列数,观察单迭代耗时
同一提示词重复请求 prefill 很慢未开启前缀缓存加 --enable-prefix-caching
嵌入式模型启动报错未指定任务类型或镜像版本过老加 --task embedding,升级镜像版本
首 token 延迟高但吞吐正常模型未进行预热或前缀缓存缺失先发若干测试请求预热,再压测

这表解决了不少线上问题。特别是部署新模型时,先用这个表过一遍,能省很多时间。

6.3 一些不吐不快的经验

vLLM 给大模型推理带来了质的提升,但它不是万能的。我见过不少团队把问题归结为 vLLM 不稳定,最后发现是调度参数和业务模型不匹配。部署前花一点时间观察真实的请求长度分布、并发形态,再决定参数,远比跑起来后盲目调参高效。

每个模型架构不同,同样的参数在不同模型上的表现可能完全相反。做推理服务,最重要的是监控和验证体系。我现在的标准流程是:先小流量灰度,观察显存曲线、请求延迟分布、KV Cache 占用率,确认稳定再全量切流。这套流程看起来很朴素,但帮我们挡掉了很多次潜在事故。

个人觉得最珍贵的经验是:生产环境里不要追求“最新功能”,要追求“最小意外”。镜像版本、模型版本、启动参数,全部固定成一套标准化配置,下次部署直接复制。把每次例外记录到兼容性表格里,团队协作会顺畅非常多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询