nano-vllm 用千行代码拆解 vLLM 核心,是读懂大模型推理最快的捷径。

1. 介绍

前文介绍的 ModelRunner 把模型当黑盒——run_model 直接 model(input_ids, positions) 拿到 hidden,再过 lm_head 得 logits。

本篇打开这个黑盒,看 Qwen3 的骨架:一串 token id 进去,怎么经词嵌入、28 层 decoder、最后一道归一化,输出能算 logits 的 hidden。

2. 总览

Qwen3 的骨架只有四段:词嵌入 embed_tokens、28 层 Qwen3DecoderLayer 堆叠、最后一道 RMSNorm、输出头 lm_head。token id 自上向下计算,最终变成 logits。

在这里插入图片描述

Qwen3-0.6B 的关键配置:

配置项 含义
hidden_size 1024 隐藏状态向量的维度
num_hidden_layers 28 堆叠的 decoder 层数
num_attention_heads 16 query 头数
num_key_value_heads 8 KV 头数(少于 q 头,GQA)
head_dim 128 每个头的维度
intermediate_size 3072 MLP 中间层宽度
vocab_size 151936 词表大小
tie_word_embeddings True lm_headembed_tokens 共享权重

3. Qwen3Model 骨架

Qwen3Model 把骨架装在一起,forward 把四段串起来跑一遍。

import torch
from torch import nn
from transformers import Qwen3Config

from nanovllm.layers.layernorm import RMSNorm                       
from nanovllm.layers.embed_head import VocabParallelEmbedding, ParallelLMHead
from nanovllm.models.qwen3 import Qwen3Attention, Qwen3MLP         


class Qwen3Model(nn.Module):
    def __init__(self, config: Qwen3Config):
        super().__init__()
        # 词嵌入:token id → [hidden] 向量
        self.embed_tokens = VocabParallelEmbedding(
            config.vocab_size, config.hidden_size)
        # 28 层 decoder 堆叠,结构完全一样
        self.layers = nn.ModuleList(
            [Qwen3DecoderLayer(config)
             for _ in range(config.num_hidden_layers)])
        # 最后一道归一化
        self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)

    def forward(self, input_ids, positions):
        # 查表得初始 hidden [N, hidden]
        hidden_states = self.embed_tokens(input_ids)
        # 残差连接
        residual = None
        # 顺序执行 28 个 DecoderLayer,上一层 layer 输出是下一次 layer 输入
        for layer in self.layers:
            hidden_states, residual = layer(
                positions, hidden_states, residual)
        # final norm 把最后一层的残差并入并归一化
        hidden_states, _ = self.norm(hidden_states, residual)
        return hidden_states

3.1 embed_tokens

模型的入口,本质是一张 [vocab_size, hidden_size] 的查找表:输入一串 token id,逐个查出对应的 hidden_size 维向量,拼成初始 hidden:

hidden_states = self.embed_tokens(input_ids)

为什么先转成向量:token id 只是个编号,像身份证号——能唯一指认一个词,号码本身却不含信息,5 号和 6 号不会因此更相近。而模型只对连续向量做计算(相加、投影、注意力),没法拿编号直接算。embedding 给每个词发一张「多维画像」:每一维刻画一点属性(训练时模型自己学的),语义相近的词画像也相近——词与词的远近、关系,这才能用数学表达。

3.2 Qwen3DecoderLayer

骨架里 self.layers 是 28 个结构完全相同的 Qwen3DecoderLayer,模型绝大部分参数与计算都在这里。一层按 input_layernorm → self_attn → post_attention_layernorm → mlp 的顺序走一遍,每个子层的输出都经残差加回主干。三种部件先看作用,内部留到后续篇幅:

  • self_attn(自注意力):让每个 token 环视它之前出现的所有 token,谁相关就多看谁,按相关度把信息汇总过来——token 之间的沟通、对上下文的理解,靠的就是它。打个比方:像开会时环顾全场,谁说的跟我有关,就多记谁的话。
  • mlp(前馈):注意力管"交流",mlp 管"消化"——每个 token 各自把刚收到的信息深加工一遍(先摊到更宽的 3072 维算,再收回 1024)。打个比方:把头脑风暴的记录整理成自己的结论。
  • layernorm(RMSNorm):子层开算前的"校准",把输入的数值规模拉回统一范围,让 attn / mlp 在稳定的尺度上计算,防止多层计算导致数值过大或过小。
class Qwen3DecoderLayer(nn.Module):
    def __init__(self, config: Qwen3Config):
        super().__init__()
        # 注意力层
        self.self_attn = Qwen3Attention(
            hidden_size=config.hidden_size,
            num_heads=config.num_attention_heads,
            num_kv_heads=config.num_key_value_heads,
            max_position=config.max_position_embeddings,
            rms_norm_eps=config.rms_norm_eps,
            qkv_bias=getattr(config, 'attention_bias', True),
            head_dim=getattr(config, 'head_dim', None),
            rope_theta=getattr(config, "rope_theta", 1000000),
            rope_scaling=getattr(config, "rope_scaling", None),
        )
        # 前馈层
        self.mlp = Qwen3MLP(
            hidden_size=config.hidden_size,
            intermediate_size=config.intermediate_size,
            hidden_act=config.hidden_act,
        )
        # 两道 RMSNorm:attn 前、mlp 前
        self.input_layernorm = RMSNorm(
            config.hidden_size, eps=config.rms_norm_eps)
        self.post_attention_layernorm = RMSNorm(
            config.hidden_size, eps=config.rms_norm_eps)

    def forward(self, positions, hidden_states, residual):
        if residual is None:
            # 归一化(第 0 层走这个if分支)
            hidden_states, residual = \
                self.input_layernorm(hidden_states), hidden_states
        else:
            # 相加 + 归一化融成一步
            hidden_states, residual = \
                self.input_layernorm(hidden_states, residual)
        # 注意力在归一化副本上算,输出待下一道 norm 时并入主干
        hidden_states = self.self_attn(positions, hidden_states)
        # post_norm:把 attn 输出并入主干 + 归一化
        hidden_states, residual = \
            self.post_attention_layernorm(hidden_states, residual)
        # MLP 在归一化副本上算
        hidden_states = self.mlp(hidden_states)
        # 把 (mlp 输出, 主干) 交给下一层(或 final norm)合并
        return hidden_states, residual

3.3 norm

28 层跑完,主干最后一层的输出还没归一化过。self.norm 这道 RMSNorm 给它收尾,把数值调回稳定范围,再交给 lm_head 算 logits:

hidden_states, _ = self.norm(hidden_states, residual)

它和 decoder layer 层内那两道是同一种 RMSNorm,区别只在位置——在所有层之后、输出之前。

3.4 残差连接

残差连接把每个子层的输出加回一条贯穿全程的主干,这条主干就是残差流。

是什么:一条从 embed_tokens 输出起、贯穿 28 层、直到 final norm 的主干流,宽度恒为 hidden_size(=1024)。每一层不直接覆写它,而是在它的归一化副本上算出一份「增量」,再加回主干。

打个比方:像一条传送带。每个工位(子层)从带上取一份副本加工,把成品放回带子;带子本身一路不断、越积越厚,后面的工位始终看得到最初的货。

为什么需要:28 层堆叠,若每层直接覆写输出,底层信息在深处会衰减。残差连接(Transformer 标配)让每层只学「对主干的修正」,信息直通到顶。

解决了什么:让深层堆叠稳定——主干恒在,子层只做加法。

怎么解决:每个子层做 主干 += 子层(norm(主干))

在这里插入图片描述

回看 3.2 的 forward:在 nano-vllm 里,这个「加」被融进下一个 norm——两参版 RMSNorm(hidden, residual) 一次做完「相加 + 归一化」,返回 (归一化结果, 新残差)。于是 residual 必须作为单独张量在层间显式传递,而不是在层内就地相加,省一遍对整条主干的读写。

主干宽度为何恒为 1024:注意力内部会投到 2048、MLP 内部投到 3072,但 o_proj / down_proj 都把它收回 1024 才加回主干,所以主干一路不变。

4. Qwen3ForCausalLM:输出 logits

Qwen3Model 算出 hidden 后还差一步:把每个位置的 [1024] hidden 投到 [151936] 词表分数(logits)。这一步是 lm_head,封装在最外层的 Qwen3ForCausalLM 里。

class Qwen3ForCausalLM(nn.Module):
    # HF 的分散权重名 → 合并参数名(权重加载用,后续章节介绍)
    packed_modules_mapping = {
        "q_proj": ("qkv_proj", "q"),
        "k_proj": ("qkv_proj", "k"),
        "v_proj": ("qkv_proj", "v"),
        "gate_proj": ("gate_up_proj", 0),
        "up_proj": ("gate_up_proj", 1),
    }

    def __init__(self, config: Qwen3Config):
        super().__init__()
        self.model = Qwen3Model(config)                    # 骨架主体
        self.lm_head = ParallelLMHead(                     # 输出头
            config.vocab_size, config.hidden_size)
        if config.tie_word_embeddings:
            # 共享权重:lm_head 直接指向 embed 的同一块存储
            self.lm_head.weight.data = \
                self.model.embed_tokens.weight.data

    def forward(self, input_ids, positions):
        return self.model(input_ids, positions)            # 得 hidden

    def compute_logits(self, hidden_states):
        return self.lm_head(hidden_states)                 # hidden → logits

lm_head:从 hidden 到 logits

主干过完 final norm 得到 hidden(每个位置一个 [1024] 向量),lm_head 是一个无 bias 的线性层,把 [1024] 投成整张词表的分数 [151936]:

logits = hidden @ lm_head.weight.T # 每个位置 [1024] → [151936],权重 [151936, 1024]

在这里插入图片描述

即给词表里每个词打一个分(logit),分最高的(或按 sampler 策略采样的)就是预测的下一个词。(自回归只用序列最后一个位置来预测下一个词,实际只对那一位算 logits。)

tie_word_embeddings(权重共享)

lm_headembed_tokens 共用同一份 [vocab, hidden] 权重矩阵 EE 的第 iE[i] 是词 i 的"释义向量"。

打个比方(查字典):E 是一本字典,每个词对应一条释义向量。embed_tokens正向查:给一个词(token id i),翻到那一行,取出释义向量 E[i];lm_head反向查:拿模型输出的向量 h,跟字典里每个词的释义逐一比对(点积 h·E[j]),谁最像,谁的分(logit)就最高。

在这里插入图片描述

为什么能共享:两步查的是同一本字典——「词 → 向量」与「向量 → 词」互为正反。一个词在入口的释义向量,本就该是出口拿来比对的那一行;共享 E 不只省参数,语义上也自洽。数学上 lm_head 算的 logits = h @ Eᵀ,正是「hE 的每一行做点积」,与 embed 的「取 E 的一行」严丝合缝。

省了多少:[151936, 1024] ≈ 1.56 亿参数,占 0.6B 模型相当比例,共享省下一整本字典的显存。

怎么实现:self.lm_head.weight.data = self.model.embed_tokens.weight.data,让两者指向同一块存储(集成验证用 data_ptr() 相等佐证)。

compute_logits 正是 L11 run_model 的第二步:先 model(...) 拿 hidden,再 model.compute_logits(hidden) 得 logits。

5. 集成验证

加载真实 Qwen3-0.6B,打印模块树看清骨架,跑一次 forward 确认残差流宽度,再验证 lm_headembed 确实共享权重。

import torch.distributed as dist
from modelscope import snapshot_download
from nanovllm.config import Config
from nanovllm.engine.sequence import Sequence
from nanovllm.sampling_params import SamplingParams
from nanovllm.utils.context import reset_context
from transformers import AutoTokenizer

# 复用 L11 ModelRunner
from topic11_model_runner import ModelRunner

# 单卡环境(同 L11)
torch.cuda.set_device(0)
if not dist.is_initialized():
    dist.init_process_group(
        "nccl", "tcp://localhost:2335", world_size=1, rank=0)

model_path = snapshot_download("Qwen/Qwen3-0.6B")
config = Config(model_path, enforce_eager=True, max_model_len=4096)
runner = ModelRunner(config)
model = runner.model           # 正是上文读的 Qwen3ForCausalLM
tokenizer = AutoTokenizer.from_pretrained(model_path)
Downloading Model from https://www.modelscope.cn to directory: /DATA/disk5/cache/modelscope/models/Qwen/Qwen3-0.6B


2026-06-01 16:58:27,884 - modelscope - INFO - Target directory already exists, skipping creation.
# 打印模块树
print(model)
Qwen3ForCausalLM(
  (model): Qwen3Model(
    (embed_tokens): VocabParallelEmbedding()
    (layers): ModuleList(
      (0-27): 28 x Qwen3DecoderLayer(
        (self_attn): Qwen3Attention(
          (qkv_proj): QKVParallelLinear()
          (o_proj): RowParallelLinear()
          (rotary_emb): RotaryEmbedding()
          (attn): Attention()
          (q_norm): RMSNorm()
          (k_norm): RMSNorm()
        )
        (mlp): Qwen3MLP(
          (gate_up_proj): MergedColumnParallelLinear()
          (down_proj): RowParallelLinear()
          (act_fn): SiluAndMul()
        )
        (input_layernorm): RMSNorm()
        (post_attention_layernorm): RMSNorm()
      )
    )
    (norm): RMSNorm()
  )
  (lm_head): ParallelLMHead()
)
# prompt → token ids(chat template,同 L11/L12)
msgs = [{"role": "user", "content": "你是谁"}]
text = tokenizer.apply_chat_template(
    msgs, tokenize=False,
    add_generation_prompt=True, enable_thinking=False,
)
prompt_ids = tokenizer(text).input_ids

# 造一条 Sequence,铺成 prefill 张量(set_context 由 prepare_prefill 完成)
seq = Sequence(prompt_ids, SamplingParams(temperature=0.6))
seq.num_scheduled_tokens = len(seq)
seq.block_table = list(range(seq.num_blocks))
input_ids, positions = runner.prepare_prefill([seq])

# 跑骨架主干:embed → 28 层 → final norm
# 推理不需梯度
with torch.inference_mode():
    hidden = model.model(input_ids, positions)
print("hidden shape :", tuple(hidden.shape))   # [N, 1024] 残差流宽度恒 1024
reset_context()

# 验证 tie weights:lm_head 与 embed 指向同一块存储
shared = (model.lm_head.weight.data_ptr()
          == model.model.embed_tokens.weight.data_ptr())
print("tie weights  :", shared)                # True
hidden shape : (14, 1024)
tie weights  : True

6. 小结

Qwen3 的骨架是四段:embed_tokens → 28×Qwen3DecoderLayer → final RMSNormlm_head

贯穿其中的是残差流:一条宽度恒为 1024 的主干,每个子层在它的归一化副本上算增量、加回主干。nano-vllm 把这个「加」融进下一个 norm,所以 residual 作为单独张量在层间显式传递。

tie_word_embeddingslm_headembed_tokens 共享一份词表权重,省下约 1.56 亿参数的显存。

下一篇详细介绍 RMSNorm 与残差相加怎么融成一个 add_rms

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐