Nano-vLLM 源码解读 - 2. Sequence 状态机与请求生命周期

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_tokensnum_cached_tokens 描述的是"序列到目前为止 长什么样"(持久状态),而 num_scheduled_tokens 描述的是"这一步调度器要做多少活"(瞬态指令)。理解这个区别,下一节的不变式就只是这个区别的形式化表达。


3. 字段不变式

这是 L2 最核心的一节。三个数 num_tokensnum_cached_tokensnum_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 = 4max_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_qslot_mappingblock_tables 元数据各自从哪儿来、给谁用?

带着 L2 这一讲对 block_tablenum_cached_tokens 的认识进入 L3,会把 PagedAttention 的设计动机看得更深。

相关推荐
cxr8281 小时前
从多目标定义到闭环实验验证的系统工程
人工智能·智能体·逆向合成·材料设计合成
刀法如飞1 小时前
Rust数组去重的20种实现方式,AI时代用不同思路解决问题
人工智能·算法·ai编程
code_pgf1 小时前
OpenClaw的tools与skills详解
人工智能
user80395279525431 小时前
Codex 新人上手——从需求到上线的完整工作流
人工智能
阿斯加德D2 小时前
《霍格沃茨之遗》风灵月影修改器下载(已汉化)2026最新版
人工智能·测试工具·游戏·3d·游戏程序
HIT_Weston2 小时前
75、【Agent】【OpenCode】用户对话提示词(question 工具)
人工智能·agent·opencode
weikecms2 小时前
外卖霸王餐API接口对接
大数据·人工智能·企业微信·微客云
zhangfeng11332 小时前
带有embeding 同时训练的Lora 权重合并,合并后的权重的模型,再训练数的Loss 突然增加
人工智能·lora·sft
树獭非懒2 小时前
Claude Code 完全入门指南:让你的 AI 从"会说"到"会做"
人工智能·程序员·llm