从零实现轻量级GPT:深入理解Transformer架构与自注意力机制
1. 项目概述:一个轻量级、可复现的GPT模型实现
最近在GitHub上看到一个挺有意思的项目,叫nazdridoy/ngpt。这个项目本质上是一个从零开始实现的、轻量级的GPT(Generative Pre-trained Transformer)模型。对于想深入理解Transformer架构和GPT模型内部运作机制的朋友来说,这类项目是个绝佳的“学习伴侣”。它不像那些动辄数百亿参数、需要庞大算力集群的工业级大模型,而是将核心逻辑剥离出来,用相对简洁的代码呈现,让你能亲手“搭积木”,搞清楚自注意力机制、前馈网络、位置编码这些关键组件到底是怎么协同工作的。
我自己也尝试过复现一些经典论文的模型,深知其中难点。ngpt这类项目的价值在于,它提供了一个清晰、可运行的“最小可行产品”(MVP)。你不需要被复杂的分布式训练、海量数据处理管道吓退,而是可以专注于模型本身的结构。它能做什么呢?核心就是文本生成。给定一段提示(prompt),模型能够基于学习到的模式,逐词(或逐token)地生成后续内容。虽然受限于规模,它生成的文本在连贯性、逻辑性和知识广度上无法与ChatGPT等相提并论,但作为教学和实验工具,它完美地演示了生成式语言模型的核心原理。
这个项目特别适合几类人:一是对Transformer和GPT充满好奇,但看原始论文或庞大开源库(如Hugging Face Transformers)感到无从下手的学习者;二是希望在自己的研究或小项目中集成一个轻量级文本生成模块的开发者;三是想要通过动手编码来巩固深度学习,特别是自然语言处理基础知识的实践派。接下来,我们就一起拆解这个项目的设计思路、核心实现,并分享如何一步步跑起来,以及过程中可能遇到的“坑”和解决技巧。
2. 核心架构与设计思路拆解
2.1 为什么选择GPT作为实现蓝本?
GPT(Generative Pre-trained Transformer)系列模型是当前大语言模型的基石之一。其设计哲学相对直观:采用纯解码器(Decoder-Only)的Transformer架构,通过自回归(Autoregressive)的方式生成文本。所谓自回归,就是模型在生成下一个词时,只能看到它之前已经生成的词(以及输入的提示),这非常符合人类语言生成的顺序特性。
ngpt选择实现GPT,而非完整的编码器-解码器(Encoder-Decoder)结构的Transformer(如原始论文中的机器翻译模型),我认为有几个考量:
- 简化复杂度:去掉了编码器部分,注意力机制只需处理解码器的自注意力(带掩码)和可能存在的编码器-解码器注意力。对于入门和理解核心的自注意力机制来说,负担更小。
- 目标单一:专注于语言建模(Language Modeling)这一核心任务,即预测下一个词的概率分布。这避免了像翻译、摘要等任务需要对齐源序列和目标序列的额外复杂性。
- 与现代LLM接轨:当今主流的大语言模型,如GPT系列、LLaMA等,都是基于Decoder-Only架构。从
ngpt入手,建立的理解可以直接迁移到对这些更复杂模型的学习上。
项目的设计思路很清晰:用最小的、可运行的代码,实现GPT模型的核心组件,并完成在一个小规模数据集(如莎士比亚作品、维基百科片段)上的训练和文本生成演示。这意味着它必然包含以下几个关键模块:词嵌入(Token Embedding)、位置编码(Positional Encoding)、多层Transformer解码器块(每个块包含掩码自注意力层和前馈神经网络层),以及最后的语言模型头(LM Head)。
2.2 关键组件选型与实现考量
在具体实现上,ngpt需要做出一些适合教学和轻量级运行的选择。
2.2.1 Tokenizer(分词器)一个完整的语言模型离不开分词器。工业级模型使用BPE(Byte-Pair Encoding)或WordPiece等复杂算法。但在ngpt这样的项目中,为了极简,通常会采用字符级(Character-Level)或简单的单词级(Word-Level)分词。
- 字符级分词:将文本拆分为单个字符(包括字母、标点、空格)。词汇表很小(通常几十到几百),实现简单,但序列长度会变得非常长,模型需要学习更长距离的依赖关系,对模型能力要求更高。
- 简单单词级分词:按空格分词,并建立一个固定大小的词汇表。对于不在词汇表中的词(OOV)通常处理为
<UNK>。这种方式序列长度较短,但词汇表管理稍复杂。 在ngpt的代码中,我们很可能会看到一个简单的Tokenizer类,负责将字符串转换为词汇表ID序列,以及反向转换。它可能不包含子词合并算法,而是基于训练数据统计得到的固定词表。
2.2.2 模型规模(Scale)真正的GPT-3有1750亿参数,这显然不现实。ngpt的目标是“玩具级”或“小规模”。它的超参数,如n_layer(Transformer层数)、n_head(注意力头数)、n_embd(嵌入维度),会被设置得非常小。例如,可能是n_layer=6,n_head=6,n_embd=384这样的配置。这样的模型参数量可能在千万级别,可以在消费级GPU(甚至强大的CPU)上在合理时间内完成对小数据集的训练。
2.2.3 训练目标与优化训练目标就是标准的自回归语言建模损失:交叉熵损失(Cross-Entropy Loss)。给定一个文本序列,模型的任务是预测序列中每一个位置的下一个token。优化器通常会选择AdamW,这是目前训练Transformer模型的事实标准,它结合了Adam自适应学习率的优点和权重衰减(Weight Decay)的正则化效果。学习率调度(Learning Rate Schedule)可能会采用带热启动(Warmup)的余弦退火(Cosine Annealing)或简单的线性衰减,这对于稳定Transformer模型的训练至关重要。
注意:在轻量级实现中,很多工业级训练技巧(如梯度累积、混合精度训练、模型并行)会被省略,以保持代码的清晰性。但这正是学习的好机会,你可以清楚地看到最基础的训练循环是什么样子。
3. 代码结构深度解析与核心模块实现
让我们深入到代码层面,假设我们基于常见的轻量级GPT实现模式,来解析ngpt可能包含的核心文件与模块。
3.1 模型定义 (model.py)
这是项目的核心。通常会定义一个GPT类,继承自torch.nn.Module(如果使用PyTorch)。
import torch import torch.nn as nn import torch.nn.functional as F class CausalSelfAttention(nn.Module): """带因果掩码的自注意力层""" def __init__(self, config): super().__init__() assert config.n_embd % config.n_head == 0 # 键(K)、查询(Q)、值(V)的线性投影 self.key = nn.Linear(config.n_embd, config.n_embd) self.query = nn.Linear(config.n_embd, config.n_embd) self.value = nn.Linear(config.n_embd, config.n_embd) # 输出投影 self.proj = nn.Linear(config.n_embd, config.n_embd) # 正则化,通常用Dropout防止过拟合 self.attn_dropout = nn.Dropout(config.dropout) self.resid_dropout = nn.Dropout(config.dropout) self.n_head = config.n_head self.n_embd = config.n_embd # 注册一个缓冲区,用于存储因果掩码(下三角矩阵,包含-inf和0) self.register_buffer("mask", torch.tril(torch.ones(config.block_size, config.block_size)) .view(1, 1, config.block_size, config.block_size)) def forward(self, x): B, T, C = x.size() # 批大小,序列长度,嵌入维度 # 计算Q, K, V,并重塑为多头的形式 k = self.key(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) q = self.query(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) v = self.value(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) # 自注意力得分: (B, nh, T, hs) @ (B, nh, hs, T) -> (B, nh, T, T) att = (q @ k.transpose(-2, -1)) * (1.0 / (k.size(-1) ** 0.5)) # 缩放点积 att = att.masked_fill(self.mask[:,:,:T,:T] == 0, float('-inf')) # 应用因果掩码 att = F.softmax(att, dim=-1) att = self.attn_dropout(att) y = att @ v # (B, nh, T, T) @ (B, nh, T, hs) -> (B, nh, T, hs) y = y.transpose(1, 2).contiguous().view(B, T, C) # 重新组合头部输出 y = self.resid_dropout(self.proj(y)) return y关键点解析:
- 因果掩码(Causal Mask):
torch.tril(...)生成一个下三角矩阵,确保在计算注意力时,每个位置只能“看到”它之前的位置(包括自身),这是实现自回归生成的关键。 - 多头注意力(Multi-Head):通过
view和transpose操作,将嵌入维度C分割成n_head个头,每个头独立计算注意力,最后再合并。这允许模型同时关注来自不同表示子空间的信息。 - 缩放点积(Scaled Dot-Product):在计算
q@k^T后,除以sqrt(d_k)(即k.size(-1) ** 0.5),这是为了在维度较高时,防止点积结果过大导致softmax梯度消失。
接下来是Transformer块和完整的GPT模型:
class Block(nn.Module): """一个Transformer解码器块""" def __init__(self, config): super().__init__() self.ln1 = nn.LayerNorm(config.n_embd) self.attn = CausalSelfAttention(config) self.ln2 = nn.LayerNorm(config.n_embd) self.mlp = nn.Sequential( nn.Linear(config.n_embd, 4 * config.n_embd), # 扩展维度 nn.GELU(), # 激活函数,GPT使用GELU nn.Linear(4 * config.n_embd, config.n_embd), # 投影回原维度 nn.Dropout(config.dropout), ) def forward(self, x): # 残差连接(Pre-Norm结构,即先LayerNorm再进入子层) x = x + self.attn(self.ln1(x)) x = x + self.mlp(self.ln2(x)) return x class GPT(nn.Module): """完整的GPT模型""" def __init__(self, config): super().__init__() self.config = config self.token_embedding = nn.Embedding(config.vocab_size, config.n_embd) self.position_embedding = nn.Embedding(config.block_size, config.n_embd) self.dropout = nn.Dropout(config.dropout) # 堆叠多个Transformer块 self.blocks = nn.Sequential(*[Block(config) for _ in range(config.n_layer)]) self.ln_f = nn.LayerNorm(config.n_embd) # 最后的LayerNorm self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False) # 权重绑定:语言模型头的权重与词嵌入权重共享,这是一个常见技巧,可以减少参数量并可能提升性能 self.token_embedding.weight = self.lm_head.weight # 初始化权重 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) def forward(self, idx, targets=None): # idx: (B, T) 输入token索引 B, T = idx.size() # 词嵌入 + 位置嵌入 tok_emb = self.token_embedding(idx) # (B, T, n_embd) pos = torch.arange(0, T, dtype=torch.long, device=idx.device).unsqueeze(0) # (1, T) pos_emb = self.position_embedding(pos) # (1, T, n_embd) x = self.dropout(tok_emb + pos_emb) x = self.blocks(x) x = self.ln_f(x) logits = self.lm_head(x) # (B, T, vocab_size) loss = None if targets is not None: # 计算损失,只计算有效部分的交叉熵 B, T, C = logits.shape logits = logits.view(B*T, C) targets = targets.view(B*T) loss = F.cross_entropy(logits, targets) return logits, loss def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None): """自回归生成文本""" for _ in range(max_new_tokens): # 如果序列太长,裁剪到block_size idx_cond = idx if idx.size(1) <= self.config.block_size else idx[:, -self.config.block_size:] # 前向传播,获取最后一个位置的logits logits, _ = self(idx_cond) logits = logits[:, -1, :] / temperature # (B, C) # 可选:top-k采样 if top_k is not None: v, _ = torch.topk(logits, top_k) logits[logits < v[:, [-1]]] = -float('Inf') # 应用softmax得到概率 probs = F.softmax(logits, dim=-1) # (B, C) # 从概率分布中采样下一个token idx_next = torch.multinomial(probs, num_samples=1) # (B, 1) # 将新token拼接到序列中 idx = torch.cat((idx, idx_next), dim=1) # (B, T+1) return idx3.2 配置与数据准备 (config.py和data.py)
一个Config类用于集中管理所有超参数,这非常清晰。
class GPTConfig: def __init__(self, vocab_size, block_size, **kwargs): self.vocab_size = vocab_size # 词汇表大小 self.block_size = block_size # 上下文长度(最大序列长度) # 模型架构参数 self.n_layer = kwargs.get('n_layer', 6) self.n_head = kwargs.get('n_head', 6) self.n_embd = kwargs.get('n_embd', 384) # 正则化参数 self.dropout = kwargs.get('dropout', 0.1) # 训练参数(可能放在另一个配置中) self.batch_size = kwargs.get('batch_size', 64) self.learning_rate = kwargs.get('learning_rate', 3e-4) self.max_iters = kwargs.get('max_iters', 5000)数据准备模块负责加载文本、构建词汇表、创建训练和验证数据集。
import torch from torch.utils.data import Dataset, DataLoader class CharDataset(Dataset): """一个简单的字符级数据集""" def __init__(self, data, block_size): chars = sorted(list(set(data))) self.vocab_size = len(chars) self.stoi = {ch:i for i,ch in enumerate(chars)} # 字符到索引 self.itos = {i:ch for i,ch in enumerate(chars)} # 索引到字符 self.block_size = block_size self.data = data def __len__(self): return len(self.data) - self.block_size def __getitem__(self, idx): # 抓取一个长度为block_size+1的块 chunk = self.data[idx:idx+self.block_size+1] # 将字符转换为整数 dix = [self.stoi[s] for s in chunk] x = torch.tensor(dix[:-1], dtype=torch.long) y = torch.tensor(dix[1:], dtype=torch.long) # 目标是下一个字符 return x, y def get_dataloaders(text_path, block_size, batch_size, split_ratio=0.9): with open(text_path, 'r', encoding='utf-8') as f: text = f.read() n = len(text) train_text = text[:int(n*split_ratio)] val_text = text[int(n*split_ratio):] train_dataset = CharDataset(train_text, block_size) val_dataset = CharDataset(val_text, block_size) train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False) return train_loader, val_loader, train_dataset.vocab_size, train_dataset.stoi, train_dataset.itos4. 完整训练与生成流程实操
4.1 环境准备与依赖安装
首先,你需要一个Python环境(建议3.8+)和PyTorch。如果你有NVIDIA GPU,安装支持CUDA的PyTorch会极大加速训练。
# 使用conda创建环境(可选) conda create -n ngpt python=3.9 conda activate ngpt # 安装PyTorch (请根据你的CUDA版本访问 https://pytorch.org/ 获取正确命令) # 例如,对于CUDA 11.8: pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 # 安装其他可能需要的库(如tqdm用于进度条,tensorboard用于可视化) pip install tqdm tensorboard4.2 训练脚本编写与执行
创建一个train.py脚本,它将所有模块串联起来。
import torch import torch.nn as nn from torch.optim import AdamW from tqdm import tqdm import os from model import GPT, GPTConfig from data import get_dataloaders # 1. 配置参数 data_path = './data/input.txt' # 你的文本数据路径 block_size = 128 # 上下文长度 batch_size = 64 n_layer = 6 n_head = 6 n_embd = 384 dropout = 0.1 learning_rate = 3e-4 max_iters = 10000 eval_interval = 500 eval_iters = 200 # 2. 准备数据 train_loader, val_loader, vocab_size, stoi, itos = get_dataloaders(data_path, block_size, batch_size) print(f"词汇表大小: {vocab_size}") # 3. 初始化模型 config = GPTConfig(vocab_size=vocab_size, block_size=block_size, n_layer=n_layer, n_head=n_head, n_embd=n_embd, dropout=dropout) model = GPT(config) device = 'cuda' if torch.cuda.is_available() else 'cpu' print(f"使用设备: {device}") model.to(device) # 4. 初始化优化器 optimizer = AdamW(model.parameters(), lr=learning_rate) # 5. 训练循环 @torch.no_grad() def estimate_loss(): """评估模型在训练集和验证集上的平均损失""" out = {} model.eval() for split, loader in [('train', train_loader), ('val', val_loader)]: losses = torch.zeros(eval_iters) for k, (x, y) in enumerate(loader): if k >= eval_iters: break x, y = x.to(device), y.to(device) _, loss = model(x, y) losses[k] = loss.item() out[split] = losses.mean() model.train() return out pbar = tqdm(range(max_iters), desc="训练中") for iter in pbar: # 定期评估 if iter % eval_interval == 0 or iter == max_iters - 1: losses = estimate_loss() pbar.set_postfix({'train_loss': f"{losses['train']:.4f}", 'val_loss': f"{losses['val']:.4f}"}) # 可以在这里保存模型检查点 # torch.save(model.state_dict(), f'ckpt_iter_{iter}.pt') # 获取一个batch xb, yb = next(iter(train_loader)) xb, yb = xb.to(device), yb.to(device) # 前向传播,计算损失 _, loss = model(xb, yb) # 反向传播,优化 optimizer.zero_grad(set_to_none=True) loss.backward() optimizer.step() print("训练完成!") # 保存最终模型 torch.save(model.state_dict(), 'gpt_final.pt') torch.save({'stoi': stoi, 'itos': itos, 'config': config}, 'meta.pkl')关键操作解析:
@torch.no_grad()装饰器:在评估函数estimate_loss中使用,这会禁用梯度计算,节省内存和计算资源。optimizer.zero_grad(set_to_none=True):清空梯度。将梯度设置为None比设置为零张量更节省内存。- 训练-评估循环:定期在验证集上评估损失,是监控模型是否过拟合(训练损失下降但验证损失上升)的关键。
4.3 文本生成与交互测试
训练完成后,我们可以加载模型进行文本生成。
import torch import pickle # 加载模型和元数据 with open('meta.pkl', 'rb') as f: meta = pickle.load(f) stoi, itos, config = meta['stoi'], meta['itos'], meta['config'] model = GPT(config) model.load_state_dict(torch.load('gpt_final.pt', map_location='cpu')) model.eval() def generate_text(prompt, max_new_tokens=500, temperature=0.8, top_k=40): """根据提示生成文本""" # 将提示转换为token索引 idx = torch.tensor([[stoi.get(ch, 0) for ch in prompt]], dtype=torch.long) # 生成 with torch.no_grad(): idx = model.generate(idx, max_new_tokens=max_new_tokens, temperature=temperature, top_k=top_k) # 将索引转换回字符 output_chars = [itos[i] for i in idx[0].tolist()] return ''.join(output_chars) # 示例 prompt = "Once upon a time" generated = generate_text(prompt, max_new_tokens=200, temperature=0.9) print(f"提示: {prompt}") print(f"生成: {generated}")生成参数解析:
temperature(温度):控制生成的随机性。temperature=1.0使用原始logits;temperature < 1.0(如0.8)会使概率分布更尖锐(更确定,生成更保守);temperature > 1.0会使分布更平缓(更随机,生成更有创意但也可能更混乱)。top_k:仅从概率最高的k个token中采样。这可以防止模型从低概率的“荒谬”token中采样,提高生成质量。top_k=40是一个常用值。
5. 常见问题、调试技巧与优化方向
在实际运行ngpt或类似项目时,你几乎一定会遇到一些问题。下面是我在复现过程中总结的一些常见坑点和解决思路。
5.1 训练不收敛或损失为NaN
这是最令人头疼的问题之一。
- 检查数据:确保你的输入数据是有效的文本,并且分词器(
stoi)能正确处理所有字符。如果出现了词汇表中没有的字符(OOV),而你的代码没有处理(比如默认返回0),可能会导致奇怪的行为。可以在数据加载时打印几个样本的x和y,看看是否合理。 - 检查损失计算:确保
targets的维度与logits的维度正确对齐。在forward函数中,logits的形状是(B, T, C),而cross_entropy函数期望的输入是(N, C)和(N,),其中N = B*T。代码中的view操作就是为了这个。 - 梯度爆炸/消失:这是深度网络的通病。
- 权重初始化:确保使用了合适的初始化,如代码中的
_init_weights方法(正态分布,标准差0.02)。这是Transformer常用的初始化策略。 - 梯度裁剪(Gradient Clipping):在
loss.backward()之后,optimizer.step()之前,添加torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。这可以防止梯度变得过大导致训练不稳定。 - 学习率:
3e-4是Adam优化器训练Transformer的一个经典起点。如果损失爆炸,尝试调低(如1e-4);如果下降太慢,可以适当调高,但要谨慎。
- 权重初始化:确保使用了合适的初始化,如代码中的
- 数值稳定性:在softmax计算中,如果
logits值过大,可能会导致溢出。缩放点积注意力中的除以sqrt(d_k)就是为了缓解这个问题。确保你的temperature参数在生成时不为零。
5.2 生成结果毫无意义或重复
模型训练完了,但生成的东西像乱码,或者不断重复同一个词。
- 训练不足:这是最常见的原因。小模型在小数据上需要足够长的训练时间才能学到有意义的模式。增加
max_iters,观察验证损失是否还在持续下降。 - 过拟合:如果训练损失很低但验证损失很高,且生成效果差,说明模型只是记住了训练数据,没有泛化能力。可以尝试:
- 增加
dropout率(如从0.1调到0.2)。 - 使用权重衰减(Weight Decay)。在
AdamW优化器中,权重衰减参数是内置的,确保它被正确设置(通常weight_decay=0.01)。 - 获取更多样化的训练数据。
- 增加
- 生成参数问题:
temperature太低:如果temperature设得太低(如0.1),模型会变得极其“自信”,总是选择概率最高的那个token,导致生成结果非常呆板、重复。尝试调到0.7-0.9。top_k太小:如果top_k=1,就变成了贪婪解码(总是选最好的),同样会导致重复。通常top_k=40或top_p(核采样)是更好的选择。
- 模型容量不足:如果数据复杂度较高(如现代英文),而模型太小(
n_embd或n_layer太小),它可能没有足够的能力捕捉语言规律。尝试增大模型规模,但要注意计算资源。
5.3 内存不足(CUDA out of memory)
尤其是在增大batch_size或block_size时容易出现。
- 减小
batch_size:这是最直接有效的方法。 - 减小
block_size:上下文长度直接影响注意力矩阵的大小(O(T^2))。如果不需要很长的上下文,可以适当减小。 - 使用梯度累积(Gradient Accumulation):如果想让有效批次大小(batch size)更大,但GPU内存不够,可以使用梯度累积。每
accum_steps个微批次(micro-batch)才更新一次权重。accum_steps = 4 optimizer.zero_grad() for micro_step in range(accum_steps): # 获取微批次数据... _, loss = model(xb_micro, yb_micro) loss = loss / accum_steps # 损失按累积步数缩放 loss.backward() # 梯度累积 optimizer.step() - 检查数据格式:确保输入数据
idx是torch.long类型,而不是torch.float,后者会占用更多内存。
5.4 项目扩展与优化方向
当你成功运行了基础版本后,可以考虑以下方向进行扩展,这能让你更深入地理解现代LLM:
- 实现更高效的自注意力:实现多头注意力并行计算的更高效版本(通常称为“合并QKV投影”)。或者,尝试集成Flash Attention(如果CUDA版本支持),这是一种能显著降低内存占用和加速计算的算法。
- 改进分词器:将字符级分词替换为BPE分词器。你可以尝试集成
tiktoken(OpenAI用的)或sentencepiece。这需要修改数据预处理和模型词汇表部分。 - 实现更复杂的训练技巧:
- 学习率调度:实现带热启动的余弦退火调度。
- 混合精度训练(AMP):使用
torch.cuda.amp来减少内存占用并加速训练。 - 模型检查点与恢复:完善模型保存和加载逻辑,以便从中断处继续训练。
- 架构修改:
- RMSNorm:尝试用RMSNorm替换LayerNorm,一些新模型(如LLaMA)使用了它。
- SwiGLU/SiLU激活函数:将前馈网络中的GELU替换为SwiGLU,这是GPT-4等模型使用的。
- 旋转位置编码(RoPE):替换掉绝对位置编码,实现相对位置编码的RoPE,这对长文本生成更友好。
- 在更大数据集上训练:尝试使用维基百科、书籍语料库等更大规模的数据,观察模型能力的提升。这需要更完善的数据加载和预处理管道。
这个项目就像一把钥匙,打开了理解Transformer和GPT的大门。从运行最简单的版本开始,逐步添加功能、修复问题、进行实验,你会对自注意力、位置编码、层归一化、残差连接这些概念有肌肉记忆般的理解。这远比只读论文或调用现成的API来得深刻。
