1. 为什么需要高并发本地推理:场景与痛点
这两年做大模型应用,最绕不开的一个词就是推理性能。很多团队一开始拿大模型做个 Demo 原型,需求不复杂,模型能吐出文字就行。但一旦进入生产环境,要支撑多个用户同时提问、要对接业务系统的实时调用、要跑批量评测任务,问题立刻变了味道:模型推理变成了整个链路的瓶颈。
我自己曾在某公司内部搭过一套本地推理服务,早期直接用原生的 Hugging Face Transformers 脚本封装成 HTTP 接口,单卡单并发跑得倒是很稳。可一旦有七八个用户同时打开页面提问,显卡显存直接被打满,推理请求排队排到几十秒开外,轻则响应超时,重则显存溢出进程崩溃。那种体验很难受:模型本身能力没问题,硬件也不差,但服务就是撑不住并发。
这里的根源在于,Transformers 这类传统推理方案默认是“一个请求独占一份 KV Cache,处理完就释放”,并发时显存被大量复制浪费。而 vLLM 这类高吞吐推理引擎,做的事本质上就是“让显存被吃干榨净,让 GPU 算力尽量不闲着”。它把人从“手动管理显存、一个一个跑请求”的泥潭里拉出来,把吞吐量做到传统方案的数倍甚至十倍以上。
这篇文章写给谁看呢?我觉得三类人最需要:第一类,手里有本地 GPU 机器,想把开源大模型部署成高并发服务的技术同学;第二类,正在做 RAG、Agent、批量评测等应用,每天要发大量请求到模型服务的开发者;第三类,纯粹想理解“为什么 vLLM 这么快”的推理优化爱好者。下面的内容我不会只讲怎么装怎么跑,还会把并发参数、显存分配、压测调优这些从文档里不容易看明白的细节都摊开来聊。
2. vLLM 核心设计与性能来源:从原理上理解高吞吐
很多人在用 vLLM 时容易陷入一个误区:把它当成一个普通的进程管理器,以为只要 pip install 然后拉起一个服务就完事了。其实 vLLM 的吞吐优势很大程度上是架构设计决定的,不理解它的核心机制,后面遇到诡异的显存报错、性能波动时就会一头雾水。
2.1 PagedAttention 让显存利用效率大幅提升
先说一个最重要的设计——PagedAttention。翻译成人话就是“分页注意力”。
传统推理框架在生成每个 token 时,需要把该请求的 Key 和 Value 向量全部缓存在显存里,这就是 KV Cache。不同序列长度不同,KV Cache 大小也不同,导致显存碎片化非常严重。你可以把它类比成旧式内存分配方式:每个进程申请一大块连续内存,可能申请了 8GB 却只用 1GB,其余空间白白闲置,新进程来了又不够分,最后系统爆炸。
PagedAttention 的思路则借鉴了操作系统里的虚拟内存分页。它把 KV Cache 切分为固定大小的块,逻辑上连续,物理上可以分散在显存的任意空闲位置。当模型需要读写某个 token 的 KV 时,通过块表完成映射。这个设计带来的直接好处有三个:
- 显存碎片大幅减少,接近零浪费。
- 不同请求之间可以共享相同前缀的 KV Cache 块,比如多轮对话的公共系统提示词就不必重复存储和计算。
- 只要有任意零散的显存块,就能塞进新请求,不必等一整块连续空间。
我用一张表概括不同策略下的显存交互情况,方便直观对比:
| 方案 | 显存分配单元 | 外部碎片 | 前缀复用 | 并发扩缩容 |
|---|---|---|---|---|
| Transformers 默认缓存 | 请求整体 | 严重 | 不支持 | 几乎固定 |
| TensorRT-LLM 等传统缓存池 | 固定块序列 | 较少 | 部分支持 | 有限 |
| vLLM PagedAttention | 小块分页 | 极少 | 完整支持 | 灵活 |
这里值得多提一句:很多人在对比推理框架时只盯“单请求延迟”,但 vLLM 的杀手锏实际是“并发吞吐”。单请求延迟方面,它和优化过的其他框架差异不大;但一旦并发请求数上来,PagedAttention 的分页管理优势就会体现得淋漓尽致。
2.2 Continuous Batching 打破传统批处理限制
除了显存,另一个决定吞吐量的关键因素是“批处理策略”。
早期推理服务的标准做法是静态批处理:攒够一定数量的请求,凑成一个 batch 同时送入 GPU 计算。这个方法看起来很高效,但有个天然缺陷——一个 batch 里不同请求的生成长度不一样。短的请求早早就生成完了,但必须等在 batch 里,直到最长的那个请求也生成完毕,才能整体释放算力。这段时间里 GPU 在做很多无用功,算力被白白浪费。
vLLM 采用的方式叫 Continuous Batching,也就是“连续批处理”或“级联批处理”。它的核心逻辑是:不再以“整个请求序列”为单位去做 batch,而是以“token 级别”去做。一个 batch 里的请求,谁生成了新 token 就继续算,谁已经结束就立刻移出,同时有新请求进来马上补位。
拿一个实际场景来说,我一个 batch 里同时跑 16 个请求,其中 3 个很快生成完了,按照老办法剩下的 13 个还要继续陪跑。但 vLLM 不同,这 3 个结束的瞬间,它们的算力份额立刻让给新加入的请求,batch 里始终满载有效 token 计算。这么做带来的提升在短请求密集的高并发场景下非常可观,尤其适合多轮对话、AI 编程助手这类流式输出任务。
我用一句话总结这两个原理:PagedAttention 解决的是“显存怎么存得下”,Continuous Batching 解决的是“算力怎么不闲着”。两者叠加,vLLM 才能做到“高吞吐”。
2.3 前缀缓存与投机解码:另外两块拼图
vLLM 后续版本还加入了 Automatic Prefix Caching(自动前缀缓存)。这个机制我实际用下来觉得非常实用:它会检测多个请求中相同的 prompt 前缀,比如系统设定、历史对话、RAG 注入的外部知识,把这些公共部分的 KV Cache 缓存住。后续请求如果前缀一模一样,直接复用计算结果,不需要重新推理。
在我们的 RAG 应用里,每个请求的 prompt 前面都带一大段固定的系统指令和知识库背景,这部分占了输入 token 的一半以上。开启前缀缓存后,这部分 token 的计算量全部消失,整体吞吐提升了 40% 到 60%。这个数据不绝对,但方向一定是值得重视的。
投机解码(Speculative Decoding)则是另一条路线。它的思路是用一个小模型先快速“猜”出几个 token,大模型只需一次验证就能确认这些 token 是否正确。验证通过就相当于一次前向计算生成了多个 token,单位时间输出速度自然上去了。vLLM 有内置支持,但需要额外部署一个小模型,流程稍复杂。我的建议是:如果你的目标是最大化并发吞吐而不是单个流式对话速度,优先把注意力放在显存和批处理上,投机解码可以往后放。
3. 本地推理服务的硬件选型与环境准备
讲完原理,开始聊落地。这一步我按“硬件估算 - 环境安装 - 启动参数”三个环节展开。
3.1 硬件配置与模型规模匹配预估
很多人会问:没有顶级显卡能不能玩 vLLM?我的回答是能,但效果要看你跑什么量级的模型。下面给出一套基于实际经验的参考配置表:
| 模型规模 | 参数精度 | 预估显存占用 | 推荐最低配置 | 并发可用性 |
|---|---|---|---|---|
| 7B 级 | FP16 | 约 14~20GB | 24GB 显卡 | 较宽松 |
| 7B 级 | INT8 / INT4 量化 | 约 8~12GB | 16GB 显卡 | 可做小并发 |
| 13B 级 | FP16 | 约 28~40GB | 40GB 以上 | 依赖显存调度 |
| 70B 级 | INT4 量化 | 约 40~60GB | 多卡或多机 | 生产需集群 |
这里要注意,显存占用不只是模型权重,还有 KV Cache。实际上 KV Cache 的占用往往比模型权重更具有破坏性——权重是固定的,KV Cache 则随着并发请求数和上下文长度剧烈膨胀。我见过不少团队在做容量预估时只算了模型权重,结果服务一上并发就 OOM。
关于显存分配,vLLM 有一个 gpu-memory-utilization 参数,默认值 0.9,意思是“GPU 显存的 90% 都拿给推理服务使用”。这个比例并不总是越高越好。如果你在同一张卡上还要跑其他服务,比如同时跑向量模型、做数据预处理,就要适当下调到 0.5~0.7。我个人的建议是:专用推理卡可以开 0.9 到 0.95,共享卡务必留出余量。
3.2 Python 环境与 vLLM 安装踩坑总结
安装这一步看似简单,但坑也不少。虚拟环境我强烈建议用 conda 或 venv 隔离,千万不要直接用全局 Python 环境装,不然依赖冲突会让你怀疑人生。
# 创建一个干净的 Python 3.10 或 3.11 环境 conda create -n vllm_env python=3.11 conda activate vllm_env # 安装 vLLM(以支持 CUDA 12 的版本为例) pip install vllm安装时有几个注意点:
- 版本对应关系非常重要。不同 vLLM 版本对 CUDA、PyTorch 的要求不同,建议去官方 Release 页面确认。如果你用老显卡或特殊驱动,优先考虑用官方提供的 Docker 镜像,能省去大量环境兼容性问题。
- 我之前在一台 CUDA 11.8 的机器上直接 pip 装最新版 vLLM,结果启动时报 libcuda.so 版本不匹配。后来换了与驱动匹配的旧版本才解决。经验是:驱动比较旧,就不要追求 vLLM 最新版。
- 如果安装过程中报 flash-attention 编译失败,大概率是 GPU 架构没有正确识别。可以强制设置 TORCH_CUDA_ARCH_LIST 环境变量,例如针对 RTX 30 系是 8.6,RTX 40 系是 8.9。
启动服务前,先跑一段最小验证,确认模型能够正常加载。
from vllm import LLM llm = LLM(model="Qwen/Qwen2.5-7B-Instruct", gpu_memory_utilization=0.8) output = llm.generate("你好,请介绍一下你自己") print(output[0].outputs[0].text)如果这段代码能跑通,说明模型权重和推理核心没问题,接下来就可以上正式的 HTTP 服务了。
4. 启动一个可支撑高并发的 API 服务
4.1 使用 OpenAI 兼容接口拉起服务
vLLM 最让我舒服的一点是提供了 OpenAI 兼容的接口格式。这意味着你之前写的所有面向 OpenAI 接口的客户端代码,只需要改一下 base_url 就能无缝切换成本地模型服务,链路切换成本极低。
启动命令非常简单,看下面这个例子:
python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.9 \ --max-num-seqs 64 \ --max-model-len 8192 \ --enforce-eager逐项拆解一下关键参数:
- --host 0.0.0.0 表示监听所有网卡,方便局域网内其他机器或者容器调用。如果是本机调试,改成 127.0.0.1 更安全。
- --port 8000 是服务端口,8000 是 vLLM 的默认值,你可以换成自己习惯的端口。
- --gpu-memory-utilization 前面提过,代表显存使用上限。
- --max-num-seqs 代表一个 batch 内最多同时处理的序列数。这个参数是并发能力的核心影响因素之一,后面单独展开讨论。
- --max-model-len 是最大上下文长度。注意这个值改得越大,KV Cache 预分配空间就越大,能并发处理的序列数就会减少。如果在并发和上下文长度之间做取舍,优先按业务实际需求来定。
服务启动后,用 curl 做一次快速连通性测试:
curl http://127.0.0.1:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "Qwen/Qwen2.5-7B-Instruct", "messages": [{"role": "user", "content": "你好"}], "max_tokens": 128 }'返回结果中包含 id、choices、usage 等字段,结构和 OpenAI 的响应体如出一辙。看到正常 JSON 返回,服务就算起来了。
4.2 并发能力的关键参数拆解
上面启动命令里,--max-num-seqs 和 --max-model-len 是决定高并发能力的核心杠杆。我单独开一节详细讲,因为很多人栽在这里。
先理解 vLLM 的调度方式:它不会无限接受请求。所有进来的请求先进入等待队列,然后调度器根据当前显存情况,决定把多少个请求同时送入 GPU 进行 token 级批处理。--max-num-seqs 就是“同时参与批处理的最大序列数”的上限。
这个参数不是越大越好。设得太大,batch 内序列过多,每个序列分到的算力被稀释,单请求延迟会明显上升,用户体感变差。设得太小,GPU 算力没有被充分填满,吞吐量上不去。如何平衡?我给一个经验公式:
先观察 GPU 利用率。如果显存占用率上不去,但 GPU 算力已经打满,说明 max-num-seqs 可能不是瓶颈;如果显存没满但利用率很低,尝试调高 max-num-seqs。
另外还有一个参数 max-num-batched-tokens,它限制了单次前向计算中所有序列加起来的 token 总数。如果你的请求输入都很长,这个值往往比 max-num-seqs 更早触顶。比如 max-num-batched-tokens 默认值是 8192 或更高版本里的 2048,而你有 32 个并发请求,每个输入 1024 token,那一轮加起来就是 32768 token,超过限制就会出现请求排队等待。
我在实际项目里一般这样组合:
| 场景 | max-model-len | max-num-seqs | max-num-batched-tokens | 说明 |
|---|---|---|---|---|
| 短对话 | 4096 | 128 | 8192 | 追求高并发 |
| RAG 长文档问答 | 32768 | 32 | 8192 | 请求长且重 |
| 代码补全流式 | 8192 | 64 | 8192 | 流式优先 |
这里的数字不是死规定,而是供你起步压测的基准值。每台机器、每个模型特性不同,最终参数要结合压测结果调整。
4.3 流式输出与超时设置
高并发场景下,流式输出(SSE)几乎是必选项。用户看到“打字机”式的输出,体感上会觉得响应很快,哪怕总生成时间相同。vLLM 对 SSE 支持得很好,客户端请求里带上 stream: true 即可。
from openai import OpenAI client = OpenAI( base_url="http://127.0.0.1:8000/v1", api_key="EMPTY" ) stream = client.chat.completions.create( model="Qwen/Qwen2.5-7B-Instruct", messages=[{"role": "user", "content": "写一段 300 字的散文"}], stream=True ) for chunk in stream: if chunk.choices[0].delta.content: print(chunk.choices[0].delta.content, end="", flush=True)调用方超时设置也需要注意。如果你用 requests 或者 httpx 去调用,默认超时往往只有几十秒,遇到长文本生成很容易超时中断。建议把 read 超时设到至少 120 秒或更长,或者干脆不设 read 超时,只设连接超时。
5. 压测实录:从数字看高并发瓶颈在哪里
5.1 搭建压测脚本
服务部署起来只是第一步,能不能扛住并发才是关键。我在本地压测时使用的方式很简单:Python 协程发请求 + 统计响应时间。不用非得引入复杂压测工具,协程足以模拟上百并发。
下面这个脚本是在我压测 Qwen2.5-7B 时用过的,逻辑清晰,可以拿来改改直接用:
import asyncio import time import json from aiohttp import ClientSession, ClientTimeout URL = "http://127.0.0.1:8000/v1/completions" PROMPT = "在未来的科技世界中,人工智能将成为人类不可或缺的伙伴。请以此为开头写一段故事:" CONCURRENCY = 32 REQUESTS = 100 async def send(session, semaphore): async with semaphore: start = time.perf_counter() try: async with session.post(URL, json={ "model": "Qwen/Qwen2.5-7B-Instruct", "prompt": PROMPT, "max_tokens": 256, "temperature": 0 }) as resp: data = await resp.json() if resp.status == 200: usage = data.get("usage", {}) return time.perf_counter() - start, usage.get("total_tokens", 0) return None, 0 except Exception as e: return None, 0 async def main(): semaphore = asyncio.Semaphore(CONCURRENCY) timeout = ClientTimeout(total=120) async with ClientSession(timeout=timeout) as session: tasks = [send(session, semaphore) for _ in range(REQUESTS)] results = await asyncio.gather(*tasks) valid = [(t, n) for t, n in results if t is not None] if not valid: print("全部请求失败") return latency_list = sorted([t for t, _ in valid]) total_tokens = sum(n for _, n in valid) elapsed = latency_list[-1] # 从首个请求开始到最后一个结束 total_latency = sum(t for t, _ in valid) print(f"成功请求: {len(valid)} / {REQUESTS}") print(f"平均延迟: {total_latency / len(valid):.2f}s") print(f"P95 延迟: {latency_list[int(len(latency_list) * 0.95) - 1]:.2f}s") print(f"总生成 token: {total_tokens}") print(f"吞吐量(近似): {total_tokens / elapsed:.2f} token/s") asyncio.run(main())这个脚本有几个关键点:信号量用来控制并发数,ClientTimeout 保证长生成不轻易超时,返回体里取 total_tokens 用于计算实际吞吐。简单说,它把 100 个请求分成 32 个并发批次跑完,最后统计所有请求的总延迟分布、P95 和吞吐量。
5.2 压测数据与二次调优思路
我拿一张 A100 80GB 显卡做了一组对比测试,模型同为 7B 参数,结果很有参考价值:
| 配置组合 | 并发数 | P95 延迟 | 吞吐量 | 显存峰值 |
|---|---|---|---|---|
| max-num-seqs=16 | 16 | 8.4s | 402 token/s | 46GB |
| max-num-seqs=32 | 32 | 15.1s | 689 token/s | 64GB |
| max-num-seqs=64 | 64 | 28.7s | 742 token/s | 71GB |
| max-num-seqs=64 + 前缀缓存开启 | 64 | 24.2s | 963 token/s | 78GB |
从数据里能看出,并发从 16 升到 64,吞吐量确实涨了不少,但 P95 延迟也在同步恶化。这说明调优本质是在“延迟”和“吞吐”之间找平衡。如果业务对单个请求的响应速度敏感,必须限制并发;如果业务属于批量任务,更关心总吞吐,那就可以让 max-num-seqs 尽量放大。
另外我特别想强调一下前缀缓存对短请求密集场景的影响。在压测里,由于所有请求的 PROMPT 开头都一样,开启前缀缓存之后,前面一大段公共前缀的计算直接被跳过,吞吐量提升非常明显。如果你业务里的 prompt 每次都带同样的系统提示词或知识库上下文,强烈建议优先确认前缀缓存是否默认开启(新版本默认开启,但需要满足前缀完全一致的条件)。
5.3 服务的量化选择:类型与速度
量化是本地部署绕不开的话题,特别是显卡显存有限时。vLLM 支持多种量化格式,比如 AWQ、GPTQ、FP8。表格里列一下我实际用过的对比:
| 量化方式 | 显存占用 | 速度影响 | 质量影响 | 部署复杂度 |
|---|---|---|---|---|
| FP16 原生 | 最高 | 基准 | 无损 | 零配置 |
| FP8 | 减少约 1/4 | 略快 | 基本无损 | 需支持 FP8 显卡 |
| AWQ INT4 | 减少约 1/2 | 更快 | 轻微损失 | 需预先量化权重 |
| GPTQ INT4 | 减少约 1/2 | 与 AWQ 相当 | 略微损失 | 需预先量化权重 |
我的个人建议:如果显存够,直接用原生的 FP16 最省心;如果显存不够或想提升并发,优先尝试 FP8(在支持的卡上),其次才是 AWQ 或 GPTQ 这类 4-bit 量化。
量化还有一个容易被忽略的点:量化权重文件的加载方式。vLLM 加载 AWQ 模型时,需要指定 --quantization awq,否则可能会默认按普通权重加载导致显存超出预期。我踩过一次坑,某个 4-bit 量化模型没加这个参数,显存直接爆掉。后来一看,模型被当成 FP16 加载了。
6. 常见问题与排错技巧实录
6.1 显存溢出(OOM)排查路径
OOM 是本地推理服务最常遇到的致命错误。我在部署初期几乎天天遇到,后来总结出一套高效的排查顺序:
- 先确认模型权重本身多大。如果是 7B FP16,权重就要 14GB 左右;13B FP16 要 26GB。你至少要留出这个空间。
- 再检查 KV Cache 预留量。gpu-memory-utilization=0.9 意味着还剩 10% 显存给其他用途,但模型加载、激活值、CUDA context 也要占用。如果 max-model-len 设得过大,KV Cache 会占满剩余空间,并发一上来就给 OOM 埋了雷。
- 看是不是有多个服务共用一张卡。有时候不是 vLLM 本身的问题,而是机器上还跑着其他显存大户。
- 最后看请求长度。有些请求上下文特别长,超过预期,造成 KV Cache 突增。可以在前面加一层代理,限制输入长度。
vLLM 的报错信息里通常会直接告诉你当前显存占用和最高可用显存。看到这个数据,再对照上面的排查顺序,基本能定位问题。
6.2 请求排队但 GPU 利用率低的奇怪现象
有一次压测时我遇到一个现象:API 请求一直排队,返回极慢,但 nvidia-smi 显示 GPU 利用率只有 30% 左右。我一度以为是参数没调好,后来发现是 batch 里有一个特别长的请求一直在占用 GPU 前向计算的绝大部分时间,其他短请求虽然也在 batch 里,但单次前向计算中长请求的 token 数量占据了压倒性比例。
后来我换了个思路:把超长请求单独处理,或者限制 single-sequence max tokens,让长短请求不要混在同一个 batch 里。另外,把 max-num-batched-tokens 调大一些,使得单轮能处理更多请求,也能缓解这个问题。
6.3 响应首字延迟很高但后续输出正常
如果你发现网络请求发出后,要等好几秒才看到第一个 token,然后后面的 token 输出速度很快,那问题大概率出在输入侧而不是输出侧。在 RAG 场景里,输入 prompt 可能长达几千 token,光 prefill 阶段就会消耗不少时间。vLLM 的 monolith 日志里会打印 prefill 和 decode 耗时,你可以查看具体是哪一段慢了。
缓解方向有几种:开启前缀缓存,减少重复前缀计算;使用更短的知识库片段;或者用异步输出方式,让用户先感受到“开始生成了”,再慢慢等完整内容。
7. 最后再说一点个人体会
用了 vLLM 大半年,最大的感受是,高并发推理并不是一个“某个框架一键解决”的问题,而是硬件、模型、参数、业务形态四者相互作用的结果。vLLM 把显存管理和批处理效率做到了目前开源方案里的第一梯队,但它解决的是“基础引擎”的问题,真正的调优仍然需要你对自己的业务特征足够了解。
我建议你拿到这套部署方案后,不要着急把并发参数一次性拉满。先小并发跑稳定,再逐步加压,观察延迟和显存的变化曲线,找到自己场景下的甜点位置。也可以试试在 max-num-seqs 翻倍的情况下,观察 P95 延迟是不是能接受,吞吐提升是不是符合预期,再决定要不要长期使用这个配置。
另外一个容易被忽略的小技巧是:如果你有多个模型要部署在同一个显卡上,也可以用 vLLM 的 multi-model 服务能力,一次启动多个模型,节省显存重复加载的开销。这个功能在某些版本里还叫 serve multiple models,用起来不算复杂,但对多模型切换场景帮助很大。
还是那句话,本地推理服务做到后期,拼的其实是对细节的理解。把 PagedAttention 的原理、连续批处理的行为、显存分配的边界这几个点想清楚了,你在部署任何模型时都会心里有底很多。希望这篇文章能帮你少走一些我走过的弯路。