ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

vLLM高并发推理实战:PagedAttention与连续批处理调优指南

vLLM高并发推理实战:PagedAttention与连续批处理调优指南

1. 从单次对话到并发洪流:为什么本地推理必须换一套引擎

很多人第一次在本地跑大模型,都是拿一段几十行的脚本,加载权重,然后model.generate()一把梭。单条请求、单轮对话,体验确实不错,看着 token 一个个蹦出来,成就感拉满。可一旦你想把它变成一个能同时服务好几个人的小服务,问题立刻暴露:显存被一个请求占满,第二个请求进来直接排队;显存里明明还有空间,却因为碎片化分配不出去;批量请求时 GPU 利用率忽高忽低,像坐过山车。这不是模型不行,而是推理引擎的调度方式没跟上。

vLLM这个高吞吐推理引擎,解决的正是"本地高并发推理服务"这件事。它把大模型推理从"一次算一条"变成"一次算一批",并且用一套叫PagedAttention的显存管理机制,把 KV Cache 像操作系统管理内存页一样切块管理。结果就是:同样的显卡,吞吐量能翻好几倍,显存浪费大幅下降,多用户并发时响应更稳。这篇文章面向的是已经能在本地跑通模型、想进一步把它做成"能扛并发的小服务"的开发者,也适合想理解推理引擎底层调度逻辑的技术爱好者。我会从核心机制讲到实操部署,再到压测调优和踩坑排查,尽量把每一步"为什么这么做"讲透。

先说一个反直觉的结论:本地推理的瓶颈,绝大多数时候不是算力不够,而是显存管理和请求调度太粗糙。你显卡的算力可能只用了三成,剩下七成都在等显存分配、等前一个请求算完、等数据搬运。vLLM 的价值,就是把这七成找回来。

2. PagedAttention 到底在解决什么:KV Cache 的碎片化困局

2.1 传统 KV Cache 为什么浪费显存

要理解 vLLM,先得理解它最核心的机制 PagedAttention。大模型在生成每个 token 时,都要用到之前所有 token 的 Key 和 Value 向量,这就是KV Cache。传统做法是:一个请求进来,就按这个请求可能的最大长度,预先分配一块连续显存。比如你设最大长度 4096,那不管用户实际只聊了 50 个 token,这块 4096 的连续空间都得先占着。

问题来了。第一,预留浪费:用户实际用 50,你占了 4096,浪费率极高。第二,碎片化:不同请求长度不一,连续分配会导致显存里出现大量无法利用的小空洞,就像停车场里车停得歪歪扭扭,明明还有空位却停不进一辆大车。第三,无法共享:多个请求如果前缀相同(比如同一个系统提示词),传统方式各存各的,重复占用显存。

我实测过一个场景:单卡 24G 显存,跑一个 7B 模型,传统方式下并发到第 4 个请求就开始 OOM(显存溢出),但此时nvidia-smi显示显存占用才 70% 出头,剩下全是碎片。这就是典型的"看着有空间,实际用不了"。

2.2 分页思想:把 KV Cache 切成固定大小的块

PagedAttention 的思路直接借鉴了操作系统的虚拟内存分页。它把每个请求的 KV Cache 切成固定大小的block(块),比如每块存 16 个 token 的 KV。这些块在物理显存里不需要连续,可以散落在各处,然后用一张block table(块表)记录逻辑块到物理块的映射。

这样做带来三个直接好处:

  • 按需分配:请求生成到第 17 个 token 时,才分配第二个块,几乎不预留浪费。
  • 消除外部碎片:所有块大小一致,显存里不会出现"大小不匹配"的空洞,碎片率极低。
  • 前缀共享:多个请求如果开头相同,它们的 block table 可以指向同一批物理块,用Copy-on-Write机制在需要分叉时才复制。这在多轮对话、批量同提示词场景下省显存非常明显。

用一个生活类比:传统方式是给每个客人预订一整层楼,哪怕他只住一晚;PagedAttention 是给每个客人按需分配标准间,楼层可以打散,还能让几个客人先合住同一间,等有人要独立时再开新房。

2.3 连续批处理:让 GPU 不再空转

光有显存管理还不够,调度也得跟上。传统批处理是"静态批":凑齐一批请求,一起算,等这批全部生成完,再收下一批。问题是每个请求生成长度不同,短的早就算完了,但 GPU 还得等长的那个,期间算力白白空转。

vLLM 用的是连续批处理(Continuous Batching),也叫迭代级调度。它的逻辑是:每生成一个 token 就是一轮迭代,每轮结束后,已经完成的请求立刻退出批次,腾出的位置马上塞进新来的请求。GPU 几乎永远在满负荷算有效 token,而不是等某个慢请求。

这两个机制合在一起,才是 vLLM 高吞吐的真正来源。PagedAttention 管"显存怎么省着用",连续批处理管"算力怎么不闲着"。缺一个,吞吐都上不去。

3. 本地把服务跑起来:环境、参数与第一次压测

3.1 环境准备里最容易忽略的三件事

装 vLLM 本身不复杂,pip install vllm基本能搞定,但有几个细节不注意,后面会反复踩坑。

第一,CUDA 版本和 PyTorch 的匹配。vLLM 对 CUDA 版本比较敏感,装之前先nvcc --version和python -c "import torch; print(torch.version.cuda)"对一下。如果版本错位,常见表现是能 import 但一加载模型就报 kernel 相关错误。建议用官方推荐的组合,别自己乱升。

第二,显存预留参数gpu_memory_utilization。这个参数默认 0.9,意思是允许 vLLM 占用 90% 的显存。很多人本地还开着桌面环境、浏览器,显存本来就被占了一部分,结果 vLLM 按 90% 去算可用空间,直接 OOM。我的经验是本地开发机设成0.85 甚至 0.8,留出余量给系统和临时波动。

第三,模型权重的存放位置。如果模型放在机械硬盘上,加载会慢到怀疑人生。放到 SSD 上,加载时间能从几分钟降到几十秒。这个不是 vLLM 的问题,但直接影响你的调试效率。

3.2 启动命令与关键参数逐个拆

最简启动方式是用 vLLM 自带的 OpenAI 兼容服务:

python -m vllm.entrypoints.openai.api_server \ --model /path/to/your/model \ --served-model-name local-model \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.85 \ --max-model-len 4096 \ --max-num-seqs 64 \ --tensor-parallel-size 1

逐个说这些参数为什么这么设:

  • --max-model-len:模型支持的最大上下文长度。设太大,vLLM 会按这个上限去规划显存,可能直接启动失败;设太小,长对话会被截断。建议先按模型原生上限设,跑不通再往下调。
  • --max-num-seqs:单批次最多同时处理多少个请求。这个值直接决定并发能力,但也吃显存。24G 卡跑 7B 模型,设 64 通常没问题,设 256 就可能 OOM。
  • --tensor-parallel-size:张量并行数,单卡就设 1,多卡才需要调。本地单卡千万别乱设,设错了直接报错。
  • --gpu-memory-utilization:前面说过的显存占用比例,本地留余量。

启动成功后,服务会监听 8000 端口,接口格式和 OpenAI 兼容,意味着你原来调云端 API 的代码,改个base_url就能直接指向本地。

3.3 第一次压测:别用单线程测吞吐

服务跑起来后,很多人拿一个 curl 测一下,看到能返回就以为成了。这测的是延迟,不是吞吐。要测并发能力,得用压测工具同时打多个请求。

我一般用wrk或者写个简单的 Python 脚本,开 32 个并发,每个请求固定输出 128 个 token,跑 60 秒,看两个指标:吞吐(tokens/s)和首 token 延迟(TTFT)。这两个指标要分开看,吞吐高不代表体验好,首 token 延迟高用户会觉得"卡"。

一个实测参考:单张 24G 卡跑 7B 模型,传统脚本单请求大概 30-40 tokens/s;vLLM 在 32 并发下,总吞吐能到 800-1200 tokens/s,提升一个数量级。但注意,单请求的延迟可能反而略高,因为要和其他请求共享算力。这是吞吐和延迟的经典权衡,看你服务的目标是什么。

提示:压测时一定要监控nvidia-smi的显存和 GPU 利用率。如果利用率长期低于 60%,说明批次没吃满,可以适当调大max-num-seqs;如果显存接近上限,就要往下调。

4. 并发上不去时的排查链路:从现象到根因

4.1 现象一:请求排队严重,首 token 延迟飙升

这是最常见的抱怨。表现是并发一高,每个请求都要等很久才开始出字。排查顺序我一般这样走:

先看max-num-seqs是不是设太小。如果设成 8,那第 9 个请求只能等,延迟自然高。调大这个值,但每调一次都要观察显存。

再看是不是prefill 和 decode 抢资源。prefill 是处理输入提示词的阶段,计算密集;decode 是逐 token 生成阶段,显存带宽密集。如果大量长提示词请求同时进来,prefill 会占满算力,decode 被饿死,已经在生成的请求就卡住。vLLM 有chunked prefill相关配置,可以把长 prefill 切块,和 decode 交错执行,缓解这个问题。开启后长提示词场景的首 token 延迟会明显改善。

最后看是不是调度队列积压。vLLM 内部有等待队列和运行队列,如果等待队列一直很长,说明到达速率超过了处理能力,这时候要么加卡,要么限流,要么降模型规格,没有别的捷径。

4.2 现象二:显存够但启动就 OOM

这种最迷惑人。nvidia-smi看显存还剩不少,但 vLLM 启动就报 OOM。原因通常是显存碎片或者预留计算方式。vLLM 启动时会按gpu_memory_utilization去估算可用 KV Cache 块数,如果系统其他进程占着显存但没释放干净,估算就会出错。

解决办法:启动前先确认没有残留的 Python 进程占着显存,nvidia-smi里如果有僵尸进程,kill掉再启动。另外把gpu_memory_utilization往下调 0.05 试试,很多时候就好了。

4.3 现象三:吞吐忽高忽低,像心电图

这种波动通常来自请求长度差异太大。一批请求里混着超长和超短的,连续批处理虽然能动态进出,但长请求会拖慢整体节奏。如果业务允许,尽量把相似长度的请求聚在一起,或者对超长请求单独走一个队列。

还有一个隐蔽原因是采样参数。如果开了很高的top_k或者复杂的采样逻辑,每步 decode 的 CPU 开销会变大,GPU 等 CPU,吞吐就抖。生产环境建议用简单的采样配置,把复杂度降下来。

5. 把吞吐再压榨一截:量化、前缀缓存与多卡

5.1 量化:用精度换显存和速度

如果显存吃紧,量化是最直接的手段。vLLM 支持 AWQ、GPTQ 等量化格式。4bit 量化能把模型显存占用降到原来的三分之一左右,同时因为权重读取量减少,decode 阶段的速度往往还有提升。

但量化不是免费的午餐。精度损失在复杂推理任务上可能比较明显,简单问答和摘要任务基本无感。我的建议是:先跑 FP16 建立基线,再跑 4bit 对比效果,如果业务能接受再切。别一上来就量化,出了问题分不清是量化还是配置的锅。

5.2 前缀缓存:多轮对话的省显存利器

前面提过 PagedAttention 支持前缀共享。vLLM 有自动前缀缓存功能,开启后,多个请求如果开头相同(比如同一个系统提示词),它们的 KV 块会被复用。在多轮对话场景下,历史对话就是天然的前缀,这个功能能省下大量显存,直接转化为更高的并发数。

开启方式一般是在启动参数里加--enable-prefix-caching。实测在多轮客服场景下,同样显存能多扛 30%-50% 的并发。这个收益非常划算,建议默认打开。

5.3 多卡并行:什么时候值得上

单卡跑不动或者并发要求很高时,才考虑多卡。vLLM 支持张量并行(TP)和流水线并行(PP)。本地小规模部署,TP 更常用,把一层拆到多卡上算。但 TP 有通信开销,卡越多,通信占比越高,扩展效率会下降。两张卡通常能到 1.7 倍左右,四张卡可能只有 2.8 倍,不是线性增长。

如果只是显存不够装模型,TP 是合理的;如果是为了提高并发,先确认单卡的批次是不是已经吃满,没吃满就加卡是浪费。

场景推荐手段预期收益注意事项
显存不足装不下模型4bit 量化显存降约 60%验证精度损失
多轮对话并发低前缀缓存并发提升 30%-50%提示词需有公共前缀
单卡批次没吃满调大 max-num-seqs吞吐线性提升盯紧显存
单卡彻底跑不动多卡 TP1.7-2.8 倍通信开销随卡数上升

6. 几个只有踩过才知道的实操细节

第一个坑是模型路径里的中文或空格。vLLM 加载模型时对路径处理有时不够健壮,路径里带中文或空格可能报奇怪的错。养成习惯,模型放在纯英文无空格的目录下。

第二个坑是服务重启后显存没释放。有时候Ctrl+C停服务,显存没马上回收,再启动就 OOM。等十几秒或者手动确认进程退出再启动,别急着连点。

第三个坑是客户端超时设置。vLLM 在并发高时首 token 延迟可能到几秒,如果客户端超时设成 3 秒,请求会被客户端主动断开,但服务端还在算,白白浪费算力。把客户端超时设长一点,比如 60 秒。

第四个经验是日志级别。默认日志在并发高时刷屏严重,影响性能。生产环境把日志级别调到 warning,只在必要时开 debug。

第五个细节是健康检查接口。做服务化时,加一个轻量的健康检查,别用真实推理请求去探活,否则探活本身就在消耗算力。vLLM 有/health接口,直接用它。

这些细节单看都不大,但叠在一起,决定了你的本地推理服务是"能跑"还是"能扛"。

7. 从能跑到好用:我个人的调优顺序

如果让我给一个刚上手 vLLM 的人排调优优先级,我会这么排:先把gpu_memory_utilization和max-model-len调到一个能稳定启动的组合,这是地基;然后开前缀缓存,这是性价比最高的一步;接着根据压测结果调max-num-seqs,找到显存和吞吐的平衡点;最后才考虑量化和多卡。顺序反了,很容易在错误的地基上做无用功。

我在实际使用中发现,很多人一上来就纠结量化精度、多卡配置,结果连单卡的最佳批次都没找到。其实单卡调好了,吞吐已经能满足大部分本地场景。vLLM 这套引擎的真正价值,是让"本地高并发推理"从一件需要深厚底层功底的事,变成一件配置对了就能跑好的事。剩下的,就是根据你的业务场景,在吞吐、延迟、显存三者之间找到那个属于你的平衡点。这个平衡点没有标准答案,只能靠压测数据说话。

RELATED READING

延伸阅读

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