想系统掌握大模型核心原理,却总觉得网上资料太散?从 Transformer 结构到分布式训练,每个环节都有大量“黑盒”概念。斯坦福的 CS336 课程之所以受欢迎,就是因为它不满足于让你调 API,而是带着你从数据准备、Tokenizer、模型定义、预训练、微调一路手写到底。本文结合 CS336 的学习路径,梳理一份从零构建 LLM 的完整实操指南,包含环境配置、核心原理、可运行的迷你 GPT 代码示例以及高频故障排查思路。无论你是准备入门大模型方向的学生,还是希望在业务中真正掌握大模型训练细节的工程师,都可以照着这条路径逐步落地。
1. 背景与核心概念
1.1 LLM 为什么值得亲手构建
LLM(Large Language Model,大语言模型)本质上是一个超大规模的、基于自回归方式训练的概率语言模型。通俗地说,它做的事情是“根据已经出现的词,预测下一个词出现的概率”。训练数据足够多、模型参数足够大之后,这类模型会涌现出对话、翻译、摘要、代码生成等能力。
很多人会问:现在开源模型这么多,Hugging Face 上直接下载权重就能用,为什么还要花时间自己去构建?
原因有三点:
- 理解深度不同。只调用
model.generate(),你很难理解为什么模型会“胡说八道”,为什么显存会溢出,为什么长文本生成效果变差。只有亲手写过训练循环、调过损失函数、处理过数据批次,才能真正理解这些问题。 - 定制能力不同。业务场景往往需要针对特定领域数据做继续预训练或微调。当你要改模型结构、改注意力掩码、改采样策略时,不熟悉底层实现是无从下手的。
- 工程能力提升。训练大模型背后涉及数据流水线、分布式并行、显存优化、模型保存与评估等工程问题。这些经验在调 API 时学不到,但恰恰是大模型工程师的核心竞争力。
1.2 CS336 是什么
CS336 是斯坦福大学开设的 LLM 系统课程,核心理念是“从零开始构建语言模型”。课程内容覆盖了构建大模型的完整技术栈:
- 语言模型基础与数据准备;
- Tokenizer 的原理与实现;
- Transformer 模型结构与实现;
- 预训练(Pretraining);
- 监督微调(SFT)与人类偏好对齐;
- 模型评估;
- 分布式训练与推理优化。
这门课最有价值的地方在于:它不会直接丢给你一个封装好的框架,而是让你在课程实践中逐步实现数据加载、Tokenization、模型前向传播、反向传播、分布式训练等模块。这和 Andrej Karpathy 的 NanoGPT 思路有相通之处:先用最小代码跑通一个可训练的 GPT,再逐步扩展。
另外,围绕 LLM 的学习,很多开发者会建立自己的知识库,比如使用 llm wiki 整理课程笔记。这样做的好处是,当你遇到“DDP 为什么导致 loss 不一致”、“学习率 warmup 有什么作用”这类细节问题时,可以快速定位到自己的笔记,而不是重新翻几十个网页。
1.3 需要掌握哪些前置知识
在开始动手之前,建议先具备以下基础:
| 前置知识 | 掌握程度 | 用途 |
|---|---|---|
| Python | 能熟练编写类、循环、函数 | 实现模型与训练逻辑 |
| PyTorch | 熟悉 Tensor、nn.Module、自动求导 | 构建神经网络 |
| 深度学习基础 | 了解损失函数、反向传播、过拟合 | 理解训练过程 |
| Linux 基础 | 会使用命令行、安装环境 | 管理和运行训练任务 |
| 数学基础 | 了解矩阵乘法、概率基础 | 理解 Transformer 计算过程 |
如果某些知识点还不熟,可以在实践过程中边做边补。下面进入正题,先看从零构建 LLM 的整体流程。
2. 从零构建 LLM 的整体流程
在进入代码之前,我们需要先建立一个全局认知。构建 LLM 不是一上来就写模型,而是一条完整的数据与训练流水线。
2.1 构建流程概览
从零到可用的 LLM,大致分为六个环节:
数据收集与清洗 → Tokenization → 模型结构设计 → 预训练 → 微调与对齐 → 评估与部署每个环节的职责如下。
数据收集与清洗
模型的“知识”全部来自训练数据。数据质量直接影响模型效果。原始文本需要做去重、过滤垃圾信息、处理编码错误、去除个人隐私数据等。对应到 CS336 的课程中,这一部分会涉及数据集的采样与去重(比如 MinHash 去重)。
Tokenization
Tokenizer 将原始文本切分为模型可以处理的 token 序列。常见方案有字符级、BPE(Byte Pair Encoding)、WordPiece、SentencePiece 等。选择合适的分词方案,直接影响词表大小、训练速度和模型效果。
模型结构设计
目前主流 LLM 基本都基于 Transformer 的 Decoder-only 架构。核心组件包括 Token Embedding、位置编码、多头自注意力、前馈网络、残差连接与 LayerNorm。在这个环节,需要理解每个组件的输入输出形状。
预训练
预训练是让模型在海量文本上做自监督学习。最常用的训练目标是“下一个词预测”(Next Token Prediction)。这一阶段需要处理学习率调度、优化器选择、梯度累积、混合精度训练等问题。
微调与对齐
预训练模型只能学会“续写文本”,并不一定擅长对话。为了让模型符合人类的指令和偏好,还需要做 SFT(监督微调),进一步还可以使用 RLHF 或 DPO 进行偏好对齐。
评估与部署
训练完成后,需要通过困惑度(Perplexity)、下游任务指标等方式评估模型。部署时则要考虑推理速度、显存占用、量化、服务化等问题。
2.2 课程实践与真实项目的差异
CS336 课程中的实现更偏向教学,强调把每块逻辑讲清楚。真实项目则会大量借助成熟框架(如 Hugging Face Transformers、DeepSpeed、vLLM 等)来提升效率。
因此,建议的学习策略是:先跟着课程手写核心代码,理解原理;再学习框架,了解生产环境下的最佳实践;最后回到自己的业务场景,用框架快速实现方案。
下面我们进入实践环节,先把环境准备好。
3. 环境准备与版本说明
构建 LLM 对环境的要求比普通 Web 应用高很多。我们先明确推荐配置,再给出可选的降级方案。
3.1 硬件环境
最理想的情况是拥有一块大显存 GPU:
| 配置项 | 推荐配置 | 最低可运行配置 |
|---|---|---|
| GPU | NVIDIA A100 / RTX 4090 | RTX 3060 12GB |
| 显存 | 24GB 以上 | 8GB ~ 12GB |
| 内存 | 64GB 以上 | 16GB |
| 硬盘 | 1TB SSD | 200GB 可用空间 |
如果你没有本地 GPU,也可以使用云 GPU 实例或者 Colab 的免费 GPU。课程中的小型实验完全可以在低配环境上运行,关键是先跑通流程。
3.2 软件环境
以下版本以常见稳定组合为例,具体版本请结合你的环境和官方文档确认:
# Python # 推荐 Python 3.9 或 3.10 # 创建虚拟环境 python -m venv llm_env source llm_env/bin/activate # 安装 PyTorch # 根据自己的 CUDA 版本选择对应命令,示例为 CUDA 12.1 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 # 安装基础依赖 pip install numpy tqdm transformers datasets安装完成后,可以运行以下命令检查环境是否可用:
import torch print("PyTorch 版本:", torch.__version__) print("CUDA 是否可用:", torch.cuda.is_available()) print("当前设备:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU")一般来说,只要torch.cuda.is_available()返回True,表示你的 PyTorch 正确识别到了 GPU。如果这里返回False,需要检查驱动版本和 PyTorch 的 CUDA 版本是否匹配。
3.3 项目结构规划
为了后续代码整洁,建议按照下面的目录结构组织项目:
llm-from-scratch/ ├── input.txt # 训练语料 ├── tokenizer.py # 分词器实现 ├── model.py # Transformer 模型定义 ├── train.py # 训练脚本 ├── generate.py # 推理脚本 └── checkpoints/ # 模型保存目录下面我们先把核心原理解析清楚,再进入完整代码实现。
4. 核心原理拆解
4.1 数据与 Tokenizer
模型无法直接理解字符串,需要把文本转换成整数序列。最简单的方案是字符级 Tokenizer:把每一个字符映射到一个整数。
例如文本hello,去掉重复字符后得到字符集合{h, e, l, o},建立映射:
h -> 0 e -> 1 l -> 2 o -> 3那么hello就转换为[0, 1, 2, 2, 3]。
字符级 Tokenizer 的优点是实现简单,缺点也很明显:序列长度会很长,且模型难以学到有意义的词级语义。实际 LLM 更常使用 BPE 或 SentencePiece。以 BPE 为例,它从字符级别开始,不断合并出现频率最高的相邻 token,形成一个既能覆盖常见子词、又能控制词表大小的编码方案。
在 CS336 的课程中,Tabular 阶段会要求实现 BPE 算法,并对比不同词表大小对模型效果的影响。
4.2 Transformer 核心组件
目前主流的 LLM 结构是 Decoder-only Transformer。一个标准的 Transformer Block 由以下部分组成:
- Token Embedding:将 token 整数映射为稠密向量。
- 位置编码:为模型提供序列位置信息。
- 多头自注意力:让每个 token 关注序列中其他 token。
- 前馈网络:对注意力输出做非线性变换。
- 残差连接:缓解深层网络梯度消失问题。
- LayerNorm:稳定训练过程。
自注意力机制的公式可以简化为:
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V其中Q、K、V分别代表查询、键、值矩阵。sqrt(d_k)的作用是缩放点积结果,避免数值过大导致 softmax 梯度消失。
值得强调的是,LLM 使用的是带因果掩码的自注意力。也就是说,在预测第t个 token 时,模型只能看到前t-1个 token,不能看到未来的信息。实现时通常用一个上三角矩阵来屏蔽未来位置。
4.3 训练目标与优化器
预训练阶段,LLM 的任务是“根据前文预测下一个 token”。这本质上是一个多分类问题,损失函数使用交叉熵。
训练过程中,模型对每个位置的输出都计算出对所有词表 token 的概率分布,然后与真实的下一个 token 计算交叉熵。
优化器方面,目前 LLM 训练最常用的是 AdamW。相比于标准的 Adam,AdamW 将权重衰减与梯度更新解耦,能够更好地抑制过拟合,训练更稳定。
此外,LLM 训练普遍使用学习率预热(warmup)与余弦退火策略。训练初期使用较小的学习率,逐步增大到目标学习率,可以减少早期训练的不稳定性;训练后期学习率逐渐降低,则有助于模型收敛到更优区域。
4.4 训练与推理的区别
训练时模型会同时计算所有位置的输出和损失,并进行反向传播。推理时则通常采用自回归生成:每次只生成一个 token,将新生成的 token 拼接到输入序列末尾,再继续预测下一个 token。
推理阶段有一个非常重要的优化手段:KV Cache。因为每个新 token 只需要计算与前面 token 的注意力,缓存前序 token 的 K、V 矩阵可以避免重复计算,大幅提升推理速度。这也是为什么训练时显存占用远高于推理的原因之一。
5. 完整实战案例:手搓一个迷你 GPT
现在进入最核心的实操环节。我们从一个可运行的迷你 GPT 项目开始。
为了确保代码能在普通电脑上运行,我们选择小规模参数、字符级 Tokenizer 的方案。你可以先把这个跑通,再按照 CS336 的思路逐步扩展。
5.1 准备训练数据
为了实验简单,我使用一段英文文本作为示例。你可以在项目目录下创建input.txt,放入任意英文小说或文章。这里以一小段文本为例:
Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it.实际训练时,数据量越大越好。字符级模型需要的语料往往更多,后续可以替换为更大规模的数据集,比如使用中文维基百科或开源语料。
5.2 编写 Tokenizer
文件路径:tokenizer.py
import torch def create_tokenizer(text): """ 基于训练文本创建字符级 tokenizer。 返回 encode 和 decode 函数。 """ chars = sorted(list(set(text))) stoi = {ch: i for i, ch in enumerate(chars)} itos = {i: ch for i, ch in enumerate(chars)} encode = lambda s: [stoi[c] for c in s] decode = lambda l: ''.join([itos[i] for i in l]) return encode, decode, len(chars) def load_data(file_path): with open(file_path, 'r', encoding='utf-8') as f: text = f.read() return text这里的关键点是:
set(text)可以提取文本中所有出现过的字符;stoi负责把字符映射为整数;itos负责把整数映射回字符;vocab_size是词表大小,也就是模型输出层的维度。
5.3 实现 Transformer 模型
文件路径:model.py
下面这段代码实现了一个简洁的 Decoder-only Transformer。它去掉了复杂的分布式逻辑,但保留了完整的注意力、MLP、残差连接与 LayerNorm。
import torch import torch.nn as nn import torch.nn.functional as F class LayerNorm(nn.Module): """自定义 LayerNorm,用于理解内部实现""" def __init__(self, dim, eps=1e-5): super().__init__() self.eps = eps self.gamma = nn.Parameter(torch.ones(dim)) self.beta = nn.Parameter(torch.zeros(dim)) def forward(self, x): mean = x.mean(-1, keepdim=True) var = x.var(-1, keepdim=True, unbiased=False) return self.gamma * (x - mean) / torch.sqrt(var + self.eps) + self.beta class CausalSelfAttention(nn.Module): """带因果掩码的多头自注意力""" def __init__(self, n_embd, n_head, block_size, dropout): super().__init__() self.n_head = n_head self.c_attn = nn.Linear(n_embd, 3 * n_embd) # Q、K、V 合并计算 self.c_proj = nn.Linear(n_embd, n_embd) # 输出投影 # 因果掩码:上三角为 0 的位置被掩码 self.register_buffer( "bias", torch.tril(torch.ones(block_size, block_size)).view(1, 1, block_size, block_size) ) self.attn_dropout = nn.Dropout(dropout) self.resid_dropout = nn.Dropout(dropout) def forward(self, x): B, T, C = x.shape qkv = self.c_attn(x) q, k, v = qkv.split(C, dim=-1) q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs) k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) att = (q @ k.transpose(-2, -1)) * (1.0 / (C // self.n_head) ** 0.5) att = att.masked_fill(self.bias[:, :, :T, :T] == 0, float('-inf')) att = F.softmax(att, dim=-1) att = self.attn_dropout(att) y = att @ v y = y.transpose(1, 2).contiguous().view(B, T, C) y = self.resid_dropout(self.c_proj(y)) return y class MLP(nn.Module): """前馈网络""" def __init__(self, n_embd, dropout): super().__init__() self.c_fc = nn.Linear(n_embd, 4 * n_embd) self.gelu = nn.GELU() self.c_proj = nn.Linear(4 * n_embd, n_embd) self.dropout = nn.Dropout(dropout) def forward(self, x): return self.dropout(self.c_proj(self.gelu(self.c_fc(x)))) class Block(nn.Module): """Transformer Block""" def __init__(self, n_embd, n_head, block_size, dropout): super().__init__() self.ln1 = LayerNorm(n_embd) self.attn = CausalSelfAttention(n_embd, n_head, block_size, dropout) self.ln2 = LayerNorm(n_embd) self.mlp = MLP(n_embd, dropout) def forward(self, x): x = x + self.attn(self.ln1(x)) # 残差 + 注意力 x = x + self.mlp(self.ln2(x)) # 残差 + 前馈网络 return x class GPT(nn.Module): """迷你 GPT 模型""" def __init__(self, vocab_size, n_embd=192, n_head=6, n_layer=4, block_size=128, dropout=0.1): super().__init__() self.block_size = block_size self.token_embedding = nn.Embedding(vocab_size, n_embd) self.position_embedding = nn.Embedding(block_size, n_embd) self.blocks = nn.ModuleList([ Block(n_embd, n_head, block_size, dropout) for _ in range(n_layer) ]) self.ln_f = LayerNorm(n_embd) self.lm_head = nn.Linear(n_embd, 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) def forward(self, idx, targets=None): B, T = idx.shape assert T <= self.block_size tok_emb = self.token_embedding(idx) # (B, T, C) pos = torch.arange(0, T, device=idx.device).unsqueeze(0) pos_emb = self.position_embedding(pos) # (1, T, C) x = tok_emb + pos_emb for block in self.blocks: x = block(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 loss = F.cross_entropy(logits.view(B * T, C), targets.view(B * T)) return logits, loss def generate(self, idx, max_new_tokens, temperature=1.0): """自回归生成""" for _ in range(max_new_tokens): idx_cond = idx[:, -self.block_size:] logits, _ = self.forward(idx_cond) logits = logits[:, -1, :] / temperature probs = F.softmax(logits, dim=-1) idx_next = torch.multinomial(probs, num_samples=1) idx = torch.cat([idx, idx_next], dim=1) return idx代码中有几个点需要特别解释:
为什么要合并 Q、K、V 的线性层?
c_attn = nn.Linear(n_embd, 3 * n_embd)一次计算得到 Q、K、V,然后再用split切分。这主要是为了代码简洁和计算效率,实际项目中也很常见。
为什么要使用因果掩码?
register_buffer("bias", torch.tril(...))注册了一个下三角矩阵。masked_fill会将上三角位置替换为-inf,经过 softmax 后这些位置的注意力权重趋近于 0,从而实现“只能看到过去和当前 token”的效果。
为什么生成时要除以 temperature?
temperature控制生成随机性。大于 1 会让概率分布更平滑,输出更随机;小于 1 会让分布更尖锐,输出更确定。
5.4 编写训练脚本
文件路径:train.py
import torch from tokenizer import create_tokenizer, load_data from model import GPT # ---------- 超参数 ---------- batch_size = 32 block_size = 128 max_iters = 2000 eval_interval = 200 learning_rate = 3e-4 n_embd = 192 n_head = 6 n_layer = 4 dropout = 0.1 device = 'cuda' if torch.cuda.is_available() else 'cpu' # ---------- 数据准备 ---------- text = load_data('input.txt') encode, decode, vocab_size = create_tokenizer(text) data = torch.tensor(encode(text), dtype=torch.long) n = int(0.9 * len(data)) train_data, val_data = data[:n], data[n:] # ---------- 批次采样 ---------- def get_batch(split): data = train_data if split == 'train' else val_data ix = torch.randint(len(data) - block_size, (batch_size,)) x = torch.stack([data[i:i + block_size] for i in ix]) y = torch.stack([data[i + 1:i + block_size + 1] for i in ix]) x, y = x.to(device), y.to(device) return x, y # ---------- 模型与优化器 ---------- model = GPT( vocab_size=vocab_size, n_embd=n_embd, n_head=n_head, n_layer=n_layer, block_size=block_size, dropout=dropout ).to(device) optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=0.1) print(f"模型参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.2f} M") # ---------- 训练循环 ---------- for step in range(max_iters): xb, yb = get_batch('train') logits, loss = model(xb, yb) optimizer.zero_grad(set_to_none=True) loss.backward() optimizer.step() if step % eval_interval == 0: model.eval() with torch.no_grad(): _, val_loss = model(*get_batch('val')) print(f"step {step}: train loss {loss.item():.4f}, val loss {val_loss.item():.4f}") model.train() # ---------- 保存模型 ---------- torch.save(model.state_dict(), 'checkpoints/mini_gpt.pt') print("训练完成,模型已保存到 checkpoints/mini_gpt.pt")训练时你会看到类似下面的输出:
模型参数量: 5.42 M step 0: train loss 4.0872, val loss 4.0921 step 200: train loss 1.2345, val loss 1.5233 step 400: train loss 0.8921, val loss 1.2108 ...观察 train loss 和 val loss 的变化可以判断模型状态:
- 两个 loss 都在下降,说明训练正常;
- train loss 持续下降但 val loss 开始上升,说明出现过拟合;
- 两个 loss 基本不动,说明数据、学习率或模型结构可能有问题。
5.5 编写推理脚本
文件路径:generate.py
import torch from tokenizer import create_tokenizer, load_data from model import GPT device = 'cuda' if torch.cuda.is_available() else 'cpu' text = load_data('input.txt') encode, decode, vocab_size = create_tokenizer(text) model = GPT( vocab_size=vocab_size, n_embd=192, n_head=6, n_layer=4, block_size=128, dropout=0.1 ).to(device) model.load_state_dict(torch.load('checkpoints/mini_gpt.pt', map_location=device)) model.eval() # 使用一个 start token 触发生成 start = torch.zeros((1, 1), dtype=torch.long, device=device) output = model.generate(start, max_new_tokens=200, temperature=0.8) print(decode(output[0].tolist()))如果你的训练数据是英文小说片段,生成结果可能是语法上不太通顺但已经具备一定统计规律的文本。如果是字符级模型,输出中偶尔会出现拼写错误,这是正常现象。
5.6 如何扩展到真实规模
上面的代码是教学演示版本,距离真正的 LLM 还有很大差距。从“跑通”到“可用”,你可以在以下几个方向继续扩展:
- Tokenizer 升级:将字符级替换为 BPE,接入
tiktoken或 Hugging Facetokenizers库。 - 数据管线升级:使用流式数据加载,避免把所有数据一次性读入内存。
- 训练优化:加入学习率 warmup、余弦退火、梯度裁剪、混合精度训练(
torch.cuda.amp)。 - 并行训练:使用
torch.nn.parallel.DistributedDataParallel进行多卡训练,或使用 DeepSpeed 的 ZeRO 优化。 - 工程化:使用 Hugging Face Transformers 对接现有生态,使用 vLLM 做推理加速。
6. 常见问题与排查思路
手搓 LLM 的过程中,问题通常集中在显存、loss 不收敛、生成效果差三个方面。下面整理一个常见问题排查表。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 显存溢出(OOM) | batch_size 太大、序列太长 | 减小 batch_size 或 block_size;使用梯度累积;开启混合精度 |
| 训练 loss 不下降 | 学习率不合适、数据量不足、模型初始化问题 | 检查数据;尝试更大学习率或使用 warmup;增加训练步数 |
| train loss 下降但 val loss 不降 | 过拟合 | 增大数据量;增大 dropout;降低模型容量 |
| 生成文本完全重复 | 温度过低、上下文太短 | 调高 temperature;增加 block_size;检查训练数据多样性 |
| 训练很慢 | 单卡算力不足、未开启优化 | 使用torch.compile;开启混合精度;使用 Flash Attention |
| 模型输出乱码 | Tokenizer 编码不一致 | 统一数据编码为 UTF-8;检查decode逻辑 |
| 保存模型后加载报错 | 词表大小与模型维度不匹配 | 确保推理时使用的 vocab_size 和训练时一致 |
| 调用模型 API 返回请求失败 | 请求格式或工具参数与模型不匹配 | 参考模型官方文档,检查请求 schema 和工具 payload 格式 |
6.1 显存溢出的具体排查步骤
显存溢出是最常见的问题。按以下顺序排查:
- 查看当前 GPU 显存使用情况:
nvidia-smi逐步减小
batch_size,直到程序能正常启动。4 句话后,定位到显存不足的根本原因是显存占用峰值过高。使用梯度累积来模拟更大的 batch:
accumulation_steps = 4 loss = loss / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()- 开启混合精度训练:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() with autocast(): logits, loss = model(xb, yb) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()6.2 Loss 不下降的排查清单
如果 loss 始终不下降,按照下面的清单检查:
- 数据是否正确编码?打印几个 batch 的
x和y,确认y是x的下一个 token。 - 学习率是否过小或过大?日志中打印梯度范数,正常情况下梯度过大会导致训练崩溃。
- 模型是否在训练模式?确认调用了
model.train()。 - 损失计算是否在正确的维度上?
logits.view(B * T, C)与targets.view(B * T)的维度必须匹配。
7. 最佳实践与学习路线
7.1 工程层面的最佳实践
在实际项目中,有几个经验值得坚持:
保持配置与代码分离
训练脚本里的超参数不应该散落在代码各处。推荐使用 YAML 或 dataclass 统一管理。例如:
# config.yaml model: n_embd: 192 n_head: 6 n_layer: 4 block_size: 128 train: batch_size: 32 learning_rate: 3e-4 max_iters: 2000重视实验记录
每次实验都应该记录:数据版本、模型结构、超参数、loss 曲线、评估结果。推荐使用wandb或tensorboard记录训练过程。没有记录的训练等于没有训练。
做好数据质量把控
数据质量对模型效果影响巨大。建议在训练前对数据做统计分析:token 数量、重复率、语言分布、特殊字符占比。数据清洗不是可有可无的步骤,而是决定模型天花板的关键因素。
遵循最小权限与安全原则
如果训练任务涉及敏感数据,务必在隔离环境中进行,设置权限访问控制。模型训练完成后,也要考虑模型可能带来的隐私泄露问题。
注重版本可控
训练框架、PyTorch 版本、CUDA 版本、数据预处理代码都要做好版本管理。推荐使用 Docker 镜像固化训练环境,避免“换台机器跑不起来”的尴尬。
7.2 从 CS336 出发的进阶学习路线
如果你愿意系统学习,建议按照下面的路线推进:
- 第一阶段:跑通最小代码。完成本文的迷你 GPT,并尝试修改模型层数、头数、词表大小,观察对 loss 的影响。
- 第二阶段:研究 Tokenizer。阅读 Hugging Face Tokenizers 库文档,实现一个 BPE Tokenizer,理解词表大小与 OOV(未登录词)问题。
- 第三阶段:优化训练流程。学习学习率调度、混合精度、梯度累积、分布式训练,使用多卡训练一个参数量过亿的模型。
- 第四阶段:对齐与微调。实现 SFT 数据构建,尝试用 LoRA 微调开源模型,理解人类偏好对齐的基本思路。
- 第五阶段:推理优化。学习 KV Cache、量化、vLLM 推理框架,探索模型部署方案。
在整个学习过程中,强烈建议使用llm wiki或 Obsidian 这类工具维护个人知识库。每次遇到报错、读到一篇好文章、复现一个实验,都把它整理成 markdown 笔记。知识库的意义不在于收集,而在于“用自己的话重写一遍”,这是真正把知识内化的过程。
7.3 最后想说的话
手搓 LLM 是一次非常值得的投入。早期你可能觉得直接调 Hugging Face 更高效,但当你真的自己实现了一遍数据管道、注意力机制、训练循环之后,再看任何开源模型代码都会从容很多。
不要被“大模型”三个字吓到。再大的模型,也是从最基本的数据采样、矩阵乘法和交叉熵开始长出来的。耐心把一个最小闭环跑通,再逐步扩大规模,你就会发现,所谓“手搓大模型”,其实并没有那么神秘。
如果本文对你的学习有帮助,欢迎收藏备用。也欢迎在评论区交流你训练过程中遇到的报错和问题,很多坑只有踩过才知道怎么绕开。