1. 从“会用”到“会造”:一门AI工程课的范式转变
最近在技术社区里,一个名为“ai-engineering-from-scratch”的课程项目引起了不小的讨论。它的标题就很有意思——“当一门课程不满足于教你‘会用AI’,它开始逼你亲手造AI”。这短短一句话,精准地戳中了当前AI学习领域的一个普遍痛点:我们似乎正处在一个“API调用师”过剩,而“系统构建者”稀缺的时代。
每天,我们被各种“10分钟搭建AI应用”、“用ChatGPT API实现XX功能”的教程包围。这些内容当然有价值,它们降低了门槛,让更多人能快速体验到AI的能力。但久而久之,一个隐忧浮现了:我们是否过于依赖那些封装好的、黑盒般的云服务和大模型API?当我们需要定制一个特殊的推理逻辑、优化一个关键环节的性能、或者将一个AI模块深度集成到一个复杂的生产系统中时,仅仅会调用openai.ChatCompletion.create()是远远不够的。我们可能对背后的张量如何流动、梯度如何下降、模型如何被压缩和部署一无所知。这种“知其然不知其所以然”的状态,在追求快速原型时没问题,但在构建严肃、可靠、可维护的AI系统时,就成了阿喀琉斯之踵。
“ai-engineering-from-scratch”这门课,在我看来,其核心价值就在于它试图扭转这种局面。它不满足于让你成为一个熟练的“API拼接工”,而是立志于将你训练成一个能从零开始搭建AI系统核心组件的“工程师”。这意味着你需要深入更底层,去理解数据如何被处理成模型能“消化”的格式,一个最简单的神经网络层是如何前向传播和反向传播的,一个训练循环是如何被组织和监控的,以及最终训练好的模型如何被打包、优化并服务于真实的线上流量。这门课覆盖了从Python、TypeScript到Rust、Julia等多种语言,这本身也暗示了现代AI工程是一个多层次、多工具链的复合体,不同的语言在数据科学、Web服务、高性能计算等不同层面扮演着关键角色。接下来,我将结合对这门课程内容的观察和个人在AI工程化中的实践经验,拆解从“使用”到“建造”这一跃迁过程中的核心环节与心法。
2. 课程核心设计思路:构建“第一性原理”认知
2.1 为何要“从零开始”?
“从零开始”(From Scratch)听起来有些复古,在充斥着各种成熟框架(PyTorch, TensorFlow, JAX)的今天,甚至显得有点“自讨苦吃”。但这正是这门课程设计最精妙也最具挑战性的地方。它的目的不是让你去重复发明轮子,然后在生产环境中使用自己写的、漏洞百出的深度学习框架。相反,它的目标是通过亲手建造轮子,来彻底理解轮子为何要这样设计。
当你使用torch.nn.Linear时,它只是一个黑盒。你知道输入维度、输出维度,给它数据它就能工作。但如果你亲手用NumPy实现一个全连接层,你会被迫思考并解决一系列问题:权重矩阵W的维度应该是(input_dim, output_dim)还是(output_dim, input_dim)?前向传播时,是X @ W还是W @ X(这里假设X是批处理数据)?偏置b如何正确地加到每个样本上?反向传播时,如何根据损失函数对输出的梯度,计算出对W和b的梯度?这些梯度下降的公式又是如何推导出来的?
这个过程极其痛苦,但也极其有效。在调试一个自己写的、跑不通的Linear层时,你对矩阵求导、链式法则、计算图的理解会以指数级速度加深。此后,你再看到PyTorch的Linear层,你看它的眼神就不同了。你不再把它看作一个魔法函数,而是一个由清晰数学原理和工程考量构成的具体实现。当它出现一些诡异的行为(比如梯度爆炸或消失)时,你脑海里的第一反应不再是盲目搜索Stack Overflow,而是能根据其内部运作原理进行有根据的假设和排查。
注意:“从零实现”的度需要把握好。课程明智地选择了关键组件(如层、优化器、损失函数)进行手写,而对于更底层的数值计算(如卷积的im2col操作)、自动微分引擎本身,可能仍然依赖现有库。我们的目标是理解原理,而非重建整个软件生态。
2.2 多语言栈的工程意义
课程提到了Python, TypeScript, Rust, Julia。这四种语言的选择并非随意,它们分别对应了AI工程流水线上的不同阶段,体现了现代AI系统从研发到部署的全栈视角。
- Python:研发与原型设计的绝对主力。这是起点。课程中“从零实现”的核心部分很可能在Python(配合NumPy)中完成。Python生态(NumPy, SciPy, Pandas)提供了极其友好且高效的科学计算和数据处理环境,是快速验证算法、进行实验分析的理想场所。手写模型组件能在这里得到最直观的体现。
- TypeScript:前端交互与边缘部署的桥梁。AI模型最终要为人所用,一个Web界面是最常见的交互方式。TypeScript能帮你构建健壮的前端应用,可视化训练过程、模型结果。更重要的是,随着
ONNX Runtime Web、TensorFlow.js等技术的发展,将训练好的模型直接转换并在浏览器或Node.js环境中运行已成为可能。TypeScript在这里扮演了将AI能力“产品化”和“服务化”的关键角色。 - Rust:高性能计算与系统集成的基石。当你需要将模型部署到对性能、内存安全、并发要求极高的生产环境时,Python可能就显得力不从心了。Rust以其零成本抽象、无畏并发和卓越的内存安全性,成为编写高性能推理引擎、自定义算子、或与现有后端系统深度集成的绝佳选择。例如,你可以用Rust重写模型中计算最密集的部分,或者构建一个高并发的模型服务API网关。
- Julia:科学计算与高性能研究的潜在利器。Julia的设计目标就是解决“两语言问题”(原型用Python/MATLAB,高性能部分用C++),其即时编译(JIT)特性使其在数值计算上性能可媲美C。对于涉及复杂数学建模、物理仿真或需要极致性能的科研导向AI项目,Julia是一个值得关注的选项。课程引入Julia,可能是为了展示在特定领域(如微分方程求解、优化问题)如何利用其性能优势。
这种多语言视角逼迫学习者思考:一个AI想法,如何从Python中的实验代码,一步步演化为一个由TypeScript构建界面、Rust提供核心计算服务、可能用Julia处理特定模块的完整、健壮、可扩展的工程系统。
3. 动手“造轮子”:关键组件实现深度解析
3.1 神经网络基础层的实现
我们以最基础的全连接层(Dense/Linear Layer)和ReLU激活函数为例,看看“从零实现”到底要做什么。
首先,初始化。一个全连接层需要两个可训练参数:权重矩阵W和偏置向量b。初始化方法至关重要,糟糕的初始化(如全零初始化)会导致梯度消失或爆炸。通常我们会采用Xavier或He初始化。
import numpy as np class Linear: def __init__(self, input_dim, output_dim): # He 初始化,适用于ReLU激活函数 self.W = np.random.randn(input_dim, output_dim) * np.sqrt(2. / input_dim) self.b = np.zeros((1, output_dim)) # 缓存输入,用于反向传播 self.x = None # 参数梯度 self.dW = None self.db = None前向传播相对直观,就是矩阵乘加运算。但这里有个细节:为了支持批量训练,我们的输入x形状通常是(batch_size, input_dim)。因此,我们需要确保矩阵乘法的维度正确,并且偏置b能正确地广播到每个样本上。
def forward(self, x): """ 前向传播 x: 形状 (batch_size, input_dim) 返回: 形状 (batch_size, output_dim) """ self.x = x # 缓存输入,反向传播时需要 out = np.dot(x, self.W) + self.b # 广播机制使b加到每个样本 return out反向传播是核心难点。假设从上一层(或损失函数)传回的是关于本层输出out的梯度dout(形状同out)。我们需要计算:
- 关于输入
x的梯度dx,以便继续向前一层传播。 - 关于参数
W和b的梯度dW和db,用于后续的参数更新。
根据多元微积分和链式法则,我们可以推导出:
dW = x.T @ dout(@表示矩阵乘法)db = np.sum(dout, axis=0, keepdims=True)(对批量维度求和)dx = dout @ W.T
def backward(self, dout): """ 反向传播 dout: 关于本层输出的梯度,形状 (batch_size, output_dim) 返回: 关于本层输入的梯度,形状 (batch_size, input_dim) """ batch_size = self.x.shape[0] # 计算关于参数的梯度 self.dW = np.dot(self.x.T, dout) / batch_size # 通常取平均,与损失函数对齐 self.db = np.sum(dout, axis=0, keepdims=True) / batch_size # 计算关于输入的梯度,并向前传递 dx = np.dot(dout, self.W.T) return dx实操心得:在实现反向传播时,最常遇到的bug是维度不匹配。一个非常有效的调试方法是使用梯度检查(Gradient Checking)。即使用数值梯度(通过给参数加一个很小的扰动来计算损失函数的变化)来验证你通过解析公式计算出的梯度(
dW,db)是否正确。虽然计算很慢,但在开发初期是保证正确性的“金标准”。
激活函数如ReLU的实现则简单很多,但它引入了非线性,这是神经网络能拟合复杂函数的关键。
class ReLU: def __init__(self): self.mask = None # 缓存输入大于0的位置 def forward(self, x): self.mask = (x > 0) return x * self.mask # 小于0的部分置为0 def backward(self, dout): # 只有在前向传播时输入>0的神经元,梯度才能回传 return dout * self.mask3.2 损失函数与优化器的构建
有了层,我们需要一个目标来指导学习,这就是损失函数。以多分类任务常用的交叉熵损失结合Softmax为例。Softmax将网络输出的原始分数(logits)转换为概率分布,交叉熵则衡量预测概率与真实标签(one-hot编码)之间的差距。
def softmax(x): # 数值稳定版:减去最大值防止指数运算溢出 exp_x = np.exp(x - np.max(x, axis=1, keepdims=True)) return exp_x / np.sum(exp_x, axis=1, keepdims=True) def cross_entropy_loss(probs, y_true): """ probs: 模型输出的概率,形状 (batch_size, num_classes) y_true: 真实标签的one-hot编码,形状 (batch_size, num_classes) """ batch_size = probs.shape[0] # 取正确类别对应概率的对数,并求平均负值 correct_log_probs = -np.log(probs[range(batch_size), np.argmax(y_true, axis=1)]) loss = np.sum(correct_log_probs) / batch_size return loss损失函数关于网络最终输出(Softmax输入)的梯度有一个非常简洁优美的形式:dout = probs - y_true。这是经过数学推导得出的,实现起来非常简单,但理解其推导过程对于掌握反向传播至关重要。
优化器负责根据梯度更新参数。最基础的随机梯度下降(SGD)实现如下:
class SGD: def __init__(self, parameters, lr=0.01): """ parameters: 一个列表,包含所有需要更新的参数(如层的W, b) lr: 学习率 """ self.params = parameters self.lr = lr def step(self): for param in self.params: param -= self.lr * param.grad # 假设每个参数都有.grad属性存储梯度 def zero_grad(self): for param in self.params: param.grad = np.zeros_like(param)更先进的优化器如Adam,会引入动量(Momentum)和自适应学习率,实现起来更复杂,但核心思想是一致的:利用梯度历史信息来更平滑、更智能地更新参数。
3.3 训练循环的组装与监控
将层、损失函数、优化器组装起来,就构成了一个完整的训练循环。这个循环是AI工程的“总控程序”。
# 假设我们有一个简单的两层网络 model = [Linear(784, 128), ReLU(), Linear(128, 10)] criterion = cross_entropy_loss optimizer = SGD([layer.W, layer.b for layer in model if hasattr(layer, 'W')], lr=0.01) for epoch in range(num_epochs): total_loss = 0 for batch_x, batch_y in dataloader: # 假设dataloader能提供批量数据 # 1. 前向传播 x = batch_x for layer in model: x = layer.forward(x) probs = softmax(x) # 最后一层输出后接Softmax # 2. 计算损失 loss = criterion(probs, batch_y) total_loss += loss # 3. 反向传播 # 计算损失函数对网络输出的梯度 dout = probs - batch_y # 交叉熵损失+Softmax的梯度简化形式 for layer in reversed(model): dout = layer.backward(dout) # 4. 参数更新 optimizer.step() optimizer.zero_grad() avg_loss = total_loss / len(dataloader) print(f"Epoch {epoch}, Loss: {avg_loss:.4f}") # 这里还可以在验证集上计算准确率这个简单的循环包含了深度学习训练的所有核心要素。在工程实践中,我们需要在其中加入大量“脚手架”代码:学习率调度、模型检查点保存、早停、使用TensorBoard或W&B进行可视化监控、分布式训练支持等。
4. 从原型到产品:工程化扩展实践
4.1 模型部署与服务化考量
在笔记本上训练出一个准确率不错的模型,只是万里长征第一步。如何让这个模型在线上稳定、高效、低延迟地服务成千上万的请求,是AI工程的核心挑战。
首先面临的是模型序列化与格式转换。你不能每次都重新训练模型。你需要将训练好的参数(W,b等)和模型结构保存下来。简单的方法可以用pickle保存整个Python对象,但这不利于跨语言使用。工业标准是使用**ONNX(Open Neural Network Exchange)**格式。你需要编写代码将你的“从零实现”的模型转换成ONNX的计算图表示。这个过程本身又是一个对模型计算图深度理解的机会。
# 伪代码示例:思考如何将自定义层映射到ONNX算子 # 你的Linear层的前向传播: out = np.dot(x, W) + b # 这对应ONNX中的 Gemm (General Matrix Multiplication) 算子 # 你需要将W, b作为初始值,构建一个包含Gemm节点的计算图模型服务化通常需要一个推理服务器。你可以用Python的Flask/FastAPI快速搭建一个API,但这在性能要求高的场景下可能成为瓶颈。这时,Rust的优势就体现出来了。你可以用Rust重写模型推理部分(尤其是前向传播),利用其零成本抽象和高并发特性(如Tokio运行时),构建一个高性能的HTTP或gRPC服务。对于更复杂的流水线(如图像预处理-模型推理-后处理),可以考虑使用专门的推理服务器如Triton Inference Server,它支持多框架模型、动态批处理、并发执行等高级特性。
4.2 性能优化与调试技巧
当你自己实现底层组件时,性能优化就从“调库参数”变成了“调自己代码”。
- 向量化操作:这是最基础的优化。确保所有操作都使用NumPy的向量化函数,避免Python层面的
for循环。例如,在计算批量数据的损失时,使用np.sum()和索引操作,而不是遍历每个样本。 - 内存布局与缓存:了解NumPy数组的C顺序(行优先)和F顺序(列优先)。不连续的数组切片(如
x[:, ::2])会导致缓存不友好,降低速度。尽量保证数据在内存中是连续存储的。 - 算法优化:例如,在实现卷积层时,朴素的六重循环(批、出通道、入通道、高、宽、卷积核)效率极低。需要实现或理解
im2col算法,将卷积操作转换为一个大的矩阵乘法,从而充分利用BLAS库的高性能。 - 性能剖析:使用
cProfile、line_profiler或py-spy等工具找出代码中的热点(Hotspot)。你可能会惊讶地发现,大部分时间可能花在了数据加载或预处理上,而不是模型前向传播。
调试自己写的AI系统比调试使用框架的系统更困难,因为你没有那些成熟的调试工具(如PyTorch的autograd.gradcheck或TensorFlow的tf.debugging)。除了之前提到的梯度检查,还可以:
- 前向传播检查:用一个小批量数据,手动计算第一层、第二层的输出,与你的代码输出对比。
- 梯度爆炸/消失监控:在训练初期,打印每一层权重梯度的范数(
np.linalg.norm(layer.dW))。如果梯度范数极大或接近0,说明网络可能存在问题(如初始化不当、激活函数饱和)。 - 可视化中间激活:对于图像任务,可以将卷积层输出的特征图可视化出来,看看网络是否学到了有意义的特征。
5. 常见问题与避坑指南
在从零构建AI系统的过程中,你会遇到无数个坑。以下是一些典型问题及其解决思路的实录:
问题1:训练损失不下降,准确率随机波动(约等于瞎猜)。
- 可能原因:
- 学习率过大或过小:过大导致在最优解附近震荡甚至发散;过小导致更新太慢,看似没变化。
- 梯度流中断:检查反向传播实现是否正确。特别是ReLU层,如果
mask缓存错误,梯度可能无法回传。 - 数据未归一化/标准化:输入数据尺度差异巨大,导致梯度不稳定。图像数据通常归一化到[0,1]或[-1,1]。
- 最后一层激活函数不当:做二分类或多分类,网络最后一层通常不要加激活函数(或使用Softmax),损失函数内部会处理。如果你在最后一层加了Sigmoid或Tanh,可能会将输出限制在一个饱和区,梯度很小。
- 排查步骤:
- 首先进行梯度检查,确保所有层的梯度计算正确。
- 尝试一个极小的学习率(如1e-5)和一个极大的学习率(如1.0),观察损失变化。如果小学习率缓慢下降,大学习率爆炸,说明梯度计算基本正确,问题在超参。
- 可视化第一层权重的分布,看是否在训练后发生了变化。如果没变,说明梯度根本没更新到第一层。
问题2:训练初期损失就变成NaN(Not a Number)。
- 可能原因:
- 计算中出现除零或log(0):在Softmax或交叉熵损失计算中,概率可能由于数值问题变成0,取对数时得到
-inf。务必使用数值稳定版的Softmax(减去最大值)。 - 梯度爆炸:权重初始化过大,或网络太深,导致前向传播的激活值或反向传播的梯度值呈指数级增长,最终超出浮点数表示范围。
- 计算中出现除零或log(0):在Softmax或交叉熵损失计算中,概率可能由于数值问题变成0,取对数时得到
- 解决方案:
- 在Softmax和交叉熵计算中加入微小epsilon(如
1e-8)防止除零:probs = exp_x / (np.sum(exp_x, axis=1, keepdims=True) + eps),log_probs = np.log(probs + eps)。 - 使用更合理的初始化(Xavier/He)。
- 加入梯度裁剪(Gradient Clipping):在调用
optimizer.step()之前,将所有参数的梯度限制在一个阈值内(如范数裁剪:grad_norm = np.linalg.norm(grad), 如果grad_norm > max_norm, 则grad = grad * max_norm / grad_norm)。
- 在Softmax和交叉熵计算中加入微小epsilon(如
问题3:模型在训练集上表现很好,在验证集上表现很差(过拟合)。
- 应对策略:
- 获取更多数据:最有效的方法,但通常成本最高。
- 数据增强(Data Augmentation):对训练数据进行随机变换(如旋转、裁剪、颜色抖动),在不增加新数据的情况下增加数据多样性。
- 正则化:
- L1/L2正则化:在损失函数中加入权重范数作为惩罚项,鼓励模型权重变小、变稀疏。你需要在损失计算中加上
lambda * np.sum(W ** 2),并在反向传播时在梯度中加上2 * lambda * W。 - Dropout:在训练时,随机将一部分神经元的输出置零。这强迫网络不能过度依赖某些特定的神经元,增强了鲁棒性。实现起来就是在层的前向传播中加入一个随机掩码。
- L1/L2正则化:在损失函数中加入权重范数作为惩罚项,鼓励模型权重变小、变稀疏。你需要在损失计算中加上
- 早停(Early Stopping):持续监控验证集损失,当其在连续多个epoch不再下降时,停止训练,并回滚到验证损失最小的那个epoch的模型参数。
问题4:推理速度慢,无法满足线上要求。
- 优化方向:
- 模型压缩与量化:
- 剪枝(Pruning):移除网络中不重要的权重(如接近0的权重)。
- 量化(Quantization):将模型参数和激活从32位浮点数(FP32)转换为8位整数(INT8)。这能大幅减少内存占用和加速计算,尤其有利于在移动端或边缘设备部署。你需要实现量化感知训练(QAT)或训练后量化(PTQ)的流程。
- 算子融合(Operator Fusion):将网络中连续的多个小算子(如Conv + BatchNorm + ReLU)融合成一个大的算子,减少内核启动开销和中间结果的读写。
- 使用更高效的推理后端:将模型转换为ONNX后,使用ONNX Runtime、TensorRT或OpenVINO等针对不同硬件(CPU, GPU, NPU)优化过的推理引擎进行部署,它们内置了大量图优化和内核优化。
- 模型压缩与量化:
走过这一遍“从零造AI”的艰难旅程后,再回头看那些高级的深度学习框架和云服务,你的感受会完全不同。你不再是一个被动的使用者,而是一个主动的理解者和批判者。你知道每一个便捷的API背后,都凝结着对数学原理的深刻理解和对工程难题的巧妙解决。当你在实际项目中遇到棘手的模型性能问题、诡异的训练行为或苛刻的部署需求时,这份“第一性原理”的认知将成为你最强大的调试工具和解决方案的来源。这门课的价值,或许不在于让你从此弃用PyTorch,而在于赋予你一种底气:当现有工具不够用时,你知道如何深入下去,自己动手解决问题。这,正是一名真正的AI工程师与一个单纯的AI工具使用者之间,最本质的区别。