1. 项目概述:从零构建大语言模型的动机与价值

最近几年,大语言模型(LLM)无疑是技术领域最耀眼的明星。从ChatGPT的横空出世,到各类开源模型的百花齐放,它们展现出的理解和生成能力,让无数开发者和研究者为之着迷。然而,对于大多数想要深入理解其内部运作机制的人来说,面对动辄数百亿参数、依赖复杂分布式训练的庞然大物,常常感到无从下手。我们看到的往往是封装好的API、预训练好的权重文件,以及经过高度抽象化的框架调用。模型内部的黑盒,让学习过程充满了神秘感和距离感。

这正是“LLMs-from-scratch”这个项目吸引我的地方。它的核心目标非常纯粹: 引导你,从最基础的数学原理和代码开始,亲手搭建一个能够工作的大语言模型 。这不是一个教你如何调用Hugging Face transformers 库的教程,而是一次“造轮子”的深度之旅。你需要自己实现注意力机制、前馈网络、层归一化,自己编写训练循环,甚至自己处理数据的tokenization。这个过程无疑是艰苦的,但回报也是巨大的。当你看着自己用几百行代码搭建的“微型GPT”,能够根据你给出的几个词,磕磕绊绊地生成一段连贯的文本时,那种对模型每一个神经元如何被激活、每一层变换如何影响输出的透彻理解,是任何高级API教程都无法给予的。

这个项目适合谁?我认为有三类人最能从中受益。第一类是机器学习/深度学习的学生和入门者,你已经有了一些PyTorch或TensorFlow的基础,但对Transformer架构的理解还停留在论文图表层面,急需通过实践将知识内化。第二类是经验丰富的工程师,你可能已经熟练使用BERT或GPT的API解决业务问题,但总感觉对底层原理“心里没底”,遇到复杂bug或需要定制化修改时力不从心,这个项目能帮你补上最关键的一块拼图。第三类是技术好奇者和极客,纯粹享受从无到有构建复杂系统的乐趣,理解智能表象之下的数学与代码之美。如果你属于其中任何一类,那么这次“从零开始”的旅程,将是你技术生涯中一次宝贵的“第一性原理”训练。

2. 核心架构拆解:Transformer的“自底向上”实现路径

要真正理解一个复杂系统,最好的方法就是亲手把它搭建起来。LLMs-from-scratch项目采用的正是这种“自底向上”的构建哲学。我们不依赖任何现成的Transformer模块,而是从最基础的张量操作开始,逐步堆叠出模型的每一层。下面,我就来拆解这条实现路径上的几个关键里程碑。

2.1 基石:嵌入层与位置编码

任何语言模型的第一步,都是将离散的符号(单词、子词)转换为连续的向量表示,这就是嵌入层。在代码中,这通常就是一个 nn.Embedding(vocab_size, d_model) 层。但这里的关键在于理解其维度: vocab_size 是你的词表大小,比如50000; d_model 是模型的隐藏维度,比如768。每个词会被映射成一个768维的向量。

然而,标准的Transformer没有循环或卷积结构,它无法感知序列中词的位置信息。因此,我们必须显式地注入位置信息,这就是位置编码。项目中通常会实现论文中的正弦余弦公式:

PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))

这里 pos 是位置, i 是维度索引。这个函数的设计非常巧妙,它使得模型能够轻松学习到相对位置信息(例如,通过线性变换,模型可以知道“pos+k”位置的信息)。在实现时,我们会预先计算一个位置编码矩阵,其形状为 [max_seq_len, d_model] ,然后将其加到词嵌入向量上。一个常见的坑是忘记在训练时停止位置编码张量的梯度传播,因为它不是可学习参数。

注意 :有些现代模型(如GPT-2)使用可学习的位置嵌入,这更简单,但可能牺牲了一定的外推能力(即处理比训练时更长的序列)。在从零开始的实现中,我建议先实现经典的正余弦版本,以深刻理解其设计初衷。

2.2 核心引擎:自注意力机制的逐行实现

自注意力机制是Transformer的灵魂,也是初学者最容易感到困惑的部分。让我们抛开复杂的矩阵图示,用最直白的语言和代码来理解它。

假设我们有一个序列的嵌入表示 X ,形状为 [batch_size, seq_len, d_model] 。自注意力的目标是:让序列中的每个词,都能“关注”到序列中所有其他的词(包括它自己),并根据相关性聚合信息。

第一步:计算Q, K, V。 这是通过三个不同的线性层(权重矩阵)对输入 X 进行投影得到的。

# 假设 d_model=768, 但注意力头通常使用更小的维度,如 d_head=64
# 这里为了简化,先不考虑多头,假设我们做单头注意力
W_q = nn.Linear(d_model, d_model) # 实际中,Q、K、V的维度通常等于 d_model
W_k = nn.Linear(d_model, d_model)
W_v = nn.Linear(d_model, d_model)

Q = W_q(X) # [batch, seq, d_model]
K = W_k(X) # [batch, seq, d_model]
V = W_v(X) # [batch, seq, d_model]

你可以把 Q (Query) 理解为当前词发出的“询问”:我想知道些什么? K (Key) 是每个词提供的“标签”:我有什么信息? V (Value) 是每个词真正的“内容”:我的信息具体是什么。

第二步:计算注意力分数。 相关性通过Query和Key的点积来衡量。

# 缩放点积注意力:点积后除以 sqrt(d_k),防止梯度消失
d_k = K.size(-1)
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k) # [batch, seq, seq]

现在 scores 矩阵的第 i 行第 j 列,就表示第 i 个词对第 j 个词的关注程度。

第三步:应用注意力掩码与Softmax。 对于语言模型,我们通常使用“因果掩码”(Causal Mask),防止当前位置看到未来的信息。这通过一个下三角矩阵(主对角线及以下为0,以上为负无穷)实现。

mask = torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0).unsqueeze(0) # [1,1,seq,seq]
scores = scores.masked_fill(mask == 0, float('-inf'))
attn_weights = F.softmax(scores, dim=-1) # [batch, seq, seq]

经过Softmax,每行的权重之和为1,代表了注意力在不同词上的概率分布。

第四步:加权求和。 用注意力权重对Value进行加权,得到每个词新的表示。

context = torch.matmul(attn_weights, V) # [batch, seq, d_model]

至此, context 就是经过自注意力机制聚合了全局上下文信息后的新序列表示。

多头注意力 无非是将上述过程并行执行多次(例如12个头),每个头使用不同的 W_q, W_k, W_v 投影到更小的维度( d_head = d_model / num_heads ),最后将多个头的输出拼接起来,再通过一个线性层 W_o 融合。这允许模型同时关注来自不同表示子空间的信息。

2.3 前馈网络与残差连接:稳定训练的保障

自注意力层的输出会送入一个前馈网络,这是一个简单的两层全连接网络,中间通常有一个激活函数(如GELU)。

# 通常,中间层的维度会扩大,例如 d_ff = 4 * d_model
self.net = nn.Sequential(
    nn.Linear(d_model, d_ff),
    nn.GELU(), # 比ReLU更平滑,在Transformer中效果更好
    nn.Linear(d_ff, d_model)
)

为什么需要这个FFN?自注意力层擅长聚合信息,但缺乏进行复杂非线性变换的能力。FFN为每个位置独立地提供了这种“处理”已聚合信息的能力。

然而,直接堆叠这么多层,模型会面临梯度消失或爆炸的严峻挑战。Transformer的成功,很大程度上归功于 残差连接 层归一化 。每个子层(自注意力、FFN)的输出都是 LayerNorm(x + Sublayer(x)) 。残差连接让梯度可以直接回流,极大地缓解了深度网络训练难题。层归一化则对每个样本的所有特征进行归一化,稳定了激活值的分布。

在实现时,顺序至关重要。原始论文使用的是“Post-LN”(层归一化在残差相加之后),但后来研究发现“Pre-LN”(层归一化在子层输入之前)能让训练更稳定。在从零开始的实现中,我强烈建议使用Pre-LN结构:

# Pre-LN 结构示例(对于FFN层)
def forward(self, x):
    residual = x
    x = self.norm1(x) # 先做层归一化
    x = self.ffn(x) # 再通过前馈网络
    x = residual + x # 最后残差连接
    return x

3. 从模块到模型:组装一个完整的GPT式Decoder

理解了所有核心部件后,我们就可以像搭乐高一样,将它们组装成一个完整的语言模型。当前主流的大语言模型,如GPT系列、LLaMA等,都属于 Decoder-Only 架构,这也是LLMs-from-scratch项目通常实现的类型。

3.1 构建Transformer Block

一个基础的Transformer Block(或称为Decoder Block)包含以下层:

  1. 多头因果自注意力层(带掩码)
  2. 第一个Add & LayerNorm(如果使用Pre-LN,则LN在注意力层之前)
  3. 前馈网络层
  4. 第二个Add & LayerNorm

在代码中,它的前向传播逻辑清晰:

class TransformerBlock(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
        self.ln1 = nn.LayerNorm(d_model)
        self.attn = MultiHeadAttention(d_model, num_heads, dropout) # 需要自己实现的多头注意力类
        self.dropout1 = nn.Dropout(dropout)

        self.ln2 = nn.LayerNorm(d_model)
        self.ffn = FeedForward(d_model, d_ff, dropout) # 需要自己实现的前馈网络类
        self.dropout2 = nn.Dropout(dropout)

    def forward(self, x):
        # 第一个子层:自注意力 (Pre-LN)
        norm_x = self.ln1(x)
        attn_output = self.attn(norm_x, norm_x, norm_x, causal_mask=True) # 传入因果掩码标志
        x = x + self.dropout1(attn_output)

        # 第二个子层:前馈网络 (Pre-LN)
        norm_x = self.ln2(x)
        ffn_output = self.ffn(norm_x)
        x = x + self.dropout2(ffn_output)
        return x

注意这里的 causal_mask=True ,这指示我们的注意力层在内部生成和应用因果掩码,确保自回归性质。

3.2 组装完整模型

将多个Transformer Block堆叠起来,前面加上嵌入层和位置编码,后面加上一个最终的层归一化和一个线性输出层(将 d_model 维映射回 vocab_size 维),就构成了完整的模型。

class GPT(nn.Module):
    def __init__(self, vocab_size, seq_len, d_model, num_layers, num_heads, d_ff, dropout=0.1):
        super().__init__()
        self.seq_len = seq_len
        self.token_embedding = nn.Embedding(vocab_size, d_model)
        self.pos_embedding = nn.Parameter(torch.zeros(1, seq_len, d_model)) # 这里使用可学习的位置嵌入,更简单

        self.blocks = nn.ModuleList([
            TransformerBlock(d_model, num_heads, d_ff, dropout)
            for _ in range(num_layers)
        ])
        self.ln_f = nn.LayerNorm(d_model)
        self.lm_head = nn.Linear(d_model, vocab_size, bias=False) # 通常不加偏置

        # 权重初始化非常重要!
        self.apply(self._init_weights)

    def _init_weights(self, module):
        if isinstance(module, nn.Linear):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                torch.nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
        # 对于LayerNorm,PyTorch默认初始化已经很好

    def forward(self, idx):
        # idx: [batch, seq]
        batch, seq_len = idx.shape
        assert seq_len <= self.seq_len, “输入序列超过模型最大长度”

        # 获取词嵌入和位置嵌入
        tok_emb = self.token_embedding(idx) # [batch, seq, d_model]
        pos_emb = self.pos_embedding[:, :seq_len, :] # [1, seq, d_model]
        x = tok_emb + pos_emb

        # 依次通过所有Transformer Block
        for block in self.blocks:
            x = block(x)

        # 最终层归一化和语言模型头
        x = self.ln_f(x)
        logits = self.lm_head(x) # [batch, seq, vocab_size]
        return logits

这个 GPT 类已经具备了生成能力。在推理时,我们可以通过循环调用它,每次输入已有的序列,取最后一个位置的输出logits,采样出下一个词,然后拼接到序列后,继续生成。

3.3 参数规模与计算考量

在从零实现时,我们必须对参数规模有清晰的认识。假设我们构建一个“微型GPT”,配置如下:

  • vocab_size : 50257 (GPT-2的词表大小)
  • d_model : 768
  • num_layers : 12
  • num_heads : 12
  • d_ff : 3072 (4 * d_model)

我们可以粗略估算参数量:

  1. 词嵌入层:50257 * 768 ≈ 38.6M
  2. 位置嵌入:1 * 1024 * 768 ≈ 0.8M (假设seq_len=1024)
  3. 每个Transformer Block:
    • 注意力层的Q、K、V投影:3 * (768 * 768) ≈ 1.77M
    • 注意力输出投影:768 * 768 ≈ 0.59M
    • FFN第一层:768 * 3072 ≈ 2.36M
    • FFN第二层:3072 * 768 ≈ 2.36M
    • 4个LayerNorm参数可忽略。
    • 每个Block总计约 7.08M。
  4. 12个Block:12 * 7.08M ≈ 85M
  5. 最后的LayerNorm和lm_head:lm_head 768 * 50257 ≈ 38.6M (通常与词嵌入层权重共享!)

如果词嵌入和lm_head共享权重,总参数量大约在 124M (1.24亿) 左右。这个规模的模型已经无法在个人电脑的CPU上有效训练,但在拥有8GB以上显存的消费级GPU(如RTX 3070/4060)上,通过混合精度训练和梯度累积等技术,进行小规模数据集的训练是可行的。这恰恰是“从零开始”项目的魅力所在——你可以在有限的资源下,验证整个架构和训练流程的正确性。

4. 训练流程实战:数据、损失与循环

模型搭建好了,接下来就是赋予它“智能”的训练过程。训练一个自回归语言模型,本质上是教它根据上文预测下一个词。

4.1 数据准备与Tokenization

首先需要将原始文本(比如你下载的维基百科或小说文本)转化为模型能理解的数字序列。这个过程叫做Tokenization。在从零实现的项目中,为了简化,通常会采用字符级或简单的BPE分词。

一个非常实用的选择是直接集成 tiktoken 库(OpenAI开源的GPT分词器)或 sentencepiece 。这里以 tiktoken 为例:

import tiktoken
enc = tiktoken.get_encoding(“gpt2”) # 使用GPT-2的分词器

def tokenize(text):
    # 返回一个整数列表
    return enc.encode(text)

def batchify(data, batch_size, seq_len):
    # data是一个长的token列表
    num_batches = len(data) // (batch_size * seq_len)
    # 截断数据以恰好形成整数个批次
    data = data[:num_batches * batch_size * seq_len]
    # 重塑为 [num_batches, batch_size, seq_len]
    data_tensor = torch.tensor(data).view(batch_size, -1).t().contiguous()
    # 现在按seq_len长度切分
    batches = []
    for i in range(0, data_tensor.size(0)-1, seq_len):
        batch = data_tensor[i:i+seq_len] # [seq_len, batch_size]
        target = data_tensor[i+1:i+1+seq_len] # 目标是向右移动一位
        batches.append((batch.t(), target.t())) # 转置为 [batch, seq]
    return batches

关键点在于目标的构造:对于输入序列 [x1, x2, ..., xT] ,我们的目标是 [x2, x3, ..., x_{T+1}] 。模型在位置 t 的输出,对应的是预测位置 t+1 的token。

4.2 损失函数与优化器选择

语言模型的标准损失函数是 交叉熵损失 。在PyTorch中,我们可以使用 F.cross_entropy 。需要注意的是输入的形状:

  • 模型输出的 logits 形状为 [batch_size, seq_len, vocab_size]
  • 目标的 targets 形状为 [batch_size, seq_len] ,每个位置是一个token ID。

我们需要将logits和targets重塑为二维和二维:

logits = model(input_ids) # [batch, seq, vocab]
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))

这里 view(-1, vocab_size) 将批次和序列维度展平,计算所有位置上所有样本的平均损失。

对于优化器, AdamW 是训练Transformer的绝对主流。它相比原始Adam引入了权重衰减的正确解耦。学习率调度同样关键,最常用的是带热身的线性衰减或余弦衰减。

optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=0.01)
scheduler = get_cosine_schedule_with_warmup(
    optimizer,
    num_warmup_steps=num_warmup,
    num_training_steps=total_training_steps
)

# 在每个训练步骤后
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # 梯度裁剪,防止爆炸
optimizer.step()
scheduler.step()
optimizer.zero_grad()

梯度裁剪是稳定训练的必要技巧,通常将梯度范数限制在1.0或0.5。

4.3 训练循环与评估

训练循环的骨架如下:

model.train()
for epoch in range(num_epochs):
    for batch_idx, (input_batch, target_batch) in enumerate(train_loader):
        input_batch, target_batch = input_batch.to(device), target_batch.to(device)

        logits = model(input_batch)
        loss = F.cross_entropy(logits.view(-1, vocab_size), target_batch.view(-1))

        optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()
        scheduler.step()

        if batch_idx % 100 == 0:
            # 计算当前批次的困惑度 (Perplexity),是评估语言模型的常用指标
            ppl = torch.exp(loss).item()
            print(f“Epoch {epoch}, Batch {batch_idx}, Loss: {loss.item():.4f}, PPL: {ppl:.2f}”)

每隔一段时间,需要在验证集上评估模型性能,并保存最佳检查点。评估时务必使用 model.eval() torch.no_grad() 上下文管理器以节省内存和计算。

实操心得:混合精度训练 。为了在消费级GPU上训练更大的模型或使用更长的序列,务必启用混合精度训练(AMP)。这能显著减少显存占用并加速计算。在PyTorch中非常简单:

scaler = torch.cuda.amp.GradScaler()
with torch.cuda.amp.autocast():
    logits = model(input_batch)
    loss = ...
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()

这几乎是无成本的性能提升,强烈推荐。

5. 文本生成策略:让模型“开口说话”

训练完成后,最激动人心的环节就是文本生成。给定一个提示(prompt),让模型续写。这不仅仅是调用 model.forward() 那么简单,其中涉及到多种生成策略。

5.1 自回归生成的基本循环

生成过程本质上是迭代的、自回归的:

  1. 将提示文本tokenize,得到初始序列 [p1, p2, ..., pk]
  2. 将序列输入模型,获取最后一个位置的输出logits(对应下一个token的概率分布)。
  3. 根据某种策略(如贪婪采样、随机采样),从该分布中选取一个token t_{k+1}
  4. 将该token追加到序列末尾,形成新的序列 [p1, p2, ..., pk, t_{k+1}]
  5. 重复步骤2-4,直到生成长度达到预设值,或遇到结束符。

基础代码框架如下:

def generate(model, prompt, max_new_tokens, temperature=1.0, top_k=None):
    model.eval()
    tokens = tokenize(prompt)
    for _ in range(max_new_tokens):
        # 只取序列的最后 context_len 个token作为输入(受模型最大长度限制)
        input_seq = tokens[-model.seq_len:]
        input_tensor = torch.tensor(input_seq).unsqueeze(0).to(device) # [1, seq]

        with torch.no_grad():
            logits = model(input_tensor) # [1, seq, vocab]
            # 取最后一个位置的logits
            next_token_logits = logits[0, -1, :] / temperature # [vocab]

        # 应用采样策略(见下文)
        next_token_id = sample_from_logits(next_token_logits, top_k)
        tokens.append(next_token_id)

        # 可选:遇到结束符则停止
        if next_token_id == eos_token_id:
            break

    return detokenize(tokens)

5.2 核心采样策略详解

如何从 next_token_logits 中选取下一个token,决定了生成文本的“创造性”和“连贯性”。

1. 贪婪搜索: 直接选择概率最高的token。

next_token_id = torch.argmax(next_token_logits).item()

优点 :简单、确定性强。 缺点 :容易导致重复、枯燥的文本,因为模型会陷入局部最优循环(如不断重复“的的的”)。

2. 随机采样(带温度控制): 温度参数 temperature 用于控制分布的平滑程度。

  • temperature = 1.0 :使用原始logits。
  • temperature < 1.0 (如0.8):放大高概率token的差异,使输出更确定、更保守。
  • temperature > 1.0 :平滑分布,给低概率token更多机会,输出更随机、更有创意。
probs = F.softmax(next_token_logits, dim=-1)
next_token_id = torch.multinomial(probs, num_samples=1).item()

这是最基础的随机生成方法。

3. Top-k 采样: 只从概率最高的k个token中采样。这避免了从极不可能的token中采样,保证了生成质量。

def sample_top_k(logits, k):
    top_k_values, top_k_indices = torch.topk(logits, k)
    probs = F.softmax(top_k_values, dim=-1)
    next_token_index = torch.multinomial(probs, num_samples=1)
    return top_k_indices[next_token_index].item()

如何选择k?通常根据经验,比如40或50。但它的缺点是固定的k值可能不适用于所有情况:有时概率分布很尖锐(前几个token概率极高),有时很平缓(很多token概率相当)。

4. Top-p(核采样): 这是更动态、更优雅的方法。它设定一个概率累积阈值p(如0.9),然后从最小集合的token中采样,使得这些token的累积概率刚好超过p。

def sample_top_p(logits, p):
    sorted_logits, sorted_indices = torch.sort(logits, descending=True)
    cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
    # 移除累积概率超过p的token
    sorted_indices_to_remove = cumulative_probs > p
    # 确保至少保留一个token
    sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
    sorted_indices_to_remove[..., 0] = 0
    indices_to_remove = sorted_indices[sorted_indices_to_remove]
    filtered_logits = logits.clone()
    filtered_logits[indices_to_remove] = -float(‘Inf’)
    probs = F.softmax(filtered_logits, dim=-1)
    next_token_id = torch.multinomial(probs, num_samples=1).item()
    return next_token_id

Top-p采样能自适应不同的概率分布,是目前大模型生成中最推荐的方法。通常将温度控制、Top-p和Top-k结合使用,例如 temperature=0.8, top_p=0.9

5.3 生成质量评估与调试

生成文本后,如何判断模型的好坏?除了直观阅读,可以计算生成文本在验证集上的困惑度。但更重要的调试手段是观察训练损失曲线和验证损失曲线。理想情况下,两者应该同步平稳下降。如果训练损失下降但验证损失上升,说明过拟合了。如果两者都很高且下降缓慢,可能是模型容量不足、学习率不当或数据有问题。

一个非常实用的技巧是 固定随机种子进行生成 。在调试时,使用固定的提示和随机种子,观察不同训练阶段(如每1000步)模型生成文本的变化。你能清晰地看到模型从输出乱码,到学会单词和简单语法,再到能组织起有一定逻辑的句子的全过程。这是对学习过程最直观的见证。

6. 进阶优化与扩展方向

当你成功运行了一个基础版本的“从零开始LLM”后,可能会不满足于它的性能和功能。这里有几个关键的进阶优化方向,能让你对现代大语言模型有更深入的理解。

6.1 效率优化:KV缓存与注意力优化

在自回归生成中,一个巨大的性能瓶颈是注意力计算。当生成长文本时,每次预测下一个token都需要为整个不断增长的序列计算注意力,复杂度是 O(n^2) KV缓存 是解决这个问题的标准技术。

其原理基于Transformer解码器注意力机制的一个特性:第 t 个时间步的Key和Value只依赖于前 t 个token。因此,我们可以缓存每个时间步每个层的K和V矩阵。

  • 在生成第一个token时,计算并缓存 K1, V1
  • 在生成第二个token时,只需计算当前新token的 Q2, K2, V2 ,然后从缓存中读取 K1, V1 ,将新的 K2, V2 拼接到缓存中,再计算注意力。
  • 如此反复,每次只需计算当前单个token的Q、K、V,注意力计算量从 O(n^2) 降为 O(n)

实现KV缓存需要对模型的前向传播进行修改,增加缓存逻辑,并在生成循环中维护和更新缓存张量。这是生产级推理引擎(如vLLM, Hugging Face的 generate 函数)的核心优化之一。

6.2 现代架构改进:RoPE、SwiGLU与RMSNorm

原始的Transformer论文发表于2017年,后续研究提出了许多改进。

  • RoPE (Rotary Position Embedding) : 取代了绝对位置编码(如正弦余弦或可学习嵌入)。它通过旋转矩阵将位置信息注入到Q、K向量的每一维中,被证明能更好地处理长序列和外推,被LLaMA、GPT-NeoX等模型广泛采用。实现RoPE需要修改注意力计算中Q和K的点积方式。
  • SwiGLU 激活函数 : 取代FFN中的标准GELU/ReLU。其形式为 Swish(xW) * (xV) ,其中Swish是 x * sigmoid(x) 。论文显示它能带来更好的性能,但会略微增加参数(因为多了一个线性层 V )。在实现FFN时可以考虑加入。
  • RMSNorm (Root Mean Square Layer Normalization) : 比LayerNorm更简单,去掉了减均值的操作,只进行缩放,计算更快,在某些模型中表现相当。LLaMA就使用了RMSNorm。

在从零实现的项目中,我建议先完成最原始的版本,确保一切工作正常。然后再尝试将这些现代组件逐一替换进去,并观察训练曲线和生成效果的变化,这能让你深刻理解每一项改进的实际贡献。

6.3 从玩具到实用:数据、规模与指令微调

你的“从零开始”模型可能只在几十MB的文本上训练,生成质量有限。要迈向实用,有三个关键飞跃:

  1. 高质量、大规模数据 : 模型的知识和能力几乎完全来源于数据。收集和清洗一个多样化、高质量、规模达到GB甚至TB级别的文本语料库是下一步。这涉及到网页抓取、去重、语言过滤、质量过滤等一系列复杂的数据工程。
  2. 扩大模型规模 : 按照“缩放定律”,模型性能随着参数规模、数据规模和计算量的增加而可预测地提升。你可以尝试将 d_model 增加到1024或2048,将 num_layers 增加到24或36。但这会急剧增加显存需求,需要你学习模型并行、流水线并行、ZeRO优化等分布式训练技术。
  3. 指令微调与对齐 : 基础语言模型只是“续写大师”。要让它成为有用的助手,需要进行指令微调。这需要收集或生成大量的 (指令, 输出) 对,在预训练模型的基础上进行有监督微调。更进一步,还需要通过人类反馈强化学习等技术进行对齐,使模型的输出更符合人类价值观和偏好。这是打造ChatGPT类模型的关键一步,但数据收集和训练过程都极具挑战。

从零实现一个LLM,就像是亲手绘制了一张精细的“地图”。当你日后使用Megatron、DeepSpeed等大型框架,或微调千亿参数模型时,这张地图能让你清楚地知道每一行代码、每一个配置项到底在做什么,而不是在迷雾中摸索。这种底层的掌控感,是应对未来AI技术快速迭代最坚实的底气。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐