ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

vLLM推理引擎实战:用PagedAttention与Continuous Batching破解大模型显存与并发瓶颈

vLLM推理引擎实战:用PagedAttention与Continuous Batching破解大模型显存与并发瓶颈

大概去年开始,身边越来越多做RAG、Agent的朋友把模型部署从Transformers换到了vLLM,说实话一开始我是不太理解的,因为本地推理直接拿HuggingFace的pipeline几行代码就搞定了,为什么要引入一个新框架?直到我自己在某个小项目里要把一个7B模型同时服务给几个内部工具调用,才发现问题比想象中大得多:并发一上来显存就爆,推理速度掉得离谱,CUDA OOM的报错看得人头皮发麻。后来花了两天时间系统地把vLLM的文档翻了一遍,再用它重新做了部署,效果几乎是立竿见影——同样的单卡,从一次只能处理一两个请求,变成能稳定扛住几十个并发,延迟还降了不少。

这篇文章就把我从原理到落地跑通的完整经验整理出来,里面既有PagedAttention和Continuous Batching这些核心机制的白话解读,也有模型部署、参数调优、量化加载、常见报错排查这些实战操作。主要面向技术团队的部署工程师、做RAG应用的后端开发,以及准备把大模型推向产出的算法工程师。哪怕你之前完全没用过vLLM,按着文章里的思路和命令走一遍,也能跑起一个能扛住真实业务压力的推理服务。

1. 整体设计与思路拆解:为什么需要vLLM

1.1 传统推理方案的瓶颈在哪里

先来理解一个被很多人忽略的事实:大模型推理慢、显存不够,很多时候不是算力不行,而是根本没有把资源用好。Transformers默认的generate实现方式是:接到一个请求后,为这个请求分配完整的KV Cache空间,然后独占资源一轮一轮地生成token,直到生成结束才释放。这种方式的致命问题在于分配是“一次给够”的,而实际使用是“按需增长”的,两者之间产生了大量浪费。

打个比方,你开了一家餐厅,每个客人一进门就分配一整层楼,但大多数客人只坐一张桌子就走了。Transformer推理也有同样的现象:Request进来时,系统按照最大序列长度一次性申请KV Cache,但实际生成多少token完全不确定,块内空白区域全被浪费了。更糟的是,多个并发请求同时进来时,每个请求都要独占一整份显存预算,服务端被迫用排队来限制并发数,吞吐自然上不去。

延迟和吞吐本来是跷跷板,但传统方案因为显存碎片化的存在,把两者都拖垮了。显存碎片化是另一个隐蔽的问题——不同请求的KV Cache大小不同,生成过程中动态增长,导致显存里出现大量不连续的空洞,新请求来了发现连续空间不够,即便总剩余显存还很多,也只能报OOM。这类问题在长序列、多轮对话、流式输出这些场景下尤其明显。

1.2 vLLM的核心设计理念

vLLM选择了一条不同的路:向操作系统学内存管理。它把KV Cache划分成固定大小的物理块(默认块大小是16个token),每个请求的动态KV数据按需挂载到这些块上,用一个块表记录逻辑块到物理块的映射关系。这个机制就是PagedAttention,名字里的Paged直接致敬了操作系统的分页内存管理。

这套设计带来的直接好处是:第一,显存利用率大幅提升,因为只有真正生成的token才占用物理块,不存在预留浪费;第二,几乎消除了碎片化,物理块可以分散在显存任意位置,通过块表索引拼接成连续的逻辑空间;第三,支持跨请求共享KV Cache,同一个前缀(比如系统提示词、对话前几轮)可以被多个请求复用,这是Prefix Caching能生效的基础。

除了显存管理,vLLM还做了一层连续批处理(Continuous Batching)的调度优化。传统推理服务通常是静态批处理:攒够一批请求一起算,这批全部结束后再处理下一批。vLLM改成迭代级调度——每个解码迭代都重新检查有哪些请求已经结束、哪些新请求可以加入,把GPU的每一轮计算都尽量填满。做过高并发服务的人应该能立刻意识到这层优化的价值,它把“批量处理”从请求维度细化到了token迭代维度。

1.3 什么场景真正需要vLLM

聊到选型问题时,我一般会给出一个比较务实的判断:如果你只是偶尔本地跑几个推理Demo,输入输出都很短,用Transformers也没问题;但一旦你的服务需要同时响应多个用户,或者需要处理长文档、多轮对话、流式输出,vLLM基本是当前开源方案里最稳妥的选择。它兼容OpenAI的API格式,迁移成本极低,生态里的LangChain、LlamaIndex、FastAPI框架都能直接对接。

还有一类场景是内部批量任务,需要离线跑大量Prompt来回放测试,这时候vLLM的高吞吐特性会大幅缩短整体处理时间。另外,在做Agent应用时,多个子Agent会并发调用同一个模型服务,vLLM的连续批处理能有效减少每个子Agent的等待时间。总之,判断标准很简单:你的推理服务是否有多请求并发、长序列、高吞吐这三者中的任一特征,如果是,vLLM就是绕不开的选项。

2. 核心细节解析:PagedAttention与调度策略的底层逻辑

2.1 KV Cache为什么是显存杀手

理解vLLM的原理,绕不开KV Cache这个概念。Transformer模型在生成每个新token时,需要参考前面所有token的Key和Value信息。如果每次重新算一遍前面所有token的注意力,计算量会随着序列长度线性增长,代价太高。所以推理框架会把已经算过的Key矩阵和Value矩阵缓存起来,后续生成直接读取缓存,这就是KV Cache的由来。

问题在于这个缓存非常占显存。以LLaMA-7B这样的模型为例,每个token的KV Cache大小可以用公式估算:2(Key和Value)乘以层数乘以每层注意力头数乘以每个头的维度,再乘以参数精度占用的字节数。7B模型跑起来,一个序列的KV Cache在FP16精度下每token大概需要几百KB甚至更多,如果最大序列长度是2048,并发几十个请求,KV Cache总占用很容易超过模型权重本身的显存占用。

很多人在部署时遇到的“显存不够”其实主要就是KV Cache造成的。模型权重是静态的,加载完就固定了;KV Cache是动态的,随并发和序列长度剧烈波动。传统框架因为静态分配策略,不得不按最坏情况预留,实际业务负载一波动就容易出问题。vLLM的PagedAttention解决的就是这个核心矛盾。

2.2 PagedAttention的块管理机制

PagedAttention之所以是vLLM的基石,是因为它改变了KV Cache的存储和索引方式。在vLLM里,KV Cache不再按“请求-序列”连续存储,而是切分成固定大小的物理块(默认每块16个token),通过块表来维护逻辑上的连续性。

具体来说,每个请求进来时会拿到一个逻辑块序列,但逻辑块对应到哪个物理块是不连续的,由块表记录映射。当模型通过注意力机制读取某个token的KV时,vLLM会先查块表拿到物理块的实际位置,再做内存访问。这个间接层多了一次查表的开销,但换来的是物理显存的高利用率,整体收益远大于成本。

跨请求共享是这套设计里更高级的能力。多个请求如果拥有相同的前缀,比如RAG场景里所有请求都带同一段系统提示词,或者多轮对话里的历史上下文,它们的前缀KV Cache可以只存一份,通过引用计数让多个请求共享同一批物理块。实测下来,在一些Prefix命中率高的业务中,显存占用能下降40%以上,首token延迟也能明显缩短。这个能力和Prefix Caching配合使用,在有公共上下文的场景下效果特别显著。

2.3 Continuous Batching:迭代级调度

再展开说说Continuous Batching。传统静态批处理的运转方式是这样的:请求先排队,凑够batch size开始计算,这批全部生成完成才释放GPU。问题在于序列生成长度差异很大,短的几秒就结束了,长的要很久,GPU每轮实际都在等最慢的那个请求。

vLLM在每个解码迭代结束后都会重新评估当前batch里的请求状态,已经结束的立刻腾出位置,新到的请求马上补进来。GPU的计算单元每一轮都在满负荷工作,没有“等待批量凑齐”或者“等慢请求完成”的空闲期。这种调度方式在混合长短请求的业务场景下优势特别大,吞吐量往往能翻倍甚至更多。

更细节的一点是,vLLM还引入了preemption机制。当显存不够时,它会优先抢占部分请求的KV Cache块,把它们换出到CPU内存,等显存释放后再换回来。这个过程对上层是透明的,但能避免新请求因为显存紧张被直接拒绝。实际使用中不推荐频繁触发preemption,因为CPU和GPU之间的数据搬运会拖慢响应,但这确实大大提升了服务的稳定性。

3. 环境搭建与模型部署实战:我跑通的过程

3.1 部署环境与安装选型

我自己的部署环境是一台单卡GPU服务器,显存24GB,搭配较新的CUDA环境。如果你是为了跑通流程,其实不用太高的配置,重点在于把依赖匹配好。vLLM的官方安装方式推荐直接用pip安装预编译的wheel包:

pip install vllm

如果你需要指定版本,可以通过pip install vllm==0.x.x来安装。安装前建议先确认Python版本在3.9到3.12之间,PyTorch版本和CUDA版本需要匹配vLLM的兼容性矩阵。我踩过的坑是CUDA版本太旧导致编译不过,后来升级到较新的CUDA环境后一次通过。建议直接用官方镜像或者干净的虚拟环境,不要图省事混装已有项目里的依赖。

vLLM依赖特定的GPU能力,所以对显卡型号有要求,需要支持较新的计算能力。如果你的显卡比较老,可能需要找旧版本。这一点在官方文档里有明确说明,装之前最好确认一下。

如果是生产环境,我更推荐用Docker镜像,官方发布的镜像会把CUDA、PyTorch、vLLM的兼容版本全部配好,省去环境折腾的时间。启动命令也很简单:

docker run --runtime=nvidia --gpus all \ -v ~/.cache/huggingface:/root/.cache/huggingface \ -p 8000:8000 \ --ipc=host \ vllm/vllm-openai:latest \ --model /path/to/your/model

生产环境里还有一个容易忽略的点:共享内存的大小。容器默认/dev/shm只有64MB,多进程数据交互很容易打满,需要加上--ipc=host或者通过--shm-size来扩容。

3.2 加载模型与启动API服务

vLLM提供了两种使用方式:一种是通过Python直接调用LLM类和推理接口,适合在脚本里使用;另一种是启动一个OpenAI兼容的API服务,适合给多个应用提供统一入口。

我日常使用最多的是启动API服务的方式,因为业务方调用起来最方便。下面是一个标准的启动命令:

python -m vllm.entrypoints.openai.api_server \ --model /models/llama-3-8b-instruct \ --served-model-name my-llm \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.85 \ --max-model-len 8192 \ --port 8000

这里的几个参数非常重要,后面我逐个说明。--model指定模型路径,可以是HuggingFace上的模型名,也可以是本地模型目录;--served-model-name是暴露给调用方的模型别名,方便业务层统一对接;--tensor-parallel-size是张量并行度,单卡设1,多卡可以设成卡数;--gpu-memory-utilization控制KV Cache能使用的显存比例,是调优的关键参数之一;--max-model-len是最大序列长度,直接影响显存预留量。

启动成功后,vLLM会默认提供一个OpenAI兼容的接口,/v1/chat/completions和/v1/completions都能用。这意味着你可以直接使用OpenAI的SDK来调用这个服务,只需要改一下base_url指向本地端口。对业务方来说迁移成本几乎为零。

3.3 通过Python客户端调用服务

服务启动后,验证是否正常工作的最快方式是用Python发一个请求。这里给一段可直接运行的示例代码:

import openai client = openai.OpenAI( base_url="http://localhost:8000/v1", api_key="EMPTY" ) response = client.chat.completions.create( model="my-llm", messages=[ {"role": "system", "content": "你是一个有用的助手。"}, {"role": "user", "content": "用一句话解释RAG是什么。"} ], temperature=0.7, max_tokens=512, stream=True # 开启流式输出 ) for chunk in response: delta = chunk.choices[0].delta.content if delta: print(delta, end="", flush=True)

这套接口的好处在于,你不需要自己实现token流式解析、会话管理这些底层逻辑,OpenAI SDK全帮你处理好了。vLLM也支持一些额外的参数,比如ignore_eos、skip_special_tokens等,在需要做生成控制时很实用。

我在实际项目里还试过用curl直接测试接口连通性,排查问题时比写Python脚本更快:

curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"my-llm","messages":[{"role":"user","content":"你好"}]}'

3.4 多卡部署:张量并行的使用

如果你的模型比较大,一张卡放不下,vLLM也支持多卡张量并行。所谓张量并行,是把模型的参数切分到多张卡上,每张卡负责一部分计算,计算过程中通过高速通信同步结果。启动时只需要把--tensor-parallel-size设成卡的数量:

python -m vllm.entrypoints.openai.api_server \ --model /models/qwen-14b \ --tensor-parallel-size 2 \ --gpu-memory-utilization 0.9 \ --max-model-len 4096

这里有个需要提前注意的坑:多卡部署时,每张卡的显存都会被分配一份KV Cache,所以--gpu-memory-utilization是相对于单卡的比例,不是全局比例。另外,通信瓶颈对最终性能影响很大,建议先确认服务器上的GPU互联带宽足够,否则多卡加速比会低于预期,甚至可能因通信开销而变慢。

4. 性能调优:把显存和吞吐压榨到极限

4.1 显存分配:gpu-memory-utilization该设多少

--gpu-memory-utilization是vLLM中最值得花时间调的核心参数之一。它表示允许vLLM使用的显存比例,剩余的部分留给模型权重、CUDA上下文和推理中间结果。默认值是0.9,含义是预留10%给系统开销。

我在实践中发现,这个值并不是越大越好。设得太大,CUDA kernel加载、框架运行时的显存需求可能不够,服务启动就会报CUDA OOM;设得太小,KV Cache空间不足,能支持的并发数和序列长度都会受限。通常建议从0.85开始试,观察显存占用是否稳定,再逐步上调到0.92左右。检测显存占用可以通过nvidia-smi实时观察,重点看allocated和free区间的变化趋势。

如果服务启动时报错说需要多少显存但可用显存不够,多半是GPU上还有其他进程占用,先清理干净再启动。另外,可以通过--max-model-len限制最大序列长度来压缩KV Cache预留上限。长序列场景显存压力大的时候,优先压缩max-model-len而不是降低并发能力。

4.2 批处理大小:max-num-seqs与并发上限

--max-num-seqs控制单次迭代最多同时处理的序列数,默认值通常是256。这个参数决定了服务的并发承受能力,但它不是越高越好。序列数太多时,显存和算力都被分散,每个请求的响应延迟反而会上升。合理的设置方式是用gpu-memory-utilization除以单序列的平均KV Cache开销来判断大致的上限。

比如8B模型,单序列上下文长度4096,FP16精度下KV Cache每token约占0.5MB,单序列约占2GB。如果给KV Cache预留了16GB显存,理论上最多支持8个长序列并发。如果业务需要更高的并发,可以适当压缩每个请求的最大token长度,或者升级量化精度。实际调优中,我的习惯是先跑典型负载,看GPU利用率是否保持在95%以上,如果利用率上不去,说明并发容量没有打满;如果频繁出现长延迟波动,说明并发设得太高了。

4.3 量化加载:显存减半的实用方案

量化是另一个显著的显存优化手段。vLLM支持多种量化格式,包括AWQ、GPTQ、FP8等。量化后的模型权重精度降低,显存占用下降,同时KV Cache也可以按量化精度存储,进一步扩大并发容量。部署方式很简单,用对应的量化模型路径即可:

python -m vllm.entrypoints.openai.api_server \ --model /models/llama-3-8b-awq \ --quantization awq \ --gpu-memory-utilization 0.9 \ --max-model-len 8192

关于量化方式的选择,我的经验是:如果只是自己测试,用GPTQ或AWQ都可以,二者在4比特下质量损失很小;如果追求推理速度,FP8配合较新的显卡表现更好。需要注意的坑是,量化模型的精度损失在低温度、短答案场景下不明显,但在长文本生成、代码生成这类任务里可能会被放大,上线前一定要做质量评测对比。

4.4 Prefix Caching:长上下文场景的加速器

vLLM从较早版本开始支持自动Prefix Caching,核心复用我前面提到的跨请求共享KV Cache机制。启用后,当多个请求拥有相同的前缀内容时,前缀部分的KV计算只做一次,后续请求直接命中缓存,首token延迟和显存占用都能显著降低。

这个能力在两类场景里收益最大:一类是RAG知识库问答,每个用户查询都带着相同的指令模板和大段上下文;另一类是Agent多轮交互,每轮请求都带着完整的历史对话。实测在RAG场景中,如果命中率高,整体吞吐能提升30%到50%。

vLLM中是自动启用Prefix Caching的,不需要额外配置。但对服务端显存会多消耗一部分用于缓存,因为缓存KV需要占用物理块。对前缀复用频繁的业务来说,这笔投入非常划算。

5. 常见问题排查速查:我踩过的坑

5.1 CUDA OOM:最常见的启动失败原因

CUDA OOM是vLLM部署中最常见的报错,原因基本出在显存分配参数没配好。报错信息通常包含申请了多少显存、可用多少之类的字段。排查步骤按优先级排列:

  • 检查GPU是否被其他进程占用,用nvidia-smi查看,把无关进程清理掉;
  • 降低--gpu-memory-utilization,比如从0.9调到0.85;
  • 降低--max-model-len,因为长序列的KV Cache是显存大头;
  • 确认模型权重本身没有超过显存,大模型适当使用量化或张量并行。

还有一个隐蔽的情况是服务已经启动,但请求进来时偶发OOM。这种通常是KV Cache preemption频繁触发或者物理块碎片化严重。建议观察日志里的GPU KV cache usage指标,如果长时间接近100%,就说明并发或序列长度已经逼近上限了。

5.2 首token延迟高:不是网络问题

首token延迟高指的是用户发出请求后,到第一个输出token返回的时间过长。很多人第一反应是网络问题,其实大部分是服务端调度问题。常见原因有三个:

  • 并发过高,新请求排队等待显存和调度资源;
  • 配置了较长的最大序列长度,KV Cache预留过多,实际可并发数下降;
  • Prefix命中率低,每个请求都从零开始计算前缀。

排查方法是用vLLM的--verbose日志观察每个请求的排队时间和实际计算时间,区分卡在等待还是卡在计算。如果排队时间长,适当降低max-num-seqs;如果计算时间长,检查模型是否用了量化,以及服务端是否启用了前缀缓存。

5.3 生成结果质量下降:量化后的代价

换了量化模型之后,生成质量下降是正常现象,不要慌。4比特量化对大多数任务影响不大,但在复杂推理、数学、代码生成等任务上,错误率会有所上升。遇到这类问题,建议先做A/B对比:用原始FP16模型跑同一批测试用例,量化模型跑同样的输入,人工对比输出差异。

如果差异确实明显,有几个补救手段:换更高精度的量化方式(如8比特);在量化模型基础上叠加采样温度调整,降低低概率token的采样概率;或者在关键业务上保持FP16,只在非关键批量任务上使用量化。没有完美的省钱方案,质量与资源之间的权衡是部署工程师必须做的取舍。

5.4 请求排队无限等待

请求长时间没有响应,但服务进程没挂,这种情况通常是并发调度出现了“活锁”。比较常见的原因是--max-num-seqs设置过大,导致大量请求抢占显存和调度资源,靠后的请求一直轮不上。另一个原因是长请求占用大量物理块,短请求反复被preempt换出换入,形成抖动。

遇到这种情况,我会先看日志里是否有大量preemption或swap事件,如果有,说明物理块周转太频繁,需要降低并发或压缩序列长度。如果服务本身没有日志输出,可以调用/metrics端点查看实时指标,vLLM暴露了显存使用、请求等待时间、吞吐等监控指标,是排查这类问题最直接的入口。

6. 经验总结:生产落地的几个关键决策

6.1 不必盲目追求最新版本

vLLM迭代速度很快,几乎每个版本都有新特性。但生产环境求稳,我的建议是不要追最新版,优先选择已经发了多个补丁的稳定版本。升级前先在测试环境跑通自己的业务路径,包括API兼容性、量化格式支持、监控指标是否变化等。我遇到过一个大版本升级后,自定义采样参数不再生效的问题,排查了很久才发现是版本行为变更。

如果你依赖某些开源组件做集成,比如LangChain或特定框架的Adapter,升级前要确认版本兼容关系。稳定压倒一切,在推理服务这个环节尤其重要。

6.2 性能压测要模拟真实负载

很多人在部署完vLLM后,习惯用单条请求测试延迟,然后就直接上线。实际上单条请求的延迟数据几乎不能反映真实服务水平。正确的压测方式是模拟业务的真实并发特征:请求到达的间隔分布、消息长度的概率分布、流式输出的使用比例等。

vLLM官方仓库里带了benchmark脚本,可以指定并发数、请求总数、输入输出长度等参数。压测时要重点看两个指标:吞吐(每秒完成的请求数或token数)和P95/P99延迟。吞吐高但P99延迟爆炸的配置在实际业务中是没法用的。我自己的标准是:P99延迟控制在用户可接受范围内,同时GPU利用率尽量保持在90%以上。

6.3 监控和弹性的重要性

上线vLLM服务之后,一定要建立基础监控,否则显存被打满或者请求超时你完全不知道。vLLM暴露了/metrics端点,可以采集GPU KV Cache使用率、请求延迟分布、token生成吞吐、排队请求数等指标,配合Prometheus和Grafana就能搭出一套完整的监控面板。

我个人的经验是至少盯三个指标:GPU显存利用率趋势、平均生成吞吐变化、P99延迟曲线。显存利用率持续高位运行说明需要扩容或优化;吞吐突然下降往往是前缀命中率波动或并发结构变化;P99延迟爬升说明服务资源接近瓶颈。这三个指标能帮你尽早发现问题,而不是等用户投诉了才去查日志。

写在最后

从原理到实战这一整套走下来,感触最深的一点是:vLLM真正厉害的地方不是某一个单独的技术,而是它把虚拟内存的思想用在了显存管理上,又用迭代级调度把GPU的每一轮计算填满,这两个设计的组合,让大模型推理服务从“能用”变成了“好用”。如果你正准备把自己的模型服务推上线,或者正在为并发和显存犯愁,按照这篇文章里的思路去部署和调参,大概率能少走很多弯路。我自己踩过的坑已经都写出来了,剩下的就是在你真实的业务负载里,把那些参数调到你业务的最优解。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进