AI

从零训练LLM

2026年2月26日

你希望用最简洁的方式理解多头注意力(Multi-Head Attention) 的核心机制(你提到的“多头训练机制”核心就是多头注意力),我用「生活化比喻+3句话核心总结」帮你快速抓住本质:

最通俗的比喻

把多头注意力想象成:

你读一句话时,同时用「语法视角」「语义视角」「情感视角」去理解每个词和其他词的关联——每个“视角”就是一个「注意力头」,最后把所有视角的理解汇总,得到更全面的上下文关系。

3句话核心总结(极简版)

  1. 拆分:把模型对文本的“理解维度”拆成多个独立的小维度(多头),每个头专注捕捉一种类型的关联(比如谁指代谁、动作和对象的关系);
  2. 并行计算:每个头独立计算注意力(即“每个词该关注哪些词”),避免单一视角的片面性;
  3. 合并:把所有头的计算结果拼接、整合,得到更全面的上下文特征,让模型理解更精准。

补充1个关键目的(新手必懂)

单头注意力只能捕捉一种模式的关联,多头相当于“多维度观察”——比如同样是“他”这个词,头1可能关注“小明”(指代关系),头2可能关注“跑步”(动作关系),合并后模型才知道“他”是“小明在跑步”的“小明”,而不是其他词。

最终极简口诀

分头看、各关注、合起来、理解全

你希望用最通俗、最简单的方式理解QKV(查询/键/值)的核心公式和训练逻辑,我会彻底抛开复杂的数学符号,用「生活化比喻+3步核心公式+一句话训练逻辑」讲清楚,新手也能秒懂。

先铺垫:QKV的生活化比喻(先懂逻辑,再看公式)

把QKV想象成查字典

  • Q(查询Query):你现在想查的“问题”(比如文本中当前的词:“他”);
  • K(键Key):字典里所有词条的“标题”(比如文本中所有其他词:“小明、跑步、公园”);
  • V(值Value):字典里每个词条的“解释内容”(比如每个词的语义特征);
  • 注意力的核心:用“问题(Q)”匹配“标题(K)”,找到最相关的“解释(V)”。

QKV核心公式(极简版,去掉复杂符号)

整个过程就3步,每步公式只保留核心逻辑:

第一步:生成Q/K/V(训练时学出来的)

Q = 输入 × Wq  
K = 输入 × Wk  
V = 输入 × Wv  
  • 人话解释:
    Wq/Wk/Wv是模型训练中要学的参数矩阵(相当于“翻译规则”),把原始的文本向量(输入),分别翻译成“查询向量(Q)”“键向量(K)”“值向量(V)”——这一步是训练的核心:模型通过数据学出“怎么把文字转成能匹配的Q/K/V”。

第二步:算匹配度(注意力分数)

匹配度 = (Q × K的转置) ÷ √d_k  
  • 人话解释:
    用“查询(Q)”和所有“键(K)”做乘法,算出Q和每个K的“相似度”(值越高越相关);除以√d_k是为了“降温”,避免数值太大导致训练不稳定(新手记“做除法是为了训练更稳”就行)。√d_k 就是注意力头的维度(d_k)的平方根,计算时直接用 math.sqrt(d_k) 即可,核心作用是「缩放注意力分数,避免数值过大导致训练不稳定」。

第三步:拿结果(加权求和V)

最终结果 = (匹配度归一化) × V  
  • 人话解释:
    先把“匹配度”做softmax(归一化,让所有匹配度加起来=1,变成“注意力权重”),再用这个权重去乘对应的V——相当于“越相关的K,它对应的V占比越高”,最终拿到Q想要的信息。

QKV的训练逻辑(一句话总结)

训练的核心就是学出Wq/Wk/Wv这三个矩阵:让模型能把文字转成“合适的Q/K/V”,使得“Q和K的匹配度”能精准对应“文本中词的真实关联”(比如让“他”的Q能匹配到“小明”的K),最终让V的加权结果能正确表达上下文。

最终极简口诀(记这个就够了)

学规则(Wq/Wk/Wv)→ 算匹配(Q×K)→ 拿答案(加权V)

补充:新手不用纠结的细节

  • 不用背复杂的张量维度(比如[batch, seq_len, d_model]),知道“Q/K/V都是向量,能做乘法”就行;
  • 训练时模型只需要调整Wq/Wk/Wv的数值,让最终结果和“正确的上下文特征”越接近越好(损失越小越好);
  • 多头注意力只是把这三步拆成多个头并行做,核心公式完全一样。 动态展示llm过程
import torch
import torch.nn as nn
import torch.optim as optim
import random
from collections import defaultdict
import math

# ===================== 1. 数据准备(大模型预训练的基础:高质量语料) =====================
# 【核心】小型中文语料库(模拟大模型的万亿级语料,这里简化为10条短句)
# 大模型预训练的核心是"海量、高质量、多样化"的文本,这里只是最小化示例
corpus = [
    "今天天气很好",
    "我喜欢学习编程",
    "人工智能改变世界",
    "深度学习很有趣",
    "PyTorch是好用的框架",
    "大语言模型能生成文本",
    "早餐吃了包子和豆浆",
    "周末去公园散步",
    "读书可以增长见识",
    "运动对身体有好处"
]

# 【核心】构建词汇表(Tokenization:大模型的第一步,把文字转成数字)
# defaultdict自动为新字符分配唯一ID,是最简单的分词方式(大模型用BPE/WordPiece,原理类似)
vocab = defaultdict(lambda: len(vocab))
vocab["<PAD>"] = 0  # 填充符:用于把不同长度的句子补到相同长度
vocab["<UNK>"] = 1  # 未知词:处理词汇表中没有的字符
vocab["<BOS>"] = 2  # 句子开始符:标记句子的开头
vocab["<EOS>"] = 3  # 句子结束符:标记句子的结尾

# 【核心】文本转token索引(大模型训练的基础:所有输入必须是数字)
# max_len:限制句子最大长度(大模型如GPT-4是8k/32k,这里简化为10)
def text2ids(text, vocab, max_len=10):
    # 按字分词(大模型用更复杂的分词器,如Tiktoken,本质都是拆分为最小单位)
    tokens = list(text)
    # 给句子加开始/结束符(大模型训练必须的,标记句子边界)
    ids = [vocab["<BOS>"]] + [vocab[token] for token in tokens[:max_len-2]] + [vocab["<EOS>"]]
    # 填充到固定长度(大模型训练要求批次内数据长度一致)
    while len(ids) < max_len:
        ids.append(vocab["<PAD>"])
    # 转为PyTorch张量(模型只能处理张量,不能直接处理列表)
    return torch.tensor(ids, dtype=torch.long)

# 【核心】构建自回归训练数据(大模型预训练的核心目标:预测下一个token)
# 自回归:用前n个token预测第n+1个token,是GPT/LLaMA等模型的核心训练方式
max_seq_len = 10  # 句子最大长度
train_data = []
for text in corpus:
    # 把文本转成数字索引
    ids = text2ids(text, vocab, max_seq_len)
    # 输入:去掉最后一个token(用前n-1个token做输入)
    input_ids = ids[:-1]
    # 目标:去掉第一个token(要预测的就是这n-1个token的下一个token)
    # 例如:输入[<BOS>,今,天] → 目标[今,天,<EOS>]
    target_ids = ids[1:]
    train_data.append((input_ids, target_ids))

# ===================== 2. 实现Transformer Decoder(大模型的核心架构) =====================
class MultiHeadAttention(nn.Module):
    """【核心】多头自注意力(大模型能理解上下文的关键)
    作用:让模型在预测每个token时,关注输入序列中相关的token(如"他"指代"小明")
    n_heads:注意力头数,越多能捕捉越多维度的语义(大模型用16/32头,这里简化为4)
    """
    def __init__(self, d_model, n_heads):
        super().__init__()
        # 确保模型维度能被头数整除(每个头分到的维度相等)
        assert d_model % n_heads == 0, "d_model必须能被n_heads整除"

        self.d_model = d_model  # 模型隐藏层维度(大模型用768/4096,这里简化为128)
        self.n_heads = n_heads  # 注意力头数
        self.d_k = d_model // n_heads  # 每个头的维度

        # 【核心】Q/K/V线性层:把输入映射为查询(Q)、键(K)、值(V)
        # Q:当前token要找什么信息;K:其他token提供什么信息;V:其他token的实际内容
        self.w_q = nn.Linear(d_model, d_model)
        self.w_k = nn.Linear(d_model, d_model)
        self.w_v = nn.Linear(d_model, d_model)
        # 输出线性层:把多头注意力结果合并
        self.w_o = nn.Linear(d_model, d_model)

    def split_heads(self, x):
        """【核心】拆分多头:把维度从 [批次, 序列长度, 模型维度] 拆为 [批次, 头数, 序列长度, 单头维度]
        作用:让每个头独立计算注意力,捕捉不同类型的关联(如语法/语义)
        """
        batch_size = x.size(0)
        return x.view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)

    def combine_heads(self, x):
        """【核心】合并多头:把多头结果还原为 [批次, 序列长度, 模型维度]"""
        batch_size = x.size(0)
        return x.transpose(1, 2).contiguous().view(batch_size, -1, self.n_heads * self.d_k)

    def forward(self, q, k, v, mask=None):
        # 1. 线性变换:把输入转为Q/K/V
        q = self.split_heads(self.w_q(q))  # [batch, n_heads, seq_len_q, d_k]
        k = self.split_heads(self.w_k(k))  # [batch, n_heads, seq_len_k, d_k]
        v = self.split_heads(self.w_v(v))  # [batch, n_heads, seq_len_v, d_k]

        # 2. 计算注意力分数:Q @ K^T / sqrt(d_k)
        # 分数越高,说明当前token越关注对应位置的token
        scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k)

        # 3. 应用掩码(大模型自回归的核心:禁止看未来token)
        if mask is not None:
            # 把掩码为0的位置设为极小值,softmax后权重接近0,相当于看不到
            scores = scores.masked_fill(mask == 0, -1e9)

        # 4. 注意力权重:softmax归一化,让分数之和为1
        attn_weights = torch.softmax(scores, dim=-1)

        # 5. 加权求和:用注意力权重乘以V,得到最终的注意力输出
        output = self.combine_heads(torch.matmul(attn_weights, v))
        output = self.w_o(output)

        return output, attn_weights

class FeedForward(nn.Module):
    """【核心】前馈网络(FFN):对注意力输出做非线性变换,增强模型表达能力
    大模型的FFN通常是 模型维度→4*模型维度→模型维度,这里简化为 128→256→128
    """
    def __init__(self, d_model, d_ff):
        super().__init__()
        self.linear1 = nn.Linear(d_model, d_ff)  # 升维
        self.linear2 = nn.Linear(d_ff, d_model)  # 降维
        self.relu = nn.ReLU()  # 非线性激活(大模型常用GELU,效果更好)

    def forward(self, x):
        return self.linear2(self.relu(self.linear1(x)))

class DecoderLayer(nn.Module):
    """【核心】单个Transformer Decoder层(大模型由数十/数百层堆叠而成)
    结构:自注意力 + 残差连接 + 层归一化 → 前馈网络 + 残差连接 + 层归一化
    残差连接:防止梯度消失,让深层模型能训练
    层归一化:稳定训练过程,加速收敛
    """
    def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
        super().__init__()
        self.self_attn = MultiHeadAttention(d_model, n_heads)  # 自注意力层
        self.ffn = FeedForward(d_model, d_ff)  # 前馈网络

        # 层归一化(大模型训练的关键,必须放在残差连接后)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)

        self.dropout = nn.Dropout(dropout)  # 防止过拟合

    def forward(self, x, mask):
        # 自注意力 + 残差连接 + 层归一化
        attn_output, _ = self.self_attn(x, x, x, mask)  # Decoder只用自注意力
        x = self.norm1(x + self.dropout(attn_output))  # 残差连接:输入+输出

        # 前馈网络 + 残差连接 + 层归一化
        ffn_output = self.ffn(x)
        x = self.norm2(x + self.dropout(ffn_output))

        return x

class MiniLLM(nn.Module):
    """【核心】极简版LLM(Transformer Decoder-only架构,和GPT/LLaMA完全一致)
    大模型就是这个结构的"放大版":更多层、更大维度、更多头
    """
    def __init__(self, vocab_size, d_model=128, n_heads=4, n_layers=2, d_ff=256, max_seq_len=10):
        super().__init__()
        self.d_model = d_model
        self.max_seq_len = max_seq_len

        # 1. 词嵌入层:把数字token转为向量(大模型的嵌入层是可学习的,维度=d_model)
        self.embedding = nn.Embedding(vocab_size, d_model)
        # 2. 位置嵌入层:给每个位置加唯一向量(大模型用正弦位置编码,这里用可学习的)
        # 作用:让模型知道token的位置(Transformer本身没有顺序信息)
        self.pos_embedding = nn.Embedding(max_seq_len, d_model)
        self.dropout = nn.Dropout(0.1)
        # 3. Decoder层堆叠(大模型用24/32/70层,这里只用2层)
        self.decoder_layers = nn.ModuleList([
            DecoderLayer(d_model, n_heads, d_ff) for _ in range(n_layers)
        ])
        # 4. 输出层:把模型输出映射到词汇表大小,预测下一个token的概率
        self.fc_out = nn.Linear(d_model, vocab_size)

    def create_mask(self, input_ids):
        """【核心】创建掩码(大模型自回归的关键)
        掩码包含两部分:
        1. 未来掩码:禁止模型看当前token之后的token(下三角矩阵)
        2. Padding掩码:忽略填充符<PAD>,避免无效计算
        """
        batch_size, seq_len = input_ids.size()

        # 1. 未来掩码:下三角矩阵,布尔类型(True=可见,False=不可见)
        future_mask = torch.tril(torch.ones((seq_len, seq_len), dtype=torch.bool)).to(input_ids.device)
        # 2. Padding掩码:标记非PAD的位置为True
        pad_mask = (input_ids != vocab["<PAD>"]).unsqueeze(1).unsqueeze(2)
        # 3. 合并掩码:两个掩码都为True的位置才可见
        mask = pad_mask & future_mask.unsqueeze(0).unsqueeze(0)

        return mask

    def forward(self, input_ids):
        """【核心】模型前向传播(训练/推理的核心流程)
        输入:[批次大小, 序列长度] → 输出:[批次大小, 序列长度, 词汇表大小]
        """
        batch_size, seq_len = input_ids.size()

        # 1. 词嵌入 + 位置嵌入(大模型会对嵌入做缩放,乘以sqrt(d_model))
        x = self.embedding(input_ids) * math.sqrt(self.d_model)
        # 生成位置索引:[0,1,2,...,seq_len-1]
        pos_ids = torch.arange(0, seq_len).repeat(batch_size, 1).to(input_ids.device)
        x += self.pos_embedding(pos_ids)  # 词嵌入+位置嵌入
        x = self.dropout(x)

        # 2. 创建掩码
        mask = self.create_mask(input_ids)

        # 3. 经过所有Decoder层
        for layer in self.decoder_layers:
            x = layer(x, mask)

        # 4. 输出层:预测每个位置的下一个token
        logits = self.fc_out(x)  # logits是未归一化的概率

        return logits

    def generate(self, input_text, max_gen_len=5, temperature=0.7):
        """【核心】自回归生成文本(大模型的核心功能:逐token生成)
        流程:输入文本→预测下一个token→拼接→再预测→直到结束
        temperature:温度系数,越高越随机,越低越确定
        """
        self.eval()  # 切换到评估模式(关闭Dropout等训练层)
        with torch.no_grad():  # 关闭梯度计算,节省内存、加速推理
            # 1. 输入文本转token
            input_ids = text2ids(input_text, vocab, self.max_seq_len)[:-1].unsqueeze(0)
            input_ids = input_ids.to(next(self.parameters()).device)

            # 2. 逐token生成
            for _ in range(max_gen_len):
                # 前向传播:预测所有位置的token
                logits = self(input_ids)
                # 只取最后一个token的预测结果(要生成的下一个token)
                next_token_logits = logits[:, -1, :] / temperature
                # 取概率最大的token(大模型会用采样,如top_k/top_p,这里简化为argmax)
                next_token = torch.argmax(torch.softmax(next_token_logits, dim=-1), dim=-1).unsqueeze(1)
                # 拼接新token到输入,作为下一轮的输入
                input_ids = torch.cat([input_ids, next_token], dim=1)

                # 终止条件:生成结束符<EOS>
                if next_token.item() == vocab["<EOS>"]:
                    break

            # 3. 把数字token转回文本
            id2token = {v: k for k, v in vocab.items()}
            gen_ids = input_ids.squeeze(0).tolist()
            # 过滤掉特殊符号,只保留有效文本
            gen_text = "".join([id2token.get(id, "<UNK>") for id in gen_ids if id not in [vocab["<PAD>"], vocab["<BOS>"], vocab["<EOS>"]]])

            return gen_text

# ===================== 3. 手动训练LLM(大模型训练的核心流程) =====================
# 设备配置:优先用GPU(大模型必须用GPU/TPU,CPU训练太慢)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")

# 初始化模型(大模型初始化会用更复杂的策略,如Xavier初始化)
vocab_size = len(vocab)
model = MiniLLM(
    vocab_size=vocab_size,
    d_model=128,       # 模型维度(大模型用768/4096)
    n_heads=4,         # 注意力头数(大模型用16/32)
    n_layers=2,        # Decoder层数(大模型用24/70)
    d_ff=256,          # FFN维度
    max_seq_len=max_seq_len
).to(device)  # 把模型放到指定设备(CPU/GPU)

# 【核心】损失函数:交叉熵损失(自回归训练的标准损失)
# ignore_index:忽略<PAD>的损失,避免填充符影响训练
criterion = nn.CrossEntropyLoss(ignore_index=vocab["<PAD>"])
# 【核心】优化器:AdamW(大模型训练的标准优化器,带权重衰减)
# lr:学习率(大模型会用学习率预热+余弦退火,这里简化为固定值)
# weight_decay:权重衰减,防止过拟合
optimizer = optim.AdamW(model.parameters(), lr=5e-4, weight_decay=1e-5)

# 训练参数(大模型训练会用数万/数十万轮,这里只用200轮)
epochs = 200          # 训练轮数
batch_size = 2        # 批次大小(大模型用超大批次,如百万Token)
loss_history = []     # 记录损失,观察训练是否收敛

# 【核心】训练循环(大模型训练的核心逻辑,只是规模更大)
model.train()  # 切换到训练模式(开启Dropout等层)
for epoch in range(epochs):
    # 打乱数据:避免模型记住数据顺序,提升泛化能力
    random.shuffle(train_data)
    total_loss = 0.0
    num_batches = 0

    # 按批次训练(大模型用分布式训练,多机多卡)
    for i in range(0, len(train_data), batch_size):
        batch = train_data[i:i+batch_size]
        # 把批次数据转为张量,放到指定设备
        input_ids = torch.stack([item[0] for item in batch]).to(device)
        target_ids = torch.stack([item[1] for item in batch]).to(device)

        # 1. 前向传播:输入数据,得到模型预测
        logits = model(input_ids)  # [batch, seq_len, vocab_size]

        # 2. 计算损失:调整维度(展平为二维,符合交叉熵输入要求)
        # logits展平:[batch*seq_len, vocab_size]
        # target_ids展平:[batch*seq_len]
        loss = criterion(logits.reshape(-1, vocab_size), target_ids.reshape(-1))
        total_loss += loss.item()
        num_batches += 1

        # 3. 反向传播:计算梯度(大模型会用梯度累积,解决批次太小问题)
        optimizer.zero_grad()  # 清零梯度(必须,否则梯度会累加)
        loss.backward()        # 反向传播,计算参数梯度

        # 4. 梯度裁剪:防止梯度爆炸(大模型训练的必备操作)
        # max_norm:梯度的最大范数,超过则裁剪
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

        # 5. 更新参数:用优化器根据梯度更新模型参数
        optimizer.step()

    # 计算本轮平均损失
    avg_loss = total_loss / num_batches
    loss_history.append(avg_loss)

    # 每20轮打印一次损失(观察训练是否收敛:损失持续下降说明训练有效)
    if (epoch + 1) % 20 == 0:
        print(f"Epoch [{epoch+1}/{epochs}], Loss: {avg_loss:.4f}")

        # 每40轮测试生成效果(验证模型是否学到东西)
        if (epoch + 1) % 40 == 0:
            test_text = "今天"
            gen_text = model.generate(test_text)
            print(f"生成测试(输入:{test_text})→ 输出:{gen_text}")

# ===================== 4. 最终生成测试(验证模型效果) =====================
print("\n===== 最终生成测试 =====")
test_texts = ["我喜欢", "人工智能", "周末"]
for text in test_texts:
    gen_text = model.generate(text, max_gen_len=6)
    print(f"输入:{text} → 生成:{gen_text}")

# ===================== 5. 保存模型(大模型训练的必备步骤) =====================
# 【核心】保存模型权重+词汇表(只保存权重,不保存完整模型,兼容性更好)
# 大模型会保存checkpoint,包含模型权重、优化器状态、训练步数等
torch.save({
    'model_state_dict': model.cpu().state_dict(),  # 模型权重
    'vocab': dict(vocab),                          # 词汇表(必须保存,否则无法处理新文本)
    'max_seq_len': max_seq_len                     # 序列长度(必须保存,输入要对齐)
}, "mini_llm.pth")
print("\n✅ MiniLLM模型已保存为 mini_llm.pth")