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 的这个“里程碑”不仅属于项目本身也属于每一个真正静下心研究过它的人。