从零构建AI工程能力:手写神经网络与Transformer实战

发布时间:2026/10/3 3:42:57
从零构建AI工程能力:手写神经网络与Transformer实战 1. 从零构建AI工程能力为什么我劝你别急着调包这两年AI应用层的工具链成熟得吓人LangChain、LlamaIndex、各种Agent框架轮番上阵好像随便几行代码就能搭出一个“智能体”。但我自己带过几个项目、也帮朋友救过几次火之后越来越确信一件事如果你不理解底层到底发生了什么那些框架迟早会在某个深夜把你坑到怀疑人生。ai-engineering-from-scratch这个标题说的就是这条看起来笨、但走得最稳的路——不依赖现成的高级封装从最基础的张量运算、反向传播、注意力机制开始一步步把AI工程的核心能力长在自己身上。这篇文章适合谁看如果你是刚转行想做AI工程、被各种框架的抽象层搞得云里雾里的开发者或者你已经能跑通demo但一遇到显存爆炸、梯度消失、推理延迟高就束手无策再或者你单纯想搞清楚“大模型到底是怎么被训练和部署的”那这篇内容就是写给你的。我会把整个从零构建的过程拆成可操作的模块讲清楚每一步为什么这么做、坑在哪里、怎么绕过去。全文基于我自己的实践和踩坑记录不是教科书复述你可以直接拿去对照着做。2. 整体设计思路为什么“从零”反而更快2.1 先想清楚你要的到底是“会用”还是“能改”很多人一上来就问“学AI工程要不要先学PyTorch”这个问题本身就问偏了。工具是手段不是目的。ai-engineering-from-scratch的核心思路是先建立对计算过程的直觉再引入工具来加速。就像学开车你得先知道油门刹车方向盘分别控制什么再去用自动挡。如果你连矩阵乘法为什么能表达神经网络的前向传播都不清楚那调包调出来的模型你根本不知道它什么时候会崩。我自己的路径是这样的先用纯Python和NumPy手写一个两层神经网络在MNIST上跑到90%以上的准确率然后手写一个简化版的Transformer理解注意力权重的计算逻辑最后才引入PyTorch做工程化实现。这个过程大概花了我三周业余时间但后面省下的debug时间至少是十倍。2.2 技术选型的取舍NumPy打底PyTorch收尾为什么选NumPy而不是直接上PyTorch因为NumPy没有自动求导你必须自己推导反向传播的公式、自己实现梯度更新。这个过程极其痛苦但正是这种痛苦让你真正理解“梯度”到底是什么。等你手推过一遍链式法则再看PyTorch的loss.backward()你会有一种“哦原来你帮我做了这个”的顿悟感。具体选型上我的建议是阶段工具目的预计耗时基础运算NumPy理解张量、矩阵乘法、广播机制3-5天前向传播纯Python NumPy手写全连接层、激活函数3天反向传播纯Python NumPy手推梯度、实现SGD5-7天注意力机制NumPy手写Self-Attention3天工程化PyTorch复现、加速、部署持续这个顺序不能乱。我见过太多人直接跳到PyTorch结果连view()和reshape()的区别都说不清楚遇到维度不匹配的报错就卡住。2.3 避坑前提数学基础到底要多少一听到“从零”就有人担心数学。实话实说你不需要精通实分析或凸优化。你需要的是矩阵乘法的维度规则、偏导数的链式法则、以及一点点概率论softmax和交叉熵。这三样东西花一个周末就能补到够用的程度。我推荐的做法是边写代码边补数学——当你手写反向传播卡住的时候再去翻链式法则的推导印象会比干啃教材深十倍。注意不要陷入“先把数学学完再动手”的陷阱。AI工程的数学是工具性的够用就行边做边补效率最高。3. 核心细节解析从张量到注意力的关键实现3.1 张量运算一切AI计算的基石张量说白了就是多维数组。标量是0维向量是1维矩阵是2维再往上就是高维张量。AI工程里90%的操作都是张量之间的加减乘除和变形。我用NumPy实现一个最简单的张量类核心是搞清楚形状shape和广播broadcasting。广播机制是新手最容易翻车的地方。比如一个形状为(32, 128)的矩阵和一个形状为(128,)的向量相加NumPy会自动把向量扩展成(32, 128)再逐元素相加。这个规则看起来方便但一旦维度对不上报错信息往往让人摸不着头脑。我的经验是每次做运算前先打印两个操作数的shape养成这个习惯能省下大量排查时间。import numpy as np # 手写一个简单的线性层前向传播 def linear_forward(x, w, b): # x: (batch_size, in_features) # w: (in_features, out_features) # b: (out_features,) return np.dot(x, w) b x np.random.randn(32, 128) w np.random.randn(128, 64) * 0.01 b np.zeros(64) out linear_forward(x, w, b) print(out.shape) # (32, 64)这段代码简单到不能再简单但它是所有神经网络的基础。你把这个搞透了后面堆多少层都是同样的逻辑。3.2 反向传播手推梯度才是真正的分水岭反向传播的本质是链式法则的高效实现。我建议你拿一个两层网络输入层-隐藏层-输出层用MSE损失手动推导每一层的梯度公式。推导过程大概两页纸但推完之后你对“梯度”的理解会完全不一样。核心公式就三个输出层误差delta_out (y_pred - y_true) * activation_derivative(z_out)隐藏层误差delta_hidden (delta_out w_out.T) * activation_derivative(z_hidden)权重梯度grad_w hidden.T delta_out手写实现的时候最容易出错的是矩阵转置的位置和激活函数导数的计算。我的建议是先用一个极小的网络比如2个输入、3个隐藏、1个输出手动算一遍数值再用代码验证。数值对上了说明你的推导没问题。def backward(x, y_true, hidden, y_pred, w_out, z_hidden, z_out): # 假设激活函数是sigmoid def sigmoid_derivative(z): s 1 / (1 np.exp(-z)) return s * (1 - s) delta_out (y_pred - y_true) * sigmoid_derivative(z_out) grad_w_out hidden.T delta_out delta_hidden (delta_out w_out.T) * sigmoid_derivative(z_hidden) grad_w_hidden x.T delta_hidden return grad_w_hidden, grad_w_out这段代码我写了不下十遍每次重新推导都有新的体会。梯度消失的问题在这里就能直观感受到——sigmoid的导数最大只有0.25多层连乘之后梯度会指数级衰减。这也是为什么后来ReLU成了标配。3.3 注意力机制从公式到代码的完整映射Transformer的核心是Self-Attention公式看起来吓人拆开其实很清晰Attention(Q, K, V) softmax(QK^T / sqrt(d_k)) V其中Q是查询、K是键、V是值d_k是键的维度。除以sqrt(d_k)是为了防止点积结果过大导致softmax梯度消失。这个细节很多人忽略但它是训练稳定性的关键。我用NumPy手写一个单头注意力def self_attention(x, w_q, w_k, w_v): # x: (seq_len, d_model) q x w_q # (seq_len, d_k) k x w_k # (seq_len, d_k) v x w_v # (seq_len, d_v) d_k q.shape[-1] scores q k.T / np.sqrt(d_k) # (seq_len, seq_len) # softmax scores_max np.max(scores, axis-1, keepdimsTrue) exp_scores np.exp(scores - scores_max) attn_weights exp_scores / np.sum(exp_scores, axis-1, keepdimsTrue) output attn_weights v # (seq_len, d_v) return output, attn_weights这里有个实操细节softmax之前要减去最大值否则指数运算容易溢出。这个技巧叫“数值稳定softmax”在工程实现里是必须的。我当初手写的时候没注意结果输入稍微大一点就出NaN排查了半天。提示注意力权重的形状是(seq_len, seq_len)它表示每个位置对其他位置的关注程度。你可以把它可视化出来直观感受模型在“看”哪里。3.4 位置编码让模型知道顺序Self-Attention本身是位置无关的打乱输入顺序结果不变。所以需要位置编码来注入序列信息。原始Transformer用的是正弦余弦编码def positional_encoding(seq_len, d_model): pe np.zeros((seq_len, d_model)) for pos in range(seq_len): for i in range(0, d_model, 2): pe[pos, i] np.sin(pos / (10000 ** (i / d_model))) if i 1 d_model: pe[pos, i1] np.cos(pos / (10000 ** (i / d_model))) return pe这个编码的好处是任意两个位置之间的编码差异可以被线性变换表示模型能学到相对位置关系。后来RoPE旋转位置编码成了主流但理解正弦编码是理解RoPE的基础。4. 实操过程从零搭建一个迷你GPT4.1 环境准备与依赖安装我用的环境是Python 3.10 NumPy Matplotlib用于可视化。不需要GPUCPU就能跑通所有代码。依赖安装就一行pip install numpy matplotlib如果你后面要上PyTorch再加一行pip install torch。但我强烈建议先用NumPy把整个流程跑通再切到PyTorch。这个顺序不能反。4.2 数据准备用字符级语言模型练手我选了一个极简的任务字符级语言模型。输入是一段文本模型预测下一个字符。数据准备很简单text hello world, this is a tiny language model. chars sorted(list(set(text))) char_to_idx {ch: i for i, ch in enumerate(chars)} idx_to_char {i: ch for ch, i in char_to_idx.items()} # 构造训练样本 seq_len 8 inputs [] targets [] for i in range(len(text) - seq_len): inputs.append([char_to_idx[ch] for ch in text[i:iseq_len]]) targets.append(char_to_idx[text[iseq_len]]) inputs np.array(inputs) targets np.array(targets)这个数据集小到可以在CPU上秒级训练但包含了语言模型的全部核心要素词表映射、序列切分、下一词预测。你把这里的char换成token就是大模型训练的数据 pipeline。4.3 模型搭建手写一个单层Transformer我把模型拆成四个模块嵌入层、注意力层、前馈层、输出层。每个模块都用NumPy实现前向传播和反向传播都手写。嵌入层就是把离散的字符索引映射成稠密向量class Embedding: def __init__(self, vocab_size, d_model): self.weight np.random.randn(vocab_size, d_model) * 0.01 def forward(self, x): # x: (batch_size, seq_len) self.input x return self.weight[x] # (batch_size, seq_len, d_model) def backward(self, grad_output): grad_weight np.zeros_like(self.weight) np.add.at(grad_weight, self.input, grad_output) return grad_weight注意np.add.at的用法——当同一个索引出现多次时普通的会覆盖而不是累加必须用add.at。这个坑我踩过梯度更新不对导致模型完全不收敛。注意力层就是前面写的self-attention加上残差连接和LayerNorm。前馈层就是两个线性变换加ReLU。输出层把d_model映射回词表大小接softmax算交叉熵。整个模型参数量大概几万在CPU上训练几百个epoch就能看到loss明显下降。4.4 训练循环手写SGD和交叉熵训练循环的核心是前向传播算loss反向传播算梯度SGD更新参数。def train_step(model, inputs, targets, lr0.01): # 前向 logits model.forward(inputs) # (batch_size, vocab_size) # softmax 交叉熵 probs softmax(logits) loss -np.mean(np.log(probs[np.arange(len(targets)), targets])) # 反向 grad_logits probs.copy() grad_logits[np.arange(len(targets)), targets] - 1 grad_logits / len(targets) model.backward(grad_logits) # SGD更新 for param, grad in model.params_and_grads(): param - lr * grad return loss交叉熵的梯度有个非常优雅的性质softmax的输出减去one-hot标签就是logits的梯度。这个结论自己推一遍会很有成就感。4.5 推理与生成让模型“说话”训练完之后用模型生成文本def generate(model, start_text, length50, temperature1.0): input_seq [char_to_idx[ch] for ch in start_text] generated start_text for _ in range(length): logits model.forward(np.array([input_seq[-seq_len:]])) logits logits[0, -1] / temperature probs softmax(logits) next_idx np.random.choice(len(chars), pprobs) generated idx_to_char[next_idx] input_seq.append(next_idx) return generatedtemperature参数控制生成的随机性小于1更保守大于1更发散。这个参数在工程部署里非常关键直接影响用户体验。5. 常见问题与排查技巧实录5.1 梯度爆炸与消失诊断与解决手写反向传播时最常见的问题就是梯度爆炸或消失。诊断方法很简单打印每一层梯度的范数。如果某一层的梯度范数超过1e3基本就是爆炸了如果小于1e-6就是消失了。解决方案问题现象解决方法梯度爆炸loss变成NaN梯度范数极大梯度裁剪、减小学习率、更好的初始化梯度消失loss不下降浅层梯度接近0换ReLU、加残差连接、用LayerNorm梯度不稳定loss震荡剧烈调小学习率、增大batch size我自己的经验是初始化权重不要用标准正态要用缩放后的正态比如除以sqrt(fan_in)。这个细节能让训练稳定性提升一个档次。5.2 维度不匹配最常见的报错维度错误是手写代码时最高频的报错。我的排查流程是打印所有中间变量的shape对照公式检查矩阵乘法的维度规则检查转置是否用对比如(batch, seq, d_model)和(d_model, d_model)做矩阵乘法NumPy会自动广播但结果可能不是你想要的。显式地写出维度注释能避免90%的维度错误。5.3 数值稳定性NaN和Inf的预防除了softmax减最大值还有几个地方要注意除法前检查分母是否为0对数运算前加一个极小值1e-8梯度裁剪防止爆炸# 安全的交叉熵 def safe_cross_entropy(probs, targets): probs np.clip(probs, 1e-8, 1.0) return -np.mean(np.log(probs[np.arange(len(targets)), targets]))这些技巧在工程实现里是标配但自己从零写的时候很容易忽略。5.4 训练不收敛系统排查清单模型不收敛的原因很多我整理了一个排查顺序学习率太大loss震荡或爆炸先降到1e-3试试初始化太差权重全0或太大换缩放初始化数据有问题标签错位、输入未归一化梯度计算错误用数值梯度验证解析梯度模型太复杂先减小模型规模跑通再放大数值梯度验证是个杀手锏def numerical_gradient(f, x, eps1e-5): grad np.zeros_like(x) for i in range(x.size): x_flat x.flatten() x_flat[i] eps f_plus f(x_flat.reshape(x.shape)) x_flat[i] - 2 * eps f_minus f(x_flat.reshape(x.shape)) grad.flatten()[i] (f_plus - f_minus) / (2 * eps) return grad把解析梯度和数值梯度对比误差在1e-6以内就说明反向传播写对了。这个技巧帮我省了无数个小时。6. 从手写实现到工程部署的过渡6.1 什么时候该切到PyTorch手写实现是为了理解不是为了生产。当你满足以下条件时就该切到PyTorch了能徒手推导两层网络的反向传播理解注意力机制的每一个矩阵运算知道梯度消失/爆炸的原因和解决方法切过去之后你会发现PyTorch的autograd帮你省掉了所有梯度推导但你依然需要理解底层否则遇到RuntimeError: CUDA out of memory或者loss.backward()报错时你还是不知道怎么修。6.2 工程化的关键优化点从手写代码到生产部署有几个关键优化混合精度训练用float16加速loss scaling防止下溢梯度累积小显存跑大batchKV Cache推理时缓存键值对避免重复计算量化int8推理显存减半这些优化在PyTorch里都有现成API但理解它们的原理需要你对手写实现有足够的认知。6.3 我个人的学习路径建议如果你打算走这条路我的建议是第一周NumPy手写全连接网络MNIST跑到90%第二周手写反向传播数值梯度验证第三周手写Self-Attention可视化注意力权重第四周手写迷你GPT字符级生成之后切PyTorch复现优化部署这个路径看起来慢但每一步都踩实了后面会越来越快。我见过太多人跳过前三周直接上PyTorch结果半年后还在调包遇到问题就卡住。最后分享一个我自己的小习惯每次遇到新的模型架构先用NumPy手写一遍前向传播。不用写反向就写前向把维度跑通。这个习惯让我对BERT、GPT、T5这些架构的理解比看论文深得多。手写一遍比读十遍论文都管用。