1. 从零手搓大模型,我为什么选择Qwen作为第一个研究对象
大概是从去年年底开始,身边越来越多的朋友问我要大模型学习资料,说看完理论还是不知道模型内部到底长什么样。说实话,这种情况太正常了。市面上的大模型学习路线、上海交大的《动手学大模型》教程、各种AI大模型入门指南,内容质量都不差,但大部分都停在一个层次:告诉你Transformer是什么、Attention是什么,然后就没有然后了。
我自己的感觉是,学习大模型这件事,卡住大多数人的不是理论,而是“不知道怎么把理论对应到代码上”。所以我想写这个“从零手搓大模型之路”系列,带着大家真正走进一个开源大模型的内部,把它拆开揉碎看清楚。系列第一篇选定的对象,是Qwen(通义千问)。
为什么选Qwen而不选其他模型?理由有三个。
第一,Qwen是当前中文开源大模型里生态最完整的之一。从0.5B到72B、MoE架构的Qwen3系列,覆盖了从个人娱乐到生产部署的全部规模段。你在网上搜“大模型本地部署”“ollama部署大模型”“vllm部署大模型”这类教程,Qwen是出场率极高的名字。选择它,意味着后续你学到的内容可以无缝迁移到实际部署中。
第二,Qwen的代码实现非常规矩。它没有像某些模型那样为了刷榜搞各种奇怪的trick,而是把当前大模型主流的成熟技术老老实实地用上了:GQA、RMSNorm、SwiGLU、RoPE、KV Cache,一个不少。读懂Qwen,基本上就摸清了当前主流大模型架构的通用范式,之后再看LLaMA、Mistral、DeepSeek,都会觉得非常亲切。
第三,也是最重要的一点:Qwen系列有各种规格的模型权重,而且从0.5B到7B级别的模型对显存需求并不夸张。你可以真的在自己的消费级显卡甚至MacBook上跑起来,边看代码边调试,这是“手搓”的前提。
这篇文章,我会按我自己实际研究Qwen的过程来组织:先把Qwen的整体架构在脑子里建立一个地图,再逐层拆解关键组件的实现,最后给出我亲测有效的代码阅读路径和环境配置方案。正文里所有关于源码的解析,都基于Qwen2/Qwen2.5系列的官方实现,因为Qwen3的MoE版本在架构上做了一些变化,属于进阶内容,我会在系列后面的文章里单独讲。
2. 学习Qwen之前,先要在脑子里建立一张架构地图
2.1 Qwen的整体结构:一个标准Decoder-Only架构
很多第一次接触大模型的同学,拿到Qwen的代码后第一反应是懵:代码目录里一堆文件,不知道先看哪个。我自己第一次看的时候也踩了这个坑,在配置文件和工具函数里绕了好久,最后才意识到,应该先从模型的核心类入手。
如果你下载的是官方源码,找到qwen2/modeling_qwen2.py这个文件,里面有一个叫Qwen2ForCausalLM的类,这就是整个模型的“外壳”。这个类做的事情可以概括为三件:
- 管理一个
Qwen2Model实例,这是真正的主体网络 - 管理一个
lm_head线性层,把模型输出的hidden states映射到词表大小的logits - 在训练和推理时,把“预测下一个token”这个任务从数学上封装好,包括交叉熵损失的计算逻辑
而Qwen2Model内部,就是标准的Decoder-Only堆叠结构。它由一个embed_tokens词嵌入层、一组数量相同的DecoderLayer(也就是我们常说的Transformer Block)堆叠而成,最后再接一个norm层(RMSNorm)。
这里有一个很重要的细节:早期Transformer的Encoder-Decoder架构,现在已经不是大模型的主流了。Qwen这类因果语言模型,用的是纯Decoder结构,每个token在计算attention时只能看到它之前的token,不能看到后面的。这种设计决定了它天然适合做文本生成任务,而不是文本理解任务(理解任务通常需要双向上下文)。为什么大家最后都统一到了Decoder-Only?简单说就是:效果更好,训练更稳定,而且在推理时可以通过KV Cache大幅提升速度,工程上也更简单。作为一个系列的第一篇,我不展开讲这段历史,但大家在脑子要记住这个判断。
2.2 组件清单:Qwen里都有哪些关键零件
把Qwen模型比作一台汽车的话,DecoderLayer就是发动机。发动机里有哪些主要零件,我先把清单列出来:
Qwen2Attention:注意力模块,整个模型的核心计算单元Qwen2MLP:多层感知机模块,负责对每个token的特征做非线性变换input_layernorm:注意力前的归一化层post_attention_layernorm:MLP前的归一化层
一个DecoderLayer的工作流程是:输入hidden_states,先做input_layernorm归一化,然后送入注意力层,注意力输出与原始输入做残差连接;再做post_attention_layernorm归一化,送入MLP,MLP输出同样做残差连接。两份残差加起来,就是这个DecoderLayer的最终输出。
这就是大名鼎鼎的Pre-Norm结构。为什么要用Pre-Norm而不是Post-Norm?在原始Transformer论文里,归一化是放在子层之后的,但后来大家发现Pre-Norm在深层网络中训练更稳定,梯度不容易爆炸,所以现在几乎所有大模型都切到了Pre-Norm。这些细节看起来小,但实际上都是决定模型能否训练起来的关键。
最终整体结构可以用这样一行伪代码来概括:
输入tokens -> Embedding -> [DecoderLayer x N] -> RMSNorm -> Linear -> Logits -> Softmax -> 下一个token把这个结构刻在脑子里,你再看任何模型的代码,都是在往这个框架里填补具体实现。
2.3 Qwen模型规模的配置哲学:从0.5B到72B,差异在哪里
Qwen系列的另一大学习价值在于,它用同一套代码支撑了多个规模的模型。从0.5B、1.8B、7B、14B,到72B,它们共享同一个Qwen2Config配置类,差异完全由几个关键数字控制:
hidden_size:隐藏层维度,决定每个token被表示成多少维的向量num_hidden_layers:DecoderLayer的层数num_attention_heads:注意力头的数量num_key_value_heads:KV头的数量(GQA的核心参数)intermediate_size:MLP中间层维度
以Qwen2.5-7B为例,它的配置大致是:hidden_size=3584,num_hidden_layers=28,num_attention_heads=28,num_key_value_heads=4,intermediate_size=18944。注意看num_key_value_heads=4,这就是Qwen使用GQA(分组查询注意力)的体现。而0.5B模型配置差异极大,num_key_value_heads和num_attention_heads都小很多。
拿到这些数字,可以算一算模型的大概参数量。比如单个attention层的参数量大约是4 * hidden_size * hidden_size(Q、K、V、O四个矩阵),加上MLP的参数量大约3 * hidden_size * intermediate_size(gate、up、down三个矩阵),再加embedding是vocab_size * hidden_size(Qwen的词表大小是151936,比一般模型大不少),大致就能和模型实际参数量对得上。这个计算过程,建议大家都自己动手算一遍,能极大地帮助理解模型规模从何而来。
3. 用一整个章节来讲清楚Qwen的注意力机制
3.1 从标准自注意力开始,理解Attention到底在算什么
注意力机制,是所有现代大模型的核心。网上讲Attention的文章已经非常多,但我发现很多朋友的问题不是“不理解公式”,而是“不知道代码里具体在做什么”。
我们先用标准的缩放点积注意力来理解。假设输入的hidden_states是一个形状为[batch_size, seq_len, hidden_size]的张量,注意力层要做的事情是:
- 通过三个权重矩阵
Wq、Wk、Wv,把hidden_states分别投影成Query、Key、Value,得到三个张量 - 计算Query和Key的点积,除以缩放因子
sqrt(head_dim),得到注意力分数 - 对注意力分数做Softmax归一化,得到每个位置对所有历史位置的注意力权重
- 用注意力权重对Value做加权求和,得到输出
- 最后通过
Wo矩阵投影回原来的维度
用代码表达就是:
import torch import torch.nn.functional as F def scaled_dot_product_attention(query, key, value, mask=None): d_k = query.size(-1) scores = torch.matmul(query, key.transpose(-2, -1)) / torch.sqrt(torch.tensor(d_k, dtype=query.dtype)) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attn_weights = F.softmax(scores, dim=-1) output = torch.matmul(attn_weights, value) return output但这里有一个细节:Qwen的实际实现并不是直接对完整的hidden_states做矩阵乘法,而是先view成多头的形式。具体来说,会把hidden_states从[batch_size, seq_len, hidden_size]reshape成[batch_size, seq_len, num_heads, head_dim],然后转置成[batch_size, num_heads, seq_len, head_dim]。为什么要拆成多头?因为不同的头可以关注不同维度的信息,有的头关注语法关系,有的头关注语义相关性,多头让模型有机会在不同子空间学习不同的注意模式。
3.2 GQA:Qwen在注意力模块上做的最重要改进
如果完全使用标准的多头注意力(MHA),每个头都会拥有独立的K和V,在推理时,随着生成的token越来越多,KV Cache的占用量会线性增长。对于7B这种规模的模型,MHA的KV Cache会轻松吃掉几个GB的显存,这在生产环境是难以接受的。
Qwen采用的分组查询注意力(Grouped Query Attention,GQA)是对MHA的折中方案。GQA的做法是:让Query保持多个头(比如28个),但把Key和Value的头数量减少(比如只有4个),然后每组Query头共享一个KV头。在Qwen的实现中,28个Query头被分成4组,每组7个Query头对应1个KV头。这直接让KV Cache的占用降到了MHA的七分之一。
登录一个真实的模型部署场景:使用vllm部署Qwen2.5-7B时,如果max_model_len设置为8192,MHA的KV Cache需要大约4.6GB,而GQA只需要约0.66GB。这个差距是实际且显著的。在你用Ollama做本地部署时,GQA也是能让消费级显卡跑起来的重要前提。
至于为什么不是MQA(Multi-Query Attention,所有Query头共用一个KV头)?MQA的KV Cache更小,但实验表明它对模型质量有可感知的损伤。GQA是质量和效率之间的平衡点。这些判断不是我的结论,而是当前开源社区的共识,了解它,你就明白架构设计中处处是取舍。
3.3 因果掩码与位置编码的工程实现
Qwen作为因果语言模型,在注意力计算时必须保证每个位置只能attend到它自己及之前的位置。这个掩码在训练时是通过一个causal_mask矩阵实现的,形状为[1, 1, seq_len, seq_len],上三角部分被mask掉。Flash Attention的实现里则直接把is_causal=True传给底层kernel,不需要显式构造掩码矩阵,这也是FlashAttention节省显存的原因之一——它不需要把完整的注意力分数矩阵存在显存里。
另一个关键组件是RoPE(旋转位置编码)。位置编码解决的核心问题是:Attention计算是基于点积的,点积本身对位置是无感的,如果不加位置信息,“我爱你”和“你爱我”在模型看来很难天然区分。RoPE的思路是,不同位置(指的是token在序列中的位置)对Q和K向量施加不同的旋转角度。
Qwen的配置文件里有rope_theta=1000000.0和rope_scaling两个参数。rope_theta控制旋转频率的基数,更大的rope_theta能支持更长的上下文外推。在Qwen的官方代码中,还有一个微妙的处理:inv_freq在100维之前没有缩放、在100维之后乘以1/2。这是Qwen为了稳定长上下文训练做的特殊设计,在注释里被称为“truncate dimension”。如果你在阅读其他模型的代码时不记得这个细节,反而会对不上数值。
RoPE的代码实现是比较容易劝退初学者的地方。但我的建议是:这个阶段,你要能理解RoPE的原理和它解决的问题,不必逐行手推它的复数数学形式。等真正需要做长上下文微调时,再回来抠细节,绝对来得及。
4. 归一化与激活函数:Qwen里看似简单但不容忽视的零件
4.1 RMSNorm:为什么Qwen不用LayerNorm
传统的Transformer用的是LayerNorm,它对输入向量做归一化,需要计算均值和方差。Qwen用的是RMSNorm,它只做了缩放,不减去均值。
RMSNorm的计算公式非常简单:把输入向量的每个元素平方、求平均、开根号,得到RMS,然后每个元素除以这个RMS,再乘上一个可学习的权重weight。相比LayerNorm,RMSNorm省去了计算均值的步骤,计算开销更小,而且实验表明它在大模型训练中效果不输LayerNorm,甚至更稳定。
Qwen的RMSNorm实现在qwen2/modeling_qwen2.py里,调用的是HuggingFace的Qwen2RMSNorm类。代码里有几个细节值得注意:
eps参数设成了1e-6,这是一个很小的常数,防止除以零variance_epsilon在配置里叫rms_norm_eps- 归一化之后,还要乘上一个可学习的
weight向量,具体来说就是self.weight,形状与hidden_size一致
RMSNorm的一个工程优势是:在推理时,权重可以提前融合计算,减少kernel调用的延迟。在量化场景下,RMSNorm的数值稳定性也优于LayerNorm。总之,现在的开源大模型基本已经达成了“用RMSNorm”的共识。
4.2 SwiGLU激活函数:MLP模块为什么是三个矩阵
Qwen的MLP模块不是简单的“线性层-激活函数-线性层”两层结构,而是采用了SwiGLU激活函数,拆成了三个权重矩阵:gate_proj、up_proj和down_proj。
直观理解:up_proj负责把hidden_states映射到更高维的空间(intermediate_size,通常是hidden_size的2到5倍),让模型有更大的特征表达能力;gate_proj负责计算门控信号,决定每个维度的信息有多少应该通过;gate_proj的输出经过SiLU激活函数后,与up_proj的输出逐元素相乘,最后经过down_proj降维回hidden_size。
写成公式或代码是:
def forward(self, x): return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))Qwen为什么选择SiLU而不是更经典的ReLU或者GELU?SiLU(也叫Swish)在负区间不是完全截断为零,而是保留了一个平滑的负值,这让梯度可以更顺畅地流过负值区域,训练更稳定。SwiGLU则是这个基础上的门控变体,引入门控机制后,模型可以更灵活地控制信息的通过程度。如今SwiGLU几乎成了开源大模型的标配,你看LLaMA和Mistral,用的都是它。
4.3 残差连接的作用:让数百层网络也能稳定训练
大模型动辄几十层甚至上百层,如果没有残差连接,信号在逐层传递时会不断衰减或爆炸。残差连接的做法非常朴素:把输入直接加到子层的输出上,让网络学习的是“残差”,而不是完整的映射函数。
Qwen在每个注意力层和MLP层前后都加了残差连接。这带来一个有趣的推论:即使中间层的输出为0,信号依然可以通过残差旁路传递到下一层,梯度也能通过这条短路回传。这也是为什么堆到几十层还能训练起来的一个重要原因。
对于初学者,理解残差连接还有一个好处:当你调试模型输出时,如果你把某一层权重全部置零,模型依然能输出一些类词向量的结果,这就是残差连接在起作用。动手验证这种小实验,比看十篇文章都记得牢。
5. 手把手实操:把Qwen源码跑起来并逐层调试
5.1 环境准备与依赖安装
在正式读代码之前,我建议你先搭一个能跑通的环境。具体硬件方面,0.5B模型只需要大约4GB内存或显存,7B模型在CPU上也能跑但速度较慢,推荐至少有8GB显存的GPU。我自己的开发机是一张RTX 3090,跑7B模型毫无压力。
环境建议用Python 3.10以上版本,然后安装依赖。
pip install transformers>=4.45.0 torch>=2.1.0 accelerate sentencepiece需要注意:Qwen2模型的tokenizer与现代分词器不完全一样,需要用sentencepiece来处理。这里有一个很多新手会踩的坑:只安装transformers和torch,运行时却报ModuleNotFoundError: 'sentencepiece',然后卡了半天才发现漏装。
如果你想要完整复现我的调试过程,最好clone一份官方源码到本地:
git clone https://github.com/QwenLM/Qwen2.git不过更实用的做法是直接在Python中引入已安装包的源码位置,transformers库会自带modeling_qwen2.py等文件,路径通常在你的Python环境site-packages/transformers/models/qwen2/目录下。
提示:阅读模型源码,我更推荐直接读
site-packages里那一份。它经过HuggingFace的适配,与AutoModelForCausalLM接口完全对齐,你用起来更方便,而且它还额外做了cache、device_map等工程上的兼容。
5.2 从加载模型到查看参数:第一段必须运行的代码
环境搭好后,先运行下面这段最朴素的代码,确认能成功加载模型:
from transformers import AutoModelForCausalLM, AutoTokenizer import torch model_name = "Qwen/Qwen2.5-0.5B-Instruct" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.float32, device_map="cpu" ) print(model)这段代码会输出整个模型的层级结构。你会在输出中看到类似这样的结构:
Qwen2ForCausalLM( (model): Qwen2Model( (embed_tokens): Embedding(151936, 896) (layers): ModuleList( (0-23): 24 x Qwen2DecoderLayer( (self_attn): Qwen2Attention(...) (mlp): Qwen2MLP(...) (input_layernorm): Qwen2RMSNorm(...) (post_attention_layernorm): Qwen2RMSNorm(...) ) ) (norm): Qwen2RMSNorm(...) ) (lm_head): Linear(in_features=896, out_features=151936, bias=False) )注意0.5B模型的embed_tokens输出维度是896?是的,不同规模的模型hidden_size不同。实际动手查看这个结构,对建立“模型就是一堆张量运算”的直觉,帮助极大。
5.3 用断点调试的方式观察Attention内部计算
光看模型结构还远远不够,关键是要能看到张量在每一层的形状变化。这是最接近“手搓”的体验。
建议你在Pycharm或者VS Code里打开site-packages/transformers/models/qwen2/modeling_qwen2.py,在Qwen2Attention.forward的函数入口处打上断点,然后运行一段生成代码。你会看到输入张量的形状是[batch_size, seq_len, hidden_size],然后一路观察:
- hidden_states通过
q_proj、k_proj、v_proj线性层,形状变为[batch_size, seq_len, num_heads * head_dim] - 经过
view和transpose之后,变成[batch_size, num_heads, seq_len, head_dim] - 注意
k_proj输出的头数是num_key_value_heads,而不是num_heads
第3点是理解GQA的关键,也是最直观的观察点。你在调试时可以看到Qwen2-7B的query在attention计算时被repeat_kv扩展到了与K、V相同的头数。
我自己的经验是:不要直接把整个model.generate跑完。先用model(input_ids)单次前向,在指定的层里打点查看张量形状,配合sizes、shapes这类调试窗口,比任何教程都高效。
5.4 验证位置编码:RoPE的核心逻辑
位置编码这部分,我建议你调试一下apply_rotary_pos_emb函数。它的代码逻辑是:
- 根据
seq_len生成一个位置索引序列[0, 1, 2, ..., seq_len-1] - 结合
inv_freq计算出cos和sin值 - 调用
rotate_half函数,把Q和K的向量一分为二,旋转后拼接
关于这段代码,我踩过的一个坑是:手动修改rope_theta后,直接用短序列训练好的模型生成长文本,效果不佳。这是因为rope_theta变化需要配合相应的位置编码缩放策略(比如Yarn或NTK-aware scaling),不是简单调大就能外推的。在长上下文微调和推理时要格外注意,这也是很多人在模型部署、微调中遇到“效果变差”的隐藏原因。具体的数学原理,我在后面写长上下文文章时再展开。
5.5 实际动手:用CPU跑通一次完整的文本生成
环境、代码、调试都搞定之后,跑通完整生成流程是检验理解的好方法。代码如下:
prompt = "大模型架构的核心组件包括" inputs = tokenizer(prompt, return_tensors="pt") outputs = model.generate( inputs.input_ids, max_new_tokens=50, do_sample=False, ) print(tokenizer.decode(outputs[0], skip_special_tokens=True))用0.5B模型在CPU上,这一步通常几秒就能完成。实际操作时,建议你设置断点,观察generate内部是如何一步步调用模型的:先是单次前向得到logits,然后取最后一个位置,用softmax或贪心策略选出最有概率的token,拼接回输入,再继续生成。这个循环本质上就是所有大模型在推理时最主要的工作模式。
当你亲手看到这个循环跑起来之后,下一步想了解KV Cache、vllm部署、LoRA微调时,就有了坚实的基础。因为你已经知道了:推理速度慢是因为每生成一个token,都要重新计算前面所有token的attention。
6. 我踩过的坑与给你的避坑建议
6.1 版本兼容性问题:transformers版本太老或太新都会出事
Qwen2系列模型对transformers版本是有要求的。太老的版本(比如4.30以下)根本不知道Qwen2这个模型类,加载时会报错说找不到匹配的模型架构;太新的版本有时候也会因为库内部重构,出现一些兼容性警告,虽然通常不影响使用,但会让人心里没底。
我的建议是:把transformers锁定在4.45系列,这是目前最稳的版本区间。无论你是用AutoModelForCausalLM.from_pretrained加载,还是准备后续做微调和部署,这个版本踩坑最少。
另一个常见问题是:torch_dtype设定为float16在CPU上会报错。因为CPU上的某些算子不支持半精度计算,最简单的解决办法就是CPU上用float32,GPU上再用float16或bfloat16。这个细节在5.2的代码里我已经体现出来了。
6.2 初学阶段最容易走偏的三个方向
根据我带过不少新人的经验,初学大模型架构时最容易走偏的方向有三个。
第一个是陷入数学细节不能自拔。比如非要把RoPE的复数推导手推一遍,或者把FlashAttention的前向反向原理完全搞透。这些内容值得学,但绝对不该在你还没跑通模型时花大量时间。先建立整体认知,再逐个突破难点,效率会高上一倍。
第二个是过度依赖别人的教程而不看源码。网上各种大模型学习资料非常多,但很多二手资料在传播过程中会失真,甚至出现错误。源码就是最权威的文档,当你对某个实现有疑问时,永远应该先回到源码去验证。
第三个是只跑代码不总结。读完代码之后,一定要动手画一遍结构图或者写一篇笔记。写笔记的过程就是检验你理解程度的过程。如果你发现自己写不清楚某个组件,说明这个组件你还没吃透,回去再看。
6.3 关于“手搓大模型”的正确预期
最后说一下我对“手搓”这个词的理解。很多人以为“从零手搓大模型”是让你从线性代数开始手写一个完整的大模型训练脚本,我觉得这不是合理的学习路径。以现在大模型的复杂程度,哪怕是7B级别的模型,从零手写训练代码且训练出有意义的模型,对个人开发者来说几乎不可能。
我更推荐的路径是:把开源模型当“乐高积木”,理解每一块积木的结构和功能,尝试修改一部分积木,观察整体的影响,然后逐步走向微调、部署和定制。这个系列后续的文章,也会沿着这条路径展开:先讲清楚架构,再讲数据准备,然后是LoRA微调实战(这也可以直接对接你搜到的各种“lora微调实战教程qwen”内容)、量化部署、vllm服务化,最后是RAG应用开发。
其实做这件事对我的个人收获还有一个意外之喜:搞懂模型架构之后,再看那些大模型相关的新闻、论文和技术讨论,眼里看到的不再是空洞的概念,而是具体的计算图。你会觉得那些八股文一样的模型介绍,突然都变成了一种你可以验证、甚至能质疑的东西。这大概就是“手搓”带来的最大的底气。