Month 1 · 入口与请求生命周期
目标:能在白板上画出 "curl 进来到 token 流出" 的完整调用链。
这个月不碰 KV cache、不碰 CUDA、不碰调度细节。
你只做一件事:跟着一个真实请求,把它走过的每一层代码都摸一遍。
这一层走通,后面所有深入细节都有"挂靠点" —— 当你 M2 读到 BlockManager.allocate() 时,你已经知道它是谁、什么时候被叫起来、传给它的参数从哪里来。
curl http://localhost:8000/v1/chat/completions ...。
这个 HTTP 请求会经过哪些 Python 对象、被哪些线程/进程处理、最后是谁真的把它送上 GPU?
画一张图,需要画几个框?这些框之间是同步调用还是跨进程消息?哪些跑在 GIL 锁里,哪些不?
先猜一下层数(不用准确)
大概 5–7 层:HTTP server → API handler → engine wrapper (async) → engine core → scheduler → worker → model forward。 每层做的事不一样:HTTP 解析、tokenize、调度入队、batch 组装、GPU forward、detokenize、流式输出。
真实数字(在 v1 架构下):3 类进程(API server、EngineCore、Worker × N), 2 个 IPC 边界(ZMQ 把 1↔2 连起来,多机/多 GPU 时 2↔3 走 PyTorch distributed), 4 个主要的内部队列(input queue、waiting、running、output queue)。这一节就是去把这张图真正画对。
01AI infra 的"请求生命周期"到底是什么
在动手挖代码之前,先把"为什么花一个月学这个"想清楚。AI infra 有两种完全不同的生命周期, 你以后做任何决定都要先分清楚自己在哪一边:
训练 (Training)
给定一堆数据,跑 N 个 epoch 的梯度下降,输出是权重文件。 周期以小时/天计;失败容忍度高(多 checkpoint 就行);瓶颈在数据并行 / TP / PP / 通信。
关键词:DDP、FSDP、Megatron、torchtitan、Megatron-LM。
推理 (Serving / Inference)
给定一个权重,处理来自 N 个用户的并发请求流,每个请求要在 P99 SLA 内出第一个 token。 周期以毫秒计;失败容忍度低(用户在等);瓶颈在显存、KV cache、调度。
关键词:vLLM、TGI、SGLang、TensorRT-LLM、batching、prefill / decode。
vLLM 是推理引擎。这意味着它的中心问题是"如何把一台静态的 GPU 变成一台动态的 token 流水线"。 本月你要追踪的"请求生命周期",就是这条流水线上一个 token 从被请求到被吐出的全过程。
另一个动机:vLLM 的入口(vllm/entrypoints/、vllm/v1/engine/)是整个仓库里最 Python 的部分,
没有 CUDA、没有 Triton、没有汇编。你能静态读懂这部分代码,意味着你已经掌握了进入更难层的"密码本"。
02前置:Python async/await 速通
下面这些大致懂就行。模糊的现在补,不熟悉的标记一下,月底再看:
- Python async/await:知道
asyncio.create_task()、await让出控制权、event loop 是单线程的。 - FastAPI / Starlette:HTTP handler 是 async function;middleware 链;SSE (Server-Sent Events) 流式响应。
- Producer-consumer 队列:一边塞、一边消费、用锁/CV 同步。
- Tokenization:知道 text → token id 的转换是 HuggingFace tokenizer 在做。具体算法(BPE / SentencePiece)这里不需要。
其中 async 是这一章绕不开的"语法地基",把它说透。
Event loop 的单线程心智模型
最关键的一句话:asyncio 不是多线程。它是一个线程里调度多个"协程"(coroutines), 让阻塞 I/O 的时候不傻等而是让出控制权,去跑别的协程。可以这样想象:
await 主动让出,event loop 用一个就绪队列调度它们。任何一个协程跑死循环或 CPU 重活,整个 loop 都卡住。async def / await 实际编译成什么
这是读 vLLM 源码时被反复忽略的关键。async def foo() 不是函数调用 —— 它是一个生成器工厂。
调用 foo() 返回的是一个 coroutine 对象,什么都没执行。直到你 await foo() 或塞给 asyncio.create_task(),event loop 才开始拉它的状态机。
# 看起来像普通函数
async def fetch_token():
request_id = await engine.add_request(prompt) # ① 在这里 suspend
async for output in engine.generate(request_id): # ② 每个 yield 是一次 suspend
yield output
# 调用它不执行任何代码
gen = fetch_token() # 这是一个 async generator object
# 直到 ↓ 才真正跑起来
async for tok in gen:
print(tok)
心智模型:把 await x 念作 "暂停当前任务,把 x 交给 event loop,等它有结果再叫醒我"。
这个"叫醒"就是 vLLM 流式输出能边算边返回的关键。
asyncio.Queue vs threading.Queue
vLLM 内部到处是队列。但两种队列不能混用:
asyncio.Queue | queue.Queue (threading) | |
|---|---|---|
| 使用方 | 同一个 event loop 内的协程 | 多个 OS 线程 |
put 阻塞时 | await 让出(其他协程可跑) | OS 真线程阻塞 |
| 跨进程? | ❌(用 ZMQ / multiprocessing.Queue) | ❌(同上) |
| vLLM 用在哪 | AsyncLLM 给每个 request 的 output 队列 | 极少(worker 内部偶有) |
queue.Queue.get()(无 timeout),整个 event loop 卡死,
连 health check 都不响应。看到 vLLM 哪里挂死,第一时间用 py-spy dump --pid X 看是不是这类问题。
SSE / generator 流式响应
SSE (Server-Sent Events) 是 HTTP 的"服务器推送"协议。vLLM 用它把 token 流回 client。FastAPI 的实现:
from fastapi.responses import StreamingResponse
@app.post("/v1/chat/completions")
async def chat(req: ChatRequest):
if req.stream:
async def gen():
async for tok in engine.generate(req):
# SSE 格式:data: {...}\n\n
yield f"data: {tok.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
# 非流式:等所有 token 攒齐
full = ""
async for tok in engine.generate(req):
full += tok.text
return {"choices": [{"message": {"content": full}}]}
关键洞察:这个 gen() 是一个 async generator,每个 yield 都把控制权还给 event loop,
让别的请求也能继续推进。如果你在中间不小心写了同步 I/O(比如 requests.get(...) 而不是 httpx.AsyncClient.get(...)),
整个服务就只能"一次一个请求"。
常见 gotcha 速查
| 错 | 表现 | 对 |
|---|---|---|
忘了 await | 函数立刻返回一个 coroutine 对象,没执行 | 记得 await,或 asyncio.create_task() |
在 async 函数里 time.sleep(1) | 整个 event loop 卡 1 秒 | await asyncio.sleep(1) |
| 在 async 里跑 CPU 重活 | 所有协程都被卡 | await loop.run_in_executor(None, heavy_fn) |
| 在 sync 代码里调 async 函数 | 得到 coroutine 但没执行 | asyncio.run(coro()) |
| 多个 event loop | "got Future attached to a different loop" | 整个进程只用一个 loop |
03vLLM v0 vs v1:为什么要拆进程
记住两个版本概念:
- v0 引擎 (历史架构,
vllm/engine/):单进程异步。 - v1 引擎 (vLLM 0.6.4+ 默认,
vllm/v1/):API server 和 engine 分进程,scheduler 在 engine 进程,跑得更快、CPU 不再卡瓶颈。
新代码统统看 v1。但为什么要拆?这背后是一个非常 OS 风格的故事 —— Python GIL。
v0 的瓶颈:GIL
v0 的设计很自然:一个 Python 进程,一个 event loop。这个 loop 既要:
- 处理 HTTP(FastAPI / uvicorn)
- 跑 tokenizer(CPU 重活)
- 跑 scheduler(决定下一步 batch)
- 把 batch 发给 worker(多进程 / 多机)
- 等 worker 返回 logits,detokenize
- 把 token SSE 推回去
CPython 的 GIL 决定了上面这些 Python 操作不能并行,只能串行(更准确说:交错)。 当 QPS 高时,scheduler 那一步可能要花 1–3 ms,期间 HTTP handler 拿不到 GIL, 新请求积压、SSE 推送变慢、整体延迟劣化。GPU 反而经常空转等 CPU 喂。
v1 解决了什么
- 新请求接入延迟:HTTP handler 在自己进程里,不被 scheduler 拖。p99 first-token latency 显著改善(vLLM 团队博客称 throughput 提升 1.7×–2×)。
- scheduler 可以更"贪":不用担心一次 step 卡住 HTTP,可以做更复杂的调度决策(如 chunked prefill)。
- 更清晰的状态边界:API server 不再持有 KV 池、调度状态。崩了重启只丢"飞行中"的请求,不会影响别人。
v1 的代价
- 多一道 IPC:每个请求至少多两次 ZMQ 序列化(API→Engine 一次,Engine→API 多次的 streaming output)。vLLM 用 msgpack 或 pickle,单次开销几十微秒。
- 调试复杂度:两个进程,
py-spy要分别看,日志要交叉对齐。 - 关停语义:API server 要管 EngineCore 子进程的生命周期,处理 graceful shutdown / SIGTERM 转发。
04v1 架构总览 · 谁拥有什么状态
下图是 v1 架构的"空间视图"(谁在哪个进程):
下面是同一个系统的"时间视图",看一次 decode step 里这些组件如何交错:
schedule 和 sample 是同一个进程的同一个事件循环,但要在 GPU 算的时候让出,让别的请求也能进 schedule 队列。四类参与者 · 各自的状态
| 角色 | 持有状态 | 不持有什么 |
|---|---|---|
| API Server | HTTP 连接、每个请求的 output queue (asyncio.Queue)、tokenizer、chat template | KV cache、scheduler 状态 |
| EngineCore | waiting / running 队列、block 映射表(不是 block 本身)、scheduler 配置、metrics | 实际的 KV 张量、模型权重 |
| Executor | Worker 集合、TP/PP 拓扑、通信句柄 | 模型权重(在 Worker 里) |
| Worker | 模型权重、实际 KV cache 张量(GPU 显存)、CUDA stream、CUDA graph | 调度决策、HTTP 状态 |
跨进程消息一览
- API → Engine:
EngineCoreRequest(含 request_id、prompt token ids、sampling params)、abort 信号。 - Engine → API:
EngineCoreOutput(含 request_id、新 token ids、finished flag、metrics)。 - Engine → Worker:
SchedulerOutput(这一步要算哪些 token、block table、attn metadata)。多 GPU 时由 Executor 广播到所有 Worker。 - Worker → Engine:
ModelRunnerOutput(这一步的 sampled token ids、logprobs 等)。
05请求生命周期 · 13 步详解
下面这套调用链是 v1 在 vLLM main 分支的形态(路径可能微调,但结构稳定)。
拿这张表去对照你 clone 下来的代码。
| # | 发生了什么 | 关键代码位置 |
|---|---|---|
| 1 | HTTP 请求到 FastAPI route handler | vllm/entrypoints/openai/api_server.py |
| 2 | 构造 ChatCompletionRequest,绑模板 → prompt text | vllm/entrypoints/openai/serving_chat.py |
| 3 | Tokenize prompt → list[int] | vllm/transformers_utils/tokenizer.py |
| 4 | 构造 EngineCoreRequest 通过 ZMQ 发到 engine | vllm/v1/engine/async_llm.py |
| 5 | Engine 主循环把它加入 waiting 队列 | vllm/v1/engine/core.py 中 run_busy_loop() |
| 6 | 每一步 schedule() 决定哪些 request 进入这一步的 batch | vllm/v1/core/sched/scheduler.py |
| 7 | BlockManager 给每个 sequence 分配 KV blocks | vllm/v1/core/kv_cache_manager.py |
| 8 | Executor 把 batch 序列化发给 worker | vllm/v1/executor/ |
| 9 | Worker 在 GPU 上跑 model.forward() | vllm/v1/worker/gpu_model_runner.py |
| 10 | Attention kernel 用 block table 访问 KV | csrc/attention/ (CUDA) 或 vllm/v1/attention/ (Triton) |
| 11 | 采样得到下一个 token,回写 KV | vllm/v1/sample/sampler.py |
| 12 | Detokenize 这个 token → str,发回 API server | vllm/v1/engine/output_processor.py |
| 13 | SSE 流式 yield 给 HTTP client | 回到 step 2 的 streaming generator |
下面把这 13 步当作 13 个小故事来讲,每一步注明:读什么状态、写什么状态、怎么会出错。
步骤 1–4 · 接入阶段(API Server 进程)
步骤 1 · uvicorn 收到 TCP 包,Starlette 解析 HTTP,把请求路由到 chat_completion handler。
状态读:路由表、CORS / auth middleware 缓存。状态写:当前请求的 ASGI scope。
故障点:连接打满(uvicorn 默认 worker 数有限)、JSON 解析失败、内容超长被 middleware 截。
步骤 2 · OpenAIServingChat.create_chat_completion() 把 messages 数组用 Jinja chat template 渲染成单一 prompt 字符串,
顺带处理 tool_choice、response_format。
状态读:模型的 chat template(启动时从 tokenizer 加载)。状态写:内部 ChatCompletionRequest dataclass。
故障点:未知 role、template 渲染失败、tool schema 不合法。
步骤 3 · 渲染好的 prompt 喂给 HuggingFace tokenizer,得到 list[int]。
状态读:tokenizer vocab、merges。状态写:prompt_token_ids。
故障点:超长(> max_model_len)—— vLLM 会 reject;
特殊 token 不一致 —— 这是相当多模型的兼容性 bug 来源。
步骤 4 · 构造 EngineCoreRequest,通过 AsyncLLM 的 ZMQ socket 发到 EngineCore 进程。
状态读:sampling params、stop tokens。状态写:API server 端为该 request 开一个 asyncio.Queue 接收 output;
发出 EngineCoreRequest 字节流。
故障点:序列化失败(很少)、ZMQ socket 满(背压,理论上会发生但生产配置下罕见)。
步骤 5–8 · 调度阶段(EngineCore 进程)
步骤 5 · EngineCore 的 run_busy_loop 从 ZMQ 收到 request,调用 Scheduler.add_request() 入 waiting 队列。
状态读:当前 running 集合、KV pool 剩余 block。状态写:waiting 队列追加。
故障点:max_num_seqs 达上限(罕见)、请求格式不一致。
步骤 6 · 每次 schedule() 决定:哪些 waiting 进入 prefill、哪些 running 继续 decode、是否要 preempt 出某些 running 来腾 KV block。
状态读:waiting / running / preempted、KV pool。状态写:这一步的 batch 描述、被 evict 的 sequence。
故障点:deadlock(一个长 prompt 占不下任何位置,scheduler 死循环)—— 通过 chunked prefill 缓解。
步骤 7 · BlockManager.allocate() 给新进 prefill 的 sequence 分配 KV block,或给 decode 的 sequence 在写满当前 block 时补一个。
状态读:free block list、prefix cache hash 表。状态写:block 引用计数、sequence 的 block_table。
故障点:没 block 了(应该不发生,scheduler 该兜住);prefix cache miss 算错(M2 会深挖)。
步骤 8 · Executor.execute_model() 把 SchedulerOutput 序列化广播到所有 Worker。
单卡时 Executor 直接 in-process 调 Worker;TP/PP 时走 PyTorch distributed 的 broadcast。
状态读:worker handle、通信 topology。状态写:序列化的请求 payload。
故障点:worker hang、通信 timeout、payload 序列化爆栈(罕见)。
步骤 9–11 · 计算阶段(Worker 进程,GPU)
步骤 9 · gpu_model_runner.execute_model() 把 batch 转成 PyTorch tensor:把所有 sequence 的 input_ids 拼平、构造 positions、查每个 sequence 的 block_table。
状态读:模型权重、当前的 KV cache 池、上一步的 token。状态写:input batch tensor、attn metadata。
故障点:shape 不对(debug 噩梦)、显存 OOM。
步骤 10 · 跑 model.forward(),关键是 attention 层 —— 它用 attn metadata(block table、slot mapping)告诉 PagedAttention kernel 去哪儿读 KV、写新 KV。
状态读:input tensor、attn metadata、KV cache 池。状态写:每个 sequence 的新 KV、最后一层 logits。
故障点:kernel illegal memory(block table 错位最常见)、numerics 不稳定(fp16 vs bf16)。
步骤 11 · Sampler 从 logits 采样下一个 token(greedy / topk / topp / temperature)。多个请求并行采样。 状态读:logits、各请求的 sampling params。状态写:每个 sequence 的 next token id。 故障点:采样器实现 bug(最近的 PR 经常在这里)、bad logits(NaN / Inf)。
步骤 12–13 · 输出阶段(回到 EngineCore 再到 API Server)
步骤 12 · EngineCore 把 token id 流过 output_processor(detokenize + 处理 stop strings + finish reason),打包成 EngineCoreOutput 发回 API server。
状态读:detokenizer 状态、stop strings。状态写:每个 sequence 的累计 text。
故障点:BPE 半字符 token(detokenize 必须维持状态,不能逐 token 解码)—— vLLM 用增量 detokenizer 处理。
步骤 13 · API server 端的 asyncio.Queue 拿到 output,handler 的 async for 拉出来 yield 成 SSE 帧推给 client。
当 finished=True 时 yield data: [DONE]。
状态读:当前 request 的 output queue。状态写:HTTP 响应 body。
故障点:client 提前断开 —— 需要把 abort 信号反向传回 EngineCore(用 asyncio.CancelledError)。
git grep:比如 git grep -l "run_busy_loop" 就能定位。
这是日常技能 —— 教程里的路径只是"线索",不是"地图"。
06代码深读 · api_server.py
这一节起,我们挑 4 段最关键的代码做"深读"。每一段先看结构骨架(伪代码层面),再点出 1–2 个细节"读到这里要停一下"的地方。
vllm/entrypoints/openai/api_server.py 是整个引擎的 HTTP 边界。它做四件事:
启动时加载模型、注册路由、把每个请求转给引擎、关停时清理。
下面是它的骨架(删掉细节,保留结构):
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
# ── ① lifespan:启动 / 关停的 hook ──
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动:构建 AsyncLLM(会 fork EngineCore 子进程)
engine = await build_async_engine_client(args)
app.state.engine = engine
yield # ← FastAPI 在这里把控制权还给主线程
# 关停:发送 abort 给所有 in-flight 请求,shutdown engine
await engine.shutdown()
app = FastAPI(lifespan=lifespan)
# ── ② 健康检查 ──
@app.get("/health")
async def health(req: Request):
if req.app.state.engine.is_running():
return {"status": "ok"}
raise HTTPException(503)
# ── ③ Chat completion 主路由 ──
@app.post("/v1/chat/completions")
async def create_chat_completion(req: ChatCompletionRequest, raw: Request):
handler: OpenAIServingChat = raw.app.state.openai_serving_chat
generator = await handler.create_chat_completion(req, raw)
if isinstance(generator, ErrorResponse):
return JSONResponse(content=generator.model_dump(), status_code=generator.code)
if req.stream:
# SSE 流式
return StreamingResponse(content=generator, media_type="text/event-stream")
else:
# 非流式:消费完所有 chunk 攒成一个 dict
return JSONResponse(content=generator.model_dump())
# ── ④ Completions / embeddings / 等等路由略 ──
--disable-async-output-proc,谁会少一个子进程?为什么 v0 模式不能享受这个拆分?Streaming response 的生命周期
关键魔法在 StreamingResponse(content=generator, ...) 这一行。
这个 generator 是一个 async generator,每次 yield 一个 SSE 帧。
Starlette 拿到它后,会:
- 开一个 background task 去消费这个 generator
- 每拿到一个 chunk,写到 TCP socket(
send) - 如果 client 中途断开,
send抛ConnectionResetError,generator 收到asyncio.CancelledError - generator 的
finally块负责反向通知 engine把这个 request abort 掉
# serving_chat.py 里的简化版 generator
async def chat_completion_stream_generator(...) -> AsyncIterator[str]:
request_id = f"chatcmpl-{uuid()}"
try:
async for output in engine.generate(prompt, params, request_id):
chunk = build_sse_chunk(output)
yield f"data: {chunk}\n\n" # ← 每次 yield 让出控制权
yield "data: [DONE]\n\n"
except asyncio.CancelledError:
# client 断开
await engine.abort(request_id)
raise
finally:
# 兜底清理
pass
async for 都在等 engine 推下一个 token。这是"用 200 行代码服务 1000 并发"的核心机制。
07代码深读 · async_llm.py 与 ZMQ
vllm/v1/engine/async_llm.py 里的 AsyncLLM 类是 API server 进程看到的 engine。
实际 engine 在子进程,AsyncLLM 只是个 RPC 代理。它持有 ZMQ socket,把 add_request / abort / get_output 转成跨进程消息。
骨架:send / receive 双循环
class AsyncLLM:
def __init__(self, engine_args):
# fork 出 EngineCore 子进程
self.engine_proc = start_engine_subprocess(engine_args)
# 两个 ZMQ socket
self.input_socket = zmq_ctx.socket(zmq.PUSH)
self.input_socket.connect(input_path) # → 给 engine 发请求
self.output_socket = zmq_ctx.socket(zmq.PULL)
self.output_socket.connect(output_path) # ← 从 engine 收 output
# 每个 request_id → asyncio.Queue,存它的 output
self.output_queues: dict[str, asyncio.Queue] = {}
# 后台任务:把 output_socket 收到的消息分发到各 queue
asyncio.create_task(self._output_handler_loop())
async def add_request(self, request_id, prompt_token_ids, params):
# 准备这个 request 的 output queue
self.output_queues[request_id] = asyncio.Queue()
# 序列化并通过 PUSH socket 发给 engine
msg = EngineCoreRequest(request_id, prompt_token_ids, params)
await self.input_socket.send(msgpack.packb(msg))
async def generate(self, prompt, params, request_id) -> AsyncIterator[Output]:
await self.add_request(request_id, ..., params)
queue = self.output_queues[request_id]
try:
while True:
output = await queue.get() # ← await 让出
yield output
if output.finished:
break
finally:
del self.output_queues[request_id]
async def _output_handler_loop(self):
# 后台循环:不停从 ZMQ 收 output,路由到对应 queue
while True:
raw = await self.output_socket.recv()
output = msgpack.unpackb(raw)
queue = self.output_queues.get(output.request_id)
if queue:
await queue.put(output)
ZMQ socket 类型选择
| 方向 | socket 类型 | 语义 |
|---|---|---|
| API → Engine 请求 | PUSH / PULL | 单向、消息排队、无应答;多 Producer 时 ZMQ 自动 round-robin |
| Engine → API output | PUSH / PULL | 同上,反方向 |
| 多 API server replicas | DEALER / ROUTER | 双向、可寻址;用 identity 标识 client;不常用 |
实际代码可能用 DEALER/ROUTER 以便支持多个 API server replicas 连同一个 engine(如果做 multi-tenant),
但单机最常见的是 PUSH/PULL + Unix domain socket(路径形如 ipc:///tmp/vllm-XXX)。
序列化格式
vLLM 当前主要用 msgpack(带自定义 hook 处理 numpy / dataclass)。 msgpack 比 pickle 安全(不执行任意代码)、比 JSON 紧凑、比 protobuf 简单。 单次开销在几十微秒到几百微秒,跟 forward 的 10+ ms 比是小数。
mmap / SharedMemory)确实更快,但要自己管同步、生命周期、版本兼容。
vLLM 在 worker 间用过共享内存(vllm/distributed/device_communicators/),但 API↔Engine 这一层用 ZMQ —— 消息量不大,调试方便,扩展性好(同样的代码能跑多机)。这是 trade-off。
背压:当 client 太慢
ZMQ 默认会缓冲消息,缓冲满了发送方会阻塞(HWM,high water mark)。
在 vLLM 里这极少触发 —— output 推送很快,client 通常跟得上。
真正要担心的是反向:client 一次发上千请求把 input socket 塞爆。vLLM 用 max_num_seqs 在更上层兜住。
08代码深读 · EngineCore.run_busy_loop()
这是整个引擎的心跳函数。读懂这 50 行(实际更多但骨架就这些),你就理解了 vLLM 怎么把"接收请求"和"算 token"交错起来。
class EngineCore:
def run_busy_loop(self):
while True:
# ── ① 处理新到的请求(非阻塞 poll)──
while not self.input_queue.empty():
req = self.input_queue.get_nowait()
if isinstance(req, EngineCoreRequest):
self.scheduler.add_request(req)
elif isinstance(req, AbortRequest):
self.scheduler.abort(req.request_id)
# ── ② 没事干就 idle wait ──
if not self.scheduler.has_unfinished_requests():
req = self.input_queue.get() # 阻塞,等新请求
self._handle_input(req)
continue
# ── ③ 调度这一步的 batch ──
scheduler_output = self.scheduler.schedule()
# ── ④ 执行模型(这一步会去 GPU 上跑 forward)──
model_output = self.model_executor.execute_model(scheduler_output)
# ── ⑤ 更新调度状态(标记已完成的 sequence)──
engine_outputs = self.scheduler.update_from_output(
scheduler_output, model_output
)
# ── ⑥ detokenize 并推回 API server ──
for output in engine_outputs:
detokenized = self.output_processor.process(output)
self.output_queue.put(detokenized)
关键观察:
- 这是一个 sync 循环,不是 async。EngineCore 进程的 main loop 是同步的 —— 因为它只有一件事要做(驱动 GPU),不需要协程交错。
- 每一圈 = 一个 step = 一次 forward。一次 forward 可能 produce 几十个新 token(每个 sequence 一个,这一步 batch 多大就多少个)。
- 请求接入是"轮询":每圈先 drain input queue 一次。不会阻塞太久(GPU 在算的时候 CPU 这边可以处理新接入),但也不是真的 zero-latency。
- 有一个隐藏的优化:execute_model 的 GPU 时间和下一轮的 schedule 可以重叠(vLLM 内部用 CUDA event 或单独 CPU 线程做 pipeline),这就是 "async output processing"。
09代码深读 · worker 端的 input prep
到这里 scheduler 已经给出 "下一步算哪些 token",但真的开始算之前,worker 还有一段非常微妙的 input prep。 这一段是 vLLM 性能的核心,看懂了你才能在 M4 读 attention kernel 时不迷路。
从 SchedulerOutput 到模型输入
SchedulerOutput 是一个紧凑的描述:"这一步 batch 里有 N 个 sequence,sequence A 是 prefill(要算 1024 个 token),sequence B 是 decode(只算 1 个 token)..."。
gpu_model_runner.prepare_inputs() 要把它翻译成 PyTorch 能直接 forward 的 tensor:
# 简化的伪代码 —— 真实代码在 vllm/v1/worker/gpu_model_runner.py
def prepare_inputs(scheduler_output: SchedulerOutput):
# ── ① 拼接所有 sequence 这一步要算的 token ──
# prefill sequence 把整段 prompt 都放进来;decode sequence 只放最后一个 token
input_ids = [] # shape: [sum_of_lens]
positions = [] # 每个 token 在它所在 sequence 里的位置
seq_lens = [] # 每个 sequence 在 input_ids 里的长度
for seq in scheduler_output.sequences:
if seq.is_prefill:
input_ids.extend(seq.prompt_token_ids[seq.computed_len:])
positions.extend(range(seq.computed_len, seq.total_len))
seq_lens.append(seq.total_len - seq.computed_len)
else: # decode
input_ids.append(seq.last_token_id)
positions.append(seq.total_len - 1)
seq_lens.append(1)
# ── ② 构造 attn metadata ──
attn_metadata = AttentionMetadata(
# block_table: 每个 sequence 用了哪些物理 KV block
block_tables=stack_block_tables(scheduler_output.sequences),
# slot_mapping: 每个新 token 的 KV 要写到 KV pool 里哪个 slot
slot_mapping=compute_slot_mapping(scheduler_output.sequences),
# context_lens: 每个 sequence 当前一共看过多少 token (含 prefill)
context_lens=[s.total_len for s in scheduler_output.sequences],
seq_lens=seq_lens,
# ... 还有 num_prefills、num_decode_tokens 等
)
return ModelInput(
input_ids=torch.tensor(input_ids, device="cuda"),
positions=torch.tensor(positions, device="cuda"),
attn_metadata=attn_metadata,
)
三个最容易混的名词
| 名词 | 形状 | 含义 |
|---|---|---|
block_table |
[num_seqs, max_blocks_per_seq] |
每个 sequence 用了哪些物理 KV block(block 编号)。 类比:进程的页表(虚拟页 → 物理页)。 |
slot_mapping |
[num_new_tokens] |
这一步要新算的每个 token,它的 KV 要写到 KV pool 里哪个 slot(slot = block_id × block_size + offset)。 这是 kernel 写 KV 的目标地址。 |
context_lens |
[num_seqs] |
每个 sequence 当前累计的 token 数(含 prompt + 已 decode)。 attention kernel 用它来决定 causal mask 的范围。 |
prepare_inputs 跑在 CPU 上,单步可能要 1–2 ms。
vLLM 的优化方向:把这一步缓存(CUDA graph capture)、用 torch.compile、提前 prepare 下一步(pipelining)。
M1 只需要看懂结构;M4 你会真正去测它。
slot_mapping 是怎么算的?block_table 里"还没分配"的位置怎么处理?
attn_metadata 怎么从这里流到 kernel。10完整时序图 · 从 curl 到 token
把前面 10 节合到一张图上。下图是一个 single chat request 从发出到收到第一个 token 的完整时序, 时间是典型值(7B 模型、A10 卡、batch_size=8、prompt 长度 200):
对照上面的图,SLO 优化的杠杆点分布如下:
- 第一个 token 延迟(TTFT):主要是 prefill 时长 → chunked prefill、prefix cache、speculative decoding。
- 后续 token 延迟(ITL / TBT):主要是 decode forward 时长 → batch 加大、TP、attention kernel 优化。
- 吞吐(QPS):CPU 路径效率 + KV cache 利用率 → v1 架构本身 + PagedAttention。
11动手 · curl 一个真实请求并追踪它
下面是一个必做练习。读了 10 节代码,现在让一个真实请求在你眼前过一遍。
步骤 A · 起一个带 DEBUG 日志的 vLLM
# 单卡 A10 / 7B 模型
VLLM_LOGGING_LEVEL=DEBUG \
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--port 8000 \
--max-model-len 4096 \
--gpu-memory-utilization 0.85 \
2>&1 | tee /tmp/vllm.log
等到日志里出现 Application startup complete.,你就准备好了。
另开一个 terminal:
步骤 B · 发一个 chat completion 请求
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "meta-llama/Llama-3.1-8B-Instruct",
"messages": [{"role": "user", "content": "Explain async/await in 2 sentences."}],
"stream": true,
"max_tokens": 64
}'
你会看到 SSE 帧一条条流出来。
步骤 C · 把日志和 13 步对齐
打开 /tmp/vllm.log,搜下面这些关键词,把它们和上面的 13 步对齐:
| 关键日志(截断 / 大致样貌) | 对应步骤 |
|---|---|
POST /v1/chat/completions | 步骤 1(HTTP 到达) |
Received request chatcmpl-xxx | 步骤 2–4 |
Added request chatcmpl-xxx to waiting | 步骤 5 |
Engine step 12345: scheduled X prefill + Y decode | 步骤 6 |
KV cache: allocated N blocks | 步骤 7 |
Executor: forward took A ms | 步骤 8–10 |
Sampled token X for chatcmpl-xxx | 步骤 11 |
Finished request chatcmpl-xxx | 步骤 12–13 |
grep -i + 关键词搜(schedule、block、forward、token),
把能找到的都标上 13 步对照号。
步骤 D · 用 py-spy 看一眼运行时栈
这是非常有教育意义的一招。在请求处理时(保持 vLLM 跑着、curl 一边发请求),另开 terminal:
# 找到 EngineCore 子进程 PID
ps aux | grep EngineCore
# 抓一个栈
py-spy dump --pid
你应该能看到栈最顶上是 run_busy_loop,往下是 execute_model → model.forward → 一堆 attention / matmul。
这是你画的那张图的物证。
12这个月你要读的 5 段代码
下面 5 个 anchor,按顺序读完,每段带一个核心问题。前面深读章节是"导读",下面是你自己要去做的事。
schedule()execute_model()attn_metadata 怎么构造的。13本月作业 · 画图 + PR
作业 A · 画一张请求生命周期图
读完上面 5 段,不看任何东西,自己在白板/Notion/纸上画:
- 所有出现的进程 / 线程框(标注哪个是 async 的、哪个是 worker subprocess)
- 每个框里存的关键状态(队列、cache、KV pool)
- 跨进程的消息箭头(ZMQ payload 是什么形状)
- "一次 decode step" 这条主路径标红
画完后跟本文 §04 那两张图对照。发现的不一致就是你下个月的研究方向。
作业 B · 第一个非 trivial 的 PR(可选)
Month 1 KPI 是至少一个 PR 已 merge。除了 Week 0 的 typo PR,本月可选挑战:
- 给 API server 加一个新的 OpenAI 兼容字段(参考最近相似 PR 的写法)。
- 修一个 streaming response 的 edge case bug(用
label:bug在 issue 列表筛)。 - 给
serving_chat.py写测试覆盖一个未测的分支。 - 给
async_llm.py的某个 metric 路径加日志(小但实用,且能 force 你读完那段代码)。
label:good-first-issue 标签下的 issue 通常已被人盯,抢手。
更可靠的来源:看最近 merged PR 的 reviewer 留言里 "we should also do X..." 这种隐藏 todo,主动 ping 那位 reviewer。
作业 C · 把请求追踪日志贴到你的笔记里
§11 那个动手练习的产出物。用 markdown 表格记下:日志关键行 → 13 步号 → 你的一句话解释。 这份笔记会在 M2 / M3 反复用到。
14本页自检
Month 1 结束时这些应该全部 ✓
勾选状态会保存在你的浏览器 localStorage 里,下次打开继续。
15延伸阅读
- vLLM v1 alpha release blog · 官方对 v1 架构的设计解释,含 throughput 对比数据。
- Anyscale · How continuous batching enables 23x throughput · 经典文章,建立 continuous batching 直觉。
- Real Python · async IO tutorial · 如果 §02 的 async 速通对你不够,这是更系统的教程。
- OSTEP · Event-based Concurrency · 39 章。把 async / event loop 放回 OS 课本里看,理解会更深。
- ZMQ Guide · 想真正搞懂 PUSH/PULL/DEALER/ROUTER 的区别,没有比这本指南更好的入门。第 1–3 章足够。
- vLLM Discussions · 找代码以外的问题答案的好地方;新人问题常被 maintainer 直接答。