vLLM高并发推理部署实战:从显存预算到调优压测 如果你最近在折腾本地大模型推理应该绕不开 vLLM 这个名字。原因很简单同样一张显卡用普通脚本一次只处理一个请求GPU 利用率经常连一半都到不了换用 vLLM 这类高吞吐推理引擎之后可以把多个请求塞进同一个批次用显存换并发整体吞吐能拉高好几倍。我之前帮团队搭建本地高并发推理服务时最头疼的不是模型下载和环境配置而是并发一上来就崩要么显存溢出要么请求排队排到天荒地老。后来把服务迁到 vLLM 上排队、OOM、卡顿这些问题才慢慢理顺。这篇文章不聊论文里的公式推导只把我们实际搭建、压测、调参、踩坑的完整过程写下来给准备在本地起这类服务的人一个可以参照的路径。1. 从“脚本调用模型”到“高并发服务”这个坎1.1 单请求脚本为什么一到并发就崩本地跑通一个大模型不算难。把权重文件下载好用常见的模型加载库读进来写一个generate调用输入一句话等几秒结果就出来了。这个阶段大家都很开心因为至少模型“能用了”。但开心通常持续不了太久一旦服务要开放给团队内部的多个人试用问题马上冒出来。几个同事同时发请求最直接的表现就是显存溢出。原因其实不复杂如果每次请求都在显存里独立创建一个完整的推理上下文几个请求叠加起来显存占用就会成倍上涨。而且这种“一个请求一个请求处理”的方式GPU 只能在单个请求上做计算大批计算单元在等待 token 生成时处于闲置状态看起来显卡很忙实际上算力利用率很低。我见过更隐蔽的问题并发请求多的时候延迟会变得极其不稳定。有人发了一个长文本生成任务后面所有短请求都在等它跑完。因为在简单实现里GPU 是串行处理请求的先到先得长任务可能占用几十秒短任务就被堵在后面。用户感知是“服务卡了”但实际上服务进程还活着只是把所有计算资源都耗在了第一批请求上。这些现象归结起来是三个问题显存管理太粗、批次调度太死、并发控制缺失。普通的模型加载库并不负责解决这类问题它默认你每次只跑一个请求。把推理从“脚本”变成“服务”本质上是把这三个问题重新设计一遍。vLLM 这类高吞吐推理引擎恰恰就是冲着这三个问题去的。1.2 高吞吐推理引擎到底多做了哪些事先说最关键的连续批处理。传统方案里一批请求必须同时开始、同时结束批次中如果有人提前生成了结束符GPU 也只能等整个批次跑完才释放资源。连续批处理不一样只要有请求生成了结束符它的显存空间立刻释放新请求可以在下一个时间片插进来不用等整批收尾。这个机制对真实业务特别友好因为用户请求的长度千差万别有的问一句就结束有的让它写两千字长文传统做法会被最长的一个请求拖住连续批处理则始终把 GPU 上的空闲位置留给新请求。第二是显存管理方式。推理过程中每个 token 都要保存对应的键值缓存也就是常说的 KV Cache。不同请求的上下文长度不同KV Cache 的大小也一直在变如果给每个请求预留整块最大长度显存浪费会很严重。vLLM 采用按页分配的方式管理 KV Cache类似操作系统的分页内存管理空闲页可以复用请求结束时释放显存碎片化问题也轻很多。第三是预填充和解码的调度。生成第一个 token 之前需要把整段提示词计算一遍这个过程叫预填充之后逐 token 生成的过程叫解码。预填充计算密集解码访存密集两者混在一起时调度器如果处理不好就会出现“一个人发长文本所有人陪等”的情况。引擎会把这些阶段拆开尽量让 GPU 的不同资源都被用上。当然实际实现比这几句话复杂得多但理解这三个方向就够了。你不需要背源码只需要知道 vLLM 不是简单地把模型包一层 HTTP 接口它真正改写了推理服务的内核调度逻辑这也是它高吞吐的本质来源。1.3 哪些场景适合直接上这套引擎不是所有跑模型的任务都需要 vLLM。如果只是本地写个 Demo或者离线推理几百条数据花时间搭引擎反而没必要。但如果是下面这几类情况很值得认真考虑服务要同时服务多人比如团队内部的知识库问答、代码助手、文案生成工具同一张卡上同时跑多个模型的多个副本需要按负载动态调整需要兼容标准 HTTP 接口让已有业务系统无缝接进来批量评测场景要并发跑大量 prompt同时希望总耗时可控请求长度不稳定忽长忽短需要一个能动态调度的系统而不是“一把梭”的脚本。“本地高并发推理服务”这个需求本质上是把单机多卡甚至单卡的能力榨干。vLLM 适合的正是这种“模型已经选好、主要矛盾在吞吐和稳定”的阶段。2. 部署前把显存和并发额度算明白2.1 模型权重占用的基本算法很多人以为“显卡显存够大就能跑模型”这个想法会吃大亏。模型权重只是显存占用的一部分真正决定能支撑多少并发的是权重加运行时开销加 KV Cache 的总和。我先给一个最基础的估算方法。模型权重的显存占用可以按参数数量和精度估算参数数量乘以每个参数占用的字节数。比如一个 70 亿参数模型用半精度存储每个参数占 2 字节权重就需要约 14GB如果用单精度每个参数占 4 字节就需要约 28GB。130 亿参数半精度大约是 26GB700 亿参数半精度大约是 140GB。这个估算在模型加载前就能算出来能帮你快速判断手里的显卡到底够不够。但这只是起点。运行时还要给激活值、临时计算图、CUDA context 等留出空间这部分在小模型上可能只占几百 MB在大模型上会更多。更关键的是 KV Cache它的占用会随着请求数量、上下文长度动态变化这才是并发服务的显存大头。2.2 KV Cache 和上下文长度的关系KV Cache 在推理过程中保存的是每一层注意力计算出来的键和值目的是避免每个 token 生成时重新计算历史 token 的注意力结果。它的占用可以粗略理解成这样KV Cache 大小 ≈ 层数 × 注意力头维度 × 2 × 序列长度 × 并发请求数 × 每个元素字节数这里面的“2”代表键和值两份。为了不让数字吓到人我直接给一个实际估算的例子。一个比较常见的 70 亿参数模型大概 32 层KV 相关维度加起来按 1024 算半精度下每个 KV 元素占 2 字节。如果同时处理 8 个请求每个请求上下文平均 4096 token那 KV Cache 大约是32 × 1024 × 2 × 4096 × 8 × 2 字节算出来大约是 4GB 多。如果并发翻倍到 16或者上下文上限放到 8192这个数字会直接涨到十几 GB。这就是为什么有些人只做了个简单部署显存看着还有很多剩余一旦并发上去就 OOM。我建议部署前先做一个简单的显存预算表不需要非常精确但要有概念。下面是一个粗略参考实际数值和模型结构有关别把它当精确表模型规模半精度权重单请求 4K 上下文的 KV Cache 估算并发 8 时的估算显存需求7B 级别约 14GB约 0.5GB约 20GB13B 级别约 26GB约 1GB约 35GB70B 级别约 140GB约 4GB多卡并行这张表的重点是让你有个体感显存不是“能装下权重就行”还要给 KV Cache 留空间。vLLM 启动日志里会打印 KV Cache 实际分配了多少部署后可以回头核对。2.3 三个启动参数决定了并发上限vLLM 启动时常用的三个参数是内存利用率、最大序列长度和最大同时处理序列数。这三个参数互相制约理解它们的逻辑比死记配置更重要。gpu-memory-utilization表示 vLLM 最多能占用多少比例的显存。设得越高留给 KV Cache 的空间越大能支撑的并发越多但给系统和其他进程留的余地越小。我实践下来一般设 0.85 到 0.9太高了会遇到预分配失败尤其是显存本身比较紧的时候日志里会出现申请内存失败的报错。max-model-len决定单条请求最长的上下文长度包含输入和输出 token 的总和。这个值设得越大每条请求可能占用的 KV Cache 上限就越高。如果你知道自己业务里最长请求不会超过 8192就别把上限设成 32768白白浪费显存。很多人以为“设大一点更安全”实际上这个参数设得越大KV Cache 预留的上限越高相同显存下能跑的并发越低。max-num-seqs决定同一时刻最多处理多少个序列相当于引擎内部的并发上限。这个值设高了单个批次可以塞进更多请求吞吐容易上去但每一条请求的首 token 延迟也会相应变高。它和前面的显存预算要一起调不能单方面拉高。这三个参数的关系可以这样理解显存利用率和最大序列长度共同决定 KV Cache 空间上限max-num-seqs决定你在这个空间里最多塞多少条请求。空间是水池并发数是水龙头水池不够大水龙头拧再大也没有用。2.4 输入长度分布比“最大值”更重要做显存预算时很多人会犯一个错误按最大可能上下文去算然后发现并发只能开一个于是觉得 vLLM 也没多强。实际上真实业务的输入长度分布通常很不均匀大多数请求可能只有几百 token偶尔才有长文本。按平均值或中位数做预算更合理同时对超过阈值的长请求要有限流或者拒绝策略。我处理过的场景里绝大多数内部工具请求的输入都在 512 token 以内输出也经常限制在 256 token这种情况下就算是单卡也能支撑很高的并发。真正吃掉资源的往往是一些没人管的长文本生成请求它们会把 KV Cache 占满然后拖垮所有短请求。所以部署前最好先明确业务允许的最大输入是多少输出最大是多少需不需要做长度限制。把这些约束想清楚后面的参数配置才有的放矢。3. 本地起服务的完整流程和容易忽略的细节3.1 环境准备与安装先强调一点不要直接在系统全局 Python 环境里装特别是有多个 Python 项目的机器。我用独立虚拟环境省去一大堆依赖冲突的烦恼。创建环境这一步比较标准python -m venv vllm_env source vllm_env/bin/activate pip install -U vllm装完之后可以确认一下版本python -c import vllm; print(vllm.__version__)能正常输出版本号基本说明安装没问题。如果安装过程中遇到编译相关的报错先检查 Python 版本和 CUDA 环境是否匹配vLLM 对这两者的组合比较敏感。环境版本太老或太新都容易出现“装上了但导入报错”的诡异问题这时候不要硬装换一个符合要求的 Python 版本往往更省时间。顺便说一句如果机器上有多个 CUDA 环境确认当前终端配置的是期望的那一个这是本地部署最容易翻车的一步。你可以用系统自带的方式查一下 CUDA 版本再对照 vLLM 的安装说明保证环境变量没有指向旧版本。3.2 最小可用的启动指令环境准备好之后启动服务其实只有一条命令。假设我有一个本地模型文件路径直接这样起vllm serve /data/models/my-local-model \ --served-model-name local-chat \ --gpu-memory-utilization 0.9 \ --max-model-len 8192 \ --max-num-seqs 16启动后默认会在8000端口监听日志里出现类似 “Uvicorn running” 的字样就说明服务已经起来了。这里served-model-name是给外部调用方看的模型名可以随便取只要和请求体里的model字段保持一致。第一次启动时不要急着加各种复杂参数先用最精简的配置确认服务能跑通再逐步加限制。日志里会显示模型加载时间和 KV Cache 的预分配情况这些信息后面调参时非常有用。举例来说如果日志显示 KV Cache 只有几百 MB说明显存大部分被权重占了并发空间很小如果显示很大说明当前配置下可以承受更高的并发。3.3 请求格式和流式返回vLLM 对外提供的是标准 HTTP 接口调用方式和大多数商业推理服务一致。先用一个最简单的请求验证curl http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -d { model: local-chat, messages: [{role: user, content: 你好做一下自我介绍}], max_tokens: 128, temperature: 0.7 }正常情况下返回里会包含生成的文本、token 数量统计、结束原因等信息。这段请求跑通之后再去折腾并发调优才有意义。如果做实时聊天类的应用建议用流式返回也就是在请求体里加stream: true。流式返回能大幅降低用户的等待感知首 token 延迟几秒可能体验还能接受如果非流式要等全部生成完再返回几十秒的等待很难有人受得了。vLLM 对流式返回的支持很好切换成本很低所以业务侧能接流式就尽量接流式。3.4 多卡场景并行策略和单机多实例本地如果有多张显卡常见做法是设置--tensor-parallel-size让单个模型在多个显卡上并行计算。比如四张卡跑一个大模型可以这样vllm serve /data/models/my-local-model \ --tensor-parallel-size 4 \ --gpu-memory-utilization 0.9 \ --max-model-len 8192这个方案适合模型本身太大、单卡装不下的情况。但如果模型单卡能装下只是想要更高的并发吞吐我建议不要直接开 tensor parallel而是多起几个服务实例再用前置负载均衡把请求分散到不同实例上。原因在于 tensor parallel 需要多卡之间频繁同步通信开销会吃掉一部分收益相反多实例之间完全独立没有通信开销而且一个实例出问题不影响其他实例。多实例的缺点是要自己做负载均衡和状态统一管理但本地服务规模不大时这个成本完全可以接受。我经常把一张 24GB 的卡分成两个实例每个实例用 12GB 左右的显存跑一个小模型整体吞吐反而比单实例更好因为两个实例可以在不同的 GPU 流上并行处理请求。4. 高并发压测数据比感觉可靠4.1 不要用“手动点几个请求”来评估性能服务刚起来的时候大多数人会拿浏览器或者命令行敲几个请求试试感觉“响应挺快”就认为并发没问题。这个判断方式在低并发下完全失效。单个请求跑得快不代表 20 个请求同时进来时还快只有真正模拟出多个用户同时访问的情况才能暴露排队、显存竞争和调度问题。我习惯写一个简单的并发脚本批量发起请求统计总耗时、每请求耗时和错误数。以下是一个基于 Python 并发库的简化压测脚本不依赖额外工具只要装上requests就能跑import json import time import requests from concurrent.futures import ThreadPoolExecutor, as_completed url http://localhost:8000/v1/chat/completions headers {Content-Type: application/json} payload { model: local-chat, messages: [{role: user, content: 用三句话介绍本地模型服务}], max_tokens: 64, temperature: 0.7, } def send_request(_): start time.time() try: resp requests.post(url, headersheaders, jsonpayload, timeout120) elapsed time.time() - start return {ok: resp.status_code 200, elapsed: elapsed, error: None} except Exception as e: return {ok: False, elapsed: time.time() - start, error: str(e)} concurrency 20 with ThreadPoolExecutor(max_workersconcurrency) as executor: futures [executor.submit(send_request, i) for i in range(concurrency)] results [f.result() for f in as_completed(futures)] ok_count sum(1 for r in results if r[ok]) error_count len(results) - ok_count elapsed_list sorted(r[elapsed] for r in results if r[ok]) print(f总请求: {len(results)}, 成功: {ok_count}, 失败: {error_count}) if elapsed_list: print(f平均耗时: {sum(elapsed_list) / len(elapsed_list):.2f}s) print(fP50 耗时: {elapsed_list[len(elapsed_list) // 2]:.2f}s) print(fP95 耗时: {elapsed_list[int(len(elapsed_list) * 0.95)]:.2f}s) print(fP99 耗时: {elapsed_list[int(len(elapsed_list) * 0.99)]:.2f}s)从 5 个并发开始逐步往上加10、20、50、100。每次跑完看四个数字成功数、平均耗时、P95、P99。P95 和 P99 比平均值重要得多因为它们代表“大多数用户在大多数时候的真实体验”。如果 P99 比平均值高了好几倍说明存在明显的排队效应该降并发或者调整调度参数了。4.2 除了延迟还要盯住这两个吞吐指标延迟是用户视角吞吐是资源视角。压测时只看延迟不够还要看每秒能生成多少个 token。vLLM 的日志里会周期性输出统计信息包含当前请求数、吞吐等。如果你没有完整的监控平台直接看日志也够用。我一般盯两个指标首 token 延迟从请求发出到模型生成第一个 token 的时间代表用户“第一次感觉到响应”的等待生成吞吐单位时间内所有请求合计生成的 token 数代表 GPU 的真实利用率。这两个指标往往互相排斥。并发加大吞吐会上升首 token 延迟也会上升。目标不是把某一个压到极限而是找到业务可接受的延迟上限下吞吐尽量高的那个点。下面是一个感受型的对比表具体数值取决于模型和显卡但趋势是通用的并发档位首 token 延迟整体生成吞吐用户体感4低低快但浪费显卡16中中高比较均衡64高高吞吐高但排队感明显128很高可能下降显存和调度成为瓶颈注意最后一行并发继续往上加吞吐不一定会继续涨反而可能因为显存紧张和频繁调度而下降。这也是为什么要压测而不是直接凭感觉把并发拉满。4.3 我这边压测时看到的一个典型现象有一次我把max-num-seqs从 8 调到 32预期吞吐能翻倍结果首 token 延迟从 0.6 秒涨到了 3 秒多吞吐只提升了不到 50%。原因是并发一多预填充阶段变得拥挤每个请求都要等上一批的预填充跑完才能进入解码。后来我开启了分块预填充把长请求的预填充切成多个小块穿插到解码的间隙里首 token 延迟降到 1.2 秒左右吞吐也稳定住了。这个例子说明高并发场景下的瓶颈往往不是模型本身而是调度策略。遇到延迟明显抬高时先检查一下是不是所有请求都在同一时刻做预填充再决定是否调整分块策略而不是一味降并发。4.4 推荐的调参顺序调参最忌讳一次性把参数全改一遍出了问题都不知道是哪个参数引起的。我建议按这个顺序来先固定max-model-len根据业务需求定不随意放宽设置一个保守的gpu-memory-utilization比如 0.85用默认的max-num-seqs跑一轮压测记录性能基线每次只调整一个参数重新压测对比延迟和吞吐当吞吐不再上升、延迟已经偏高时回退到上一组参数。如果版本支持还可以尝试开启分块预填充观察长文本请求占比高时的效果。记住任何配置都需要结合自己的模型、显卡和业务场景来验证网上再好的配置也不能直接照抄。5. 本地服务容易踩的坑以及我的排查过程5.1 显存溢出不只是模型太大现象很典型服务刚启动很正常跑上一个小时突然开始报显存申请失败或者某个请求直接返回 500。我最初以为是大模型单卡放不下后来一排查发现完全不是。看日志的时候发现 KV Cache 已经塞满了。原因有两层一个是并发数开得太高超过了显存能承受的范围另一个是某些请求的输出长度远超预期把 KV Cache 的余量全部吃掉了。这种情况和模型大小无关纯粹是显存预算没做好。处理方法分几步。先把gpu-memory-utilization稍微调低一点留出缓冲空间然后把业务侧请求的max_tokens限制住避免单条请求输出无限长最后把max-num-seqs降低让并发上限回到能承受的范围。调整之后OOM 明显减少。如果你想提前预防最好在服务前面加一层请求检查把异常长的请求直接拒掉或纳入单独的低并发队列不要让它们和普通请求挤同一个通道。5.2 首 token 延迟高先看是不是预填充阻塞有一次压测并发 30吞吐看起来不错但用户反馈“半天不出来第一个字”。我看了日志里的统计发现大部分时间都在做预填充解码阶段反而很空闲。问题的根源是并发请求里有一批长文本预填充一次性算了很长时间后续的解码请求全部排后面。这就是典型的预填充阻塞。解决方式是把预填充切成小块让解码请求能插入处理。vLLM 中开启分块预填充后长请求的预填充不会一口气占满 GPU而是切成多个片段和解码请求交错执行。改完之后首 token 延迟明显降下来了。如果你也遇到类似情况先确认长文本请求的比例是否偏高再决定是否开启分块预填充。长文本占比很低时这个优化效果有限反而可能因为拆分增加额外开销。5.3 相同的前缀反复计算前缀缓存没生效多用户共用同一个服务时通常每个请求都带一大段相同的 system prompt 或对话历史。如果服务每次都重新计算这些相同 token 的 KV等于同一段内容反复被算了很多遍浪费了大量显存和算力。vLLM 支持前缀缓存启用后遇到相同前缀可以复用 KV Cache命中率越高节省的计算量越大。我踩过的坑是虽然开了前缀缓存但命中率很低。后来对比请求内容才发现每个请求的 system prompt 里都带了一个动态时间戳导致前缀从第一个 token 就不一样缓存自然完全失效。后来我把动态内容从 system prompt 里移到了尾部把固定不变的指令放在前面命中率立刻上来了。这个优化对长 prompt 请求占比高的场景非常值得做比调并发参数省力得多。5.4 请求排队和超时控制vLLM 内部有排队逻辑请求太多时新请求会等待但排队不是无限期的。默认情况下某些版本对排队长度和超时的处理比较宽松如果请求一直得不到调度连接会长时间挂着占用线程资源最终拖垮整个服务的可用性。我在压测时就遇到过并发 100 的请求打进来服务没有崩但大量请求在“等”表现就是 P99 延迟无限上涨用户慢慢失去耐心。这个问题靠 vLLM 本身不好解决最好在最前面加一层网关控制。网关负责限制最大排队数量、设置超时时间、超时直接返回错误而不是让请求无限等待。对于本地服务哪怕不引入完整网关至少应该有一个简单的反向代理或自定义入口做限流。服务内部的调度重要外面的“闸门”同样重要否则一台机器很容易被打满。6. 一些值得长期坚持的本地服务经验6.1 先让引擎稳定再追求极端性能我见过有人部署 vLLM 当天就把并发参数调到最大值结果第二天服务就崩了然后花了一整天排查。我的习惯是第一周只跑默认配置先把接口、日志、异常处理、调用方这些外围打磨好第二周再开始压测按照前面说的顺序逐步加压。性能优化是一个“找到瓶颈、解决瓶颈”的循环不是一蹴而就。显卡和模型不变的情况下吞吐上限就在那里参数调不到上限之外。先稳定再压榨遇到问题才不会被一堆变量同时干扰。6.2 用上 vLLM 不代表所有请求都该走同一个通道不同请求的优先级可能完全不同。内部的人工问答和批量的离线评测任务如果混在一起离线任务会占用大量 KV Cache交互请求就会被堵住。我最后是把它们拆成两个服务实例一个跑交互应用一个跑批量任务互不干扰。如果你的场景里长文本生成、短文本问答、批量打分混在一起建议先按请求特征和服务等级拆开再谈优化。引擎统一处理所有请求确实方便但优先级和隔离性这种事引擎层面往往解决不了。6.3 日志、可观测性和显存监控要提前做好vLLM 的日志本身能告诉你不少事情比如请求数、吞吐、显存分配情况。但日志是在服务端调用方的延迟和错误率得靠客户端记录。我压测时都会保存一个带时间戳的结果文件方便对比每次参数调整后的效果。如果要长期维护这个服务至少要把显存占用、请求队列长度、P95 延迟这三个指标记录下来。本地环境不需要上复杂监控系统一个定时任务把指标写入本地文件就够了。问题出现时有历史数据能省掉大量“猜原因”的时间。6.4 有些时候不要用 vLLM最后说点反直觉的。vLLM 很强但不是万能。如果你只是想跑一条离线数据而且数据量不大直接写脚本批量推理比部署服务更简单如果你的请求逻辑重度依赖自定义采样策略而当前引擎不支持这些策略硬套上去反而束手束脚如果你需要和训练流程无缝集成专门的高吞吐推理服务也未必合适。工具是为场景服务的。把 vLLM 用在“高并发、长连接、标准接口”的场景它能发挥出真正价值用在小规模一次性任务上只会白白增加部署和维护成本。我的建议是从最简配置开始先让服务稳定跑起来再根据压测数据一点一点加压。性能不是拍脑袋调出来的是用一次一次压测和日志对比换来的。这套方法放在本地单卡、多卡、甚至后面扩展到更大规模的场景里思路都是通用的。