从一次 generate() 开始:理解 vLLM-Ascend 的推理过程

输入一句话,调用 generate(),模型就返回了后续文本。但在推理框架内部,这个调用会被拆成多轮工作:调度请求、准备输入、读写 KV Cache、执行模型、选择新 token,最后释放请求占用的缓存块。

本文用 Qwen3-0.6B 在昇腾 NPU 上的一组实验,把这条链路连起来。读者只需要了解基本 Python;文中会逐步解释 Tensor 形状、缓存地址和采样的含义。

先记住本文要追踪的例子:

输入:The capital of France is
输出: Paris. The

我们不评价这句话生成得好不好,而是追问:这三个新 token 是怎样算出来的,计算期间的数据存在哪里,请求结束后又发生了什么?

1. 实验起点:一次调用,三个新 token

本文主线采用同步调度、关闭 Prefix Cache 的观察实验。最早的启动测试使用过不同配置,已保留在实验原稿中,不与下面的逐轮记录混用。

项目实验环境
硬件昇腾 A3,使用一个可见逻辑设备
模型Qwen3-0.6B,模型目录 /workspace/models/Qwen3-0.6B
PyTorch / torch-npu2.10.0+cpu / 2.10.0.post4
vLLM / vLLM-Ascend0.26.0+empty / 0.26.0rc1
执行方式Eager、同步调度、TP=1
跨请求前缀复用关闭

下面的脚本展示主线配置及最终输入输出。逐轮 Tensor 和缓存记录来自额外加入的观察点,仅运行这段脚本不会打印本文全部中间结果。它要求环境中已经安装匹配的软件,并准备好模型文件。

from vllm import LLM, SamplingParams

llm = LLM(
    model="/workspace/models/Qwen3-0.6B",
    tensor_parallel_size=1,
    max_model_len=1024,
    max_num_seqs=1,
    gpu_memory_utilization=0.3,
    enforce_eager=True,
    async_scheduling=False,
    enable_prefix_caching=False,
)

params = SamplingParams(
    temperature=0,
    max_tokens=3,
    ignore_eos=True,
)

result = llm.generate(["The capital of France is"], params)[0]
output = result.outputs[0]

print("输入 token IDs:", result.prompt_token_ids)
print("输出 token IDs:", output.token_ids)
print("生成文本:", repr(output.text))
print("结束原因:", output.finish_reason)

结果为:

输入 token IDs: [785, 6722, 315, 9625, 374]
输出 token IDs: [12095, 13, 576]
生成文本: ' Paris. The'
结束原因: length

Tokenizer 把文本编码为 token ID。这些整数是词表编号,不是向量,也不是字符的 ASCII 编码;一个 token 可以对应词、词片段、标点等。

这里使用文本续写,没有套用聊天模板。max_tokens=3 限制的是三个新生成的 token,不包含输入;因此输出可以停在半句话中。temperature=0 使用贪心选择,ignore_eos=True 则让本实验忽略 EOS 停止条件,便于观察长度限制。

还有三组配置容易混淆:

  • Eager 与同步调度分别涉及模型执行方式和调度方式,不是同一个开关。
  • 关闭 Prefix Cache表示本实验不复用其他请求的前缀缓存;同一个请求在 Decode 时仍然使用 KV Cache。
  • gpu_memory_utilization 在这个 NPU 环境中仍沿用该参数名,它参与显存预算设置,不表示把计算速度限制为 30%。

2. 先把整条链路放在一起

下面是本文的阅读地图。它表示职责和数据流,不要求每个箭头都对应一次直接的 Python 函数调用。

文本 → Tokenizer → 请求
                    ↓
            Engine 推进请求
                    ↓
       Scheduler 决定本轮计算哪些 token
                    ↓
            Worker → ModelRunner
                    ↓
       准备输入 Tensor 和 KV 地址信息
                    ↓
       模型计算:Embedding、Attention、MLP 等
                    ↓
       选取 hidden states → logits → 采样
                    ↓
           新 token 写入请求状态
                    ↓
          未结束:进入下一轮
          已结束:返回结果、归还缓存块

在本实验中,vLLM 提供请求管理、调度、模型实现等通用组件;vLLM-Ascend 提供或适配 NPU 执行相关的 Worker、Runner、Attention、算子等。边界并非完全互不接触:后文会看到,Ascend 也通过补丁扩展了模型和调度路径。

其中 Scheduler 决定“本轮做什么”,Runner 把任务转成模型能执行的数据。一份请求可能经历很多轮调度,一次 generate() 也不等于一次模型前向计算。

3. 先追第一个输出:模型接收到什么?

第 1 节已经得到输出 Paris. The,但最终结果没有告诉我们中间发生了什么。先缩小观察范围:只追第一个新 token 12095 是怎样从输入句子算出来的。 后面的两轮先放在一边。

Tokenizer 已经把输入句子转换成五个编号:

The capital of France is
              ↓
[785, 6722, 315, 9625, 374]

这时有了文本的数字表示,模型计算却还没有开始。请求进入框架后,Scheduler 决定本轮处理哪些 token;在这次短输入实验中,五个输入 token 被安排在同一轮处理。首次处理提示词的阶段称为 Prefill。

Worker 接收这份调度结果,Runner 再准备模型需要的输入。我们在输入准备之后设置观察点,首先看两个字段:

input_ids = [785, 6722, 315, 9625, 374]
positions = [0,   1,    2,   3,    4]

input_ids 表示输入的内容编号,positions 表示这些内容在序列中的位置。例如 374 是一个 token ID,4 表示它位于输入序列的第 5 个位置。两者逐项对应,不能混为一类数字。

源码入口是 vllm_ascend/worker/model_runner_v1.py 中的输入准备与模型执行路径。Runner 还会准备请求边界、缓存地址等辅助信息;它们的作用将在批处理和 KV 读写时展开。现在先沿着这五个输入往下走。

到这里,框架已经把“处理这句话”变成了“计算这五个位置”。但这五个整数还不是模型用来预测的特征,下一步要进入模型本身。

4. 五个整数进入模型后,发生了什么?

模型先通过 Embedding 按 token ID 查出向量。本次每个 token 对应 1024 个数,五个 token 就得到形状为 [5,1024] 的 Tensor:5 行,每行 1024 个数。

这一步把编号变成了可计算的特征,但仅仅查表还没有让不同位置交换上下文信息。为了根据整段提示词预测后续内容,模型还要经过多层 Transformer,让每个位置的表示结合它能看到的前文。

其中,Attention 负责按内容建立位置之间的联系。当前位置生成 Q,用它与可见位置的 K 计算匹配分数,再据此加权汇总 V。例如最后一个输入位置需要结合前面的上下文,而不只是使用自身的 Embedding。Q/K/V 都是由当前层的输入投影得到的向量,不是三份 token ID。

因此,接下来观察第一层 Attention,是为了看清这五行特征如何开始融合上下文,也为后面的 KV Cache 找到来源。以下用 N 表示本轮输入 token 数;当前 Prefill 中 N=5。

本模型有 28 层 Transformer,隐藏维度为 1024。实验在第 0 层设置观察点,记录到:

步骤输出或关键形状含义
Embedding[N,1024]按 token ID 查出向量
输入 RMSNorm[N,1024]归一化隐藏特征
QKV 投影[N,4096]一次线性投影得到待拆分的 Q/K/V
Q[N,16,128]16 个 Query 头,每头 128 维
K、V各 [N,8,128]8 个 KV 头,每头 128 维
Attention 输出[N,2048]合并 16 个 Query 头的结果
输出投影 o_proj[N,1024]映射回隐藏维度

表中的“头”表示 Attention 内部并行计算的多个子空间。本模型使用 GQA:16 个 Query 头对应 8 个 KV 头,每两个 Query 头共享一组 KV 头。初读时可以先关注 [N,1024] → Attention → [N,1024] 这条包含输出投影的特征流,头数用于解释中间 Tensor 的形状。

读取源码时还有一个重要发现:运行时的 Qwen3Attention.forward 被替换到了 vllm_ascend/patch/worker/patch_qwen3vl.py。文件名带 vl,但本次模型仍然是 Qwen3ForCausalLM。因此,仅阅读上游同名类还不足以确认实际执行路径。

这条路径的核心流程可简化为以下伪代码:

qkv = qkv_projection(hidden_states)
q, k, v = split_qkv(qkv)
q = q_norm(reshape_into_heads(q))
k = k_norm(reshape_into_heads(k))
q, k = rotary_embedding(positions, q, k)
attention_output = attention(q, k, v, kv_cache, metadata)
output = output_projection(attention_output)

RoPE 把位置信息作用于 Q/K,改变其数值而不改变形状;这条路径中 V 不经过 RoPE。Attention 后,模型还会经过残差连接、归一化和 MLP,并继续后续层的计算。

这里的形状来自 Embedding 和第一层的观察点,不代表已逐层观测全部 28 层或 MLP 内部。后续各层继续更新这些特征;整个模型主干完成后,实测输出仍是 [5,1024],称为 hidden_states。

现在我们已经从五个整数走到了五行融合上下文的特征,但仍没有得到 12095。下一步要看框架如何把这些特征转换成一个词表编号。

5. 模型输出怎样变成新 token?

模型主干的输出是 hidden_states,还不是文本。本文模型对应的 vllm/model_executor/models/qwen3.py 中,Qwen3ForCausalLM.forward 返回主干算出的隐藏特征。

Prefill 得到 [5,1024]:五个输入位置各有一个 1024 维表示。为了预测下一 token,Runner 执行:

sample_hidden_states = hidden_states[logits_indices]
logits = self.model.compute_logits(sample_hidden_states)

这两行来自本次环境的 vllm_ascend/worker/model_runner_v1.py。Prefill 的 logits_indices=[4],所以选取最后一个输入位置的表示,得到 [1,1024]。它已经融合了该位置可见的上下文,不只是最后一个词的原始向量。

compute_logits 再通过 LM head 等处理得到词表候选分数:

logits = self.logits_processor(self.lm_head, hidden_states)
return logits

第一轮选出的 token 会成为下一轮输入。后续利用历史 KV、继续处理新输入的阶段称为 Decode,具体复用方式留到下一节。先对照三轮模型输出和采样的实测结果:

本轮主干输出选中后的形状logits 形状选出的 token ID
Prefill[5,1024][1,1024][1,151936]12095
Decode 1[1,1024][1,1024][1,151936]13
Decode 2[1,1024][1,1024][1,151936]576

151936 是本次 logits 的候选维度,不是生成了这么多 token。每列保存一个候选编号的分数;logits 尚未归一化为概率。

Sampler 随后处理这些分数。本实验采用贪心选择,观察到返回的 ID 等于最高分候选。Ascend Sampler 普通贪心分支的核心是:

return logits.argmax(dim=-1).view(-1)

argmax 返回最高分所在的列编号,不是分数本身。三轮最高分分别为 17.5、19.25、17.0,对应 ID 12095、13、576。这些数不是概率,也不能直接跨轮比较“置信度”。实现还存在其他分支,这段代码不是对所有采样配置的概括。

把 Prefill 的变化连起来就是:

5 个 token IDs
  → [5,1024] 隐藏特征
  → 取第 4 行,得到 [1,1024]
  → [1,151936] 候选分数
  → 选择 token ID 12095
  → 写入请求的输出记录
  → 下一轮作为输入

这就是“一轮处理五个输入 token,却只产生一个新 token”的原因。

6. Decode 只输入一个 token,为什么还能利用整段历史?

因为前面各位置的 K/V 已保存在缓存中。Decode 1 新计算的位置是 5,但 Attention 需要的信息覆盖位置 0~5:历史部分来自缓存,当前位置的 K/V 来自本轮计算。

在本次 AscendAttentionBackendImpl 路径中,可以沿着以下位置阅读:

vllm_ascend/attention/attention_v1.py
  forward
    → reshape_and_cache:把当前 K/V 写入对应槽位
    → forward_impl:选择 Attention 执行路径

缓存按块组织:block_table 记录请求使用哪些物理块,slot_mapping 指定本轮 K/V 写入块中的哪些槽位。槽位是缓存中的位置索引,不是 token ID。

第 0 层的 Key Cache 实测形状为 [1257,128,8,128],依次是块数、每块槽位数、KV 头数、每头维度。Decode 1 的槽位 133 对应这个缓存中的 [1,5,:,:]。

7. 把各轮连起来:三个输出 token 怎样产生?

现在已经看到了两个关键动作:模型为本轮输入计算并保存 K/V,采样再选出一个新 token。把它们放回请求的时间线上,就能解释为什么生成三个 token 只需要一轮 Prefill 和两轮 Decode。这个短提示词没有被拆成多个 Prefill 分块,实测为:

本轮输入模型的 token IDs序列位置 positions本轮新生成 token
Prefill[785,6722,315,9625,374][0,1,2,3,4]12095
Decode 1[12095][5]13
Decode 2[13][6]576

Prefill 本身就产生第一个新 token。 下一轮把这个 token 输入模型,再得到第二个;如此反复。

这里要区分“已经生成”与“已经计算过 KV”:

Prefill 结束:
  已知序列 = 五个输入 token + 12095
  已计算 KV 的位置 = 0~4

Decode 1 结束:
  已知序列 = 五个输入 token + 12095 + 13
  已计算 KV 的位置 = 0~5

Decode 2 结束:
  已知序列 = 五个输入 token + 12095 + 13 + 576
  已计算 KV 的位置 = 0~6

12095 刚被选出来时,还没有作为输入经过模型。它在下一轮进入模型之后,各层才计算并写入它的 K/V。最后的 576 被选出后,请求达到长度上限,本次同步实验没有再把它输入模型。

这也解释了后续把输出上限改为 5 的实验:一轮 Prefill 加四轮 Decode,得到 [12095,13,576,6722,315]。这个计数关系适用于这里的普通自回归、未拆分 Prefill、无提前停止的配置,不能直接套用于分块 Prefill 或投机解码。

到这里,再回看 Runner 的完整记录,每个字段就有了对应的计算用途:

数据PrefillDecode 1Decode 2
input_ids[785,6722,315,9625,374][12095][13]
positions[0,1,2,3,4][5][6]
调度 token 数511
query_start_loc[0,5][0,1][0,1]
seq_lens[5][6][7]
logits_indices[4][0][0]
有效 block_table[1][1][1]
slot_mapping[128,129,130,131,132][133][134]

query_start_loc 划分本轮拼接输入中的请求边界,单请求 [0,5] 表示它占据第 0~4 行;seq_lens 是本轮参与 Attention 的序列长度。logits_indices 用来选取预测行;block_table 保存逻辑块到物理块的映射,slot_mapping 则指定当前 K/V 的写入槽位。

以 Decode 1 为例,最容易混淆的三个数字是:

positions = [5]       完整序列中的位置
logits_indices = [0] 本轮只有一行,取第 0 行用于预测
slot_mapping = [133] 这一位置的 K/V 写入物理槽位 133

它们分别属于序列坐标、本轮 Tensor 行号、物理缓存地址。token ID 12095 则是内容编号,不属于上述任何一种位置。

本实验每块包含 128 个槽位。对这个普通单设备布局,地址关系为:

logical_block = position // 128
physical_block = block_table[logical_block]
slot = physical_block * 128 + position % 128

位置 5 对应块表中的第 0 项,即物理块 1,所以槽位是 1 × 128 + 5 = 133。槽位是索引,不是字节地址;块表保存映射,也不直接保存 K/V 数值。不同运行可能分到不同物理块号。

8. 使用 KV Cache,会不会改变下一 token 的计算结果?

前面已经解释了 KV 的读写方式,并串起了请求的逐轮推进过程。接下来通过一个对照实验检查:复用历史 KV,与重新计算完整上下文,会不会得到不同的下一 token 分数?我们比较两条路径:

路径 A:保留前五个位置的 KV,只输入新 token 12095
路径 B:重新输入原五个 token + 12095,完整计算六个位置

比较:两条路径预测第七个位置时的完整 logits

随后在上下文长度为 7 时再做一次。两条路径使用同一进程中的同一套模型权重,Prefix Cache 关闭。完整重算请求仍会建立自己的新 KV;这里没有把框架所有 KV 机制禁用,而是确认没有复用前一请求的前缀计算。

实际调度数量分别是缓存路径的 5、1、1,以及重算路径的 6、7。比较位置位于 compute_logits 之后、Sampler 之前:

上下文长度每次比较的 logits 数量最大绝对误差均方根误差两条路径最高分 token
61519360013
715193600576

比较的是保存下来的 BF16 logits,转成 FP32 后计算误差;这种转换不能恢复 BF16 舍入前的信息。结果说明:在这两个短上下文测试点,缓存增量计算与完整重算得到的已保存 logits 一致。

这支持了 KV 复用的基本理解:因果模型中,后续 token 不会反过来改变前面位置的表示,因此可以复用历史 K/V。它不保证所有模型、精度和执行路径都逐位相等,也没有测量使用缓存带来的速度收益。

9. 两个请求怎样放进同一轮计算?

单请求链路清楚之后,再看批处理。实验把 max_num_seqs 改为 2,提交:

  • A:The capital of France is,输入 5 个 token。
  • B:Hello,输入 ID 为 [9707]。

每个请求最多生成 3 个 token。实际第一轮只有 A;第二轮开始,A 的 Decode 与 B 的 Prefill 出现在同一批输入中:

轮次活跃请求拼接的 input_idspositionsquery_start_loc新 token,按请求顺序
1A[785,6722,315,9625,374][0,1,2,3,4][0,5][12095]
2A、B[12095,9707][5,0][0,1,2][13,21806]
3A、B[13,21806][6,1][0,1,2][576,0]
4B[0][2][0,1][358]

第二轮的边界 [0,1,2] 表示:A 占拼接 Tensor 的 [0:1],B 占 [1:2]。positions=[5,0] 则表示 A 处理自己序列的位置 5,B 处理自己的位置 0。

边界是一组切分下标,不是 token 内容;每轮 batch 变化后会重新构造。请求 A 结束后,B 在第四轮成为唯一请求,所以它在拼接输入中的起点又回到了 0。

最终输出是:

A: [12095,13,576] → ' Paris. The'
B: [21806,0,358]  → ' Answer! I'

这里的 ID 0 是实际 token,不能因为数值为零就认定它是 padding。两份输出仍属于各自请求,拼接计算没有把两段上下文混为一段。

批处理让多个请求共享一次执行过程,为提高设备利用率提供机会;这属于单设备上的请求批处理,不是把一个模型分布到多卡。此次实验验证了输入组织和请求隔离,没有测量吞吐提升,也没有记录足够的入队时序来解释为什么 B 没进入第一轮。

10. 序列变长以后:跨块,再归还块

前面的序列一直装得进一个 128 槽位的块。为观察跨块,我们构造了一个 127-token 输入:[785] * 126 + [374],再生成 3 个 token。它用于检查边界,不用于评价生成质量。

本轮本轮位置有效块表写入槽位
Prefill0~126[1]128~254
Decode 1127[1]255
Decode 2128[1,2]256

位置 127 仍属于逻辑块 0;位置 128 才进入逻辑块 1:

位置 127:block_table[0] × 128 + 127 = 255
位置 128:block_table[1] × 128 +   0 = 256

此次物理块恰好相邻,因此槽位也连续;实际分到不相邻的物理块同样可以工作。分页映射的作用,就是让序列的逻辑连续性不依赖物理块连续。

接下来,在独立的回收实验中连续运行两个相同请求。第一个请求的生命周期为:

时刻请求持有块空闲块数请求状态
初始化无1256尚未开始
Prefill[1]1255运行中
第一次 Decode[1]1255运行中
第二次 Decode[1,2]1254输出达到长度上限
块释放返回后已归还1256两块引用计数均归零
请求清理返回后无1256已从调度器请求表移除

缓存池总共 1257 块,其中一个 null block 被保留,所以初始可用数是 1256。释放后的判断基准应是恢复到初始可用数,而不是所有块都空闲。

第二个请求分到 [2],跨块后变成 [2,1],结束后空闲数也回到 1256。这确认了先前归还的块可以再次分配;块号顺序不是固定承诺。

实际调度类为 Ascend BalanceScheduler,更新输出的方法来自 patch_kv_delivery_preemption.py。本次普通结束路径可概括为:

更新请求输出
  → check_stop 判断达到长度上限
  → 请求状态变为 FINISHED_LENGTH_CAPPED
  → 请求清理与 KVCacheManager.free
  → 释放请求的块映射
  → block_pool.free_blocks 减少引用计数
  → 可释放块进入空闲队列

归还块意味着改变缓存池的管理状态,不意味着把整块数据清零,也不意味着把预分配的 NPU 显存还给系统。 因此请求结束后,设备显存占用不一定明显下降。本次验证的是同步、关闭 Prefix Cache 的普通结束路径;取消请求、在途计算或缓存传输参与时的延迟释放不在验证范围内。

11. 带着实验结果回到源码

下表按本文的数据流排列阅读入口。路径分别相对于 vLLM 或 vLLM-Ascend 仓库根目录;它们对应实验镜像中的代码,其他版本可能调整类名、路径和调用关系。

想确认的问题仓库及源码入口
调度哪些 token、怎样接收执行结果?vLLM:vllm/v1/core/sched/scheduler.py;同时检查 Ascend 调度补丁
怎样准备输入并调用模型?Ascend:vllm_ascend/worker/model_runner_v1.py,关注 _prepare_inputs、execute_model 和采样路径
模型主干和 logits 怎样计算?vLLM:vllm/model_executor/models/qwen3.py
本次 Qwen3 Attention 的实际 forward?Ascend:vllm_ascend/patch/worker/patch_qwen3vl.py
K/V 怎样写入和参与 Attention?Ascend:vllm_ascend/attention/attention_v1.py
怎样选择下一个 token?vLLM:vllm/v1/sample/sampler.py;Ascend:vllm_ascend/sample/sampler.py
位置怎样转换成槽位?Ascend:vllm_ascend/ops/triton/compute_slot_mapping.py
请求的块怎样归还?vLLM:vllm/v1/core/kv_cache_manager.py、single_type_kv_cache_manager.py、block_pool.py

阅读时可以拿 Decode 1 自查:输入为什么是 [12095],位置为什么是 [5],选 hidden state 为什么用 [0],KV 槽位为什么是 [133],最后又为什么得到 13。能够把这五个值连到不同阶段,就已经把请求的一轮推进过程串起来了。

附录:证据范围与复现说明

这些观察完成于 2026-09-27,包含多次独立实验,并非一份运行日志。主线使用单请求同步执行;双请求、跨块和回收实验在对应章节明确改变了输入或配置。

观察点位于 Runner 输入准备之后、模型与第一层 Attention 的调用边界、logits 生成之后、采样返回处,以及调度器/缓存管理器的释放路径。部分观察使用临时源码插桩并在实验后恢复,回收实验使用进程内方法包装。提取 Tensor 会引入设备同步,因此本文日志不能用来评估性能。

原始实验记录存放在实验环境的 /workspace/learning/ 下:

实验记录目录
单请求三轮、五轮trace_20260927/、trace5_20260927/
双请求trace2req_20260927/
hidden states 与采样logits_20260927/
第一层 Q/K/Vlayer0_20260927/
KV 写入与局部 Attention 对照kvcheck_20260927/
增量计算与完整重算recompute_20260927/
跨块与请求结束回收crossblock_20260927/、freeblocks_20260927/

这些是作者实验环境中的目录索引,尚不是公开下载材料。当前记录保留了软件包版本,但没有完整固定模型 revision 和远程源码 Git commit,因此还不足以构成可以逐位复现的公开实验包。对外复现时,应补齐版本、模型来源及观察脚本。

本文结论限于记录中的模型、配置和测试输入:没有测试长上下文数值一致性、Prefix Cache 命中、异步释放、投机解码或多卡通信,也没有给出性能收益结论。最初启动测试中的退出告警没有在本文中完成根因定位;后续实验正常退出不能替代对该告警的解释。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐