首页 / 资讯中心 / 文章详情

vLLM 高并发推理引擎:原理、部署与调优实践

vLLM 高并发推理引擎:原理、部署与调优实践 ★ FEATURED ARTICLE
把本地推理服务切到 vLLM 之后我的第一感受是以前那套按请求锁显存的部署方式真的可以退休了。同一个模型权重同一张显卡原生脚本并发十几路请求就开始频繁超时、显存告警换到 vLLM 高吞吐推理引擎后二三十路并发只是热身四五十路并发时整体吞吐反而更稳生成速度也没有肉眼可见的劣化。这篇文章不打算罗列优点重点把 vLLM 背后的技术机制、部署命令、参数调优和排障经验串起来讲一遍。目标读者是正在搭建私有化 API 服务、做模型推理加速、或者纯粹想搞清楚高并发推理原理的开发者看完之后可以直接在自己的环境里复制出一套本地高并发推理服务。1. 为什么高并发推理必须换引擎传统方案的三处硬伤先说结论如果只是单机单卡跑通一个模型 demo传统脚本直接加载推理其实也够用。但只要你把请求从一路变成十路、三十路传统部署的短板立刻就会暴露。这三个短板不是配置问题而是机制问题不换思路根本解决不了。1.1 显存管理像“包场”预留了大头却用不上大模型推理的显存大头除了模型权重还有一块很容易被忽略的开销KV Cache。每个请求进入解码阶段后自注意力机制会把已经生成 token 的 Key 和 Value 全部缓存下来作为后续生成的条件。传统方案在请求刚进来的时候就直接按最大上下文长度预分配一整块连续显存给这个请求。假设一个模型的 KV Cache 每个 token 需要几百 KB具体跟层数、头数、精度有关用户请求实际只输出几百个 token但预分配的 8K 上下文空间里绝大部分是空置的。并发一高这种“包场式”的分配方式就非常致命。几十路请求同时进来每路都预留一整块大显存显存利用率经常只有两三成碎片化严重服务动不动就 OOM。更尴尬的是A 请求没用完的空间也没办法临时借给 B 请求所有余量白白锁死在那里。这个问题越是在长上下文场景下越明显因为预分配量跟着最大长度一起涨。1.2 批处理还停在“整班发车”慢请求拖垮全队为了让 GPU 满载传统做法是把多个请求拼成一个 batch 一起推理。但多数框架的 batch 是在模型迭代层面同步的一批请求同时输入、同时做前向等这一批里最慢的序列生成完毕整批才会一起释放。万一这批请求里有一个生成长文其它早就生成完的短请求就得跟着空转等待响应延迟被硬生生拉长。这种机制的调度颗粒是“一批”不是“一个请求”。一轮推理结束到下一轮开始之间新请求进不来已完成请求出不去GPU 在批次切换时甚至会出现短暂的空闲。我在实际测试中见过很典型的情况一批 8 个请求里有一个要求生成 2000 token其余 7 个只需要 200 token结果这 8 个请求的返回时间被那个长请求拉齐到同一档位。用户感知到的平均延迟被显著抬高体验非常差。1.3 算力与显存的两难恰好是 vLLM 的切入点前面两个问题叠在一起就出现了一个经典的两难局面降低并发GPU 算力大量闲置提高并发显存立刻爆掉。经常是显卡利用率 30% 不到服务却已经在 OOM 边缘反复试探。我最早在 24GB 显存显卡上跑十几 B 参数模型想稳定支撑十路以上并发试过各种批处理大小和显存优化技巧效果都有限瓶颈始终卡在 KV Cache 的分配方式和批处理调度逻辑上。vLLM 之所以能解决这个问题是因为它没有在传统的分配方式上打补丁而是同时改掉了 KV Cache 的管理模型和批处理的调度粒度。这也是这篇文章后面要展开的核心内容理解了这两个机制你才能真正理解为什么同样的硬件换一个引擎并发能力能差出一个数量级。2. vLLM 高吞吐的秘密PagedAttention 与连续批处理2.1 PagedAttentionKV Cache 按页分配显存利用率拉到 90%vLLM 最先被大家熟知的是 PagedAttention这个机制的灵感来自操作系统里的虚拟内存分页。传统的 KV Cache 分配要求一整块连续显存PagedAttention 则把每个请求的 KV Cache 切成固定大小的物理块每个块能存一定数量的 token 状态。请求的逻辑缓存空间由一个块映射表来维护逻辑上连续物理上可以分散在显存各处。实际效果简单说就是需要用多少就分配多少不再按最大长度预留。一个请求生成 300 token那就只占 300 token 对应的 KV Cache 空间再多生成一个 token 就动态多分配一个块请求结束之后这些块全部回收。这种分配方式把显存浪费从“整个上下文长度”缩小到了“块”的粒度碎片化问题大幅缓解。按官方和社区公开的数据在多种常见场景下显存利用率可以到 90% 以上而传统预分配方案往往只有 20% 到 40%。我常用一个类比来解释这个差异以前去餐厅直接包一整层包间不管坐不坐得满钱先付、位置先锁死现在是去大厅的四人桌人来了拼桌走了立刻翻台利用效率自然完全不同。两种方式的对比看这张表更清楚对比维度传统静态 KV Cache 分配vLLM PagedAttention分配单位整块连续显存固定大小的物理块预留策略按最大上下文长度预分配按实际生成 token 逐步分配请求间复用基本隔离支持块级共享与前缀复用显存利用率较低碎片化严重高常规场景可达 90% 以上OOM 风险高并发下容易触发明显降低2.2 Continuous Batching请求级的动态调度PagedAttention 解决了显存“装不下”的问题Continuous Batching连续批处理解决的则是“跑不满”的问题。传统批处理是整班车统一发车、统一到站Continuous Batching 则是滚动发车每个迭代周期结束后已经生成完的序列立刻下车新到达的请求马上补位上车。这样一来GPU 在每个迭代里始终装满有效计算请求长序列不再拖垮短序列短序列完成后也不会堵住后面新进来的请求。连续批处理让 vLLM 真正做到了请求级别的并发而不是批次级别的并发。这个差异在高并发场景下非常关键用户请求到达时间参差不齐只有请求级调度才可能把 GPU 的计算时间片利用到极致。还有一个容易被忽略的细节是 vLLM 对 prefill 和 decode 的混合调度。prefill 阶段要处理用户输入的整段 prompt计算量很大decode 阶段每轮只生成一个 token计算量小但数据依赖强。传统框架往往让一批 prefill 或一批 decode 分别占满整个迭代结果一个超长 prompt 进来正在输出中的几十个请求全部卡顿。vLLM 把这两类任务放在同一个迭代批次里精细调度既不让 prefill 饿死其他请求也不让 decode 的空转浪费算力。这也是高并发下响应曲线依然平滑的重要原因。2.3 前缀缓存、量化与多卡加速手段还能再叠加除了 PagedAttention 和 Continuous BatchingvLLM 还有几个很加分的能力。前缀缓存Prefix Caching会把公共系统提示词、few-shot 示例的 KV Cache 缓存下来新请求只要带着相同前缀就不用重复计算 prefill。在我自己的业务场景里所有请求都会带一大段固定的系统提示词开启前缀缓存之后这部分耗时基本被抹掉了首 token 延迟肉眼可见地下降。量化支持方面AWQ、GPTQ、FP8 这些常见格式都能直接加载显存占用变低吞吐自然更高。尤其 FP8在较新的显卡上既能保持较好的效果又能明显压缩显存占用属于性价比很高的方向。另外遇到单卡显存放不下的大模型可以开启张量并行把模型权重切分到多张卡上加载。前面这些机制不是互相独立的在实际部署里经常一起用综合提升效果远大于单项优化的简单叠加。3. 本地高并发推理服务部署全流程3.1 安装环境前先搞清楚三件事第一件事是确认硬件底子。单卡 16GB 显存可以跑 7B 级别模型并支撑一定并发但做生产级服务我建议至少 24GB 起步想跑 13B 级别以上模型或者追求更高并发直接看向多卡方案。显存大小直接决定 KV Cache 能容纳多少并发序列这是高并发推理的第一约束。第二件事是确认显卡驱动和计算环境版本匹配。先别急着装软件先保证底层驱动正常能顺利跑通一个简单的矩阵运算。很多环境问题都出在这一层驱动版本和运行库不匹配后面 vLLM 跑起来会出现各种莫名其妙的报错排查成本很高。第三件事才是安装 vLLM 本身。直接用 pip install vllm 安装即可会自动拉取依赖。装完执行 import vllm 验证一下。我强烈建议在虚拟环境或者容器里安装不要直接装到全局环境vLLM 对依赖版本比较敏感全局环境容易跟其他深度学习项目打架一旦版本冲突会带来很大麻烦。提示首次安装完先跑一个最小样例确认环境 OK再进入正式部署。不要上来就加载大模型否则环境问题会被误判成模型问题排查方向容易跑偏。3.2 拉起服务一条命令加六个关键参数模型权重准备好之后启动服务只需要一条命令。以新版 vLLM 为例直接使用 vllm serve 入口vllm serve /data/models/xxx \ --served-model-name my-llm \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.92 \ --max-model-len 8192 \ --max-num-seqs 128逐个说下关键参数参数作用我的默认值--served-model-name对外暴露的模型名客户端调用时填入这个字段容易记的短名称--host监听地址0.0.0.0 表示允许局域网访问0.0.0.0--port服务端口8000--gpu-memory-utilizationvLLM 允许使用的显存上限比例0.92--max-model-len最大上下文长度决定 KV Cache 单请求上限8192--max-num-seqs最大并发序列数128模型路径要指向本地下载好的模型目录目录里一般包含权重文件、config、tokenizer 等文件用你的实际路径替换示例中的 /data/models/xxx 即可。启动日志里能看到的模型加载进度、显存分配信息和 KV Cache 块数量都是后续调优的重要参考。日志会明确打印出当前配置下能容纳多少 KV Cache 块并发上限不是拍脑袋定的而是显存算出来的。3.3 客户端调用标准 ChatCompletion 接口直接对接服务起来之后客户端调用就简单了。vLLM 对外暴露的是标准的 ChatCompletion 接口路径是 /v1/chat/completions用任意 HTTP 客户端都能直接对接。下面是一个最基本的不带流式的 Python 示例import requests url http://127.0.0.1:8000/v1/chat/completions payload { model: my-llm, messages: [ {role: system, content: 你是一个技术助手}, {role: user, content: 请简单解释一下分页注意力机制} ], temperature: 0.7, max_tokens: 512, stream: False } resp requests.post(url, jsonpayload, timeout120) data resp.json() print(data[choices][0][message][content]) print(data[usage])请求体里的 model 字段必须和启动时的 --served-model-name 保持一致这个字段决定请求路由到哪个模型。响应结构就是标准的 choices 数组加 usage 统计业务代码可以直接复用之前写好的解析逻辑几乎不需要改动。如果你要同时发大量请求直接开线程池或者协程池即可。我压测时常用的并发写法是from concurrent.futures import ThreadPoolExecutor def send_one(i): resp requests.post(url, jsonpayload, timeout120) return resp.status_code with ThreadPoolExecutor(max_workers20) as pool: results list(pool.map(send_one, range(200))) print(成功数:, results.count(200))流式输出也是必选项。生产环境里用户感知往往更依赖首 token 延迟流式模式能让第一个字立刻回来体验好很多。写法上把 payload 里的 stream 置为 True然后逐行解析服务端返回的 SSE 事件流payload[stream] True with requests.post(url, jsonpayload, streamTrue, timeout300) as resp: for line in resp.iter_lines(): if not line: continue line line.decode(utf-8) if not line.startswith(data:): continue chunk line[len(data:):].strip() if chunk and chunk ! [DONE]: token json.loads(chunk)[choices][0][delta].get(content, ) print(token, end, flushTrue)提示timeout 一定要给足。模型生成 512 个 token 在本地显卡上可能也只需要几秒到几十秒但高并发排队时单个请求的等待时间会被拉长。timeout 设太短会把正常排队误判为服务不可用。4. 高并发场景下的压测与参数调优4.1 压测脚本怎么设计才能看出真实水平部署完成之后别急着上线先压测。压测不是简单把并发数拉满跑一遍而是要分梯度比如 20、50、100、200 路并发逐级往上加观察每个梯度的吞吐、首 token 延迟、最大延迟和显存占用。这样你才能知道自己服务的容量边界在哪里而不是在某个并发点上碰一次运气。我自己的压测脚本一般长这样用协程模拟并发请求统计总耗时和每秒生成 token 数import asyncio import aiohttp import time async def one(session): payload { model: my-llm, messages: [{role: user, content: 写一段关于本地部署的小结}], max_tokens: 200, stream: False } async with session.post(http://127.0.0.1:8000/v1/chat/completions, jsonpayload) as resp: data await resp.json() return data[usage][total_tokens] async def main(): async with aiohttp.ClientSession() as session: tasks [one(session) for _ in range(100)] start time.time() results await asyncio.gather(*tasks) cost time.time() - start print(总请求数:, len(results)) print(总耗时(秒):, round(cost, 2)) print(总token数:, sum(results)) print(吞吐(tokens/s):, round(sum(results) / cost, 2)) asyncio.run(main())压测过程中要同时盯显存占用。观察重点有两个一是显存是否逼近上限二是日志里有没有出现 swap 记录。这两项数据比单纯看请求成功失败更重要它们能提前暴露服务容量不足的信号。压测结束后用结果判断当前参数配置是否合理吞吐上不去就去调并发上限和上下文长度延迟抖动明显就去查调度和 swap。4.2 吞吐、延迟、并发三者的参数平衡策略我把常用的参数平衡策略整理成一张表方便对照业务目标推荐配置倾向追求极限吞吐gpu-memory-utilization 调到 0.95 左右max-num-seqs 尽量拉高max-model-len 贴近业务真实值追求低延迟max-num-seqs 控制住一般 32 或 64max-model-len 不要拉太长长文档处理max-model-len 需要 16384 或更高但要接受并发容量的明显下降频繁命中相同系统提示词开启前缀缓存能显著降低首 token 延迟多卡才能放下模型设置合适的张量并行数量通信开销需要实测评估参数之间是联动的不是单点调到最大就行。max-model-len 越长每个请求的 KV Cache 成本就越高同样显存能容纳的并发请求数就越少max-num-seqs 设太小高并发请求会在服务端排队设太大一旦大量请求都是长上下文显存可能被吃光。gpu-memory-utilization 也不是越大越好显卡本身还有显示输出、驱动上下文这些开销建议给系统留一点余量。还有个进阶参数值得提一下max-num-batched-tokens它控制单个迭代批次最多处理的 token 数。默认值通常够用但在极限并发下如果某一路请求发了一个超长 prompt它的 prefill 计算量会很大可能让同批次正在 decode 的请求等待时间变长。调小这个参数可以让单次 prefill 占用的时间片更短对整体延迟波动有改善作用。4.3 多卡张量并行与整体架构延伸如果单卡放不下模型vLLM 可以在启动时加一个参数实现多卡张量并行把模型权重切到多张卡上共同推理。多卡部署首先需要确认卡间通信环境正常最好优先用高速互连的卡位组合通信带宽对真实吞吐影响很大。启动命令里加vllm serve /data/models/xxx --tensor-parallel-size 2 --gpu-memory-utilization 0.9表示把模型权重切成两份加载到两张卡上。卡数增加后单请求的显存压力下降但多卡之间同步中间结果的开销也会增加所以不是卡越多越快需要通过实测找到收益最大化的档位。架构层面vLLM 本身就是一个独立 HTTP 服务前面可以直接挂负载均衡或者网关做多副本扩展。比如内部业务有多个模型可以起多个 vLLM 实例分别监听不同端口再由统一网关按模型名做路由。这种横向扩展方式很干净每个实例无状态扩容缩容都不用改业务代码。5. 实操中踩过的坑常见问题与排查思路5.1 显存 OOM先从三个参数找问题OOM 是最常见的问题现象要么是启动阶段直接报显存不足要么是运行中某个请求触发 CUDA out of memory。我的排查顺序是先看有没有其他进程占显存再看 gpu-memory-utilization 是否设得过高最后查 max-model-len 和 max-num-seqs 是不是组合不合理。调整手法一般是这样先把 max-num-seqs 砍一半如果还不行就把 max-model-len 降一档比如从 8192 降到 4096再不行把 gpu-memory-utilization 从 0.92 降到 0.85。还有一种情况是模型权重本身太大比如 24GB 显存硬跑一个完整精度的大模型那基本没有调参空间直接换量化权重更合适。提示OOM 不只是高并发之后才出现。有时服务重启后第一次请求就 OOM大多是因为显存利用率设太满或者 max-model-len 太长和当前负载关系不大。5.2 首次请求慢得像卡死其实是预热问题服务启动之后第一个请求往往特别慢有时候甚至让人怀疑服务挂了。核心原因是模型加载到显存需要时间特别是模型文件比较大、磁盘读取明显变慢时这种体感会被放大。另外如果模型路径指向远端仓库而不是本地目录首次请求还会先执行下载操作那就更慢了。生产环境里建议固定使用本地模型目录起步前先把所有权重文件准备好。再就是主动做一次 warmup服务启动后先发一个很短的小请求让模型完成一次完整的 prefill 和 decode 流程把关键路径跑热。之后压测和正式流量的数据都比较真实不会把冷启动的耗时混入统计。5.3 并发一高延迟就抖大概率是 swap 在捣乱如果并发上去之后某些请求迟迟不返回延迟波动非常大优先去看服务日志里的 Swap 相关记录。KV Cache 块在显存不够时会被换到 CPU 内存内存访问速度比显存慢一个数量级一旦发生 swap响应就会明显变慢。这个信号是 vLLM 服务是否健康的重要指标。解决办法通常是把 max-num-seqs 降下来或者把 max-model-len 缩短让 KV Cache 的总量可控。如果这些手段用完了还是持续 swap说明显存容量已经不适合当前的并发目标换到更大显存的显卡或者走多卡方案是更现实的路径。另外业务里长系统提示词频繁出现时前缀缓存能显著降低 prefill 压力对延迟抖动也有帮助。5.4 版本依赖与模型格式的“暗坑”vLLM 迭代很快版本之间的行为差异也不小。最常见的问题是升级 vLLM 之后同一个模型加载报错或者 tokenizer 读取异常。这类问题大多出在依赖版本不匹配上。我的建议是部署完成之后把依赖版本记录固定下来形成可复现的部署环境不要随意升级。模型格式也要注意。不同量化格式对 vLLM 版本有要求比如某些量化权重是在旧工具链下生成的加载时可能报格式不支持或权重形状对不上。这种情况下要么把量化权重转换到兼容格式要么换一个匹配的 vLLM 版本。不管哪种方式都建议先在命令行跑通加载流程再暴露到服务层不要一上来就重启线上服务避免把问题带到业务现场。在很长一段时间里我一直觉得推理服务的瓶颈是显卡不够好直到把 KV Cache 管理方式和批处理调度改掉之后才发现原来的硬件潜力远没有被挖出来。vLLM 带来的提升很实在而且不需要改业务代码主要工作量集中在参数配置和容量规划上。我个人建议不要一开始就追求极限参数先用默认配置把链路跑通再逐步压测观察显存、延迟、吞吐三者的关系找到适合自己负载的平衡点。每个环境的负载特征不一样没有万能参数组合。如果后续想继续压榨性能可以试试量化模型和 FP8 推理很多场景下性价比突出再配合前缀缓存和多副本部署基本能满足比较高并发的内部服务需求。
阅读完成 · 觉得有帮助?
咨询建站