nano-vllm 用千行代码拆解 vLLM 核心,是读懂大模型推理最快的捷径。
这是「Nano-vLLM 源码解读」第 2 讲,承接 L1 的全景导论。这一讲只盯一个类------engine/sequence.py 里的 Sequence。它是 nano-vllm 里被传得最频繁的对象,也是理解后续 KV Cache、调度、抢占等所有章节的前提。
读完这一讲,你能:
- 说出
Sequence在系统里同时承担的三重身份 - 解释
num_tokens/num_cached_tokens/num_scheduled_tokens之间的核心不变式 - 画出包含
WAITING → RUNNING → FINISHED主线和抢占回退的完整状态机 - 跟着一条具体请求把 Sequence 的字段变化逐步追完
1. Sequence 的三重身份
Sequence 本身只是一个普通的 Python 类,但系统里有三个完全不同的模块都会拿到同一个 Sequence 对象,每个模块只关心其中一部分字段。所以同一个对象,在不同模块眼里其实是在扮演不同的角色。这就是所谓"三重身份"。
| 身份 | 谁在用它 | 关心哪些字段 |
|---|---|---|
| 用户请求载体 | tokenizer / 用户 | seq_id, token_ids, num_prompt_tokens, temperature 等采样参数 |
| 调度最小单位 | Scheduler | status, num_scheduled_tokens, is_prefill |
| KV 块持有者 | BlockManager | block_table, num_cached_tokens |
把这三个身份拆开看,很多乍一看"奇怪"的设计就能讲清楚了。
举个例子:num_cached_tokens 为什么不一定等于 num_tokens?
num_tokens服务的是"用户请求载体"身份------这条序列目前一共有多少个 token(prompt + 已生成)。num_cached_tokens服务的是"KV 块持有者"身份------在这些 token 里,已经有多少个的 KV 被写进显存里的 Cache 块了。
两者描述的是同一条序列的不同侧面:前者是"逻辑上序列有多长",后者是"物理上 KV Cache 算到哪一步了"。它们本来就不必相等,也不该混为一谈。
2. 字段一览
按身份分组:
python
class Sequence:
block_size = 256 # 类变量,由 LLMEngine 启动时根据 Config 改写
counter = count() # 自增 seq_id 来源
# ------ 用户请求载体 ------
seq_id: int # 全局唯一编号
token_ids: list[int] # 完整的 token 序列(prompt + 已生成)
last_token: int # token_ids[-1] 的缓存,decode 路径会高频访问
num_prompt_tokens: int # 原始 prompt 长度,永不变
temperature: float # 来自 SamplingParams
max_tokens: int
ignore_eos: bool
# ------ 调度最小单位 ------
status: SequenceStatus # WAITING / RUNNING / FINISHED
num_scheduled_tokens: int # 当前 step 计划计算的 token 数;非 step 期间 = 0
is_prefill: bool # 当前是否在 prefill 阶段
# ------ KV 块持有者 ------
block_table: list[int] # 物理 KV 块号列表,长度 = ceil(num_tokens / block_size)
num_cached_tokens: int # 已写入 KV Cache 的 token 数
num_tokens: int # token_ids 的长度,便于 O(1) 读取
外加几个派生量:
python
@property
def num_blocks(self): # 当前需要多少物理块
return (self.num_tokens + self.block_size - 1) // self.block_size
@property
def last_block_num_tokens(self): # 最后一块里实际用了多少 slot
return self.num_tokens - (self.num_blocks - 1) * self.block_size
@property
def num_completion_tokens(self): # 已经生成的(非 prompt)token 数
return self.num_tokens - self.num_prompt_tokens
def block(self, i): # 取第 i 个物理块对应的 token 列表(用于 hashing)
return self.token_ids[i * self.block_size : (i + 1) * self.block_size]
block(i) 是 BlockManager 计算 prefix-cache hash 时调的关键接口------把"逻辑 token 序列"切成"物理块大小的窗口"。这里只需要记一个直觉:BlockManager 会给每个写满的物理块算一个 hash,目的是让后续请求直接复用前缀已经算好的 KV,跳过这部分的 prefill。具体怎么链式 hash、怎么防碰撞、怎么挂回收,留到 L4 展开。
聚焦 num_scheduled_tokens
三个 num_* 字段里,num_scheduled_tokens 最容易被忽略,却是把整个引擎"步进"起来的那个字段。它的含义是:这一个 step,调度器决定要为这条序列计算多少个 token。
它的生命周期被严格夹在一个 step 内部:
scss
Scheduler.schedule() → 写入 num_scheduled_tokens
ModelRunner.run() → 按这个值做 forward + 写 KV
Scheduler.postprocess()→ 累加到 num_cached_tokens,再清回 0
非 step 期间恒等于 0。这就是为什么下一节会把"它是瞬态字段"放在第一条不变式:所有读它的代码都隐含假设"现在正处于一个 step 内"。
它在不同场景下的取值,对应了引擎里几条不同的执行路径:
| 场景 | num_scheduled_tokens |
|---|---|
| Decode 阶段 | 恒为 1(每步只解一个新 token) |
| Prefill,token 预算够 | num_tokens - num_cached_tokens(一口气把剩下的 prompt 全部 prefill) |
| Prefill,token 预算不够 | 本 step 的剩余预算(chunked prefill,分多步把 prompt 吃完) |
| 不在 step 内 | 0 |
下游谁会读它:
- BlockManager :用
num_cached_tokens + num_scheduled_tokens算出本步要写到哪几个 KV 块、哪些 slot。 - ModelRunner :把它当作每条序列的
seqlen_q,告诉 FlashAttention 本步 query 长度。 - Scheduler 自己 :判断
num_cached_tokens + num_scheduled_tokens == num_tokens是否成立,来决定本步是不是把 prefill 一次性吃完,可以挪到 RUNNING 队列。 - 吞吐统计:把 batch 里所有序列的这个值加起来,作为本 step 处理的 token 总量。
一句话:num_tokens 和 num_cached_tokens 描述的是"序列到目前为止 长什么样"(持久状态),而 num_scheduled_tokens 描述的是"这一步调度器要做多少活"(瞬态指令)。理解这个区别,下一节的不变式就只是这个区别的形式化表达。
3. 字段不变式
这是 L2 最核心的一节。三个数 num_tokens、num_cached_tokens、num_scheduled_tokens 之间的关系,决定了调度器和 KV 写入的所有判断逻辑。把它们的关系理清楚,后面 4 讲都会顺很多。
第一条:num_scheduled_tokens 是瞬态字段。 只有在一个 step 的"已 schedule、还没 postprocess"窗口里才非零,其它时间恒等于 0。
第二条:num_cached_tokens ≤ num_tokens,永远成立。 含义:KV Cache 里写入的 token 数不可能超过逻辑序列长度。
第三条:随 step 进展的语义切换。
| 阶段 | num_cached_tokens 与 num_tokens 关系 | 含义 |
|---|---|---|
| 入队后 | num_cached = 0, num_tokens = prompt 长 | 还没分配 KV 块 |
| chunked prefill 中 | 0 < num_cached < num_tokens | 已经计算了一部分 prefix 的 KV,还在继续 |
| prefill 完成瞬间 | num_cached = num_tokens | 整个 prompt 的 KV 已落盘;下一刻 append_token 后 num_tokens 增 1 |
| decode 稳态 | num_cached = num_tokens − 1 | 最后一个 token(刚 append 的那个)的 KV 还没算,下一 step 算 |
| 抢占回 WAITING | num_cached = 0(全部 deallocate) | 块归还了,下次重新 prefill 时再补 |
围绕这套不变式,scheduler 在一个 step 的开头和结尾各埋了一个判断点。
第一个判断在 schedule(),决定"本步之后还要不要留在 WAITING":
python
if seq.num_cached_tokens + seq.num_scheduled_tokens == seq.num_tokens:
seq.status = SequenceStatus.RUNNING # 切到 decode 队列
self.waiting.popleft()
self.running.append(seq)
num_cached + num_scheduled == num_tokens 这个等式怎么读?
num_cached_tokens:进入本 step 之前,已经写入 KV Cache 的 token 数。num_scheduled_tokens:本 step 打算计算 KV 的 token 数。- 两者相加 = 本 step 跑完后 KV Cache 里会有多少 token 的 KV。
- prefill 阶段
num_tokens就是 prompt 长度(还没 append 过任何 decode token)。
所以这个等式的物理含义是:"本步刚好把 prompt 剩下的部分全部 prefill 掉"。如果成立,说明这是 prefill 的最后一步,可以把 status 翻成 RUNNING,下一步开始 decode;如果不成立(小于),说明这是 chunked prefill 的中间步,本步只啃了一块,后面还要继续。
第二个判断在 postprocess(),决定"这一步要不要 append 一个新生成的 token":
python
if is_prefill and seq.num_cached_tokens < seq.num_tokens:
continue # 还在 chunked prefill 中,本步不 append
seq.append_token(token_id)
注意此时 num_cached_tokens 已经在前一行加上了 num_scheduled_tokens(也就是上面等式的左边)。条件 num_cached < num_tokens 等价于"上面那个等式不成立",意思是:prompt 还没 prefill 完 ,本步算出的 logits 不该当作"用户的下一个 token"接到序列尾巴上------否则就会在 prompt 还没读完时凭空冒出一个生成 token,把序列搞乱。等到最后一步 chunk 跑完,等式成立,这个 if 不再 continue,第一个 decode token 才被正式 append 进去。
下面这张图把上面这套逻辑跑了一遍:一条 prompt 长度 8、单步预算 3 的序列,要经过 3 步 chunked prefill 才会触发那个等式:

第四条:block_table 长度 = num_blocks,仅在序列被 deallocate 时清空。 preempt 也会清空(因为内部就是 deallocate)。
4. 状态机

图里把第 3 节那条等式翻译成了边的触发条件:cached + scheduled == num_tokens 成立时,序列从 WAITING 跨到 RUNNING;不成立时(小于)就停在 WAITING 自循环里继续 chunked prefill。橙色虚线箭头是抢占回退------RUNNING 的序列被踢回 WAITING,KV 块全部归还,下一轮重新走 prefill 流程。
完整的合法转换列在下表(包括停留在同一状态的两类自循环):
| 转换 | 触发位置 | 关键代码 |
|---|---|---|
[*] → WAITING |
LLMEngine.add_request |
seq = Sequence(prompt, sp); scheduler.add(seq) |
WAITING → WAITING |
Scheduler.schedule(prefill 分支) |
当 num_cached + num_scheduled < num_tokens 时不切状态 |
WAITING → RUNNING |
Scheduler.schedule(prefill 分支) |
seq.status = RUNNING; waiting.popleft(); running.append(seq) |
RUNNING → RUNNING |
Scheduler.schedule(decode 分支) |
seq.num_scheduled_tokens = 1; seq.is_prefill = False |
RUNNING → WAITING |
Scheduler.preempt |
seq.status = WAITING; block_manager.deallocate(seq); waiting.appendleft(seq) |
RUNNING → FINISHED |
Scheduler.postprocess |
seq.status = FINISHED; block_manager.deallocate(seq); running.remove(seq) |
注意两件事:
- 抢占回退是
appendleft,不是append。 被抢占的序列下一轮优先恢复,避免长尾。 - 抢占等同于全 deallocate 后重做 prefill。 重做的代价不小------但 prefix cache 多半能救一部分(L5 详谈)。
5. 一条请求的字段时序
具体跑一条:prompt = [1, 2, 3],block_size = 4,max_tokens = 3,假设 EOS = 2 且 ignore_eos = False。
| Step | 阶段 | 触发动作 | num_tokens | num_cached | num_scheduled | block_table | status |
|---|---|---|---|---|---|---|---|
| t0 | --- | add_request |
3 | 0 | 0 | [] |
WAITING |
| t1.s | prefill | schedule:分配 block #7,整段一起跑 |
3 | 0 | 3 | [7] |
RUNNING |
| t1.r | prefill | run:计算 [1,2,3] 的 K/V,存 slot 28-30 |
3 | 0 | 3 | [7] |
RUNNING |
| t1.p | prefill | postprocess:cached+=3, append 42 |
4 | 3 | 0 | [7] |
RUNNING |
| t2.s | decode | schedule:4%4≠1 不分配新块 |
4 | 3 | 1 | [7] |
RUNNING |
| t2.r | decode | run:计算 token 42 的 K/V,存 slot 31 |
4 | 3 | 1 | [7] |
RUNNING |
| t2.p | decode | postprocess:hash block #7(满了),cached+=1, append 99 |
5 | 4 | 0 | [7] |
RUNNING |
| t3.s | decode | schedule:5%4=1 → 分配新块 #9 |
5 | 4 | 1 | [7,9] |
RUNNING |
| t3.r | decode | run:计算 token 99 的 K/V,存 slot 36 |
5 | 4 | 1 | [7,9] |
RUNNING |
| t3.p | decode | postprocess:cached+=1, append 2 = EOS → FINISHED + deallocate |
6 | 0 | 0 | [] |
FINISHED |
几个值得停一下的点:
- t1 步长 3 而不是 1。 prefill 是 chunked-only-on-head 的,能一口吃就一口吃;和 decode 的固定 1 token 形成强烈对比。
- block #7 的 hash 在 t2.p 才出现。 因为 t1 结束时 block 还没满(只有 3 个 token),等 t2 把 token 42 写进去后块满了,
hash_blocks才会真正计算并注册到hash_to_block_id,给后续 prefix cache 复用。 - t3.s 的"5%4=1"判断。
BlockManager.may_append用这个简单条件判断"刚好溢出一格、需要新块",详见 L4。 - deallocate 把
block_table清空,num_cached_tokens归 0。 但token_ids保留,因为用户还要拿来 decode 输出。
6. 下一讲
到这一讲为止,你已经看完了 nano-vllm 里"用户请求"和"调度对象"这两层抽象。接下来进入这门课最核心的部分------KV Cache 与内存管理。
下一讲:PagedAttention 设计动机与块化布局。我们会回答这些问题:
- 为什么 vLLM 系列要把 KV Cache 切成固定大小的块,而不是按序列连续分配?
- 物理块号、
block_table、token 在 cache 里的 slot 这三层映射到底怎么对上? prepare_prefill里那串cu_seqlens_q、slot_mapping、block_tables元数据各自从哪儿来、给谁用?
带着 L2 这一讲对 block_table 和 num_cached_tokens 的认识进入 L3,会把 PagedAttention 的设计动机看得更深。