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版本在官方支持范围内,之后用虚拟环境安装vLLM,Python版本建议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-utilization:vLLM最多能用多少比例的显存。默认一般是0.9,也就是90%显存都会交给vLLM统一分配,剩下10%留给模型加载、推理激活和框架开销。如果显存吃紧,可以降到0.8或0.85,但别压到0.5以下,否则KV cache空间不够,并发马上受限。
--max-model-len:允许的最大序列长度,等于“输入长度+生成长度”。它直接决定一块内存块能支撑多长的对话。这个值的设置要参考实际业务,而不是越大越好。模型本身的能力上限不到8K时你硬设成32K,KV cache会把显存吃光,吞吐量反而暴跌。
这里给一个KV cache占用估算方法,方便你调参前心里有底。一个请求在某个序列长度L下占用的KV cache大致是:
每token占用字节数 = 层数 × KV头数 × 头维度 × 2(K和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_DEVICES=0,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, json=payload, timeout=120) 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_workers=n) 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,因为生成阶段才是高并发下最容易成为瓶颈的地方。
另外还有几个指标值得看:
- TTFT(Time 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,避免雪崩式的请求堆积把所有请求拖死。这个设计在高峰期很顶用。
第三个习惯是每次启动前先跑一个最小请求冒烟测试,确认服务真的活了再接入流量。别小看这一步,很多次环境问题在冒烟测试阶段就会暴露,而不是等压测时才炸出来。
这些经验回头再看,核心其实就是一句话:高并发推理不是简单地多开几个进程或多买几张卡,而是把显存管理、批调度、监控调优这几个环节理顺。数据到位了,参数自然有方向。