把本地推理服务切到 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 PagedAttention:KV 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 Batching,vLLM 还有几个很加分的能力。前缀缓存(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-utilization | vLLM 允许使用的显存上限比例 | 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, json=payload, timeout=120) 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, json=payload, timeout=120) return resp.status_code with ThreadPoolExecutor(max_workers=20) 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, json=payload, stream=True, timeout=300) 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="", flush=True)提示: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", json=payload) 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 或 64,max-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 推理,很多场景下性价比突出,再配合前缀缓存和多副本部署,基本能满足比较高并发的内部服务需求。