1. 为什么本地推理服务需要高吞吐引擎
大模型本地部署这件事,前两年大家关注的重点还是“能不能跑起来”——把权重下载下来,装好依赖,能对着命令行吐出一段通顺的话,就算成功。但到了今年,情况明显变了。越来越多的团队和个人开发者开始把本地模型接入到实际业务流里:客服自动回复、文档批量摘要、代码补全、知识库问答,甚至是一些需要持续跑批的离线任务。这时候“能跑”和“跑得好”之间的差距就暴露出来了。
我最早用原生推理框架搭服务的时候,单条请求响应还算能接受,但只要并发一上来,显存占用直接飙满,吞吐量断崖式下跌,延迟从几百毫秒涨到十几秒。后来换成带动态批处理能力的推理引擎,同样的硬件,吞吐量翻了六七倍,显存反而更稳了。这个差距的核心,就在于推理引擎有没有真正解决高并发场景下的显存管理和请求调度问题。
vLLM 这个引擎之所以在本地部署圈子里被反复提起,就是因为它把两个关键问题处理得比较到位:一是用PagedAttention把 KV Cache 的显存碎片问题压下去,二是用连续批处理让不同长度的请求能动态拼批,不用等最长的那个请求结束才释放资源。这两点听起来是工程细节,但实际效果非常直观——同样的显卡,能同时服务的请求数上去了,单位 token 的成本下来了。
这篇文章面向的是已经有一定本地部署经验、想把推理服务从“玩具”升级到“能扛并发”的开发者。我会从整体设计思路讲起,把核心机制拆开,再给出一套可以直接复现的部署流程,最后把我踩过的坑和排查经验整理出来。如果你现在正被并发上不去、显存爆掉、延迟抖动这些问题困扰,下面的内容应该能帮你省不少时间。
2. 推理引擎整体设计与核心思路拆解
2.1 从“单请求独占”到“批处理共享”的思维转变
传统推理框架的默认行为是:一个请求进来,分配一块显存,把 KV Cache 按最大可能长度预留好,等这个请求完全结束再释放。这种模式在单用户场景下没问题,但一旦并发上来,问题就很明显——每个请求都按最大长度占坑,实际用到的可能只有十分之一,剩下的全是浪费。更麻烦的是,不同请求长度差异很大,短的早就跑完了,长的还在占着资源,新请求进不来,整体吞吐被拖死。
vLLM 的思路是把显存管理从“按请求预留”改成“按需分页”。这个灵感来自操作系统的虚拟内存分页机制:把 KV Cache 切成固定大小的块(block),每个请求按实际需要申请块,块与块之间不需要连续。这样一来,显存利用率大幅提升,碎片问题也缓解了。你可以把它理解成停车场:以前是每辆车不管大小都占一个固定大车位,现在是按实际车型分配车位,小车不浪费,大车也能停进来。
2.2 PagedAttention 到底解决了什么
PagedAttention 是 vLLM 的核心机制,但很多人第一次看介绍的时候容易把它当成一个“优化技巧”,没意识到它其实是整个高吞吐能力的地基。简单说,它把注意力计算中的 Key 和 Value 缓存按块管理,每个块存储固定数量 token 的 KV 数据。请求在生成过程中,需要新块的时候向引擎申请,用完的块可以及时回收给其他请求复用。
这个机制带来的直接好处有三个。第一,显存浪费大幅降低。以前按最大长度预留,现在按实际生成长度动态分配,实测下来显存利用率能从三四成提到七八成。第二,支持更大的批处理规模。因为显存省下来了,同样一张卡能同时容纳更多请求的 KV Cache,批处理规模自然上去。第三,请求间可以共享块。比如多个请求用同一个系统提示词,这部分 KV 可以共享,不用每个请求都存一份,对于固定前缀的场景(比如统一的系统指令)效果非常明显。
注意:PagedAttention 的块大小是一个需要根据场景调整的参数。块太小,管理开销大;块太大,内部碎片多。后面实操部分我会给出具体的取值建议。
2.3 连续批处理如何提升吞吐
连续批处理解决的是另一个维度的效率问题。传统批处理是“静态”的:凑一批请求,一起跑,等这批全部结束再跑下一批。问题是不同请求生成长度不一样,短的早就完了,但必须等长的那个结束,整个批次才能释放。这期间短请求占用的资源白白浪费,新请求也只能排队等着。
连续批处理的逻辑是:每个生成步骤结束后,检查哪些请求已经完成,把它们移出批次,同时把排队中的新请求加进来。这样批次始终是“满”的,没有资源空转。实际效果是,在请求长度差异大的场景下,吞吐量能比静态批处理高出好几倍。我做过一个对比测试,同样混合短问答和长文生成两类请求,静态批处理的 GPU 利用率大概在四成左右波动,连续批处理能稳定在七成以上。
2.4 为什么选 vLLM 而不是其他方案
本地推理引擎的选择其实不少,有偏轻量的,有偏易用的,也有偏极致性能的。vLLM 的定位比较清晰:在吞吐量和部署复杂度之间取一个平衡点。它不像某些方案那样需要深度定制才能发挥性能,也不像一些轻量方案那样并发一上来就撑不住。对于大多数想把本地模型接入业务流的团队来说,这个平衡点是比较合适的。
另外,vLLM 对主流模型架构的支持比较全,HuggingFace 上的模型基本能直接加载,不需要太多转换工作。它的 API 也兼容常见的接口规范,接入现有系统时改动量小。这些工程上的便利性,在实际项目里往往比单纯的性能数字更重要。
3. 核心细节解析与实操要点
3.1 显存分配的关键参数怎么定
部署 vLLM 时,有几个参数直接决定服务能不能稳住,我逐个说。
gpu_memory_utilization这个参数控制引擎能占用多少比例的显存。默认是 0.9,意思是留 10% 给系统和其他进程。这个值不是越高越好。如果你把模型权重、KV Cache、中间激活都算进去,实际能用的显存比你想的紧张。我一般建议先设 0.85 到 0.9,跑起来之后看显存监控,如果还有余量再往上调。设太高容易在并发高峰时 OOM,设太低则浪费显存,批处理规模上不去。
max_model_len是模型支持的最大上下文长度。这个值直接影响 KV Cache 的块数量和显存占用。很多人习惯直接设成模型标称的最大值,比如 32K 或 128K,但实际业务里可能根本用不到那么长。设得过大,引擎会按这个长度预留管理结构,浪费显存。我的做法是先统计实际请求的长度分布,取一个覆盖 95% 请求的值,再留一点余量。比如大部分请求在 4K 以内,那就设 8K,没必要开到 32K。
block_size是 PagedAttention 的块大小,默认 16。这个值影响显存碎片和管理开销的平衡。块小,内部碎片少,但块数量多,管理开销大;块大,管理简单,但每个请求最后一个块可能浪费较多。实测下来,16 在大多数场景下是比较稳的选择。如果你的请求长度普遍很短(比如都在 512 以内),可以试试调到 8;如果都是长文本,32 也可以考虑。
3.2 批处理相关的调度参数
max_num_seqs控制同时处理的请求数上限。这个值不是越大越好,它受显存和计算能力双重约束。设太大,显存不够会触发抢占,反而导致请求被反复换入换出,延迟飙升。设太小,吞吐上不去。我的经验是先按显存估算一个上限,然后逐步加压测试,找到延迟和吞吐的平衡点。
max_num_batched_tokens是单个批次能处理的最大 token 数。这个参数和 max_num_seqs 配合使用,共同决定批处理规模。对于长请求多的场景,这个值要设大一些,否则长请求会被频繁打断;对于短请求多的场景,可以设小一点,让调度更灵活。
实操心得:这两个参数不要孤立地调。我一般固定一个,调另一个,观察吞吐和 P99 延迟的变化。通常先把 max_num_batched_tokens 设成 max_model_len 的两到三倍,再根据实际表现微调 max_num_seqs。
3.3 模型加载与量化选择
模型加载方式直接影响启动速度和显存占用。vLLM 支持多种精度加载,包括 FP16、BF16 以及各种量化格式。选择哪种,取决于你的硬件和精度要求。
如果显卡显存充足(比如 24G 以上跑 7B 模型),直接用 FP16 或 BF16 就行,精度损失最小,兼容性最好。如果显存紧张,可以考虑量化版本。目前比较成熟的是 GPTQ 和 AWQ 两类,4bit 量化能把显存占用降到 FP16 的三成左右,精度损失在可接受范围内。但要注意,量化模型的推理速度不一定比 FP16 快,因为反量化本身有开销,具体要看硬件和实现。
还有一个容易被忽略的点是张量并行。如果你有多张卡,可以用张量并行把模型切开放到多卡上,这样单卡显存压力小,能支持更大的模型或更大的批处理规模。但张量并行会引入卡间通信开销,卡越多,通信占比越高。一般 2 到 4 卡是比较划算的区间,再多就要仔细评估了。
3.4 请求调度的优先级与抢占
vLLM 的调度器支持请求优先级,这在混合负载场景下很有用。比如你同时有实时问答和离线批处理两类请求,实时问答对延迟敏感,离线批处理可以慢慢跑。这时候可以给实时请求设高优先级,让它们在资源紧张时优先被调度。
抢占机制是另一个需要理解的细节。当显存不够时,调度器会把一些正在运行的请求换出,腾出空间给新请求。被换出的请求之后会重新换入继续生成。这个机制保证了服务不会因为显存不足直接崩溃,但代价是被抢占的请求延迟会变高。如果你的场景对延迟极其敏感,就要通过参数控制尽量避免抢占发生,比如适当降低 max_num_seqs。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
先说环境。我用的是一台带单张 24G 显存的机器,系统是常见的 Linux 发行版,Python 版本 3.10。vLLM 对 Python 版本有一定要求,太老或太新都可能遇到依赖问题,3.10 是比较稳的选择。
安装方式我推荐用 pip 直接装预编译包,省去编译时间。如果你需要特定版本或者要改源码,再从源码编译。命令大致如下:
# 创建独立环境,避免污染系统 Python python -m venv vllm-env source vllm-env/bin/activate # 安装 vLLM,指定版本避免自动升级到不兼容的新版 pip install vllm==0.4.2 # 验证安装 python -c "import vllm; print(vllm.__version__)"这里指定版本是个经验。vLLM 迭代很快,不同版本之间 API 和默认参数可能有变化。生产环境一定要锁版本,不要用 latest,否则某天自动升级后行为变了,排查起来很麻烦。
注意:安装过程中如果遇到 CUDA 相关的编译错误,先确认显卡驱动和 CUDA 版本是否匹配。vLLM 对 CUDA 版本有要求,版本不对会直接报错。可以用
nvidia-smi看驱动支持的 CUDA 版本,再对照 vLLM 的文档选择对应版本。
4.2 启动服务的完整命令与参数说明
环境好了之后,启动服务。我一般先用命令行方式启动,方便调试,稳定之后再写成配置文件或容器化。
python -m vllm.entrypoints.openai.api_server \ --model /path/to/your/model \ --served-model-name local-model \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.88 \ --max-model-len 8192 \ --max-num-seqs 64 \ --max-num-batched-tokens 16384 \ --block-size 16 \ --dtype auto \ --trust-remote-code逐个说这些参数的实际含义。--model指向模型权重目录,可以是本地路径,也可以是模型仓库标识。--served-model-name是服务对外暴露的模型名,客户端调用时用这个名字。--host和--port是监听地址和端口,设成 0.0.0.0 是为了让局域网内其他机器也能访问。
--gpu-memory-utilization 0.88是我在 24G 卡上跑 7B 模型常用的值,留出约 12% 给系统和中间激活。--max-model-len 8192是根据实际请求长度分布定的,覆盖了绝大多数场景。--max-num-seqs 64和--max-num-batched-tokens 16384是配合使用的,前者限制并发请求数,后者限制单批 token 总量。
--dtype auto让引擎自动选择精度,一般会选 FP16 或 BF16。--trust-remote-code在加载一些自定义架构的模型时需要,但要注意安全,只对你信任的模型开启。
启动后,终端会打印模型加载进度和显存占用情况。看到类似 “Uvicorn running on http://0.0.0.0:8000” 的输出,就说明服务起来了。
4.3 用客户端验证服务是否正常
服务起来之后,先做基本验证。用 curl 发一个简单请求:
curl http://localhost:8000/v1/completions \ -H "Content-Type: application/json" \ -d '{ "model": "local-model", "prompt": "用一句话解释什么是批处理", "max_tokens": 64, "temperature": 0.7 }'如果返回正常的文本内容,说明服务通了。接下来做并发测试,这才是真正验证吞吐能力的地方。我一般用 Python 写一个简单的并发脚本:
import concurrent.futures import requests import time def send_request(idx): start = time.time() resp = requests.post( "http://localhost:8000/v1/completions", json={ "model": "local-model", "prompt": f"这是第 {idx} 个测试请求,请生成一段关于并发处理的说明。", "max_tokens": 128, "temperature": 0.7 } ) elapsed = time.time() - start return idx, elapsed, resp.status_code # 并发 32 个请求 with concurrent.futures.ThreadPoolExecutor(max_workers=32) as executor: futures = [executor.submit(send_request, i) for i in range(32)] results = [f.result() for f in concurrent.futures.as_completed(futures)] # 统计 latencies = [r[1] for r in results] print(f"请求数: {len(results)}") print(f"平均延迟: {sum(latencies)/len(latencies):.2f}s") print(f"最大延迟: {max(latencies):.2f}s") print(f"最小延迟: {min(latencies):.2f}s")这个脚本能帮你快速了解服务在并发下的表现。重点看平均延迟和最大延迟的差距,如果差距很大,说明调度有问题,可能需要调整批处理参数。
4.4 压力测试与参数调优过程
基本验证通过后,进入调优阶段。我一般用逐步加压的方式,从并发 8 开始,逐步加到 16、32、64,观察吞吐和延迟的变化曲线。
第一次测试,我用默认参数跑,并发 32 时发现 P99 延迟明显高于 P50,说明有请求被抢占或排队。查看日志发现显存接近上限,触发了抢占。于是把max_num_seqs从默认值降到 48,同时把gpu_memory_utilization从 0.9 降到 0.85,给系统留更多余量。再测,P99 延迟明显改善,吞吐只下降了不到 5%。
第二次测试,我调整了block_size。原来用默认的 16,改成 8 之后,短请求的显存利用率有提升,但管理开销增加,整体吞吐变化不大。后来改回 16,因为在这个场景下差异不明显,默认值更省心。
第三次测试,我尝试开启enable_prefix_caching。这个功能对固定前缀的场景效果很好,比如所有请求都带同一段系统提示词。开启后,这部分 KV 只存一份,多个请求共享,显存占用下降,吞吐有可见提升。但如果你的请求前缀各不相同,这个功能收益不大,反而增加管理开销。
经过几轮调整,最终稳定在一组参数上:gpu_memory_utilization 0.85、max_model_len 8192、max_num_seqs 48、max_num_batched_tokens 12288、block_size 16。在这个配置下,并发 32 时平均延迟控制在 2 秒以内,P99 在 4 秒左右,GPU 利用率稳定在七成以上。
5. 常见问题与排查技巧实录
5.1 启动就 OOM 怎么办
这是最常见的问题,尤其是第一次部署的时候。启动阶段 OOM 通常有几个原因。一是gpu_memory_utilization设太高,模型权重加载完就没剩多少给 KV Cache 了。解决办法是降到 0.8 甚至 0.75 试试。二是max_model_len设太大,引擎按这个长度预留管理结构,显存不够。先降到 4096 或 2048,确认能起来再往上调。三是模型本身太大,单卡放不下,这时候要考虑量化或者张量并行。
还有一种情况是显存看起来够,但启动时还是 OOM。这往往是因为系统或其他进程占了一部分显存,nvidia-smi看到的空闲显存不等于可用显存。启动前先确认没有其他进程占着卡,必要时用nvidia-smi查看并清理。
5.2 并发上不去、吞吐低怎么排查
如果服务能跑,但并发一高就撑不住,先看显存监控。显存满了会触发抢占,请求被反复换入换出,吞吐自然上不去。这时候要么降max_num_seqs,要么降max_model_len,要么换量化模型。
如果显存没满但吞吐还是低,看 GPU 利用率。利用率低说明计算资源没吃满,可能是批处理规模太小。适当提高max_num_batched_tokens,让每个批次处理更多 token。但要注意,这个值太大也会导致单批计算时间过长,延迟变高,需要找平衡。
还有一种容易被忽略的情况是请求长度分布太极端。如果大部分请求很短,少数请求特别长,长请求会拖慢整个批次。这时候可以考虑把长请求单独路由到另一个实例,或者用优先级调度让短请求先走。
5.3 延迟抖动大的原因分析
延迟抖动大,P99 远高于 P50,通常和抢占、排队、批处理规模波动有关。先看日志里有没有抢占记录,有的话说明显存紧张,需要调整参数。再看请求到达是否均匀,如果是一阵一阵的突发流量,排队延迟会明显。这种情况可以考虑加一个请求队列,平滑流量,或者适当增加实例数做负载均衡。
另一个原因是批处理规模波动。如果max_num_batched_tokens设得比较小,批次经常凑不满,计算效率低,延迟也会波动。适当调大这个值,让批次更稳定,延迟抖动会改善。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 启动 OOM | 显存预留不足 | 查看 gpu_memory_utilization 和 max_model_len | 降低这两个参数,或用量化模型 |
| 并发上不去 | 显存满触发抢占 | 监控显存和抢占日志 | 降 max_num_seqs,或降 max_model_len |
| 吞吐低但显存没满 | 批处理规模太小 | 查看 GPU 利用率和批次大小 | 提高 max_num_batched_tokens |
| 延迟抖动大 | 抢占或排队 | 查看 P99 延迟和抢占记录 | 调整参数,平滑流量,或加实例 |
| 长请求拖慢整体 | 请求长度分布极端 | 统计请求长度分布 | 长短请求分离路由 |
| 前缀缓存没效果 | 请求前缀不固定 | 检查请求前缀是否一致 | 前缀不固定时关闭该功能 |
5.5 几个容易踩的坑
第一个坑是盲目追求大 batch。批处理规模大确实能提升吞吐,但延迟也会上去。如果你的场景对延迟敏感,不要一味加大 batch,要在吞吐和延迟之间找平衡。我见过有人把max_num_seqs设到 256,结果 P99 延迟飙到十几秒,用户体验极差。
第二个坑是忽略请求长度分布。不同业务的请求长度差异很大,参数不能照搬。做在线问答的,请求普遍短,参数可以设得激进一些;做长文生成的,请求长,参数要保守,给长请求留足空间。
第三个坑是不锁版本。前面提过,vLLM 迭代快,不同版本行为可能不同。生产环境一定要锁版本,升级前先在测试环境验证。
第四个坑是不做压测就上线。本地测试单条请求没问题,不代表并发场景没问题。上线前一定要做压力测试,摸清服务的容量边界,留出余量。
6. 服务化部署与长期维护建议
6.1 从命令行到容器化
命令行启动适合调试,但长期运行建议容器化。容器化有几个好处:环境隔离,不会因为系统升级导致依赖冲突;启动参数可以写成配置文件,版本化管理;配合编排工具可以做健康检查和自动重启。
写 Dockerfile 的时候,基础镜像选带 CUDA 的官方镜像,把 vLLM 和依赖装进去,模型权重可以挂载卷,不用打进镜像。启动命令写成 entrypoint 脚本,参数从环境变量读取,方便不同环境用不同配置。
注意:容器里访问 GPU 需要配置运行时,确保容器能识别到显卡。启动容器时加上
--gpus all或者对应的运行时参数。
6.2 监控指标该看哪些
服务上线后,监控是必须的。核心指标有几个:GPU 利用率,反映计算资源是否吃满;显存占用,反映是否有 OOM 风险;请求延迟分布,P50、P95、P99 都要看,只看平均延迟会掩盖问题;吞吐量,每秒处理的 token 数或请求数;抢占次数,反映显存压力。
这些指标可以用常见的监控系统采集,vLLM 本身也暴露了一些指标接口,可以对接。关键是设好告警阈值,比如显存超过 90% 告警,P99 延迟超过某个值告警,抢占次数突增告警。
6.3 扩容与负载均衡
单实例撑不住的时候,就要考虑扩容。最简单的方式是起多个实例,前面加一个负载均衡。但要注意,大模型推理实例的负载均衡和普通 Web 服务不太一样,因为每个请求的处理时间差异很大,简单的轮询可能导致某些实例过载。
比较好的做法是根据实例的实时负载来路由,比如看当前排队请求数或 GPU 利用率,把新请求发给负载最低的实例。另外,如果多个实例加载同一个模型,显存是各自占用的,扩容时要把这部分成本算进去。
6.4 模型更新与版本管理
模型更新是另一个需要规划的事情。新模型上线,不能直接替换,要先在测试环境验证效果和性能,确认没问题再切流量。切换时最好支持灰度,先切一小部分流量,观察一段时间再全量。
版本管理方面,建议把模型版本和服务配置都纳入版本控制,每次变更都有记录,出问题能快速回滚。模型文件本身比较大,可以用对象存储管理,服务启动时拉取指定版本。
7. 一些实测数据与个人体会
最后分享一些实测数据,供参考。在单张 24G 显存的卡上跑 7B 模型,FP16 精度,max_model_len设 8192,max_num_seqs设 48,实测并发 32 时,短请求(生成 64 token)平均延迟约 1.2 秒,长请求(生成 512 token)平均延迟约 6 秒,整体吞吐约每秒 800 token。开启前缀缓存后,固定前缀场景下吞吐能再提升一成多。
如果换成 4bit 量化模型,显存占用降到约三分之一,max_num_seqs可以提到 96,吞吐翻倍,但生成质量会有轻微下降,具体能不能接受要看业务场景。
我个人在实际操作中的体会是,vLLM 这套东西的上手门槛不算高,但要把参数调好、把服务跑稳,需要花时间做压测和观察。不要指望一套参数走天下,不同模型、不同硬件、不同业务场景,最优参数都不一样。最好的办法是建立一套自己的测试流程,每次变更都跑一遍,用数据说话。
另外,本地推理服务的稳定性,很大程度上取决于你对显存的管理。显存是稀缺资源,所有参数调整本质上都是在显存、吞吐、延迟三者之间找平衡。理解了这一点,调参就不会盲目,遇到问题也知道往哪个方向排查。
这个内容后续还可以这样扩展:如果你有多台机器,可以研究一下分布式推理的部署方式;如果对成本敏感,可以对比不同量化方案的性价比;如果业务对延迟要求极高,可以研究投机解码等加速技术。这些方向我之后有机会再单独展开聊。