干这行这几年,经常被人问到一个问题:想入门 AI 工程,是不是必须先把数学啃穿、把论文读透?我的答案一直都很明确:不用,但你必须亲手把一个东西从零造出来。不是说非得去复现一篇顶会论文,而是说,你至少要经历一次“模型跑不动、loss 不降、显存爆掉、调参调到怀疑人生”的全过程。今天这篇,我就拿“ai-engineering-from-scratch”这个项目当作主线,把从零构建一套 AI 工程能力需要踩的路、绕的坑、以及真正值得投入精力的地方,一次性讲清楚。
这不是一篇让你“看完就会”的文章,因为 AI 工程没有任何“看完就会”的捷径。这更像是一份路线图加实操笔记:我把自己从搭环境、造数据、手写模型到训练调参的全过程拆开给你看。无论你是刚接触 AI 的学生、想转行的开发,还是已经在用现成模型 API 的工程师,这篇文章都能帮你补上“从调用者到构建者”之间最缺的那块拼图。
1. 项目整体思路拆解:什么叫真正的“from scratch”
很多人听到 from scratch,第一反应是“从零开始写代码”。但作为工程实践,这个理解其实有点偏。真正的 from scratch,核心不在于“不抄别人的代码”,而在于不依赖任何现成的黑盒——从数据准备到模型训练,你要对每一个环节都有掌控力。
1.1 两条常见路线的对比
网上关于 AI 入门的路线五花八门,但归纳起来无非两条:
| 路线 | 典型做法 | 核心问题 |
|---|---|---|
| 工具优先 | 先学调用现成模型,跑通 API,再做应用层开发 | 训练细节、数据分布、调参逻辑全是黑盒,遇到性能问题无从下手 |
| 原理优先 | 先啃完线性代数、概率论、凸优化,再碰代码 | 成本极高,大量数学知识在实际早期工程中用不上,容易劝退 |
我走的路线算是第三条:以“手写一个迷你版核心模型”为锚点,倒推需要补什么知识。要写注意力机制,就去补矩阵乘法;要写反向传播,就去理解链式法则;要调学习率,就去了解优化器的原理。这种做法最大的好处是——每个知识点都有明确的“使用场景”,学完就能用,用完就能加深理解。
1.2 工程思维的起点:设计约束
真正的 AI 工程,第一步不是写模型,而是定义边界。我在这个项目里给自己定了四个硬性约束:
- 单卡 GPU(显存 8GB 以内)能跑完整个训练流程;
- 不依赖任何预训练模型权重,全部参数从随机初始化开始;
- 使用公开的小型数据集,避免数据版权和下载困难的问题;
- 整个项目代码控制在 2000 行以内,保证可读性和可维护性。
这四个约束听起来简单,但对后续的每个决策都产生了实质性影响。比如模型规模必须控制在百万参数级别,词汇表不能太大,序列长度不能太长,训练步数也不能太多。约束不是限制,反而是最好的设计指引——它逼着你在每个环节都想清楚“为什么这么做”,而不是无脑套用大模型的配置。
1.3 技术栈选型:不要为了用而用
选型方面,我直接用了最主流的组合:PyTorch + HuggingFace Tokenizers + 自研训练循环。选 PyTorch 没什么悬念,生态最成熟、资料最多。选 HuggingFace Tokenizers 是为了省掉手写 BPE 的痛苦——这玩意儿没几百行代码很难写好,而且容易出隐蔽 bug。训练循环则完全自己写,因为这一部分才是 AI 工程的核心能力所在。
有一点想提醒大家:不要迷信“全手写”。有人喜欢连 DataLoader、优化器都要自己实现,觉得这才叫 from scratch。但工程的目标是可靠地解决问题,不是彰显代码量。Tokenizer 用现成的、优化器用 PyTorch 内置的,完全不影响你理解底层原理——因为你还是会自己实现模型结构,自己写训练逻辑。
2. 环境搭建与数据工程:最容易翻车的地方
很多人以为 AI 工程最难的是模型,实际上我见过太多项目死在环境和数据上。这个环节没做好,后面模型再漂亮也白搭。
2.1 可复现的环境配置
我就直接说结论:用 Docker 或 conda 锁环境,别用裸机。AI 项目最烦人的一件事是,今天能跑的代码,过两个月换了依赖版本就报错。PyTorch、CUDA、Transformer 库之间版本兼容性极其敏感,一个版本错位就可能导致 GPU 完全用不上。
我项目的环境配置大概是这样的:
conda create -n ai-eng python=3.10 conda activate ai-eng pip install torch==2.1.0 --index-url https://download.pytorch.org/whl/cu118 pip install transformers tokenizers datasets numpy tqdm matplotlib这里有个非常实在的建议:先确认自己的 CUDA 驱动版本,再选 PyTorch 的 CUDA 版本。用nvidia-smi看驱动支持的 CUDA 版本,用python -c "import torch; print(torch.cuda.is_available())"验证安装是否成功。如果你在这步耽误超过一个小时,说明环境有问题,直接考虑用 Docker 镜像。
2.2 数据集选择的经验之谈
数据方面,我用的是 TinyShakespeare——一个只有 1MB 左右的小型文本数据集,内容是莎士比亚作品的合集。选它的原因很朴素:
- 文件小,任意网络环境都能快速下载;
- 纯英文,不需要额外处理中文分词;
- 文本质量高,训练出来的模型能明显看到“像英语”的痕迹。
数据处理的流程大概是:
from datasets import load_dataset dataset = load_dataset("tiny_shakespeare", split="train") text = "\n".join(dataset["text"]) # 写入本地,方便反复使用 with open("data/tiny_shakespeare.txt", "w") as f: f.write(text) print(f"总字符数: {len(text)}")别看这个数据集小,它足以触发深度学习里几乎所有典型问题——过拟合、loss 震荡、生成质量差、重复文本循环等。小数据反而更练手艺,因为它把你逼到“必须精细调参才能出效果”的境地。
2.3 从字符到张量:Tokenizer 的选型与实施
对于这个项目,我选择的方案是训练一个字符级 BPE Tokenizer。字符级的意思是,先按字符拆分文本,再用 BPE 算法合并高频字符组合成子词。为什么不直接用现成的 GPT-2 tokenizer?因为 GPT-2 的词汇表是英文语料训练出来的,直接用在莎士比亚文本上虽然也能跑,但分词粒度不一定最优。更重要的是,自己训练 tokenizer 能让你彻底理解 NLP 流水线的前端环节。
from tokenizers import Tokenizer, models, trainers # 初始化 BPE 模型 tokenizer = Tokenizer(models.BPE(unk_token="<unk>")) trainer = trainers.BpeTrainer( vocab_size=4096, # 模型很小,词汇表不宜过大 special_tokens=["<pad>", "<bos>", "<eos>", "<unk>"] ) # 训练 tokenizer files = ["data/tiny_shakespeare.txt"] tokenizer.train(files, trainer) # 保存 tokenizer.save("data/tokenizer.json")这里有一个容易犯的错误:vocab_size 和 embedding 维度的关系。词汇表越大,embedding 矩阵参数量就越大。如果词汇表设成 30000 而 embedding 维度只有 128,光 embedding 层就有 384 万个参数——对一个百万级参数总量的小模型来说太奢侈了。所以我把词汇表压到了 4096,刚好覆盖莎士比亚文本中绝大多数子词单元。
3. 核心模型实现:注意力机制、位置编码与训练循环
到了重头戏。这个项目的模型核心是 GPT 风格的 decoder-only Transformer,但尺寸被大幅缩小。我逐层拆开讲。
3.1 模型参数配置与维度推导
先给出一组我用下来比较顺手的参数:
| 参数 | 数值 | 说明 |
|---|---|---|
| vocab_size | 4096 | 匹配 BPE 词汇表大小 |
| block_size | 128 | 上下文窗口,即模型一次能看到的 token 数 |
| n_embed | 128 | 嵌入向量维度 |
| n_head | 4 | 注意力头数 |
| n_layer | 3 | Transformer block 数量 |
| dropout | 0.1 | 防止过拟合 |
参数量大约在 230 万左右,非常轻量。为什么用 128 的 embedding 维度?因为 4 个头,每个头的维度是 32。这符合注意力机制的设计逻辑——每个头在 32 维的子空间里学习不同的语义模式。如果 n_embed 变成 256,头维度变成 64,效果也完全可行,但训练速度会明显变慢。
维度推导的逻辑是这样的:
- 输入形状:(batch_size, block_size) = (32, 128);
- 经过 token embedding 后:(32, 128, 128),即每个 token 变成 128 维向量;
- 经过 position embedding 相加,形状不变;
- 经过 3 层 Transformer block,每层的形状保持 (32, 128, 128);
- 经过 lm_head 线性层映射到词汇表维度:(32, 128, 4096);
- 取最后一个位置的输出,计算交叉熵损失。
所有关键形状都能手算清楚,这个项目的建模能力就过关了一半。
3.2 自注意力机制的从零实现
自注意力是整个 Transformer 的灵魂。公式很简单,但代码里有很多细节。我直接贴核心实现:
class CausalSelfAttention(nn.Module): def __init__(self, config): super().__init__() assert config.n_embed % config.n_head == 0 self.c_attn = nn.Linear(config.n_embed, 3 * config.n_embed) self.c_proj = nn.Linear(config.n_embed, config.n_embed) self.n_head = config.n_head self.n_embed = config.n_embed def forward(self, x): B, T, C = x.size() q, k, v = self.c_attn(x).split(self.n_embed, dim=2) q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) 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 / sqrt(k.size(-1))) att = att.masked_fill(self.causal_mask[:, :, :T, :T] == 0, float("-inf")) att = F.softmax(att, dim=-1) y = att @ v y = y.transpose(1, 2).contiguous().view(B, T, C) return self.c_proj(y)这里有三个关键细节:
第一,因果掩码(causal mask)的实现。训练时模型不能看到未来的 token,所以注意力分数矩阵的右上三角区域要被遮住。常见做法是生成一个下三角矩阵,把需要屏蔽的位置替换为-inf,再过 softmax 后这些位置的权重会变成 0。
第二,qkv 从同一个线性层输出再 split。这是 GPT 论文里的做法,用一个矩阵计算三个投影,比分开写三个线性层更高效,虽然数学上等价。初学者建议先拆开写,理解后再合并。
第三,缩放因子 1/sqrt(d_k)。为什么需要这个缩放?因为当维度变大时,点积的方差也随之增大,导致 softmax 的梯度趋近于零。缩放之后把方差拉回 1 的量级,训练更稳定。
3.3 位置编码:用简单的,还是用可学习的?
这个项目里我用的位置编码是 PyTorch 自带的nn.Embedding,也就是可学习的位置向量。初始化一个 (block_size, n_embed) 的矩阵,训练过程中会逐步更新。
为什么不做 Sinusoidal 位置编码?在原始的 Transformer 论文里,位置编码是手工设计的正弦余弦函数,好处是不需要额外参数。但对于这种规模的小模型,可学习位置编码的表现反而更好,因为模型容量小,手工设计的先验特征不一定能充分发挥。
位置编码的一个常见陷阱是:训练时序列长度是 128,但推理时想生成 200 个 token,位置编码不够用了。解决方式很粗暴但没有更好——要么训练时就支持最大长度,要么在推理长度逼近位置编码上限时做截断。这也是为什么 GPT 模型都有固定的 context window。
3.4 训练循环:从数据流到权重更新
训练循环的核心代码如下:
def train(model, dataloader, optimizer, scheduler, config): model.train() total_loss = 0 for batch_idx, batch in enumerate(dataloader): x, y = batch x, y = x.to(device), y.to(device) optimizer.zero_grad() logits = model(x) loss = F.cross_entropy( logits.view(-1, logits.size(-1)), y.view(-1), ignore_index=-1 ) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() total_loss += loss.item() if batch_idx % 100 == 0: print(f"step {batch_idx}, loss: {loss.item():.4f}")比较关键的是clip_grad_norm_这一行。梯度裁剪是我认为最被低估的工程技巧。小模型训练中,偶尔一个 batch 的梯度会异常大,如果不裁剪,模型参数会被推到一个不稳定的区域,导致后续所有 step 的 loss 都无法下降。裁剪到 1.0 基本能避免 90% 的“loss 突然变成 NaN”问题。
优化器我用的是AdamW。AdamW 比 Adam 多了一个权重衰减的解耦,简单说就是权重衰减不再被 Adam 的一阶矩和二阶矩估计干扰,正则化更干净。学习率设置比较激进:前 500 个 step warmup,从 1e-7 线性升到 1e-3,之后用余弦退火降回 1e-4。这个 warmup 机制在小模型上尤其重要,因为它防止模型在刚开始训练时用巨大的梯度更新把参数从随机初始化的位置彻底冲散。
4. 训练执行与调参实战:loss 不降怎么办
这部分是干货中的干货。训练过程中你会遇到一千种问题,但归纳起来就几个大类。我逐个说解法。
4.1 训练中的过拟合识别
TinyShakespeare 只有 1MB 左右,而模型有 230 万参数,理论上完全有能力把训练集背下来。如果你发现 train loss 持续下降但 validation loss 不下降甚至上升,恭喜你,过拟合了。
三个解决手段:
- 加大 dropout,从 0.1 调到 0.2 或 0.3;
- 提前停止,当 validation loss 连续 N 个 epoch 不下降就停;
- 数据增强:对于文本,可以做随机的段落拼接或者改写,但实际操作起来收益有限,不如直接加 dropout 或者调高权重衰减。
这里插一个经验:小数据 + 小模型,过拟合的速度远超你想象。我见过很多第一次做这个项目的朋友,训练不到 3000 步 validation loss 就开始回升了,但他们还盯着 train loss 期待模型变聪明。要建立纪律:只看 validation loss 和生成样例做判断,不要被 train loss 迷惑。
4.2 损失函数的学习曲线解读
训练一个语言模型,你最终会得到一个规律性的 loss 曲线:一开始快速下降,然后缓慢下降,最后平台期震荡。
以字符级语言模型为例,如果词汇表是 4096,随机初始化的 cross-entropy loss 接近 ln(4096) ≈ 8.32。训练开始后,loss 会快速掉到 4 以下,这意味着模型已经学会了最简单的 n-gram 规律。再往下,从 4 到 3 可能需要几万步,因为模型在逐渐学习更复杂的语义依赖。
如何判断曲线是否健康?看三个信号:一是 loss 是否在下降,二是下降速度是否逐渐减缓,三是 loss 是否出现周期性的大幅波动。如果大幅波动频繁,大概率是学习率太高,或者某个 batch 的数据质量异常。遇到这种情况,先把学习率减小一个数量级再试。
4.3 生成阶段的效果评估
训练结束后,用模型生成文本进行直观检验。生成逻辑是:输入一个开头,模型预测下一个 token 的概率分布,然后从这个分布中采样,把采样结果拼接到输入末尾,再继续预测下一个 token。
生成的温度参数(temperature)对结果影响巨大:
| 温度 | 概率分布表现 | 生成效果 |
|---|---|---|
| 0.1 ~ 0.3 | 接近 argmax | 文本重复度高,保守但稳定 |
| 0.7 ~ 0.9 | 适度平滑 | 通顺的文本,有创意但不大胆 |
| 1.2 以上 | 接近均匀分布 | 随机性强,语法容易错乱 |
我用的默认值是 0.8,输出既能看出语言结构又有一定多样性。另外,top-k 采样(每次只从概率最高的 K 个候选中采样)可以进一步控制质量,K 设为 50 在莎士比亚数据集上效果不错。
5. 常见问题与排查技巧实录:我踩过的坑,希望你别再踩
写这一节的时候,我翻了一下自己的实验日志,把那些最有代表性的问题整理成了表格。这些问题基本覆盖了从环境搭建到训练完成的各个阶段。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| CUDA 不可用,torch.cuda.is_available() 返回 False | PyTorch 的 CUDA 版本和驱动不匹配 | 用 nvidia-smi 查驱动版本,卸载重装对应 PyTorch 版本 |
| 训练时 loss 持续为 8.3 左右不下降 | 模型输出没有学起来,可能是学习率太低或 tokenizer 词汇表与模型输出维度不匹配 | 检查 vocab_size 是否一致,学习率改为 warmup + 余弦退火 |
| loss 出现 NaN | 梯度爆炸,或者 learning rate 过高 | 添加梯度裁剪,降低学习率 |
| 生成的文本全是“the the the”之类的重复 | 温度太低或模型容量不足 | 调高温度到 0.8+,或者增大 n_embed 和 n_layer |
| validation loss 不降反升 | 过拟合 | 增大 dropout,提前停止,减少训练步数 |
| 训练速度极慢 | batch size 过大或数据加载瓶颈 | 用 torch.utils.data.DataLoader 的 num_workers 参数,或者减小 batch size |
| save/load 后模型效果消失 | 模型权重和优化器状态没对应上 | 保存 checkpoint 时同时保存 model_state_dict 和 optimizer_state_dict |
5.1 环境配置的坑:CUDA 版本不匹配
这个坑太经典了。很多新手按网上的教程装了最新版 PyTorch,结果发现 CUDA 不可用。原因在于 PyTorch 的预编译包绑定了特定 CUDA 版本,如果你的显卡驱动较老,就无法使用。检查方法:
# 查看驱动支持的 CUDA 版本 nvidia-smi # 在 Python 中查看 PyTorch 编译的 CUDA 版本 python -c "import torch; print(torch.version.cuda)"两者不匹配时,要么升级驱动,要么更换 PyTorch 版本。这里我强烈推荐 pip 安装方式而不建议 conda,因为 pip 对 CUDA 版本的控制更精细,不容易装出冲突。
5.2 数据 Tokenizer 不匹配的坑
这是一个隐蔽性极强的 bug:训练时 tokenizer 的 vocab_size 和模型输出层维度不一致,代码不会报错,但 loss 会一直维持在一个很高的值,因为模型把概率分布映射到了错误的维度上。排查方法很简单——打印一下两边的值:
print("tokenizer vocab size:", tokenizer.get_vocab_size()) print("model vocab size:", model.lm_head.out_features)两个数必须完全一致。这个 bug 最气人的地方在于,它不会导致训练崩溃,只会让你觉得“我的模型是不是有问题”,白白浪费大量时间去调学习率。
5.3 多 GPU 体验与显存优化
虽然我的约束是单卡,但工程中难免遇到大模型需要多卡训练的情况。讲几个显存优化的核心思路:
- 梯度累积:模拟大 batch size,但显存占用不变;
- 梯度检查点:用计算时间换显存,在前向传播时不保存中间激活值,反向传播时重新计算;
- 混合精度训练:用 fp16 或 bf16 存储参数,显存直接减半。
训练小模型的时候不需要这么复杂,但理解这些思路有助于你以后扩展到更大的项目。我的建议是:先把单卡小模型跑通并产出合格结果,再考虑分布式训练。不要跳级,否则你会被叠加的复杂度整崩溃。
5.4 可复现性:随机种子的管理
如果你希望训练结果可复现,必须设置随机种子:
import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)注意即使设置了种子,GPU 上的某些操作仍然不完全确定。如果对复现要求极高,需要固定torch.backends.cudnn.deterministic = True,但这会牺牲一些训练速度。对一般实验来说,设置种子保证大致可复现就够了。
6. 从模型到工程:部署与后续扩展
训练出一个模型只是开始,真正的 AI 工程还包含推理部署、监控和迭代。
6.1 模型导出与推理部署
训练好的模型需要转换成适合部署的格式。PyTorch 提供了两种常见方式:
# 方式一:TorchScript,适合跨语言调用 model_scripted = torch.jit.script(model) model_scripted.save("model_scripted.pt") # 方式二:ONNX,适合跨框架部署 torch.onnx.export( model, dummy_input, "model.onnx", opset_version=17, input_names=["input_ids"], output_names=["logits"] )对于这个项目,ONNX 导出有明显的优势——不需要在部署环境安装 PyTorch,且换用 ONNX Runtime 后推理速度会快不少。但有一个需要注意的问题:自定义的模型结构(比如自定义 attention mask)在 ONNX 导出时可能会遇到算子兼容性问题,需要逐层验证。这也是一个小技巧:保持模型结构尽量使用标准 PyTorch 算子,便于后续导出。
6.2 推理服务的性能优化
部署阶段最常遇到的瓶颈是推理速度。对于这种百万参数的小模型,CPU 其实已经能跑,但如果你想追求更好的体验:
- 使用半精度推理:
model.half()可以让 GPU 推理吞吐翻倍; - 使用 KV Cache:在自回归生成时只缓存历史 attention 的 K 和 V,避免重复计算;
- 批量推理:同时生成多个序列,充分利用 GPU 并行能力。
KV Cache 是工程中用到最多的优化,尤其在做聊天机器人类应用时必开。实现不算复杂,就是在 Transformer block 里额外传递一个 cache 字典。
6.3 后续扩展:从迷你模型到真实项目
当你走完这一整套流程,你会发现自己的能力地图已经完全不同了。下一步的扩展方向有很多,我列出几个优先级最高的:
- 扩大数据规模:从 1MB 的数据集换成更大的公开语料,你会马上感受到数据质量对模型效果的决定性影响;
- 引入微调流程:在预训练基础上做指令微调,学习如何构造训练数据、如何选择微调超参数;
- 实现分布式训练:学习 DDP、DeepSpeed 的思路,把模型训练从单卡扩展到多卡;
- 模型量化:把 fp32 的权重压缩到 int8 甚至 int4,体验一下部署真正的落地。
我的建议是,不要急着跳到下一个新技术,先把当前这套从零构建的流程反复跑几遍,直到你闭着眼都能说清楚每个环节的输入输出和关键参数。AI 工程的能力不是靠广度堆出来的,而是靠深度磨出来的。
以我个人经验来看,把 from-scratch 的流程走完一遍,带给你的不仅是代码能力,还有一种“任何模型我都能理清来龙去脉”的信心。以后再面对大模型、复杂应用,你不再是一个只会调用接口的人,而是一个能真正理解系统从哪里来、为什么这么设计、问题可能出在哪个环节的工程师。把这条路线走扎实,AI 工程的大门才算真正为你敞开。