
1. 从零搭建AI工程能力为什么我劝你别一上来就调包这两年AI应用开发的门槛肉眼可见地降低了随便拉个框架、调个API就能跑出一个能对话的Demo。但我带过的新人里十个有八个在真正接手一个需要上线的AI项目时会卡住——不是模型不会用而是不知道一个完整的AI工程链路到底长什么样。ai-engineering-from-scratch这个方向说白了就是解决这个问题的它不教你调某个具体模型的接口而是带你从最底层的数据处理、特征工程、模型训练、评估、部署到监控把整条链路自己动手走一遍。适合谁看刚入行的算法工程师、想从传统后端转AI方向的开发者、以及那些“会调包但心里没底”的AI应用开发者。我自己走过这条路也踩过不少坑下面把整个从零搭建的思路和实操细节拆开讲。2. 整体设计思路为什么从零写比调包更值得2.1 调包侠的天花板在哪里先说一个我观察到的现象。很多开发者用现成的框架做AI功能前三个月进步飞快因为框架把数据加载、模型定义、训练循环、推理部署全封装好了。但到了第四个月问题开始暴露模型效果不好不知道该调数据还是调模型推理延迟高不知道瓶颈在预处理还是后处理线上出现bad case没有中间日志可以追溯。这些问题的根源都一样——你对链路中间的每一环都没有控制力。ai-engineering-from-scratch的核心思路就是反其道而行先用最基础的工具把每个环节手写一遍哪怕写得丑、跑得慢目的是建立对整条链路的“肌肉记忆”。等你对手写版本的问题了如指掌之后再去用框架你就能清楚地知道框架帮你做了什么、在什么地方可能出问题、以及什么时候该绕过框架自己写。2.2 从零搭建的四个阶段划分我把整个从零搭建的过程分成四个阶段每个阶段有明确的产出物和验收标准。这个划分不是拍脑袋来的而是根据我实际带项目和带新人的经验总结的。阶段核心任务产出物验收标准第一阶段数据管道搭建可复现的数据处理脚本给定原始数据一条命令生成训练集第二阶段模型训练与评估训练脚本评估报告能画出loss曲线能解释每个指标含义第三阶段推理服务化可调用的API服务单条推理延迟200ms支持并发第四阶段监控与迭代日志系统告警规则能定位bad case能触发重训练这个划分的好处是每个阶段都有独立的交付物你可以随时停下来检查自己是不是真的掌握了。我见过太多人一上来就想搞端到端结果每个环节都是半吊子出了问题根本不知道从哪查。2.3 工具选型为什么我坚持用最朴素的工具起步在工具选择上我的建议可能和很多人不一样。第一阶段我强烈建议用Python原生库加NumPy不要用pandas的高级封装更不要用现成的数据加载器。为什么因为你需要亲手处理缺失值、异常值、类别不平衡这些脏活。用pandas的fillna一行搞定当然爽但你就失去了理解“为什么这里要填均值而不是中位数”的机会。模型训练阶段我建议先用NumPy手写一个最简单的线性回归或逻辑回归把前向传播、反向传播、梯度更新全部自己实现一遍。这个过程大概需要两三百行代码但写完你对“模型到底在学什么”的理解会完全不一样。之后再过渡到PyTorch或TensorFlow你会发现自己看框架源码的时候不再是一头雾水。推理服务化阶段Flask或FastAPI就够了不需要一上来就上TorchServe或Triton。先用最轻量的方式把模型包成一个HTTP接口理解请求进来之后数据怎么流转、模型怎么加载、结果怎么返回。等你把单机版的延迟和吞吐摸清楚了再去考虑更复杂的服务框架。3. 核心细节解析数据管道与特征工程的手写实践3.1 数据清洗从原始日志到可用样本假设我们拿到的是一份用户行为日志目标是预测用户是否会点击某个推荐位。原始数据通常是这样的每行一条记录包含用户ID、时间戳、行为类型、物品ID、一些上下文特征。第一步是把它变成监督学习需要的格式——每条样本要有特征和标签。手写清洗脚本的时候有几个细节是调包时很容易忽略的。第一是时间窗口的划分。你不能直接用所有历史数据因为用户行为有时序性用未来的数据预测过去会造成标签泄漏。我的做法是按时间排序后用前70%的数据做训练中间15%做验证最后15%做测试。这个切分必须是在清洗阶段就做好的不能等到训练时再随机切。第二是负样本的构造。点击预测场景下正样本是有点击行为的记录负样本需要从曝光未点击的记录里采样。这里有个坑曝光日志和点击日志往往是分开存储的你需要先做一次join操作把曝光和点击对齐。我一般会用用户ID加物品ID加时间窗口比如5分钟作为关联键超过时间窗口的点击不算作该次曝光的正样本。第三是特征的时间一致性。比如“用户过去7天的点击率”这个特征在构造训练样本时必须只用该样本时间点之前7天的数据来计算。如果你用全量数据算了一个全局点击率然后填到每条样本上模型在训练集上表现会很好但上线后效果会崩掉。这个坑我踩过当时离线AUC 0.85线上只有0.62排查了一周才发现是特征穿越。3.2 特征工程手写离散化与交叉特征特征工程是AI工程里最脏最累但也最能体现功力的环节。从零搭建的话我建议至少手写实现以下几种特征处理方式。数值特征的离散化。连续值直接喂给模型不是不行但树模型对连续值的分裂点选择比较敏感而且异常值影响大。我一般会先做分桶比如把用户年龄分成[0,18)、[18,25)、[25,35)、[35,50)、[50,)五档。分桶的边界不是随便定的要看数据分布。手写的时候可以用等频分桶把数据排序后按分位数切保证每个桶里的样本数差不多。代码大概长这样import numpy as np def equal_frequency_binning(values, n_bins5): percentiles np.linspace(0, 100, n_bins 1) bins np.percentile(values, percentiles) bins[0] -np.inf bins[-1] np.inf return np.digitize(values, bins) - 1这段代码看起来简单但有几个细节要注意。np.percentile默认用的是线性插值如果数据里有大量重复值分桶边界可能会重叠导致某些桶里样本数为零。我的处理方式是先做一次去重统计如果某个边界值重复出现次数超过总样本的10%就手动调整边界。类别特征的编码。用户ID、物品ID这种高基数类别特征直接用one-hot会维度爆炸用label encoding又会让模型误以为ID的大小有关系。我一般用两种方式一是频次编码把类别值替换成它在训练集中出现的次数二是目标编码用该类别的历史点击率来替换。目标编码效果通常更好但必须做平滑否则出现次数少的类别会过拟合。平滑公式我用的是encoded_value (count * mean prior * alpha) / (count alpha)其中prior是全局均值alpha是平滑系数一般取10到50之间。这个公式的意思是当某个类别的样本数很少时编码值会被拉向全局均值样本数多了之后才逐渐相信它自己的统计值。交叉特征的手写实现。比如“用户性别×物品类别”这种二阶交叉手写的时候可以用特征哈希。把两个特征的字符串拼接后做哈希映射到一个固定大小的空间里。这样做的好处是不需要预先知道所有组合坏处是可能有哈希冲突。我一般会把哈希空间设成总组合数的2到3倍冲突率可以控制在可接受范围内。3.3 数据管道的可复现性保障从零搭建数据管道最容易出问题的地方是“这次跑通了下次跑不通”。原因通常是随机种子没固定、文件读取顺序不确定、或者中间结果被覆盖了。我的做法是每个处理步骤都输出到独立的目录目录名包含时间戳和参数哈希。比如/data/processed/20250101_143022_a3f2c1/这样每次运行都有完整的记录出问题可以回溯。另外我会在管道入口处加一个配置校验步骤。把所有参数写在一个YAML文件里运行前先检查必填项是否齐全、数值范围是否合理。这个习惯帮我省了很多调试时间因为很多错误其实在参数层面就能发现不需要等到跑完整个管道。4. 模型训练与评估手写训练循环的完整实操4.1 从NumPy实现逻辑回归开始我建议每个从零学AI工程的人都用NumPy手写一遍逻辑回归。不是为了用它上线而是为了理解训练的本质。核心代码不超过100行但涵盖了前向传播、损失计算、反向传播、参数更新四个关键步骤。前向传播就是矩阵乘法加sigmoid激活def sigmoid(z): return 1 / (1 np.exp(-np.clip(z, -500, 500))) def forward(X, w, b): z X.dot(w) b return sigmoid(z)这里np.clip是为了防止指数溢出当z特别小的时候np.exp(-z)会变成无穷大。这个细节在调包时完全不用操心但手写的时候必须处理。损失函数用交叉熵def compute_loss(y_true, y_pred): epsilon 1e-15 y_pred np.clip(y_pred, epsilon, 1 - epsilon) return -np.mean(y_true * np.log(y_pred) (1 - y_true) * np.log(1 - y_pred))epsilon的作用是防止log(0)出现负无穷。反向传播求梯度def backward(X, y_true, y_pred): m X.shape[0] dw (1 / m) * X.T.dot(y_pred - y_true) db (1 / m) * np.sum(y_pred - y_true) return dw, db参数更新就是梯度下降w - learning_rate * dw b - learning_rate * db这四段代码写完之后你可以自己控制学习率、迭代次数、是否加正则化。我建议至少试三组学习率比如0.1、0.01、0.001观察loss曲线的变化。学习率太大loss会震荡甚至发散太小则收敛太慢。这个直观感受是调包时看loss曲线给不了的。4.2 评估指标的选择与陷阱模型训练完之后评估指标的选择直接决定了你优化的方向。分类问题最常用的指标是AUC、准确率、召回率、F1。但不同场景下该看哪个指标很多人是糊涂的。AUC衡量的是模型排序能力适合正负样本比例不均衡的场景。但AUC有个问题它只关心排序不关心具体的概率值。如果你的业务需要设定一个阈值来决定是否推荐那AUC高不代表阈值好设。这时候要看PR曲线Precision-Recall Curve它直接反映了在不同阈值下的精确率和召回率。准确率在样本不均衡时基本没有参考价值。比如点击率预测正样本只有5%模型全预测为负也有95%的准确率。所以我看评估报告时第一眼先看正负样本比例如果比例超过1:10准确率直接忽略。F1是精确率和召回率的调和平均适合需要平衡两者的场景。但F1隐含了一个假设精确率和召回率同等重要。实际业务中往往不是这样比如内容审核场景召回率比精确率重要得多宁可错杀不可放过这时候应该用F2或F0.5来调整权重。我一般会同时看四个指标然后根据业务目标确定主指标。主指标用来做模型选型辅助指标用来监控是否出现异常。比如点击率预测主指标用AUC辅助指标看Top 10%的精确率因为推荐位有限只关心排序最高的那部分。4.3 过拟合与欠拟合的手动诊断手写训练循环的一个好处是你可以随时打印中间变量来诊断过拟合和欠拟合。我的诊断流程是这样的先看训练集和验证集的loss曲线。如果训练loss持续下降但验证loss先降后升说明过拟合。如果两者都居高不下说明欠拟合。如果两者都下降但验证loss始终比训练loss高很多说明模型容量不够或者特征区分度不足。过拟合的处理方式按优先级排序增加数据量、加正则化L1/L2、减小模型复杂度、加Dropout。我一般先试L2正则化因为实现简单且效果稳定。L2的系数从1e-4开始试每次乘10观察验证loss的变化。欠拟合的处理方式增加特征、增加模型复杂度、减小正则化强度。这里有个容易犯的错误一看到效果不好就加模型层数。实际上很多时候是特征不够模型再复杂也学不到东西。我的经验是先把特征工程做扎实再考虑模型层面。还有一个诊断技巧是看学习曲线。固定模型逐步增加训练数据量观察验证集指标的变化。如果增加数据后指标还在提升说明数据不够如果已经平了说明模型容量到顶了。这个实验做一次大概需要跑5到6组但能帮你明确下一步该往哪个方向优化。5. 推理服务化从单机脚本到可调用API5.1 模型序列化与加载的坑训练好的模型要上线第一步是序列化。Python里最常用的是pickle但pickle有几个坑。第一是版本兼容性训练时用的库版本和推理时不一致可能导致加载失败。我的做法是训练完成后同时保存模型权重和依赖库的版本信息推理环境按这个版本信息来搭建。第二是pickle的安全问题反序列化不可信的pickle文件可能执行任意代码。如果模型文件需要跨团队传输我建议用ONNX格式。ONNX的好处是框架无关PyTorch训练的模型可以导出成ONNX然后用ONNX Runtime推理性能通常比原生PyTorch好而且没有pickle的安全隐患。导出ONNX的时候要注意输入输出的维度。动态维度比如batch size需要显式指定否则导出的模型只能接受固定batch size的输入。我一般会把batch维度设成动态其他维度固定。导出后用ONNX Runtime加载跑几条测试数据对比输出是否和原模型一致误差在1e-5以内算通过。5.2 FastAPI封装推理接口服务化我首选FastAPI原因是它自带异步支持、自动生成文档、性能也够用。一个最简的推理服务大概长这样from fastapi import FastAPI from pydantic import BaseModel import numpy as np import onnxruntime as ort app FastAPI() session ort.InferenceSession(model.onnx) class Request(BaseModel): features: list class Response(BaseModel): score: float app.post(/predict, response_modelResponse) async def predict(req: Request): input_array np.array([req.features], dtypenp.float32) outputs session.run(None, {input: input_array}) score float(outputs[0][0][0]) return Response(scorescore)这段代码看起来简单但有几个生产环境必须考虑的点。第一是模型加载的时机放在模块级别只加载一次不要放在请求处理函数里否则每次请求都加载模型会慢得无法接受。第二是输入校验features的长度必须和模型输入维度一致否则会报错。我一般会在启动时做一次校验用一个全零的输入跑一遍推理确认模型能正常加载和输出。第三是并发处理。ONNX Runtime默认是单线程的如果同时来多个请求会排队。可以在创建session时设置intra_op_num_threads参数来启用多线程或者用多个session实例做负载均衡。我实测下来对于小模型参数量100万单线程QPS大概在200左右开4个线程能到600左右再往上提升就不明显了。5.3 延迟优化从200ms到50ms的实操记录推理延迟是AI服务最关键的指标之一。我记录过一次完整的优化过程从最初的200ms降到50ms每一步都有明确的收益。第一步是定位瓶颈。在请求处理函数里打时间戳分别记录预处理、推理、后处理的耗时。结果发现预处理占了120ms推理只占60ms后处理20ms。预处理慢的原因是每次请求都在做特征拼接和归一化而这些操作其实可以提前做。第二步是把预处理逻辑前移到客户端。让调用方直接传处理好的特征向量服务端只做推理。这一下把延迟降到了80ms。但这样做增加了客户端的复杂度需要和调用方协商好特征格式。第三步是批处理。单条推理时GPU利用率很低改成攒一批请求一起推理可以大幅提升吞吐。但批处理会增加单条请求的等待时间需要根据业务场景设置合适的batch size和超时时间。我一般设batch size为16超时10ms这样平均延迟在60ms左右吞吐提升了3倍。第四步是模型量化。把FP32的模型转成INT8推理速度能提升2到3倍精度损失通常在1%以内。ONNX Runtime支持动态量化只需要在导出模型时加一个参数。量化后延迟降到了30ms左右但要注意有些算子对量化敏感需要逐层验证精度。6. 监控与迭代上线只是开始6.1 日志系统的设计要点AI服务的日志和普通后端服务不一样除了记录请求响应还要记录模型输入输出的分布。我一般会记录以下几类信息请求ID、时间戳、输入特征的统计量均值、方差、缺失率、模型输出的分数、以及后续的用户反馈比如是否点击。输入特征的统计量特别重要因为模型效果下降往往是因为输入分布变了。比如某个特征平时均值是0.5突然变成0.8说明数据源可能出了问题。我一般会按小时聚合这些统计量画成时间序列图设置阈值告警。日志的存储我建议用结构化格式比如JSON Lines每行一条记录。这样方便后续用SQL查询和分析。不要用纯文本日志解析起来太痛苦。如果数据量大可以按天分文件存储同时写入一个消息队列供实时监控消费。6.2 模型效果衰减的排查思路模型上线后效果衰减是必然的关键是衰减的速度和原因。我一般按以下顺序排查先看数据分布是否变化。对比最近一周和训练时的特征分布用KL散度或PSIPopulation Stability Index来量化。PSI小于0.1说明分布稳定0.1到0.25说明有轻微变化大于0.25说明分布显著变化需要重新训练。再看标签分布是否变化。比如点击率从5%降到了3%说明用户行为变了模型需要适应新的数据分布。如果数据和标签都稳定但效果还是降了那可能是模型本身的问题。这时候需要看bad case的具体样本分析是哪些类型的样本预测错了。我一般会按特征维度做分组分析找出错误率最高的那组样本针对性地补充训练数据。6.3 重训练策略全量还是增量重训练策略的选择取决于数据量和更新频率。如果数据量不大百万级以下我建议全量重训练因为实现简单且效果稳定。如果数据量很大千万级以上全量重训练成本太高可以考虑增量训练。增量训练的做法是用新数据在旧模型的基础上继续训练学习率设小一点比如原来的十分之一训练轮数也少一些。但增量训练有个风险是灾难性遗忘模型学了新数据忘了旧数据。缓解方法是混合一部分旧数据一起训练新旧比例一般设成1:1到1:3之间。重训练的触发条件我一般设两个一是定时触发比如每周一次二是监控触发当PSI超过阈值或效果指标下降超过5%时触发。两个条件满足其一就执行重训练重训练完成后先跑离线评估达标了再上线。7. 常见问题与排查技巧实录7.1 训练不收敛的六种可能手写训练循环时loss不下降是最常见的问题。我整理了一个排查清单按出现频率排序问题现象排查方法解决方案学习率过大loss震荡或变成NaN打印每轮loss减小学习率10倍学习率过小loss下降极慢观察loss曲线斜率增大学习率10倍特征未归一化某些维度梯度爆炸检查特征取值范围做标准化或归一化标签泄漏训练AUC异常高检查特征计算时间重新构造特征初始化不当所有输出相同打印初始预测值用Xavier初始化梯度消失深层网络不更新打印每层梯度范数加BatchNorm或换激活函数这个表是我踩了无数次坑之后总结的基本上覆盖了90%的情况。其中标签泄漏最隐蔽因为现象是“效果太好”而不是“效果太差”很多人反而不会去排查。7.2 推理服务超时的排查路径推理服务超时通常有三个原因模型加载慢、单次推理慢、并发排队。排查的时候先看日志里的时间戳确定是哪个环节慢。如果是模型加载慢检查模型文件大小和加载方式。ONNX模型加载通常比pickle快但如果模型很大超过1GB加载时间可能在秒级。解决方案是用内存映射或者把模型常驻内存。如果是单次推理慢用profiler工具分析每个算子的耗时。ONNX Runtime自带profiling功能可以输出每个算子的执行时间。找到耗时最长的算子看是否可以用更高效的实现替换。如果是并发排队看服务的并发数和QPS。如果QPS远小于并发数说明请求处理太慢如果QPS接近并发数说明并发能力不足。解决方案是增加worker数量或者优化单次推理速度。7.3 实操心得三个让我少走弯路的习惯第一个习惯是每次实验都记录配置。我用一个简单的YAML文件记录所有超参数、数据版本、代码commit hash。实验完成后把结果追加到一个CSV文件里。这样三个月后回头看能清楚地知道哪个配置对应哪个效果不会出现“这个参数我好像试过但忘了结果”的情况。第二个习惯是先用小数据跑通全流程。不要一上来就用全量数据训练先用1%的数据把整个管道跑一遍确认没有报错、没有维度不匹配、没有内存溢出。这个过程通常只需要几分钟但能避免跑了几小时才发现问题。第三个习惯是保留中间结果。数据处理的每一步都输出到独立文件模型训练的每个epoch都保存checkpoint。这样出问题的时候可以快速定位到是哪一步出的错不需要从头重跑。磁盘空间不够的话可以只保留最近三次的结果旧的自动清理。8. 从零搭建之后下一步往哪走走完一遍从零搭建的流程之后你对AI工程的全貌应该有了基本的掌控感。接下来有几个方向可以深入。一是性能优化学习用CUDA写自定义算子、用TensorRT做推理加速、用分布式训练处理大规模数据。二是工程化学习用Docker封装环境、用Kubernetes做服务编排、用MLflow做实验管理。三是业务深入针对具体场景推荐、搜索、风控学习领域特定的特征工程和模型设计。我个人在实际操作中的体会是从零搭建最大的价值不是让你以后什么都自己写而是让你在用框架的时候知道什么时候该信任它、什么时候该怀疑它、以及出问题的时候从哪里开始查。这个判断力是调包调不出来的只能通过亲手踩坑来积累。