ARTICLE DETAIL

资讯详情

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

从零手写AI工程:反向传播、注意力机制与模型部署实战

从零手写AI工程:反向传播、注意力机制与模型部署实战 1. 这个项目到底在解决什么问题第一次看到 ai-engineering-from-scratch 这个标题我脑子里蹦出来的第一个念头是终于有人把这件事挑明了。市面上讲 AI 的内容大致分两拨一拨是调包侠路线上来就pip install一堆框架跑通一个 demo 就敢说自己会 AI 了另一拨是论文路线满屏的偏微分和矩阵推导看完之后你依然不知道该怎么把一个模型塞进生产环境。这两拨人中间有一大片真空地带而 from scratch 这个词恰恰就是冲着这片真空去的。所谓 AI engineering和机器学习研究、数据科学是三个不同的工种。研究员关心的是 SOTA 能不能再刷高零点几个点数据科学家关心的是业务指标能不能被某个模型解释而 AI 工程师关心的是这个模型怎么在有限的显存里跑起来、推理延迟能不能压到 200ms 以内、上线之后怎么监控它的漂移、成本能不能从每千次调用三块钱压到三毛。这三件事的技能树重叠度其实很低但市面上大部分教程把它们混为一谈导致很多人学了一堆理论真到要部署的时候两眼一抹黑。from scratch 在这个语境下有两层意思我觉得都得说清楚。第一层是从零手写核心组件比如不调库实现一个注意力机制、手撸一个反向传播、自己写一个 tokenizer目的是把黑盒拆开看清楚里面到底在转什么。第二层是从零搭建工程链路也就是从数据清洗、特征处理、模型训练、评估、打包、部署到监控整条链路自己走一遍而不是只做其中一环。这两层加起来才是完整的 AI engineering。这个项目适合谁我的判断是三类人。第一类是有一定编程基础但没系统做过 AI 项目的后端或全栈工程师想转方向但被各种框架的抽象层挡住了视线。第二类是科班出身但只做过课程作业的学生理论懂一堆没碰过真实的数据脏活和部署坑。第三类是做了一段时间调包工作、感觉根基不牢的从业者想回头把底层补上。如果你已经能独立训练并部署模型这个方向对你价值有限如果你连 Python 的类和装饰器都还不熟那得先补语言基础再来。我之所以对这个方向有共鸣是因为我自己就踩过这个坑。早年做第一个推荐系统项目的时候我直接拿现成的库跑了个协同过滤离线指标看着挺漂亮一上线发现冷启动用户全推的是同一批热门商品因为我在特征工程阶段压根没考虑新用户的分布。那次之后我才明白AI 工程里真正难的不是模型本身而是模型外面那一圈脏活累活。这个项目标题里的 from scratch本质上就是在逼你把那一圈脏活也亲手做一遍。2. 整体设计思路与方案选型拆解2.1 为什么强调从零而不是从框架很多人会问现在框架这么成熟为什么还要从零写这不是重复造轮子吗我的回答是造轮子的目的不是为了用这个轮子而是为了理解轮子为什么是圆的。你手写一遍注意力机制之后再看 Transformer 的源码那种哦原来是这么回事的感觉是看十篇博客都换不来的。具体来说从零实现能带来三个别的方式给不了的东西。第一是对数值稳定性的直觉。你手写 softmax 的时候如果不做减最大值的处理遇到大数值直接溢出成 NaN这个坑踩过一次以后看任何涉及指数运算的代码都会下意识检查。第二是对计算复杂度的体感。你在 Python 里用嵌套循环实现一遍矩阵乘法再对比 NumPy 的向量化版本那个几百倍的性能差距会让你真正理解为什么 GPU 和并行计算是 AI 的命脉。第三是对抽象层泄漏的警觉。框架帮你屏蔽了细节但细节不会消失它只会在你最不希望的时候冒出来比如显存 OOM、梯度爆炸、精度损失。当然从零不等于拒绝框架。我的实践原则是核心组件手写一遍理解原理生产环境该用框架就用框架。你手写过一遍卷积不代表你上线的时候要自己写 CUDA kernel那是另一回事。这个边界要拎清楚否则容易走极端变成为了从零而从零效率极低。2.2 技术栈的选择逻辑如果让我来规划这个项目的技术栈我会按最小依赖原则来选。语言层面毫无疑问是 Python生态最全但我会刻意控制第三方库的使用范围。数值计算用 NumPy 打底因为它是几乎所有深度学习框架的底层依赖理解 NumPy 的广播机制和内存布局等于理解了半个 PyTorch。可视化用 Matplotlib够用就行不追求花哨。深度学习框架这块我的建议是 PyTorch 为主。原因很实际它的动态图机制对调试友好你可以像写普通 Python 一样打断点、打印中间变量这对从零理解计算图至关重要。TensorFlow 的静态图在早期版本里调试体验很差虽然后来有了 eager mode但生态惯性还在。至于 JAX函数式风格很优雅但对新手的心智负担偏重我一般建议放到第二阶段再接触。工程链路部分数据处理用 Pandas 加 Polars 的组合。Pandas 生态成熟但大数据量下性能拉胯Polars 用 Rust 写的处理千万行级别的数据快很多两者配合能覆盖大部分场景。实验管理我会用 MLflow 或者 Weights Biases前者开源可自托管后者体验更好但依赖云服务看你的合规要求。部署环节 FastAPI 是首选轻量、异步、类型提示友好配 Uvicorn 跑起来很顺。这里有个选型上的坑我得提醒一句不要一上来就追求最先进的工具。我见过太多人项目还没跑通先花两周搭了一套 Kubernetes 集群结果模型本身还是个逻辑回归。工具是为目标服务的从零阶段的目标是理解原理和跑通链路不是炫技。等你真的遇到了单机扛不住的场景再上分布式也不迟。2.3 学习路径的编排从零做 AI 工程路径编排很关键顺序错了会事倍功半。我推荐的顺序是先数值计算基础再经典机器学习然后深度学习最后工程化。这个顺序背后的逻辑是依赖关系后面的东西依赖前面的直觉。数值计算基础包括向量、矩阵、广播、求导。这部分不用学得太深但 NumPy 的基本操作要熟练到肌肉记忆的程度。经典机器学习阶段重点手写线性回归、逻辑回归、决策树和 KNN理解损失函数、梯度下降、过拟合这些概念。这个阶段不要碰神经网络先把传统方法的直觉建立起来。深度学习阶段再手写反向传播、全连接层、卷积层、注意力机制一步步搭起来。最后工程化阶段把前面写的模型包装成服务加上日志、监控、测试。每个阶段我都会配一个小项目来验证。数值计算阶段做一个手写矩阵运算库经典机器学习阶段做一个手写垃圾邮件分类器深度学习阶段做一个手写字符识别工程化阶段把字符识别部署成一个 API。这四个项目串起来就是一条完整的从零链路。项目不用大但每个环节都要自己走通不能跳步。3. 核心细节解析与实操要点3.1 手写反向传播的关键细节反向传播是深度学习的核心也是从零路上最容易卡住的地方。我见过很多人能背出链式法则的公式但一到写代码就懵。问题出在公式和代码之间隔着一层计算图的抽象这层抽象不打通永远是雾里看花。我的建议是从标量开始不要一上来就搞矩阵。先写一个最简单的计算图两个输入 x、y经过乘法得到 z再经过加法得到输出。然后手动推导每个节点对最终损失的梯度写成代码。这个过程你会真正理解反向两个字是什么意思——它是从输出往输入方向一层层把梯度传回去。标量搞明白之后再扩展到向量和矩阵。这里的关键是维度对齐。矩阵求导的维度规则很容易搞混我的经验法则是梯度的形状永远和被求导的变量形状一致。你写代码的时候如果发现梯度形状对不上八成是转置搞错了或者求和维度搞错了。这个检查方法救过我无数次。还有一个细节是数值梯度的验证。手写反向传播之后一定要用数值梯度做校验。数值梯度的原理是给某个参数加一个极小的扰动看损失变化多少用差分近似导数。虽然慢但它是检验你解析梯度对不对的黄金标准。我一般会随机抽几个参数做校验如果相对误差在 1e-6 量级基本就没问题。这个步骤千万别省我见过太多人梯度写错了训练半天 loss 不降还以为是学习率的问题。3.2 注意力机制的手写实现注意力机制是现在大模型的基石手写一遍价值极高。它的核心其实就三步算相似度、归一化、加权求和。但每一步都有坑。第一步算相似度常见的是点积。这里要注意缩放也就是除以根号 d_k其中 d_k 是 key 的维度。为什么要缩放因为点积的方差会随维度增长维度一大softmax 的输入就会跑到饱和区梯度趋近于零训练不动。这个缩放因子不是拍脑袋来的是推导出来的你手推一遍就明白了。第二步归一化用 softmax前面提过要减最大值防溢出。这里还有个细节是 mask 的处理。做自回归生成的时候当前位置不能看到未来的 token所以要加一个负无穷的 mask让 softmax 之后这些位置的权重为零。mask 加的位置很讲究要在 softmax 之前加加在之后是错的。这个坑我踩过当时模型能训练但生成的结果全是乱的排查了好久才发现是 mask 位置错了。第三步加权求和就是矩阵乘法没什么好说的。但整个注意力机制的计算复杂度是序列长度的平方这是 Transformer 处理长序列时的瓶颈。理解这一点你才能理解为什么后来会有各种稀疏注意力、线性注意力的变体。从零实现一遍标准注意力再去看这些变体思路会清晰很多。3.3 数据处理的脏活细节模型之外数据处理是最耗时间也最容易出问题的环节。我统计过自己做过的项目大概六成的时间花在数据处理上真正调模型的时间不到两成。这个比例很真实但很多教程把数据处理一笔带过导致新手以为 AI 工程就是调模型。数据处理的第一个坑是缺失值。缺失值不是简单填个零或者均值就完事你得先搞清楚它为什么缺失。是随机缺失还是跟某个特征相关如果是后者简单填充会引入偏差。我的做法是先做缺失模式分析看看缺失是不是集中在某些样本上如果是可能要考虑把这些样本单独处理或者干脆剔除。第二个坑是类别不平衡。真实数据里正负样本比例悬殊是常态比如欺诈检测可能是一万比一。这时候直接用准确率评估就是自欺欺人模型全预测成多数类也有 99.99% 的准确率。正确的做法是用 F1、AUC 这些指标配合重采样或者类别权重。重采样又分上采样和下采样各有各的问题上采样容易过拟合下采样会丢信息实践中我一般先用类别权重试试不行再考虑采样。第三个坑是数据泄漏。这个最隐蔽也最致命。典型场景是你在做特征工程的时候用了未来才知道的信息。比如预测用户会不会流失你用了他未来三十天的登录次数作为特征那模型离线指标肯定爆表上线就废。防泄漏的核心原则是任何特征在预测时刻都必须是已知的。我一般会做时间维度的切分训练集的时间必须早于验证集验证集早于测试集严格按时间顺序来。3.4 模型评估的实操要点评估这件事看起来简单做起来全是细节。第一个问题是数据集怎么切。随机切分在时序数据上是大忌因为会泄漏未来信息。正确做法是按时间切或者用滚动窗口的方式做交叉验证。我一般会留一个时间上最靠后的测试集模拟真实上线场景。第二个问题是评估指标怎么选。分类问题里准确率、精确率、召回率、F1、AUC 各有各的适用场景。我的经验是如果两类错误代价差不多用 F1如果更在意不漏掉正例用召回率如果更在意别误伤用精确率如果正负样本极度不平衡用 AUC 或者 PR 曲线下的面积。没有万能指标得看业务。第三个问题是置信区间。单次评估的结果是有随机性的尤其是小数据集。我一般会做多次评估取均值和标准差或者用 bootstrap 方法估计置信区间。如果两个模型的指标差异在置信区间内那这个差异可能只是噪声别急着下结论说谁更好。这个习惯能帮你避免很多调了半天参其实没区别的无效工作。4. 完整实操流程与关键环节实现4.1 环境搭建与依赖管理从零开始的第一步是把环境搞干净。我的习惯是每个项目一个独立的虚拟环境用 conda 或者 venv 都行我个人偏好 conda因为它在处理科学计算库的依赖关系上更省心。创建环境的时候指定 Python 版本我一般用 3.10 或 3.11太新的版本有些库还没适配太老的又缺特性。依赖管理我会用 requirements.txt 加 pip-tools 的组合。直接写 requirements.txt 的问题是版本不锁定今天能跑明天可能就崩了。pip-tools 可以生成一个锁定的 requirements.txt把所有间接依赖的版本都固定下来保证可复现。这个习惯在团队协作里尤其重要我见过太多在我机器上能跑的扯皮根源就是依赖没锁。conda create -n ai-scratch python3.11 conda activate ai-scratch pip install pip-tools # 在 requirements.in 里写顶层依赖 pip-compile requirements.in pip-sync requirements.txt环境搭好之后先跑一个冒烟测试确认 NumPy、PyTorch 这些核心库能正常导入GPU 能正常识别。这一步别省我见过有人写了半天代码最后发现是 CUDA 版本和 PyTorch 版本不匹配白白浪费时间。4.2 手写线性回归的完整流程线性回归是入门第一课但我要说的不是调sklearn.linear_model.LinearRegression而是从零手写。完整流程分五步生成数据、定义模型、定义损失、推导梯度、写训练循环。生成数据这步我会故意加一些噪声模拟真实场景。然后用 NumPy 手写前向传播y_pred X w b。损失用均方误差loss np.mean((y_pred - y) ** 2)。梯度推导出来是dw 2 * X.T (y_pred - y) / ndb 2 * np.mean(y_pred - y)。训练循环就是不断用梯度更新参数。这里有个细节是学习率的选择。学习率太大会震荡甚至发散太小收敛慢。我的经验是先试 0.01看 loss 曲线如果震荡就调小如果下降太慢就调大。更科学的方法是用学习率扫描从 1e-5 到 1 按对数间隔试一遍看哪个收敛最快。这个技巧在后面的深度网络里同样适用。训练完之后我会画三张图loss 随迭代的变化曲线、预测值和真实值的散点图、残差的分布图。这三张图能帮你快速判断模型有没有欠拟合、过拟合、或者系统性偏差。这个可视化习惯我从入门保持到现在非常有用。4.3 手写神经网络的搭建过程从线性回归到神经网络核心变化是引入了非线性激活函数和多层结构。我会从单层感知机开始然后加隐藏层然后加激活函数一步步搭起来。单层感知机本质上就是线性回归加一个阈值只能解决线性可分问题。加一个隐藏层之后理论上能逼近任意函数这就是万能逼近定理。但理论归理论实践中隐藏层的宽度、深度、激活函数的选择都会影响效果。我的经验是先用一个隐藏层宽度设成输入维度的两到四倍激活函数用 ReLU这个配置能解决大部分入门问题。激活函数的选择有讲究。Sigmoid 和 Tanh 在深层网络里会有梯度消失问题因为它们的导数在饱和区趋近于零。ReLU 解决了这个问题但又有神经元死亡的问题也就是某些神经元永远输出零梯度也永远是零。后来有了 Leaky ReLU、ELU 这些变体。我的实践是默认用 ReLU如果发现大量神经元死亡换成 Leaky ReLU。初始化也很关键。全零初始化会让所有神经元学到一样的东西对称性破不掉。小随机数初始化在深层网络里会导致信号逐层衰减。现在常用的是 Xavier 初始化和 He 初始化前者适合 Tanh后者适合 ReLU。这些初始化方法的公式背后都是为了让每一层的输出方差保持稳定你手推一遍就理解了。4.4 模型部署的最小可行方案模型训练完只是开始部署才是真正见真章的地方。我会用 FastAPI 搭一个最小可用的推理服务包含三个接口健康检查、单条推理、批量推理。from fastapi import FastAPI from pydantic import BaseModel import numpy as np app FastAPI() class PredictRequest(BaseModel): features: list[float] class PredictResponse(BaseModel): prediction: float latency_ms: float app.get(/health) def health(): return {status: ok} app.post(/predict, response_modelPredictResponse) def predict(req: PredictRequest): import time start time.time() x np.array(req.features).reshape(1, -1) # 这里调用你的模型 pred float(model.predict(x)[0]) latency (time.time() - start) * 1000 return PredictResponse(predictionpred, latency_mslatency)这个服务跑起来之后用uvicorn main:app --host 0.0.0.0 --port 8000启动。然后可以用 curl 或者 Python 的 requests 测试。部署这块有几个细节要注意模型加载要在服务启动时完成不要每次请求都加载输入要做校验防止脏数据把服务搞崩要加超时和限流防止单个请求拖垮整个服务。生产环境还要考虑模型版本管理。我的做法是模型文件带版本号服务启动时从配置里读版本这样回滚的时候改个配置就行不用重新部署代码。日志要记录每次请求的输入、输出、延迟方便排查问题。监控要盯住 QPS、延迟分布、错误率这几个核心指标一旦异常就告警。5. 常见问题与排查技巧实录5.1 训练不收敛的排查思路训练不收敛是最常见的问题排查要有章法不能瞎试。我的排查顺序是这样的先看数据再看模型最后看超参。看数据这一步检查输入有没有 NaN 或者 Inf标签有没有错位数据分布是不是正常。我遇到过一次 loss 一直不降最后发现是数据加载的时候把特征和标签搞反了这种低级错误反而最难发现因为你会下意识觉得数据没问题。看模型这一步检查梯度有没有正常回传。可以在反向传播之后打印几层的梯度范数如果全是零或者全是 NaN那问题就在这。梯度全零可能是激活函数饱和或者初始化太小梯度 NaN 可能是学习率太大或者数值溢出。看超参这一步学习率是头号嫌疑。我一般会做一个学习率扫描从 1e-5 到 1e-1 按对数间隔试看哪个能让 loss 稳定下降。如果所有学习率都不行那问题多半不在超参上得回到数据和模型去找。5.2 显存不足的应对策略显存 OOM 是深度学习的高频问题尤其是想在自己机器上跑大模型的时候。应对策略按优先级排先减 batch size这是最直接的再减模型规模比如减少层数或者隐藏维度然后用梯度累积用小 batch 模拟大 batch 的效果再不行用混合精度训练FP16 能省一半显存最后考虑梯度检查点用计算换显存。梯度累积的原理很简单正常是一个 batch 算一次梯度更新一次梯度累积是算几个 batch 的梯度累加之后再更新一次。这样等效的 batch size 变大了但显存占用还是小 batch 的水平。实现上就是在 loss 上除以累积步数然后每累积几步才调用一次 optimizer.step()。混合精度训练要注意数值稳定性。FP16 的动态范围小容易溢出所以一般会配一个 loss scaling把 loss 放大再算梯度更新前再缩回去。PyTorch 的torch.cuda.amp把这些都封装好了用起来很方便但底层原理还是得懂不然出了问题不知道怎么排查。5.3 常见问题速查表问题现象可能原因排查方法解决方案loss 不降学习率不当、数据问题、梯度异常打印梯度范数、检查数据分布调学习率、清洗数据、检查反向传播loss 变 NaN学习率过大、数值溢出检查中间激活值范围减小学习率、加梯度裁剪、用数值稳定实现显存 OOMbatch 过大、模型过大打印显存占用减 batch、梯度累积、混合精度过拟合模型复杂、数据少对比训练和验证指标加正则、dropout、数据增强欠拟合模型太简单、训练不足看训练 loss 是否够低加容量、加训练轮数、调学习率推理慢模型大、没优化profile 各层耗时量化、剪枝、ONNX 导出预测结果全一样模型没学到东西、输入有问题检查输入和输出分布重新训练、检查数据管道这张表是我这些年踩坑总结出来的覆盖了八成以上的常见问题。遇到问题先对号入座能省不少时间。5.4 几个容易被忽视的实操心得第一个心得是关于随机种子的。做实验的时候一定要固定随机种子否则你调了半天参结果发现差异只是随机性带来的。但固定种子也有个坑就是你可能过拟合到某个特定的种子。我的做法是调参阶段固定种子保证可复现最终评估阶段跑多个种子取平均这样既可控又稳健。第二个心得是关于日志的。训练的时候一定要把关键信息记下来包括超参、每轮的 loss 和指标、耗时、显存占用。我一般会用 TensorBoard 或者 WandB 记录这样能直观看到训练过程。日志不只是为了排查问题也是为了复盘很多时候你回头看几周前的实验记录能发现当时没注意到的规律。第三个心得是关于代码组织的。从零做项目代码会越写越多如果不注意组织很快就会变成一坨。我的习惯是按功能分模块数据、模型、训练、评估、部署各一个文件公共的工具函数放 utils。配置用 YAML 或者 dataclass 管理不要硬编码在代码里。这样改起来方便也方便别人接手。第四个心得是关于版本控制的。代码用 Git 管理是基本操作但模型和数据也要版本化。模型文件大不适合直接放 Git我一般用 DVC 或者简单的文件命名规范来管理。数据版本化更重要因为数据一变模型的可复现性就没了。我见过太多项目因为数据没版本化导致几个月后想复现某个结果却复现不出来。6. 从零路线的延伸与个人体会从零做完一遍之后你会发现自己的视角变了。以前看一个模型看到的是输入输出和 API现在看一个模型看到的是计算图、参数、梯度、内存布局。这种视角的转变是调包永远给不了的。延伸方向有几个。一个是往底层走手写 CUDA kernel理解 GPU 的并行计算模型这个方向门槛高但天花板也高。一个是往系统走学习分布式训练、模型并行、流水线并行理解大规模训练的工程挑战。还有一个是往应用走把从零学到的原理用到具体场景里比如推荐、搜索、对话系统每个场景都有自己的一套工程实践。我个人的体会是从零这条路走起来慢但走得扎实。调包能让你快速出结果但遇到框架解决不了的问题时从零的底子就是你的护城河。我见过太多人卡在某个框架的 bug 上束手无策而有底层理解的人能直接绕过框架自己实现。这种能力在关键时刻的价值远超平时省下的那点时间。最后分享一个小技巧学完一个组件之后试着不看资料把它默写出来。能默写出来说明你真的理解了默写不出来说明你只是看懂了没内化。这个自测方法我用到现在屡试不爽。从零做 AI 工程本质上就是一场把知识内化成能力的修行慢就是快。
返回列表