从一次真实的推理服务调优说起
不少团队在把大模型推上线的第一周,都会撞到同一堵墙:单卡 A100 跑一个 13B 模型,并发到 10 个请求以上,延迟就开始飙升,GPU 利用率却只有 30% 出头。你盯着 nvidia-smi 看,显存明明还剩不少,但服务就是扛不住更多并发了。
这个现象的核心原因往往不是模型本身慢,也不是 GPU 算力不够,而是推理框架在显存管理和批处理策略上存在系统性浪费。vLLM 正是在这两个环节上做了根本性的重新设计,才把吞吐量拉上了一个数量级。
我第一次在项目中切换到 vLLM 时,用同样一张 A100 跑 Llama-2-13B,吞吐量从 HuggingFace Transformers 原生推理的约 200 tokens/s 直接跳到了 2000 tokens/s 以上。这个差距不是因为 vLLM 在模型计算层面做了什么魔法,而是它解决了一个被很多人忽视的问题——显存碎片的隐性成本。
真正吃显存的是 KV Cache,不是模型权重
很多人对推理显存的直觉是:模型多大,就需要多少显存。但实际上,在自回归生成过程中,模型权重只是固定开销,真正随请求量线性增长的是 KV Cache——每一层 Attention 都要缓存历史 token 的 Key 和 Value 向量,用于后续生成时的注意力计算。
以一个 32 层、注意力头维度为 128 的模型为例,单个请求在 2048 token 序列长度下,KV Cache 的占用大约是:
2 (K+V) × 2048 (seq_len) × 32 (layers) × 32 (heads) × 128 (head_dim) × 2 (bytes, fp16)
≈ 1.07 GB / 请求
也就是说,一个 13B 模型的权重可能占 26GB,但如果你要并发处理 32 个请求,KV Cache 又要吃掉 34GB。显存就那么多,KV Cache 的管理效率直接决定了你能跑多少并发。
传统框架的做法是为每个请求预分配一段连续的显存空间,长度按最大序列长度预留。问题在于,实际请求的生成长度差异极大——有些请求输出 50 个 token 就结束了,有些可能要生成 2000 个 token。预分配按最大值来,短请求的空间白白浪费;而如果想动态分配,又会导致显存碎片化严重,最后大块连续显存根本分配不出来。
根据社区的测试数据,传统方式下 KV Cache 的显存浪费率通常在 60% 到 80% 之间。这意味着你买了 80GB 的 A100,实际能用于推理的有效负载可能只有 20GB 出头。这才是吞吐量上不去的根源。
PagedAttention:把操作系统的虚拟内存搬进 GPU
vLLM 的核心创新是 PagedAttention。它的思路其实并不复杂,如果你了解操作系统的虚拟内存分页机制,就能很快理解:与其给每个请求分配一大块连续物理显存,不如把显存切成固定大小的物理块(Block),然后通过一个页表把逻辑上连续的 KV Cache 映射到物理上可能不连续的块上。
这样做的好处是直接的:
- 按需分配:生成了多少 token 就分配多少块,不需要按最大长度预留
- 零碎片:物理块大小固定,不存在外部碎片问题
- 共享复用:对于 beam search 或多个请求共享同一前缀的场景,多个逻辑块可以映射到同一个物理块,进一步省显存
第三个好处在实际业务中非常有用。比如你做一个对话系统,很多请求的前缀(system prompt + few-shot examples)是相同的,vLLM 的 prefix caching 功能可以让这些请求共享同一份 KV Cache 物理块,省下的显存可以用来跑更多并发请求。
从内核实现角度看,PagedAttention 重写了 Attention 的 CUDA kernel,让它在计算注意力时能够正确地从非连续物理块中读取 KV Cache。这是整个方案最难的地方——你得在不牺牲计算性能的前提下,让 kernel 支持非连续内存访问。vLLM 团队在这上面花了大量精力做 kernel 优化,包括分块预取、共享内存中的 KV 重组等技巧,确保分页带来的内存灵活性不以计算性能为代价。
连续批处理:别再等最慢的那个请求了
PagedAttention 解决的是显存效率问题,而 Continuous Batching(连续批处理)解决的是 GPU 计算效率问题。这两个技术是配合使用的,缺一不可。
传统静态批处理的做法是:凑齐一批请求,padding 到相同长度,一起跑,等所有请求都生成完毕后再统一返回。这会产生严重的”木桶效应”——如果 batch 里有 8 个请求,7 个在 100 token 内就结束了,但第 8 个要生成 2000 token,那 7 个请求的结果也得等着,GPU 算力在等待期间大量空转。再加上 padding 填充的 token 也参与无意义的计算,算力浪费 30%~50% 是常态。
连续批处理的核心改动是:在每个 decode 迭代结束后,立即检查有没有请求完成或新请求到达,动态调整 batch 组成。一个请求生成完了就立刻移出 batch,释放它的 KV Cache 块,新请求可以马上填进来。这样 GPU 始终在满负载运转,没有”等最慢请求”的空窗期。
用一个简化的调度逻辑来理解这个过程:
# vLLM 连续批处理调度伪代码
def schedule_iteration(self):
# 1. 移除已完成的请求,回收 KV Cache 物理块
self.running = [r for r in self.running if not r.finished]
for req in finished_requests:
self.block_manager.free(req.block_table)
# 2. 检查剩余可用显存块
free_blocks = self.block_manager.get_num_free_blocks()
# 3. 从等待队列中尽可能多地放入新请求
while self.waiting and free_blocks > MIN_BLOCKS:
req = self.waiting.pop(0)
if self.block_manager.can_allocate(req):
self.block_manager.allocate(req)
self.running.append(req)
# 4. 对当前 running 队列执行一步 decode
self.step_decode(self.running)
关键在于,这个调度是在每一次 decode 迭代之后执行的,而不是等整个 batch 跑完。这意味着新请求几乎可以在毫秒级别被插入到正在运行的 batch 中,GPU 利用率可以稳定保持在 90% 以上。
vLLM 也不是银弹:真实部署中的坑
说了这么多优势,但实际落地时,vLLM 也有不少需要注意的地方。我在不同项目中遇到过几次比较典型的问题。
第一个坑是显存预分配策略。 vLLM 默认会把 GPU 的全部可用显存预先申请为 KV Cache 池,通过 gpu_memory_utilization 参数控制比例(默认 0.9)。这个策略在单卡单实例部署时没问题,但如果你的 GPU 上还跑了其他进程(比如监控 agent 或数据预处理服务),vLLM 可能把显存吃满导致 OOM。建议在多服务共享 GPU 的场景下,把这个参数调到 0.8 甚至更低。
第二个坑是并发参数和延迟的权衡。 vLLM 的吞吐量优势主要来自高并发批处理,但高并发意味着 batch 中每个请求的 decode 速度会被均摊。如果你的业务对首 token 延迟(TTFT)有严格要求,比如对话场景要求 500ms 内返回第一个 token,过高的并发数反而会让每个请求都变慢。这种情况下需要在吞吐量和延迟之间找一个平衡点,通常通过调整 max_num_seqs 参数来控制。
第三个坑是长序列场景下的显存压力。 PagedAttention 虽然解决了碎片问题,但 KV Cache 的总量并没有减少。当输入序列很长(比如 8K、16K token 的文档处理场景),单个请求的 KV Cache 可能就占好几个 GB,并发能力会显著下降。这时候光靠 vLLM 的优化已经不够了,需要配合 Sliding Window Attention 或量化 KV Cache(如 FP8 KV Cache)来进一步压缩显存。
vLLM vs TensorRT-LLM vs TGI:怎么选
目前主流的大模型推理引擎不止 vLLM 一个,NVIDIA 的 TensorRT-LLM 和 HuggingFace 的 TGI(Text Generation Inference)也有各自的用户群体。它们的差异不仅是性能数字,更反映了不同的工程取舍。
| 维度 | vLLM | TensorRT-LLM | TGI |
|---|---|---|---|
| 核心优化 | PagedAttention + 连续批处理 | TensorRT 图优化 + 自定义 kernel | Rust 路由层 + 连续批处理 |
| 易用性 | 高,几行代码即可启动 | 低,需编译引擎、处理 TensorRT 版本兼容 | 中,Docker 部署友好但定制性一般 |
| 吞吐量 | 高,尤其在中高并发场景 | 极高,kernel 级优化更深 | 中等,略低于 vLLM |
| 首 token 延迟 | 中等,高并发时有波动 | 低,延迟优化做得更极致 | 中等 |
| 模型兼容性 | 广泛,HuggingFace 模型直接加载 | 需转换模型格式,支持范围有限 | 较好,但不如 vLLM 广 |
| 量化支持 | AWQ、GPTQ、FP8 | INT4/INT8、FP8,深度集成 | bitsandbytes、GPTQ |
| 适合场景 | 通用推理服务、快速迭代 | 极致性能、固定模型的生产环境 | HuggingFace 生态内快速部署 |
从我的实际经验来看,vLLM 更适合需要灵活迭代、模型变更频繁的团队。它的开箱即用体验和模型兼容性是三个方案里最好的,你从 HuggingFace 拉一个新模型下来,基本上一条命令就能跑起来。TensorRT-LLM 在绝对性能上确实更强,尤其在对延迟极其敏感的场景(比如实时语音对话的 ASR 后处理),但它的部署复杂度和模型转换成本不低,更适合模型已经固定、需要极致压榨硬件性能的场景。TGI 则介于两者之间,如果你的技术栈已经深度绑定 HuggingFace 生态,它也是一个合理选择。
从零开始:vLLM 部署的实战建议
如果你决定用 vLLM 来搭建推理服务,以下是一些从实践中总结出来的建议。
第一步,先做基准测试再谈优化。 不要上来就调参数,先用默认配置跑一轮,用 vLLM 自带的 benchmark 脚本测一下你在目标硬件上的实际吞吐量和延迟分布。不同模型、不同 GPU 型号的差异非常大,别人文章里的数字不一定适用你的环境。
# 启动 vLLM OpenAI 兼容服务
python -m vllm.entrypoints.openai.api_server
--model meta-llama/Llama-2-13b-chat-hf
--tensor-parallel-size 1
--gpu-memory-utilization 0.9
--max-model-len 4096
--max-num-seqs 64
# 使用官方 benchmark 脚本测试吞吐量
python -m vllm.entrypoints.openai.benchmark_serving
--backend vllm
--model meta-llama/Llama-2-13b-chat-hf
--num-prompts 200
--request-rate 10
第二步,根据业务特征调整并发参数。 max_num_seqs 控制同时处理的请求数量,这个值不是越大越好。如果你的请求生成长度普遍较短(100~200 token),可以设大一些(128 甚至 256);如果生成长度普遍较长(1000+ token),设太大反而会因为 KV Cache 不足导致频繁的抢占和换出,影响整体稳定性。建议从 32 开始,逐步调大,观察 GPU 利用率和 P99 延迟的变化曲线,找到拐点。
第三步,善用 prefix caching。 如果你的服务有大量共享前缀的请求(比如 RAG 场景下所有请求都带相同的 system prompt 和检索文档前缀),开启 --enable-prefix-caching 可以显著减少 prefill 阶段的计算量和显存占用。我实测在 RAG 场景下,开启 prefix caching 后吞吐量还能再提升 30% 到 50%。
第四步,监控要跟上。 vLLM 暴露了 Prometheus 格式的 metrics,包括 vllm:num_requests_running、vllm:num_requests_waiting、vllm:gpu_cache_usage_perc 等关键指标。建议在生产环境中至少监控这三个指标:等待队列长度持续增长说明并发参数太小或上游流量过大;GPU cache 使用率接近 100% 说明显存快撑满了,需要降低并发或换更大显存的卡。
哪些场景 vLLM 并不合适
最后想说的是,vLLM 虽然好,但并不是所有推理场景都适合。
对于边缘设备或小显存 GPU(比如 8GB 的消费级显卡),vLLM 的优势不明显。它的 PagedAttention 机制需要一定的显存规模才能发挥分页优势,显存太小的时候,能分配的物理块数量有限,分页带来的灵活性和传统方式差距不大。这种场景下,llama.cpp 这类基于 GGUF 量化的方案反而更实用。
对于对延迟极其敏感的实时场景(比如语音助手的实时对话),vLLM 的高并发策略可能不是最优选择。高并发意味着每个请求分到的 GPU 计算时间片更少,单请求延迟反而可能上升。这种场景下,TensorRT-LLM 在延迟优化上做得更深,或者考虑结合 speculative decoding 来降低单请求延迟。
对于非自回归生成任务(比如纯 embedding 计算、分类任务),vLLM 的核心优化(PagedAttention 和 continuous batching)都用不上,直接用 Transformers 或 ONNX Runtime 就够了。
vLLM 的价值在于,它把”如何高效地在 GPU 上批量服务自回归生成模型”这个问题想透了,并用工程化的方式给出了一个相当完整的答案。对于一个需要在线服务大模型、关心吞吐量和成本的团队来说,它目前是性价比最高的选择之一。但选择推理引擎终究是一个工程决策,理解它擅长的边界和它的局限,比盲目追新要重要得多。
原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/275/