vLLM高并发推理实战:PagedAttention与连续批处理如何拉满GPU吞吐
本地跑大模型最难受的往往不是单次推理有多慢而是并发一上来整个服务就崩了。也不一定崩但响应时间会变得很难看显存偶尔还会被撑爆卡在那边需要手动重启。vLLM 这个高吞吐推理引擎说白了就是专门为了解决这类问题出现的把 GPU 的显存和算力尽可能榨干让本地部署的模型服务在高并发请求下仍然保持稳定的吞吐表现。如果你已经在用大模型做本地服务或者正准备把一个 Demo 改造成真正能扛住并发请求的服务这篇文章值得完整看一遍。我会从 vLLM 的核心机制拆起讲清楚为什么它能“吃下”那么多并发请求再把我自己部署和调参过程中的配置、压测、踩坑一五一十写出来。内容偏实战也带一点原理适合手里已经有 GPU、想立刻把服务跑起来的开发者参考。1. 为什么需要本地高并发推理服务1.1 场景痛点从单请求到高并发很多人第一次把大模型接进业务系统用的是最直接的方案模型加载进显存来一个请求就 forward 一次返回结果之后再处理下一个。这种“来一个算一个”的方式在测试阶段完全够用。我自己最早也是这样跑的一个脚本、一个 FastAPI 接口、一个模型实例单测一切正常心里还挺美。但一旦请求从“零星的测试请求”变成“密集的线上调用”问题立刻就暴露了。首先是 GPU 利用率低得离谱。单个请求推理的时候算力峰值可能很高但请求与请求之间那段空闲时间GPU 基本是闲着等数据。等到并发请求同时进来我们又往往因为担心显存不够给每个请求都单独加载一份模型上下文结果显存先顶不住了。最后的表现就是TPS 上不去、首 token 延迟忽高忽低、偶尔直接 503 或者崩掉。这个问题的本质是传统推理服务把“请求”当作独立的处理单元每个请求占一整块资源然后靠排队机制硬扛。而 vLLM 不一样它把显存管理、批处理调度、KV Cache 这些底层环节全部重新设计了一遍让多个请求能真正“挤”在同一批计算里完成GPU 的利用率和吞吐量自然就拉开了差距。1.2 vLLM 的定位与核心优势vLLM 是一个专门面向大语言模型推理的高吞吐服务框架。它给自己的定位不是“又一个模型部署工具”而是把推理引擎本身做到极致。和常见的 Transformers Flask 方案相比vLLM 最大的不同在于它不把推理过程当作黑盒而是在内部对显存分配和计算调度做了深度控制。它的三个核心优势我实测下来感受最明显吞吐量高。同样的 GPU、同样的模型vLLM 能同时处理的并发推理请求数量比传统方案多出几倍甚至一个数量级尤其是短请求、高并发的场景差距非常显著。KV Cache 显存管理高效。它实现了类似操作系统“虚拟内存分页”的机制每个请求的 KV Cache 不用一次性预留完整空间而是按需分配、按页管理显存碎片化问题大幅缓解。OpenAI 兼容接口。它直接提供了 compatible 接口你原来写的 prompt 组装和请求逻辑基本不用改指向 vLLM 的地址就能跑。它的局限性也需要提前知道vLLM 对模型架构有一定限制主要支持常见的主流架构Decoder-only 系列一些特殊结构或者 MOE 模型支持得比较晚另外如果单次请求特别长、并发又低那它相对传统方案的优势就没那么明显了因为吞吐优化主要靠的是“并行处理多个短请求”。2. 核心机制拆解PagedAttention 与持续批处理2.1 KV Cache 是推理优化的重要战场理解 vLLM 为什么快一定绕不开 KV Cache。大模型生成每个 token 的时候都需要把历史 token 的 Key 和 Value 信息保存下来下一轮生成直接复用避免把前面所有 token 重新算一遍。这份缓存就是 KV Cache它占用的显存非常可观。对于一个几千 token 的输入KV Cache 可能占到总显存的三分之一以上甚至更多。传统方案的做法很简单粗暴每个请求事先按照最大可能长度分配 KV Cache 空间。问题在于你按最大长度预留但实际请求可能只用了很小的长度剩下的空间就白占了。比如一批请求里只有一个超长文本其他都是短文本那所有短请求都会跟着“陪跑”各自预留给长文本的空间全部浪费。这种浪费直接导致并发上限被压得很低因为显存很快就满了。vLLM 借鉴了操作系统的分页思想把显存切分成固定大小的“页”每个请求的 KV Cache 按页增长。换句话说请求需要多少缓存就给多少页不需要的时候页可以释放给其他请求复用。这个机制就是 PagedAttention。它带来的直接收益是显存利用率大幅提升能放进去的并发请求数量也随之提高而且几乎不引入额外计算开销。我实际看到的效果是同样一张显卡vLLM 能同时跑的请求数经常是原生方案的 3 到 5 倍。2.2 连续批处理如何拉满 GPU 利用率除了显存管理调度策略也是关键。传统批处理通常是“静态批处理”积攒够一批请求一起算完再积攒下一批。这种方式的问题很明显一批里只要有一个请求特别长其他短请求都得等着它做完才能整体释放GPU 就这么干等着。vLLM 用的思路叫持续批处理Continuous Batching它把生成过程拆得更细。每个请求不是一次性从头算到尾而是按 token 粒度参与调度某个请求这一轮生成了一个新的 token下一轮调度的时候如果它还没结束就继续参与计算如果已经生成完就立刻让出位置新的请求马上补进来。这样 GPU 的计算单元永远不会被“慢请求”占着不动大量短请求可以在同一个时间段内穿插完成整体吞吐量自然就上去了。这个概念理解起来有点像餐厅翻台传统方案是一桌客人吃完了才让下一桌进持续批处理则是客人吃完一道菜就撤掉盘子立刻上新客人的菜后厨的火一直不闲着。vLLM 的连续批处理就是在给定显存资源和当前请求混合情况下动态决定每一轮计算里包含哪些请求、各自生到第几步从而让 GPU 利用率尽可能贴近上限。实际调服务的时候这个机制最直观的体现就是短请求和长请求混在一起vLLM 仍然能保持比较平稳的尾延迟而不会因为一个超长文本把整个服务拖死。这也是它适合本地高并发服务的原因。如果只是零星请求用不用 vLLM 其实差异不大但一旦并发上来持续批处理和 PagedAttention 这两个机制就会产生质变。3. 本地部署与并发配置实操3.1 环境准备与模型加载我假设你已经有一张显存足够的 NVIDIA GPU并且装好了驱动和 CUDA。vLLM 对 PyTorch 版本有要求最省事的做法是直接安装官方预编译的 wheel 包它会自动拉取匹配的 PyTorch 版本。我当时的安装命令大概是这样pip install vllm装完之后可以用一行命令验证环境python -c import vllm; print(vllm.__version__)模型加载有两种方式一种是直接通过命令行启动服务适合快速验证另一种是写 Python 脚本在代码里初始化引擎适合深度集成。我先说前者因为它在绝大多数场景下已经够用了vllm serve 你的模型路径或模型名 --served-model-name my-model --gpu-memory-utilization 0.9 --max-num-seqs 32模型路径可以是本地的目录也可以是一个已经存放在本地的模型快照。我个人建议生产环境里尽量用绝对路径指定本地模型目录避免每次启动去检查远程仓库的网络耗时也方便离线部署。第一次启动会自动完成模型加载和图编译耗时主要取决于模型大小和硬件。我看到很多人都担心“第一次启动是不是卡住了”其实不是大模型加载和 warmup 本身就需要几分钟。等到终端出现类似 “Starting vLLM server” 的日志服务就算就绪了。3.2 服务启动参数详解max-num-seqs 与 gpu-memory-utilization启动参数里我最在意的两个一个是--max-num-seqs另一个是--gpu-memory-utilization。这两个参数基本决定了你的服务“最多能同时处理多少请求”和“能用到多少显存”。--max-num-seqs控制的是并发序列数量上限。这个值不是越大越好。假设你的 GPU 显存有限max-num-seqs 设置太大每个请求能分到的显存空间就会变小遇到长上下文请求时反而容易报错或者触发显存溢出。我习惯的做法是先按模型的规模和显存大小估算再压测调整。比如 24GB 显存跑 7B 模型--max-num-seqs从 16 起步基本不会出问题稳健一点再往上加。--gpu-memory-utilization控制的是 vLLM 最多能用多少显存用于模型权重和 KV Cache。默认值是 0.9意思是留 10% 显存防止意外。如果你的显卡同时还要跑别的进程这个值要调低比如 0.7 或 0.6如果显卡是专用推理卡可以调到 0.95。这里有一个常见误解很多人以为这个参数设置多少就占多少显存实际上它只是一个上限vLLM 会根据当前请求动态分配只是不会突破这个上限而已。我比较推荐的组合是vllm serve /data/models/llama-7b \ --served-model-name service-model \ --gpu-memory-utilization 0.9 \ --max-num-seqs 32 \ --max-model-len 4096 \ --enforce-eager其中--max-model-len也必须注意它限制了单条请求的上下文总长度直接影响 KV Cache 预留策略。设得太大KV Cache 预留多能并发的请求就少设得太小超长输入又会被截断。要根据自己业务里的真实文本长度来设没必要盲目追求大。3.3 用 OpenAI 兼容接口做压力验证vLLM 启动后默认监听 8000 端口访问/v1/chat/completions就能调用协议对标 OpenAI 的格式。这一步非常方便我以前那些为 OpenAI API 写的调用脚本只需要把 base_url 改成http://localhost:8000/v1就可以直接跑通。最简单的验证方法用 Python 的 requests 发一个 chat 请求import requests resp requests.post( http://localhost:8000/v1/chat/completions, json{ model: service-model, messages: [{role: user, content: 请简单介绍你自己}], max_tokens: 256, temperature: 0.7, }, timeout60, ) print(resp.json()[choices][0][message][content])先跑通单请求再考虑并发压力测试。我用的工具是 Locust也可以用wrk配合自定义脚本。不过 wrk 对 HTTP 的 body 构造支持起来麻烦些我后面还是选了 Python 多线程来模拟并发请求因为更容易精确控制请求内容和分布。真正压测的时候不要只盯着 TPS 看。我更关心三个指标首 token 延迟TTFT从发请求到收到第一个 token 的时间。这个值高说明调度或排队有问题。端到端延迟从发请求到完整生成结束的时间。吞吐量每秒完成请求数和每秒生成的 token 数。vLLM 的日志也会输出 token/s 的统计这个比笼统的 TPS 更有参考价值。因为不同请求的生成长度不一样只看请求数有时会误导你。4. 高并发场景下的性能调优4.1 吞吐量优先的调度策略vLLM 有很多调度细节可以通过环境变量或启动参数调整。默认配置通常已不错但如果你明确知道自己业务的请求特征可以进一步定向调优。第一件要做的事是把服务的计算图固定下来。第一次启动的时候vLLM 会做 CUDA graph 捕获后续推理才会稳定。如果启动日志里出现很多次“capturing”相关动作说明 graph 没有真正缓存好这时候要检查是不是显存被其他进程占了或者--gpu-memory-utilization设置得太紧。第二件事是控制并发请求的到达模式。持续批处理对混合长短请求很友好但如果你的上游把所有请求都一次性打进服务瞬时队列被塞满vLLM 也只能尽量调度不可能凭空变出算力。所以公网服务一般会加一层限流比如把并发请求数限制在 max-num-seqs 的 80% 左右避免队列无限堆积。第三件事是尽量复用 keep-alive 连接。每来一个请求都新建 TCP 连接既浪费时间又容易把连接数打满。vLLM 本身基于异步框架对长连接支持不错但你的客户端也要启用连接池别在客户端那一侧自己“串行化”了。4.2 显存分配与量化取舍显存分配是另一个重要调优点。vLLM 启动时会预留一部分显存给 KV Cache这部分空间可以在运行日志里看到别只看权重加载那一段。日志里有一行类似 “KV Cache size” 的信息那才是决定并发上限的关键。如果日志显示 KV Cache 空间很小通常意味着--gpu-memory-utilization太低或者模型权重本身太大。这时候可以考虑量化。vLLM 支持多种量化方式比如 AWQ、GPTQ、FP8。我实际用过 4-bit 量化的 7B 模型推理速度损失可以接受显存占用明显下降KV Cache 能留出更多空间并发能力强一截。不过量化不是免费的。低比特量化在某些任务上会略微掉点尤其数学推理和代码生成这种对 token 精度敏感的场景建议先在业务数据上对比一下量化前后的输出质量再决定。如果只是做文本摘要或一般对话4-bit 通常问题不大。我踩过一个很典型的坑为了追求最高精度加载了 FP16 权重又把--gpu-memory-utilization设成 0.95结果随着并发请求增加显存直接耗尽服务反复崩溃。后来换成了 8-bit 量化、把显存利用率调回 0.85一切恢复正常。做部署优化时别只看“模型能不能加载”要看“留了多少余量给 KV Cache”。4.3 长上下文场景的注意事项长上下文是并发性能的隐形杀手。原因在于 KV Cache 的增长和输入长度成正比。一个 8K token 的请求可能抵得上几十个 200 token 的短请求。如果业务里存在不少长文档分析、长对话总结你的并发上限甚至会被单个请求拖低很多。我建议给业务接口做一层长度分级普通对话限制在 4K 以内长文档走单独的服务实例。这样一个长文本请求就不会频繁挤占其他短请求的调度机会。如果确实必须在同一个服务里混合长短请求那--max-num-seqs就要设置得保守一点避免极端并发时 KV Cache 分配不足。另外vLLM 在长上下文场景下的显存碎片问题虽然有 PagedAttention 缓解但还没到完全无视的程度。服务运行久了长时间不停机也可能出现性能退化我自己的习惯是每隔一段时间重启一次服务实例尤其是模型更新或者批量参数调整之后重启最省心。5. 踩坑与排查实录从 503 到显存溢出5.1 常见错误速查表本地高并发推理服务跑起来之后会遇到的问题其实不多但一旦出现就很折腾人。我把自己踩过、也帮别人排查过的问题整理成了一张表现象可能原因排查方向请求返回 503 Service Unavailable并发请求数超过 max-num-seqs排队超时提高 max-num-seqs或在上游限流启动后显存立刻溢出gpu-memory-utilization 设置过高权重和 KV Cache 冲突降为 0.8 左右或换量化权重服务能跑但响应越来越慢长请求积压短请求始终排队检查 max-model-len业务层做长度分级日志报 “No available memory”KV Cache 空间不足减小 max-num-seqs降低并发爆发性能不如预期CUDA graph 未生效计算图反复重建查看启动日志是否多次 capture某个应用场景输出明显变差量化精度损失提高量化位宽或者只量化部分模型这句话我反复跟人强调过看到 503 先别急着加机器先看是不是请求突发导致排队积压。vLLM 本身不会“拒绝”请求它是按队列处理的排队的请求如果迟迟处理不完上层就报超时。解决思路不是无限提升 max-num-seqs而是控制业务侧进入服务的瞬时并发。5.2 实测心得并发上不去先看这三个指标如果你发现并发死活上不去千万别只盯着--max-num-seqs一个参数。我建议按下面的顺序排查第一看 GPU 利用率。如果服务在压测时 GPU 利用率已经接近百分之百说明算力是瓶颈这时候加 max-num-seqs 没有意义反而增加排队。需要换更强的 GPU、用更小的模型或者搞多卡。第二看 KV Cache 池够不够。vLLM 启动日志会明确写出 KV Cache 总大小和每个序列平均分到的空间。如果 KV Cache 总量很大但每个请求可用的平均空间很小说明显存被模型权重吃太多优先做量化或者降精度。第三看平均请求长度。假设你统计到的平均请求长度为 500 token而 max-model-len 设置成 4096那每个请求实际预留的缓存空间远超需要并发上限会被白白压低。把 max-model-len 调到一个略大于业务最大长度的值往往会有惊喜。我遇到过最典型的一个案例某实验室的服务单请求响应很快但并发一到 10 个就崩。日志看完发现 KV Cache 预留空间被 max-model-len 撑满了服务器把大部分显存都用作预留而不是按需分配。把 max-model-len 从 8192 改成 2048 之后并发直接翻倍还稳得很。这问题藏得挺深不看日志根本发现不了。另外还有一个很细节的地方客户端如果使用流式输出压力和普通一次性返回完全不一样。流式请求每个 token 都走一轮 HTTP 响应上游处理逻辑、网络开销都会变大。如果你业务不需要流式就尽量一次性返回压测结果会更接近理想状态如果需要流式那就要把并发阈值再往下调一些。从部署配置到性能调优vLLM 给我最直接的感受是它不是一个“装完就完事”的工具而是一个需要配合业务特征去调校的引擎。参数配好了它能扛住很夸张的并发配不好它也会和普通方案一样频繁报错。我个人实际操作时的经验是不要一上来就把参数拉满先以最保守的配置跑通再逐步加大并发并观察显存、延迟和吞吐量的变化。每次只动一个变量出了问题才容易定位。这也是这个引擎最值得玩的地方你把机制理解透了调参就不再是玄学而是有迹可循的工程优化。