vLLM 推理全流程深度解析

本文从用户在终端(或 API 客户端)按下 Enter 键开始,逐行、逐函数、逐 CUDA kernel 地追踪 vLLM 的完整推理链路。适合希望深入理解 LLM 服务底层实现的工程师与研究者阅读。

阅读前提:假设你已了解 Transformer 基础结构(Attention、FFN、LayerNorm),并具备 PyTorch 与 CUDA 基本概念。

零、宏观架构总览

在深入到每一行代码之前,先建立 vLLM 的宏观视图。vLLM 的核心由以下几大模块构成:

模块核心职责关键文件
LLMEngine / AsyncLLMEngine对外暴露 API,管理请求生命周期,协调 Scheduler 与 Workervllm/engine/llm_engine.py, async_llm_engine.py
Scheduler使用 Continuous Batching 策略,决定哪些 sequence group 进入本轮 forwardvllm/core/scheduler.py
BlockManager基于 PagedAttention 的 KV Cache 物理块分配器,解决显存碎片问题vllm/core/block_manager.py
Worker / GPUExecutor管理模型加载、CUDA 上下文、Tensor/PP 并行vllm/worker/worker.py, gpu_executor.py
model_runner.py将 Scheduler 输出的 metadata 转换为实际 tensor,调用 model.forward()vllm/worker/model_runner.py
Model 定义(如 LlamaForCausalLM模型结构、层定义、Attention 实现(FlashAttention / PagedAttention)vllm/model_executor/models/llama.py
Sampler从 logits 中按策略采样出下一个 token idvllm/model_executor/layers/sampler.py
TokenizerGroup封装 HuggingFace AutoTokenizer,处理 chat template、截断、编码vllm/transformers_utils/tokenizer.py
关键设计决策:PagedAttention 将 KV Cache 划分为固定大小的 Block(通常为 16 tokens),通过 Block Table 实现非连续的物理存储,从而支持高效的内存共享(如 beam search、parallel sampling)与极低的显存碎片。

一、阶段一:用户按下 Enter 之后 —— 请求入口与预处理

1.1 客户端请求到达

假设用户在 OpenAI-compatible API 客户端发送如下请求并按下 Enter:

curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "meta-llama/Llama-2-7b-chat-hf",
    "messages": [{"role": "user", "content": "请解释 vLLM 的 PagedAttention 原理"}],
    "temperature": 0.7,
    "max_tokens": 512,
    "stream": true
  }'
Step 1.1.1: FastAPI 路由层

vLLM 使用 fastapi + uvicorn 启动服务。请求首先命中 vllm/entrypoints/openai/api_server.py 中的路由:

@router.post("/v1/chat/completions")
async def create_chat_completion(request: ChatCompletionRequest, raw_request: Request):
    # ...
    generator = await openai_serving_chat.create_chat_completion(
        request, raw_request)
    # ...

这里 openai_serving_chatOpenAIServingChat 实例,负责将 OpenAI 格式请求转换为 vLLM 内部格式。

Step 1.1.2: 参数解析与校验

protocol.py 中,ChatCompletionRequest Pydantic 模型对字段做类型校验。关键字段包括:

1.2 Prompt 构建 —— Chat Template

Step 1.2.1: Tokenizer 加载与 Chat Template

vLLM 启动时已通过 TokenizerGroup 加载了 HuggingFace tokenizer。当请求到达时,调用链如下:

# vllm/entrypoints/openai/serving_chat.py
prompt = self.tokenizer.apply_chat_template(
    conversation=request.messages,
    tokenize=False,
    add_generation_prompt=True,
    chat_template=request.chat_template  # 用户可覆盖
)

以 Llama-2-chat 为例,apply_chat_template 会将 messages 展开为:

<s>[INST] <<SYS>>
You are a helpful assistant.
<</SYS>>

请解释 vLLM 的 PagedAttention 原理 [/INST]

注意 <s>bos_token[/INST] 是角色分隔符,末尾没有 answer 部分,因为 add_generation_prompt=True 只添加助手的起始标记,让模型继续生成。

Step 1.2.2: Tokenize(编码为 input_ids)
# vllm/transformers_utils/tokenizer.py
input_ids = self.tokenizer.encode(prompt, add_special_tokens=True)

这一步的内部细节:

  1. 如果 tokenizer 使用 BPE(如 GPT-2/LLaMA),文本先被预分词(regex 拆成单词/子词),然后查 merge table 逐步合并成 subword token。
  2. 如果 tokenizer 使用 SentencePiece(如 Llama),文本直接被编码为 token id 序列,<s> 对应 id 1。
  3. 得到的是一个 Python list[int],例如 [1, 518, 25580, 29962, 3532, 14816, 29903, 6778, ...]

1.3 创建内部请求对象 —— Sequence & SequenceGroup

Step 1.3.1: SamplingParams 构建
from vllm import SamplingParams
sampling_params = SamplingParams(
    temperature=0.7,
    top_p=1.0,
    top_k=-1,  # 默认禁用
    max_tokens=512,
    stop=[""],  # 默认 eos
    presence_penalty=0.0,
    frequency_penalty=0.0,
    repetition_penalty=1.0,
)

SamplingParams 还会根据 temperature 做数值校验,例如 temperature=0 时会自动进入 greedy decode 模式。

Step 1.3.2: Sequence 与 SequenceGroup 的创建

vLLM 内部使用 Sequence 对象表示一条序列(一个对话/生成轨迹)。对于单次生成(n=1):

# vllm/sequence.py
seq = Sequence(
    seq_id=next(seq_counter),
    prompt=prompt,
    prompt_token_ids=input_ids,
    block_size=engine_config.block_size,  # 通常为 16
)

然后包装成 SequenceGroup

seq_group = SequenceGroup(
    request_id=str(uuid.uuid4()),
    seqs=[seq],
    sampling_params=sampling_params,
    arrival_time=time.time(),
)

SequenceGroup 是调度(Scheduler)的基本单元。当用户设置 n=4(一次生成 4 个候选)时,这里会创建 4 个 Sequence 对象,它们共享同一个 prompt 的 KV Cache。

1.4 请求入队 —— AsyncLLMEngine

Step 1.4.1: 异步入队
# vllm/engine/async_llm_engine.py
async def add_request(
    self,
    request_id: str,
    prompt: Optional[str],
    sampling_params: SamplingParams,
    prompt_token_ids: Optional[List[int]] = None,
    arrival_time: Optional[float] = None,
) -> AsyncStream:
    stream = AsyncStream()
    self._request_streams[request_id] = stream
    await self.engine.add_request_async(
        request_id, prompt, sampling_params, prompt_token_ids, arrival_time)
    return stream

AsyncStream 是一个异步迭代器,后续前端通过它逐 token 接收流式结果。真正的同步引擎 LLMEngineseq_group 送入 Scheduler 的等待队列。

二、阶段二:Scheduler 调度 —— 谁进入本轮计算?

vLLM 采用 Continuous Batching(连续批处理),即不像传统静态批处理那样等所有序列都生成完才释放 batch,而是在 每一个 decoding step 都重新构造 batch,把已经完成的序列踢出,把等待中的序列拉入。

2.1 Scheduler 主循环

Step 2.1.1: schedule() 入口
# vllm/core/scheduler.py
def schedule(self) -> Tuple[SchedulerOutputs, List[int]]:
    # 1. 检查是否可以做 prefix caching 的 prompt 复用(可选优化)
    # 2. 处理已完成的序列,释放 block
    # 3. 尝试将 waiting 队列的 seq_group 移入 running
    # 4. 尝试将 swapped 队列(被逐出到 CPU 的)移回 running
    # 5. 根据 max_num_seqs / max_num_batched_tokens 截断 batch
    # 6. 返回 SchedulerOutputs

2.2 从 Waiting 到 Running —— Prefill 阶段

Step 2.2.1: 新请求首次调度

新到达的请求初始状态为 SequenceStatus.WAITING。Scheduler 会尝试为它分配物理 KV block:

num_required_blocks = len(seq_group.prompt_token_ids) // block_size + 1

BlockManager 调用 allocate(seq_group),从空闲 block 池中分配逻辑 block 到物理 block 的映射。

Step 2.2.2: PagedAttention 的 Block Table

每个 Sequence 内部维护一个 block_table: List[int],记录其 KV Cache 占用的物理块索引。例如:

# prompt 长度为 35,block_size=16
# 需要 3 个物理块
block_table = [12, 45, 89]

其中块 12 存 tokens 0-15 的 K/V,块 45 存 tokens 16-31,块 89 存 tokens 32-35(只使用了前 4 个 slot)。

这些 block 是全局显存池中的索引,vLLM 启动时根据 GPU 显存大小和 block_size 预分配了 num_gpu_blocks 个块。例如 A100 80GB 加载 7B 模型后,可能剩余约 70GB 用于 KV Cache,若 block_size=16, head_size=128, num_layers=32, num_kv_heads=32, fp16,则每块显存:

block_mem = 2 (K+V) * num_layers * num_kv_heads * head_size * block_size * sizeof(half)
          = 2 * 32 * 32 * 128 * 16 * 2 bytes ≈ 8.4 MB

70GB / 8.4MB ≈ 8500 个 block,可服务大量并发。

2.3 Running 队列的 Decode Step

Step 2.3.1: 已运行序列的调度

对于已经在 RUNNING 状态的序列,每个 decoding step 只产生 1 个新 token,因此 KV Cache 只需要新增 1 个 slot。Scheduler 检查当前物理块是否还有空 slot:

if 当前最后一个物理块还有空位:
    直接复用
else:
    从空闲池分配一个新的物理块,append 到 block_table

如果显存不足,Scheduler 会触发 Swapping:将部分序列的 KV block 逐出到 CPU 内存(cpu_allocator),或触发 Preemption:重新计算被踢出序列的 KV Cache(cost 高,尽量避免)。

Step 2.3.2: SchedulerOutputs 的生成

最终,schedule() 返回:

@dataclass
class SchedulerOutputs:
    # 本轮要执行的 seq_groups(可能混合 prefill + decode)
    scheduled_seq_groups: List[SequenceGroup]
    # 被忽略的请求(如 prompt 过长)
    ignored_seq_groups: List[SequenceGroup]
    # 各 seq_group 的 token 数量统计
    num_batched_tokens: int
    # 本轮是新 prefill 还是纯 decode
    blocks_to_swap_in: List[Tuple[int, int]]   # CPU -> GPU
    blocks_to_swap_out: List[Tuple[int, int]]  # GPU -> CPU
    blocks_to_copy: List[Tuple[int, int]]      # 用于并行采样/Beam search 共享

这是连接调度层与执行层的关键数据结构。

三、阶段三:从 Scheduler 到 CUDA Kernel —— Model Runner 与 Forward

3.1 LLMEngine.step()

Step 3.1.1: 引擎主循环
# vllm/engine/llm_engine.py
def step(self) -> List[RequestOutput]:
    # 1. 调用 scheduler.schedule()
    seq_group_metadata_list, scheduler_outputs = self.scheduler.schedule()
    
    # 2. 如果为空,直接返回
    if not scheduler_outputs.scheduled_seq_groups:
        return []
    
    # 3. 执行模型推理
    output = self.model_executor.execute_model(
        seq_group_metadata_list,
        scheduler_outputs.blocks_to_swap_in,
        scheduler_outputs.blocks_to_swap_out,
        scheduler_outputs.blocks_to_copy,
    )
    
    # 4. 处理输出(采样、更新序列状态)
    request_outputs = self._process_model_outputs(output, scheduler_outputs)
    return request_outputs

3.2 GPUExecutor.execute_model()

Step 3.2.1: 分布式执行抽象

vLLM 支持 TP(Tensor Parallelism,张量并行)和 PP(Pipeline Parallelism,流水线并行)。对于单卡或 TP,通常使用 GPUExecutor

# vllm/executor/gpu_executor.py
def execute_model(self, seq_group_metadata_list, ...):
    # 如果是 TP,通过 torch.distributed 广播 input tensors 到各 rank
    # 各 Worker 的 model_runner 执行 forward
    return self.driver_worker.execute_model(seq_group_metadata_list, ...)

3.3 ModelRunner.prepare_input_tensors()

Step 3.3.1: 将 Scheduler 输出转为 CUDA Tensor

这是整个推理链路中最容易让人困惑的环节。Scheduler 输出的是 Python 对象,而 GPU 需要密集的 Tensor。ModelRunner 负责做这一转换:

# vllm/worker/model_runner.py
def prepare_input_tensors(
    self,
    seq_group_metadata_list: List[SequenceGroupMetadata],
) -> Tuple[torch.Tensor, torch.Tensor, InputMetadata, ...]:

核心逻辑拆解:

  1. 收集 input token ids:对每个 seq_group,如果是 prefill 阶段,取全部 prompt_token_ids;如果是 decode 阶段,取最后一个生成的 token id。
  2. 拼接为 1D input_ids tensor:vLLM 将所有序列的当前 step 要处理的 token 拼接成一个 1D LongTensor。例如 batch 中有 2 个 prefill(长度 35, 20)和 3 个 decode(各 1 个 token),则 input_ids.shape = [35+20+1+1+1] = [58]
  3. 构建 input_positions:记录每个 token 在各自序列中的位置索引,用于 RoPE / ALiBi 位置编码。
  4. 构建 slot_mapping:一一映射每个 token 应该写入 KV Cache 的物理地址(block_number * block_size + offset)。这是 PagedAttention 的核心。
  5. 构建 seq_lens / context_lens:记录每个序列的总长度(用于 attention mask / causal mask)。
Step 3.3.2: 示例数据流(张量构造)

假设本轮 batch 包含:

input_ids       = [A0..A34 | B0..B19 | C5]          # shape [56]
positions       = [0..34   | 0..19   | 5]            # shape [56]
slot_mapping    = [12*16+0, ..., 12*16+15, 45*16+0, ...,
                   89*16+2, 7*16+0, ..., 8*16+3,
                   8*16+4]                           # shape [56]
seq_lens        = [35, 20, 6]                        # 各序列当前总长度
context_lens    = [0, 0, 5]  # prefill 时 context_len=0(无过去 KV),decode 时为已生成长度

3.4 模型 Forward —— LlamaForCausalLM

Step 3.4.1: 入口 forward()
# vllm/model_executor/models/llama.py
class LlamaForCausalLM(nn.Module):
    def forward(self, input_ids, positions, kv_caches, input_metadata):
        hidden_states = self.model(input_ids, positions, kv_caches, input_metadata)
        logits = self.lm_head(hidden_states)
        return logits

注意这里的 kv_caches 并不是传统意义上每个序列一个的 KV 张量,而是全局预分配的显存池

Step 3.4.2: LlamaModel(Transformer 主体)
def forward(self, input_ids, positions, kv_caches, input_metadata):
    hidden_states = self.embed_tokens(input_ids)   # [num_tokens, hidden_size]
    
    for i, layer in enumerate(self.layers):
        hidden_states = layer(
            hidden_states,
            positions,
            kv_caches[i],        # 第 i 层的全局 KV cache
            input_metadata,
        )
    hidden_states = self.norm(hidden_states)
    return hidden_states
Step 3.4.3: LlamaDecoderLayer
def forward(self, hidden_states, positions, kv_cache, input_metadata):
    # 1. Self Attention (RMSNorm -> Attention -> Residual)
    residual = hidden_states
    hidden_states = self.input_layernorm(hidden_states)
    hidden_states = self.self_attn(
        hidden_states, positions, kv_cache, input_metadata)
    hidden_states = residual + hidden_states
    
    # 2. FFN (RMSNorm -> MLP -> Residual)
    residual = hidden_states
    hidden_states = self.post_attention_layernorm(hidden_states)
    hidden_states = self.mlp(hidden_states)
    hidden_states = residual + hidden_states
    return hidden_states

3.5 Attention 的核心 —— PagedAttention

Step 3.5.1: LlamaAttention 层
# vllm/model_executor/models/llama.py 中的 attention 实现
class LlamaAttention(nn.Module):
    def forward(self, hidden_states, positions, kv_cache, input_metadata):
        qkv = self.qkv_proj(hidden_states)  # [num_tokens, 3 * num_heads * head_size]
        q, k, v = qkv.split(...)
        
        # 使用 Rotary Position Embedding (RoPE)
        q, k = self.rotary_emb(positions, q, k)
        
        # 写入 KV Cache(通过自定义 CUDA kernel)
        # 这里不是简单的 tensor indexing,而是调用 paged_attention_ops.reshape_and_cache
        reshape_and_cache(k, v, kv_cache, input_metadata.slot_mapping)
        
        # 计算 attention(prefill 使用 xFormers/FlashAttention,decode 使用 PagedAttention)
        if input_metadata.is_prompt:  # prefill phase
            output = self.attn(q, k, v, input_metadata)
        else:  # decode phase
            output = self.attn(q, kv_cache, input_metadata)
        
        output = self.o_proj(output)
        return output
Step 3.5.2: reshape_and_cache CUDA Kernel

这是 vLLM 自定义 CUDA 算子之一,源码位于 csrc/cache_kernels.cu

// 伪代码逻辑
__global__ void reshape_and_cache_kernel(
    const scalar_t* __restrict__ key,     // [num_tokens, num_kv_heads, head_size]
    const scalar_t* __restrict__ value,   // [num_tokens, num_kv_heads, head_size]
    scalar_t* __restrict__ key_cache,     // [num_blocks, block_size, num_kv_heads, head_size]
    scalar_t* __restrict__ value_cache,   // 同上
    const int64_t* __restrict__ slot_mapping, // [num_tokens]
) {
    const int64_t token_idx = blockIdx.x;
    const int64_t slot_idx = slot_mapping[token_idx];
    if (slot_idx < 0) return;  // 被忽略的 token
    
    const int64_t block_idx = slot_idx / block_size;
    const int64_t block_offset = slot_idx % block_size;
    
    // 将 key/value 写入对应 block 的对应 offset
    // key_cache 布局: [num_blocks, block_size, num_kv_heads, head_size]
    // 通过指针计算直接写入,无 Python overhead
}

关键理解: kv_cache 的 shape 是全局的 [num_gpu_blocks, block_size, num_kv_heads, head_size],而不是按 batch 或 sequence 划分。这使得不同序列可以任意共享 block(如 beam search 中多个候选序列共享 prompt 的 block)。

Step 3.5.3: Prefill Attention(Context Phase)

对于 prefill 阶段,序列需要计算自注意力(所有 token 之间两两因果 mask)。vLLM 默认使用 xFormers memory_efficient_attentionFlashAttention

from xformers.ops import memory_efficient_attention

# q/k/v shape: [batch_size, num_heads, seq_len, head_size]
# attention_mask: causal mask(下三角为 0,上三角为 -inf)
out = memory_efficient_attention(q, k, v, attn_bias=LowerTriangularMask())

FlashAttention 则通过定制的 CUDA kernel 在 SRAM 中完成 softmax 和 matmul,避免将巨大的注意力矩阵写出到 HBM(全局显存),从而大幅减少显存带宽压力。

Step 3.5.4: Decode Attention —— PagedAttention CUDA Kernel

Decode 阶段每个序列只输入 1 个新 token,但需要 attend 到之前所有 token 的 KV。若使用标准 Attention,需要将整个 KV Cache 从显存读入计算,带宽成为瓶颈(memory-bound)。

PagedAttention 的核心创新在于:它将 Attention 计算按 block 分块,利用共享内存和寄存器优化,直接从 block 化的 KV Cache 中读取。CUDA kernel 签名类似:

// csrc/paged_attention.cu
__global__ void paged_attention_v1_kernel(
    scalar_t* __restrict__ out,           // [num_seqs, num_heads, head_size]
    const scalar_t* __restrict__ q,       // [num_seqs, num_heads, head_size]
    const scalar_t* __restrict__ k_cache, // [num_blocks, block_size, num_kv_heads, head_size]
    const scalar_t* __restrict__ v_cache, // 同上
    const int* __restrict__ block_tables, // [num_seqs, max_num_blocks_per_seq]
    const int* __restrict__ context_lens, // [num_seqs]
    const int num_kv_heads,
    const float scale,
    ...
)

每个 warp / thread block 负责计算一个 head 的一个 sequence 的输出。它通过 block_tables 查找到该序列的物理块列表,然后逐块加载 K/V 到共享内存,做 dot-product 得到 attention score,online softmax 计算加权求和。

由于 decode 时 seq_len 很长(可能 4096+),但 q 只有一个 token,PagedAttention 的性能优势极大:它将访存从 O(seq_len) 的随机/分散访问优化为按 block 的连续突发读取(coalesced memory access),配合 warp 并行和 KV cache 量化(如 KV cache int8),吞吐可提升数倍。

四、阶段四:从 Logits 到 Token —— Sampling

4.1 Sampler 入口

Step 4.1.1: 输出处理
# vllm/model_executor/layers/sampler.py
class Sampler(nn.Module):
    def forward(self, logits: torch.Tensor, sampling_metadata: SamplingMetadata):
        # logits: [num_tokens, vocab_size]
        # 注意:对于 prefill 阶段,通常只需要对最后一个位置的 token 采样
        # 对于 decode 阶段,每个 token 都对应一个要采样的 logits
        
        # 1. Apply penalties (presence, frequency, repetition)
        logits = self._apply_penalties(logits, sampling_metadata)
        
        # 2. Temperature scaling
        logits = self._apply_temperature(logits, sampling_metadata)
        
        # 3. Top-p / Top-k filtering
        logits = self._apply_top_p_top_k(logits, sampling_metadata)
        
        # 4. Sampling
        probs = torch.softmax(logits, dim=-1)
        samples = torch.multinomial(probs, num_samples=1)
        return samples

4.2 各采样策略细节

Step 4.2.1: Temperature Scaling
logits = logits / temperature

temperature → 0 时,logits 差异被放大,softmax 趋近于 argmax(greedy);temperature → ∞ 时,分布趋近均匀。

Step 4.2.2: Top-K & Top-P(Nucleus Sampling)

vLLM 中通常使用 efficient GPU kernel 完成这些操作,避免将巨大的 vocab_size tensor 回传到 CPU。

Step 4.2.3: Penalty 处理
# Presence Penalty: 如果 token 已在序列中出现过,直接减去 penalty 值
# Frequency Penalty: 根据出现次数成比例减去 penalty
# Repetition Penalty: 直接对 logits 除以 penalty(大于 1 时抑制重复)

实现上,Sampler 会读取每个序列已生成的 token set / token counter,对 logits 做 inplace 修改。

4.3 生成新 Token

Step 4.3.1: 从 multinomial 到 token id
samples = torch.multinomial(probs, num_samples=1)  # [num_tokens, 1]
next_token_ids = samples.squeeze(-1)                # [num_tokens]

对于 prefill 阶段,vLLM 通常使用 _get_logprobs 返回 prompt 的每个 token 的 logprob,而真正的 next token 只取 prefill 序列最后一个位置的采样结果。

五、阶段五:自回归循环 —— 从一次 Step 到全文生成

5.1 状态更新

Step 5.1.1: Sequence 状态机更新
# vllm/engine/llm_engine.py 中 _process_model_outputs 的逻辑
for seq_group, outputs in zip(scheduled_seq_groups, output_by_seq_group):
    for seq, output in zip(seq_group.seqs, outputs):
        # 1. 将新 token 追加到序列
        seq.append_token_id(output.token_id, output.logprobs)
        
        # 2. 检查是否达到终止条件
        if seq.get_len() >= seq_group.sampling_params.max_tokens:
            seq.status = SequenceStatus.FINISHED_LENGTH_CAPPED
        elif output.token_id in seq_group.sampling_params.stop_token_ids:
            seq.status = SequenceStatus.FINISHED_STOPPED
        elif output.token_id == tokenizer.eos_token_id:
            seq.status = SequenceStatus.FINISHED_STOPPED
        elif seq.get_output_text().endswith(seq_group.sampling_params.stop_str):
            seq.status = SequenceStatus.FINISHED_STOPPED
Step 5.1.2: KV Cache 的持续写入

由于 reshape_and_cache 已经在 forward 中将当前 step 的 K/V 写入了全局 KV Cache pool,这里不需要额外操作。Sequence 只需要记录自己的 block_table 和当前长度 data_len 即可。

5.2 流式输出返回

Step 5.2.1: AsyncStream.put()
# vllm/engine/async_llm_engine.py
for request_output in request_outputs:
    self._request_streams[request_output.request_id].put(request_output)

RequestOutput 包含:

Step 5.2.2: Detokenization(Token 转文本)

为了流式输出,vLLM 不能等所有 token 生成完再 decode,而是逐 token 增量解码。但 Byte-Pair Encoding(BPE)有一个棘手问题:一个 Unicode 字符可能被拆成多个 byte-level token,如果只解码当前 token,可能得到乱码(如 )。

vLLM 的 detokenize_incrementally 解决如下:

# vllm/transformers_utils/detokenizer.py
def detokenize_incrementally(self, new_token_id: int) -> str:
    self.token_ids.append(new_token_id)
    # 先尝试解码整个序列
    new_text = self.tokenizer.decode(self.token_ids, skip_special_tokens=True)
    # 与上一次完整文本对比,取出增量部分
    incremental_text = new_text[len(self.prev_text):]
    self.prev_text = new_text
    return incremental_text

虽然看起来是重新 decode 整个序列,但由于 tokenizer 通常有 cache(如 SentencePiece 的 DecodeIds),且序列长度有限,开销极小。对于中文,增量文本可能一次输出 1-3 个汉字。

Step 5.2.3: SSE 流式协议
# vllm/entrypoints/openai/api_server.py
async def stream_generator():
    async for request_output in generator:
        for output in request_output.outputs:
            ret = {
                "choices": [{
                    "delta": {"content": output.text},
                    "finish_reason": output.finish_reason,
                }]
            }
            yield f"data: {json.dumps(ret)}\n\n"
    yield "data: [DONE]\n\n"

客户端每次收到 SSE 事件,将 delta.content 追加到界面,用户看到"打字机效果"。

5.3 循环直到结束

上述 step() 会被引擎循环调用。在 AsyncLLMEngine 中,有一个后台 engine_loop 协程(或独立线程)持续执行:

while True:
    if not self.has_unfinished_requests():
        await asyncio.sleep(0.01)
        continue
    request_outputs = await self.step_async()
    # 通过 stream 分发到各客户端

直到所有序列都达到 FINISHED 状态,Scheduler 的 running 队列为空,引擎进入空闲等待。

六、阶段六:高级机制详解

6.1 Chunked Prefill

传统 prefill 会一次性计算整个 prompt 的 KV Cache,当 prompt 很长(如 32k)时,会独占 GPU 很长时间,阻塞 decode 序列的推进。

vLLM 的 Chunked Prefill(也叫 prefix chunking)将一个长 prompt 拆成多个 chunk(如每轮只算 512 tokens),交错在 decode steps 中执行。Scheduler 在 schedule() 中控制:

# 伪代码
max_tokens_in_batch = self.scheduler_config.max_num_batched_tokens
# 将长 prefill 截断,确保 num_batched_tokens 不超过限制

这样 decode token 的延迟不会因为一个长 prefill 而剧烈抖动。

6.2 Speculative Decoding(投机采样)

vLLM 支持用一个小的 draft model(如 7B 主模型配 100M draft model)先快速生成 K 个候选 token,然后主模型一次 forward 验证这 K 个 token 是否正确,接受其中前缀正确的部分,拒绝后再从正确位置重新采样。

如果 draft model 准确率高,decode 的 effective step 可以放大数倍,大幅降低 latency。

6.3 CUDA Graph

由于 decode step 的 batch size 和序列长度每轮都在变化,Python/CUDA 启动开销(kernel launch overhead)在 small batch 时占比显著。vLLM 使用 CUDA Graph 捕获固定形状的 decode kernel 执行序列,后续直接 replay graph,将 kernel launch 开销降到微秒级。

# vllm/worker/model_runner.py
if self.cuda_graphs_enabled and batch_size <= max_captured_batch_size:
    graph = self.cuda_graphs[batch_size]
    graph.replay()
else:
    logits = self.model(...)

6.4 Prefix Caching(自动 KV Cache 复用)

vLLM 新特性:对 prompt 的前缀计算 hash,如果后续请求的 prompt 前缀相同(如 system prompt、RAG 中的固定文档),直接复用已计算的 KV Cache block,无需重新 prefill。BlockManager 将对应 block 的 ref_count +1,实现 zero-copy 共享。

七、完整代码调用栈(从 API 到 CUDA)

以下是一个请求的完整调用链,按顺序展开:

1. uvicorn / fastapi
   └── POST /v1/chat/completions
       └── vllm/entrypoints/openai/api_server.py::create_chat_completion()
           └── OpenAIServingChat.create_chat_completion()
               ├── TokenizerGroup.encode()  # apply_chat_template + tokenize
               └── AsyncLLMEngine.generate()
                   └── AsyncLLMEngine.add_request()
                       └── LLMEngine.add_request()
                           └── Scheduler.add_seq_group()  # 进入 WAITING 队列

2. 后台 Engine Loop (async_llm_engine.py::_engine_step)
   └── LLMEngine.step()
       ├── Scheduler.schedule()
       │   ├── BlockManager.allocate()       # 分配物理 KV block
       │   └── 构造 SchedulerOutputs
       └── model_executor.execute_model()
           └── GPUExecutor.execute_model()
               └── Worker.execute_model()
                   └── ModelRunner.execute_model()
                       ├── prepare_input_tensors()
                       │   └── 构造 input_ids, positions, slot_mapping, seq_lens...
                       └── model.forward()
                           └── LlamaModel.forward()
                               ├── Embedding
                               └── for each layer:
                                   └── LlamaDecoderLayer.forward()
                                       ├── LlamaAttention.forward()
                                       │   ├── QKV projection (Linear)
                                       │   ├── RotaryEmbedding
                                       │   ├── reshape_and_cache() CUDA kernel
                                       │   └── attention()
                                       │       ├── prefill: FlashAttention / xFormers
                                       │       └── decode: PagedAttention CUDA kernel
                                       └── LlamaMLP.forward()
                               └── LM Head
                       └── Sampler.forward()
                           ├── _apply_penalties
                           ├── _apply_temperature
                           ├── _apply_top_p_top_k
                           └── torch.multinomial()

3. 输出处理
   └── LLMEngine._process_model_outputs()
       ├── Sequence.append_token_id()
       ├── 检查 finish_reason
       └── 通过 AsyncStream 发送 RequestOutput
           └── API Server SSE stream
               └── detokenize_incrementally()
                   └── 客户端收到 delta

八、关键张量与显存布局速查

张量/结构Shape含义
input_ids[num_batched_tokens] (1D)本轮所有序列要处理的 token id 拼接
positions[num_batched_tokens]每个 token 在各自序列中的绝对位置
slot_mapping[num_batched_tokens]每个 token 的 KV 应该写入全局 cache 的物理 slot
kv_cache (per layer)[2, num_blocks, block_size, num_kv_heads, head_size]全局 KV cache 显存池,K 和 V 各占一半
block_tables[num_seqs, max_blocks_per_seq]每个序列的逻辑块 → 物理块映射表
context_lens[num_seqs]各序列当前总长度(用于 attention 范围)
logits[num_batched_tokens, vocab_size]模型输出,未归一化

九、总结:一张图理解全链路

用户 Enter → FastAPI → OpenAI Protocol → Tokenizer(chat_template + encode) → Sequence/SequenceGroup → Scheduler(waiting→running) → BlockManager 分配物理 KV Block → ModelRunner 构造 1D Tensor → Model Forward(Embed → N×(Attention+FFN) → LMHead) → reshape_and_cache + PagedAttention/FlashAttention → Sampler(temperature/top-p/multinomial) → Sequence.append_token_id → Stream 返回 → Detokenizer → SSE delta → 用户看到文字

上述循环持续进行(每轮 step 约 10-50ms,取决于模型大小和 batch),直到遇到 eos / max_tokens / stop_str。