尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零构建 AI 工程:手写 Transformer 与训练调参实战

从零构建 AI 工程:手写 Transformer 与训练调参实战 干这行这几年经常被人问到一个问题想入门 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 python3.10 conda activate ai-eng pip install torch2.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, splittrain) 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_tokenunk)) trainer trainers.BpeTrainer( vocab_size4096, # 模型很小词汇表不宜过大 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_size4096匹配 BPE 词汇表大小block_size128上下文窗口即模型一次能看到的 token 数n_embed128嵌入向量维度n_head4注意力头数n_layer3Transformer block 数量dropout0.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, dim2) 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(fstep {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() 返回 FalsePyTorch 的 CUDA 版本和驱动不匹配用 nvidia-smi 查驱动版本卸载重装对应 PyTorch 版本训练时 loss 持续为 8.3 左右不下降模型输出没有学起来可能是学习率太低或 tokenizer 词汇表与模型输出维度不匹配检查 vocab_size 是否一致学习率改为 warmup 余弦退火loss 出现 NaN梯度爆炸或者 learning rate 过高添加梯度裁剪降低学习率生成的文本全是“the the the”之类的重复温度太低或模型容量不足调高温度到 0.8或者增大 n_embed 和 n_layervalidation loss 不降反升过拟合增大 dropout提前停止减少训练步数训练速度极慢batch size 过大或数据加载瓶颈用 torch.utils.data.DataLoader 的 num_workers 参数或者减小 batch sizesave/load 后模型效果消失模型权重和优化器状态没对应上保存 checkpoint 时同时保存 model_state_dict 和 optimizer_state_dict5.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_version17, 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 工程的大门才算真正为你敞开。
返回列表