01

SequenceStatus 状态枚举

每一条进入 vLLM 的推理请求,在系统内部都被封装为一个 Sequence 对象。 SequenceStatus 是该对象整个生命周期的核心标记——调度器、Block Manager、输出处理器都依靠它来判断一条序列当前处于何种阶段,以决定如何分配资源或处理结果。

vllm/sequence.py — SequenceStatus 枚举定义 L38–L71
class SequenceStatus(enum.Enum):
    """Status of a sequence."""
    WAITING = enum.auto()
    RUNNING = enum.auto()
    SWAPPED = enum.auto()
    FINISHED_STOPPED = enum.auto()
    FINISHED_LENGTH_CAPPED = enum.auto()
    FINISHED_ABORTED = enum.auto()
    FINISHED_IGNORED = enum.auto()

    @staticmethod
    def is_finished(status: "SequenceStatus") -> bool:
        return status in [
            SequenceStatus.FINISHED_STOPPED,
            SequenceStatus.FINISHED_LENGTH_CAPPED,
            SequenceStatus.FINISHED_ABORTED,
            SequenceStatus.FINISHED_IGNORED,
        ]

    @staticmethod
    def get_finished_reason(status: "SequenceStatus") -> Union[str, None]:
        if status == SequenceStatus.FINISHED_STOPPED:
            finish_reason = "stop"
        elif status == SequenceStatus.FINISHED_LENGTH_CAPPED:
            finish_reason = "length"
        elif status == SequenceStatus.FINISHED_ABORTED:
            finish_reason = "abort"
        elif status == SequenceStatus.FINISHED_IGNORED:
            finish_reason = "length"   # prompt 超长,对用户同样表现为 "length"
        else:
            finish_reason = None
        return finish_reason

七个状态可以分为两大类:

SequenceStatus 各状态含义
状态 含义 finish_reason
WAITING 序列已加入等待队列,尚未被调度。等待 GPU 资源和 KV block 分配。
RUNNING 序列正在 GPU 上执行(Prefill 或 Decode 阶段),已持有物理 KV block。
SWAPPED 序列被抢占,KV cache 已从 GPU 换出到 CPU 内存(Swap 模式),等待被重新调度。
FINISHED_STOPPED 正常终止:生成了 EOS token,或命中了用户指定的 stop 字符串。 "stop"
FINISHED_LENGTH_CAPPED 达到 max_tokens 上限被截断。输出长度受限,未自然停止。 "length"
FINISHED_ABORTED 被客户端取消或引擎内部主动中止(如客户端断开连接)。 "abort"
FINISHED_IGNORED Prompt 长度超过模型上下文窗口,请求直接被忽略,未执行任何计算。 "length"

注意 FINISHED_IGNORED 虽然从未真正运行过,其 finish_reason 仍返回 "length" 而非 "abort"。这是为了与 OpenAI API 规范对齐——从用户视角来看, prompt 过长和输出过长都属于长度限制,统一用 "length" 表达。

is_finished() 是一个频繁调用的静态方法。设计为静态方法而非实例方法,允许直接传入状态值判断, 避免持有对象引用。调度器在遍历序列组时大量使用此方法做过滤。

SequenceStage vs SequenceStatus
sequence.py 中还有一个 SequenceStage 枚举(PREFILL / DECODE), 它与 SequenceStatus 是正交的两个维度。Status 描述调度层面的状态(是否在运行、是否完成), Stage 描述计算层面的阶段(正在做 prefill 还是 decode)。一条 RUNNING 的序列可能处于 PREFILL 阶段(处理 prompt)或 DECODE 阶段(逐 token 生成)。
02

SequenceData 类

SequenceData 是序列的纯数据层,与调度、KV Cache 等系统关注点完全解耦。 它只负责三件事:存储 token ID 序列、追踪已计算的 token 数、累加 logprob。 这种关注点分离使得 SequenceData 可以安全地跨进程序列化(传递给 Worker)。

vllm/sequence.py — SequenceData.__init__ L96–L122
class SequenceData:
    def __init__(
        self,
        prompt_token_ids: List[int],
        output_token_ids: Optional[List[int]] = None,
    ) -> None:
        if output_token_ids is None:
            output_token_ids = []

        self.prompt_token_ids = prompt_token_ids
        self.output_token_ids = output_token_ids
        self.cumulative_logprob = 0.0
        # 已经过模型计算的 token 数量
        self._num_computed_tokens = 0
        self._stage: SequenceStage = SequenceStage.PREFILL

prompt_token_idsoutput_token_ids 分开存储,而不是合并为一个列表。 原因在于两者在不同场景下有不同的使用方式:prompt 是只读的(多条 beam 序列共享同一个 prompt), output 是动态追加的(每个 decode step 追加一个 token)。分开存储也便于快速获取各自的长度。

vllm/sequence.py — 追加 token 与 logprob 累积 L124–L130
def append_token_id(self, token_id: int, logprob: float) -> None:
    self.output_token_ids.append(token_id)
    self.cumulative_logprob += logprob

def get_len(self) -> int:
    return len(self.output_token_ids) + len(self.prompt_token_ids)

def get_token_ids(self) -> List[int]:
    return self.prompt_token_ids + self.output_token_ids

cumulative_logprob 累加的是每个生成 token 的 log 概率之和,即 log P(y₁) + log P(y₂|y₁) + ... + log P(yₙ|y₁...yₙ₋₁)。 这个值用于 beam search 打分:分数越高(越接近 0),说明序列在模型眼中越"合理"。 注意这里存的是原始累积值,get_beam_search_score() 会再除以长度惩罚因子。

vllm/sequence.py — Prefill 进度追踪(支持 Chunked Prefill) L140–L168
def get_num_computed_tokens(self) -> int:
    """返回已经过模型计算的 token 数(prefill 进度)。"""
    return self._num_computed_tokens

def update_num_computed_tokens(self, num_new_computed_tokens: int):
    """每次 prefill chunk 执行后更新计数。"""
    self._num_computed_tokens += num_new_computed_tokens
    assert self._num_computed_tokens <= self.get_len()
    # 所有 token 都已计算 → 切换到 Decode 阶段
    if self.get_num_uncomputed_tokens() == 0:
        self._stage = SequenceStage.DECODE

def reset_state_for_recompute(self) -> None:
    """序列被抢占后重新计算时,清零进度并回到 Prefill 阶段。"""
    self._num_computed_tokens = 0
    self._stage = SequenceStage.PREFILL

def get_num_uncomputed_tokens(self) -> int:
    # 注意:用 get_len()(prompt + output),而非仅 prompt_len
    # 因为 Recompute 模式下需要重新 prefill 已生成的 output token
    return self.get_len() - self.get_num_computed_tokens()
设计要点:为何 get_num_uncomputed_tokens 用 get_len() 而非 prompt_len
当序列因资源不足被以 Recompute 模式抢占时,已生成的 output token 对应的 KV cache 会被丢弃。 重新调度时,必须把 prompt 和之前生成的 output 全部重新 prefill 一遍,才能恢复到中断前的状态继续 decode。 因此 uncomputed tokens = prompt_len + output_len - num_computed,而不仅仅是 prompt。
vllm/sequence.py — 获取最后一个 token(Decode 阶段用于 KV 计算) L170–L175
def get_last_token_id(self) -> int:
    if not self.output_token_ids:
        return self.prompt_token_ids[-1]
    return self.output_token_ids[-1]

Decode 阶段每次前向只需要输入最后一个 token(因为历史 token 的 KV 已缓存)。 get_last_token_id() 处理了 output 为空时返回 prompt 最后一个 token 的边界情况, 这对应序列完成 prefill 后刚进入第一次 decode 的时刻。

03

Sequence 类

Sequence 是 vLLM 中对一条推理请求(或 beam candidate)的完整抽象。 它将 SequenceData(纯 token 数据)、logical_token_blocks(KV Cache 地址映射)、 output_logprobs(采样详情)和状态标记整合在一起,是调度器操作的基本单位。

vllm/sequence.py — Sequence.__init__ L197–L231
class Sequence:
    def __init__(
        self,
        seq_id: int,
        prompt: str,
        prompt_token_ids: List[int],
        block_size: int,
        eos_token_id: Optional[int] = None,
        lora_request: Optional[LoRARequest] = None,
    ) -> None:
        self.seq_id = seq_id
        self.prompt = prompt
        self.block_size = block_size
        self.eos_token_id = eos_token_id
        self.lora_request = lora_request

        self.data: SequenceData = SequenceData(prompt_token_ids)
        self.output_logprobs: SampleLogprobs = []   # 每个生成 token 的候选 logprob 字典
        self.output_text = ""

        self.logical_token_blocks: List[LogicalTokenBlock] = []
        # 构造函数中立刻将 prompt token 填入 logical blocks
        self._append_tokens_to_blocks(prompt_token_ids)
        self.status = SequenceStatus.WAITING          # 初始状态:等待调度
        self.stop_reason: Union[int, str, None] = None

        # 用于增量 detokenization(流式输出优化)
        self.prefix_offset = 0
        self.read_offset = 0
        self.tokens: Optional[List[str]] = None

构造函数在创建时就立刻调用 _append_tokens_to_blocks() 将 prompt token 填入 logical blocks。 这意味着逻辑地址空间在序列创建时就已确定,调度器随后只需要根据 logical blocks 数量为其分配相应的物理 blocks。

vllm/sequence.py — append_token_id:Decode 阶段每步追加 token L283–L291
def append_token_id(
    self,
    token_id: int,
    logprobs: Dict[int, Logprob],   # {token_id -> Logprob} 整个候选词表的 logprob
) -> None:
    assert token_id in logprobs
    self._append_tokens_to_blocks([token_id])    # 更新逻辑块
    self.output_logprobs.append(logprobs)         # 保存完整候选 logprobs
    self.data.append_token_id(token_id, logprobs[token_id].logprob)  # 更新累积 logprob

每次生成新 token 时,logprobs 字典包含词表中所有候选 token 的概率(或 top-k 子集), 而非只有采样到的那个。这样设计是为了支持 OpenAI 的 logprobs 参数—— 用户可以要求返回 top-5 候选 token 及其概率,因此需要保留完整信息。

vllm/sequence.py — fork:beam search 分叉 L319–L322
def fork(self, new_seq_id: int) -> "Sequence":
    new_seq = copy.deepcopy(self)   # 深拷贝:复制所有状态
    new_seq.seq_id = new_seq_id
    return new_seq

fork() 是 beam search 的关键操作。当需要从一条序列扩展出多个候选时, 调用 deepcopy 完整复制序列状态(包括 logical_token_blocks 和 SequenceData)。 复制后,Block Manager 会触发 Copy-on-Write(CoW)机制——新序列和原序列共享物理 KV blocks, 只有在新序列写入新 token 时才真正复制对应的 block。

vllm/sequence.py — get_beam_search_score L296–L316
def get_beam_search_score(self,
                          length_penalty: float = 1.0,
                          seq_len: Optional[int] = None,
                          eos_token_id: Optional[int] = None) -> float:
    if seq_len is None:
        seq_len = self.get_len()
        # 对齐 HuggingFace:EOS token 不计入长度惩罚
        if (eos_token_id is not None
                and self.get_last_token_id() == eos_token_id):
            seq_len -= 1
    return self.get_cumulative_logprob() / (seq_len ** length_penalty)

Beam search 打分公式:score = Σlog P(yᵢ) / len^α, 其中 α 是 length_penalty。α > 1 倾向于更长的序列,α < 1 倾向于更短的序列。 vLLM 严格对齐 HuggingFace Transformers 的实现,EOS token 不计入 seq_len

vllm/sequence.py — get_num_new_tokens:区分 Prefill 和 Decode L324–L334
def get_num_new_tokens(self) -> int:
    """返回本次前向需要计算的 token 数。
    Decode 阶段:固定为 1(只需最后一个 token)。
    Prefill 阶段:返回剩余未计算的 token 数(支持 Chunked Prefill)。
    """
    if self.data.stage == SequenceStage.DECODE:
        return 1
    return self.data.get_num_uncomputed_tokens()

def is_prefill(self) -> bool:
    return self.data.stage == SequenceStage.PREFILL
hash_of_block:Prefix Caching 的基础
hash_of_block(logical_idx)(L243–L253)计算某个 logical block 的内容哈希, 用于 Block Manager V2 的 Prefix Caching 功能。哈希值基于该 block 覆盖的所有 token ID 以及 lora_int_id(不同 LoRA 适配器的缓存不能混用)。 注释中已标记当前实现是 O(L²) 的,未来需要优化。
vllm/sequence.py — hash_of_block(Prefix Caching 哈希) L243–L254
def hash_of_block(self, logical_idx: int) -> int:
    # TODO: 当 block_size > prompt_size 时可能产生错误哈希
    # TODO: 当前哈希函数是 O(L^2),未来应优化
    num_tokens = self.num_hashed_tokens_of_block(logical_idx)
    return hash(
        (tuple(self.data.get_token_ids()[0:num_tokens]), self.lora_int_id))

def num_hashed_tokens_of_block(self, logical_idx: int):
    return logical_idx * self.block_size + self.block_size
04

SequenceGroup

SequenceGroup 将来自同一个请求的多条 Sequence 组织在一起。 在普通采样(greedy / top-p / top-k)场景下,一个 group 通常只有一条序列; 在 beam search 或 best_of > 1 场景下,一个 group 会同时维护多条并行的候选序列。

vllm/sequence.py — SequenceGroup.__init__ L374–L402
class SequenceGroup:
    def __init__(
        self,
        request_id: str,
        seqs: List[Sequence],
        sampling_params: SamplingParams,
        arrival_time: float,
        lora_request: Optional[LoRARequest] = None,
        multi_modal_data: Optional[MultiModalData] = None,
    ) -> None:
        self.request_id = request_id
        # 用字典存储:seq_id -> Sequence,支持 O(1) 查找
        self.seqs_dict = {seq.seq_id: seq for seq in seqs}
        self.sampling_params = sampling_params
        self.metrics = RequestMetrics(
            arrival_time=arrival_time,
            last_token_time=arrival_time,
            first_scheduled_time=None,
            first_token_time=None,
            time_in_queue=None)
        self.lora_request = lora_request
        self.prompt_logprobs: Optional[PromptLogprobs] = None
        self.state = SequenceGroupState()
        self.multi_modal_data = multi_modal_data

使用字典 seqs_dict 而非列表的原因:beam search 过程中需要频繁按 seq_id 查找、删除特定序列(淘汰分数低的 beam),字典提供 O(1) 的操作。

vllm/sequence.py — get_max_num_running_seqs:调度预算估算 L440–L457
def get_max_num_running_seqs(self) -> int:
    """返回该请求剩余生命周期中最多需要并行运行的序列数。
    用于调度器估算未来资源需求。"""
    if self.sampling_params.use_beam_search:
        # Beam search 始终维持 best_of 条候选
        return self.sampling_params.best_of
    else:
        if self.sampling_params.best_of > self.num_seqs():
            # Prefill 阶段只有 1 条序列;但 decode 阶段需要 best_of 条
            return self.sampling_params.best_of
        # Decode 阶段:未完成的序列数
        return self.num_unfinished_seqs()
为什么需要 get_max_num_running_seqs?
调度器在决定是否接受新请求时,不能只看当前序列数,还要看未来峰值需求。 例如一个 best_of=4 的请求,prefill 阶段只有 1 条序列,但 decode 阶段会扩展到 4 条。 如果调度器只看当前,会误判资源充足而接入过多请求,导致后续 decode 阶段内存爆满。
vllm/sequence.py — get_seqs、get_unfinished_seqs、get_finished_seqs L459–L482
def get_seqs(
    self,
    status: Optional[SequenceStatus] = None,
) -> List[Sequence]:
    return list(self.seqs_dict.values()) if status is None else [
        seq for seq in self.seqs_dict.values() if seq.status == status
    ]

def get_unfinished_seqs(self) -> List[Sequence]:
    return [seq for seq in self.seqs_dict.values() if not seq.is_finished()]

def get_finished_seqs(self) -> List[Sequence]:
    return [seq for seq in self.seqs_dict.values() if seq.is_finished()]

def is_finished(self) -> bool:
    return all(seq.is_finished() for seq in self.get_seqs())

def is_prefill(self) -> bool:
    # 组内所有序列应处于同一阶段
    return self.get_seqs()[0].is_prefill()

get_seqs(status=SequenceStatus.RUNNING) 这种按状态过滤的调用在调度器中非常频繁。 此处用列表推导式实现,每次调用都是 O(n)。在 beam search 场景下 n = best_of(通常 < 10), 这个开销可以接受。

vllm/sequence.py — RequestMetrics 性能指标追踪 L83–L96
@dataclass
class RequestMetrics:
    """跟踪请求级别的延迟指标。"""
    arrival_time: float          # 请求到达时间
    last_token_time: float       # 上一个 token 生成时间(用于计算 TPOT)
    first_scheduled_time: Optional[float]   # 首次被调度的时间
    first_token_time: Optional[float]       # 第一个 token 生成时间(TTFT)
    time_in_queue: Optional[float]          # 在等待队列中的时长
    finished_time: Optional[float] = None  # 请求完成时间

RequestMetrics 追踪了 LLM 服务中最关键的三个延迟指标:

  • TTFT (Time To First Token)first_token_time - arrival_time,用户等待第一个字出现的时间
  • TPOT (Time Per Output Token):通过 last_token_time 滚动计算相邻 token 的时间间隔
  • 时间在队列中time_in_queue = first_scheduled_time - arrival_time

注意:当序列被 Swap 后重新调度时,first_token_time 不会重置。 这是有意为之——从用户角度看,Swap 导致的延迟应该体现在 TPOT 上(某个 token 生成特别慢), 而不是重新计算 TTFT,因为第一个 token 其实已经交付给了用户。

05

SequenceGroupMetadata

SequenceGroupMetadata 是调度器与 Worker 之间的信息载体。 调度器每完成一轮调度决策,就将结果打包成一批 SequenceGroupMetadata 列表, 通过 ExecuteModelRequest 发送给 Worker。Worker 侧的 ModelRunner 读取它来构建模型输入张量和 Attention Metadata。

vllm/sequence.py — SequenceGroupMetadata 字段 L568–L622
class SequenceGroupMetadata:
    def __init__(
        self,
        request_id: str,
        is_prompt: bool,                          # True = prefill,False = decode
        seq_data: Dict[int, SequenceData],        # seq_id -> SequenceData(纯数据,可序列化)
        sampling_params: SamplingParams,
        block_tables: Dict[int, List[int]],       # seq_id -> 物理 block 号列表
        do_sample: bool = True,                   # False 时跳过采样(Chunked Prefill 中间 chunk)
        token_chunk_size: Optional[int] = None,   # 本次处理的 token 数(Chunked Prefill 用)
        lora_request: Optional[LoRARequest] = None,
        computed_block_nums: Optional[List[int]] = None,  # 已计算的 block(Prefix Caching)
        state: Optional[SequenceGroupState] = None,       # beam search 用的 RNG 状态
        multi_modal_data: Optional[MultiModalData] = None,
        const_block_idx: Optional[Dict[int, int]] = None, # norm-linear cache 用
    ) -> None:
        self.request_id = request_id
        self.is_prompt = is_prompt
        self.seq_data = seq_data
        self.sampling_params = sampling_params
        self.block_tables = block_tables
        # ...
        if self._token_chunk_size is None:
            if is_prompt:
                self._token_chunk_size = list(seq_data.values())[0].get_len()
            else:
                self._token_chunk_size = 1   # decode 阶段每次 1 个 token

这里有几个关键设计决策值得深究:

  • seq_data 而非 Sequence:传递的是 SequenceData(纯数据)而非完整的 Sequence 对象。Worker 不需要知道逻辑块、状态、日志等信息,只需要 token ID 和计算进度。 这减少了跨进程传输的数据量,也避免了不必要的耦合。
  • block_tables:将 seq_id 映射到物理 block 号的列表。这是 Worker 侧构建 PagedAttention 所需的核心信息——GPU Kernel 通过这个表将注意力计算路由到正确的物理显存地址。
  • do_sample:Chunked Prefill 时,只有最后一个 chunk 才需要采样(生成新 token)。 中间 chunk 设为 do_sample=False,跳过 Sampler 步骤,节省不必要的计算。
  • computed_block_nums:Prefix Caching 命中时,告知 Worker 哪些 block 的 KV 已经在 GPU 缓存中,这些 block 可以跳过 attention 计算。
调度器 → Worker 数据流
flowchart LR subgraph Scheduler["调度器侧"] SG["SequenceGroup\n(含完整 Sequence)"] SC["Scheduler._schedule()"] end subgraph Meta["元数据打包"] MD["SequenceGroupMetadata\n(seq_data + block_tables)"] EMR["ExecuteModelRequest\n(metadata_list + swap ops)"] end subgraph Worker["Worker 侧"] MR["ModelRunner\n.prepare_model_input()"] AT["Attention Metadata\n+ Input Tensors"] end SC --> MD MD --> EMR EMR --> MR MR --> AT
vllm/sequence.py — ExecuteModelRequest:调度器发给 Worker 的完整请求 L720–L770
@dataclass
class ExecuteModelRequest:
    """模型执行请求,从调度器发给 Executor/Worker。"""
    seq_group_metadata_list: List[SequenceGroupMetadata]
    # CPU → GPU 的 block 换入映射(Swap In)
    blocks_to_swap_in: Dict[int, int] = field(default_factory=dict)
    # GPU → CPU 的 block 换出映射(Swap Out)
    blocks_to_swap_out: Dict[int, int] = field(default_factory=dict)
    # KV block 复制映射:CoW 操作(src → [dst1, dst2, ...])
    blocks_to_copy: Dict[int, List[int]] = field(default_factory=dict)
    # Lookahead decoding 的槽位数
    num_lookahead_slots: int = 0
    # Running 队列大小(用于指标统计)
    running_queue_size: int = 0

blocks_to_swap_in/outblocks_to_copy 是调度器做出的物理内存操作决策, 一并传给 Worker 执行。Worker 在真正做模型前向之前,会先执行这些 block 迁移操作, 确保所需的 KV cache 已经在 GPU 上且位于正确的物理位置。

06

状态机图

下图展示了一条 Sequence 从创建到销毁的完整状态转换路径。 每条转换边都注明了触发条件(调度决策或生成事件)。

请求到达
──▶
WAITING
等待调度
调度器选中
──▶
RUNNING
GPU 执行中
EOS / stop
──▶
FINISHED
_STOPPED
正常完成
抢占(Swap)
SWAPPED
KV 在 CPU
Swap In
RUNNING ─── 抢占(Recompute) ───▶ WAITING (KV 丢弃,重新等待调度)
FINISHED_LENGTH_CAPPED
达到 max_tokens
FINISHED_ABORTED
客户端取消
FINISHED_IGNORED
Prompt 超长,不执行
RUNNING 还可以直接转换到以上三种终态(由 Sampler 或 abort 触发)

完整的状态转换规则总结:

  • WAITING → RUNNING:调度器的 _schedule() 选中该序列,Block Manager 成功分配 KV block。
  • RUNNING → FINISHED_*:Sampler 判断终止条件(EOS、长度上限、stop 字符串)或引擎主动 abort。
  • RUNNING → SWAPPED:高优先级新请求到来,调度器选择 Swap 模式抢占,将 KV block 换出到 CPU。
  • SWAPPED → RUNNING:GPU 资源充裕时,Block Manager 将 CPU 上的 KV block 换回 GPU。
  • RUNNING → WAITING:Recompute 模式抢占,KV block 直接释放,序列状态退回等待队列,待重新调度时从头 prefill。
  • WAITING → FINISHED_IGNORED:Prompt 长度超模型上下文限制,直接标记完成,不进入调度。
两种抢占模式的取舍
Recompute:释放 GPU block,序列回到 WAITING。代价是后续需要重新 prefill,浪费算力。 优点是立刻释放 GPU 内存,不需要 CPU 缓冲。

Swap:将 KV block 换出到 CPU DRAM,序列进入 SWAPPED。代价是 PCIe 数据传输开销。 优点是恢复时无需重新计算,节省算力。适合 KV 体积小(prompt 短)的情况。
07

与 KV Cache 的关系

vLLM 的核心创新是 PagedAttention——将 KV cache 组织为固定大小的"页"(block), 实现按需分配、碎片化避免。Sequence 维护了逻辑层的 block 视图, 而物理 block 的分配由 Block Manager 负责。两者通过 block_tables 连接。

vllm/block.py — LogicalTokenBlock:序列的逻辑 KV 页 L1–L55
class LogicalTokenBlock:
    """存储连续 token 序列的逻辑块(从左到右填充)。
    逻辑块表示 KV cache 中对应物理块的状态。
    """

    def __init__(self, block_number: int, block_size: int) -> None:
        self.block_number = block_number   # 逻辑块编号(0, 1, 2, ...)
        self.block_size = block_size       # 块大小(如 16 个 token)
        self.token_ids = [_BLANK_TOKEN_ID] * block_size
        self.num_tokens = 0                # 当前已填入的 token 数

    def is_full(self) -> bool:
        return self.num_tokens == self.block_size

    def get_num_empty_slots(self) -> int:
        return self.block_size - self.num_tokens

    def append_tokens(self, token_ids: List[int]) -> None:
        assert len(token_ids) <= self.get_num_empty_slots()
        curr_idx = self.num_tokens
        self.token_ids[curr_idx:curr_idx + len(token_ids)] = token_ids
        self.num_tokens += len(token_ids)

每个 LogicalTokenBlock 对应物理显存中一个 block_size × num_heads × head_dim 的 KV tensor。 逻辑块编号(block_number)是连续的 0, 1, 2, ...,但对应的物理块可以分散在 GPU 显存的任意位置。 这就是 PagedAttention 的核心——逻辑连续,物理分散,通过 block_tables 映射。

vllm/sequence.py — _append_tokens_to_blocks:维护逻辑块 L262–L278
def _append_logical_block(self) -> None:
    block = LogicalTokenBlock(
        block_number=len(self.logical_token_blocks),   # 编号自增
        block_size=self.block_size,
    )
    self.logical_token_blocks.append(block)

def _append_tokens_to_blocks(self, token_ids: List[int]) -> None:
    cursor = 0
    while cursor < len(token_ids):
        if not self.logical_token_blocks:
            self._append_logical_block()

        last_block = self.logical_token_blocks[-1]
        if last_block.is_full():
            self._append_logical_block()   # 当前块满了,新建一块
            last_block = self.logical_token_blocks[-1]

        num_empty_slots = last_block.get_num_empty_slots()
        last_block.append_tokens(token_ids[cursor:cursor + num_empty_slots])
        cursor += num_empty_slots

_append_tokens_to_blocks() 在两个时机被调用:

  • 序列创建时(__init__):将 prompt token 全部填入逻辑块
  • 每次 decode 生成新 token 时(append_token_id):追加单个 token 到最后一块

这个方法维护的是纯逻辑层,它不涉及任何 GPU 内存操作。 Block Manager 定期检查 logical_token_blocks 的数量,按需分配/释放物理 block。

逻辑块 → 物理块映射示意(block_size=4)
Token 序列(prompt=8, output=3)
t0
t1
t2
t3
t4
t5
t6
t7
o0
o1
o2
prompt token    output token
logical_token_blocks(Sequence 维护)
logical block 0
t0, t1, t2, t3
logical block 1
t4, t5, t6, t7
logical block 2
o0, o1, o2, _
block_tables[seq_id] = [7, 3, 15](Block Manager 分配的物理块号)
GPU 显存中的物理块(分散分布)
physical block 7
KV[t0~t3]
physical block 3
KV[t4~t7]
physical block 15
KV[o0~o2, _]

上图揭示了 PagedAttention 的工作原理:

  • GPU 显存中的物理块编号(7, 3, 15)是离散的,不连续。这意味着不同请求的 KV 可以交错存放,彻底消除外部碎片。
  • Sequence.logical_token_blocks 维护了 token 到逻辑块的映射(0, 1, 2, ...连续)。
  • block_tables(由 Block Manager 维护)将逻辑块号映射到物理块号。
  • GPU Kernel 在执行 Attention 时,通过 block_tables 跳转到实际物理地址取 KV 数据。
vllm/block.py — PhysicalTokenBlock:GPU 显存的物理 KV 页 L53–L82
class PhysicalTokenBlock:
    """表示 KV cache 中一个 block 的状态。"""
    def __init__(self, device, block_number, block_size,
                 block_hash, num_hashed_tokens) -> None:
        self.device = device             # GPU 或 CPU
        self.block_number = block_number # 物理块号(GPU 显存中的实际位置)
        self.block_size = block_size
        self.block_hash = block_hash     # 内容哈希(Prefix Caching 用)
        self.num_hashed_tokens = num_hashed_tokens  # 参与哈希的 token 数

        self.ref_count = 0              # 引用计数(多序列共享时 >1)
        self.last_accessed = DEFAULT_LAST_ACCESSED_TIME  # LRU 时间戳
        self.computed = False           # 是否已经做过 attention 计算

ref_count 是实现 beam search CoW(Copy-on-Write)的关键。 当多条 beam 序列 fork 出来后,它们共享同一批物理块,每个块的 ref_count > 1。 当某条 beam 需要写入新 token 时,Block Manager 检查 ref_count—— 若 > 1,说明该块被共享,必须先复制(这就是 blocks_to_copy 的由来), 再写入新数据。只有 ref_count == 1 时才可以直接写入。

computed 标志与 Prefix Caching 的协作
PhysicalTokenBlock.computed = True 表示该物理块的 KV 已经过 attention 计算并缓存。 当新请求的 prompt 与某些已缓存块的内容哈希匹配时,Block Manager 可以直接复用这些物理块, 跳过 prefill 计算——这就是 Prefix Caching(自动 prompt 复用)的实现机制。 Sequence.hash_of_block() 提供了逻辑块内容的哈希,Block Manager V2 用它来查找匹配的物理块。
classDiagram class SequenceGroup { +request_id: str +seqs_dict: Dict~int, Sequence~ +sampling_params: SamplingParams +metrics: RequestMetrics +prompt_logprobs: PromptLogprobs +get_seqs(status) +get_max_num_running_seqs() +is_finished() } class Sequence { +seq_id: int +status: SequenceStatus +data: SequenceData +logical_token_blocks: List +output_logprobs: SampleLogprobs +append_token_id() +fork() +hash_of_block() +get_num_new_tokens() } class SequenceData { +prompt_token_ids: List~int~ +output_token_ids: List~int~ +cumulative_logprob: float -_num_computed_tokens: int -_stage: SequenceStage +append_token_id() +update_num_computed_tokens() +reset_state_for_recompute() } class LogicalTokenBlock { +block_number: int +block_size: int +token_ids: List~int~ +num_tokens: int +append_tokens() +is_full() } class SequenceGroupMetadata { +request_id: str +is_prompt: bool +seq_data: Dict~int, SequenceData~ +block_tables: Dict~int, List~int~~ +sampling_params: SamplingParams +do_sample: bool +token_chunk_size: int } SequenceGroup "1" --> "1..*" Sequence : contains Sequence "1" --> "1" SequenceData : wraps Sequence "1" --> "0..*" LogicalTokenBlock : maintains SequenceGroupMetadata ..> SequenceData : serializes SequenceGroupMetadata ..> Sequence : derived from