简介:VLLM-0.7.3 源码包面向大语言模型推理引擎的学习者、研究者与工程实践者,聚焦超大规模模型在高速令牌生成、高效内存管理与企业级稳定推理上的性能优化问题,适合具备一定 Python 与深度学习基础、希望深入理解推理调度与算子实现的读者。压缩包共 1896 个文件、约 46.33MB,以 Python 代码为主体(1186 个),并包含 JSON 配置、Markdown 文档、YAML 与 Shell 部署脚本,以及 CUDA/C++ 扩展源码(55 个 cuh、47 个 cu 及配套 cpp/h 文件),覆盖从接口层到 GPU 内核的完整工程结构。已有 343 人学习,说明其在 LLM 推理方向具有一定关注度。读者可据此研读推理引擎的请求调度、KV Cache 管理、注意力与量化算子实现,并结合配置和脚本理解多硬件后端的构建方式,为二次开发、性能调优与源码级排错提供可复用的参考。
1. 先别急着跑 demo:这份 0.7.3 源码解决的是吞吐与显存账本
拿到一份 0.7.3 版大模型推理框架源码,很多人第一反应是找 demo 跑一句 hello,但真正决定它能不能落地的,是请求进入调度器之后 KV Cache 怎么分块、连续批处理怎么把吞吐拉起来。这个资源面向的是想把 LLM 推理从 notebook 推到服务端的人:你会看到源码目录里 engine、worker、model_executor 的分层,也会碰到显存碎片、参数不匹配、编译失败这些血泪经验。新手能按步骤跑通离线推理和兼容 Chat Completions 的服务,熟手能顺着调度链路改 block size、并发上限和并行度。它不是科普包,而是一份要动手拆的工程源码;下面按“先看主链路、再跑最小服务、最后压测调参”的顺序走。
2. 源码目录先看哪几层:从请求入口到 token 输出的主链路
2.1 目录分层:先锁定 entrypoints、engine、worker、model_executor
源码包解压后不要从最底层的 kernel 开始读,那样很容易陷进黑匣子:你知道这里在算 attention,却不知道一个请求为什么排队、为什么显存突然掉不下去。更稳的读法是从请求入口往回推,先建立“请求怎么进来、调度器怎么组 batch、worker 怎么执行、模型层怎么吐出 token”的主链路,再决定要不要改底层算子。0.7.3 这个版本的具体目录可能有些微调,但常见分层基本围绕下面几块展开,解压后以根目录实际结构为准。
| 目录/文件 | 主要职责 | 改动风险 | 适合先看什么 |
|---|---|---|---|
entrypoints/ | HTTP 服务、命令行入口、请求解析 | 低 | 接口参数如何映射到引擎参数 |
engine/ | 同步/异步引擎、请求生命周期 | 中 | 请求如何入队、如何返回 |
core/ | 调度器、块管理、KV Cache 组织 | 高 | 连续批处理与 block table |
worker/ | 设备执行、模型加载后的运行态 | 高 | 单卡/多卡如何执行一步解码 |
model_executor/ | 模型结构、层实现、权重加载 | 高 | 模型配置如何转成执行图 |
config.py一类配置 | 启动参数、默认值、校验 | 中 | 哪些参数会改变显存账本 |
从落地角度看,entrypoints/和engine/是“服务能不能起来”的问题,core/和worker/是“起来后吞吐稳不稳”的问题,model_executor/是“模型能不能正确加载”的问题。很多翻车不是算法错,而是启动参数和模型配置没对齐:比如max_model_len按模型上限开满,KV Cache 预留把显存吃光;又比如张量并行度设成 2,但机器上只有一张卡可见。先读入口和配置,能最快排掉这类低级错误。
读源码时我一般会拿一个最小请求做标记:输入一句短 prompt,输出 16 个 token,然后在entrypoints/里找请求体字段,在engine/里找add_request一类入口,在core/里看调度输出,在worker/里看一次execute_model的输入输出。这样读的好处是每一步都有现实对象对照,不会把调度器当成黑匣子。源码包的价值也在这里:你可以改一个 block size、改一个并发上限,然后立刻用同一套请求复测,而不是只会在命令行里加参数。
2.2 最小离线推理:用 LLM.generate 验证源码是否装对
在改任何调度参数之前,先把源码包安装成可导入状态,再跑一个离线推理。离线推理不经过 HTTP 层,变量少,最适合确认 CUDA、torch、模型路径和源码安装是否一致。下面命令都在源码根目录执行,虚拟环境建议新建,避免和系统里已有的 torch 打架。
# 在源码根目录执行;先确认驱动能看到卡 nvidia-smi # 创建隔离环境,避免多套 torch 混用 python -m venv .venv source .venv/bin/activate python -m pip install -U pip wheel setuptools # 按本机 CUDA 版本选择 torch wheel;装完立刻自检 pip install torch python -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())" # 安装源码包自身;-e 便于改完调度参数后直接复跑 pip install -e .这段命令的逻辑是“先看驱动,再隔离环境,再对齐 torch,最后可编辑安装源码”。nvidia-smi看的是驱动支持的最高 CUDA 运行时,torch.version.cuda看的是当前 wheel 编译时用的 CUDA 版本,两者大版本不要跨太多;torch.cuda.is_available()必须是True,否则后面加载模型一定失败。pip install -e .的好处是源码目录里的改动直接生效,调 block size、改日志、打临时 patch 都不用反复重装。注意源码包本身不带模型权重,模型路径必须指向本地已下载的模型目录。
from vllm import LLM, SamplingParams # 模型路径换成本地已下载的模型目录;源码包不包含权重 model_path = "/data/models/your-llm" llm = LLM( model=model_path, tensor_parallel_size=1, # 单卡先跑通,再改多卡 gpu_memory_utilization=0.85, # 给激活和临时张量留余量 max_model_len=4096, # 先小于模型上限,避免一启动吃满 KV Cache enforce_eager=True, # 排查阶段关掉图捕获,报错更直白 ) sampling = SamplingParams( temperature=0.0, # 验证阶段用贪心,结果可复现 top_p=1.0, max_tokens=128, ) prompts = ["用三句话解释连续批处理对 LLM 推理吞吐的影响。"] outputs = llm.generate(prompts, sampling) for out in outputs: print(out.outputs[0].text)这段代码的关键参数不是越多越好。tensor_parallel_size=1是先把单卡链路跑通,多卡并行会引入通信和切分问题,不适合作为第一次验证。gpu_memory_utilization=0.85是给激活值、临时缓冲和框架自身留余量,直接拉到 0.95 很容易在长 prompt 或多请求时 OOM。max_model_len=4096是主动限制 KV Cache 上限,先别按模型最大长度开满。enforce_eager=True在排查阶段很有用,图捕获失败时的报错往往不够直白,关掉后更容易定位是模型加载、算子还是显存问题;稳定后再关掉它换性能。SamplingParams里温度设为 0,是为了让同一输入多次运行结果可比,压测时也一样。
跑通的标准不是“print 出字”就结束,而是同时满足三条:进程不报 CUDA error,输出不是空串或乱码,显存占用在请求结束后能回落到一个稳定水位。如果输出有字但显存一路涨,后面起服务一定会在并发上来时翻车。离线推理通过后,再进入 HTTP 服务层,变量会多出请求排队、并发上限、流式返回和模型名映射,这时才需要看下一章的启动参数。
3. 把服务跑起来:兼容 Chat Completions 的接口与三个关键启动参数
3.1 启动参数:显存、长度、并发三本账
离线推理能跑,不代表服务能扛。服务层多了一个常驻进程、一个请求队列和一套 HTTP 解析,启动参数会把显存账本放大。下面是一个偏保守的启动命令,适合第一次把服务拉起来;具体参数名在 0.7.3 源码里可能略有差异,启动前用--help对一遍,不要照抄旧版本参数。
# 在源码根目录、虚拟环境已激活的前提下启动 python -m vllm.entrypoints.openai.api_server \ --model /data/models/your-llm \ --served-model-name local-llm \ --host 0.0.0.0 \ --port 8000 \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.88 \ --max-model-len 8192 \ --max-num-seqs 64 \ --max-num-batched-tokens 8192 \ --dtype auto \ --disable-log-requests这段命令里,--model是本地模型目录,--served-model-name是请求体里要填的模型名,两者不是一回事;--host 0.0.0.0让服务监听所有网卡,--port 8000是本地验证端口。--tensor-parallel-size控制张量并行卡数,单卡先写 1。--gpu-memory-utilization是显存占用目标,0.88 比离线阶段的 0.85 略高,是因为服务常驻需要更稳定的预留,但不要一上来写 0.95。--max-model-len是服务允许的最大上下文长度,它直接决定 KV Cache 的预留上限;模型支持 32K 不代表你要开 32K,先按业务真实长度开。--max-num-seqs是同时调度的序列数上限,--max-num-batched-tokens是一次批处理里 token 总量上限,这两个参数不是越大越好,后面压测会看到拐点。--dtype auto让框架按模型配置选精度,量化模型则需要额外的量化参数,不能只靠 auto 猜。--disable-log-requests在压测时减少日志噪音,排查阶段可以去掉。
| 参数 | 作用 | 第一次启动建议 | 常见误用 |
|---|---|---|---|
--gpu-memory-utilization | 控制框架可占用的显存比例 | 0.85 到 0.90 | 直接 0.95,长 prompt 时 OOM |
--max-model-len | 限制上下文与 KV Cache 上限 | 按业务真实长度 | 按模型最大长度开满 |
--max-num-seqs | 同时调度的序列数 | 32 到 64 起步 | 只看并发数,不看显存 |
--max-num-batched-tokens | 单次批处理 token 上限 | 4096 到 8192 起步 | 拉太大,prefill 挤占 decode |
--tensor-parallel-size | 张量并行卡数 | 单卡写 1 | 卡数不可见却写大于 1 |
--dtype | 模型计算精度 | auto | 量化权重不配量化参数 |
这张表的核心是“三本账”:显存账、长度账、并发账。显存账看gpu-memory-utilization和模型权重,长度账看max-model-len,并发账看max-num-seqs与max-num-batched-tokens。三本账互相牵制:长度开大,KV Cache 预留变多,能并发的序列就少;并发开大,decode 批次变厚,单请求延迟会抖;批处理 token 开大,prefill 吞吐上去,但短请求的首 token 可能被长请求拖住。第一次启动不要追求“全开”,先让服务稳定返回,再逐项压测。
注意:服务启动日志里要重点看模型加载耗时、KV Cache 块数、可用显存和最大并发估计。日志说启动成功但显存余量很小,后面一定会在真实流量下翻车。
3.2 接口验证:/v1/models 与 /v1/chat/completions 的最小闭环
服务起来后先别急着上业务代码,先用两个请求确认“模型名映射”和“推理链路”都是通的。第一个请求看模型列表,第二个请求走一次 chat completions。下面命令里的模型名必须和--served-model-name完全一致,大小写和短横线都算。
# 先确认服务看到的模型名 curl -s http://127.0.0.1:8000/v1/models | python -m json.tool # 再发一个贪心解码请求,便于复现 curl -s http://127.0.0.1:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "local-llm", "messages": [{"role": "user", "content": "用一句话说明 KV Cache 的作用"}], "temperature": 0, "max_tokens": 64 }' | python -m json.tool这两个命令的逻辑是“先读模型注册表,再打一次最小推理”。/v1/models返回的是服务端注册的模型名,不是本地目录名;请求体里的model必须与它一致,否则会报模型不存在。temperature=0让输出可复现,max_tokens=64控制验证成本,避免第一次就生成几千 token 把显存和等待时间拉长。返回体里重点看choices[0].message.content是否非空,usage里的 prompt tokens 和 completion tokens 是否合理;如果 completion tokens 为 0,通常是生成参数或模板问题,不是服务没起来。
再补一个 Python 客户端验证,方便后面改造成压测脚本。这个脚本只发一次请求,重点是把超时、状态码和用量打印出来,不要把异常吞掉。
import requests url = "http://127.0.0.1:8000/v1/chat/completions" payload = { "model": "local-llm", "messages": [{"role": "user", "content": "解释连续批处理为什么能提升吞吐"}], "temperature": 0, "max_tokens": 128, } # 超时给足,首次请求可能包含编译和缓存初始化 resp = requests.post(url, json=payload, timeout=120) print(resp.status_code) print(resp.json()["usage"]) print(resp.json()["choices"][0]["message"]["content"][:200])这段代码的timeout=120不是随便写的:首次请求可能触发图捕获、内存池初始化或前缀缓存构建,超时太短会把正常冷启动误判成服务故障。resp.status_code要先看,200 以外的响应体里通常有错误原因,比如上下文超限、模型名不匹配、请求体字段不合法。usage用来核对 token 计数,后面压测的吞吐计算就靠它。输出只截前 200 个字符,是为了避免终端被长文本刷屏,不影响判断。
接口通后,再验证流式返回。流式不是必需,但真实业务里首 token 时间很关键。请求体加"stream": true,客户端按行读data:前缀,最后以[DONE]结束。流式模式下不要用普通resp.json(),否则会解析失败。这个验证能提前暴露反向代理缓冲、超时设置和客户端读取方式的问题。服务层跑通后,接下来才是调度参数和 KV Cache 的调优,不要在这一步就急着把并发拉满。
4. 调度与 KV Cache 参数:连续批处理不是把并发开大
4.1 PagedAttention 与块表:为什么 block size 会改变显存碎片
大模型推理的显存大头不只是权重,还有 KV Cache。自回归生成每多一个 token,就要为每一层缓存 key 和 value;请求长度不一,如果按最大长度给每个请求预留连续显存,碎片和浪费会非常严重。分页式 KV Cache 的思路是把缓存切成固定大小的 block,用逻辑块到物理块的映射表管理,请求按需拿块,不要求物理连续。这样显存利用率更稳,连续批处理才能在每一步解码时动态重组 batch。读源码时重点看块表怎么建、块怎么分配、请求结束后块怎么回收,这三个动作决定了长跑服务会不会显存泄漏。
block-size是这套机制里的关键参数。块太小,块表项变多,管理开销和寻址开销上来;块太大,最后一个块尾部浪费变多,短请求多的时候尤其明显。常见默认值是 16,实际选 8、16、32 要看请求长度分布:短请求占主导时可以试小一点,长上下文占主导时可以试大一点,但不要把它当成玄学开关,改完必须用同一组固定长度请求对比显存峰值和吞吐。块表本身不是免费午餐,它把“连续显存分配”换成了“间接寻址”,所以极端碎片场景下收益明显,整齐划一的短请求场景下收益没那么夸张。
连续批处理的核心也不是“同时收很多请求”,而是每一步解码都把还能继续跑的序列重新组批。一个请求生成结束后,它的槽位可以立刻让给新请求,不用等整批结束。这提高了 GPU 利用率,但也带来一个新问题:prefill 阶段和 decode 阶段抢资源。长 prompt 的 prefill 一次要算很多 token,如果批处理 token 上限开得太大,decode 的短请求就会被拖慢,表现为首 token 时间抖动。源码里调度器会在 prefill 和 decode 之间做取舍,参数就是用来约束这个取舍的,不是用来把并发数字做大的。
前缀缓存是另一个容易被误用的点。多个请求共享同一段系统提示或固定指令时,开启前缀缓存可以复用已经算过的 KV 块,降低重复 prefill 成本。但如果每个请求的提示词都不同,缓存命中率很低,开启后反而增加块管理开销。验证方法很简单:用同一段长系统提示加不同用户问题,对比开启前后的首 token 时间和显存;再用完全不同的提示词跑一遍,看收益是否消失。不要只看一个场景就下结论。
4.2 压测调参:用固定输入输出长度找拐点
调参最怕“感觉快了”。今天并发 16 觉得挺顺,明天换一批长短不一的请求就翻车。我的习惯是固定输入长度和输出长度,只改一个参数,记录吞吐、平均延迟和显存峰值。下面这个异步脚本用固定 prompt 和固定max_tokens打一轮并发,重点是看墙钟吞吐和平均延迟,不测首 token 时间;首 token 时间要用流式请求单独采。
import asyncio import time import aiohttp URL = "http://127.0.0.1:8000/v1/chat/completions" PROMPT = "请用 200 字解释大模型推理中的显存碎片。" * 4 async def one(session, i): payload = { "model": "local-llm", "messages": [{"role": "user", "content": PROMPT}], "temperature": 0, "max_tokens": 128, } t0 = time.perf_counter() async with session.post(URL, json=payload) as resp: data = await resp.json() return time.perf_counter() - t0, data["usage"]["completion_tokens"] async def main(concurrency=16): async with aiohttp.ClientSession() as session: tasks = [one(session, i) for i in range(concurrency)] results = await asyncio.gather(*tasks) total_tokens = sum(t for _, t in results) wall = max(lat for lat, _ in results) avg_lat = sum(lat for lat, _ in results) / len(results) print(f"并发={concurrency} 输出token={total_tokens} 墙钟={wall:.2f}s") print(f"吞吐={total_tokens / wall:.2f} tok/s 平均延迟={avg_lat:.2f}s") asyncio.run(main(16))这段脚本的PROMPT故意重复四次,是为了让输入长度稳定在几百 token 量级,避免每次请求长度漂移。max_tokens=128固定输出上限,这样不同并发档位的 token 总量可比。wall取所有请求里最长的耗时,是近似墙钟时间;更严谨的做法是记录整轮开始和结束时间,但用来找拐点足够。total_tokens / wall是近似吞吐,不是严格的服务端吞吐,因为客户端和网络也占时间,所以只适合同一台机器、同一客户端条件下横向比较。并发档位从 1、4、8、16 逐档加,不要一次跳到 64。
| 参数 | 调大后的直接变化 | 调大后的风险 | 适合观察的指标 |
|---|---|---|---|
max-num-seqs | 同时解码的序列变多 | KV Cache 增长,延迟抖动 | 吞吐、显存峰值、TPOT |
max-num-batched-tokens | 单批 token 变多 | prefill 挤占 decode | 首 token 时间、吞吐 |
block-size | 单块变大 | 短请求尾部浪费 | 显存峰值、块利用率 |
| 前缀缓存 | 共享前缀复用 | 低命中率时管理开销 | 首 token 时间、命中率 |
swap-space | CPU 交换空间变大 | 掩盖显存不足,延迟变差 | 交换次数、尾延迟 |
调参顺序建议是:先固定max-model-len,再调gpu-memory-utilization找到不 OOM 的水位,然后小步调max-num-seqs,最后动max-num-batched-tokens。每次只改一个,跑三轮取中位数。swap-space不要当成后悔药,它能把部分 KV Cache 换到 CPU,但 PCIe 往返会让尾延迟很难看,线上服务尤其要谨慎。压测时还要看显存峰值是不是在请求结束后回落,如果每轮都抬高一点,说明块回收或请求清理有问题,应该回源码看块表释放路径,而不是继续加参数。
5. 常见问题与排查:五条血泪踩坑记录
5.1 安装与启动阶段:CUDA、显存、模型路径
第一条踩坑是 CUDA 版本对不上。现象是源码安装时编译扩展报错,或者启动时直接抛undefined symbol、CUDA driver 相关错误;原因通常是驱动、CUDA 运行时和 torch wheel 三者版本跨太多,或者机器里同时存在 conda 和 pip 两套 torch,导入时命中了错误的那一套。解决顺序是先看驱动,再看 torch 自检,最后清环境重装,不要在没有卸载旧 torch 的情况下反复pip install。
# 对齐驱动、运行时和 torch 的 CUDA 版本 nvidia-smi python -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())" # 看启动时显存是否被别的进程占用 nvidia-smi --query-gpu=memory.used,memory.total --format=csv这段检查的逻辑是先确认“卡可见、torch 可见、显存没被占”。nvidia-smi右上角显示的是驱动支持的 CUDA 版本,torch.version.cuda是 wheel 编译版本,torch.cuda.is_available()为False时不要继续装源码包。memory.used如果接近总量,先杀掉残留推理进程或换卡,不要怀疑模型加载逻辑。清环境时把虚拟环境删掉重建,比在旧环境里修依赖更省时间。
第二条踩坑是“显存明明够,启动却 OOM”。现象是模型权重加载完成后,初始化 KV Cache 时失败;原因常见于max-model-len按模型上限开满,gpu-memory-utilization又设得过高,框架按比例预留显存后没有给激活值留余量。解决是把max-model-len降到业务真实长度,把gpu-memory-utilization从 0.95 降到 0.85 到 0.88,再把max-num-seqs降一档。不要只看nvidia-smi的瞬时占用,KV Cache 是启动时按上限预留的,后面请求越多越容易顶到边界。
第三条踩坑是模型路径和模型名混淆。现象是服务启动成功,但/v1/models返回的名字和请求体里的model对不上,chat 请求报模型不存在;原因是--model指向本地目录,--served-model-name才是接口层名字,两者被当成同一个东西。解决是启动时显式写--served-model-name,客户端统一用这个名字,先调/v1/models确认再发 chat。模型目录里缺config.json或 tokenizer 文件时,也会表现为加载失败或输出乱码,这类问题要看启动日志第一处报错,不要被后面的堆栈带偏。
5.2 运行与性能阶段:并发、长 prompt、量化
第四条踩坑是并发一高就排队,但 GPU 利用率并不高。现象是客户端并发 32,吞吐上不去,平均延迟却翻倍;原因往往是max-num-seqs和max-num-batched-tokens互相卡住,或者大量长 prompt 的 prefill 把 decode 批次切碎。解决是先固定输入输出长度,从并发 1 到 8 逐档压测,看吞吐拐点;如果首 token 时间先炸,优先降max-num-batched-tokens或限制单请求长度;如果 decode 阶段抖动,检查max-num-seqs是否超过显存能稳住的量。不要用“再加一张卡”掩盖调度参数问题,单卡拐点没找到,多卡只是把问题放大。
第五条踩坑是量化模型加载失败或输出质量异常。现象是启动时报量化格式不支持,或者能加载但输出重复、乱码;原因是权重目录里的量化配置与启动参数不一致,比如权重是某种量化格式,启动时却用了另一种--quantization,或者根本没加量化参数让框架按默认精度加载。解决是先用非量化权重跑通整条链路,再确认权重目录里的量化配置和文档要求,启动时显式指定量化参数,不要靠猜。量化后的输出质量要用固定问题集对比,不能只看一句“能出字”。如果显存省下来了但吞吐没升,检查反量化开销是否落在关键路径上,必要时换更匹配的量化格式或回到非量化基线。
注意:排查时一次只改一个变量。同时改模型长度、并发上限和量化参数,日志里出现的新错误很难归因,最后只能全部回滚重来。
6. 进阶验证:用固定长度压测把参数从“能跑”推到“能扛”
6.1 三组固定负载与一张指标表
我一般不会只看一条平均延迟。固定 prompt 长度 512、输出 128,把并发从 1 拉到 16,记录首 token 时间、每输出 token 时间、吞吐和显存峰值;再换一组 2048 长 prompt 和一组 32 短输出,观察长请求对短请求的挤压。首 token 时间突然翻倍,多半是 prefill 排队;每输出 token 时间抖动,先看 decode 批次是否被长请求切碎;吞吐上不去但显存没满,检查max-num-seqs和max-num-batched-tokens是否互相卡住。下面这张表是我压测时必填的,填不满就说明这轮数据不可信。
| 指标 | 采集方式 | 判断标准 |
|---|---|---|
| 首 token 时间 | 流式请求记录首个data到达时间 | 长 prompt 下是否线性恶化 |
| 每输出 token 时间 | 总生成时间除以输出 token 数 | 并发升高后是否剧烈抖动 |
| 吞吐 | 总输出 token 除以墙钟时间 | 是否出现明显拐点 |
| 显存峰值 | 压测中轮询nvidia-smi | 是否逼近预留上限 |
| 请求失败率 | 统计非 200 响应和超时 | 任何非零都要先查 |
压测脚本不要只跑一次。同一档并发跑三轮,取中位数,第一轮通常包含缓存初始化和图捕获,直接拿第一轮数据会低估性能。改完block-size或前缀缓存后,用同一组 prompt 复跑,差异超过几个百分点再决定是否保留。线上流量和压测流量差别很大,压测只负责找边界,不负责模拟全部业务;真实上线前还要用脱
本文还有配套的精品资源,点击获取