Skip to content

约 13 分钟阅读

实践:逐层拆开一个小模型 ​

第 01—08 篇把一个 decoder-only 模型拆成了分词、词嵌入、注意力、KV 头、FFN、残差与归一化、位置编码和输出层。这些都是从配置和公式推出来的。这一篇在笔记本上加载一个真实的小模型,用 PyTorch 的 forward hook 把它的每一层打开看:参数是不是和手算的一样多,张量经过每个模块时形状怎样变,最后一个位置的注意力看向哪里,每层注意力和 FFN 往残差流里写了多少,中间层“此时想说什么”,以及去掉某一层以后模型会坏到什么程度。

读完你应该能对任意一个 Hugging Face 上的开源模型做同样的检查,并且知道这些观测能说明什么、不能说明什么:向量长度不等于重要性,单层消融看到的是冗余,一句提示上的现象不能推广成模型的普遍规律。

实验对象与环境 ​

模型用 Qwen2.5-0.5B-Instruct(Apache 2.0 许可,约 4.9 亿参数),和训练系列的 LoRA 实践是同一个模型。它的结构和 Llama-3.1-8B 是同一套现代配方(Pre-Norm、RMSNorm、SwiGLU、RoPE、GQA),只是更窄更浅,并且有两处不同:Q、K、V 投影带偏置(bias),词嵌入和输出层共享同一个矩阵。

字段Qwen2.5-0.5B-InstructLlama-3.1-8B
层数2432
隐藏维度8964096
Q 头 / KV 头14 / 2(每头 64 维)32 / 8(每头 128 维)
FFN 中间维度486414336
词表151,936128,256
词嵌入与输出层共享不共享
rope_theta1,000,000500,000

本次实测环境:macOS 26.6,Apple M4(24 GB 统一内存),Python 3.12.14,torch 2.14.1,transformers 5.21.0。为了让数值比较干净,模型用 FP32 加载在 CPU 上运行,并指定 attn_implementation="eager",因为只有朴素实现才会返回注意力权重;一次前向约 0.4 秒,整个脚本含 48 次消融约一分钟。

bash
mkdir arch-demo && cd arch-demo
uv venv -p 3.12 .venv && source .venv/bin/activate
uv pip install torch transformers
python dissect.py            # 结果写入 result.json,终端打印摘要

脚本下载:dissect.py。第一次运行会从 Hugging Face 下载约 1 GB 的模型,网络不通时设置 HF_ENDPOINT 指向镜像站,或用 --model 传入本地目录。脚本分 8 步,下面按问题分组看结果。

参数:手算和实际计数完全一致 ​

第一步只读配置,按第 01 篇的方法逐项手算,再遍历 model.named_parameters() 按名字归类计数。Qwen2 比 Llama 多出 Q、K、V 三个偏置向量,手算时要加上:

text
每层注意力 = q 权重 896×896 + q 偏置 896
           + k、v 权重各 896×128 + k、v 偏置各 128
           + o 权重 896×896(无偏置)
           = 802,816 + 896 + 2×(114,688 + 128) + 802,816 = 1,836,160
每层 FFN   = 3 × 896 × 4864 = 13,074,432
每层归一化 = 2 × 896 = 1,792
词嵌入     = 151,936 × 896 = 136,134,656
总计       = 136,134,656 + 24 × (1,836,160 + 13,074,432 + 1,792) + 896(最终 norm)
           = 494,032,768

实际计数也是 494,032,768,一个不差。脚本还检查了 lm_head.weight 和 embed_tokens.weight 指向同一块内存,确认输出层没有单独的参数。如果不共享,模型会多出 1.36 亿参数,总量变成 6.3 亿。

部分参数量占比Llama-3.1-8B 占比
FFN(24 层)313,786,36863.5%70.2%
词嵌入(与输出层共享)136,134,65627.6%13.1%(两者合计)
注意力(24 层)44,067,8408.9%16.7%
归一化43,9040.01%0.003%

两个模型都是 FFN 占大头,这正是第 05 篇讲的那件事。差别在词嵌入:词表大小和模型宽度无关,15 万个词每个都要一行 896 维的向量,小模型的“身体”变小了,“字典”却没变,于是字典占到四分之一以上。这也是小模型普遍共享词嵌入的原因(见第 02 篇)。注意力只占 8.9%,比 Llama 低,是因为 KV 头只有 2 个,K、V 投影各只有 896×128。

输入:对话模板占了大部分 token ​

资料助手的提问用 Qwen 的对话模板拼好以后,一共 25 个 token:

text
 0 <|im_start|>  1 system  2 \n  3 你是  4 公司  5 内部  6 的  7 资料  8 助手  9 。 10 <|im_end|> 11 \n
12 <|im_start|> 13 user  14 \n  15 年  16 假  17 怎么  18 申请  19 ?  20 <|im_end|>  21 \n
22 <|im_start|> 23 assistant  24 \n

问题本身“年假怎么申请?”只有 5 个 token(年、假、怎么、申请、?),其余是系统提示和模板里的角色标记。模型要预测的是第 24 个位置之后的下一个 token,也就是回答的第一个字。

模板还有一个容易忽略的行为:如果不传 system 消息,Qwen2.5 的模板会自动补上一句英文的默认系统提示,其中说自己由阿里云创建。第一次运行时没写 system,模型的回答就以“在阿里云,员工可以……”开头,把默认提示里的公司当成了提问者的公司。这不是架构问题,而是上下文里的每个 token 都会参与注意力计算:模板塞进来的内容,和用户写的内容一样会影响回答。实验后面都用“你是公司内部的资料助手。”作为系统提示。

形状:一个 token 在第 0 层里经过什么 ​

第三步在第 0 层的每个子模块上挂 hook,记录输入输出形状(第一维 1 是批大小):

模块输入输出说明
embed_tokens[1, 25][1, 25, 896]每个 token 编号查出一行向量
input_layernorm[1, 25, 896][1, 25, 896]RMSNorm,形状不变
q_proj[1, 25, 896][1, 25, 896]14 个头 × 64 维
k_proj / v_proj[1, 25, 896][1, 25, 128]只有 2 个 KV 头 × 64 维
o_proj[1, 25, 896][1, 25, 896]拼接各头后投影回隐藏维度
post_attention_layernorm[1, 25, 896][1, 25, 896]FFN 前的 RMSNorm
gate_proj / up_proj[1, 25, 896][1, 25, 4864]升到 FFN 中间维度
down_proj[1, 25, 4864][1, 25, 896]降回隐藏维度
norm(最终)[1, 25, 896][1, 25, 896]24 层之后只做一次
lm_head[1, 25, 896][1, 25, 151936]每个位置对整张词表打分

K、V 的输出只有 128 维,是 Q 的七分之一:14 个查询头分成 2 组,每组 7 个头共用一份 K 和 V,这就是第 04 篇的 GQA。KV Cache 存的正是这两个 128 维向量,所以每个 token 每层只需要缓存 256 个数。FFN 把 896 维升到 4864 维(约 5.4 倍)再降回来,整个过程每个 token 各自独立计算,不看其他位置。

lm_head 的输出是 [1, 25, 151936]:每个位置都会算出整张词表的分数,但生成时只用最后一个位置的那一行。

输出:下一个 token 的概率 ​

最后一个位置的 151,936 个分数经过 softmax 以后,概率最高的 5 个 token 是:

token概率
年26.9%
申请14.1%
您可以6.7%
根据4.0%
请3.3%

前五个加起来只有 55%,剩下的概率分散在十几万个 token 上。用贪心解码(每步取概率最高的)生成 40 个 token,回答是:

text
您可以按照以下步骤申请年假:

1. 登录您的工作平台(如钉钉、微信等)。
2. 在首页找到“我的”或“个人中心”,点击进入。

语句通顺、格式像样,但步骤是编的:模型的参数里没有这家公司的制度,它只是给出“申请年假”这类问题最常见的回答形状。架构决定了模型怎样根据上下文计算下一个 token 的分布,并不保证内容真实;要让它按公司制度回答,要靠检索把条款放进上下文,或者用训练改变它的行为(见训练系列第 09 篇)。采样策略怎样从这个分布里挑 token,见第 08 篇。

残差流:每层往里加了多少 ​

第 06 篇讲过,每一层都不是替换输入,而是把注意力的输出和 FFN 的输出加回残差流。第四步记录最后一个位置在每层里三个向量的长度(L2 范数):注意力的输出、FFN 的输出,以及该层结束后的残差流。

Qwen2.5-0.5B-Instruct 处理“年假怎么申请?”时,最后一个位置在 24 层里每层注意力输出和 FFN 输出的向量长度,以及残差流长度。大多数层里 FFN 写入的量大于注意力,越往后两者和残差流都越大

几件事可以直接从图上看出来。词嵌入出来的向量很短(长度约 0.58),第 0 层 FFN 一次就写进了 3.5,几乎就是第 0 层之后的全部内容。之后残差流的长度一路增长,从第 1 层的 6.4 到第 22 层的 68.9,后几层的增长尤其快。24 层里有 22 层的 FFN 输出比注意力输出长,只有第 1 层和第 21 层反过来。

最后一层是个例外:第 23 层 FFN 写进了长度 73.0 的向量,残差流却从 68.9 缩短到 52.7。两个向量相加后变短,说明 FFN 写进的方向和残差流已有的内容大体相反,它在抵消一部分前面积累的东西,而不是继续往上堆。

这张图量的只是长度。长度大不代表这部分对答案更重要,一个很长的向量可能落在后面的层和输出层都不关心的方向上。要判断重要性,得看去掉它以后输出怎样变化,也就是后面的消融实验。

注意力:最后一个位置看向哪里 ​

第六步取 5 个层,看最后一个位置(第 24 个 token,assistant 后面的换行)的注意力权重,14 个头取平均:

层权重最高的 4 个位置第 0 个 token 的权重问题 5 个 token 的权重合计
0\n(24) 0.14,assistant(23) 0.135,的(6) 0.092,\n(21) 0.0760.0080.102
5<|im_start|>(0) 0.387,\n(24) 0.163,。(9) 0.058,你是(3) 0.0510.3870.078
11<|im_start|>(0) 0.701,?(19) 0.059,怎么(17) 0.042,\n(21) 0.0310.7010.138
17<|im_start|>(0) 0.717,\n(24) 0.045,申请(18) 0.042,假(16) 0.0370.7170.148
23\n(24) 0.303,\n(14) 0.136,你是(3) 0.088,申请(18) 0.0860.0080.224

最显眼的是中间层:第 11、17 层把七成以上的注意力放在第 0 个 token <|im_start|> 上,而这个 token 本身没有任何语义。这就是注意力汇聚(attention sink):softmax 要求权重加起来等于 1,一个头在“当前不需要从上下文取信息”时,也得把权重放在某个地方,训练出来的模型习惯放在序列开头的固定 token 上,那个位置的 V 向量通常很小,取过来几乎不改变结果。第 11 篇会讲到,滑动窗口注意力如果把开头这几个 token 挤出窗口,模型的输出会明显变差,gpt-oss 等模型干脆给每个头加一个可学习的“汇聚”位置。

第 0 层主要看紧挨着的几个模板 token,像是在确认“现在轮到助手说话”;最后一层分给问题本身的权重最高(0.224),“申请”在第 17、23 层进了前四。这些是 14 个头平均后的结果,单个头的分工会更极端,也更难解读,第 04 篇讨论过头的分工研究和它的证据边界。

logit lens:中间层“此时想说什么” ​

输出层只接在最后一层后面,但所有层的残差流都在同一个 896 维空间里。把中间某一层的残差流直接送进最终 norm 和输出层,可以粗略看到“如果模型在这一层就停下,它会说什么”,这个方法叫 logit lens。第五步对词嵌入输出和 24 层各自的输出共 25 处各做一次,记录前三个 token 和最终答案“年”在这里的概率。下表按“已经过几层”计数,0 是词嵌入,24 是走完全部层(即最终输出);层号仍从 0 数起,所以“经过 22 层”就是第 21 层之后:

已经过几层前三个 token“年”的概率
0(词嵌入)\n、\n\n、,0.0%
1—14代码注释符号,如 /*****…、//----…、")));0.0%
15一般来说、(乱码)、抱歉0.0%
17具体、你可以、以下是0.0%
21Natürlich、你可以、您可以0.01%
22年、每年、你可以12.5%
23年、每年、你可以59.5%
24(最终输出)年、申请、您可以26.9%

经过 14 层以内时读出来的几乎都是代码里的分隔符号,这不说明模型在“想代码”,而是这些层的残差流还不在输出层能直接读懂的方向上,logit lens 对浅层基本无效(有研究为每一层单独训练一个小的读出层来改善,叫 tuned lens)。经过 15 层后开始出现“一般来说”“具体”“你可以”这类回答开头常用的词(经过 16、18、19 层时又退回代码符号,表中未列),经过 22 层时“年”第一次排到第一。

最后一步的变化值得注意:经过 23 层(第 22 层之后)“年”的概率是 59.5%,再经过最后的第 23 层反而降到 26.9%,“申请”升到第二。结合上一节残差流在最后一层变短的现象,最后一层像是在把过于集中的预测摊开一些。这只是一句提示上的观测,不能据此说“最后一层负责校准”。

消融:去掉一层会怎样 ​

前面的观测都是“看”,消融是“动手”:把某一层注意力或 FFN 的输出直接置零(相当于这个模块不存在,残差流原样通过),然后测一句参考回答的交叉熵损失。参考回答是“年假申请需要提前在系统中提交请假单,经直属上级审批后生效。”,完整模型在它上面的损失是 2.148。24 层各去掉一次注意力、一次 FFN,共 48 次;再一次去掉第 4—19 层的全部 FFN 或全部注意力。

把 Qwen2.5-0.5B-Instruct 某一层的注意力或 FFN 输出置零,测一句参考回答的交叉熵损失。去掉第 0 层 FFN 损失从 2.15 升到 13.97,第 0、1 层注意力和第 2 层 FFN 也影响很大;中间各层单独去掉几乎不变,但一次去掉 4—19 层的全部 FFN 或全部注意力,损失升到 9.36 和 8.19

去掉什么损失下一个 token
不去掉2.148年
第 0 层 FFN13.970 for
第 0 层注意力8.950空格
第 1 层注意力6.178年
第 2 层 FFN5.593你是
第 4—19 层中任意一层(单独)1.991—2.529多数仍是“年”
第 4—19 层全部 FFN9.362年,但后续输出是乱码
第 4—19 层全部注意力8.190基于,后续中英混杂、不成句

前三层是整个模型最脆弱的地方。去掉第 0 层 FFN,损失从 2.15 升到 13.97,比随机猜一个 token 的损失(ln 151936 ≈ 11.9)还高,模型等于彻底失效。前面看到,词嵌入出来的向量很短,第 0 层 FFN 写进的内容几乎就是后面所有层看到的输入,它把查表得到的词向量加工成后续层能用的表示;没有它,后面 23 层收到的都是它们没见过的东西。第 0、1 层注意力也很关键,它们最早把相邻 token 和模板位置的信息搬到一起。

中间各层单独去掉几乎没有影响,有几层去掉后损失甚至略降(第 8 层注意力去掉后 1.991),这是一句参考回答上的波动,不说明那一层有害。但这不等于中间层没用:一次去掉 16 层的 FFN,损失升到 9.36,生成的是乱码;去掉 16 层的注意力,损失 8.19,输出变成中英混杂的碎片。残差结构让每一层只做小幅修改,相邻层的功能有大量重叠,去掉一层,其他层能补上;去掉一片,就补不上了。这也解释了为什么有一类压缩方法直接删掉模型中间的若干层再做少量微调,代价比想象的小,而删掉开头几层不行。

置零是最粗暴的消融方式。它把模块输出变成一个模型训练时从没见过的值,前几层损失暴涨,一部分原因可能是这种“分布外”的输入,而不完全是那一层承担的功能。更细的做法是用该模块在大量文本上的平均输出替换(mean ablation),或者只替换某个方向上的分量。

这些观测能说明什么 ​

这个实验在一个 5 亿参数的模型、一句提示、一句参考回答上做,所有结论都要按这个范围理解。能比较确定地说的是:参数分布和手算一致,FFN 占了大头,小模型的词嵌入占比显著更高;GQA 让 K、V 投影只有 Q 的七分之一;对话模板会把大量不属于用户问题的 token 放进上下文并影响回答;注意力汇聚在这个模型的中间层非常明显;前几层对整个模型至关重要,中间层单独去掉几乎无影响、成片去掉则失效。

不能说明的是:某一层“负责”某种功能,向量越长越重要,中间层可以随便删。换一句提示、换一个模型,具体数字会变。想确认某个现象是否普遍,至少要在几百条不同的提示上重复,并换几个模型对照。

脚本对其他使用 Llama 式结构的模型(Qwen3、Llama 3.2 等)基本通用,参数分组按模块名匹配,手算部分要按各自的配置修改,比如 Qwen3 去掉了 QKV 偏置、加了 QK-Norm。可以试试几个方向:把问题换成英文,看注意力汇聚是否仍然出现;换成 Qwen3-0.6B,看前几层是否同样脆弱;把消融改成只去掉某几个注意力头,看哪些头去掉后损失变化最大。

到这里,一个 decoder-only 模型从输入到输出的每个部件都看过了,也在真实模型上验证过。下一部分转向架构本身的演进:第 09 篇回到 2017 年的原始 Transformer,看编码器、解码器、编码器-解码器三种结构各适合什么,以及为什么今天的大模型几乎都只保留了解码器。

别急,先让缓存热一下。