本地跑大模型推理服务,最魔幻的时刻是:显存看着还有不少,显卡利用率却上不去,并发一高响应时间就开始坐过山车,而吞吞吐吐的“吐”字恰好点在大模型服务身上——跑不快、扛不住、不敢并发。我接手本地高并发推理服务之后,第一套方案用的是静态批处理,压到百来个并发时,显存快满了,利用率只有三成,服务曲线惨不忍睹。换用 vLLM 之后,同样的硬件,吞吐直接翻了几倍,QPS 从十几涨到接近上百,服务曲线也终于平了。
vLLM 是个面向大模型的高吞吐推理引擎,核心思路是把操作系统里的分页机制搬到显存管理上,配合迭代级调度,让 GPU 在并发场景下尽量满负荷运转。对做本地部署、Agent 服务、RAG 检索增强、多租户 API 的同学来说,它是把单卡推理从“能用”变成“能扛”的关键组件。这篇文章把我从部署、配置、压测到排障的完整过程写出来,重点关注并发参数的取舍和实际跑服务踩过的坑,希望能让后来的人少走弯路。
1. 为什么 vLLM 能扛住高并发:内部机制拆解
1.1 先找到瓶颈:KV Cache 才是显存杀手
搞过大模型推理的人都知道,自回归生成每多生成一个 token,都要把之前所有 token 的 Key 向量和 Value 向量缓存下来,供后续注意力计算使用,这一坨缓存在业界叫 KV Cache。它的显存占用不是固定的,而是随序列长度线性增长,并发一多,KV Cache 占用的显存甚至会超过模型权重本身。
传统推理服务在并发场景下最大的问题是“预留浪费”。调度器为了省事,往往按批次里最大的序列长度给每个请求预留显存,但实际生成的时候,很多序列根本不会生成那么长。我见过一个典型案例:模型最大支持 4096 token,一批 32 个请求里大部分只生成一两百 token,但显存按满长度预留,一次迭代就把显存吃光,后面的请求只能在队列里干等。这就像电影院按区域包场,每个区域按最大人数锁座,结果人没坐满,票少的观众也把整个区域占死了。
除了预留浪费,显存碎片也很讨厌。不同请求生成的 token 数差异很大,频繁分配释放 KV Cache 会让显存出现大量空洞。遇到碎片化严重的时候,明明总空闲显存还有几个 GB,但新的请求就是申请不到连续的显存块,只能报 OOM。传统推理引擎对这个问题基本无解,而 vLLM 的突破口恰恰就在这里。
1.2 PagedAttention:把显存当作虚拟内存来管
vLLM 的核心创新是 PagedAttention,思路非常朴素:既然操作系统能把内存切成页,按需分配、按页映射,显存为什么不能这么干?它把 KV Cache 切成固定大小的块,每个请求的逻辑块通过一张块表映射到物理块上。这样序列占用的显存不需要连续,可以零散地分布在显存的任何角落,生成到哪个 token 就按需申请哪个物理块,彻底解决了预留浪费和碎片问题。
这里的生活化类比是:以前一本书必须整本放在书架一排,哪怕只看一页也要占一整排的位置;现在按页存放,看到哪页就去取哪页,看完的页还能立刻腾出来给别人用。对显存这种稀缺资源来说,这种“按页管理”的收益极其明显,单个并发下的显存占用下降了,同卡能容纳的并发请求数自然就上来了。
块表机制还带来了一个隐藏福利:如果多个序列的前缀 token 完全一样,它们的逻辑块可以映射到同一组物理块上,相当于多个请求共享同一段 KV Cache。这就是前缀缓存和并行采样的底层基础。在多轮对话、Agent 频繁携带相同系统提示词的场景里,这个共享机制能把显存占用再砍掉一大截,后面讲配置的时候我会专门展开。
1.3 Continuous Batching:批次滚动起来才能压满 GPU
PagedAttention 解决的是“显存放得下”的问题,Continuous Batching 解决的是“GPU 不等人”的问题。传统推理服务的批处理是静态的:一批请求整体进入模型,等这批里最慢的一个序列生成完,整批才退出,然后下一批再进来。模型生成快慢完全由最慢序列决定,GPU 在大部分时间都在等待慢序列,有效吞吐很低。
vLLM 的调度是迭代级的。每一轮前向计算结束之后,调度器都会检查哪些序列已经生成完了,把它们立刻移出批次释放显存,同时把队列里排队的请求插进来。批次是动态滚动的,GPU 每一轮都在处理尽可能多的有效 token,而不是傻等最慢的那一个。这个机制对混合长度的请求尤其有效:短请求快速退场,长请求继续留在批次里,不会有“一颗老鼠屎坏一锅汤”的情况。
需要多说一句的是,vLLM 的调度器与模型执行是异步解耦的。调度器在 CPU 上做块分配、序列排序、请求准入决策,GPU 只负责执行前向计算,两者通过队列衔接。这个设计保证了调度开销不会打断 GPU 的计算流水线,也是高并发下延迟稳定的原因之一。理解了这个机制,后面调 max-num-seqs、max-num-batched-tokens 这些参数时你就知道每个旋钮在拧哪里了。
2. 部署前必须算清的账:硬件、量化与版本匹配
2.1 显存估算公式:权重和 KV Cache 都别漏
部署 vLLM 之前最重要的一件事不是装环境,而是算显存。很多人只按模型权重大小买卡,比如 7B 参数模型半精度权重大概 14GB,就觉得 24GB 显卡很稳,结果一跑并发场景立刻 OOM,因为压根没算 KV Cache 的账。实际上 KV Cache 在并发高的时候,占用很容易超过权重。
我一般会做一个简化估算。权重部分:参数量乘以精度字节数,7B 模型 FP16 就是 7 × 10^9 × 2 字节,约 14GB;13B 模型约 26GB。KV Cache 部分:2(Key 和 Value 两份) × 层数 × KV 头数 × 头维度 × 平均序列长度 × 精度字节数 × 并发序列数。不用背公式,直接记住经验值:对于常见 7B/13B 架构,每个 token 的 KV Cache 大约占 0.5KB 到 1KB 级别,结合你要支持的并发数和平均长度估算即可。举个例子:平均生成 1024 token、100 个并发序列,KV Cache 大约就是 0.5KB × 1024 × 100,约 50MB 到 100MB 的规模。看着不大,但模型层数和 KV 头数大的架构,单 token 占用会成倍上涨。
vLLM 的--gpu-memory-utilization参数控制的是“用多少比例的显存来做权重加 KV Cache 的预算”。默认是 0.9,意思是留 10% 给激活值、CUDA 上下文和临时张量。如果你在服务启动日志里看到gpu_memory_utilization=0.90,并且 waits 了很久才开始加载,说明这 10% 被激活值或碎片吃掉了,适当下调到 0.85 反而更容易稳定。我在一张 24GB 卡上跑 7B 模型的时候,0.9 利用率偶尔会启动失败,改到 0.85 就好了,差的 0.05 其实不影响最终吞吐。
2.2 量化选型:先跑通 FP16,再上 AWQ 或 GPTQ
显存不够用的时候,第一反应应该是量化而不是换卡。vLLM 主流的量化方案有三条路:AWQ、GPTQ 和 FP8。AWQ 是激活值感知量化,校准后对精度影响很小,7B 模型量化后显存能少 40% 以上,而且推理性能在多数显卡上有提升;GPTQ 需要离线做校准数据集,量化精度在某些长尾场景会有下降;FP8 在较新的 GPU 架构上效果最好,延迟和吞吐双优,但旧显卡不支持。
我的建议路径是先跑 FP16 把功能、接口、并发逻辑验证通,再根据显存余量决定是否量化。量化模型的加载路径和权重名称有约定,不要自己改了权重格式直接丢进去,容易踩加载失败。如果只是单纯想提升并发容量,优先 AWQ,实测精度损失在可接受范围,而且不需要太复杂的校准流程。如果对精度比较敏感,也可以考虑占用多一点显存但保持较高精度的 FP8。
2.3 环境版本匹配:这套组合直接抄
vLLM 对版本匹配比较敏感,Python、CUDA、torch 三者必须对上。我在实践中的稳定组合是 Python 3.10、CUDA 12.1 环境、torch 2.1 以上的预编译版本,然后通过官方预编译 wheel 安装 vllm,不建议源码编译,除非你要改调度器内部逻辑。源码编译最大的坑是算子构建失败,又慢又不稳定,我在一台机器上试过编译一小时后报显存不足,纯属浪费时间。
装好之后用vllm --version验证,能看到版本号和构建信息就说明依赖基本没问题。首次启动服务会加载权重并编译优化 CUDA kernel,这个过程可能持续几分钟,别以为是卡住了。如果你只有 CPU 环境想调试,vLLM 也能跑但速度没有意义,只适合验证流程。
3. vLLM 高并发服务配置:从启动命令到压测调优
3.1 一条生产级启动命令长什么样
vLLM 最方便的用法是直接起 OpenAI 兼容接口,对外暴露标准的/v1/chat/completions和/v1/completions,这样客户端代码不用感知引擎差异。我常用的生产环境启动命令大概长这样:
vllm serve /data/models/7b-awq \ --port 8000 \ --gpu-memory-utilization 0.85 \ --max-num-seqs 128 \ --max-num-batched-tokens 8192 \ --max-model-len 4096 \ --quantization awq \ --enable-prefix-caching \ --trust-remote-code这条命令里每个参数都不是随便写的。--gpu-memory-utilization 0.85把显存预算控制得保守一点,给激活值留足空间;--max-num-seqs 128允许一轮迭代同时处理 128 个序列;--max-num-batched-tokens 8192限制每轮迭代的总 token 数;--max-model-len 4096把单请求最大长度限制住,防止极端长请求把显存全部拖垮;--quantization awq对应量化格式;--enable-prefix-caching开启前缀缓存;--trust-remote-code允许加载带自定义代码的模型文件。启动成功后日志里能看到 GPU 内存分配情况、模型加载耗时和块数量,这些信息是后面排查问题的重要底座。
3.2 并发参数背后的调度逻辑:别把旋钮拧错方向
--max-num-seqs和--max-num-batched-tokens是两个容易被误解的参数。max-num-seqs限制的是调度器一轮迭代中最多容纳的序列数量,它不是 QPS 上限,而是并发上限。假设你设了 128,就算 QPS 到了 500,调度器每轮最多也只让 128 个序列在跑,剩下的在队列里排队。max-num-batched-tokens限制的是每轮迭代里所有序列加起来的 token 总数,这两个参数共同决定一次前向计算吃多少计算量。理解这个逻辑之后,调参方向就清晰了:计算量是 token 数决定的,序列数乘平均长度决定 batch 的大小。
调参的时候我踩过一个坑:为了追求吞吐把max-num-seqs拉到 256,结果每个序列的输入长度不一样,短的只有几十个 token,长的有两三千 token,调度器把长序列和短序列混进同一个批次之后,一轮迭代的计算时间被长序列拉长,短请求的延迟集体飙升。后面我把max-num-seqs调回 128,并把max-model-len限制到 4096,延迟曲线明显好转。实际生产环境里,我建议从默认值出发,逐步加压,每次只改一个参数,观察吞吐和延迟两个指标的联动变化,不要一上来就追求激进。
3.3 压测方法:先看懂三个指标再动手
判断 vLLM 服务健不健康,只看 QPS 是不全面的。我习惯盯三个指标:TTFT(首 token 延迟)、TPOT(每输出 token 的时间)、端到端吞吐(tokens/s)。TTFT 反映排队和预填充阶段的效率,TPOT 反映解码阶段的稳定性,吞吐反映整体资源利用率。压测的时候用并发客户端发请求,统计这三个指标的变化曲线。如果 TTFT 稳定但 TPOT 上涨,说明解码阶段计算瓶颈;如果 TTFT 涨得飞快,说明排队严重,并发容量接近上限了。
压测脚本不需要复杂,开几十个线程同时发请求即可,核心是统计 token 输出频率。我在服务端日志里重点关注prompt、generated、total token计数器,以及 preemption 次数。如果 preemption 次数在并发升高时同步暴涨,这个信号比延迟上升更危险,它说明 KV Cache 块严重不足,调度器在反复换入换出,性能会指数级恶化。整体调优的思路是:先保证 preemption 接近零,再压并发,最后微调 batch 参数。
4. 真实跑并发时的常见问题与排查实录
4.1 显存 OOM:不一定是显存真的不够
vLLM 服务在并发冲到某个阈值时,最直观的问题是进程直接退出,日志里报 CUDA OOM。但这里有一个容易误判的地方:日志显示的显存占用可能还不到 100%,进程却炸了。原因通常是显存碎片化或者激活值瞬时峰值太高。激活值在前向计算过程中会占用大量临时显存,特别是长序列混跑时,输入长度突增会让激活值翻倍。
遇到 OOM 不要第一时间降gpu-memory-utilization,先看日志里 KV Cache 分配了多少块、实际使用率多少。如果块使用率已经接近 90%,说明是 KV Cache 预算不足,可以降低max-num-seqs、限制max-model-len、或者升级量化精度。如果 KV Cache 使用率很低但依然 OOM,那就是激活值峰值问题,调整max-num-batched-tokens比调整max-num-seqs更有效。另外,启动时换到更小的gpu-memory-utilization值也能给激活值留出更多应急空间,代价是能部署的 KV 块减少,需要做权衡。
4.2 Preemption 频繁:性能雪崩的隐形元凶
Preemption 是调度器在 KV Cache 块不够时,把一部分序列的块换出到 CPU 内存,或者标记为待重算,腾出空间给新序列。这个机制保证了服务在大并发下不直接崩溃,但代价是性能雪崩:被抢占的序列重新激活时,要么从 CPU 换回显存,要么重新计算前缀,耗时都在秒级。我见过一个惨烈的案例:并发一高,日志里每轮都有三四个 preemption,吞吐从 90 tokens/s 掉到 20 tokens/s,看起来是硬件不行,实际上全是调度器在反复搬数据。
排查方法是盯服务日志或指标中的 preemption 次数。如果它随并发上升而显著增加,先降低max-num-seqs,给每个序列留出更充裕的 KV 块配额;再检查max-model-len是否被不合理的输入撑满;最后看是否需要开--enable-prefix-caching,前缀缓存能显著减少重复前缀序列的预填充计算,省下来的 KV 块就是并发余量。我自己调过一轮之后,preemption 从高峰几十次降到零,吞吐反而上涨了,教训就是别迷信高并发参数,稳定压倒一切。
4.3 输入长度差异过大:长文档查询拖垮短请求
本地服务很难保证所有请求长度均匀,尤其是 RAG 场景,有人发一句“今天天气”,有人传一整篇 PDF 做摘要。vLLM 批次里如果既有几百 token 的短请求,又有三四千 token 的长请求,计算时长会以最长序列为主,短请求虽然早该完成了,也只能等整轮迭代结束。这就是输入长度差异导致的“批次夹带”。
我试过几种解法。第一种是按长度分层,调两个服务端口,一个专门跑长文档处理,一个跑短对话,互不干扰,效果最好但要多占一份显存。第二种是限制max-model-len,从源头卡住极端长度,适合业务可以接受截断的场景。第三种是调整批次的调度权重,把序列按输入长度排序,短序列优先调度,但效果不如前两种稳定。如果你同时处理长文档和短问答,我建议优先考虑分层部署,别让少数的长请求拖垮多数短请求的体验。
4.4 多轮对话和 Agent 场景:前缀缓存是刚需
多轮对话场景里,客户端每次请求会把完整历史记录带进来,系统提示词、历史问答都会重复计算。打开--enable-prefix-caching之后,重复前缀的逻辑块会被多个请求共享,计算量明显下降。我在一个 Agent 场景里实测过,系统提示词占请求前缀的 60%,开启前缀缓存后吞吐提升了大概 30%,显存压力也变小了,非常值。
但需要注意,前缀缓存不是所有场景都有效。如果请求前缀每次都在变,或者业务是随机的单轮问答,缓存命中率很低,反而增加调度开销,这种情况关掉更合适。另外,流式输出在多轮场景下用到 SSE 长连接,单个请求占用的连接时间久,高并发时对服务端的并发连接数也有压力,部署时要把文件描述符限制和反向代理的超时时间一起调大,不然服务端没炸,代理先把连接断了。这些细节不亲自踩一遍根本想不到,所以我不厌其烦地强调:高并发推理服务,考验的从来不只是模型推理本身。
5. 配置基线沉淀:给后来者的一份实操清单
把 vLLM 跑顺之后,我总结了一套自己的配置基线,适合 24GB 单卡、7B 到 13B 模型、几百 QPS 以内的本地服务场景。启动参数先用--gpu-memory-utilization 0.85、--max-num-seqs 64、--max-num-batched-tokens 4096、--max-model-len 2048跑通,然后根据压测结果逐步调整。不要一上来就追求 128 并发,先把服务跑稳,再观察延迟和 preemption 的变化,逐步加压。
再分享一个小技巧:vLLM 的日志远比想象中有价值。每次请求结束都会输出 token 数、时延、吞吐,积累一段时间就能看出业务请求的长度分布。拿这个分布去反推max-model-len和max-num-seqs的配置,比盲目调参靠谱得多。我最后收敛的配置看起来并不激进,并发和显存都留了余量,但吞吐和延迟反而是所有尝试中最稳的。应对高并发推理,真正的功力在于读懂调度器在干什么,而不是把参数调到极限。