“AI engineering from scratch”,这个标题我在各种技术社区、GitHub仓库里见了不少次。说真的,每次看到都想点进去看看作者到底是怎么“从零”开始的——是真的从矩阵乘法手写反向传播,还是只是“从零”指没学过深度学习但直接调库?这两种“从零”的含金量天差地别。
今天这篇内容,我就以自己这些年带团队、带新人、自己也从数学系半路出家搞AI的亲身经历,把“AI工程从零开始”这件事拆开揉碎讲清楚。适合谁看?想转行做AI的,刚入职做算法但是感觉每天都在调参的,还有那些看了不少论文但是一动手就卡壳的学生。我会尽量把学习路径、核心原理、实操步骤和踩坑经验都讲透,不整虚的。
1. 为什么要“从零开始”:先搞清楚学习的底层逻辑
1.1 从零开始不是重复造轮子,是拆掉黑盒
很多人不理解为啥要“从零开始”学AI。现在PyTorch、HuggingFace这些框架都这么成熟了,load一个预训练模型就三行代码,训练一个模型也就是写写配置文件的事,为什么要自己从头写?
我打个比方你就懂了。学做饭,你天天用外卖App点餐,永远不知道一道菜是怎么做出来的。等到哪天外卖平台挂了、或者你想做的菜外卖没有,你就抓瞎了。自己从买食材、洗菜、切菜、下锅开始做一遍,哪怕做的不好吃,你至少知道一道菜从生到熟经历了什么,也知道哪个环节出了问题可以怎么补救。
AI工程也是一样。框架帮你把forward、backward都写好了,但如果你不知道梯度是怎么流的、损失函数为什么对某些样本不敏感、学习率为什么要有warmup,那你遇到问题的时候,连排查方向都没有。
我见过太多“调参工程师”了——模型效果不好,就改学习率、改batch size、换loss,跟庙里摇签一样。但如果你从零手写过一次模型训练,你就知道,学习率只是表象,真正的问题可能出在数据分布、特征尺度、甚至是你写的那一行view到底有没有把维度搞对。
所以,“from scratch”的核心价值,不是让你把时间花在重复造轮子上,而是帮你把黑盒拆成白盒。你不需要所有东西都从零写,但关键环节,比如数据流、前向传播、反向传播、优化器更新、推理解码,你至少要亲手走一遍。
1.2 自顶向下还是自底向上:两条学习路线怎么选
在规划“从零开始”的学习路径之前,你得先选一条主线。市面上的学习路线大致分两种:
自顶向下(Top-Down):先学框架怎么用、模型怎么调,遇到问题再补底层知识。好处是上手快,一两周就能跑通一个模型,成就感强。坏处是根基不稳,遇到非常规问题容易翻车。
自底向上(Bottom-Up):先学数学基础、手动实现核心算法,再过渡到框架。好处是原理通透,换个框架、换个任务都不慌。坏处是曲线陡,很多人坚持不到动手写模型就放弃了。
我最推荐的其实是“混合路线”:用一个小目标牵引,但实现过程中把关键模块都手写一遍。比如你的目标是训练一个能生成唐诗的模型。那你就不要直接model = GPT2LMHeadModel.from_pretrained(...),而是自己用nn.Transformer或者干脆从nn.Linear开始搭一个极简的decoder-only模型,数据加载、embedding、attention mask、loss、采样全自己写。这个过程走通了,你再去用框架里的现成模块、预训练模型,感觉完全不一样。
我现在带新人,基本也是这个思路。先让他手写一个两层的MLP在MNIST上跑通,然后让他手写一个mini transformer在莎士比亚数据集上训一个字符级语言模型。这两关过了,后面的工作基本就是“搭积木”,而不是“看天书”。
2. 搭建AI工程知识栈:数学、编程与框架选型
2.1 数学基础:学到什么程度才算够
说到“from scratch”,很多人的第一反应是“数学是不是要学到博士水平”。我的答案是:不需要,但核心概念必须会算、会推。
具体来说,线性代数你需要掌握:矩阵乘法(这个真的会有人搞错维度)、转置、矩阵求导的基本法则(特别是链式法则的矩阵形式)。微积分你需要掌握:偏导数、梯度、链式法则。概率论你需要掌握:条件概率、贝叶斯公式、期望与方差、常见的分布(正态、伯努利、多项分布)。信息论方面,交叉熵、KL散度是要懂的,因为绝大多数分类任务的loss都跟这个有关。
很多人会问,那矩阵求导要不要像数学系那样一个个推导?真不用。你只需要知道,当一个(batch, seq_len, hidden)的张量经过某个变换后,梯度应该怎么流向它的输入。说白了,你只要会检查“形状对不对”就够了,这就是深度学习框架为什么叫“自动求导”的原因——梯度计算交给autograd,但你要有能力判断梯度是否合理。
我记得自己第一次手写softmax + cross entropy的反向传播时,被那个(y_hat - y) / batch_size的形状搞得头大。后来发现,只要把softmax的雅可比矩阵推导一遍,你就永远都不会怕梯度维度对不上了。建议你也可以试试手推一遍,真的会有质变。
2.2 编程基础:Python之外的隐形门槛
AI工程里的“工程”俩字,意味着你光会写模型代码是不够的。
首先是Python本身的熟练度。不是那种“会写for循环”的熟练度,而是你要对list、dict、set的复杂度心里有数,要熟悉*args、**kwargs、装饰器、上下文管理器这些Pythonic的写法。为什么重要?因为你在调试的时候会疯狂跟Python解释器打交道,如果你连异常堆栈都不会读,那就寸步难行。
其次是NumPy。PyTorch的tensor和NumPy的array长得非常像,但机制完全不同。NumPy是immediate执行,PyTorch是构建计算图。你如果NumPy玩得溜,学PyTorch会快很多。我自己带人的时候,会让新人先用NumPy手写一个最简单的线性回归,不用PyTorch,感受一下什么是手动更新参数。这个练习做完,再切到PyTorch,你会觉得“自动求导也太爽了吧”。
再就是工程配套工具。Git是必须会的,别用那种“把代码发给我”的方式协作;虚拟环境管理(conda或venv)必须会用,不然装包能把环境搞崩;还有requirements.txt、日志输出、断点调试(pdb或IDE里的debugger)、以及基本的性能分析,这些都是“工程”二字的重要组成部分。
我把这部分排优先级的话:Python熟练度 > NumPy > Git与命令行 > 调试工具 > 虚拟环境。这些不指望你一门课学完,但要有个意识,然后在一个个项目的推进中逐步补齐。
2.3 框架选型:为什么推荐从PyTorch开始
每个时代都有主流框架。早几年是TensorFlow,这两年PyTorch基本是学术界和工业界的默认选择了。新入门的话,我建议直接学PyTorch。
原因很简单,第一,生态好。绝大多数论文的官方实现是PyTorch,HuggingFace Transformers库也是PyTorch优先,这意味着你能找到海量的开源代码可以参考。第二,动态计算图。PyTorch默认是eager模式,写起来就像在写普通的Python代码,调试特别方便,你可以在forward里加print、加断点,不会像静态图那样让你感觉在跟一个黑盒编译器较劲。第三,Pythonic。PyTorch的API设计很符合Python直觉,torch.Tensor的用法和NumPy几乎一样。
当然,TensorFlow也不一无是处,尤其在生产部署的某些场景还有存量。但你学的时候如果有一个主框架,把深度学习核心概念彻底搞懂,切框架是很轻松的事。真正在底层起作用的,还是你对张量操作、自动求导、模型结构这些共性的理解。框架只是工具。
另外多说一句,JAX也值得留意。它是Google出的一个可微编程框架,近年来在科研圈增长很快,特点是函数式风格和JIT编译。但新手阶段我不建议用它入门,因为它的编程范式跟常规的Python直觉差异大,容易把你绕晕。
3. 从零构建一个小型推理模型的完整实操
3.1 定义问题:先选一个玩具级任务
理论说了一堆,下面进入实战。我想用一个非常经典也很有代表性的任务来演示:训练一个字符级的mini语言模型,让它学会生成莎士比亚风格的文本。
这个任务是“AI Engineering from Scratch”的最佳入门,原因有三点:
第一,它覆盖了大语言模型(LLM)的核心全流程:数据分词(字符级)、embedding、transformer block、decoder-only结构、语言建模loss(交叉熵)、推理采样。
第二,不需要大算力。一个几百M参数的小模型,在哪怕只有一个普通显卡的机器上也能跑。如果你连GPU都没有,用Colab的免费版也能跑起来,就是慢点。
第三,直观有趣。训完之后你可以让模型自己续写文本,看到它从输出一堆乱码到越来越像人话,那个过程很有成就感。
《Build a Large Language Model (From Scratch)》这本书里的实现思路,基本上也是沿着这个路线走的。我觉得这个思路很值得学习——先做一个玩具级模型,理解核心机制,再逐步scale up到更大的数据和更大的模型。
3.2 数据准备:字符级分词与张量化
做语言模型,第一个环节是数据准备。如果你用HuggingFace的tokenizer,是看不到“分词”内部原理的。所以我建议在入门阶段,直接用字符级分词(character-level tokenization),这会让你彻底搞明白token到底长什么样。
步骤很简单:
import numpy as np text = open("shakespeare.txt", "r", encoding="utf-8").read() chars = sorted(list(set(text))) vocab_size = len(chars) 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)这里有一个关键点:字符级分词意味着词表大小就是字符的种类数,通常只有几十到上百个。这个模型一个token就是一个字符,所以它生成文本时是一字一字蹦出来的。工业级的大模型会用到BPE(Byte Pair Encoding)这类子词分词器,把“tokenization”变成更复杂的过程,但字符级是理解这套流程的最简入口。
接着要构造训练样本。给定一段连续的文本序列,模型的任务是预测下一个字符。所以你要构造输入(x)和目标(y),其中y是x向左平移一位的结果。我习惯这样写:
import torch block_size = 64 # 每个样本的序列长度 data = torch.tensor(encode(text), dtype=torch.long) batch_size = 32 def get_batch(data, block_size, batch_size): 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]) return x, y这段代码简单直接,但有几个细节需要展开说。
block_size相当于“上下文窗口”,决定模型能看到多长的历史。太小了,模型记不住前文;太大了,训练成本高。对字符级模型,64到128就够用了。batch_size决定了每个step能看到多少条序列。这里的索引采样是随机的,也就是说每个batch从整篇文本里随机抽取起点,而不是按顺序切分。这么做的好处是训练效率高,坏处是有轻微的序列边界截断问题,但对玩具模型无伤大雅。
3.3 实现模型架构:手写一个极简Decoder-Only Transformer
数据搞定了,现在到了核心:从零实现一个小型Transformer。我见过太多人的代码是直接调nn.Transformer的,但你用这个库时,注意力mask是啥形状、为什么需要causal mask、residual connection在哪、layer norm放在什么位置,这些关键细节都是隐藏的。所以我建议至少要手写一个简化版。
我先把代码结构列出来,然后逐模块解释:
import torch.nn as nn import torch.nn.functional as F class LayerNorm(nn.Module): def __init__(self, dim, eps=1e-5): super().__init__() self.gamma = nn.Parameter(torch.ones(dim)) self.beta = nn.Parameter(torch.zeros(dim)) self.eps = eps def forward(self, x): mean = x.mean(-1, keepdim=True) var = x.var(-1, keepdim=True, unbiased=False) return (x - mean) / torch.sqrt(var + self.eps) * self.gamma + self.beta class AttentionHead(nn.Module): def __init__(self, dim, head_dim): super().__init__() self.q = nn.Linear(dim, head_dim, bias=False) self.k = nn.Linear(dim, head_dim, bias=False) self.v = nn.Linear(dim, head_dim, bias=False) def forward(self, x): B, T, C = x.shape q = self.q(x) k = self.k(x) v = self.v(x) attn_weights = q @ k.transpose(-2, -1) / (q.shape[-1] ** 0.5) # 下三角mask,保证只能看前面的token causal_mask = torch.tril(torch.ones(T, T, device=x.device)).view(1, T, T) attn_weights = attn_weights.masked_fill(causal_mask == 0, float("-inf")) attn_weights = F.softmax(attn_weights, dim=-1) out = attn_weights @ v return out为什么要scaled by sqrt(d)?如果不除,attention分数会随着维度增大而变得很大,在softmax之后分布会很尖锐(接近one-hot),导致梯度变得很小。除以根号d是为了把方差拉回可控范围。
为什么必须用因果mask?因为训练的时候模型的任务是“根据前面的token预测下一个token”,在预测第t个位置的时候,它不应该看到t之后的任何信息。如果不mask,模型就“作弊”了,直接看到了答案。推理阶段,输入只有当前能看到的tokens,天然满足因果性,但训练阶段每个样本是完整序列,所以必须mask掉未来信息。
然后是Multi-Head的组装。多头注意力就是把维度切分成几份,每个头独立做attention,然后concat起来。注意,不同头会学到不同位置的关系——有的头关注语法,有的头关注邻近词搭配,有的头关注长距离依赖。我们不需要手动指定哪个头干什么,训练自己会分化。
class MultiHeadAttention(nn.Module): def __init__(self, dim, n_heads, head_dim): super().__init__() self.heads = nn.ModuleList([AttentionHead(dim, head_dim) for _ in range(n_heads)]) self.proj = nn.Linear(n_heads * head_dim, dim) def forward(self, x): out = torch.cat([h(x) for h in self.heads], dim=-1) return self.proj(out)接下去是前馈网络(Feed-Forward Network, FFN)。Transformer里attention负责“收集信息”,FFN负责“加工信息”。FFN通常是把维度先放大4倍,做非线性激活,再缩回去。这里用GELU或者ReLU都可以,GELU在很多大模型里更流行。
最后把每个block组合起来,block内部顺序是:attn + residual,然后FFN + residual。注意LayerNorm在现代架构里通常是Pre-LN(在attention和FFN之前做归一层),而不是原始论文里的Post-LN。Pre-LN的好处是训练稳定,适合深网络。你如果喜欢折腾,可以两种都试试,看看对loss收敛的影响。
完整block:
class Block(nn.Module): def __init__(self, dim, n_heads): super().__init__() head_dim = dim // n_heads self.ln1 = LayerNorm(dim) self.attn = MultiHeadAttention(dim, n_heads, head_dim) self.ln2 = LayerNorm(dim) self.ffn = nn.Sequential( nn.Linear(dim, 4 * dim), nn.GELU(), nn.Linear(4 * dim, dim), ) def forward(self, x): x = x + self.attn(self.ln1(x)) x = x + self.ffn(self.ln2(x)) return x最后是整个语言模型本体:token embedding + position embedding + 若干个Block + LayerNorm + 输出线性层 + cross entropy loss。embedding层把离散的token映射为连续向量,位置embedding给序列注入位置信息。因为attention本身是顺序无关的(它只是计算两两token的相似度,不看它们在序列里的先后位置),所以必须额外注入位置信息。
class MiniGPT(nn.Module): def __init__(self, vocab_size, dim, n_heads, n_blocks, block_size): super().__init__() self.token_embedding = nn.Embedding(vocab_size, dim) self.pos_embedding = nn.Parameter(torch.zeros(1, block_size, dim)) self.blocks = nn.Sequential(*[Block(dim, n_heads) for _ in range(n_blocks)]) self.ln_f = LayerNorm(dim) self.lm_head = nn.Linear(dim, vocab_size) def forward(self, idx, targets=None): B, T = idx.shape tok_emb = self.token_embedding(idx) pos_emb = self.pos_embedding[:, :T, :] x = tok_emb + pos_emb x = self.blocks(x) x = self.ln_f(x) logits = self.lm_head(x) 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 return logits这里有个小知识点:为什么不直接把nn.CrossEntropyLoss放进forward里,而是要做view(B*T, C)?因为交叉熵期望的logits形状是(N, C),targets形状是(N,),而我们原始的logits是三维(B, T, C),需要把它压平。
这个mini模型,参数总量大概在几百万量级。相比之下,GPT-2是1.5亿参数,GPT-3是1750亿参数。但麻雀虽小五脏俱全,你能从这个模型中观察到attention的pattern、loss的变化曲线、生成的文本演进,这些体验和在大模型上是一致的,只是规模小太多,跑起来很快。
3.4 训练循环:从loss不下降到loss稳定
模型篇代码写完,下面进入训练环节。很多教程会把训练代码一笔带过,但我认为训练循环才是新手最容易写错的地方。
最典型的错误有:忘掉optimizer.zero_grad()、把loss.backward()写在step之后、学习率设置不合理。这些看起来不起眼,但每一个都足以让你的模型白练。
一个标准的PyTorch训练循环:
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4) for step in range(5000): xb, yb = get_batch(data, block_size, batch_size) _, loss = model(xb, yb) optimizer.zero_grad() loss.backward() optimizer.step() if step % 500 == 0: print(f"step {step}, loss {loss.item():.4f}")几个细节要特别说明:
关于学习率:语言模型的常见学习率在1e-4到3e-4之间。太大会导致loss震荡甚至发散,太小会收敛很慢。如果你用的是AdamW,默认的betas=(0.9, 0.999)一般不用动,weight_decay设置0.01到0.1之间。很多人只关注学习率而忽略weight decay,其实这个对泛化能力影响也不小。
关于梯度裁剪:大模型训练里,梯度裁剪几乎是标配。在.backward()之后、.step()之前,加一行nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。这个操作可以防止梯度爆炸,特别是在训练初期、loss快速变化的阶段。我看到过不少模型loss突然变成NaN,往往就是没做裁剪。
关于batch大小与显存:如果你的显存不够,batch_size可以调小到8甚至4,同时适当把学习率也调小一点。因为batch越小,梯度噪声越大,学习率太高会不稳。这也是一个工程经验:改batch size时记得考虑是不是该同步改学习率。
跑起来之后,你会看到loss从初始的ln(vocab_size)附近开始下降。注意这个初始值:如果你的词表有65个字符,ln(65)≈4.17。如果初始loss远高于这个值,说明模型架构有问题;如果一开始就低于这个值,也说明有问题(因为模型不可能在没训练过的情况下就比随机猜测更好)。
这个初始值check是一个很有用的调试技巧。很多人遇到模型不work,第一反应是改网络结构,但我会先看初始loss对不对。
随着训练推进,loss会慢慢降到2.5、2.0、甚至1.5左右。对你来说,训练2000-5000步就够了,不需要等它收敛到完美——玩具模型的目的是跑通流程,不是刷指标。
3.5 推理实现:让模型“开口说话”
训练完的模型怎么生成文本?这里需要“自回归生成”:每次给模型一个前缀,让它预测下一个token的概率分布,从中采样一个token,然后把新token拼接到输入里,重复这个过程。注意,这里不是简单地取argmax,而是采样,否则模型会陷入重复的死循环。
一个朴素的采样函数:
def generate(model, idx, max_new_tokens): model.eval() for _ in range(max_new_tokens): idx_cond = idx[:, -block_size:] logits = model(idx_cond) logits = logits[:, -1, :] probs = F.softmax(logits, dim=-1) idx_next = torch.multinomial(probs, num_samples=1) idx = torch.cat((idx, idx_next), dim=1) return idx几个关键点:
为什么只取最后一个位置的logits?因为在自回归模型中,预测下一个token只需要用到最后一个位置的输出,它已经编码了前面所有上下文的信息。
为什么要限制idx_cond不超过block_size?因为模型训练时只见过最多block_size长度的输入,超过这个长度位置编码是没训练过的(严格来说,pos_embedding里的参数覆盖了block_size,但后续位置是越界的)。在推理时,我们只保留最近block_size个token作为上下文,超出部分直接截掉。
为什么用torch.multinomial而不是argmax?argmax会得到确定性输出,模型会不断重复同一句话。采样则引入了随机性,让生成文本更自然,但也可能导致偶尔语法不通。你可以加个temperature参数来控制采样随机性:logits = logits / temperature,温度越低越保守,温度越高越发散。
我实际跑出来的效果是这样:刚开始训练几百步,模型输出的是一串随机字符;训练一两千步后,它开始能拼出一些像英文单词的片段;到四五千步,它偶尔会蹦出一个完整的莎士比亚式短语。这个过程真的很神奇,你亲眼看到一个“没有灵魂”的矩阵运算组合,通过梯度下降慢慢学会了语言的统计规律。
4. 从模型到工程:训练、评估与部署的完整链路
4.1 训练实验管理:留痕比跑分更重要
很多“from scratch”的教学到训练完就结束了,但真正的AI工程远不止于此。你训练的时候,难道不想知道哪个配置效果好、哪种学习率最优吗?如果每次都手动记笔记,很快你就会发疯。
我的习惯是,从一开始就养成“实验留痕”的习惯。最简单的方式是固定的目录结构加csv日志:
experiments/ 2025-01-01_shake_char_base/ config.py train.log model.pth train_losses.csvconfig.py记录超参数,train_losses.csv记录每个step的loss。有了这些,你不仅能看到自己试过什么,还能复盘“为什么这个配置不行”。等你的项目复杂到需要几十上百次实验时,这个习惯就变成刚需了。再往上走,可以学习一下wandb或mlflow这类实验管理工具,但新手阶段,先自己搭个简单的就好。
4.2 评估指标:loss下降不等于模型好用
loss是训练指标,但用户真正关心的是生成质量。训练集上的loss和实际生成质量之间的关系不是线性的——有时候loss降得很好,生成质量却一塌糊涂。
一个很常见的现象:模型在训练集上loss很低,但你让它生成新文本时,它总是在复述训练集里的句子。这就是过拟合。怎么在玩具模型上快速判断?把你的数据分成训练集和验证集,每训练几轮就在验证集上跑一次loss。如果验证loss不再下降甚至上升,而训练loss还在降,说明过拟合了。
要不要用更复杂的指标比如BLEU、ROUGE?对字符级玩具模型来说,这些指标意义不大。但我建议你用“人工评估”的方式直观感受:让模型生成几段文本,自己看看流畅度、语法、风格。这其实也对应了大模型评估中的核心方法论——人类偏好评估。别小看这个,很多人训练完模型,连生成几段样本看一眼都不做,就急着改结构、调参数,这本质上是一种盲目。
4.3 部署与性能优化:从“能跑”到“跑得快”
模型训完了,也要会用,这里就涉及工程化的部署。
推理部署相比训练,有一个完全不同的优化思路。训练关注的是吞吐量,部署关注的是延迟。比如你是做一个Web服务,用户点击“生成”,你肯定希望200ms内返回,而不是等好几秒。
最少要会做几件事。
第一,模型导出。PyTorch的训练模型不能直接用于生产服务,通常要转成更高效的格式。最简单的做法是直接用model.eval()加torch.no_grad(),但这还不够快。进一步,你可以用torch.compile(),它会对计算图做融合优化,在某些场景下能提升30%-50%的推理速度。再进一步,可以用ONNX导出,然后配合ONNX Runtime或者TensorRT做推理。
第二,权重量化。模型在推理时默认是float32,但你可以转成float16甚至int8。内存占用直接减半甚至减到四分之一,速度也明显提升。代价是精度略微下降。对生成任务来说,float16几乎无损,int8则要具体评估。
第三,批处理。Web服务如果在高并发场景下,单请求一个batch太浪费GPU算力。工程上可以做动态batching,把多个请求凑成一个batch喂给模型,大幅提升吞吐。这一点在实际生产里非常常用,但很多人只知道“训练的时候用batch”,不知道“部署也能用batch”。
记住这个核心理念:工程化的本质不是“模型有多聪明”,而是“在给定的硬件和延迟约束下,尽可能把模型能力最大化地交付给用户”。这个意识,才是“AI engineering”里“engineering”的真正含义。
5. 常见问题与排查技巧实录
5.1 损失不降反升,先别急着改模型结构
遇到loss不降的情况,很多人第一反应是“模型太简单了,我要加深加宽”。这个判断往往错误。根据我的经验,loss不corner通常有几种常见原因,按优先级排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| loss完全不降,维持初始值 | 数据加载出错,标签跟输入错位 | 打印一个batch,检查x和y的关系 |
| loss在震荡 | 学习率过大 | 把学习率降一个数量级再试 |
| loss正常下降但突然变成NaN | 梯度爆炸 | 加梯度裁剪clip_grad_norm_ |
| 训练loss降,但验证loss不降 | 过拟合 | 增加数据/减小模型/加正则化 |
| 输出全是一个token | softmax温度太低导致重复,或注意力mask写错 | 检查推理代码,温度调高 |
这里我想单独说一下“数据加载出错”这个问题。别看它low,这是我见过最多的bug来源。比如我构造y = x[i+1:...]的时候,如果不小心把切片写成了x[i:...],模型学到的就是“预测当前token”,loss也会降,但生成结果一定是胡说八道。这种错误只有在你手动检查一个batch的实际内容时才会发现。
我专门写过一个函数,打印batch里的中英文字符序列给新手看,让他们亲眼确认“输入是前一个token,预测目标是后一个token”。这种检查听起来很笨,但真的很有效。
5.2 因果mask写错:模型偷看未来,效果虚高
因果mask是自回归模型最容易写错的地方之一。常见的错误包括mask的方向搞反、mask没有正确广播到batch维度、mask在attention weights加在softmax之后等等。
一个经典的debug方法是:构造一个只有两个token的序列[A, B],单独跑一次forward,打印attention权重。如果第一个token的attention分数在softmax之后只对自己有非零值,第二个token对第一个和第二个都有非零值,那说明mask是对的。如果第二个token对自己和第一个token的softmax分数一样高,那很可能是方向写反了。
另一个相关的错误是:在推理时,有些实现会把完整生成的序列每一轮都重新计算一遍attention,重复计算所以很慢。正确做法是KV Cache——把已经计算过的K和V缓存下来,每步只计算新token的K和V。玩具模型里可以不做这个优化,但你应该知道这个机制的名字。
5.3 显存不够:batch size不是唯一可调的旋钮
很多人在本地机器上跑模型,一张消费级显卡,显存8G或16G。batch size稍微调大一点就OOM。除了调小batch size,你还有几个选择:
- 用
gradient_accumulation_steps做梯度累积。每N个小batch累积一次梯度,再更新参数。效果等同于增大batch size,显存却不受影响。 - 降低序列长度
block_size。显存占用跟序列长度也成正比。 - 开启
torch.cuda.amp混合精度训练。把部分操作从float32转成float16,显存和速度都能改善。
不过有一点提醒:混合精度在大的语言模型上是标配,但对小模型收益不大,甚至可能因为额外的转换开销变慢。所以不是所有优化手段都适合所有场景,你得自己实验。
5.4 生成文本质量差:从工程和模型两个方向排查
如果模型训练完,生成的文本不连贯,先别急着骂模型“学得不好”。分两步排查。
第一步,检查训练数据是否干净。如果语料里有大量乱码、重复片段、不同语种混杂,模型学到的东西当然不伦不类。
第二步,检查推理超参。temperature设太低,模型倾向于输出概率最高的token链,容易陷入重复;temperature设太高,输出就变成随机的字符序列。top-k和top-p采样也能显著影响生成质量。我自己常用的一组保守参数是:temperature=0.8,top_k=40,top_p=0.9。你可以用这组参数做baseline,再微调。
第三步,如果前面都没问题,再考虑是不是训练步数不够、模型容量不足。注意,别一上来就堆参数,先把前面几步排查干净再说。
5.5 一个我自己踩过的坑:忘记model.train()
这里说一个比较搞笑的经历。有次我用一个已经训练好的模型继续训练,因为刚做完推理还没切回训练模式,模型里的dropout层和BatchNorm层仍然在推理模式,导致loss降得特别慢,而且曲线怪怪的。我排查了半天,最后发现是忘了加model.train()。
在PyTorch里,model.eval()和model.train()会切换dropout、batch normalization这些层的行为。如果你在训练时忘记切回train模式,dropout不会生效,BN也不会计算batch的统计数据,最终影响就可能很隐蔽。对于纯Transformer结构来说,它的残差、层归一化不依赖这个切换,但只要你用了dropout,就必须检查这里。
我习惯在代码里把训练前几行写成固定的模板:
model.train() optimizer.zero_grad()这三行连在一起,形成肌肉记忆,就不会忘了。
6. 学习资源、动手实践与进阶路径
6.1 那些经典的“from scratch”学习材料怎么用
如果你要把“AI engineering from scratch”这条路走通,市面上有几份非常经典的学习材料,我亲测过,也带人用过,可以负责任地推荐给你。
第一份是Sebastian Raschka的《Build a Large Language Model (From Scratch)》这本书。这本书最大的优点就是“真的从零开始”——不依赖任何现成的大模型库,从数据准备到模型架构再到训练,全部手写代码,而且代码都能跑。我建议按章节跟着敲,千万不要只看不写。每章后面的练习一定要做,那才是真正检验你是否理解的地方。
第二份是Andrej Karpathy的YouTube视频教程,特别是“Let‘s build GPT: from scratch”那一期。Karpathy是那种能把复杂东西讲得很通透的人,他带着你一行行写代码,从bigram模型开始逐步演进到mini版GPT。他的代码有一个特点:风格极简,没有花哨的类封装,就是为了让你看清本质。但我觉得他视频的最大价值还不是代码,而是讲“为什么”——为什么要做normalization、为什么会有residual connection、为什么需要用position encoding,这些都讲透了。
第三份是大模型入门必读的原始论文。不要怕读论文,推荐先读《Attention Is All You Need》(Transformer论文)和GPT系列论文(GPT-1、GPT-2、GPT-3)。读论文的目的不是让你背诵结构,而是理解论文里的某些设计决策背后的动机。你会发现,当初的Transformer是没有LayerNorm位置的讨论的,是后来训练不稳定才慢慢摸索出Pre-LN架构。这些历史沉淀下来,才是真正的工程经验。
我必须要提醒的是:学习资源别贪多。把上面其中一份资料彻底啃完,比存十份资料在收藏夹吃灰有价值得多。这个领域最大的学习陷阱不是“没有资料”,而是“资料太多导致行动瘫痪”。
6.2 从玩具模型到reasoning model:差距在哪里
标题里提到了一个很热的概念:build a reasoning model from scratch。我自己最近也在折腾reasoning model,这个方向非常新,还处于快速演进阶段。
从我们前面写的mini语言模型,到能进行数学推理、逻辑推理的reasoning model,中间的差距可以拆成几层。
第一层,规模和数据。reasoning model的基础是一个足够强的预训练语言模型,这个模型要有足够大的参数量、足够丰富的训练数据。你手写的mini模型是几十万参数,而reasoning model的底座动辄几十亿、几百亿参数,这之间差了四个数量级。规模带来的不只是量的提升,还会产生质的改变——小模型学不会的推理能力,在大模型上会“涌现”出来。
第二层,训练范式。Reasoning model不是简单地在预训练基础上继续预测下一个token,而是要引入“思考”的环节,常用的范式有Self-Consistency、Chain-of-Thought(思维链)微调、以及R1-style的强化学习训练。这些方法的核心思想是让模型在给出最终答案之前,先产生一段推理过程。你可以猜到这个流程和自回归生成的基本机制完全一样——它只不过把“推理轨迹”也当成token序列来生成。
第三层,评测与对齐。Reasoning model要能解决数学题、编程题、逻辑推理题,所以需要搭建评测集,并且要防止模型“为了推理而推理”——生成很长一段推理过程但是最终答案错误。
对于入门者,我的建议是先把基础的LLM全流程走通,再进阶到SFT(监督微调)、LoRA、RLHF或GRPO这些训练范式,最后才去碰reasoning model。别一上来就追最热的概念,底子不牢,追概念也只是看热闹。
6.3 进阶路线:下一步可以做什么
如果你已经把上面的代码跑通了,那恭喜你,你已经不再是“AI小白”了。接下来怎么进阶?我根据自己的经验,给你几条可选的路线。
一是往“广度”走。用HuggingFace Transformers库,替换手写代码,在真实的预训练模型上做微调,比如微调一个对话模型、做情感分类、做命名实体识别。这条路线适合想快速做应用的人。
二是往“深度”走。继续手写更复杂的东西:实现一个BPE分词器、实现KV Cache、实现LoRA微调、手写一个简单的训练分布式框架。这条路线适合想搞懂底层原理、将来做框架开发或算法研究的人。
三是往“数据”走。自己从网上爬数据,做清洗去重,构建一个高质量的自有数据集,然后训练自己的领域模型。这条路线适合想在垂直场景做差异化应用的人。
还有一个建议:给自己找一个“项目式目标”。不要泛泛地“学习AI”,而是选定一个小项目,比如“写一个唐诗生成器”“做一个客服意图分类器”“做一个本地运行的问答助手”。目标明确之后,你自然知道该学什么、该查什么,学习效率会高出很多倍。
写在最后
文章写到这里,我把“AI engineering from scratch”的整个学习路径、核心原理和实操细节都过了一遍。还是想跟你强调一下,从零开始做的事情,看上去效率低,但它构建的是你的判断力和解决问题的能力。我见过太多用框架用得飞起、但是模型一出错就束手无策的人,也见过数学功底扎实但写代码像屎山、谁都不想接手的人。真正的AI工程能力,是在这两个方向上有基础、还能把它们串起来。
最后分享一个小技巧:每当你学完一个环节,试着把代码删掉,然后凭记忆重新实现一遍。如果第二次写的时候还能流畅跑通,说明你真的掌握了。如果卡住了,就回去翻资料继续练。这一招我每次带新人都会用,效果出奇的好。
这条路很长,但每一步都值得。希望你也能从零开始,建出属于你自己的第一座AI大厦。