最近后台收到不少私信,都在问同一个事儿:非科班、零基础,到底能不能啃下 AI 工程这块硬骨头?刚好我手头就在做一个小项目,代号就叫ai-engineering-from-scratch,意思很直白,就是完全从零开始,不依赖现成的高层封装,一步步把 AI 工程的核心链路亲手搭出来。这篇文章就把我这几周踩过的坑、验证过可行的路径,以及项目里几个关键环节的拆解,完整分享一下。
这个项目适合谁?三种人。第一种是刚入门、被各种框架搞到晕头转向的新手,你需要的是看清底层逻辑;第二种是写过不少业务代码、但从来没真正训练过一个模型的开发者,你需要的是补上数学和算法这层短板;第三种是已经在用现成 API 做应用、但一遇到性能或定制问题就束手无策的工程师,你需要的是掌控全链路的能力。一句话,这不是一个教你调库的项目,而是一个逼你理解"为什么"的项目。
1. 整体设计思路:为什么选"从零搭建"而不是"直接调框架"
这个项目立项之前,我自己先做了个实验。用 PyTorch 写一个线性回归,十行代码搞定,训练也收敛。然后我把torch的全部高级接口禁用,只用张量运算和手写梯度,结果写了八十多行,还出了一堆维度错误。但恰恰是这八十多行,让我把反向传播、梯度下降、学习率这些概念从"听说过"变成了"能解释清楚"。
所以项目的第一个设计原则就定下来了:每个核心模块,第一版必须从零手写,第二版才允许引入成熟框架做对比验证。这个原则贯穿了后面所有环节。
为什么必须这样?我说个扎心的现实。现在很多工程师用 TensorFlow 或 PyTorch 就像用神秘黑盒,模型不收敛,第一反应是改学习率,改完还不行就换优化器,再不行就上网搜"不收敛怎么办"。但你要是亲手实现过反向传播,你就知道不收敛可能有二十种原因:数据分布没归一化、权重初始化方差太大、梯度爆炸、标签有噪声、batch size 太小导致梯度震荡、学习率与 loss 曲面曲率不匹配……你只有理解了机制,才能快速定位,而不是瞎试。
第二个设计原则是"数据优先"。这个项目花了整整三分之一的时间在数据环节,而不是模型环节。原因很简单,我在真实业务里发现,超过七成的模型效果问题,根源都不在网络结构,而在数据质量。所以项目里设计了一个完整的数据流水线,从爬取、清洗、标注、特征工程到数据版本管理,每一步都要求先手工处理一小批样本,理解数据长什么样,再写自动化脚本。
第三个原则是"端到端闭环"。很多教程只讲训练,不讲部署,导致很多人学完还是不会做产品。这个项目从一开始就把部署考虑进去,训练出来的模型必须导出、打包、跑通推理服务,还要做性能压测。这样做的好处是,你在设计模型结构时就会考虑推理延迟、显存占用、量化兼容性,而不是训练完发现动不了。
设计阶段我还做了一个技术选型表,你可以对照参考:
| 环节 | 手写方案 | 框架方案 | 选型理由 |
|---|---|---|---|
| 向量与梯度 | NumPy + 手写矩阵运算 | PyTorch Tensor | 手写才能理解维度与梯度流 |
| 自动微分 | 手写反向传播图 | autograd | 理解链式法则的工程实现 |
| 模型结构 | 手写MLP/CNN/RNN | torch.nn | 先看结构再看封装 |
| 数据加载 | 手写DataLoader | torch.utils.data | 理解采样、shuffle、padding机制 |
| 训练循环 | 手写train_step | Trainer | 掌控梯度更新与状态管理 |
| 部署 | ONNX导出 + 手写推理脚本 | FastAPI + Docker | 理解序列化与serving全流程 |
2. 核心细节拆解:从梯度下降到反向传播的手工实现
项目第一步就是手写一个两层神经网络的完整训练过程。别笑,这个"玩具"恰恰是整个 AI 工程的地基。我把我最终的实现思路拆开讲。
2.1 计算图与梯度流动
我实现了一个极简的自动微分系统。核心数据结构是一个Node,它保存三样东西:当前值、梯度、以及一个计算函数。比如乘法节点,它的backward函数就是把上游梯度分别乘以另一个操作数,传给两个输入。
class Node: def __init__(self, value, parents=None, op=None): self.value = value self.grad = 0.0 self.parents = parents or [] self.op = op def backward(self, grad): self.grad += grad for parent, local_grad in self.parents: parent.backward(grad * local_grad)这个实现的核心是梯度累加。之所以要用+=而不是=,是因为当一个节点的输出被多个下游节点使用时,它的梯度是各条路径梯度之和。这是反向传播最容易出错的地方,我在这里栽过跟头,整整排查了一晚上,最后才发现是因为梯度覆盖而不是累加,导致权重更新量只有理论值的一半。
2.2 非线性与激活函数的选择
我手写了三种激活函数,并各自做了数值稳定性处理:
def sigmoid(x): # 防止 exp 溢出 if x >= 0: return 1.0 / (1.0 + math.exp(-x)) else: exp_x = math.exp(x) return exp_x / (1.0 + exp_x) def tanh(x): return 2.0 * sigmoid(2.0 * x) - 1.0 def relu(x): return max(0.0, x)tanh用sigmoid的缩放版本实现,不是炫技,是为了复用同一个数值稳定的指数函数。实测下来,sigmoid 和 tanh 在反向传播时容易造成梯度消失,层数一超过四层,浅层权重几乎学不动。ReLU 收敛速度快很多,但有个新问题叫"dying ReLU",就是某个神经元一旦输出恒为负数,它的梯度就永远是零,再也无法激活。后面我改用 Leaky ReLU 缓解,斜率设成 0.01,效果立竿见影。
注意:激活函数的选择直接影响训练稳定性和收敛速度。我的建议是,隐藏层默认用 ReLU 系列,输出层根据任务选择(二分类用 sigmoid,多分类用 softmax,回归用线性),这个经验在后续所有模型里反复验证过。
2.3 参数初始化为什么这么重要
有一段时间模型训练 loss 死活不降,我一度怀疑是梯度实现有 bug。后来逐层打印激活值分布才发现,因为初始化权重过大,经过几层矩阵乘法后,输入到 sigmoid 的值全落在饱和区(绝对值大于 3),梯度趋近于零,网络直接"冻结"了。
解决方案就是 Xavier 初始化。它的核心思想是让每层的输入方差和输出方差尽量保持一致。公式是:
W ~ N(0, sqrt(2 / (fan_in + fan_out)))
其中 fan_in 是输入维度,fan_out 是输出维度。我用 Python 手写了这个初始化:
def xavier_init(fan_in, fan_out): limit = math.sqrt(6.0 / (fan_in + fan_out)) return np.random.uniform(-limit, limit, (fan_in, fan_out))注意这里用的是均匀分布而不是高斯分布,是因为均匀分布的方差正好是(limit^2)/3,把 limit 设为sqrt(6/(fan_in+fan_out))后,方差恰好是2/(fan_in+fan_out),完美匹配 Xavier 的理论要求。这个细节很多教程不讲,但自己推导一遍就全通了。
3. 数据流水线实战:比模型更值得花时间的环节
项目进展到三分之一时,我体感最强烈的就是:数据工程的复杂度远超预期。模型结构花了两天定下来,数据处理却整整折腾了一周。
3.1 数据获取与清洗的标准流程
我构建了一个多源数据采集框架,支持从公开数据集、API 和网页三个渠道拉数据。重点说说清洗环节,我总结了一套标准流程:
- 去重:用内容哈希对文本去重,图片用感知哈希,相似度大于阈值就丢弃
- 异常值过滤:数值型特征用 IQR(四分位距)方法,超出
[Q1 - 1.5*IQR, Q3 + 1.5*IQR]区间的视为离群点 - 缺失值处理:连续变量用中位数填充,离散变量用众数,时序数据用前向填充
- 一致性校验:比如年龄字段不能为负数,日期格式必须统一为 ISO 8601
这里有个教训。我一开始用均值填充缺失值,结果模型在某个特定人群上预测偏差很大。查了半天发现,那个特征的缺失并不是随机的,而是数据采集设备在特定环境下才会失效,属于"非随机缺失"。用全局均值填充,相当于把所有缺失样本硬生生拉向总体均值,引入了系统性偏差。后来我单独给缺失值加了一个指示特征(is_missing),模型效果立刻提升。
3.2 特征工程的三个层次
我把特征工程分成三个层次,对应不同的投入产出比:
第一层:业务规则特征。这是性价比最高的。我举个例子,预测用户是否会点击广告,与其用复杂的 embedding 技术,不如直接构造"用户历史点击率"、"广告位历史 CTR"、"当前时段活跃度"这三个特征。虽然听起来简单,但它们直接编码了业务逻辑,模型学起来非常高效。
第二层:统计特征。包括分位数、偏度、峰度、序列的差分统计等。这类特征对异常检测类任务特别有效。我在做网络流量异常检测时,光是一个"滑动窗口内请求数的方差",就能把大部分突发流量识别出来。
第三层:模型学习特征。包括 embedding、自动编码器提取的隐向量、梯度提升树的叶子节点 ID 等。这类特征表达能力强,但可解释性差,适合在业务规则特征之上做增量。
注意:特征工程要遵循"先简单后复杂"的顺序。我见过太多人一上来就上大模型、搞 embedding,反而把业务中最明显的信号忽略了。第一版模型哪怕只用三五个精心构造的业务特征,效果都可能超过直接堆特征。
3.3 数据版本管理
项目里我用了一套轻量级的数据版本方案。每个数据集目录下放一个manifest.json,记录数据来源、采集时间、清洗脚本版本、样本数量、特征分布摘要(均值、方差、分位数)。训练时,模型产物里记录所用的 manifest 哈希值。这样,任何一个模型效果退化,我都能精准定位到是哪一批数据导致的。
实测这套方案帮我省了至少两次返工。有一次同事(其实是项目里的协作角色)改了清洗规则但没有同步更新版本号,模型指标掉了一点几个点,我通过比对 manifest 哈希,五分钟就定位到了原因。
4. 模型训练与调优:从手写训练循环到完整的评估体系
当手写的两层网络在验证集上达到预期准确率后,我切换到 PyTorch 重写了一遍相同结构,作为对照和效率验证。这个对比很重要,因为它既验证了手写实现的正确性,也让我理解了框架到底帮我做了什么。
4.1 训练循环里容易被忽略的状态管理
手写训练循环时,我发现一个容易踩坑的地方——梯度累积的状态清理。在 PyTorch 里,loss.backward()默认是累积梯度,所以每步更新前必须调用optimizer.zero_grad()。这个操作背后的原理是,grad属性是累加的,如果不清零,下一轮的梯度会和上一轮叠加,导致梯度方向错乱,模型剧烈震荡。
还有一个状态管理的细节:dropout 层在训练和推理时行为不同。训练时随机失活神经元,推理时必须关闭。PyTorch 用model.train()和model.eval()切换,但如果你手写模型,很容易漏掉这个切换,导致推理结果带有随机性,而且时好时坏。我排查过一个问题:同一个测试样本,前后推理两次结果不同,查了很久才发现是手写 dropout 在推理时没有关闭。
4.2 超参数调优的实用方法
项目里我做了一组超参数调优实验,用网格搜索 + 随机搜索组合的方式。先固定 batch size 和学习率,粗搜网络层数和隐藏单元数;然后固定结构,细搜学习率和正则化系数。表里是我在 CIFAR-10 简化版上的一组对照实验(我用了一个裁剪后的子集,只取 10 类中的 3 类,加快实验速度):
| 学习率 | batch size | 隐藏层数 | 验证准确率 | 收敛速度 |
|---|---|---|---|---|
| 0.01 | 32 | 2 | 82.3% | 15 epochs |
| 0.001 | 32 | 2 | 84.7% | 22 epochs |
| 0.001 | 64 | 3 | 85.2% | 28 epochs |
| 0.0001 | 64 | 3 | 78.9% | 40+ epochs(未收敛) |
结论是:学习率对收敛速度影响最显著,batch size 影响梯度稳定性,层数影响模型容量上限。但还要注意,这些参数之间有交互效应,单独调参看到的结论可能误导你。这也是我为什么推荐随机搜索的原因——它在参数空间里撒点,天然就能捕捉到交互效应。
4.3 评估指标体系设计
对分类模型,我除了准确率,还坚持监控精确率、召回率、F1 和 AUC。为什么?因为准确率在类别不平衡时完全失真。举个例子,诈骗检测场景下 99% 的样本是正常的,模型只要全预测"正常",准确率就是 99%,看起来非常棒,但实际上一个诈骗都没抓到。
我设计了一套可视化看板,包含训练集和验证集的 loss 曲线、各分类类别的混淆矩阵、以及特征重要度排序。每次训练完,我会先看验证集 loss 是否还在下降(判断是不是欠拟合)、训练集和验证集 loss 差距是否过大(判断是不是过拟合)、以及混淆矩阵的分布(判断是哪些类别在互相混淆)。
实操心得:loss 曲线是最快的体检报告。训练 loss 持续下降但验证 loss 上升,基本可以断定过拟合,优先减模型容量或加强正则化;两者都在高位不动,可能是学习率太小或特征没做好;验证 loss 震荡剧烈,先看 batch size 是不是太小或学习率是不是太大。
5. 部署与 MLOps:模型从训练到生产的最后一公里
很多独立开发者做项目,模型训练完了就以为结束了,其实部署上线才是真正的开始。
5.1 模型导出的格式选型
我对比了三种部署路径:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 直接用 PyTorch 加载 .pt 文件 | 实现简单 | 需要 Python 环境,依赖重,版本兼容性差 |
| ONNX 导出 | 格式标准,可跨框架,推理加速优化 | 部分算子不支持,需要处理动态轴 |
| TensorRT 或 TFLite | 推理速度最快 | 需要专门优化,模型结构受限制 |
项目最终选了 ONNX。原因有三:一是它可以脱离 PyTorch 运行,部署环境不用再装几百兆的依赖;二是 ONNX Runtime 在 CPU 上也有不错的加速效果;三是后续如果要转 TensorRT 或 TFLite,ONNX 都是标准中间格式。但 ONNX 也有坑,最常见的是动态维度。我的模型输入是变长的文本序列,ONNX 导出时如果不标记动态轴,推理时就只能接受固定长度。
import torch.onnx # 标记动态轴,batch 和序列长度都允许变化 torch.onnx.export( model, dummy_input, # 一个虚构的固定形状输入 "model.onnx", input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch_size", 1: "seq_len"}, "attention_mask": {0: "batch_size", 1: "seq_len"}, "logits": {0: "batch_size"} }, opset_version=13 )5.2 推理服务的完整实现
我用 FastAPI 写了一个推理服务,包含模型加载、请求校验、推理执行、结果格式化和错误处理。一个容易被轻视的细节是模型的加载时机。如果把模型加载放在请求处理函数里,第一个请求会慢得离谱——因为要加载权重、建立计算图、预热显存。正确做法是放在应用启动时用lifespan或模块级单例加载。
from fastapi import FastAPI, HTTPException from pydantic import BaseModel import onnxruntime as ort app = FastAPI() session = None @app.on_event("startup") def load_model(): global session session = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])推理服务的性能优化,我做了三件事。第一,输入批处理(dynamic batching),把并发的多个推理请求合并成一个 batch,充分利用 CPU 的并行能力;第二,结果缓存,对相同输入直接返回缓存结果,这个优化在文本分类场景下命中率很高;第三,int8 量化,把模型权重从 float32 压缩到 int8,模型体积缩小到原来的四分之一,推理速度提升约 2-3 倍,精度损失控制在 0.5% 以内。量化后再加一个回退机制:如果输入分布和训练分布差异过大,自动切回 float32 版本,保证极端情况下的精度。
5.3 模型监控与告警的基本盘
模型上线之后,我搭了一套基础监控。核心指标有三个:
- 推理延迟:P50、P95、P99 分位数,P99 最能反映极端情况下的性能
- 吞吐量:每秒处理的请求数,用于容量规划
- 预测分布漂移:模型输出的概率分布是否和训练时一致,用 KL 散度衡量
我踩过一个真实的坑:模型上线一周后,业务方反馈效果变差。我看了一下监控仪表盘,发现输入数据的特征分布已经发生了明显偏移——某个特征的平均值从 0.2 漂移到了 0.6,但模型训练时没见过这种分布。这就是分布漂移问题。后来我在监控里加了一个"数据漂移检测",对每个特征做 KS 检验,一旦 p 值小于阈值就告警。这套机制后来帮我提前两周发现了数据源变更导致的问题。
6. 项目推进中的常见问题与避坑清单
这几个问题是我在这个项目从零到一的过程中反复遇到的,也是后台私信里被问得最多的。
6.1 手写代码训练很慢,正常吗?
正常,而且完全正常。手写版本的目的是理解原理,不是为了生产性能。我的经验是,先用小数据集(比如一千条样本)验证手写实现的正确性,确认梯度正确、loss 在下降,再切换到框架版本做大规模训练。有一个快速的梯度检查方法,叫数值梯度比对:用有限差分法估计梯度,和反向传播算出的梯度做对比,相对误差小于 1e-6 基本可以确认梯度实现正确。
def numerical_gradient(f, x, eps=1e-6): grad = np.zeros_like(x) for i in range(len(x)): x_plus = x.copy() x_plus[i] += eps x_minus = x.copy() x_minus[i] -= eps grad[i] = (f(x_plus) - f(x_minus)) / (2 * eps) return grad6.2 模型在训练集上表现好,验证集上一塌糊涂?
这是典型的过拟合。我的排查顺序是:先看训练数据量是不是太少(少于一万条就要警惕);再看模型容量是不是过大(减少层数或隐藏单元数);然后看正则化手段有没有上(L2 正则、dropout、数据增强);最后看是不是数据分布不一致(训练集和验证集的来源不同,比如一个来自夜间数据、一个来自白天数据)。
6.3 部署环境没有 GPU,模型跑不动怎么办?
优先上量化和剪枝。量化能把 float32 转成 int8,模型体积缩小四倍,CPU 推理提速明显;剪枝能把不重要的权重直接置零,配合稀疏计算进一步提速。如果还不行,考虑蒸馏:训练一个小模型,让它模仿大模型输出。我的一个文本分类模型,从 BERT-base(110M 参数)蒸馏到一个小模型(6M 参数),准确率只掉了 1.2%,但推理延迟从 180ms 降到了 25ms,压缩比非常可观。
6.4 数据标注质量差,如何自动发现?
我分享一个经验:在训练集上跑一个模型,找出模型预测置信度最高但标签与预测不一致的样本,这些大概率是标注错误。再用聚类方法,把特征相似的样本聚在一起,如果同一簇里出现矛盾的标签,也高度怀疑标注有误。这两招不需要人工逐条检查,就能把标注质量问题的召回率做到一个不错的水平。
7. 项目扩展方向与资源效率的建议
这个项目做到后期,我沉淀了一套自己的方法论,也发现了一些可以继续深入的方向。如果你已经跟着做完上述所有环节,下一个阶段可以考虑这些扩展。
7.1 从 CPU 到 GPU 的训练加速
我一开始训练都在 CPU 上跑,一个 epoch 要十几分钟,迭代调参痛苦得不行。后来切换到 GPU,发现除了硬件本身的速度差距,代码层面的优化空间也很大。比如 PyTorch 的DataLoader设置num_workers多进程加载、pin_memory=True加速 CPU 到 GPU 的数据传输、混合精度训练(torch.cuda.amp)把 FP32 变成 FP16 计算,这些都让训练速度有倍数级提升。
7.2 从单模型到多模型融合
单一模型的性能往往有天花板,但多个模型融合可以稳定提升。我做过一个实验:三个结构差异较大的模型(CNN、LSTM、Transformer),在文本分类任务上分别达到 83%、85%、87% 的准确率,简单的加权投票融合后涨到了 89% 左右。但要注意,融合的前提是模型之间的"错误模式"要尽量独立,如果两个模型总是犯同样的错,融合的收益就会很有限。
7.3 从手写模型到大规模预训练模型
项目后期,我逐渐把预训练模型引入到流程里。但即便用了预训练模型,前面几章训练的原理一样没白学——因为微调(fine-tuning)阶段,你依然需要理解学习率设置、层冻结策略、正则化、数据增强这些底层技能。直接上来就用大模型,很容易变成"只会调库的调参侠"。
实操心得:这个项目最有价值的并不是某个具体模型或某段代码,而是它逼着你建立了从数据到部署的完整工程视角。我在面试时最喜欢问候选人的一个问题就是:"你的模型效果不好,第一步会怎么排查?"能答出"先看数据分布、再看训练曲线、最后才动网络结构"的人,我会认定他真正做过项目。这个顺序,恰恰是这个 from-scratch 项目教给我的最核心的东西。
最后再分享一个小技巧。项目整个过程中,我坚持每两天写一次实验日志,记录数据版本、参数配置、训练曲线截图、以及当时的判断依据。这个习惯在项目后期帮了大忙——两个月后回看某个实验结果,日志里的上下文让我三分钟就恢复了记忆,否则靠脑袋回忆,大概率记不清当时为什么选择那组参数。做 AI 工程,不只是和模型打交道,更是和自己过去的决策打交道。