从零构建AI工程能力:数据、特征、训练与推理全链路实战
1. 从零搭建AI工程能力这个项目到底在解决什么问题第一次看到ai-engineering-from-scratch这个标题我脑子里蹦出来的第一个念头是又一个教人调包的教程但仔细琢磨了一下“from scratch”这个限定词再结合这两年带团队、面候选人的实际感受我意识到它瞄准的其实是一个很具体的痛点——市面上大量所谓“AI入门”内容本质上是在教你怎么用现成框架拼装而不是让你理解一个AI系统从数据到上线到底经历了什么。我自己在工业界做AI系统落地差不多十年从早期用传统机器学习做风控到后来做推荐、做多模态检索踩过的坑基本都集中在“工程”这两个字上。模型结构本身反而是最不容易出问题的部分真正让人熬夜的是数据管道断了、特征对不齐、推理延迟抖动、线上指标和离线对不上。所以当我看到这个标题时第一反应是如果它真的能做到“from scratch”那它应该覆盖的是数据工程、训练工程、推理工程、评估工程这一整条链路而不是只讲模型。这个项目适合谁我认为有三类人值得认真看。第一类是刚入行、只会调库的算法工程师他们需要补上“模型之外”的那一半知识第二类是从后端或数据方向转过来的工程师他们有工程底子但缺AI系统的全局观第三类是想自己动手做一个小型AI产品的人比如做一个文本分类服务或者图像检索demo他们需要一条能跑通的最小闭环路径。这篇文章我会按照我自己理解一个AI工程项目的方式把这条路径拆开讲清楚包括每一步为什么这么做、参数怎么定、坑在哪里。2. 整体设计思路为什么“从零”比“调包”更难也更有价值2.1 从零构建的核心逻辑把黑盒拆成可验证的环节很多人对“from scratch”有误解以为是要手写反向传播、手写卷积核。其实在工程语境下from scratch 的核心含义是不依赖高度封装的端到端框架而是把每个环节单独实现或至少单独验证。举个例子你可以用 PyTorch 定义模型但数据加载、特征处理、训练循环、评估指标、推理服务这些部分你要能说清楚每一步的输入输出是什么、边界条件在哪里。我之所以强调这一点是因为在实际工作中一个AI系统出问题90%的情况不是模型结构错了而是某个环节的数据分布变了、某个预处理步骤在训练和推理时不一致、某个指标的计算方式有歧义。如果你一直用高度封装的pipeline这些问题会被掩盖直到线上炸了才暴露。从零构建的价值就在于每个环节都是显式的、可单独测试的。2.2 技术选型的取舍为什么我建议从轻量级工具链入手在工具选型上我的建议很明确训练侧用 PyTorch数据处理用 pandas numpy 起步服务侧用 FastAPI实验管理先用文件系统加命名规范。为什么不推荐一上来就上 Spark、TFX、MLflow 这些重型工具因为学习阶段最重要的是缩短反馈循环。你用 pandas 处理几万条数据几秒钟就能看到结果你用 Spark 处理同样的数据光环境配置和任务提交就要几分钟调试成本完全不一样。这里有个我自己的经验在数据量小于100万条、特征维度小于1000维的情况下单机 pandas numpy 完全够用。我做过一个文本分类项目80万条样本、5000维稀疏特征用 pandas 做特征工程加上 PyTorch 训练单机16核CPU加一张消费级显卡整个流程跑下来不到20分钟。这个效率对于学习和原型验证来说绰绰有余。等你真的遇到单机扛不住的数据量再迁移到分布式框架那时候你对数据流的理解已经足够深迁移只是换API的事。2.3 项目模块划分一条最小但完整的AI工程链路我把这条链路拆成五个模块每个模块都有明确的输入输出和验收标准模块输入输出验收标准数据准备原始文件/数据库清洗后的结构化数据无缺失值、类型正确、分布合理特征工程结构化数据数值化特征矩阵训练/推理一致、无泄漏模型训练特征矩阵标签模型权重训练日志损失收敛、验证集指标达标评估验证模型测试集评估报告指标可复现、切片分析完整推理服务模型请求预测结果延迟达标、错误可追踪这张表看起来简单但每个环节都有大量细节。比如“训练/推理一致”这一条我见过太多项目栽在这里训练时用了某个归一化参数推理时忘了加载训练时对缺失值填了均值推理时直接报错。这些问题的根源都是没有把特征工程当成一个独立的、可序列化的组件来对待。3. 核心细节解析数据、特征与训练中的关键决策3.1 数据清洗先做“体检”再动手拿到一份原始数据我的习惯是先做三件事看形状、看类型、看分布。看形状就是df.shape知道有多少行多少列看类型就是df.dtypes确认数值列是不是被读成了字符串看分布就是df.describe()加上对类别列的value_counts()。这三步做完基本能发现80%的数据问题。举个实际例子。我之前处理一份用户行为日志timestamp列读进来是字符串格式是2024-01-15 08:30:00。如果直接丢给模型肯定不行需要转成数值特征。我的做法是拆成四个特征小时、星期几、是否周末、距今天数。为什么这么拆因为小时反映日内模式星期几反映周内模式是否周末是二值强特征距今天数反映时效性。这四个特征加起来比直接用一个时间戳数值效果好得多因为树模型对原始时间戳的数值大小没有语义理解。注意时间特征拆分时一定要确认时区。我踩过一次坑训练数据用的是UTC推理时服务器本地时间是东八区导致小时特征整体偏移8线上效果直接掉了一截。后来统一在数据入口处强制转UTC问题才解决。3.2 特征工程训练和推理必须共用同一套代码这是我最想强调的一点。很多教程把特征工程写在训练脚本里推理时重新写一遍这是灾难的根源。正确的做法是把特征处理逻辑封装成一个类或一组函数训练和推理都调用同一份代码。具体怎么做我通常定义一个FeatureProcessor类包含fit和transform两个方法。fit在训练集上计算统计量均值、方差、类别编码映射等transform用这些统计量做转换。训练时先fit再transform推理时只transform并且把fit得到的统计量保存成文件推理服务启动时加载。class FeatureProcessor: def __init__(self): self.mean None self.std None self.category_map {} def fit(self, df): self.mean df[age].mean() self.std df[age].std() for col in [city, gender]: self.category_map[col] {v: i for i, v in enumerate(df[col].unique())} def transform(self, df): df df.copy() df[age] (df[age] - self.mean) / self.std for col in [city, gender]: df[col] df[col].map(self.category_map[col]).fillna(-1) return df这段代码看起来简单但它解决了一个大问题推理时的数据转换和训练时完全一致。保存mean、std、category_map这些统计量本质上就是把训练数据的“状态”固化下来推理时复现这个状态。3.3 训练循环别急着上高级技巧先把基础打牢训练循环的核心就四步前向传播、计算损失、反向传播、更新参数。但就是这四步有很多细节决定成败。批次大小的选择我一般从32或64开始试。太小了梯度噪声大太大了显存吃紧且泛化可能变差。有个经验公式可以参考批次大小在32到256之间学习率相应地在1e-4到1e-2之间调整。如果批次翻倍学习率也可以适当翻倍这是线性缩放规则的大致思路。学习率调度我习惯用余弦退火或者带热重启的余弦退火。为什么因为固定学习率很难同时兼顾初期快速下降和后期精细收敛。余弦退火让学习率从初始值平滑降到接近零后期模型能在局部最小值附近稳定下来。实测下来同样的模型和數據余弦退火比固定学习率在验证集上通常能高0.5到1个点。早停策略验证集损失连续N个epoch不下降就停。N一般取5到10。这个策略帮我省了大量训练时间也避免了过拟合。但要注意早停的“不下降”最好用平滑后的指标比如移动平均避免被单次波动误导。3.4 评估验证离线指标好不代表线上好评估环节最容易犯的错误是只看一个总体指标。比如分类任务只看准确率如果类别不平衡准确率会严重误导。我通常会看四个东西混淆矩阵、每个类别的精确率和召回率、AUC、以及按关键维度切片的指标。切片分析特别重要。举个例子一个推荐模型整体AUC是0.78看起来不错。但按用户活跃度切片后发现高活跃用户AUC是0.82低活跃用户只有0.65。这说明模型对低活跃用户预测能力差而低活跃用户恰恰是拉新的重点。如果不做切片这个问题根本发现不了。实操心得切片维度不要太多选3到5个业务上最关心的维度就够了。每个切片样本量不能太少少于1000条的切片指标波动太大参考价值有限。4. 实操过程从原始数据到可调用服务的完整实现4.1 环境准备与依赖管理我强烈建议用 conda 或 venv 创建独立环境依赖写进requirements.txt。核心依赖就几个numpy、pandas、scikit-learn、torch、fastapi、uvicorn、joblib。版本尽量固定比如torch2.1.0避免不同机器上行为不一致。python -m venv venv source venv/bin/activate pip install numpy pandas scikit-learn torch fastapi uvicorn joblib pip freeze requirements.txt这一步看起来简单但我见过太多人因为环境问题浪费半天。固定版本号能避免“在我机器上能跑”的经典问题。4.2 数据加载与清洗的实操步骤假设我们有一份CSV文件raw_data.csv包含用户ID、年龄、城市、性别、历史行为次数、是否转化六个字段。目标是根据前五个字段预测“是否转化”。第一步加载并检查import pandas as pd df pd.read_csv(raw_data.csv) print(df.shape) print(df.dtypes) print(df.isnull().sum()) print(df[是否转化].value_counts())第二步处理缺失值。年龄缺失用中位数填充城市和性别缺失用“未知”填充。为什么年龄用中位数而不是均值因为年龄分布通常有偏中位数更稳健。第三步处理异常值。年龄小于0或大于120的置为缺失再填充历史行为次数为负的置为0。df.loc[(df[年龄] 0) | (df[年龄] 120), 年龄] None df[年龄].fillna(df[年龄].median(), inplaceTrue) df[城市].fillna(未知, inplaceTrue) df[性别].fillna(未知, inplaceTrue) df.loc[df[历史行为次数] 0, 历史行为次数] 04.3 特征处理与数据集划分特征处理按前面说的FeatureProcessor来做。划分数据集时我习惯用train_test_split测试集占20%随机种子固定为42保证可复现。from sklearn.model_selection import train_test_split train_df, test_df train_test_split(df, test_size0.2, random_state42, stratifydf[是否转化]) processor FeatureProcessor() processor.fit(train_df) train_X processor.transform(train_df.drop(columns[是否转化, 用户ID])) test_X processor.transform(test_df.drop(columns[是否转化, 用户ID])) train_y train_df[是否转化].values test_y test_df[是否转化].values注意stratify参数它保证训练集和测试集的类别比例一致。如果类别不平衡这个参数很重要。4.4 模型定义与训练循环实现模型用一个简单的多层感知机输入维度等于特征数隐藏层128和64输出层2类。import torch import torch.nn as nn class MLP(nn.Module): def __init__(self, input_dim): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, 2) ) def forward(self, x): return self.net(x)训练循环model MLP(train_X.shape[1]) optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) criterion nn.CrossEntropyLoss() train_tensor torch.tensor(train_X.values, dtypetorch.float32) train_label torch.tensor(train_y, dtypetorch.long) dataset torch.utils.data.TensorDataset(train_tensor, train_label) loader torch.utils.data.DataLoader(dataset, batch_size64, shuffleTrue) for epoch in range(50): model.train() total_loss 0 for batch_x, batch_y in loader: optimizer.zero_grad() output model(batch_x) loss criterion(output, batch_y) loss.backward() optimizer.step() total_loss loss.item() scheduler.step() print(fEpoch {epoch}, Loss: {total_loss / len(loader):.4f})这里有几个细节Dropout(0.3)防止过拟合CosineAnnealingLR让学习率平滑下降shuffleTrue打乱批次顺序。这些看起来是小事但组合起来对最终效果影响很大。4.5 模型保存与推理服务封装训练完成后保存模型权重和特征处理器torch.save(model.state_dict(), model.pt) import joblib joblib.dump(processor, processor.pkl)推理服务用 FastAPIfrom fastapi import FastAPI import joblib import torch import numpy as np app FastAPI() model MLP(input_dim5) model.load_state_dict(torch.load(model.pt)) model.eval() processor joblib.load(processor.pkl) app.post(/predict) def predict(data: dict): df pd.DataFrame([data]) X processor.transform(df) tensor torch.tensor(X.values, dtypetorch.float32) with torch.no_grad(): output model(tensor) prob torch.softmax(output, dim1)[0][1].item() return {probability: prob, label: int(prob 0.5)}启动命令uvicorn main:app --host 0.0.0.0 --port 8000。这样你就有了一个可调用的预测接口。5. 常见问题与排查技巧实录5.1 训练不收敛或损失震荡这是最常见的问题。排查顺序我一般是先看学习率再看数据最后看模型。学习率太大导致震荡太小导致收敛慢。我通常先用1e-3试如果损失震荡就降到1e-4如果下降太慢就升到1e-2。数据方面检查特征是否归一化、标签是否有问题。模型方面检查层数是否过深、激活函数是否合适。有个容易被忽略的点输入特征的尺度差异过大。比如年龄是0到100历史行为次数是0到10000如果不做标准化梯度会被大数值特征主导。我的做法是对所有数值特征做标准化对类别特征做嵌入或独热编码。5.2 过拟合训练集好测试集差过拟合的信号很明确训练损失持续下降验证损失先降后升。应对手段按优先级排序增加数据量 加正则化 减小模型 早停。增加数据量最有效但成本最高加正则化包括Dropout、权重衰减、数据增强减小模型就是减少层数或隐藏单元早停是最省事的兜底方案。我自己的习惯是同时用Dropout和权重衰减Dropout率0.2到0.5之间权重衰减1e-4到1e-2之间。这两个参数需要根据验证集表现微调。5.3 推理延迟过高如果单次推理超过100毫秒对于实时服务来说就偏高了。优化方向有几个模型量化、批处理、ONNX导出、减少特征计算。模型量化把float32转成int8速度能提升2到4倍精度损失通常在1个点以内。批处理是把多个请求攒在一起推理吞吐量能大幅提升但单次延迟会增加。ONNX导出配合ONNX Runtime推理速度通常比原生PyTorch快20%到50%。避坑技巧量化后的模型一定要在验证集上重新评估我遇到过量化后AUC掉3个点的情况原因是某些层的数值范围太窄int8表示精度不够。这时候可以只量化部分层或者用动态量化代替静态量化。5.4 常见问题速查表问题现象可能原因排查方法解决方案损失为NaN学习率过大/数据有异常值打印每步损失和输入降低学习率、裁剪梯度、清洗数据验证指标远低于训练过拟合对比训练/验证损失曲线加Dropout、早停、增加数据推理结果与训练不一致特征处理不一致对比训练和推理的中间输出统一特征处理代码服务启动报错依赖版本冲突检查requirements.txt固定版本、重建环境预测概率全为0.5模型未加载或输入全零检查模型权重和输入重新加载模型、检查特征6. 从原型到可用我踩过的坑和总结的经验6.1 数据泄漏最隐蔽也最致命数据泄漏是指训练时用到了推理时拿不到的信息。最常见的场景是用未来数据预测过去。比如做用户流失预测特征里包含了“最近一次登录距今天数”但如果这个特征是在标签时间点之后计算的就泄漏了。排查方法是对每个特征问一句“这个特征在预测时刻真的能拿到吗”如果答案是否定的就必须去掉。我踩过一次严重的泄漏坑做商品推荐时特征里包含了商品的“总销量”但这个销量是包含预测时间点之后的。结果离线AUC高达0.95上线后直接掉到0.6。后来把销量改成“截至预测时间点的历史销量”离线AUC降到0.78但线上一致了。这个教训让我明白离线指标虚高不一定是好事可能是泄漏的信号。6.2 版本管理模型、数据、代码要一起管AI项目和传统软件项目最大的区别是模型效果依赖于数据、代码、超参数三者的组合。只管理代码版本是不够的。我的做法是每次训练生成一个实验ID把代码commit hash、数据版本、超参数配置、模型权重、评估结果都关联到这个ID上。这样任何时候都能复现某个模型。具体实现可以很简单建一个experiments目录每个实验一个子目录里面放config.json、metrics.json、model.pt。不需要上MLflow也能做到基本可追溯。6.3 监控上线只是开始模型上线后数据分布会变模型效果会衰减。必须监控三个东西输入特征分布、预测结果分布、业务指标。输入特征分布偏移检测可以用PSI或KL散度预测结果分布看均值方差变化业务指标就是点击率、转化率这些。我一般设置这样的告警规则PSI大于0.2触发警告大于0.5触发严重告警预测均值连续三天偏移超过10%触发告警。这些阈值不是绝对的要根据业务容忍度调整。6.4 最后分享几个小技巧第一个技巧训练前先跑一个极小数据集。取100条数据跑几个epoch确认整个流程能跑通、损失能下降。这能提前发现大部分代码错误比在全量数据上跑半天才发现问题高效得多。第二个技巧把随机种子固定住。torch.manual_seed(42)、np.random.seed(42)、random.seed(42)三件套。这样每次实验结果可复现排查问题时不会因为随机性干扰判断。第三个技巧保存最佳模型而不是最后一个模型。训练过程中验证集指标最好的那个epoch的权重才是你要的。我通常在每个epoch结束后判断验证指标是否提升提升就保存不提升就跳过。第四个技巧推理服务加一个健康检查接口。/health返回模型加载状态和版本号方便运维排查。这个接口不涉及预测逻辑只是确认服务活着、模型加载成功。这条从零构建AI工程能力的路径我自己走过很多遍每次都能发现新的细节。最开始觉得麻烦但当你真正遇到线上问题、需要快速定位的时候你会发现这些“麻烦”的步骤恰恰是救命的。数据要可追溯、特征要一致、模型要可复现、服务要可监控这四句话是我这些年做AI工程最深的体会。