LLM 常见手撕代码
LLM 常见手撕代码
- 来源参考: LLM 面试手撕代码大全
- 以
PyTorch实现
此外,对于 transformer 的理解建议参考: Transformer模型详解(图解最完整版)
零、张量变换与重塑
0.1 教程说明
| 操作 | 作用 | 是否复制内存 | 常见用途 |
|---|---|---|---|
view() |
重塑形状 | 否 | 分头、展平 |
reshape() |
重塑形状 | 必要时是 | 安全重塑 |
transpose() |
交换两维 | 否 | 注意力计算 |
permute() |
重排所有维 | 否 | 复杂维度重排 |
contiguous() |
内存连续化 | 必要时是 | view前确保连续 |
unsqueeze() |
插入维度 | 否 | 广播准备 |
squeeze() |
删除维度 | 否 | 去除冗余维 |
expand() |
扩展维度 | 否 | 只读广播 |
repeat() |
复制扩展 | 是 | 需要独立副本 |
cat() |
拼接 | 视情况 | 合并张量 |
stack() |
堆叠 | 视情况 | 增维合并 |
split() |
按大小分割 | 否 | 分离特征 |
chunk() |
按份数分割 | 否 | 均分张量 |
where() |
条件选择 | 视情况 | 掩码、索引 |
- 优先使用
view()而非reshape()(如果确定张量连续) transpose()后如需view(),必须先contiguous()- 只读扩展用
expand(),需要副本用repeat() - 使用
-1让 PyTorch 自动推断维度大小
1 | import torch |
0.2 完整示例
1 | import torch |
一、注意力机制 Attention
1.1 Scaled Dot-Product Attention
缩放点积注意力机制
- 它的核心功能是计算序列中不同元素之间的相关性,并根据这种相关性重新分配信息。
- 通俗来说,它在做三件事:
- 打分(Scores):询问“
Query(我想要什么)”和“Key(你有什么)”之间匹配度有多高? - 归一化(Softmax):将匹配度转换成概率(权重),总和为 1。
- 加权融合(Sum):根据权重,从“
Value(信息内容)”中提取出最相关的部分。
- 为什么代码中要“缩放(Scaled)”?
- 即代码中的
/ math.sqrt(head_dim)。这是为了防止点积结果过大。如果数值过大,经过 Softmax 后会进入梯度的饱和区,导致梯度消失,模型就学不动了。
- 为什么 Mask 放在计算完 Scores 后?
- 核心原因:为了在进行 Softmax 归一化之前,彻底“封死”不该看的信息。
- 逻辑顺序:
- Scores ():算出每个词对其他词的原始“亲密度”。
- Mask:把不该看的位置手动强行抹除(设为 )。
- Softmax:数学上,。这样归一化后,非法位置的权重就变成了严格的 。
- 可以放在别的地方吗?
- 放在投影前? 不行。投影是在提取特征,此时还没算词与词的关系,你不知道该遮住谁。
- 放在 Softmax 后? 理论上可以(手动把某些权重置 0),但会带来数学偏差。如果你在 Softmax 后置 0,剩下位置的权重和就不再是 了,这会破坏概率分布的特性,导致训练不稳定。
- 为什么 Dropout 放在 attn_weights 后?
- 它的作用:Dropout 会随机让某些注意力权重变成 。为了增加注意力的“鲁棒性(Robustness)”。
- 为什么要这么做?
- 防止模型过于依赖某一个特定的词。比如翻译“苹果”时,模型可能 90% 的注意力都盯着“Apple”。
- Dropout 强迫模型:“如果我不让你看 Apple,你能不能通过上下文的其他词(如“红色的”、“多汁的”)也推断出结果?”
- 可以放在别的地方吗?
- 放在 矩阵后:这是很常见的做法。很多实现会在
context = attn_weights @ v之后再加一个Dropout。- 放在 投影后:也可以,那是为了防止过拟合特征。
- 结论:在 attn_weights 上做 Dropout 是 Transformer 论文的原生做法,目的是对“联系”进行随机阻断,而不是对“特征”进行随机阻断。
1 | import torch |
1.2 Multi-Head Attention
多头注意力机制
- 前面是单次“缩放点积注意力”,多头注意力(Multi-Head Attention, MHA) 就是在这个基础上进行的“并行化升级”。
- 核心功能是 “分而治之”,具体体现在以下两点:
- 捕捉多维度关系:在自然语言中,一个词可能有多重身份。单头注意力很难同时关注这些不同的维度,而多头注意力可以。
- 增强模型稳定性:通过并行计算并拼接(Concatenate),模型可以综合多个头的意见,类似于集成学习(Ensemble Learning),减少了对单一注意力权重的依赖。
疑问:
- 为什么简单的线性层就能提取信息?
- 投影的本质是“特征重组”:线性层其实是在对原始维度做加权组合。
- 是静态的知识,而 是动态的结果。
- 参数是怎么训练出来的?
- 初始化、前向传播、计算损失、反向传播,经过数万亿次训练, 终于学会了:“哦,当我看到名词时,我产生的 应该去寻找它的修饰词”。
- Wq 是怎么“分头”的?
- 这是一个非常重要的工程实现细节。在代码实现中,我们通常不会真的定义 num_heads 个小的线性层,而是定义一个大的线性层,然后通过矩阵切分。
- 逻辑如下:
- 假设
model_dim = 512,num_heads = 8,那么head_dim = 64。self.w_q是一个(512, 512)的矩阵。- 当你做完投影
q = self.w_q(x_query)后,得到的q形状是[batch, seq_len, 512]。接下来的view操作将 512 拆成 8 个 64。 的前 64 列:实际上构成了“第 1 个头”的变换矩阵。 的第 65-128 列:构成了“第 2 个头”的变换矩阵…- 大矩阵 被横向切分成了 块。每一块负责把输入 映射到一个特定的子空间(头)。
- 输出映射
w_o在做什么?
- 整合多头的意见:在 MultiHeadAttention 中,我们将一个大的维度(比如 512)拆成了 8 个独立的小头(每个 64 维)。
- 拆分时:每个头独立工作,互不干扰(头 1 在看语法,头 2 在看语义)。
- 拼接后:我们把这 8 个头的结果横向拼在一起,得到了一个 512 维的向量。
问题来了: 拼接后的向量,其前 64 维和后 64 维是完全“孤立”的,它们之间没有发生过任何数学上的交互。w_o(输出投影层)的作用就像是一个总编辑:它把 8 个专家的意见汇总起来,通过矩阵乘法让这 8 个子空间的信息进行二次加权融合。没有 w_o,多头注意力就只是“简单堆砌”,而不是“深度集成”。- 空间的“恢复”, 保持维度一致性:虽然拼接后的维度已经是 512 了,但这个 512 维的空间分布和输入时的 空间可能已经完全不同了。w_o 提供了一个线性变换的机会,让模型能够把注意力机制提取到的特征,重新映射回主干网络所期望的特征空间里。
- 多头注意力全流程工作?
- 如果我们把 MultiHeadAttention 比作一次专家座谈会:
- :给每个专家发一份不同侧重点的资料。
Scaled Dot-Product:专家们各自关起门来写分析报告。Concat(拼接):把 8 份报告装订成一册。- (Output Projection):主编阅读这本手册,提炼出最终的决策摘要,发给下一个部门。
- 如果没有 ,下一个部门拿到的就是 8 份乱七八糟、各说各话的草稿,无法直接使用。
1 | import torch |
1.3 Group Query Attention
分组查询注意力
- 这是对标准多头注意力(MHA)的变体优化。
- 它旨在解决大模型在生成(Inference)时的一个巨大瓶颈:KV Cache(键值缓存)过大导致的内存带宽受限。
- 在 LLM 生成单词时,我们需要把每一层已经计算过的 K 和 V 存下来,这就是 KV Cache。
- MHA(多头)的问题:Q, K, V 的头数一样多。如果模型很大,KV Cache 会占用海量的显存,导致 Batch Size 开不大,推理速度慢。
- MQA(多查询)的极端:让所有 Q 头共享 同一组 K 和 V。虽然省了空间,但模型表达能力下降太厉害,效果变差。
- GQA(分组)的折中:把 Q 分成几组,每一组 Q 共享一对 KV。
- 核心功能:在不显著降低模型效果的前提下,大幅减少显存占用并提升推理速度。
1 | import torch |
1.4 Multi-Latent Attention
多头潜在注意力
- 痛点:KV Cache 的矛盾
- MHA:性能好,但 KV 缓存太大(显存炸了)。
- GQA:缓存小了,但多少还是牺牲了一些模型性能。
- MLA 的黑科技功能:
- 低秩压缩(Compression):通过
kv_down_proj把高维信息压缩到一个很小的latent_dim。在推理生成时,我们只需要缓存这个极小的压缩向量,而不是庞大的原始 KV。 - 解耦 RoPE(Decoupled RoPE):这是 MLA 最天才的设计。传统 RoPE 旋转编码会破坏矩阵分解的特性,导致无法压缩。MLA 把 KV 拆成了 “内容(Content)” 和 “位置(RoPE)” 两部分:
- 内容部分:被狠狠压缩,节省空间。
- 位置部分:不压缩,保证模型对顺序的敏感度。
- 计算时还原(Up-Projection):虽然缓存的是压缩包,但在计算的那一瞬间,通过
kv_up_proj把它瞬间解压还原出来。
- 低秩压缩(Compression):通过
- 核心逻辑:
- KV 的“漏斗形”变换
- 压缩:从
model_dim压到很小的latent_dim - 解压:从
latent_dim还原出多个头的信息
在推理(Inference)阶段,模型只需要把kv_latent存进显存。这比存下所有头的 KV 要小得多。
- 三路分离(Content, RoPE, Value)
k_content & v_content:这些是从压缩包里解压出来的,用于表达“这是什么词”。k_rope:这是专门用来加旋转位置编码的。
- 混合计算
- 最后计算注意力得分时,模型把带有位置信息的向量和内容向量拼在一起。这样既保证了计算效率,又保证了位置感知的精度。
- 应用在哪里:目前仅见于 DeepSeek 系列模型(以及效仿它的自研模型)。
- 完成的工作:
- 大幅降低推理成本:KV 缓存大小降至 MHA 的几十分之一。
- 吞吐量翻倍:因为显存省了,可以在同一张显卡上跑更多的并发(Batch Size 大幅提升)。
- 超长文本:支持极长的上下文,因为 KV Cache 不再是长文本的瓶颈。
- 既然计算时还是要通过
up_proj还原出全维度的 KV,那为什么能省显存?
- 显存消耗主要在存储,而不在那一瞬间的计算。
- 在生成任务中,模型需要把每一层、每一个 Token 的 KV 永久保留。
- MLA 把原本需要存 512 维的东西,变成了只存 128 维(举例)。虽然计算那一刻要变回 512 维,但计算完就扔掉了,不需要长久占用显存。
- 这就像:
- MHA 是把所有快递都拆开,平铺在仓库里。
- MLA 是把快递全部真空压缩叠放。只有当你要看某个快递的那一秒,你才给它充气,看完立马放气存回去。
- 为什么 Q 也需要压缩(
q_down_proj)?KV 压缩是为了省显存(KV Cache),但 Q 又不需要缓存,为什么要多此一举压一遍再解压呢?
- 不是为了省显存,而是为了节省计算量(训练和推理时的算力)
- 低秩特性:如果 KV 是低秩的(即可以用很小的维度表达核心信息),那么 Q 理论上也可以是低秩的。
- 计算对齐:通过让 Q 也经过一个“压缩-解压”的过程,模型可以学习在一个更紧凑的“潜在特征空间”里进行匹配。
- 减少参数量:如果直接从
model_dim(如 512) 映射到num_heads * head_dim(如8 * 128 = 1024),参数量很大。通过先压到 128 再弹回去,中间的参数量会显著减少。
- K 和 V 下采样到共享同一个潜在向量(Latent Vector),上采样才映射到各自向量?
- 是的。K 和 V 在存储(Cache)阶段是合二为一的(共享 kv_latent),只有在计算注意力的一瞬间,才通过 up_proj 临时变回各自的样子。
- K 是为了告诉 Query:“我是什么标签”。
- V 是为了告诉 Query:“我携带什么内容”。
- 虽然它们功能不同,但它们都源自同一个输入 。DeepSeek 团队认为,既然它们都来自同一个 ,那么它们的信息一定是高度冗余的。既然高度冗余,我干脆把 压缩成一个全能的“潜在压缩包”(kv_latent)。这个压缩包既包含了做 Key 的潜力,也包含了做 Value 的信息。
- 最后输出
model_dim可以完全不等于num_heads * head_dim是吗?
- 是的,但在工程实践中,我们为了“效率”和“对齐”,绝大多数模型都强行让它们相等。
- 为什么主流架构(如 GPT, Llama, BERT)都要求相等?
- 在经典的 Transformer 论文中,有一个核心设计原则:残差连接(Residual Connection)。
- 因此,这是维度对齐的硬性要求和计算逻辑的对齐。
q_rope, k_rope = self.rope(q_rope, k_rope)是进行什么操作,会变换维度吗?
- 简单来说:RoPE 是一种“给向量注入位置感”的数学变换,它不会改变维度,但会改变向量指向的方向。
- 传统的编码(如 BERT)是直接把位置向量加到词向量上。而 RoPE 是让向量旋转。
- 想象每个 head_dim 里的元素成对组成一个二维平面上的点:
- 第 1 个词:向量旋转 角度。
- 第 2 个词:向量旋转 角度。
- 第 个词:向量旋转 角度。
- 为什么要旋转?
- 因为旋转有一个神奇的数学特性:两个向量点积的结果,只取决于它们之间的相对角度。
- 这意味着,当 Query 和 Key 进行点积时,模型能自动感知到它们之间相隔了多少个词(相对位置),而不仅仅是它们各自在什么位置(绝对位置)。
- RoPE 是一种**逐元素(Element-wise)**的变换。它在保持向量长度(模长)不变的情况下,通过正弦和余弦函数改变了分量的数值。
- 旋转位置编码(RoPE)是“加”还是“拼”?
- 传统做法(如 Llama/GPT):它们不分 content 和 rope。它们是直接对整个
head_dim进行旋转。你可以理解为把整个特征向量丢进一个旋转矩阵里“搅匀”了。- MLA 的做法:拼接(Concatenate)。
q_content:纯粹的特征,不带位置信息。q_rope:纯粹的位置,不带语义特征(或者是极简特征)。torch.cat:把它们像火车车厢一样接在一起。- 为什么要“拼”在后面,而不是“加”或“全旋”?
- 保护“低秩压缩”的线性特性(最核心原因)
- MLA 为了省显存,把 KV 压缩到了一个很小的 latent_dim。数学冲突:RoPE 旋转是一个非线性的三角函数变换。如果你对整个向量进行旋转,那么压缩矩阵 和解压矩阵 就无法再通过简单的矩阵分解来还原信息了。
- 我只压缩 content 部分。因为这部分不旋转,它保持了纯粹的线性,可以完美地被压缩和解压。而 rope 部分我不压缩,让它独立存在,保证位置信息的绝对精准。
- 解耦:上帝的归上帝,凯撒的归凯撒
- 通过拼接,MLA 实现了**语义(Content)与位置(Location)**的彻底解耦:
q_content @ k_content:计算的是“这两个词的意思匹配吗?”q_rope @ k_rope:计算的是“这两个词的相对距离合适吗?”- 最终 Score:是这两者的求和(因为向量拼接后的点积,等于各部分点积之和)。
- 拼接后的点积数学原理为什么 cat 之后做点积能起作用?
看这个数学等式:
假设 ,( 为内容, 为位置):
这意味着,模型在计算注意力分数时,实际上是在同时考虑两个独立的维度:
- 意思对不对? (Content Match)
- 位置对不对? (Position Match)
1 | import torch |
二、归一化层 Normalization
- BatchNorm (BN):是在一个 Batch 之间算均值。
- LayerNorm (LN):是在每个样本内部(特征维度)算均值。
2.1 LayerNorm
层归一化
- LayerNorm 的作用就是**“控场”**。它确保神经元的输出不会因为层数太深而变得忽大忽小,维持整个系统的稳定性。
- 在深度网络中,数据经过每一层都会发生偏移和缩放。LayerNorm 的存在是为了让每一层输出的特征分布都回到一个“标准状态”(均值为 0,方差为 1),防止梯度爆炸或梯度消失。
- 核心功能可以概括为两步:
- 标准化(Standardization):把这一层所有神经元的输出拿出来,算出平均值(Mean)和标准差(Std),然后把大家强行拉回到同一个起跑线上。
- 重构(Re-scaling & Shifting):利用代码中的 gamma 和 beta。因为单纯的标准化可能会破坏模型已经学到的有用特征,所以我们给模型两个可学习的参数,让它自己决定:“如果全归一化太死板了,我可以稍微偏移一点点。”
- 标准化(0, 1):是为了生存(不让梯度爆炸,让训练能跑通)。
- 仿射变换():是为了进化(让模型学到更复杂的特征关系)。
- 未进行仿射变换(只有标准化)时,还未应用 (缩放)和 (平移),LayerNorm 的输出就是纯粹的标准化结果:
- 均值 () 为 0:所有 个特征值加起来除以 等于 0。
- 方差 () 为 1:这些特征值的离散程度被缩放到单位 1。
此时的状态:这种状态被称为**“白化(Whitening)”。它保证了无论输入数据的量级(Scale)如何(比如有的层输出数值在 100 左右,有的在 0.1 左右),进入下一层时,它们的分布都是统一的。这极大地解决了内部协变量偏移(Internal Covariate Shift)**问题,让学习率可以开得更大,收敛更快。
- 进行仿射变换后,均值和方差的变化一旦乘上 并加上 ,输出的均值和方差就不再是 0 和 1 了。
- 均值的变化:输出的均值会变成 。
- 方差的变化:输出的方差会变成 。
- 数学推导简述:
- 如果 的均值为 0,方差为 1,那么对于 :
- 好不容易归一化成了 0 和 1,为什么又要用 和 把它破坏掉?
这是深度学习中一个极其精妙的设计哲学:“保底”与“自适应”。
- 恢复模型的表达能力(Identity Mapping)
- 如果模型发现“纯归一化”反而让效果变差了(例如某些激活函数在 0 附近是线性的,失去了非线性表达力),那么模型可以通过学习,让 ,从而回到原始分布。仿射变换给了模型一个**“后悔药”**,保证 LayerNorm 层的加入最差也不会让模型变笨。
- 调整激活函数的激活区间
- 很多激活函数(如 Sigmoid 或 Tanh)在 0 附近最敏感(梯度最大),但在远处会饱和(梯度消失)。
- 通过 ,模型可以把特征移动到激活函数最灵敏的区域。
- 通过 ,模型可以控制特征进入非线性区的程度。
- 特征重要性的重分配
- 在一个词向量的 512 维中,并不是每一维都同样重要。
- 可以增大某些重要维度的权重,缩小噪音维度的权重。
- 这本质上是给模型提供了一层逐通道(Per-channel)的缩放控制。
- 为什么要这么变?
- 如果不归一化:如果下一层是一个 ReLU 激活函数,10.0 这个值可能太大了,导致网络对微小的变化不敏感;或者如果是 Sigmoid,10.0 会直接让梯度变成 0(饱和)。
- 标准化后:数据回到了激活函数最敏感的“黄金地带”(0 附近)。
- 仿射变换后:模型通过 和 告诉网络:“虽然大家都要在 0 附近,但我觉得第二维信息比较重要,我要把它拉长一点;第三维信息有点吵,我把它压低一点。”
1 | import torch |
2.2 RMSNorm
均方根层归一化
- RMSNorm 的公式极其简单:
- 它去掉了 LayerNorm 中的两个关键部分:
- 均值中心化(Re-centering):不再减去 μ。
- 偏置项(Additive Bias):不再加上 β。
- 为什么敢这么删?
研究发现,LayerNorm 的成功主要来自于**重缩放(Re-scaling)**带来的不变性,而不是减去均值带来的平移不变性。删掉均值计算后,不仅计算量减少了约 10%-40%,而且实验证明模型效果几乎没有下降,甚至在某些大规模训练中更加稳定。
1 | import torch |
三、前馈网络
3.1 FFN
- FFN的功能
- 知识存储器:研究表明,Transformer 中的大部分“知识”(比如:巴黎是法国的首都)其实是存储在 FFN 的权重里的,而 Attention 更多是负责逻辑路由。
- 非线性变换:Attention 本质上是加权求和(线性操作)。如果没有 FFN 里的 ReLU 激活函数,整个模型无论叠加多少层,在数学上都只是一个巨大的线性矩阵。FFN 赋予了模型处理复杂逻辑的非线性能力。
- 维度跳跃(升维再降维):
- 上投影():把特征从 512 维拉伸到 2048 维。在一个更高维的空间里,特征更容易被分开和处理。
- 下投影():处理完后,再压回 512 维,以便进行残差连接。
- 时代眼泪 ReLU 的局限性:ReLU 在 时输出全为 0,这会导致所谓的“神经元死亡”现象(Dead ReLU)。
1 | import torch |
3.2 SwiGLU
SwiGLU 前馈神经网络
- 对比刚才的标准 FFN,你会发现 SwiGLU 最直观的变化是:中间层多了一个线性分支。
- 标准 FFN (ReLU):
- SwiGLU:它把中间层拆成了两路:
- Gate 路:经过 w_gate 再加上 SiLU 激活函数。
- Up 路:经过 w_up,但不加激活函数。
- 合体:两路数据对应位置相乘(Element-wise Product)。
- 门控机制(Gating Mechanism)
“GLU”代表 Gated Linear Unit。你可以把 gate 分支想象成一个过滤器或开关:- SiLU(w_gate(x)) 算出每个特征的“重要程度”(0 到 1 之间的值)。
- 用这个程度去乘以 w_up(x) 提取的原始特征。
- 意义:模型可以根据当前上下文,动态地决定哪些信息需要加强,哪些信息需要抑制。ReLU 只有“开”或“关”,而 SwiGLU 是“精细化调节”。
- SiLU (Swish) 的平滑性
代码里用的 F.silu(也就是 ):- 它比 ReLU 更平滑。在 附近没有生硬的折点,这有助于梯度在深层网络中更稳定地流动。
- 它在负数区域有一点点“下潜”(小负数),这能保留一些微弱的负向信号,增加模型的容错率。
- 一个有趣的细节:“intermediate_dim 通常是 model_dim 的 8/3 倍”。
这可不是随便写的数字,而是一个**“等效替换”**的数学题:- 标准 FFN:有 2 个线性层()。
- SwiGLU:有 3 个线性层()。
为了让 SwiGLU 的总参数量和标准 FFN 差不多,研究者通常把 intermediate_dim 缩小一点点。 - 标准 FFN 是 。
- SwiGLU 通常设为 (即 8/3 倍)。
- 关于 Sigmoid 和 SwiGLU 的区别:
- Sigmoid:$$\text{Sigmoid}(x) = \frac{1}{1 + e^{-x}}$$
- 它是一个“压制函数”,不管输入多大,输出永远在 (0, 1) 之间。
- 局限性:当 是负数时,它变成 0;当 是正数时,它趋近 1。它像是一个开关,要么开要么关。
- 问题:在深度网络中,Sigmoid 在 很大或很小时梯度几乎为 0,这会导致“梯度消失”。
- SiLU (Swish):$$\text{SiLU}(x) = x \cdot \text{Sigmoid}(x) = \frac{x}{1 + e^{-x}}$$它在 Sigmoid 的基础上乘以了输入本身 。
- 优势:当 为正数时,它几乎是线性的()。当 为负数时,它会有一个轻微的负值下潜,然后再回到 0。
- 意义:这个小小的“下潜”允许模型在负数区域保持一定的梯度,且因为它具有非单调性(先下后上),它能捕捉到比 ReLU 和 Sigmoid 更复杂的特征。
- Sigmoid:$$\text{Sigmoid}(x) = \frac{1}{1 + e^{-x}}$$
1 | import torch |
3.3 Mixture of Experts
混合专家模型
- MoE (Mixture of Experts) 的本质是:让模型在参数量上变成“巨无霸”,但在计算量上保持“小清新”。
- 在标准 Transformer 中,每一层 FFN 都是“全员上阵”。如果你想让模型更聪明,你就得增大 FFN 的维度,但这会让推理速度变慢。
- MoE 的思维跳跃:
- 我准备 100 个专家(FFN),每个专家擅长不同的知识(比如:专家 A 懂代码,专家 B 懂法语)。
- 当一个 Token 进来时(比如“Bonjour”),Router(路由器) 发现这是法语,就只激活专家 B。
- 结果:模型总参数量是 100 倍,但每次计算只耗费 1 个专家的算力。
- 这里有一个隐藏的“大坑”:专家负载均衡
- 在这段纯净的 MoE 代码中,隐藏着一个工业界极其头疼的问题:专家贫富差距。
- 现象:Router 可能会发现某一个专家特别好用,导致所有的 Token 都往它那里跑(比如专家 1 处理了 99% 的 Token),而其他专家都在“带薪休假”。
- 后果:
- 专家 1 所在的显卡会爆显存。
- 其他专家没有得到训练,模型退化成了单专家模型。
- 解决方案:在实际的 DeepSeek 或 Llama 代码中,会加入一个
Auxiliary Loss(辅助损失),强迫 Router 把任务均匀地分配给所有专家。
- 为什么要“遍历专家”而不是“遍历 Token”?
- 为什么代码写
for i, expert in enumerate(self.experts)而不是直接循环每一个词?- GPU 效率: 如果你有 100 万个 Token,循环 100 万次会慢死。
- 批处理 (Batching): 既然 Token A 和 Token C 都选了专家 0,我们把它们拼在一起一次性传给专家 0 的 Linear 层计算,这能利用 GPU 的并行能力。
- 函数声明
index_add_(dim, index, source)
- 含义:带索引的累加。
- 说明:这是一个“原地(In-place)”操作(函数名末尾的下划线 _ 表示会直接修改原张量)。它把 source 中的值,按照 index 指定的索引,累加到 self(即调用它的张量)中。
- 关键点:如果多个索引指向同一个位置,它会将所有源值累加在一起。
- 在 MoE 中的作用:因为很多 Token 可能都选择了同一个专家,它们计算出的结果需要“归位”到最终输出中。index_add_ 完美解决了多对一的映射与累加。
1 | import torch |
四、损失函数
4.1 EntropyLoss
熵损失
Softmax函数
- 标准公式:
将一组得分(Logits)转化为概率分布,所有分量之和为 1。
- 数值稳定公式(代码实现版):
令 ,通过平移避免 溢出。
为什么要减去 max_logits?(数值稳定性的真相)
- 这是面试中出镜率极高的问题。
- 理论公式:
- 现实危机:计算机表达浮点数是有极限的。如果 logits 中有一个数是 ,那么 就会超出单精度浮点数(Float32)的最大范围,变成 inf(无穷大)。一旦出现 inf,整个模型就崩了。
- 解决方案:利用指数函数的性质:。通过减去最大值 ,所有的指数项都会变成 ,结果永远在 之间。再大的数也被“驯服”了。
Log Softmax函数
- 标准公式:
对Softmax结果取自然对数。
- 数值稳定公式(利用 的性质展开):
这种写法称为Log-Sum-Exp技巧。
代码中进一步优化为:。
Log Softmax:为什么要把它拆出来?
- 既然有了
softmax函数,为什么不直接写torch.log(softmax(x))?- 原因:因为
softmax会把某些非常小的概率压成 (由于精度限制)。如果你对 取 log,结果是 。Log-Sum-Exp技巧:$$\log(\frac{e^{x_i}}{\sum e^{x_j}}) = x_i - \log(\sum e^{x_j})$$- 这种写法直接在对数空间操作,避免了先求 再求 的精度损耗。这是大模型训练(如 Llama, DeepSeek)中交叉熵损失的标准写法。
- 交叉熵损失 (
Cross Entropy Loss)
- 标准公式:
衡量真实分布 (通常是 one-hot 向量)与预测分布 之间的差异。
- 单标签分类简化公式(代码实现版):
由于 中只有一个位置是 1(正确类别 ),其余都是 0,公式简化为:
结合 Log Softmax,它直接等于:
- KL 散度 (
Kullback-Leibler Divergence)
- 标准公式:
衡量概率分布 相对 的偏离程度。
- 计算变形公式(代码实现版):
为了计算方便,通常拆分为两个 Log Softmax 的差:
| 函数 | 输入 | 输出 | 关键操作 |
|---|---|---|---|
Softmax |
任意实数 | (0,1) 概率 |
归一化,和为 1 |
LogSoftmax |
任意实数 | (−∞,0] |
解决概率太小时的精度丢失 |
CrossEntropy |
Logits + 标签 | 正数标量 | 只盯着“正确答案”的概率 |
KL Divergence |
两个分布 | 正数标量 | 衡量两个分布有多“像” |
1 | ''' |
以上部分已经完成学习,以下部分对于推荐算法重要性不是特别大,以后学习,暂时把内容先放在这里。
4.2 SFT Loss
1 | """ |
4.3 DPO Loss
1 | """ |
4.4 PPO Loss
1 | """ |
4.5 GRPO Loss
1 | """ |
五、其他
5.1 位置编码 Rotary Position Embedding (RoPE)
1 | """ |
5.2 参数高效微调 LoRA
1 | """ |
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 isSeymour!
评论


