简介:VLLM-0.7.3推理引擎完整源码包,面向从事大模型推理优化、部署与性能调优的算法工程师和系统开发者。该版本聚焦超大规模语言模型在推理阶段的高速令牌生成与高效内存管理,可帮助读者理解并定制PagedAttention、连续批处理等核心机制,适用于阅读推理引擎源码、二次开发或构建企业级推理服务。包内共1896个文件,压缩包约46.33MB,类型覆盖1186个Python源码、47个CUDA源文件、55个CUDA头文件及13个C++源文件,另含178个JSON配置、132个Markdown文档、45个YAML与40个Shell脚本及多个Dockerfile,完整呈现Python控制层、C++/CUDA算子层和容器化部署脚本,结构清晰便于按需查阅。目前已有340人学习下载。通过这份源码,读者能对照实际工程实现梳理VLLM的调度流程、显存管理及多模态支持,借鉴模型并行与量化方案;同时借助附带编译配置和Dockerfile快速搭建开发调试环境,为研究或生产落地提供可靠参考。 从跑通到敢改代码,中间隔着一层源码。
我最初用VLLM 0.7.3,纯粹是因为它“快”,OpenAI 接口兼容,一行命令能把 Qwen、Llama 这些大模型拉起来做推理服务。真正让我开始读它源码的契机,是线上服务遇到一个诡异问题:同样的并发量,别人的实例能扛住,我的实例却在跑到第 30 个请求左右开始变慢,首 token 延迟飙升,最后直接EngineDeadError。翻文档、改参数都没解决,最后只能老老实实把vllm源码拉下来一点一点看。
如果你也是做模型服务端、推理优化,或者单纯想搞清楚“VLLM凭啥比原生 Transformers 快这么多”,这篇内容应该能帮你少走不少弯路。这篇文章不是源码逐行注释,而是我梳理出的阅读主线和关键模块的拆解,附带我从源码构建到上线部署的完整实操记录。
1. 源码没白啃:先搞明白 VLLM 0.7.3 到底解决什么问题
读源码最忌讳一上来就一头扎进.py文件里乱翻。我建议先想清楚一个问题:VLLM 存在的意义是什么?它的核心卖点不是简单的“加速推理”,而是三大能力:显存管理、连续批处理、前缀缓存。这三个点正好对应我在业务里遇到的三个痛点,理解了它们,后面看代码才有方向。
1.1 我在服务端真正碰到的三个痛点
第一个痛点是显存浪费。之前用transformers做批量推理时,每条请求的 KV Cache 都是独立申请一整块连续显存,长度不定就会出现大量碎片。请求一多,显存明明还有空闲,却因为不连续而申请失败,表现就是 OOM。
第二个痛点是长请求阻塞。传统静态批处理(static batching)必须等一个批次里所有序列都跑完,才释放显存接下一批。我的场景里经常有人问一个很长的代码问题,一条 3000 token 的请求能卡住后面几十条短请求,体验非常差。
第三个痛点是重复计算。多轮对话里,system prompt 和前面的历史消息每次都要从头算一遍注意力,造成大量无效计算。特别是智能客服场景,用户问的问题千变万化,前面的系统提示词却一模一样,等于每轮都在反复烧钱。
VLLM 0.7.3 对这三个痛点都有对应的源码实现:PagedAttention解决显存碎片,Scheduler实现连续批处理,Prefix Caching复用前缀计算。所以理清楚这三条主线,源码也就读懂了七成。
1.2 框架选型时我为什么最终选了 VLLM
现在开源推理框架不止一个,同时期我对比过 Hugging Face TGI、NVIDIA Triton 推理后端,还有 SGLang。这里有个结论可以先给出来:没有绝对最好的框架,只有当前最适合自己场景的框架。
| 对比项 | VLLM | TGI | SGLang |
|---|---|---|---|
| 接口兼容性 | OpenAI 格式原生支持 | OpenAI 格式支持 | OpenAI 格式支持 |
| 调度粒度 | 请求级连续调度 | 请求级连续调度 | 更细粒度的 radix 调度 |
| Prefix Cache | 支持,基于 block hash | 支持 | 支持,但实现更激进 |
| 社区生态 | 最活跃,模型适配快 | 偏轻量 | 较活跃 |
| 分布式扩展 | 完善 | 一般 | 加强中 |
我最后选 VLLM,第一理由是生态。新模型发布后 VLLM 通常几天内就会出适配版本,vllm/entrypoints/openai/api_server.py直接提供了生产可用的 HTTP 服务,省去自己搭 service 的时间。第二理由是代码结构相对清晰,模块边界比较分明,后期如果要改调度逻辑、加自定义算子,不至于无从下手。SGLang 在调度上的激进设计确实让人眼馋,但综合团队维护成本和踩坑风险,VLLM 更稳。
2. 从 API 请求到 CUDA kernel:一条主线追完关键路径
读 VLLM 源码时,我最大的感悟是:不要按目录结构顺序读,而要按“一个请求从进来到出去”的完整生命周期去追。把这根主线追通了,你自然就知道哪些目录是干嘛的、哪些类在什么阶段被调用。
2.1 请求的完整生命周期
我自己总结的主线路径如下:
HTTP 请求 -> vllm/entrypoints/openai/api_server.py(接收请求,转成 OpenAI 协议格式) -> AsyncLLMEngine.add_request()(把请求转成内部 SequenceGroup) -> vllm/core/scheduler.py(调度器决定这个请求什么时候被执行) -> vllm/worker/model_runner.py(加载模型,执行 forward) -> vllm/attention/backends/paged_attn.py(调用 PagedAttention 算子) -> csrc/attention/attention_kernels.cu(真正的 CUDA kernel)对新手来说,建议从api_server.py开始调试,打印每个阶段的耗时。我当初做性能分析时,就靠在这些关键位置加日志,定位到瓶颈其实不在模型计算上,而在调度器的等待队列里。
2.2 我优先读的三个源码模块
顺着主线,我真正精读的不超过十个文件,其中三个是重点中的重点:
第一个是vllm/core/scheduler.py。这个文件是调度器的核心,里面Scheduler.schedule()方法输入当前所有等待请求和 GPU 资源状态,输出本 step 可以执行的请求列表。读这里的重点是理解三类序列:RUNNING(正在计算)、SWAPPED(被换出到 CPU)、WAITING(排队中)。VLLM 的调度策略简单说就是:只要 GPU 资源够,尽量把WAITING拉进来跑;如果显存紧张,优先把低优先级的RUNNING请求的 KV Cache 换出,腾空间给新请求。
第二个是vllm/attention/backends/paged_attn.py。这是 PagedAttention 的接口层,里面定义了PagedAttention.forward(),核心参数是k_cache、v_cache和block_table。k_cache的形状是[num_blocks, block_size, num_kv_heads, head_dim],看到这个形状你就知道 KV Cache 是按“块”存的了。而block_table是每个序列的逻辑块到物理块的映射表,相当于一个翻译官,把逻辑上连续的第 0 块、第 1 块,翻译成物理显存上零散分布的块号。
第三个是vllm/core/block_manager.py。这个文件负责物理块的分配和释放,尤其是PrefixCachingBlockAllocator,它实现了前缀缓存的核心逻辑。这个类里维护了一张block_hash到物理块的映射表,如果两个请求的前缀 block hash 一致,就直接复用同一个物理块,不再重复申请显存。这个机制回头我会在第 4 节单独展开。
3. PagedAttention:内存分页这套思路是怎么落到代码里的
PagedAttention 是 VLLM 的立身之本。它的灵感来自操作系统里的虚拟内存分页:把逻辑上连续的 KV Cache 拆成固定大小的块,物理上可以放在任意不连续的显存地址。对应到我们服务端场景,就相当于把一张大桌子(连续显存)换成了一排小格子(固定块),谁要用就给谁分配几个格子,用完再收回来,灵活性提升了一大截。
3.1 为什么 KV Cache 是大头:显存账怎么算
在 LLM 推理时,权重只占显存的一部分,真正随并发上涨的是 KV Cache。要优化显存,必须先会算这笔账。我以 Qwen2.5-7B-Instruct(bf16)为例:
- 层数 L = 28,KV heads = 4,head_dim = 128,dtype = 2 bytes
- 每个 token 每层的 KV Cache =
2 * num_kv_heads * head_dim * dtype=2 * 4 * 128 * 2= 2048 bytes = 2KB - 28 层累计:28 * 2KB = 56KB / token
也就是说,一个 token 的 KV Cache 要占 56KB。如果max_model_len = 8192,一条序列的完整 KV Cache 大约是 56KB * 8192 ≈ 448MB。听着还行对吧?但并发 50 条时,光 KV Cache 就要 21.8GB,这还没算 14GB 的模型权重。所以 KV Cache 的管理效率直接决定你一台机能扛多少并发。
而 VLLM 默认block_size是 16。意味着一个物理块的大小是:
16 * 56KB = 896KB(约 0.9MB),这是 7B 模型一层的块大小精确讲,这个值对应的是整 28 层中所有 layer 的子块合计,但你可以粗略理解成:一个块大概是 0.9MB。分配的最小单位从“整条序列的 448MB”变成了“0.9MB 的块”,碎片化程度直接降低两个数量级以上。这就是 PagedAttention 最直接的收益。
3.2 逻辑块到物理块的映射
源码里最常见的参数是block_table,我第一次看的时候一直没绕明白。现在用生活化的例子解释:你点了一份套餐(序列),套餐本身是按固定菜量(token)计的,但餐厅厨房里没有连续的大灶台(连续显存),只有从小到大乱七八糟的小灶(物理块)。
block_table就是一张点菜清单:第一块逻辑区对应 3 号灶台,第二块逻辑区对应 17 号灶台,第三块对应 8 号灶台。注意力计算时,GPU 只需要拿着这张清单去对应灶台取菜(读 KV),不需要管它们在物理上是不是挨着。
在paged_attn.py里,最终会调用paged_attention_v1或paged_attention_v2这两个 CUDA kernel。这两个 kernel 的区别在于并行策略,v2将block_table和max_num_partitions等参数传入,对长序列的数值稳定性更好。实际部署我用 vLLM 默认选择,稳定性和性能都在可接受范围。
3.3 前缀缓存到底怎么提升命中率
再回来说 Prefix Caching。它解决的问题是:当两个请求的前缀完全一致时,这些前缀的 KV Cache 不需要重复计算。典型场景就是带固定 system prompt 的多轮对话,或者是给所有请求加了统一人设的客服机器人。
源码实现的核心在block_manager.py的PrefixCachingBlockAllocator。每个物理块被分配时,会计算它对应的 token 序列的 hash,存在content_hash_to_block字典里。新请求到达时,先用同样的规则计算前缀块的 hash,如果字典里能查到相同的块,就直接把物理块“共用”出来,不重新申请显存、也不重新计算。
这个设计精妙归精妙,实际使用有三个必须注意的点:
- 前缀必须逐 token 完全一致,多一个空格少一个标点都会导致 hash 变,命中率骤降
- 共享块不能随意释放。源码中每一块都有引用计数,只有引用计数归零时才能回收
- 开启
--enable-prefix-caching并不会在所有场景都有收益,如果你的每个请求前缀都不同,这个功能只是白白增加 hash 计算的额外开销
我在客服场景里实测,固定 system prompt 为 500 token,开启 Prefix Caching 后首 token 延迟从平均 1.2 秒降到 0.6 秒,吞吐提升也接近 40%。这笔账非常划算。
4. 调度器与连续批处理:并发扛不扛得住,全看这里
性能优化做到后半段,我才明白一个道理:VLLM 里真正决定服务吞吐的不是模型计算多快,而是调度器会不会“精打细算”。模型计算是死功夫,调度策略是巧劲。
4.1 调度器的核心逻辑长什么样
scheduler.py里最核心的类是Scheduler,它的入口是schedule()方法。我简化一下它做的事,分三步:
- 显式判断当前可用的显存块数量,也就是 KV Cache 还剩多少
- 从
WAITING队列里挑请求,为它们分配块,如果能分配就直接进入RUNNING - 如果显存不够,把部分
RUNNING请求的块换出到 CPU(swap),腾出空间给优先级更高的请求
这里最反直觉的一点是:VLLM 默认不做公平调度,而是先到先服务。当请求并发高时,先来的长请求会持续占用显存和计算资源,后来的短请求可能一直排不上号。这也是为什么我强烈建议线上要按照自己的场景去调参,而不是照搬默认配置。
4.2 chunked prefill 的取舍
在 0.7.x 版本里,chunked prefill(分块预填充)已经是很重要的默认策略。它的思路是:一个特别长的 prefill 阶段不一口气算完,而是拆成多个 chunk,穿插在 decode 请求之间执行。
这样做的收益很明显:长提示词的请求不会再独占 GPU,短请求的 decode 延迟不会因为一个长 prefill 而飙升。但代价是调度复杂度提高,并且 chunk 之间的中间状态需要维护。
实操中我的建议是:如果场景里既有长文档处理又有短问答,开启 chunked prefill 是正确的;如果你只做短文本、高并发小请求,可以把它关掉或者调大--max-num-batched-tokens,让每个 step 塞进更多的 token,吞吐反而更高。
4.3 影响并发上限的三个参数
读源码时注意看这些参数怎么影响调度器的判断:
| 参数 | 作用 | 我的建议 |
|---|---|---|
--max-num-seqs | 一个 step 最多执行的序列数,调度器不会超过这个值 | 显存够时调大,默认 256,小显存调小到 32~64 |
--max-num-batched-tokens | 一个 step 最多处理的 token 总数 | 短文本场景可以调大,长文本场景保持适中 |
--gpu-memory-utilization | KV Cache 可用的显存比例上限 | 一般 0.85~0.95,别设 1.0,要给运行时和临时张量留余量 |
这几个参数是线上服务的“黄金三角”,我在调优时一般先固定前两个,只动--gpu-memory-utilization和--max-model-len,因为在源码里这两个参数直接影响调度器可用的 block 数量,改动效果立竿见影。
5. 实操:从源码构建到上线一个 Qwen 服务
前面讲了原理和代码,这一节记录我实际部署 VLLM 0.7.3 的完整过程。注意,我推荐用源码方式安装,不只是为了能改代码,还因为源码安装后你能直接看到vllm目录下的所有源码文件,排查问题时方便得多。
5.1 环境版本的选择
VLLM 对环境的版本要求比较敏感,0.7.3 我建议的环境组合如下:
| 组件 | 版本 | 备注 |
|---|---|---|
| 操作系统 | Ubuntu 20.04 / 22.04 | 其他 Linux 发行版也能跑,但编译坑多 |
| Python | 3.10 / 3.11 | 3.12 也可以,但部分依赖需要更新 |
| CUDA | 12.1 / 12.4 | 需与 PyTorch 版本匹配 |
| PyTorch | 2.4.0 及以上 | 建议 >= 2.4 |
| NCCL | 跟随 PyTorch 自动安装 | 多卡部署时注意版本 |
踩过最大的坑就是 Python 3.12 配旧版 PyTorch 导致的编译失败。建议新建虚拟环境,先装 PyTorch 再装 VLLM,顺序不能反。
5.2 源码编译安装的完整命令
我实际执行的命令如下:
git clone --branch v0.7.3 https://github.com/vllm-project/vllm.git cd vllm python3.10 -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install torch==2.4.0 --index-url https://download.pytorch.org/whl/cu121 pip install -e . --no-build-isolation -v这里有两个关键点:
第一,pip install -e .会触发 C++/CUDA kernel 的编译,默认可能是多线程并行编译,内存占用很大。如果物理内存不够,建议在编译前设置:
export MAX_JOBS=4控制并行度。我一开始没设置,编译到一半系统直接卡死,换成MAX_JOBS=4后稳稳通过。
第二,第一次编译通常需要 30~60 分钟,这很正常。VLLM 需要编译csrc目录下的多个 CUDA kernel,包括cache_kernels.cu、attention_kernels.cu等。如果不想等,可以直接装官方发布的 wheel 包,但那就不方便改源码了。
5.3 启动服务的完整参数与验证
部署 Qwen2.5-7B-Instruct 时,我的启动命令是:
python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2.5-7B-Instruct \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.90 \ --max-model-len 8192 \ --max-num-seqs 16 \ --enable-prefix-caching \ --port 8000启动后,用 curl 做一次最基础的验证:
curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "Qwen/Qwen2.5-7B-Instruct", "messages": [{"role": "user", "content": "用一句话解释什么是PagedAttention"}], "temperature": 0.7 }'正常会返回带usage信息的 JSON。这里要特别说下--tensor-parallel-size:单卡就是 1,如果换成多卡,参数改成--tensor-parallel-size 4,VLLM 会自动把模型权重切分到 4 张卡上。但要注意多卡部署时 NCCL 通信很容易成为瓶颈,实测下来 8 卡比 4 卡提升明显,4 卡比 2 卡提升取决于模型大小,不是线性的。
6. 我在这套系统上踩过的坑和排查思路
最后整理几个我在 VLLM 0.7.3 上用源码部署时真实踩过的坑,每个都是可以复现的,也附上排查方向。
6.1 模型 max seq len 与 KV Cache 冲突
启动时报错:
ValueError: The model's max seq len (xxx) is larger than the maximum number of tokens that can be stored in KV cache根源是--max-model-len设太大,导致按这个长度预留的 KV Cache 块数严重不足。解决办法两个:一是调低--max-model-len,比如从 16384 降到 8192;二是调高--gpu-memory-utilization。但显存已经吃紧时,优先调低--max-model-len更稳。这个坑本质是调度器没有足够的 block 给每条序列使用,所以无论并发多低都会报错。
6.2 并发一高就 EngineDeadError
这是我最初遇到的顽疾。排查之后发现,根因不是模型崩溃,而是服务内部的队列堆积太多请求后,调度器在等待锁或资源时超时,导致 engine 被判定为死循环。解决思路是限制--max-num-seqs、--max-num-batched-tokens,让同一时刻进入执行态的请求数可控。
同时建议把日志级别打开,观察是否出现Waiting for available KV cache blocks这类提示。出现这条日志就说明 block 分配严重不足,要么扩容显存,要么降并发。
6.3 Prefix Caching 完全不生效
开了--enable-prefix-caching但首 token 延迟没降,检查下来发现是请求里的 system prompt 每次都多了一个时间戳变量,导致前缀 hash 永远对不上。这不是 VLLM 的问题,而是 OS 层的“纯前缀匹配”特性决定的。排查方法很简单:用相同的两条请求日志对比,确认除最后一轮用户消息外前面的内容完全一致。
还有一个点:Prefix Caching 在 0.7.x 版本里需要调度器开启enable_prefix_caching=True,而部分自定义 Serving 层调用EngineArgs时没有透传这个参数,导致实际没生效。我每次排查都会先确认启动日志里有没有这一行:Enable prefix caching with a block size of 16。
6.4 源码编译慢或编译 OOM
编译 VLLM 源码最常见的问题是内存不足,而不是显存不足。C++ 和 CUDA kernel 的编译需要大量内存,建议MAX_JOBS=4甚至MAX_JOBS=2。如果编译中途报internal compiler error,先检查内存而不是去找编译器版本问题。
另外,使用 VLLM 0.7.x 时建议把torch升级到 2.4 以上,旧版torch与新版 VLLM 的算子注册逻辑不兼容,经常报Operator was not registered这类错。
6.5 VLLM 与 SGLang 的切换经验
因为项目需要,我中间对比过 SGLang。坦白讲 SGLang 在调度上更激进,某些场景吞吐比 VLLM 高,尤其长 prompt 场景的 prefix cache 命中率设计很亮眼。但它的社区生态和模型适配速度落后于 VLLM,而且对自定义算子和自定义模型的支持成本更高。我的建议是:如果你的场景比较标准,两个框架都能胜任;如果团队要频繁接入新模型,VLLM 是更稳妥的选择。如果对极致性能有执念,SGLang 值得单独做一轮压测,再决定是否切换。
最后再分享一个排查技巧
在我读 VLLM 源码和做线上调优时,最常用的一个方法是“跟随一次请求的日志”。先把日志等级调到DEBUG,然后发起一个真实请求,追踪这条请求从进入api_server到执行完的完整链路,观察它何时被调度、何时被分配 block、KV Cache 具体用了多少块。把这些细节和scheduler.py的源码对照起来看,比读十篇解析文章都管用。
根据我个人实际维护这套系统的经验,互联网上关于 VLLM 的参数调优帖很多,但项目千差万别,别人的“最优参数”未必适合你的场景。与其到处抄配置,不如花一个下午把scheduler.py里关于 block 分配的那段逻辑读透,自己算清楚显存账,后面的优化都会顺很多。如果这篇文章能让你少走一点弯路,我就觉得很值了。
本文还有配套的精品资源,点击获取