“ai-engineering-from-scratch” 这个项目名懂行的人一看就知道它的野心不在“用AI做点什么”而在“把AI系统完整地造出来”。现在市面上的教程和工具已经把人伺候得太好了——拖拽平台、一键训练、AutoML、预训练模型API几步就能产出一个看似可用的模型。但一旦进入生产环境数据分布变了、延迟要求高了、模型性能劣化了那些被封装掉的知识就会集体向你讨债。这个项目就是我想把AI工程的每一块基石亲手敲一遍的产物数据怎么治理、梯度到底怎么回传、模型怎么部署、上线后怎么监控全部自己来。写这篇复盘是因为我翻遍社区发现讲单点技术的很多把整条链路串起来、还愿意把踩坑细节拿出来讲的极少。如果你刚入行、正准备做自己的第一个AI项目或者你已经用框架跑过几个模型、却总觉得哪里悬空这篇文章都能给你一条可落地的路线。整个项目规模不大一个真实的小型数据集从爬虫采集、数据清洗、特征工程开始手写一个两层神经网络理解原理再用主流框架做生产级训练最后部署成容器化服务并搭建监控告警。下面按我实际执行的顺序展开每个环节都包含当时的决策理由和事后验证。1. why from scratch项目定位与技术选型解析1.1 为什么选择从零开始先说结论从零开始不是笨功夫也不是为了对抗框架而是为了把高层框架替你藏起来的决策点一个个捞回来。我见过太多只会在notebook里跑通模型的人用现成框架处理文本分类问他输入序列为什么要pad成同样长度为什么要有attention mask他说不清楚只说“文档这么写的”。原因很简单框架封装得太顺滑把token到id的映射、padding、mask这些关键决策全部自动化了你根本接触不到原始信息。一旦换到线上场景遇到真实流量里的长尾数据、脏数据、延迟抖动这种“只知其然”的经验立刻失效。从零开始的项目每一个环节的决策都摆在你面前特征要不要做归一化、归一化放在哪个阶段、模型梯度消失时该调激活函数还是初始化方式、服务部署用容器还是裸进程、监控指标选哪几个才有意义。这些在教程里通常已经“替你选好了”但生产系统里每一项都要自己做出有依据的判断。这个项目的价值就是让我把“选择权”完整地拿回到自己手里。1.2 技术栈拆解与每层选型的理由整个链路的技术栈我按层次拆开每一层都做了对比再定理由写在这里供你参考。语言层选了Python这是AI工程绕不开的选择生态、资料、人才都最全。但也要认识到Python只适合做主流程线上高并发入口我后面用Go写了网关避免Python服务直接面对流量峰值被打穿。数据处理层用Pandas加NumPy起步中期引入Polars处理千万行量级的表格。Pandas交互式探查数据很方便Polars列式存储加惰性求值数据量大时速度优势明显API风格也很接近切换成本可控。模型训练层最特别我先用NumPy手写了反向传播再切到PyTorch做正式训练。“先手写再上框架”这个顺序让后来的问题排查有了底层直觉。看到PyTorch的autograd报错你能反应过来是计算图哪里断了看到梯度消失你能从网络结构推断出是链式法则里哪一段在连续压缩数值。服务化部署层选FastAPI加Docker加NginxFastAPI是ASGI模型适合模型推理这种混合负载场景Docker保证训练环境与线上环境一致因为模型推理对numpy、编译器这些依赖版本极其敏感。可观测层用Prometheus加Grafana加自建日志链路模型服务的监控除了常规QPS、延迟、错误率还要记录预测分布、特征覆盖率、单样本耗时明细这些自己定义为Custom Metrics打点在推理中间件里不侵入业务代码。选型原则有一条每层都选主流方案但必须知道替代方案是什么。主流方案保证文档多、社区活跃、招人容易替代方案认知保证你在瓶颈出现时知道往哪个方向切。比如Polars不行了可以回Pandas硬扛FastAPI扛不住了可以上gRPC框架这种“可退路”意识在生产里很重要。2. 数据管道从原始数据到可用特征2.1 数据探查先建基线再谈优化很多人拿到的数据第一时间就写清洗逻辑这是最容易跑偏的地方。我实际执行时先花整个项目20%的时间做全面探查产出一份数据基线报告后面所有决策都从这里出发。基线报告至少包含这些内容每列缺失率、唯一值数量、类型分布、数值列的min-max和分位数、目标列分布。这些“看一眼就知道”的信息在特征工程阶段会反复决定方向。举个例子缺失率超过40%的列通常直接删但如果是用户ID、订单类型这种强业务字段缺失本身可能就是信号反而要保留成is_missing的二值特征。我还养成了一个习惯把探查逻辑写成一个固定的脚本数据每次更新后重跑并设置阈值自动告警。比如某特征缺失率从8%突然跳到30%这种变化多半意味着上游数据管道出了问题如果靠人工每次检查对比等发现问题时脏数据早就融进训练集了。2.2 清洗与特征工程里的隐藏坑清洗和特征工程是最容易被当成“dirty work”的部分但模型上限其实在这里决定。我认真整理了几个反复踩到的坑每个都值得记进你自己的检查清单。第一个坑训练和预测时特征处理不一致。我在早期做日期特征训练时用本地时区生成“一周第几天”线上服务部署在不同时区的机器上同一时刻的特征值就不一样模型预测结果完全扭曲。后来统一所有时间特征先转UTC再进入后续逻辑才彻底解决。第二个坑离群值截断和归一化的执行顺序。先截断再归一化与先归一化再截断结果完全不同。我的标准顺序是缺失值填充、分位数截断、标准化归一化、缺失指示特征生成。每一步之间都要校验dataframe形状和取值范围这样数据出问题时能立刻定位到具体环节。第三个坑类别特征编码。低基数类别用Label Encoding配合树模型问题不大但换到神经网络整数编码会被误认为有大小关系。这个项目里我按类别数量做了分流超过100类的字段直接学Embedding少于100类用One-Hot既控制维度爆炸又避免引入虚假顺序。这个决策的背景是我后来琢磨明白了“Embedding本质是在连续空间里学类别之间的相似度”。2.3 数据版本化与回放能力数据版本化是这个项目里容易被低估、但后期收益很大的设计。模型训练依赖的数据每次更新都该有唯一标识。我用最简单的方式实现清洗完成的数据集打包成带元数据的目录metadata.yaml记录生成时间、上游数据源版本、清洗脚本commit号、特征列表和各自的hash。训练脚本通过dataset_id加载对应目录。这样任何一个历史模型的训练记录都能还原它当时用的是什么数据旧版本bug复现和目标数据调查的速度大幅提升。回放能力我也做了保留最近N天的原始数据落盘格式是parquet压缩存储。当线上数据漂移告警触发时你可以直接取出历史数据做特征分布对比快速定位哪些维度偏移最大。没有这层回放能力线上只能看到一个“效果下降”的干巴巴报警剩下全靠猜。3. 模型核心动手实现反向传播的完整过程3.1 从线性模型起步理解梯度下降的本质我没有一上来就上Transformer而是从线性回归开始手写梯度下降。线性模型虽然简单但它是对“训练到底在优化什么”最短路径的答案。假设只有单特征预测任务y_pred w*x b损失函数用均方误差。梯度下降做的事非常朴素计算当前参数下损失对w和b的偏导数然后沿着负梯度方向更新参数。更新式是w w - lr * ∂L/∂w其中lr控制“一步走多远”。这句话看起来简单实际手算一次偏导数才能真正建立直觉。手推∂L/∂w会得到2/n ∑(y_pred - y)*x。结论很有画面感每个样本对梯度的贡献等于它的预测误差乘以特征值。预测错得多、同时特征值大的样本在参数更新中说话的分量就重。这个直觉迁移到神经网络就是误差反向传播的来源。实操上第一个版本固定学习率0.01跑1000轮记录loss收敛曲线然后把学习率调到0.5观察loss发散曲线。这两个实验放在一起看能直观理解学习率为何是训练里最敏感的超参数。我建议新手一定亲手做一次比看多少文档都有用。3.2 手写一个两层神经网络单层模型只能学线性关系想理解深度学习的能力边界得从两层MLP入手。结构定义是输入层→隐藏层(ReLU激活)→输出层前向就是h relu(X·W1b1)输出y_pred h·W2b2。反向传播的核心是把链式法则从输出端一层层往回用。关键代码在这里刻意没用深度学习框架只用NumPyimport numpy as np class TwoLayerNet: def __init__(self, input_dim, hidden_dim, output_dim, lr0.1): # He初始化降低ReLU下梯度消失的风险 self.W1 np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) self.b1 np.zeros(hidden_dim) self.W2 np.random.randn(hidden_dim, output_dim) * np.sqrt(2.0 / hidden_dim) self.b2 np.zeros(output_dim) self.lr lr def forward(self, X): self.h np.maximum(0, X self.W1 self.b1) self.y_pred self.h self.W2 self.b2 return self.y_pred def backward(self, X, y): m X.shape[0] dloss 2 * (self.y_pred - y) / m grad_W2 self.h.T dloss grad_b2 np.sum(dloss, axis0) dhidden dloss self.W2.T dhidden[self.h 0] 0 grad_W1 X.T dhidden grad_b1 np.sum(dhidden, axis0) self.W2 - self.lr * grad_W2 self.b2 - self.lr * grad_b2 self.W1 - self.lr * grad_W1 self.b1 - self.lr * grad_b1这段代码有两个地方特别容易写错我逐个讲清楚。第一梯度矩阵的维度必须严格对齐。grad_W2的形状是(hidden_dim, output_dim)所以要self.h.T dloss不能反过来。shape对不上会直接报错但更隐蔽的是shape能对上、含义却错了比如漏了转置程序不报错loss却永远不降。我的调试技巧是在每个梯度计算步骤后打印对应矩阵的shape和纸面上推导的维度对照半小时就能定位问题。第二ReLU反向传播存在“死亡ReLU”现象。dhidden dloss self.W2.T回传之后需要把隐藏层里激活值为0的位置梯度清掉。但如果你初始化方式不对、学习率又过大很多神经元的输出可能一直为负梯度也一直零整个隐藏层彻底变成死区loss不再变化。这就是为什么初始化我选He初始化它按输入维度做缩放能大概率避免这个问题。手写两层之后我建议追加两个小实验把激活函数换成sigmoid观察收敛速度变慢把网络加深到四层看梯度消失现象。这两个实验做完你对激活函数和网络设计之间关系会有比读论文更深的体会。3.3 训练调试的几个硬经验训练阶段有几个经验在文档里很难看到但实际项目里躲不开我按排查顺序记下来。第一个loss不降先查数据、再查梯度、最后查超参。这个顺序是我用一次惨痛教训总结的。当时我在一个分类任务上loss卡在0.69纹丝不动折腾两天学习率和网络结构后才发现训练数据里两个类别的标签写反了。后来我固定一套流程先做gradient check验证反向传播实现再在小批量数据上跑冒烟测试确认loss能下降最后才开始调学习率。这个顺序能帮你省下大量时间。第二个学习率调度。固定学习率在训练后期会导致loss震荡收敛不稳定。我采用Cosine Annealing前几个epoch用较大学习率快速下降后面沿余弦曲线逐步衰减到接近零。PyTorch里torch.optim.lr_scheduler.CosineAnnealingLR直接可用。具体选学习率时别迷信某一个值老老实实跑一组等比数列消融比如0.1、0.01、0.001选loss下降最快且能收敛的档位。第三个Early Stopping的指标选择。验证集loss不是监控的首选它在训练早期降得快、后期可能和测试表现背离。我在项目里改用验证集上的业务指标比如F1或AUC做Early Stopping依据而且要求连续多个epoch不提升才停避免验证集波动导致早停。4. 部署上线模型服务的工程化落地4.1 模型序列化与服务化的选择训练产出模型后上线第一步是解决“模型文件怎么存、怎么被在线服务调用”。模型序列化我踩过一个大坑早期用pickle直接存整个训练对象后来训练代码加了一个不影响预测的字段线上load直接失败。从那以后统一用ONNX格式导出。ONNX的好处是输入输出Tensor的shape和dtype都随文件自带跨语言调用不愁。PyTorch原生的torch.save加载快、兼容性好但和训练代码耦合深ONNX是中间表示适合接推理引擎但转换时要小心个别算子不支持。服务化我用FastAPI写独立推理服务对外暴露一个predict接口内部封装ONNX Runtimefrom fastapi import FastAPI from pydantic import BaseModel import onnxruntime as ort app FastAPI() session ort.InferenceSession(model.onnx, providers[CPUExecutionProvider]) class PredictRequest(BaseModel): features: list[float] app.post(/predict) async def predict(req: PredictRequest): inputs [req.features] result session.run(None, {input: inputs})[0] return {score: float(result[0][0])}代码本身不难但ONNX Runtime的线程配置值得专门说。默认情况下onnxruntime会用满所有CPU核心高并发时线程频繁切换开销反而大。我这个项目单实例设置intra_op_num_threads4、inter_op_num_threads2配合多容器实例横向扩展实测吞吐比单实例全核跑提升近一倍。线程数不是越大越好要结合实例个数和请求并发度做压测确定。4.2 容器化与自动扩缩容的取舍容器化解决环境一致性问题但也引入新的性能瓶颈关键在取舍。Dockerfile我用了多阶段构建第一阶段装训练环境并生成模型文件第二阶段只拷贝模型文件和推理依赖镜像体积从2.1GB压到680MB。这个优化对冷启动很关键生产环境频繁发布或扩容时大镜像会让新实例长时间处于不可用状态。部署编排的选型我一开始用Docker Compose后来流量上来才切到Kubernetes。但这里说句实话如果你的模型服务日调用量在几千次以下上K8s是杀鸡用牛刀光运维成本就比收益大。我更推荐按规模分档日调用量千级Compose加自动重启万级再上K8s配HPA百万级增加消息队列做削峰填谷。模型推理是计算密集型的HPA的扩缩容指标不能只看CPU我自定义为“推理队列长度加平均延迟”因为CPU使用率反应慢半拍流量瞬间冲高时来不及扩容。4.3 CI/CD里的模型验证关卡模型上线不能把训练完的文件直接扔生产CI流水线里必须有多道验证关卡。我这个项目的CI设计为四个阶段代码检查与单测、数据完整性检查、模型评估、上线审批。模型评估不是只跑一遍测试集准确率而是新模型和线上当前模型做全面对比整体指标是否提升分特征子群有没有回退预测分布和旧模型做KL散度对比。任何一个阈值超限流水线自动失败。这一步非常关键能拦住“整体指标涨了0.5%、但某类用户全被误杀”的隐性回归。上线审批我加了一层人工确认算法和数据负责人同时点了通过发布任务才会触发。这不是流程形式主义模型上线影响的是全量用户多一道确认既能防止自动流水线误发也能在出问题时明确责任边界。5. 监控与迭代让AI系统持续可用5.1 上线之后最该监控的指标模型上线不是终点监控才是让系统长期可靠运转的核心。我把监控指标按性质分为三类。系统指标包括QPS、P95延迟、错误率用Prometheus标准指标就能采集。模型指标包括预测值分布、confidence分布、各类别预测占比。数据指标是请求特征覆盖率、特征分布与训练分布的KL散度。在实践里数据指标往往能最早预警。当某个特征的平均值缓慢偏移预测分布和系统指标还没什么明显变化模型效果可能已经处于滑坡的前夜。我写了一个简单的滑窗监控每小时计算最近所有请求的特征分布和训练集分布算KL散度阈值设0.1。一旦超限就告警。实际运行下来这个告警几乎总是在业务指标恶化之前先触发是成本极低但价值极高的防线。5.2 数据漂移与特征漂移的实用检测数据漂移检测听起来高大上工程上完全可以用务实手段实现。最简单的做法是单特征分布对比数值型特征做两样本KS检验类别型特征做卡方检验p值小于0.05认为分布显著变化。我每天跑一次任务筛出p值最小的十几个特征人工检查这些特征在业务层面是不是合理漂移。比如用户年龄分布变了可能是活动拉新带来的结构性变化如果没有任何业务动作分布却变了大概率是数据管道问题。预测漂移检测更直接监控模型输出的平均值、标准差和分位点。不需要特别复杂的统计量一个移动平均线加阈值就很有用。我在Prometheus里记录prediction_mean和prediction_q95用Grafana画趋势线。当预测均值比历史均值连续偏离超过两个标准差说明线上数据环境已经变化该考虑重训了。5.3 模型迭代的灰度流程模型迭代不能全量替换线上服务必须走灰度流程。我的流程是新模型先部署成shadow副本复制线上流量但不返回结果给用户离线对比新旧模型在真实请求上的输出差异。通过后再切5%流量观察业务指标和人工抽检没问题逐步放大到20%、50%、100%。每个阶段至少停留一个完整业务周期比如推荐系统至少观察一周让周一和周末的用户行为差异不会干扰评估。灰度期间新旧模型的输出差异本身就是核心指标。我监控不一致率——两个模型对同一批请求给出不同结果的占比。不一致率过高说明新模型行为变化太大哪怕整体指标好看也不能着急全量因为很可能只是某些小众case上暴涨而这些case在业务上恰好非常敏感。6. 常见问题排查实录6.1 高频问题速查表整个项目跑下来我遇到的高频问题整理成一张表按现象、原因和排查顺序展开方便直接对照。现象可能原因排查顺序与解法训练loss不降数据标签错误梯度实现错误学习率不合适先做gradient check抽查50条样本最后做学习率消融离线指标高、线上效果差训练和线上特征处理不一致数据漂移对比两条特征管道代码重点查时区和缺失值跑特征分布对比接口P95延迟飙高ONNX线程数配置不当容器CPU限制过低检查推理线程配置逐步增加实例并观察吞吐新特征上线后指标反而下降特征处理顺序错误新特征与现有特征高度相关重跑特征管道定位偏差做特征相关性分析漂移告警频繁触发业务环境变化模型确实需要迭代区分周期波动和持续偏移周期波动放宽阈值持续偏移启动重训镜像构建慢、体积大推理镜像装进了训练依赖改多阶段构建推理镜像只保留运行时依赖只监控系统指标发现不了问题数据指标和模型指标缺失补充特征分布、预测分布监控6.2 排查思路的心法排查问题最怕没章法地乱试参数。我总结出一套固定套路先复现再二分。先复现指的是无论什么问题先在小范围里让它稳定发生——单条请求、小批量数据、单次推理。如果复现不了就加日志把中间结果全部打出来。二分法是把整条链路切成两半先判断是数据处理的问题还是模型计算的问题再继续往下找。比如接口延迟高用毫米级时间戳打印各个阶段耗时序列化慢还是推理慢一目了然。日志一定要带上下文。只打“error: feature is None”没有意义要把样本ID和原始请求体一起打。我在推理服务里给每个请求生成trace_id所有中间步骤的日志都挂在同一个trace_id下。排查线上问题时按trace_id一把捞出来五分钟定位问题源头。6.3 两个值得单独说细节的坑第一个坑归一化参数保存。训练时的均值方差我起初放在服务配置里后来数据漂移、模型重训归一化参数更新了服务配置却忘了同步线上预测结果整体偏移。最后我把归一化参数并入模型包元数据加载模型时一并加载彻底消除了这个隐患。第二个坑多实例下的推理结果稳定性。模型推理在极端情况下会和线程数、浮点累加顺序有关差异非常小业务上感知不到但做A/B测试时会被当成噪声。如果在灰度阶段流量随机分配到多个实例不同实例的数值精度略有差别新旧模型指标对比就会出现微小的失真。解决方法是灰度时固定实例数量或把相同批次请求路由到同一实例。到这里整条ai-engineering-from-scratch主线算是完整走了一遍。我个人最大的体会是从零构建AI工程值钱的不是最后那个模型指标而是每个环节被迫想清楚的“为什么”。数据为什么要版本化、梯度为什么这样回传、线程为什么不是越多越好、监控为什么要看分布而不是只看均值——这些判断在教程里都被默认省略了但恰恰是它们决定了项目能不能持续演进。最后分享一个小技巧做类似从零项目开局别铺太大。先挑一个极小的任务比如单个特征的销量预测把数据管道、手写模型、服务化部署、监控告警这条链路完整走通再逐步扩大。这条链路本身的通用性比任何花哨的模型都值得先掌握。