news 2026/10/16 4:44:40

vLLM高吞吐推理:原理、部署与性能调优实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
vLLM高吞吐推理:原理、部署与性能调优实践

vLLM这个名字,做本地大模型推理的朋友应该不陌生。我在搭内部AI评测服务那阵子,被传统的推理方式折腾得够呛:显存动不动被占满,请求一多就开始排队,后台看着GPU在忙,实际吞吐却低得让人着急。后来把推理引擎换成vLLM,同样的单卡资源,并发处理能力直接上了一个台阶。这篇文章是我从踩坑到调优的完整记录,主要讲清楚vLLM的高吞吐原理、本地部署步骤和性能调优经验,适合正在搭建本地推理服务、或者想把已有服务并发能力提上去的开发者参考。

1. 高吞吐推理服务:先搞清楚替换vLLM之前痛在哪

1.1 本地推理服务的传统痛点

传统的本地大模型推理请求是按序列排队处理的:一个请求来了,占住整块GPU把文本算完,然后释放资源给下一个。这种模式在小流量demo里没问题,一旦开始做高并发服务,立刻暴露出三个问题。

第一个是显存碎片化。每个请求在生成过程中都会产生KV Cache,这是Transformer推理时无法避免的中间缓存。不同请求的生成长度不一样,叠加起来就会把显存切成大量大小不一的空洞,后续请求明明看到显存还有剩余,却很难申请到一整块连续空间。第二个是空闲等待。普通批处理会把一批请求整体做前向传播,这一批的结束时间由最后一个完成的序列决定,GPU利用率就会波峰波谷明显,看起来一直在跑,实际上大量算力在空闲。第三个是排队模型僵化。一个长回答的请求可能占着显存和GPU好几分钟,其他短请求全部排队等,这会让高并发场景的尾延迟极不稳定。

说白了,传统推理框架追求的是“单请求稳定完成”,而高并发推理服务追求的是“单位时间处理尽量多请求”。目标变了,底层设计也要跟着变。

1.2 vLLM把问题拆成了两块

vLLM把高吞吐优化重点放在两个层面。

第一层是KV Cache管理。请求在生成过程中都会产生一批中间缓存,即KV Cache,这是推理时无法避免的开销。vLLM引入了PagedAttention,让KV Cache按固定大小块存储,并像操作系统的虚拟内存一样维护块表,碎片问题被极大缓解。

第二层是请求调度。它不再等整个序列跑完才决定下一个请求是否进入批处理,而是以token生成为单位持续调度,完成一个立刻腾出位置,插入一个新的,这也就是常说的Continuous Batching。两层配合下来,同一块显卡能同时容纳的请求数明显增加,吞吐自然就上去了。

我想用一个直观类比:传统批处理像满载公交车,必须一站到头;vLLM更像地铁加换乘,站与站之间不需要整车等待,到站客流马上腾出空间。处理并发请求的逻辑完全变了。

1.3 什么场景值得上vLLM

不要被“高吞吐引擎”这个定位吓到,它不只是给大型集群用的。比较适合上vLLM的场景包括:本地API服务,多个人或多个业务系统同时调用同一个模型接口;离线批量评测和数据处理,需要持续打满GPU,完成条数优先于单条时延;交互式Agent框架,会频繁发多个短请求,希望系统提示词和公共前缀被复用;单卡日常实验,希望把有限显存用到极致的场景。

反过来,如果只是偶尔在Jupyter里跑一次推理,那vLLM的启动和配置成本反而高,用最简单的推理脚本更合适。这也是我经常提醒别人的一句话:vLLM解决的是“多人同时用”和“批量持续用”的问题,不是让单次推理变快。它优化的目标函数是吞吐量,不是时延。

2. 核心原理拆解:PagedAttention与连续批处理

2.1 PagedAttention:给KV Cache做虚拟内存换页

让我从最底层开始讲它的关键机制PagedAttention。在生成式模型里,每处理一个token,都需要读取之前所有token产生的KV Cache。Transformer的attention计算要求每个请求的全部历史键值对连续存在内存里,所以传统方案里每个请求都得预分配一块连续显存,长度难预测——要么给短请求浪费空间,要么长请求分配不够。

vLLM的做法是不要求用户的KV Cache物理连续,而是按固定大小切成块,每块单独占一块显存,块与块之间用块表串联起来。计算attention时通过块表逐块寻址。这个机制直观上解决了两个问题:分配即用,按生成的token数逐块扩张,不再担心预分配过大或过小;碎片收敛,块大小固定,剩余空间最多浪费一个块内空隙,显存利用率明显提升。

这个机制很像操作系统的分页:进程不需要连续物理内存,而是通过页表完成虚拟地址到物理地址的映射。给它起名“PagedAttention”不是概念拼凑,而是真的把这一套思想移植到了GPU推理调度上。用起来我发现,同等显存下能承载的并发长度总和确实高了很多,这也是vLLM高吞吐的第一个底层支撑。

2.2 Continuous Batching:调度粒度小到每一步

传统批处理是把一批请求一起做前向传播,但这批里有的序列已经生成了结束符,有的还在解码,有的刚开始预填充。如果强行等全部结束再换一批,GPU总会有明显的空窗口。

连续批处理把调度粒度缩小到一次迭代。每一轮前向传播结束后,立即检查哪些序列满足完成条件,把它们从本批次移除,并把这些空位让给等待队列中的新请求。同时在一个大batch里,不同序列可以处于不同生成阶段,prefill和decode可以混合调度。vLLM会把已经完成的序列直接归还它的KV Cache块,然后调度器给新请求分配块并加入。GPU每时每刻都在处理有意义的计算,不会因为“等待某一根长尾”而闲置。

用之前那个类比继续说:传统方案是一趟车拉着一批乘客直达终点站,连续批处理则是随上随下的换乘系统,每趟车的上座率都被拉满。实践里的表现是,我压测同一模型时,切到vLLM后GPU利用率从40%多稳定到90%以上,吞吐量变化非常直观。这就是vLLM高吞吐的第二个底层支撑。

2.3 前缀缓存与投机采样,vLLM还能再省一点

vLLM另一个经常被忽略但实际很有用的特性是自动前缀缓存。很多业务场景里,所有请求的system prompt或者prompt的前半段是完全相同的。vLLM可以把公共前缀的KV Cache计算一次保存下来,新请求如果前缀一致,直接复用对应的KV Cache,而不是重新执行耗时的prefill阶段。这在多轮对话和Agent场景里收益非常明显,公共前缀越长,收益越大。

还有一类辅助优化叫投机采样:用一个更小的草稿模型先快速生成多个候选token,再交给大模型一次性验证。如果草稿预测准确率足够高,相当于一段序列的多次串行decode被压缩成一个并行验证步骤,能进一步降低单请求时延。这个功能我一般是在有多余CPU资源或内存时用,单卡场景收益需要实测,因为草稿模型本身也要占显存,配置不好反而拖后腿。

到这里,vLLM高吞吐的理论基础基本齐了:KV Cache高效复用,调度无空档,前缀不再重复计算。后面要说的部署和调优,都是围绕这三件事展开的。

3. 本地部署实操:从零搭建一个高并发推理服务

3.1 环境准备与安装

我用的环境是:一台24GB显存的GPU机器,Linux系统,Python 3.10,CUDA驱动12.1,PyTorch版本按安装文档匹配。装vLLM没有想象中复杂,直接pip是最省事的路线。

conda create -n vllm python=3.10 -y conda activate vllm pip install vllm

如果网络下载遇到问题,先换镜像源再装,避免卡在编译依赖上。20分钟到30分钟左右装完,装好后可以验证一下当前核心模块是否可用。

python -c "import vllm; print(vllm.__version__)"

装的时候有个坑需要提醒:不要在同一环境里装大量杂七杂八的CUDA相关包,版本冲突会导致导入时报错。我的做法是单独建一个conda环境,只放vLLM和压力测试需要的轻量依赖,装完就锁环境。这样后面即使升级其他库,也不会把推理环境搞崩。

3.2 启动命令与参数逐个拆解

vLLM自带常见大模型API协议的HTTP接口,启动一个本地高并发推理服务后,客户端只需要替换base_url和模型名,就能用标准方式调用。我的启动脚本大致是这样:

python -m vllm.entrypoints.openai.api_server \ --model /home/user/models/llm-base \ --served-model-name local-llm \ --gpu-memory-utilization 0.85 \ --max-model-len 8192 \ --max-num-seqs 32 \ --max-num-batched-tokens 4096 \ --enable-prefix-caching \ --port 8000

参数不多,但每个都值得说清楚。--model指向模型权重目录,目录里的格式要符合当前框架的要求。--served-model-name是暴露给客户端的模型名字,可以按内部约定自定义,不必等于真实模型名。--gpu-memory-utilization控制vLLM能使用的最大显存比例,我习惯留15%左右给后处理和其他系统功能,如果纯做服务压测,可以调到0.9以上。--max-model-len决定模型最大上下文长度,这个值影响KV Cache块的分配预算,设得太大,能并发的序列数量会自动下降。--max-num-seqs是调度器允许的同批次最大序列数,常规业务可以先从16到32起步。--max-num-batched-tokens控制单批次累计token数上限,实际是把prefill和decode的计算量限制在一个可控范围内。--enable-prefix-caching开启自动前缀缓存,公共前缀场景收益很大。

启动完成后,日志里会出现类似“Initializing an LLM engine”和“# GPU blocks: xxx”,这个GPU blocks数量就是KV Cache的可用块数,是后续判断显存是否够用的关键指标。之后访问本地地址加/v1/models,如果能看到刚设置的模型名字,服务就算起来了。

3.3 并发压测脚本与结果解读

接口能否并发,不能凭感觉。我用线程池写了一个轻量压测脚本,重点观察三个指标:平均时延、成功率和整体吞吐。

import json import time import urllib.request from concurrent.futures import ThreadPoolExecutor API_URL = "http://127.0.0.1:8000/v1/completions" PROMPTS = [ "请用一段话解释什么是大语言模型。", "帮我把下面的句子改写成更正式的风格。", "请列举三个学习编程的建议。", ] def run_one(prompt): payload = { "model": "local-llm", "prompt": prompt, "max_tokens": 128, "temperature": 0.7 } req = urllib.request.Request( API_URL, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"} ) t0 = time.time() try: with urllib.request.urlopen(req, timeout=120) as resp: json.loads(resp.read().decode()) return time.time() - t0, True except Exception as exc: return time.time() - t0, False def bench(concurrency, total=50): tasks = [] with ThreadPoolExecutor(max_workers=concurrency) as pool: for i in range(total): prompt = PROMPTS[i % len(PROMPTS)] tasks.append(pool.submit(run_one, prompt)) results = [t.result() for t in tasks] done = [r for r in results if r[1]] success = len(done) avg_latency = sum(r[0] for r in done) / max(success, 1) print(f"并发{concurrency} 成功率{success}/{total} 平均耗时{avg_latency:.2f}s") for c in [1, 4, 8, 16, 32]: bench(c)

我拿一个中等规模开源模型跑过一轮,单请求平均约2.2秒,并发8时平均耗时约2.8秒,并发32时平均耗时约4.5秒。表面上看单请求变慢了,但按总吞吐算,从单位时间只能完成约10个请求,到并发32时能完成约50个请求,吞吐提升非常显著。高并发服务的指标从来不是单请求耗时,而是整体吞吐和成功率,这个观念要转过来。

4. 性能调优实战:让吞吐量再上一个台阶

4.1 显存预算先算清

调优的第一步是看清显存账本。推理时模型权重和KV Cache都占显存,前者基本上是固定的。以典型的fp16权重为例,7B模型权重约14GB,13B模型约26GB。如果还有KV Cache预算,那两者不能同时超出总显存。我一般先跑一下服务看日志中的GPU blocks,然后用下面公式粗估剩余可用空间。

可用KV显存 = 总显存 × gpu_memory_utilization - 模型权重占用量 - 运行时预留

比如24GB显卡,设0.85,模型占14GB,剩余约6.4GB左右。块大小和每个token的KV大小决定这个预算能支撑多少个序列,这个测算很有用,否则一股脑调高并发数,服务启动时会直接OOM或者自动降并发。测算的逻辑也简单:KV Cache大小和max-model-len、每序列实际生成长度强相关。我在实验时会把max-model-len先按业务最大上下文调,然后根据显存余量反推并发上限,不要逆向操作。

4.2 批处理参数作用的细节

vLLM调度器有几个参数是互相制约的,最有感受的是max-num-batched-tokens和max-num-seqs这一对。max-num-seqs决定同一个batch最多能放几个序列,限制的是序列数量。max-num-batched-tokens决定这批序列总共允许预置多少token,限制的是计算规模。

如果max-num-seqs设得大,但max-num-batched-tokens设得小,那很多序列会被挤在后面等待;反过来batching token设得过大,prefill一步占用的显存和计算量会很高,带来一次尖峰。实际经验是,同步调大它们,做好prefill阶段上限控制,再结合显存余量逐步加,而不是一次拉到最大。

还有一种常见冲突:业务里输入长度差异非常大,有的prompt只有几十token,有的上千。此时最大显存预算被长prompt请求吃得很严重,短prompt也会跟着排队。我的解法是把服务拆两个端口,一个给短输入,一个给长输入,各自设置不同的max-model-len。成本几乎为零,排队体验好了很多。

4.3 单卡与多卡配置速查

并不是调参越多越好,先看资源。单卡消费级显卡优先做量化,4bit量化可以在显著降低权重显存的同时,保留大部分推理质量。vLLM对多种量化格式支持都还可以,具体格式以官方文档为准。如果有多卡,可以开启张量并行,把模型权重和计算切到多卡,但小模型切多卡收益通常不明显,还会增加通信开销,我一般只有较大模型才开。

下面是常用配置速查,供参考:

资源情况模型规模参考推荐启动关键参数
单卡16GB小规模权重量化加载, gpu-memory-utilization 0.9, max-num-seqs 16
单卡24GB中等规模权重fp16, gpu-memory-utilization 0.85, max-num-seqs 32
单卡40GB以上中大规模权重fp16, gpu-memory-utilization 0.9, max-num-seqs 64
双卡均摊更大规模权重张量并行size 2, 注意跨卡通信带宽

这个表不是铁律,它更像一个起点。我在不同显卡上试过之后发现,真正影响服务稳定性的是“峰值显存”而不是“平均显存”,所以压测阶段一定要留观察窗口,注意GPU显存曲线是否在某个并发下突然跳高。

5. 常见问题与排查技巧实录

5.1 CUDA Out of Memory:先看日志再决定减参

最典型的启动报错是CUDA OOM。很多人的第一反应是调低gpu-memory-utilization,但有时候问题不在总显存,而是某个prefill批次过大。

我的排查顺序是:一看启动日志中GPU blocks数量,如果很少,说明KV Cache没有足够空间;二看完整报错栈明确哪个tensor分配失败,是在权重加载阶段,还是在第一个请求进入时;三在OOM点位明显指向上大batch时,先调低max-num-seqs,再考虑调低max-model-len;四如果OOM发生在启动后立即执行大prompt请求,把max-num-batched-tokens降到比如2048,基本能缓过来。

另外提醒一个经验:不要开着多个实验同时占住同一张卡,这最浪费时间。显存被其他任务吃掉的场景,vLLM再优化也白搭。

5.2 服务起来了但请求总是排队超时

API服务启动没问题,客户端频繁超时,这也是高并发服务里的高频问题。原因无外乎三种:每个请求的prompt太长,prefill阶段就要好几秒,客户端默认几秒的timeout自然不够;QPS超过服务实际能承载的上限,请求在等待队列里堆积;请求体携带大量不需要的上下文,服务端还没开始处理就已超时。

改法上,长prompt的客户端timeout拉到60秒以上是正常操作。服务端可以看一下日志里的耗时分布。如果确实是批量prompt太多导致排队,优先做限流而不是无限扩并发。我自己的经验是,把API服务前面加一个轻量队列,既能让负载平滑,也能在业务侧直接看到积压深度。否则客户端全糊在服务端门口,一旦超过并发上限,表现就是大面积超时。

5.3 吞吐不达标,GPU利用率却上不去

这是最多人困惑的情况:显存还够,服务也正常,看着GPU利用率却很低。我对这个问题的定位分三步。

先看是不是并发序列不够多:如果当前同时处理的请求数少,自然达不到满载。压测时要用持续请求,不要发两个请求就去看指标。再看是不是decode阶段到了内存带宽瓶颈:decode阶段每个序列每次只生成一个token,对显存带宽敏感,算力再高也帮不上太多,此时提高吞吐的方法是增加并发序列数。最后看KV Cache是否被大量浪费:比如请求的公共前缀没有开启缓存,或者每条请求长度差异巨大产生额外分配。打开--enable-prefix-caching观测单请求首个token耗时是否有下降。

很多调优问题是层叠的,我通常不一次改多个参数,而是每次只调一个变量,记录数据后对比。那种同时改完所有参数、成绩不好又不知道原因的情况,我吃过几次亏,也建议你避开。

至于量化质量影响,我在一些任务上对比过,质量下降在大部分文档生成、问答场景完全可接受,但涉及代码生成和数学推理场景时下降会更明显,需要具体评测后再上线。

我个人在多次实际项目里的体会是,vLLM这类高吞吐引擎真正改变的不是单次推理速度,而是你思考服务架构的方式:显存不再按“最大可能长度”给每个请求划死,而是按块动态分配;调度器不再等整条请求跑完才换人,而是每一步都在滚动。刚开始切换时确实会不习惯那些参数之间的相互制约,但只要你按“先测显存预算,再调并发,观测峰值,一次只动一个参数”这个顺序走,大多数情况下都能把本地推理服务的吞吐拉到一个很满意的水平。最后再分享一个小技巧:每次压测后把启动参数和结果记录到一份文档里,下次换模型或换显卡时,这份记录就是你最快的调参起点。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 4:41:49

Java 8与MyBatis XML为何仍是企业项目的主流选择?

“都202x年了,怎么还有人抱着Java 8不撒手,守着MyBatis写XML?直接用JPA、Hibernate它不香吗?”我几乎每隔一段时间就能在技术群里看到这样的质疑。说实话,作为一个从JDK 1.4时代走过来、近几年又扎扎实实写了大量MyBat…

作者头像 李华
网站建设 2026/10/16 4:41:27

VTK读取MHD医学影像并转换为三维数组的完整实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 4:40:23

TensorFlow 2.x 模型落地全流程:从环境搭建到部署实战

很多人把深度学习项目想成“把模型训练完就大功告成”,但真正上手做过一轮才知道,训练只是其中一环。我最早接触 TensorFlow 2.x 实战时,最大的感受就是:从环境搭建、数据管道、模型训练到最终把模型交出去给别人调用,…

作者头像 李华
网站建设 2026/10/16 4:40:22

AI产品经理的核心能力:人机协作直觉与Agent设计断层

1. 为什么“会调 API”不等于“懂 Agent 产品”我带过三支不同背景的团队落地 Agent 项目:一支是纯算法出身、能手写 LLM 微调 pipeline 的博士小组;一支是资深 ToB SaaS 产品经理,做过五个以上千万级客户交付系统;还有一支是刚毕…

作者头像 李华
网站建设 2026/10/16 4:39:58

梯度、散度、旋度:场论三大算子的工程直觉与实战避坑

做仿真、写求解器、做图像处理,迟早都会撞上三个算子:梯度、散度、旋度。它们就像场论的“三把尺子”,一把量坡度、一把量源头、一把量涡旋。我在某CFD模拟项目里第一次被旋度方向坑到整个流场镜像翻转之后,才意识到这些矢量运算不…

作者头像 李华
网站建设 2026/10/16 4:39:33

JVM垃圾回收核心:从GC Roots到可达性分析,彻底看懂对象生死判定

刚开始准备JVM面试的人,几乎都会被这个组合拳打懵:一边是“垃圾回收的时候怎么确定谁是垃圾”,一边是“你知道什么是GC Roots吗”。这两个问题看着像两件事,实际上考的是同一个底层模型。我在模拟面试里问过很多候选人&#xff0c…

作者头像 李华