vLLM本地高并发推理:从PagedAttention到Continuous Batching的实战调优
1. 为什么要换掉常规推理范式从显存浪费聊起本地跑大模型推理最让人头疼的就是明明模型参数不大可一旦并发上来显存瞬间爆掉。我最早用HuggingFace的transformers直接做批量推理测一个7B规模的对话模型时单请求没问题可一旦QPS每秒请求数超过5显存就像漏了底的水桶。仔细看日志才发现每个并发请求都在重复加载一份完整的KV Cache——也就是模型在生成每个token时缓存住的Key和Value矩阵。这个缓存有多大呢以7B模型、2048上下文长度为例单请求的KV Cache约占显存数百MB到1GB。并发一上来几百个请求同时占着各自的缓存区显存瞬间被吃掉一大块。更让我不能忍的是常规推理框架默认是串行生成即请求逐个排队前一个生成完了才处理下一个。GPU在等生成间隙时大量算力在空转。这对于本地做高并发推理服务来说几乎等于拿着跑车的引擎在市区堵车。后来调研了一圈换到vLLM这套推理引擎核心感受就是一句话它把高吞吐这个目标从架构层面就定好了不是靠堆硬件或加缓存硬扛而是重写了KV Cache的管理方式和请求调度逻辑。用下来同样一张显卡上同型号模型的吞吐量翻了数倍并发能力明显更稳。这篇文章围绕的vLLMVery Large Language Model推理引擎取了个高吞吐的简称重点记录了我如何在本地搭建一个高并发推理服务从引擎原理、部署参数、性能实测到踩坑处理的过程。适合两类人参考一类是刚入行、想搞懂推理引擎到底在优化什么的开发者另一类是正在做本地服务压测和并发调优的工程师。我的一个基本思路是不用一上来追最新特性先把快在哪、为什么快琢磨明白再动手部署不然遇到参数调优时容易抓瞎。2. PagedAttention和Continuous Batching两个关键引擎是怎么工作的vLLM的高吞吐能力并不是一个魔法它主要建立在两项关键技术之上PagedAttention分页注意力机制和Continuous Batching连续批处理。2.1 PagedAttention到底解决了什么问题传统的KV Cache管理方式可以类比成一个自助餐厅每个顾客来吃饭都固定分到一整张桌子。哪怕顾客只吃了一道菜桌子其他区域也空着别人不能坐。换到模型推理上每次请求进来推理引擎就为它在显存里预先分配一块连续区域来存放KV Cache。但生成token的数量是动态的——模型可能在35个token后就停了也可能一直生成到512个token甚至更多。如果预分配太大显存就这么白白浪费了预分配太小又会导致生成中途空间不足触发重新分配性能反而更差。PagedAttention的思路则像操作系统里的虚拟内存分页。它把KV Cache切分成固定大小的块每个块可以存放在显存的不同物理位置再用一张页表把它们逻辑上连起来。我们可以把它想象成在库房里存书传统方式是给每个读者分配一整排书架读完之前别人不能碰分页方式则是把书拆成若干页每页可以分别放在任意空书架上读到哪里再按索引去取。这样做带来了一个关键收益——碎片利用。多个请求的KV Cache块可以紧密排列在显存里不再有整块区域被一个请求独占但大部分空闲的情况。实际表现是同样一张显卡原来只能同时跑5个并发请求换成分页管理后可以同时跑20个甚至更多显存利用率大幅提高。2.2 Continuous Batching是怎么把请求塞进一个批次的传统的动态batching是攒批模式推理引擎会等待一段时间把同时到达的请求凑成一个batch一起处理当batch里某个请求提前生成完毕它会继续占着位置直到整个batch处理完再一起释放。这样有两个明显问题一是等待时间造成延迟二是早完成的请求拖慢整批。Continuous Batching也称in-flight batching是vLLM调度器的核心逻辑。它的做法有点像高峰期的网约车调度不再等满一车人才发车只要有乘客上车且车上有空位就走。具体到推理过程当一个请求完成生成后调度器立刻把这个位置让给一个等待中的新请求而不用等整个batch结束。这种调度机制对吞吐量的提升非常直接。我做过一次简单的对照实验同样的模型、同样的数据集开Continuous Batching后服务端每秒处理的请求数显著增加而且每个请求的平均排队时间也更稳定。因为GPU计算单元在任何一个时间窗口内都在处理当前最需要处理的那批token而不是被慢请求拖住。2.3 对这块原理梳理的实测心得在真正部署之前我有两点体会值得分享。第一理解KV Cache分页是理解vLLM显存参数的前提。vLLM启动时的--gpu-memory-utilization参数、--max-num-seqs参数本质上都在和可用的KV Cache块数博弈。调整这些参数时不能凭感觉改要结合模型的层数、hidden size和上下文长度来估算每请求的Cache占用。第二Continuous Batching看似是纯调度优化但在实际部署中它会放大长尾请求的影响。因为调度器永远优先做填空当一个超长请求长时间占着大量的块而很多短请求在排队时短请求的延迟可能会被拉高。在调并发时我后面的实测也会提到这一点——不能只盯着吞吐更要看TPTail Percentile延迟。3. 本地部署的完整配置镜像、模型和启动参数原理总算搞清楚了接下来就是动手。这一节记录我从环境准备到服务正常启动的完整过程。3.1 环境准备与镜像选择我的部署环境是一台配备24GB显存显卡的Linux工作站系统是Ubuntu 22.04已安装NVIDIA驱动和CUDA 12.x。vLLM官方推荐使用Docker镜像来部署理由很直接依赖打包完整省去很多Python版本、CUDA版本不匹配的麻烦。我选的是基于CUDA 12.1的vLLM公共镜像。拉取命令很简单docker pull vllm/vllm-openai:latest如果网络环境拉取镜像有困难可以提前配置好镜像加速器或者找一台网络较好的机器先把镜像导出再导入。这一步看似不起眼但实际部署时不少人卡在镜像拉取上。拉取完成后可以用一个简单的命令测试环境是否正常docker run --rm --gpus all vllm/vllm-openai:latest \ python -c import vllm; print(vllm.__version__)--gpus all是让容器访问宿主机全部GPU若机器有多卡后续建议按具体需求指定。3.2 模型准备与加载方式我用的模型是从开源社区下载的一个13B规模对话模型权重保存为HuggingFace格式。下载完成后我把它放在宿主机一个专门的目录下比如/models/my-chat-model/ |- config.json |- tokenizer.json |- model-00001-of-00008.safetensors |- ...启动vLLM服务时用--model参数指定模型路径。这里需要注意模型路径可以是本地目录也可以是HuggingFace模型仓库ID。如果是本地目录建议把路径挂载进容器内例如docker run --gpus all \ -v /models:/models \ -p 8000:8000 \ vllm/vllm-openai:latest \ --model /models/my-chat-model \ --served-model-name chat-model \ --port 8000其中--served-model-name是给这个模型起一个对外开放的服务名称。比如客户端请求里写model: chat-model服务端就会路由到对应的权重。如果不设置这个名称默认和模型路径同名本地部署时可能是一串很长的路径名客户端调用容易写错建议显式指定。3.3 显存参数和并发参数的初始设定启动参数里最需要花时间理解的是这三个--gpu-memory-utilization 0.85 --max-num-seqs 32 --max-model-len 4096--gpu-memory-utilization表示vLLM最多可使用GPU总显存的85%。这个值不是越高越好。模型权重本身要占一部分显存激活值也要占一部分如果KV Cache的可用空间把显存几乎全部挤满一旦某个请求生成了超长序列可能直接触发显存溢出OOM。我最初把它设为0.95服务稳定运行了几小时后在某个长上下文请求上遇到了报错后来降到0.85才稳定下来。--max-num-seqs表示单批次最多同时处理多少个请求序列。这个值和--gpu-memory-utilization、--max-model-len是联动的。如果显存足够大可以调大这个值来提高吞吐但如果显存有限而请求上下文又都很长过大的并发只会频繁触发调度器把请求重新排入队列反而增加排队时间。--max-model-len表示模型支持的最大上下文长度包括输入和生成的token总数。务必要看清楚模型本身支持的上下文范围只设置成模型声称支持的数值。很多开源模型的权重是基于2048或4096上下文训练出来的强行给vLLM设置成8192一方面会占用更多显存为KV Cache预留空间另一方面模型实际效果也可能不稳定。让我用一个表格整理一下启动参数的参考关系方便大家按自身显存做初步估算参数作用我使用的初始值调整方向gpu-memory-utilizationvLLM可用的显存上限比例0.85显存大且请求短时可调高至0.92~0.95不稳定时回调max-num-seqs单批次最大序列数32显存宽裕且短请求多时调大长上下文的请求占比高时调小max-model-len最大上下文长度4096按模型实际支持的上下文长度设置不要盲目按显存余量放大这里补充一个我自己的估算习惯。显存24GB模型权重经过4-bit量化后约8GB权重加激活余量约5GB剩余约11GB留给KV Cache。按13B模型、hidden size 5120、40层、每层每token的KV Cache约1KB来估算具体值取决于是否使用GQA、head数等结构设置4096 token的上下文约需要4MB左右的缓存空间。那么理论上能同时容纳的请求数在两百个量级——但实际运行中还要考虑计算并发上限和调度开销所以--max-num-seqs设成32是比较稳妥的起步值。如果后续请求的输入长度大多在几百token内可以适当往上调。4. 实测数据与调参方向并发数、批处理量和量化方案的对比清单服务能启动只是第一步能不能稳定扛住并发才是关键。这一节我记录了两轮压测的过程和典型数据并给出调参方向。4.1 压测场景与工具选型压测目标是回答三个问题这个服务在本地能跑到多高的吞吐在并发上升时延迟曲线会怎么变化不同的显存分配策略对性能影响有多大我用的是一个基于Python的简单压测脚本核心逻辑是并发请求一个统一的对话接口输入固定长度约300token的提示词设定生成长度上限为128token总共发送500个请求统计吞吐每秒处理请求数和延迟的P50、P90、P99。脚本的关键代码大致长这样import asyncio import aiohttp import time import statistics BASE_URL http://localhost:8000/v1/chat/completions MESSAGES [{role: user, content: 假设你是一个技术顾问请介绍深度学习推理引擎的发展历程。}] async def send_one(session, idx): payload { model: chat-model, messages: MESSAGES, max_tokens: 128, temperature: 0.7, } async with session.post(BASE_URL, jsonpayload) as resp: await resp.json() return time.perf_counter() async def main(): concurrency int(sys.argv[1]) total 500 async with aiohttp.ClientSession() as session: start time.perf_counter() tasks [] for i in range(total): tasks.append(send_one(session, i)) await asyncio.gather(*tasks) elapsed time.perf_counter() - start print(fthroughput: {total / elapsed:.2f} req/s) if __name__ __main__: asyncio.run(main())注意这个脚本用的并发模式是asyncio.gather同时发起500个请求和真实的流量曲线不完全一样。真实场景中请求到达是泊松分布式的有突刺但作为基准测试已经足够说明问题了。4.2 三组对比数据默认服务、大显存KV Cache、量化后的服务先看默认参数的实测值gpu-memory-utilization0.85max-num-seqs32未量化并发数平均延迟(ms)P90(ms)P99(ms)吞吐(req/s)842061086018.216620890132023.9328301250215032.66413502840580038.412820504650980041.2一个很典型的趋势出现了并发从8提升到64时吞吐确实在不断上升但P99延迟增长得非常快。到了128并发时吞吐几乎不再增长从38.4到41.2只涨了约7%但P99已经接近10秒这个延迟对在线服务来说已经不太能接受了。这说明服务的吞吐瓶颈不只是显存还有GPU计算能力本身。当并发高到一定程度每个请求都在等待计算资源吞吐接近上限而排队时间急剧拉长。第二组实验把gpu-memory-utilization从0.85调到0.95其他不变再次压测并发数平均延迟(ms)P90(ms)P99(ms)吞吐(req/s)16580850125025.7327901200205033.96412902710560039.6对比第一组并发32时吞吐只多了1.3req/sP99却略低了。这足以说明多分配KV Cache能容纳更多并发序列但当GPU算力成为瓶颈后纯粹调大显存利用率对吞吐的边际作用已经很小。这一点我在实际部署前完全没预料到以为显存越大并发能力越强事实证明还要顾虑算力。第三组实验把模型用4-bit AWQ量化后重新加载同样设置下再跑一遍并发数平均延迟(ms)P90(ms)P99(ms)吞吐(req/s)1636052078039.432540790122056.2648301430245071.512813202510485078.8量化后模型权重占用显著下降更多显存留给了KV Cache同时单次前向传播的浮点计算量减少吞吐明显上了一个台阶。我们可以看到同样并发64时量化后的吞吐比未量化高了近一倍。不过量化也带来了一个需要考虑的问题模型输出质量会略降。在对话场景中差异感知不明显但在追求精确输出的代码生成或数据分析场景里可能会感受到微妙差别。这是一个需要根据应用场景权衡的取舍。4.3 对这几组结果的解读单看数据可能会得出只要量化性能就高的结论但实际部署还需要结合场景。我的建议分三种情况如果服务面向内部工具请求波动不大建议保持不量化的高精度模型gpu-memory-utilization设0.85max-num-seqs设32附近保障输出质量。如果服务面向真实用户且并发波动剧烈建议启用4-bit量化调高gpu-memory-utilization至0.92同时在应用层加超时控制和熔断机制避免请求排队过久。如果请求文本普遍很短几十个token以内可以适当加大max-num-seqs因为短请求的KV Cache占用小单位时间能处理更多请求。另外max-num-seqs大小对P99的影响很大。我在一次测试中把它从32调到64并发不变结果平均延迟没有明显变化但P99翻了一倍。原因是调度器在同一时间窗口内塞入了更多请求GPU计算资源被切得更碎少数请求的排队时间显著拉长。所以如果对延迟敏感不要贪这个参数的数值。5. 本地推理服务的高并发调用细节从API到客户端服务部署好之后还要让客户端稳定地调用它。这一节重点讲API适配和高并发调用时的连接管理问题。5.1 OpenAI兼容接口的适配vLLM的OpenAI兼容接口让迁移成本变得很低。之前用OpenAI SDK写的代码只需要换掉base_url和模型名就能直接接上本地服务。from openai import OpenAI client OpenAI( api_keyEMPTY, base_urlhttp://localhost:8000/v1 ) response client.chat.completions.create( modelchat-model, messages[ {role: user, content: 你好介绍一下你自己.} ], max_tokens128, temperature0.7 ) print(response.choices[0].message.content)api_key随便给一个字符串本地服务不校验但请求体里不能不带这个字段否则客户端SDK会报错。除了最常用的chat/completionsvLLM还提供v1/completions原生补全接口和v1/embeddings向量嵌入接口。如果项目里要同时跑生成和向量检索可以一并使用省去另外搭一个向量推理服务的麻烦。5.2 连接池和超时机制高并发场景下客户端最常见的坑就是连接泄漏和单请求长时间不返回。先说连接池。在aiohttp中如果没有设置TCPConnector的连接限制默认并发数能达到非常大的值但这会打满服务端的连接队列导致大量请求排队等待反而拉低整体性能。我习惯把连接数限制在并发数的1.5倍左右connector aiohttp.TCPConnector(limit32, ttl_dns_cache300) session aiohttp.ClientSession(connectorconnector)再说超时。vLLM的推理服务不是普通的Web API——一个请求可能持续几十秒甚至更长。如果把超时设成3秒绝大多数正常请求都会被误杀。但也不能不设超时否则某个卡死的请求会一直占着连接。我一般分成两档连接超时设短一点5~10秒读取超时根据模型响应速度设长一点60~120秒。timeout aiohttp.ClientTimeout(totalNone, sock_connect5, sock_read120)还有一个经常被忽略的点客户端SDK的max_retries设置。当并发超过服务端容量时客户端会自动重试这个重试如果设置成默认值会在服务端已经过载时继续加压导致雪崩。建议把重试次数设成1~2次并加上指数退避。5.3 分场景负载均衡如果是单机部署vLLM一次只能加载一个模型目前官方支持加载多个模型需要同一显存池协调涉及更复杂的方案暂不在本文范围。如果面对的是多路不同请求类型比如A路要快响应、B路要长文生成更好的做法是起两个不同配置的vLLM实例实例A短请求max-tokens限制短max-num-seqs调高追求低延迟。实例B长文生成max-tokens调大max-num-seqs调低避免长请求挤占太多调度队列。这样两路请求互相隔离比在同一个实例内混跑更可控。成本是多占用一部分显存用于权重副本但在显存充足时这种隔离带来的稳定性收益是值得的。6. 踩坑记录能预见和不能预见的问题最后总结一下我在实操中踩过的几个代表性坑。VLLM文档写得不错但很多细节是文档里不会直接提示的。6.1 显存OOM的处置顺序使用默认参数跑了一段时间后某个长输入请求直接报了显存不足的错误。我当时第一反应是调小--max-num-seqs后来发现这样虽然避免了崩溃但吞吐也降了。后来排查清楚问题的真正根源是--max-model-len设得过大导致每个KV Cache块都按最长序列的大小预留显存被提前占掉太多。正确的处置顺序应该是这样先确认模型实际支持的上下文长度把--max-model-len设成合理值再估算单请求的KV Cache占用用模型层数、hidden size等参数做个粗算最后才考虑调整--gpu-memory-utilization和--max-num-seqs这两个参数。如果显存仍然不够再考虑量化模型、缩小batch或者换小模型。顺序颠倒的话容易白折腾。6.2 预热问题和首token延迟服务刚启动时的第一次请求会明显慢很多。这是因为模型权重和张量并行布局还没完全预热CUDA的上下文也没有初始化到位。更严重的其实是后续突然到来的大批量请求——在服务空闲一段时间后显存中的KV Cache块全部释放调度器需要重新分配这部分工作也会造成一定延迟。针对这类情况我一般会在服务启动后先发几个预热请求把显存和调度器跑热。如果是对外提供服务还可以在应用层加一个最小并发维持机制让服务不彻底空闲减少冷启动抖动。6.3 热加载多模型时的显存账要重新算vLLM支持在启动时通过多个--model参数加载多个模型但这会让显存分配更加复杂。不同模型共享KV Cache池时调度器内部要做更复杂的显存账目管理。我第一次尝试加载一个大模型和一个小模型时直接报错说没有足够显存给第二个模型分配KV Cache空间即使那时GPU还有空闲显存。这是因为vLLM在启动时会根据--gpu-memory-utilization一次性预留KV Cache的可用区。第一个模型占了一部分第二个模型加载时如果余量不足就会拒绝加载。解决思路是显式地给不同模型分配不同比例的资源而不是指望它自动平衡。如果这个机制太复杂就回到上一节说的多实例方案一个实例一个模型简单直接。6.4 请求长度对性能的长尾影响最后想提的是请求长度的分布。在一次压测里我模拟了100个短请求10个超长请求混合的流量。结果发现那10个长请求虽然只有10%的比例却占据了大约50%的GPU计算资源。短请求的P99延迟被长请求明显拉高。后来我在服务前加了一层请求分类策略把长度明显超标的请求路由到单独的长文实例同时给短请求设了更低的超时上限。这个调整把短请求的P99从原来的6秒降到了1.5秒效果非常明显。在实际操作中我的体会是vLLM确实把本地高并发推理服务变成了一件可行的事但它不是装完即高性能的银弹参数和场景的匹配程度决定了最终效果。每一个参数的选择背后都是算力、显存和延迟之间的交易想清楚交易目的再动手往往比盲目套用配置更高效。