资讯动态

vLLM高并发推理实战:从KV Cache管理到吞吐量调优

发布时间:2026/10/14 23:35:19 来源:尧图企业网站定制
vLLM这个开源推理引擎最近在本地大模型服务里出镜频率是真的高。它的定位很明确把本地GPU推理做成高吞吐、高并发的服务而不是一次只能处理一两个请求的小玩具。我这段时间负责的内部项目就是拿它搭一个面向多个业务方的高并发推理入口从环境准备、参数调优到压测踩坑基本都走了一遍。这篇博客就是把这些过程里的关键决策和实操细节整理出来给同样准备把本地LLM服务化、或者正在纠结推理引擎选型的人参考。如果你只有一张普通显卡也没关系文里的思路和参数取舍同样适用。高并发推理不等于堆显卡把吞吐这件事想明白比盲目买卡重要得多。1. 先搞清楚本地高并发推理到底卡在哪1.1 表面是并发低根子是“资源利用率”低先回忆一个现象本地跑大模型单条请求一条条发模型生成速度其实不算慢。但只要同时来十几二十个请求响应时间立刻拉长甚至显存直接爆掉。很多人第一反应是“显存不够加卡”但把账算下来会发现明明GPU算力还有富余带宽也还行就是跑不快。根子在几块第一每个对话请求都要维护自己的状态也就是KV cache。模型每生成一个新token都要把前面所有token的Key和Value都存下来。请求一多显存里到处是这种中间状态空间利用率低浪费严重。第二传统推理服务在批处理时通常要等一批里的所有请求都结束才会把这批资源释放给下一批。如果一批里有个请求生成长度特别长其他人就得等GPU在尾部阶段也会大量空转。第三框架自身的调度开销和显存碎片也会在高并发时放大。这几点凑在一起造成的结果就是并发一高吞吐骤降响应慢慢悠悠。所以真正要解决的不是简单增加显存而是把显存里的KV cache、GPU的空闲算力、请求调度这三件事同时理顺。1.2 直接拿原始加载方式挂服务为什么不行很多人最初会把模型用原始的方式加载起来开一个HTTP接口然后循环处理请求。单线程模式还好一旦进入多线程或异步并发问题就非常具体如果你为每个请求单独加载一份模型显存立刻被复制品塞满如果你共享同一个模型实例又得用锁把生成过程串行化等于并发被锁死。另一个常见做法是动态批量。这个方向是对的但普通实现的动态批量是在“请求级别”等待凑够N条请求再一起前向计算生成过程中一个请求结束资源也不会马上腾出来给新请求。结果就是batch里各条序列长短不一计算浪费严重。更麻烦的是普通实现里KV cache的频繁分配释放会造成显存碎片跑久了可用显存越来越小类似磁盘碎片化之后系统变卡。vLLM 这几个“痛点”我是真实体验过的。最开始我拿一种重量级框架把模型包起来内部机器上十几个并发调用GPU利用率只有百分之十几生成速度一条腿走路。换成vLLM之后同样显卡压测出来的总吞吐量能差一个数量级。这不是玄学是它在底层把KV cache管理方式和批调度策略都改了。2. vLLM 的核心思路为什么它能扛住高并发2.1 三个关键词用大白话拆开先说PagedAttention。它的灵感来源可以直接说成“操作系统的虚拟内存分页”。以前KV cache是按一个请求的完整连续空间申请的长请求需要一大块连续显存容易碎片。vLLM把KV cache切成固定大小的物理块按需分配、按页管理逻辑上连续、物理上可以零散。这样显存碎片大幅减少还可以把多块小显存空间拼起来用。效果就是同样的显存容量能同时容纳更多的并发请求。然后是Continuous Batching连续批处理。过去的批处理是“一个batch从零到整整批结束再换下一批”vLLM把调度粒度从“请求级”降到了“token级”。每个token生成步骤里只要某条序列已经结束或者超长就可以立即把它移出batch把空位让给新请求。相当于餐厅翻台客人吃完一个马上补一个而不是非得等整桌人都吃完才让下一桌进来。这个细节在长尾生成场景特别有用GPU空闲率会被压得非常低。再就是Prefix Caching前缀缓存。如果多个请求共享同一段Prompt前缀比如都在问同一个公共文档、同一套系统提示词vLLM可以缓存并复用这部分计算和KV结果。聊胜于无实测下来对固定知识库场景收益非常大相同前缀的重复请求能少算一大截。2.2 和其他服务化方案对比为什么我选它当时我手边可以选的方案不只有vLLM还有另外一两个主流推理服务框架。对比下来我的选择逻辑很简单第一它默认暴露的是OpenAI兼容的API格式业务方接入成本最低之前的代码几乎不用改第二它对模型生态的兼容很直接我只需要把模型路径指给它不需要额外转换第三社区更新频率和issue响应速度都更活跃遇到兼容性问题容易找到同类案例。成本上它完全开源本地部署没有授权成本。部署方式也灵活既可以直接用Python包也可以跑官方容器镜像还能对接分布式推理。虽然不是所有场景都是最优解比如某些私有格式支持得不够好、某些量化算子依赖特定GPU架构但在“本地高并发LLM服务”这个需求上它是我目前实测最稳的方案。注意vLLM 是一个开源项目名我这里所有的结论都基于本地内部环境实测不同显卡、不同模型、不同版本之间的差异可能很大生产环境一定要以自己压测数据为准。3. 本地部署实操从环境准备到启动服务3.1 环境准备与安装我这边实际用的环境是Linux系统两张高显存卡显存都在40GB以上。日常开发机如果只有一张16GB或24GB的卡也能跑只是并发容量和能加载的最大模型长度会受到限制。基础软件版本这块别偷懒。显卡驱动、CUDA版本、基础深度学习框架版本要和vLLM匹配否则启动时会遇到算子上不了的错。按照我踩过的坑一般建议先装好能用的显卡驱动再确认CUDA版本在官方支持范围内之后用虚拟环境安装vLLMPython版本建议3.9以上。安装分两条路。一条是pip直装python -m venv /data/venv/vllm_env source /data/venv/vllm_env/bin/activate pip install vllm另一条是用官方镜像可以省掉一堆系统依赖匹配的麻烦docker pull vllm/vllm-openai:latest docker run --gpus all -v /data/models:/models -p 8000:8000 -it vllm/vllm-openai:latest bash如果公司内部网络不方便访问镜像源就把镜像提前导出导入这是个务实的小技巧。3.2 启动参数与显存预算计算启动一个服务看似简单真正决定它能不能高并发的全在参数里。逐个拆开来说。--model模型路径或Model Hub上的id。本地我建议直接把模型文件放到一个独立目录比如 /data/models/local-model-7b避免每次启动都去网络拉权重。--gpu-memory-utilizationvLLM最多能用多少比例的显存。默认一般是0.9也就是90%显存都会交给vLLM统一分配剩下10%留给模型加载、推理激活和框架开销。如果显存吃紧可以降到0.8或0.85但别压到0.5以下否则KV cache空间不够并发马上受限。--max-model-len允许的最大序列长度等于“输入长度生成长度”。它直接决定一块内存块能支撑多长的对话。这个值的设置要参考实际业务而不是越大越好。模型本身的能力上限不到8K时你硬设成32KKV cache会把显存吃光吞吐量反而暴跌。这里给一个KV cache占用估算方法方便你调参前心里有底。一个请求在某个序列长度L下占用的KV cache大致是每token占用字节数 层数 × KV头数 × 头维度 × 2K和V各自一份 × 2半精度浮点占2字节比如一个32层、KV头数8、头维度128的模型每个token大约占用 32×8×128×2×2 131072 字节也就是128KB。如果支持4096 token的上下文单请求就要预留 128KB × 4096 ≈ 512MB。你以为显存挺多全算下来就懂了一张40GB的卡模型权重去个十几GB剩下的空间可能只够几十个长序列并发。这也是为什么不能只关心“模型多大”必须把并发度和max-model-len放在一起算。--tensor-parallel-size张量并行卡数。我这边一张卡放不下模型或要更大的KV空间时就设置成2模型会被拆到两张卡上共同推理。设置这个参数要满足模型结构的可切分条件一般指定后程序自己会处理但注意多卡并行时模型权重会被切分每张卡上的权重加KV cache总和必须控制在单卡显存内。如果开了并行--gpu-memory-utilization每次设定的比例是按每张卡算的别理解成总体显存的90%。--max-num-seqs同时参与调度的最大序列数。这个参数和吞吐强相关调大的确能拉高并发但不是越大越好。过大时大量请求挤在KV cache里互相抢空间每个请求能用的上下文变少或者直接OOM过小则Continuous Batching的翻台优势发挥不明显。我一般先按默认值跑压测再逐步上调。--served-model-name对外暴露的模型名。API请求里的model字段要和它一致否则会报模型不存在的错。本地部署时我习惯设一个固定的短名字方便业务方统一配置。3.3 一个能跑起来的启动脚本下面这个脚本是我实际在用的精简版可以直接抄走改参数#!/bin/bash export CUDA_VISIBLE_DEVICES0,1 python -m vllm.entrypoints.openai.api_server \ --model /data/models/local-model-7b \ --served-model-name local-model \ --tensor-parallel-size 2 \ --max-model-len 8192 \ --gpu-memory-utilization 0.9 \ --max-num-seqs 128 \ --enable-prefix-caching \ --port 8000启动后看到类似“Uvicorn running on ... 8000”的日志就算起来了。在另一终端用curl做个冒烟测试curl http://127.0.0.1:8000/v1/chat/completions \ -H Content-Type: application/json \ -d {model:local-model,messages:[{role:user,content:你好}],max_tokens:32}能正常返回内容就说明基础链路没问题。这一步别跳过很多后续并发问题如果底层接口都没通查起来会非常痛苦。4. 高并发压测与性能调优4.1 先压测再调优别拍脑袋改参数服务起来以后第一件事不是调参是压测。没有数据支撑的参数调整都是自我感觉良好。我用的压测方式很朴素写一个Python脚本用线程池同时发几十上百个请求控制目标并发数记录每轮的吞吐和响应时间。这里给一个可参考的并发探测脚本不要用单个curl就代表并发import time import json import threading import requests from concurrent.futures import ThreadPoolExecutor BASE_URL http://127.0.0.1:8000/v1/chat/completions MODEL local-model def call_once(idx): payload { model: MODEL, messages: [{role: user, content: 请用三句话介绍并发编程的核心问题。}], max_tokens: 128, temperature: 0.7 } start time.time() resp requests.post(BASE_URL, jsonpayload, timeout120) elapsed time.time() - start try: out resp.json() usage out.get(usage, {}) total_tokens usage.get(total_tokens, 0) return idx, elapsed, total_tokens, resp.status_code except Exception: return idx, elapsed, 0, resp.status_code def run_concurrency(n): start time.time() with ThreadPoolExecutor(max_workersn) as pool: results list(pool.map(call_once, range(n))) cost time.time() - start ok sum(1 for *_ , code in results if code 200) total_tokens sum(r[2] for r in results) print(f并发数{n}, 总耗时{cost:.2f}s, 成功率{ok}/{n}, f总tokens{total_tokens}, 吞吐≈{total_tokens/cost:.1f} tokens/s) for r in results[:5]: print(r) if __name__ __main__: concurrency_list [4, 8, 16, 32, 64] for n in concurrency_list: run_concurrency(n) time.sleep(2)跑的时候要注意这种脚本只是初筛真实业务请求长度、生成长度是可变的。我建议压测时把输入长度和max_tokens都贴近线上实际否则数据会虚高。4.2 关键指标怎么读vLLM 自己的日志会输出吞吐数据主要是两行Average prompt throughput 和 Average generation throughput。前者反映处理输入阶段的速度后者反映生成token的速度。我一般更关注generation throughput因为生成阶段才是高并发下最容易成为瓶颈的地方。另外还有几个指标值得看TTFTTime To First Token从发请求到返回第一个token的时间。这个值太高业务方会觉得服务“卡了”。Inter-token Latency生成过程中每两个token之间的间隔。它和TTFT一起决定了用户实际感受到的响应速度。并发成功率和P95/P99响应时间做服务的人不能只看平均值P95和P99才能暴露长尾问题。GPU利用率通过nvidia-smi观察理想情况应该持续保持高位如果并发上去利用率还很低说明调度或数据喂入有问题。4.3 我的调优顺序与几个重要参数压测一轮之后再开始动参数。我自己的调优顺序是先确认显存预算没有被浪费再调并发序列数最后上量化或前缀缓存来换空间。第一先检查--gpu-memory-utilization和--max-model-len是否合理。如果日志里经常报KV cache空间不足或者显存使用率极低优先调整这两个它们决定池子有多大。第二调整--max-num-seqs。以64为起点逐步往128、256试。观察吞吐量和TTFT的拐点。比如在我这套环境里64到128时吞吐明显上涨但继续加到256后成功率开始下降P99时间飙升。那就说明当前显存容量撑不住这个并发度需要回退。第三考虑量化。如果加载一个7B模型都嫌显存紧可以用AWQ、GPTQ这类量化格式把权重压到4bit/8bit。代价是某些算子可能没有针对性优化生成的数值精度会有轻微变化用在正式对外服务前要先做一轮质量验证。我把常用的调优参数和效果整理成了一张表参数作用调优建议--gpu-memory-utilization限制vLLM可用显存比例从0.85-0.95之间试同时观察KV cache大小--max-model-len限制最大序列长度按真实业务定别硬撑超长上下文--max-num-seqs同时调度序列数从默认值逐步上调找吞吐拐点--enable-prefix-caching前缀缓存复用高频相同前缀建议开启--tensor-parallel-size多卡张量并行单卡放不下时开启需满足切分条件调参最忌讳同时改多个量。改一个参数跑一轮压测记录结果再改下一个。反正vLLM启动一次也就几十秒这点时间花得值。5. 常见问题与排错实录5.1 问题速查表我把这段时间遇到的高频问题整理成了速查表排查时可以按图索骥。现象最可能原因排查和解决启动时报CUDA错误驱动或CUDA版本不匹配查看CUDA版本切换兼容的vLLM版本或升级驱动模型加载很久后报错OOM显存不够或--gpu-memory-utilization过高降低该值换更小模型或开启量化请求一直排队吞吐上不去max-num-seqs太小或KV cache空间不足调高max-num-seqs同时确认max-model-len没虚高API报model字段不存在served-model-name和请求model不一致统一名称重启后重新请求多卡场景经常报通信错误tensor-parallel-size配置不合理或卡间通信没打通检查多卡拓扑、通信库版本必要时降到1并发一高就返回429默认限流策略或队列满了检查服务端并发限制参数适当调高队列上限使用量化模型后输出变差量化精度损失换更高精度量化或使用FP16原始权重重新测试5.2 三个让我印象深刻的坑第一个坑是docker启动没用--shm-size参数。默认的共享内存太小vLLM在加载权重或做某些张量操作时会直接进程崩溃而且报错信息不明显。加了--shm-size 16g之后问题消失。这属于文档里容易忽略、但生产环境几乎必然遇到的一类问题。第二个坑是改max-model-len只看模型能力不看显存预算。我最初把序列长度设得很高觉得模型能做更长对话体验更好结果KV cache被占满生成阶段频繁OOM连短请求都跟着遭殃。后来老老实实根据业务压测数据调低吞吐立刻稳了。显存分配这件事永远是个平衡题不是配置越大越好。第三个坑是版本升级太勤。vLLM迭代很快每几个版本之间接口和算子行为都可能变。我在一次升级后发现同样的模型A/B测试结果和之前差了不少仔细排查才确认是版本差异导致。生产环境用固定版本并且把参数文件、镜像tag一起锁定别一有新版就手痒。6. 经验收尾一些配置上的个人习惯最后说几个我个人在部署这类服务时比较坚持的习惯算不上标准答案但每次都能帮我少走弯路。第一个习惯是先把监控指标跑出来再调优。没有指标就好比蒙眼开车。我在压测脚本里会把当前并发数、GPU利用率、显存余量、响应时间都记录下来和参数变更一起存档。之后想回看“为什么这个参数效果好”时直接查记录就行。第二个习惯是给服务预留一个降级通道。本地高并发服务并不代表永远不崩。我会在接口层做一个简单的熔断当队列占用超过阈值时直接返回503避免雪崩式的请求堆积把所有请求拖死。这个设计在高峰期很顶用。第三个习惯是每次启动前先跑一个最小请求冒烟测试确认服务真的活了再接入流量。别小看这一步很多次环境问题在冒烟测试阶段就会暴露而不是等压测时才炸出来。这些经验回头再看核心其实就是一句话高并发推理不是简单地多开几个进程或多买几张卡而是把显存管理、批调度、监控调优这几个环节理顺。数据到位了参数自然有方向。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑