1. 从“请求”到“生成”:理解推理服务的核心脉络
当我们谈论一个现代的大语言模型推理服务时,比如 Nano-vLLM,其核心挑战往往不在于模型本身的一次前向传播,而在于如何高效、稳定地管理海量并发、长度不一的用户请求。想象一下,你正在运营一个在线问答服务,每秒有成千上万个问题涌入,每个问题的答案长度未知,有的可能只需要几个词,有的则需要生成一篇短文。服务器资源(主要是GPU显存和算力)是有限的,如何让这些请求“排好队”、“不打架”,并且能最快地得到响应,这就是推理服务框架要解决的根本问题。
Nano-vLLM 作为 vLLM 的一个轻量化、高性能实现,其精髓就在于设计了一套精巧的请求调度与执行机制。而这一切的基石,便是Sequence 状态机。它不是一个抽象的概念,而是一个实实在在的、驱动着每个用户请求从诞生到结束的“生命引擎”。理解了这个状态机,就相当于拿到了打开高性能推理服务黑盒的钥匙。你会明白为什么你的请求有时会等待,为什么服务能同时处理多个长短不一的序列,以及框架是如何在吞吐量和延迟之间寻找最佳平衡点的。
本文将深入 Nano-vLLM 的源码,聚焦于 Sequence 状态机与请求的完整生命周期。我们将抛开复杂的分布式和高级优化,从最核心的单机调度逻辑入手,拆解一个请求是如何被接收、调度、执行直至返回的。这对于任何想要深入理解推理服务底层原理,或是有意进行二次开发的工程师来说,都是至关重要的一课。
2. Sequence:请求的具象化与状态定义
在 Nano-vLLM 中,用户的每一个推理请求(例如“写一首关于春天的诗”)并不会被直接丢给模型。它首先会被封装成一个Sequence对象。这个对象是请求在系统内部的核心表示,它携带了请求的所有元信息,并随着处理的推进,在不同的状态间流转。
2.1 Sequence 的核心数据结构
一个Sequence对象通常包含以下关键字段,我们可以通过一个表格来快速理解:
| 字段名 | 类型 | 描述 |
|---|---|---|
seq_id | int/str | 序列的唯一标识符,用于在系统中追踪该请求。 |
prompt_token_ids | List[int] | 用户输入(Prompt)经过分词器(Tokenizer)转换后的 token ID 列表。这是生成的“起点”。 |
output_token_ids | List[int] | 动态增长的列表,用于存放模型已生成的所有 token ID。初始为空。 |
status | SequenceStatus | 核心字段,表示序列当前所处的状态,如WAITING,RUNNING,FINISHED等。 |
block_tables | Dict[int, List[int]] | 物理块表。由于 vLLM 使用 PagedAttention 内存管理,它记录了该序列的逻辑块(如第0块、第1块)对应在 GPU KV Cache 中的物理块位置。这是实现高效内存复用的关键。 |
remaining_decode_steps | int | 预估该序列还需要多少步解码才能完成(例如达到最大长度或被停止词终止)。用于调度决策。 |
注意:
block_tables是理解 vLLM 系列高性能的关键。传统方法为每个序列预留固定长度的 KV Cache,导致内存碎片和浪费。PagedAttention 将 KV Cache 划分为固定大小的“块”(Block),不同序列可以共享这些块。block_tables就像一个“页表”,告诉系统:“我这个序列的第0个逻辑块,实际数据存放在物理块号5的位置上。”
2.2 SequenceStatus 状态枚举:生命周期的阶段划分
状态机由SequenceStatus这个枚举类定义。虽然具体状态名可能因版本略有差异,但其核心生命周期通常包含以下几个阶段:
- WAITING (等待):请求刚被系统接收,完成了初步的封装(创建了
Sequence对象),但尚未被调度器选中执行。它正在等待可用的计算资源(主要是GPU上的槽位)。 - RUNNING (运行中):该序列已被调度器选中,其对应的 token 正在或即将在本轮模型前向传播中被计算。这是序列的“活跃”阶段。
- SWAPPED (已换出):这是一个高级状态,在内存压力极大时出现。序列的 KV Cache 被从快速的 GPU 显存转移到了较慢的 CPU 内存或磁盘上,以腾出空间给更高优先级的序列。当资源空闲时,它需要被“换入”才能回到
WAITING或RUNNING状态。 - FINISHED (已完成):序列的生成过程已结束。结束原因可能是:a) 生成了停止词(
<eos>);b) 达到了预设的最大生成长度;c) 用户主动取消了请求。此时,output_token_ids就是最终结果,可以被返回给用户。 - CANCELLED (已取消):用户端主动终止了请求。系统需要释放该序列占用的所有资源(内存块)。
这个状态枚举定义了Sequence对象status字段所有可能的取值,状态之间的转换由调度器(Scheduler)和核心执行引擎(Worker)协同控制,构成了完整的生命周期。
3. 请求生命周期的全景图:一次完整的旅程
让我们跟随一个用户请求,走完它在 Nano-vLLM 中的完整生命周期。这个过程清晰地展示了状态机是如何被驱动的。
3.1 阶段一:接收与初始化 (WAITING)
当服务端通过 HTTP 或 gRPC 接口收到一个推理请求时,生命周期开始。
- 请求解析:框架解析请求体,获取
prompt文本、生成长度参数(max_tokens)、停止词等。 - Tokenization:调用分词器(Tokenizer),将
prompt文本转换为prompt_token_ids。 - 创建 Sequence:系统生成一个唯一的
seq_id,并以prompt_token_ids和其他参数为输入,实例化一个Sequence对象。此时,它的status被初始化为SequenceStatus.WAITING,output_token_ids为空列表。 - 加入调度队列:新创建的
Sequence对象被添加到调度器(Scheduler)的等待队列中。至此,请求的“纸上”准备工作完成,进入排队状态。
3.2 阶段二:调度与执行 (WAITING -> RUNNING -> ...)
这是最核心的循环阶段,由调度器周期性触发。
- 调度决策:调度器(如
FCFS-先来先服务,或更复杂的Continuous Batching策略)从其等待队列中,根据策略选择一组Sequence进入本轮执行。选择的标准通常考虑公平性、优先级以及最重要的——当前 GPU 显存中剩余的物理块是否足够容纳这些序列的 KV Cache。 - 状态转换与资源分配:被选中的
Sequence,其状态从WAITING更新为RUNNING。同时,调度器为这些序列分配或确认它们所需的物理块(更新block_tables)。如果某个序列是从SWAPPED状态恢复的,这里还会涉及将 KV Cache 从 CPU 换回 GPU 的操作。 - 构造模型输入:Worker 将所有
RUNNING状态的Sequence的当前 token 组织起来。对于每个序列,输入是它下一个待生成的 token(对于刚开始的序列,就是prompt的最后一个 token 或一个特殊的开始符)。这些 token 被拼接成一个一维的input_tokens张量。同时,系统会根据每个序列的block_tables生成对应的block_tables张量和context_lens(上下文长度)等元信息,供 PagedAttention 内核使用。 - 内核执行:将
input_tokens和 KV Cache 的元信息输入模型,执行一次前向传播(解码)。这里的关键是调用高度优化的 PagedAttention 算子,它能根据block_tables像查页表一样,高效地从分散的物理块中 gather 出每个序列所需的 K, V 向量,并进行注意力计算。 - 采样与更新:得到每个序列下一个 token 的 logits 后,根据设定的采样策略(如贪婪采样、top-p采样)选择出最终的
next_token_id。将这个next_token_id追加到对应Sequence的output_token_ids列表中。 - 状态再评估:检查每个
RUNNING的序列:- 如果
next_token_id是停止词,或output_token_ids长度达到max_tokens,则将其状态标记为FINISHED。 - 否则,它将继续保持
RUNNING状态,等待下一轮调度。同时,系统会为这个新生成的 token 在 KV Cache 中分配一个新的逻辑块(可能需要分配新的物理块),并更新block_tables。
- 如果
3.3 阶段三:完成与清理 (RUNNING -> FINISHED)
- 结果封存:对于状态变为
FINISHED的Sequence,其完整的output_token_ids即为生成结果。Worker 或一个专门的输出处理器会将其转换回文本字符串。 - 资源释放:这是至关重要的一步,直接关系到系统的长期稳定性和内存利用率。系统必须将该
Sequence所占用的所有物理块(记录在block_tables中)标记为“空闲”,以便分配给后续的序列。在 Nano-vLLM/vLLM 中,这通常由块管理器(BlockManager)负责。 - 结果返回:将生成的文本通过网络接口返回给客户端。
- 对象销毁:
Sequence对象本身可以从管理器中移除,其生命周期正式结束。
3.4 异常路径:取消与换出
- 取消 (CANCELLED):用户可以在请求过程中发送取消指令。系统会将该
Sequence的状态立即置为CANCELLED,并触发与FINISHED类似的资源释放流程,然后丢弃该对象。 - 换出 (SWAPPED):在支持 CPU/GPU 异构存储的配置下,当 GPU 显存紧张且某个
Sequence优先级较低或已长时间未执行时,调度器可能决定将其状态从RUNNING或WAITING改为SWAPPED。触发将它的 KV Cache 从 GPU 复制到 CPU 内存,并释放 GPU 上的物理块。当资源允许时,再将其换回。
整个生命周期可以用一个简化的状态转换图来概括(注意,此处为描述性文字,非图表):“WAITING” 在调度时变为 “RUNNING”;“RUNNING” 在生成完成后变为 “FINISHED”,或在资源紧张时变为 “SWAPPED”;“SWAPPED” 在资源空闲时可被换回 “WAITING”;在任何状态,都可能因用户取消而直接变为 “CANCELLED”。
4. 源码透视:状态转换的关键代码节点
让我们深入到 Nano-vLLM 的源码层面,看看这些状态转换具体发生在哪里。我们以类似伪代码的形式展示核心逻辑。
1. 调度器中的状态转换 (scheduler.py或类似文件):
class Scheduler: def schedule(self) -> SchedulingDecision: # 决策哪些序列本次运行 running_seqs: List[Sequence] = [] for seq in self.waiting_queue: if self._can_allocate(seq): # 检查是否有足够内存块 seq.status = SequenceStatus.RUNNING # WAITING -> RUNNING self._allocate_blocks(seq) # 分配物理块 running_seqs.append(seq) else: break # 内存不足,停止调度 # 可能还有从 SWAPPED 恢复的逻辑 for seq in self.swapped_queue: if self._can_allocate(seq): seq.status = SequenceStatus.WAITING # SWAPPED -> WAITING (或直接 RUNNING) self.waiting_queue.append(seq) return SchedulingDecision(running_seqs, ...)这里清晰地展示了调度器如何将序列从WAITING队列中取出,在资源满足的条件下将其状态改为RUNNING,并分配资源。
2. Worker执行后的状态再评估 (worker.py或engine.py):
class Worker: def execute_model(self, running_seqs: List[Sequence], ...) -> List[SequenceOutput]: # ... 执行模型前向传播,得到 next_tokens ... finished_seqs: List[Sequence] = [] for seq, next_token in zip(running_seqs, next_tokens): seq.output_token_ids.append(next_token) # 检查终止条件 if self._check_stopping_criteria(seq, next_token): seq.status = SequenceStatus.FINISHED # RUNNING -> FINISHED finished_seqs.append(seq) else: # 未结束,为下一个token准备KV Cache空间 self._append_slot(seq) # 处理已完成的序列 for seq in finished_seqs: self.block_manager.free(seq) # 释放内存块 self._send_response(seq) # 发送响应 # ...在执行完模型后,Worker 会遍历所有运行的序列,检查生成是否应结束。如果结束,则立即更新状态为FINISHED,并触发资源释放和结果回传。
3. 块管理器的释放逻辑 (block_manager.py):
class BlockManager: def free(self, sequence: Sequence): # 遍历该序列block_tables中的所有物理块号 for physical_block_number in sequence.get_all_physical_blocks(): self.free_block(physical_block_number) # 将块标记为空闲 sequence.block_tables.clear() # 清空序列的块表资源清理是状态机闭环的关键。当序列FINISHED或CANCELLED时,必须立即、彻底地释放其占用的所有物理块,否则会导致内存泄漏,系统可用内存会越来越少,最终崩溃。
5. 实战中的状态机:调试与问题排查
理解状态机不仅有助于读源码,更能直接指导我们调试和优化推理服务。
场景一:请求堆积,延迟飙升
- 现象:客户端请求响应时间很长,监控发现大量请求处于
WAITING状态。 - 排查思路:
- 检查调度策略:是否是简单的 FCFS 导致长序列阻塞了后面所有请求?考虑是否需引入基于剩余解码步数的预emptive调度。
- 分析内存瓶颈:
WAITING队列过长,根本原因往往是 GPU 显存(KV Cache 空间)不足。使用监控工具查看block_manager的物理块利用率。如果接近100%,说明内存是瓶颈。 - 查看序列长度:是否出现了超长上下文(Long Context)的请求?单个这样的请求会占用大量块,挤占其他请求的资源。可能需要设置单请求最大长度限制。
- 行动:根据排查结果,可以调整调度参数、扩容 GPU 显存、或对输入进行长度裁剪。
场景二:GPU利用率低,但吞吐量上不去
- 现象:
nvidia-smi显示 GPU-Util 不高,但服务吞吐量(Tokens/s)远低于预期。 - 排查思路:
- 检查
SWAPPED状态序列:如果系统配置了交换(Swap),大量序列处于SWAPPED状态意味着频繁的 CPU-GPU 数据拷贝,这会带来巨大的开销,严重拖慢整体速度。监控swapped_queue的大小。 - 分析每次调度的序列数:调度器每次选择的
RUNNING序列数量(Batch Size)是否过小?过小的 Batch Size 无法充分“榨干”GPU 的并行计算能力。但 Batch Size 过大又可能导致内存不足(OOM)。需要找到一个平衡点。 - 审视
FINISHED序列的清理延迟:资源释放是否及时?如果FINISHED序列的块没有立即释放,会虚占内存,导致后续WAITING序列无法被调度,GPU 出现空转。
- 检查
- 行动:禁用或优化交换策略;调整调度器的批量大小参数;确保资源释放是同步且立即执行的。
场景三:生成结果不完整或提前截断
- 现象:返回的文本突然结束,不符合停止词规则,也未达到最大长度。
- 排查思路:
- 确认状态转换逻辑:仔细检查
_check_stopping_criteria函数。是否是停止词列表(Stop Tokens)设置有问题?或者最大长度(Max Tokens)的计算逻辑有误(是否包含了Prompt的长度?)。 - 检查异常处理:在模型前向传播或采样过程中是否发生了未处理的异常,导致序列被强制标记为
FINISHED或CANCELLED?查看日志中是否有错误信息。 - 排查资源强制回收:在极端内存压力下,某些实现可能会强制终止(Kill)最老的或占用最大的序列来保全系统,这可能导致生成意外结束。
- 确认状态转换逻辑:仔细检查
- 行动:修复停止词或长度计算逻辑;增强异常处理的健壮性;优化内存管理策略,避免强制回收。
通过将线上问题映射到Sequence状态机的异常流转上,我们可以快速定位问题根因,从系统层面而不仅仅是模型层面去思考和解决问题。