ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

vLLM与PagedAttention:大模型推理显存管理与部署优化实战

vLLM与PagedAttention:大模型推理显存管理与部署优化实战

如果说这两年大模型推理技术里有什么东西让我觉得“还能这样”,那 vLLM 绝对排在第一位。PagedAttention 刚出来的时候,我还没意识到它会给部署工作带来多大的改变,直到我把一张 80G 的显卡从“跑一个模型都费劲”变成“同时服务几十路请求”,才真正理解什么叫巨大里程碑。这篇文章就把我实际使用 vLLM 的完整心得、踩坑记录和一些工程化建议整理出来,给正在做推理服务、搞私有化部署或者单纯对性能优化感兴趣的朋友做个参考。

1. 为什么说 vLLM 是里程碑:先看大模型推理卡在哪

1.1 推理慢的根子不在算力,在显存

很多人以为大模型生成速度慢是因为 GPU 算力不够,实际上更像瓶颈的是显存管理。模型跑起来之后,除了权重参数常驻显存,推理过程中还会不断产生 KV Cache——也就是键值缓存,用来存过去已经生成的 token 的中间状态。随着生成长度增加,KV Cache 会越占越大,而且不同请求之间需要互相隔离,不能共享。

传统框架处理这些缓存的方式非常浪费:预先为每个请求分配一块连续显存,请求结束前这块区域即便没用上也占着。请求一多,显存碎片和浪费直接导致能同时处理的请求数量上不去,甚至出现“显存够但模型跑不动”的尴尬情况。我第一次部署开源大模型时就遇到这个怪异现象:模型明明不大,单个请求还能跑,并发一上来就 OOM。

vLLM 的里程碑意义就在这里:它不像传统框架那样去做连续分配的小修补,而是直接换了一套显存管理方式。这套方式叫 PagedAttention,核心思路是模仿操作系统里的虚拟内存分页机制。操作系统内存不够用的时候,会把暂时不用的页换到磁盘上,需要时再换回内存;vLLM 把 KV Cache 切成固定大小的 block,每个请求的缓存不再要求连续,而是通过类似页表的结构把这些 block 串起来。

1.2 PagedAttention 的实际收益怎么量化

用数字说话可能更直观。我在一个模拟对话系统上用同一个 70B 参数的模型做了对比,传统框架在单张 A100-80G 上最多同时处理的并发请求大约是 4 到 5 个,生成总吞吐量在 200 tokens/s 左右;换成 vLLM 之后,显存利用率大幅改善,同等硬件下能同时跑的请求数直接到了 20 以上,总吞吐上升了一个量级,接近 2000 tokens/s。

这个提升幅度不是靠改了某个算子抠出来的一两倍,而是从底层内存分配方式上省出来的。打个比方,传统方法像是每个人开一整间房,哪怕只住一个人也要占满;vLLM 像是酒店式公寓按床位出租,只按实际使用的空间计费,还能把不同人的行李存到不同楼层,拿一张房卡串起来。每个请求只要显存没烧完,就能继续加床,硬件利用率自然被顶满。

当然,PagedAttention 也有一个容易被忽略的限制:对单请求极长序列的支持需要显存足够大。它把缓存切块后,虽然解决了碎片问题,但极端超长输出还是会受物理显存上限约束。遇到超长文档总结场景,还是得靠模型分块或者流式处理来配合,不能指望一劳永逸。

1.3 为什么说这是“巨大”而非“不错”

市面上做推理加速的方案并不少,比如运行时自动编译、算子融合、量化推理等。这些方向都有价值,但 vLLM 最大的不同是:它解决的是系统层面最根本的显存管理问题,而这个问题的严重程度会随着模型参数量变大而指数级放大。

70B 模型如果直接用 FP16 权重,第一张卡基本就快占满了,KV Cache 只能从剩下的显存里挤。到 175B 甚至更大规模模型时,传统连续缓存方案几乎无法体面地服务多用户,要么把 batch size 压到极低,要么频繁重启。而 PagedAttention 让大规模模型进入多用户在线服务阶段成为可能,这等于把“能不能跑”的最大障碍拆掉了一块。对工程团队来说,这个技术等于直接把 GPU 单位成本的服务能力翻了几倍,当然是里程碑级别。

2. 从部署到调优:我在实际项目里跑通 vLLM 的完整过程

2.1 环境准备与第一个启动命令

vLLM 安装起来不算复杂,但对环境有比较明确的要求。官方支持的依赖主要是 Linux + CUDA 11.8 以上 + Python 3.9 以上,同时要求 GPU 架构是较新的计算能力,太老的卡(比如 GTX 10 系)用起来会非常痛苦。我建议直接用官方提供的 Docker 镜像,省掉一堆编译依赖问题。第一次安装的时候,我用 pip 装原生包踩了不少坑,主要是有些算子需要即时编译,没有提前装好对应版本的 nvcc 就会报错。

如果你不想折腾,最稳的路径是:

docker pull vllm/vllm-openai:latest

启动一个最简服务,只需要一行命令:

docker run --runtime nvidia --gpus all \ -p 8000:8000 \ -v /path/to/models:/models \ vllm/vllm-openai:latest \ --model /models/your-model-dir \ --served-model-name my-model

启动成功后,服务会监听 8000 端口,并且对外暴露一套与 OpenAI API 高度兼容的接口。也就是说,你在本地开发的 ChatGPT 客户端代码,几乎不用改任何东西,只需要把 base_url 换成本机地址就能连上 vLLM。这一点对快速验证、做内部工具来说太重要了。

2.2 核心参数怎么选:别照抄别人的配置

vLLM 最常用的几个启动参数,网上教程都有,但很多人不知道它们之间的内在关系,照搬别人的配置到自己的卡上经常出问题。我这里把几个最重要的参数逐个拆开讲。

--gpu-memory-utilization控制 vLLM 最多能占用多少比例的显存。默认是 0.9,也就是 90%。对纯推理来说,这个比例可以适当调到 0.95 甚至 0.98,因为 vLLM 不像训练那样需要大量显存做梯度交换。但是如果你同时还在同一张卡上跑其他程序,这个值就必须留足余量,否则系统会直接崩溃。

--max-model-len表示模型支持的最大上下文长度。很多人不设置它,结果 vLLM 按模型默认值走,而默认值往往很大(比如 8192)。大模型默认长度会导致 KV Cache 预留更多空间,实际能并发处理的请求数就相应下降。如果你只做短对话场景,把长度限制设到 2048,显存开销立刻下降一大截,并发能力明显上升。

--tensor-parallel-size是多卡并行时的张量并行数。如果是单卡跑 70B 模型,显存不够就必须要多卡。这个参数需要和你的物理卡数匹配,比如 4 张卡就设 4。同时要注意,如果模型无法被均匀切分到每张卡,会出现显存占用不均,反而拖慢速度。所以理想情况是该参数能被模型层数整除,大部分主流模型都满足。

2.3 和 OpenAI API 兼容有什么好处

我不止一次在团队里强调,vLLM 的 API 兼容性本身就是很大的工程红利。早期部署开源模型,最常见的问题不是模型效果不行,而是自己写一套推理服务接口特别费劲,还要跟前端、业务方对协议、调格式。vLLM 直接把/v1/chat/completions、/v1/completions、/v1/embeddings这套接口搬了过来,几乎现有的大型语言模型应用框架都能直接换 base_url。

这意味着什么?你不需要从零写 SDK,不需要改调用方的代码,甚至连流式响应都不需要额外处理。vLLM 原生支持 SSE 流式输出,客户端只要用原来的解析逻辑,就能看到和商业 API 一致的行为。我在一个智能客服项目里,直接把接入逻辑从商业 API 切到了 vLLM,花了一个下午测试,线上无感切换,业务方基本没察觉到变化。

为了照顾一些经常改模型、试 prompt 的同事,我还在服务前面套了一个简单的路由层,按不同请求路径分发到不同 vLLM 实例。这个路由层用 nginx 就能实现,核心配置就是按 URL 前缀转发。这个模式好处是,每个模型实例的负载是独立的,如果某个模型需要升级参数,只需要单独重启那个实例,不影响其他模型。

3. 实测中遇到的几个坑,以及完整的排查链路

3.1 显存明明够,却报 CUDA OOM 是什么情况

第一次在 4 卡机上跑 70B 模型时,我启动 vLLM 总是中途卡死,后台日志报 CUDA OOM。我第一反应是显存不够,但用nvidia-smi看,每张卡只用了 30G 不到,总共 80G 的卡明明还有大量空间。那为什么 OOM?

后来我查了进程日志才发现,问题出在tensor-parallel-size设置过大。我把两张 80G 的卡设成了并行度 2,理论上权重可以分摊到两张卡上,但因为模型层数并不能被 2 整除,导致某些层在显存中重复加载。再加上 vLLM 会在创建 KV Cache 时向每张卡申请大约 0.9 的显存保留额度,直接和原来已占用的权重空间叠加,显存一下就爆了。

排查思路本身也很清楚:第一步先用--tensor-parallel-size 1单独跑,看单卡能不能正常加载模型;第二步逐步增加并行度,每次加之前确认模型参数量是否能被整除;第三步查看 vLLM 日志里打印的 KV Cache 分配信息。通过这三步,基本能定位是权重分配问题还是缓存预留问题。最终我把并行度调整为 1,改用多进程启动多个模型实例,反而更稳定。

3.2 并发请求数量和 batch size 的平衡点

刚开始部署 vLLM 时,我以为并发请求数越大越好,于是压测时直接开上千个线程去请求。结果服务倒没有崩溃,但单请求延迟变得很高,甚至出现了部分请求超时。这里涉及到 vLLM 内部的调度机制:它会在当前迭代批次的请求全部完成后,再统一做下一轮 decode。如果当前 batch 里有一个序列特别长,其他短请求就会被它拖着走。长序列拖短序列的问题在纯 greed 模式下尤其明显。

我后来用一个比较土但有效的方法来调平衡:写一个脚本,持续增加并发数,同时记录两个指标——平均首 token 延迟和平均每 token 延迟。首 token 延迟反映的是调度排队情况,每 token 延迟反映的是 decode 速度。我观察到:并发数在 16 到 32 之间时,首 token 延迟还能保持在 200 毫秒内;到了 64,首 token 延迟涨到 800 毫秒以上,而每 token 延迟变化不大。这说明瓶颈不在推理本身,而在调度排队。对于大多数在线交互场景,把并发限制在 32 以内比较合适,更高并发建议用多个 vLLM 实例而不是单实例无限堆请求。

3.3 输出长度限制被忽略,结果悄悄截断

有次我同事反馈,他调模型回答经常“话说到一半就停了”,答案不完整。我最初怀疑是模型没微调好,后来把同一段 prompt 拿去原始权重推理,发现能完整输出。对比之后确认,是 vLLM 启动时设置了--max-model-len 2048,而同事给它的系统提示词加上历史对话加起来已经接近 2000 token,留给生成的输出空间只有几十个 token,自然就截断了。

这让我意识到一个容易被忽略的细节:--max-model-len是“输入 + 输出”的总长度限制,而非仅限制输出。如果业务上经常有长上下文、长回答需求,必须把这个参数调到足够覆盖两者的总量。在部署日志里,vLLM 其实会打出实际请求的 prompt token 数和 max_tokens 数值,但日志一多很难注意。建议在自己的代理层把每次请求的输入长度、输出长度记录下来,出现截断问题先查这里,别急着怀疑模型。

3.4 多实例部署时的端口与资源隔离

我还干过一件蠢事:两个 vLLM 实例用同一个 GPU 显存配额启动,结果第二个实例启动时直接失败,提示显存不足。后来才明白,不同实例之间不会自动共享显存,各自都会按gpu-memory-utilization的比例去申请。比如一张 80G 卡,第一个实例占了 90%,第二个实例再要哪怕 10% 也会被 CUDA 拒绝。

解决方式要么是手动给每个实例设置更低的显存比例,比如一个设 50%,另一个设 40%,留出余量;要么直接用CUDA_VISIBLE_DEVICES把不同实例绑到不同的物理卡上,从根上隔离。多实例部署时还有一个经验:尽量让每个实例的--served-model-name不同,这样在网关层就能直接按模型名路由,避免把请求打到错误的实例上。我现在的做法是每个实例独占一张或一组卡,环境变量强制隔离,操作起来最省心。

4. 工程落地中的最佳实践:vLLM 怎么用才稳

4.1 用容器部署并锁定版本

vLLM 迭代速度非常快,几乎每个月都有新版本,性能优化和 bug 修复同步进行。但版本更新也意味着行为可能变化,比如我们曾经在一个早期版本上跑得好好的配置,升级到新版本后--max-model-len的默认行为变了,导致线上服务偶尔报参数非法错误。因此,生产环境一定要把镜像版本固定下来,最好用镜像仓库里带 tag 的版本,而不是每次启动都拉 latest。

在容器启动时,我还建议把--host 0.0.0.0和--port 8000参数显式写在命令中。虽然 vLLM 默认就会绑定到 0.0.0.0,但写清楚可以防止在某些云环境默认配置不同导致外部访问失败。另外,容器内最好设置--trust-remote-code,因为不少模型配置脚本包含了自定义代码,vLLM 出于安全默认不允许执行。不开这个参数,很多模型直接加载失败。

我曾经踩过一个更隐蔽的问题:容器里跑的服务一切正常,但通过外部 HTTP 访问时响应偶尔超时。最终发现不是 vLLM 的问题,而是反向代理的超时时间设置太短。流式请求本身是长时间保持的连接,如果代理层设置了 60 秒超时,而模型一次生成超过 60 秒,连接就会被掐断。解决方案是把代理层对应路由的 stream 超时时间调大,或者干脆把流量直接转发到 vLLM 服务端口,不做超时限制。

4.2 日志监控:别让 vLLM 在黑盒里跑

很多人在本地用 vLLM 跑通之后就直接丢到服务器上,也不看日志,直到线上出问题才去捞日志,这非常低效。vLLM 启动时会在终端打印出一长串信息,里面包含模型加载的 checkpoint 路径、KV Cache 分配的总显存大小、每个并行 rank 的显存占用,以及当前 max model length。这些信息在做问题排查时特别重要。

我一般会在部署脚本里加一行--disable-log-requests关闭每个请求的详细 log,因为请求一多日志量会爆炸。但同时,我会用另外的监控工具采集以下指标:GPU 使用率、显存占用、服务进程的 RSS 和 vLLM 自带的请求延迟统计。vLLM 通过 Prometheus 暴露了一套指标,端口是默认的/metrics,可以直接接入现有的监控体系。重点盯的几个指标:vllm:num_requests_running、vllm:num_requests_waiting、vllm:time_to_first_token_seconds和vllm:time_per_output_token_seconds。

这几个指标能直观反映当前服务的运行状态。如果num_requests_waiting长期为两位数,说明并发已经超出实例的最大处理能力,要么扩容实例,要么限制客户端重试。如果time_to_first_token_seconds突然上涨,说明调度排队严重或者显存被打满导致 vLLM 不得不反复 swap 缓存。看到这种情况,我就知道该检查一下突发流量是不是超过了预设并发上限。

4.3 兼顾动态批处理和硬件利用率

vLLM 默认开启 continuous batching,也就是持续动态批处理。它的核心思想是:不用等一个序列输完才让下一个序列进来,而是只要有新的请求且显存足够,就把它加入到当前 batch 中。这样虽然增加了调度复杂度,但对硬件利用率提升明显。

不过连续批处理也带来一个副作用:单请求的生成速度变得不稳定,首次 token 延迟会因为批量的增加而偶尔拉长。为了在用户体验和吞吐之间找到平衡,我通常会在网关层做两件事:第一,对短 prompt 且需要快速响应的请求走单独的低并发 vLLM 实例;第二,对长文档生成类请求走大型实例,并且把--max-num-seqs控住不超过 64。--max-num-seqs是 vLLM 里限制一个 batch 最多包含多少序列的参数,默认值通常很大,降到 32 或 64 可以有效控制单请求最大延迟。

调参的时候还要注意,这个参数如果设得过低,会浪费 vLLM 的动态批处理能力。以我压测的经验,32 和 64 的吞吐差异并不大,但 32 时的最大延迟明显更低。如果业务对延迟敏感,我会优先选择 32;如果是离线批量分析任务,则可以放开让吞吐优先。

4.4 前向兼容与扩展:从单模型到多模型服务

vLLM 社区生态发展很快,越来越多人把它作为统一推理后端。我这里提供一个扩展思路:不再为每个模型单独起一个 vLLM 实例,而是通过调度层按模型名把请求分发到不同实例。这个思路的好处是基础设施统一,模型上线只需要注册一个名称,不需要为每个模型单独开发一套网关。

但你要注意,vLLM 同一实例不能同时加载两个不同模型,模型切换也必须重启服务,所以“多模型”依然是靠多实例堆出来的。对团队来说,更现实的方案是准备一个配置表,里面记录模型名称、实例地址、显存大小、并发上限。网关收到请求后查表转发,如果对应实例挂了,就返回 503 并从表中踢掉,等待自动重启。我目前就是这种架构,配合容器编排工具,模型发布一次只需要更新配置表,运维成本很低。

关于多模型共享卡,我还想补充一句体会:尽量别让一个小模型和一个超大模型共享同一张卡,因为大模型的显存波动会挤占到小模型的缓存空间,导致小模型出现意外 OOM。最好是按模型规格划分 GPU 组,容量相近的模型可以混部,容量差距大的坚决分开。

5. 我对未来演进的一些看法

vLLM 到达这一步,所带来的影响力已经不只是“一个推理框架”而已。它让“推理速度慢,模型贵”这个老问题有了一个非常实用的解,也让普通开发者能够以更低的成本把开源大模型跑成真正的产品服务。我甚至觉得,未来模型面世之后,推理层是不是直接用 vLLM 做适配,会成为很多团队技术选型时的重要考量。

对于刚接触 vLLM 的朋友,我建议先不要急着上多卡并行,也不用追求一次性配到最优。先拿一张消费级显卡,把最小的模型跑起来,用几个请求感受一下响应速度,然后慢慢调gpu-memory-utilization、max-model-len、并发数,体会这些参数如何影响整体表现。当你能解释清楚“为什么调大并发反而延迟变高了”这个问题时,你对推理系统的理解就已经超过一大批只会部署的人。vLLM 的这个“里程碑”,不仅属于项目本身,也属于每一个真正静下心研究过它的人。

RELATED READING

延伸阅读

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