ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零构建AI工程化:手写自动求导与推理优化实战

从零构建AI工程化:手写自动求导与推理优化实战 1. 这个项目到底在解决什么问题第一次看到 ai-engineering-from-scratch 这个标题我脑子里蹦出来的第一个念头是又一个教人调包的教程但仔细琢磨了一下 from scratch 这四个字我意识到它想做的事情可能完全不一样。市面上大部分 AI 工程化的内容要么是教你pip install transformers然后三行代码跑个推理要么是直接甩给你一套 LangChain 的模板让你填空。这些东西不是没用但它们有个共同的毛病——你学完之后遇到一个框架没覆盖的场景整个人就懵了。这个项目的核心价值我理解下来是把 AI 工程化这条链路上每一个环节都拆开给你看。从最底层的张量操作、梯度计算到模型训练、推理优化再到部署上线、监控运维它试图让你理解每一层在干什么、为什么这么干、不这么干会出什么问题。说白了它不是教你用工具而是教你造工具的思路。适合什么人看我觉得有三类人特别需要第一类是刚入行的算法工程师会用 PyTorch 但说不清楚反向传播到底怎么算的第二类是从后端转过来的工程师想搞明白模型部署为什么跟普通服务差别那么大第三类是有一定经验但总觉得基础不扎实的人比如我这种干了几年之后回头发现很多底层细节全靠框架兜底的。这个项目不太适合纯小白——如果你连 Python 的装饰器和生成器都没写明白直接啃这个会非常痛苦。但如果你有一点点编程基础又愿意花时间把每个环节都动手实现一遍那它的价值会比看十篇十分钟入门深度学习的教程大得多。2. 整体设计思路为什么非要从零开始2.1 从零实现不是目的理解边界才是很多人对 from scratch 有误解觉得非要手写一个完整的 Transformer 才算数。其实不是这样的。从零实现的核心目的是让你知道每个组件的输入输出是什么、计算量在哪里、内存瓶颈在哪里、数值稳定性怎么保证。你手写一遍矩阵乘法不是为了以后真的用自己写的版本而是为了理解为什么 GPU 上的矩阵乘法要分块、为什么要用 shared memory、为什么 batch size 大了之后显存会爆。我举个例子。你在 PyTorch 里写loss.backward()就完事了但如果你自己实现过一遍反向传播你就会知道每一层的梯度计算其实是在做链式法则的展开中间需要缓存前向传播的激活值这就是为什么训练比推理更吃显存。知道了这一点你在设计网络结构的时候就会下意识地考虑激活值占用的内存而不是等到 OOM 了才去查。2.2 分层递进的设计哲学这个项目的结构我推测应该是按照数据层、计算层、模型层、训练层、推理层、服务层这样一条线来组织的。每一层都建立在前一层的基础上但又可以独立理解。为什么这么设计因为 AI 工程化的复杂性恰恰来自于层与层之间的耦合。比如你在训练层遇到 loss 不收敛的问题可能根源在数据层的归一化没做好你在服务层遇到延迟高的问题可能根源在推理层没有做算子融合。如果你对每一层都有清晰的认识排查问题的时候就能快速定位到是哪一层的锅。这种分层思路还有一个好处它让你在学习的时候有明确的阶段性目标。你不需要一次性把所有东西都搞懂可以先把手写线性回归跑通再逐步加上非线性激活、多层堆叠、正则化、优化器等等。每一步都有可验证的产出不会学着学着就迷失了。2.3 工具选型的取舍逻辑从零开始做 AI 工程化工具选型是个绕不开的问题。我的经验是底层用 NumPy 理解原理中层用 PyTorch 验证实现上层用工程化工具解决实际问题。为什么底层要用 NumPy 而不是直接上 PyTorch因为 PyTorch 的自动求导太方便了方便到你根本不会去思考梯度是怎么算的。用 NumPy 手写一遍前向和反向虽然麻烦但能让你对计算图、链式法则、数值稳定性这些概念有肌肉记忆。为什么中层又要回到 PyTorch因为你需要一个参照系。你自己写的版本对不对跟 PyTorch 的输出对比一下就知道。而且实际工作中你不可能什么都手写PyTorch 的生态和性能优化是你必须掌握的。上层为什么强调工程化工具因为模型训练出来只是第一步怎么把它变成可用的服务、怎么监控它的表现、怎么在流量增长的时候扩容这些才是 AI 工程师日常面对的问题。这部分内容市面上讲得少但对实际工作的重要性一点都不低。3. 核心细节拆解每个环节到底在干什么3.1 数据管道最容易被低估的环节我见过太多项目在数据管道上翻车。模型结构调了半天最后发现是数据加载的时候 shuffle 没开或者归一化用了全局统计量导致数据泄露。从零实现数据管道你需要关注几个核心点。数据加载的效率问题。Python 的 GIL 决定了你不能用多线程来加速 CPU 密集型的预处理所以要用多进程。但多进程又有进程间通信的开销所以 batch size 和 worker 数量的配比需要调。我的经验是worker 数量设为 CPU 核心数的 0.8 倍左右比较合适batch size 要保证每个 worker 每次能处理足够多的样本摊薄通信开销。数据增强的时机。数据增强应该在 CPU 上做还是 GPU 上做答案是看情况。如果增强操作简单比如随机翻转可以在 GPU 上做省去 CPU 到 GPU 的传输如果增强操作复杂比如随机裁剪加颜色抖动在 CPU 上做更合适因为 GPU 的并行度用不满反而浪费。归一化的坑。计算均值和方差的时候一定要用训练集的统计量然后应用到验证集和测试集。我见过有人图省事在整个数据集上算均值和方差结果验证集的准确率虚高上线之后直接崩盘。这个坑我在实际项目中踩过排查了两天才发现是数据泄露。3.2 计算图与自动求导理解框架的底层逻辑自动求导是现代深度学习框架的核心。从零实现一个简易版的自动求导引擎能让你对计算图有非常直观的认识。核心思路是这样的每个张量除了存储数据之外还要存储三个东西——grad梯度值、requires_grad是否需要求导、grad_fn产生这个张量的函数。当你进行前向计算的时候框架会隐式地构建一张计算图记录每个操作之间的依赖关系。反向传播的时候从 loss 开始沿着计算图反向遍历每个节点根据自己的grad_fn计算梯度并传递给上游。这里有个关键细节计算图是在前向传播时动态构建的。这就是为什么 PyTorch 被称为动态图框架。动态图的好处是灵活你可以用 Python 的控制流if、for、while来构建计算图调试也方便。坏处是每次迭代都要重新构建计算图有一定的开销。静态图框架比如早期的 TensorFlow会把计算图先定义好再执行优化空间更大但灵活性差。实现自动求导的时候最容易出错的地方是梯度的累加。如果你不清零梯度梯度会在多次反向传播之间累加。这个设计其实是有意为之的——在某些场景下比如 RNN 的 truncated BPTT你需要手动控制梯度的累加和清零。但如果你不知道这个机制就会遇到训练几个 batch 之后 loss 突然爆炸的问题。3.3 模型训练从线性回归到多层网络训练一个模型表面上看就是前向传播算 loss、反向传播算梯度、优化器更新参数这三步循环。但每一步都有很多细节值得深挖。损失函数的选择。分类问题用交叉熵回归问题用均方误差这是常识。但为什么分类问题不用均方误差因为交叉熵的梯度形式更简洁而且当预测值偏离真实值越远的时候梯度越大学习越快。均方误差在分类问题上会出现梯度消失的问题——当预测值接近 0 或 1 的时候sigmoid 的导数接近 0梯度就传不回去了。优化器的选择。SGD 是最基础的但收敛慢。Momentum 加了动量能加速收敛并减少震荡。Adam 结合了动量和自适应学习率在大多数场景下表现都不错。但 Adam 有个问题它在某些任务上泛化性能不如 SGD。为什么有一种解释是 Adam 的自适应学习率会让模型收敛到尖锐的极小值而 SGD 更容易收敛到平坦的极小值平坦的极小值泛化性能更好。所以如果你追求极致的泛化性能可以试试 SGD Momentum虽然调参麻烦一点。学习率调度。固定学习率通常不是最优的。常见的学习率调度策略有StepLR每隔几个 epoch 降一次、CosineAnnealing余弦退火、Warmup预热。Warmup 在 Transformer 类模型中特别重要因为训练初期梯度很大直接用大学习率会导致训练不稳定。我一般会先用小学习率预热几百步然后再逐步增大到目标学习率。3.4 推理优化从模型到服务的最后一公里模型训练出来只是第一步怎么让它高效地跑起来才是工程化的重点。算子融合。把多个连续的小算子合并成一个大的算子减少 kernel launch 的开销和内存访问。比如 Conv BatchNorm ReLU 可以融合成一个算子。这个优化在推理框架里通常是自动做的但你需要知道它的原理才能理解为什么某些模型结构对推理不友好。量化。把 FP32 的权重和激活值用 INT8 表示模型大小直接缩小 4 倍推理速度也能提升 2-4 倍。但量化会带来精度损失需要在精度和速度之间做权衡。常见的做法是训练后量化PTQ和量化感知训练QAT。PTQ 简单但精度损失大QAT 复杂但精度保持得好。批处理。推理的时候把多个请求攒成一个 batch 一起处理能显著提升 GPU 利用率。但批处理会增加延迟因为你要等凑够一个 batch 才能开始计算。所以需要在吞吐量和延迟之间做权衡。我的经验是在线服务用动态批处理设置一个最大等待时间超时了就立即处理当前攒到的请求。KV Cache。自回归生成模型比如 GPT 系列推理的时候每次生成一个 token 都要重新计算前面所有 token 的 Key 和 Value这是巨大的浪费。KV Cache 的思路是把之前算过的 Key 和 Value 缓存起来每次只计算新 token 的 Key 和 Value。这个优化能把生成速度提升一个数量级代价是额外的显存占用。4. 实操过程手把手搭建一个最小可用的 AI 工程化流程4.1 环境准备与依赖管理我强烈建议用 conda 来管理环境因为 AI 工程化的依赖关系比较复杂pip 有时候处理不好版本冲突。conda create -n ai-scratch python3.10 conda activate ai-scratch pip install numpy matplotlib jupyter pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu这里我特意装了 CPU 版本的 PyTorch因为从零实现阶段你不需要 GPUCPU 版本更轻量装起来也快。等到需要跑大规模实验的时候再换 GPU 版本。注意不要在一个环境里混装多个深度学习框架。TensorFlow 和 PyTorch 的 CUDA 版本经常打架混装之后两个都用不了。如果确实需要用不同的 conda 环境隔离。4.2 手写线性回归理解训练循环的本质先从一个最简单的例子开始——用 NumPy 实现线性回归。这个例子虽然简单但包含了训练循环的所有核心要素。import numpy as np # 生成数据 np.random.seed(42) X np.random.randn(1000, 1) y 3 * X 2 np.random.randn(1000, 1) * 0.1 # 初始化参数 w np.random.randn(1, 1) b np.zeros(1) lr 0.01 # 训练循环 for epoch in range(100): # 前向传播 y_pred X w b loss np.mean((y_pred - y) ** 2) # 反向传播 grad_w 2 * X.T (y_pred - y) / len(X) grad_b 2 * np.mean(y_pred - y) # 更新参数 w - lr * grad_w b - lr * grad_b if epoch % 10 0: print(fEpoch {epoch}, Loss: {loss:.4f})这段代码的关键在于反向传播那两行。grad_w的推导过程是loss 对 w 的导数等于 loss 对 y_pred 的导数乘以 y_pred 对 w 的导数。loss 对 y_pred 的导数是2 * (y_pred - y) / Ny_pred 对 w 的导数是X所以合起来就是2 * X.T (y_pred - y) / N。这个推导过程你手写一遍比看十遍公式都管用。4.3 搭建多层神经网络从线性到非线性线性回归只能拟合线性关系要拟合非线性关系就需要引入激活函数。下面是一个两层神经网络的实现。import numpy as np class TwoLayerNet: def __init__(self, input_dim, hidden_dim, output_dim): self.W1 np.random.randn(input_dim, hidden_dim) * 0.01 self.b1 np.zeros(hidden_dim) self.W2 np.random.randn(hidden_dim, output_dim) * 0.01 self.b2 np.zeros(output_dim) def relu(self, x): return np.maximum(0, x) def relu_grad(self, x): return (x 0).astype(float) def forward(self, X): self.z1 X self.W1 self.b1 self.a1 self.relu(self.z1) self.z2 self.a1 self.W2 self.b2 return self.z2 def backward(self, X, y, y_pred): batch_size X.shape[0] dz2 (y_pred - y) / batch_size dW2 self.a1.T dz2 db2 np.sum(dz2, axis0) da1 dz2 self.W2.T dz1 da1 * self.relu_grad(self.z1) dW1 X.T dz1 db1 np.sum(dz1, axis0) return dW1, db1, dW2, db2这里有几个细节值得注意。第一权重初始化用了0.01的小随机数这是为了防止初始输出过大导致梯度爆炸。第二ReLU 的导数在 x0 时为 1x0 时为 0实现的时候用(x 0).astype(float)就能得到。第三反向传播的时候除以了 batch_size这是为了对 batch 内的梯度取平均。4.4 训练技巧让模型真正收敛上面的代码能跑但实际训练的时候会遇到各种问题。我整理了几个最常用的技巧。学习率 warmup。训练初期用很小的学习率然后逐步增大。这样做是为了避免初期梯度太大导致参数更新过猛。def get_lr(step, warmup_steps, max_lr): if step warmup_steps: return max_lr * step / warmup_steps return max_lr梯度裁剪。当梯度的范数超过某个阈值时按比例缩小梯度。这是为了防止梯度爆炸。def clip_gradients(grads, max_norm): total_norm np.sqrt(sum(np.sum(g**2) for g in grads)) if total_norm max_norm: scale max_norm / total_norm grads [g * scale for g in grads] return grads早停。在验证集上监控性能如果连续几个 epoch 没有提升就停止训练。这是防止过拟合最简单有效的方法。实操心得梯度裁剪的阈值不要设得太小否则会限制模型的正常学习。我一般从 1.0 开始试如果训练不稳定再往下调。另外梯度裁剪是在所有参数上统一做的不是每个参数单独做。5. 常见问题与排查技巧实录5.1 Loss 不下降或者变成 NaN这是最常见的问题原因可能有很多。我按照排查优先级列一下。可能原因排查方法解决方案学习率太大打印每次更新的梯度范数降低学习率加 warmup数据没有归一化检查输入数据的均值和方差做标准化处理损失函数用错检查分类/回归任务是否匹配分类用交叉熵回归用 MSE梯度爆炸监控梯度范数加梯度裁剪初始化太激进检查初始输出的量级用 Xavier 或 He 初始化我遇到最多的情况是学习率太大。特别是用 Adam 的时候默认学习率 1e-3 对某些任务来说还是太大。我的习惯是从 1e-4 开始试确认能稳定训练之后再逐步增大。5.2 训练集表现好但验证集表现差这是典型的过拟合。解决方案有几种增加数据量、加正则化L1/L2/Dropout、减小模型容量、早停。我一般会先加 Dropout因为实现简单效果也明显。Dropout 的概率从 0.1 开始试太高了会导致欠拟合。还有一种可能是数据泄露——验证集的数据不小心混到了训练集里。检查一下数据划分的逻辑确保训练集和验证集没有重叠。5.3 推理速度慢推理速度慢的原因通常有这几个模型太大、batch size 太小、没有做算子融合、没有用量化。排查的时候先用 profiler 看一下时间花在哪里了。如果是某个算子特别慢考虑替换成更高效的实现如果是整体都慢考虑量化和算子融合。避坑技巧不要一上来就追求极致的推理速度。先把功能跑通再逐步优化。过早优化会让你在还没搞清楚需求的时候就陷入细节。5.4 显存不够用显存不够用的解决方案按优先级排序减小 batch size、用梯度累积模拟大 batch、用混合精度训练、用梯度检查点、用模型并行。梯度累积是个很实用的技巧——用小的 batch size 跑多次前向反向累积梯度然后一次性更新参数。这样能用小显存模拟大 batch 的效果。accumulation_steps 4 for i, (inputs, labels) in enumerate(dataloader): outputs model(inputs) loss criterion(outputs, labels) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()6. 从项目到产品工程化的最后一公里6.1 模型服务化的基本架构模型训练出来之后怎么把它变成一个可用的服务最基本的架构是Web 框架接收请求预处理模块把原始数据转成模型输入模型推理模块执行前向计算后处理模块把输出转成业务需要的形式。Web 框架的选择上FastAPI 是目前比较流行的方案因为它的异步支持好性能也不错。但要注意模型推理通常是同步的 CPU/GPU 密集型操作异步框架的优势在这里体现不出来。所以更常见的做法是用 FastAPI 做请求接入然后把推理任务丢到单独的进程池或线程池里执行。6.2 监控与日志模型上线之后你需要监控它的表现。核心指标包括请求延迟P50、P95、P99、吞吐量QPS、错误率、GPU 利用率、显存占用。这些指标能帮你判断服务是否健康以及什么时候需要扩容。除了系统指标还要监控模型指标。比如分类模型的预测分布是否发生了偏移回归模型的预测值范围是否正常。如果发现异常可能是数据分布变了需要重新训练模型。6.3 版本管理与回滚模型更新是常态但每次更新都有风险。所以需要一套版本管理机制确保出问题的时候能快速回滚。我的做法是每次模型更新都保留上一个版本的权重和配置新版本先在小流量上灰度测试确认没问题再全量。灰度期间密切监控核心指标一旦发现异常立即回滚。这套流程听起来简单但实际执行的时候容易偷懒。我见过不少团队为了赶进度直接全量上线结果出了问题手忙脚乱。我的建议是宁可慢一点也要把灰度流程走完。一次线上事故的代价远比多花几个小时灰度测试要大。6.4 持续迭代的闭环AI 工程化不是一次性的工作而是一个持续迭代的闭环。模型上线之后你需要收集线上数据分析模型的 bad case然后针对性地补充训练数据、调整模型结构、重新训练、再上线。这个闭环跑得越快模型的迭代速度就越快。收集线上数据的时候要注意隐私合规问题。不要直接存储用户的原始输入而是存储脱敏后的特征或者中间表示。另外线上数据的分布和训练数据的分布可能不一致直接用线上数据训练可能会导致模型偏移。我的做法是线上数据先经过筛选和标注确认质量之后再加入训练集。7. 我踩过的坑和总结的经验7.1 不要跳过数据检查我刚开始做项目的时候拿到数据就直接往模型里灌结果训练了半天 loss 不下降排查了一整天发现是数据里有 NaN。从那以后我养成了一个习惯拿到任何数据先做一遍完整性检查——有没有缺失值、有没有异常值、分布是否合理、标签是否平衡。这个检查花不了多少时间但能省下大量排查问题的时间。7.2 小步快跑不要憋大招我见过很多人包括我自己犯的一个错误是想一次性把所有东西都做好。结果代码写了一堆跑起来一堆 bug调试都不知道从哪下手。正确的做法是先跑通一个最小的版本确认没问题之后再逐步加功能。比如先跑通单层线性回归再加隐藏层再加正则化再加学习率调度。每一步都有可验证的产出出问题的时候也容易定位。7.3 实验记录比代码更重要做 AI 工程化你会跑大量的实验。每个实验的超参数、数据版本、代码版本、结果指标都需要记录下来。我一开始用 Excel 记后来发现太麻烦换成了 Weights Biases。这个东西能自动记录超参数和指标还能对比不同实验的结果省了我大量时间。如果你不想用第三方工具至少也要用一个结构化的格式比如 JSON把每次实验的配置和结果存下来。7.4 理解比会用更重要最后说一点体会。AI 工程化这个领域工具和框架更新换代非常快。今天流行的框架可能明年就没人用了。但底层的原理——梯度计算、优化算法、数值稳定性、并行计算——这些东西变化很慢。你把原理搞懂了学新框架的时候就能快速上手你只学框架的 API框架一换你就得从头再来。所以我的建议是花时间在原理上不要只满足于会调包。这个项目 ai-engineering-from-scratch 最大的价值就是逼着你去理解原理。手写一遍虽然慢但学到的东西是自己的。后面再用框架的时候你就不再是盲人摸象而是知道每一步在干什么、为什么这么干。这种掌控感是单纯调包永远给不了的。
返回列表