vLLM高并发本地推理部署实战:从原理到压测调优 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~20GB24GB 显卡较宽松7B 级INT8 / INT4 量化约 8~12GB16GB 显卡可做小并发13B 级FP16约 28~40GB40GB 以上依赖显存调度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 python3.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.6RTX 40 系是 8.9。启动服务前先跑一段最小验证确认模型能够正常加载。from vllm import LLM llm LLM(modelQwen/Qwen2.5-7B-Instruct, gpu_memory_utilization0.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-lenmax-num-seqsmax-num-batched-tokens说明短对话40961288192追求高并发RAG 长文档问答32768328192请求长且重代码补全流式8192648192流式优先这里的数字不是死规定而是供你起步压测的基准值。每台机器、每个模型特性不同最终参数要结合压测结果调整。4.3 流式输出与超时设置高并发场景下流式输出SSE几乎是必选项。用户看到“打字机”式的输出体感上会觉得响应很快哪怕总生成时间相同。vLLM 对 SSE 支持得很好客户端请求里带上 stream: true 即可。from openai import OpenAI client OpenAI( base_urlhttp://127.0.0.1:8000/v1, api_keyEMPTY ) stream client.chat.completions.create( modelQwen/Qwen2.5-7B-Instruct, messages[{role: user, content: 写一段 300 字的散文}], streamTrue ) for chunk in stream: if chunk.choices[0].delta.content: print(chunk.choices[0].delta.content, end, flushTrue)调用方超时设置也需要注意。如果你用 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(total120) async with ClientSession(timeouttimeout) 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(fP95 延迟: {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-seqs16168.4s402 token/s46GBmax-num-seqs323215.1s689 token/s64GBmax-num-seqs646428.7s742 token/s71GBmax-num-seqs64 前缀缓存开启6424.2s963 token/s78GB从数据里能看出并发从 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-utilization0.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 的原理、连续批处理的行为、显存分配的边界这几个点想清楚了你在部署任何模型时都会心里有底很多。希望这篇文章能帮你少走一些我走过的弯路。