1. 为什么我要从零手搓一套AI工程化框架
第一次听到“ai-engineering-from-scratch”这个说法,是在一个做推荐系统的老哥群里。有人甩了个链接,说现在市面上讲AI的教程一抓一大把,但真正教你从零把一套AI工程化体系搭起来的,几乎没有。我当时正被公司内部那套“祖传”推理服务折磨得死去活来——模型训练用一套代码,上线部署用另一套,监控告警全靠人肉盯日志,每次迭代都像在拆炸弹。看到这个标题的瞬间,我脑子里就一个念头:这事我得自己干一遍。
所谓“from scratch”,不是让你从晶体管开始造芯片,而是说你要亲手把数据管道、特征工程、模型训练、评估验证、服务部署、监控回滚这一整条链路串起来,每一环都自己写代码、自己调参数、自己踩坑。市面上大部分课程教你的是“如何用某个框架训练一个模型”,但工程化的核心从来不是训练那一下,而是训练之外的所有事。你想想,一个模型在notebook里跑出0.95的AUC,和它在生产环境里稳定扛住每秒几千次请求、延迟控制在50毫秒以内、还能随时热更新,这中间隔着的就是AI工程化的全部内容。
这套东西适合谁?如果你是个算法工程师,平时只负责调参和跑实验,想补上工程化这一环,那这套内容就是为你准备的。如果你是个后端工程师,想转AI方向但被各种框架搞得头晕,那从零搭建能帮你把底层逻辑理清楚。甚至如果你是个技术负责人,想评估团队自建AI平台的成本,跟着走一遍也能心里有数。我自己的背景是后端开发转算法,踩过不少“以为懂了其实没懂”的坑,所以我会尽量把每个决策背后的“为什么”讲透,而不是甩一堆代码让你抄。
2. 整体架构设计与技术选型思路
2.1 为什么选择“单体分层”而不是“微服务全家桶”
刚开始规划的时候,我差点掉进微服务的坑。网上那些架构图一个比一个花哨,什么特征服务、模型服务、元数据服务、监控服务,每个都独立部署,用消息队列串起来。我试着搭了个原型,光是服务发现和网络配置就花了两天,本地开发环境跑起来要8个容器,笔记本风扇转得像直升机。后来我冷静下来问自己:我就一个人,要的是能跑通、能理解、能改,不是给投资人看的架构图。
所以我最终选了单体分层架构:一个Python包,内部按职责分成数据层、特征层、模型层、服务层、监控层,层与层之间通过明确的接口通信,但部署的时候就是一个进程。这样做的好处是调试极其方便,你可以在一个断点里从HTTP请求一路跟到特征计算再到模型推理,不用在多个服务之间跳来跳去。坏处当然也有,比如无法单独扩容某一层,但对于学习和中小规模场景,这个取舍是值得的。等你把这套单体玩透了,再拆微服务就是水到渠成的事。
具体分层是这样的:最底层是数据接入层,负责从各种源(CSV、数据库、消息队列)拉数据并做初步清洗;往上是特征工程层,把原始数据转成模型能吃的特征向量,这里我坚持用纯Python实现,不依赖Spark那种重型工具,目的是让你看清每一步变换;再往上是模型层,包含训练、评估、序列化;然后是服务层,用FastAPI暴露推理接口;最上面是监控层,记录每次请求的输入输出、延迟、异常。每一层我都写了详细的日志和断言,方便你定位问题。
2.2 技术栈选型:为什么是这些工具
选型这件事,我的原则是“够用就好,但关键环节不将就”。下面这张表是我最终定下来的核心依赖,以及每个选择背后的理由:
| 组件 | 选型 | 理由 | 替代方案及不选原因 |
|---|---|---|---|
| 语言 | Python 3.10 | 生态最全,AI领域事实标准 | Go性能好但AI库少,Rust学习曲线陡 |
| Web框架 | FastAPI | 异步支持好,自动生成文档,类型校验强 | Flask太老,Django太重 |
| 数据处理 | Pandas + NumPy | 单机够用,API直观 | Spark杀鸡用牛刀,Polars生态还在追 |
| 模型训练 | PyTorch | 动态图调试方便,社区活跃 | TensorFlow部署工具链复杂 |
| 模型序列化 | ONNX | 跨框架,推理优化空间大 | Pickle有安全风险,TorchScript绑定PyTorch |
| 配置管理 | Pydantic Settings | 类型安全,环境变量自动读取 | YAML文件容易写错且无校验 |
| 日志 | structlog | 结构化输出,方便后续分析 | 标准logging太原始 |
| 监控 | Prometheus + Grafana | 行业标准,可视化强 | 自建方案费时费力 |
这里重点说下ONNX的选择。很多人训练完直接用torch.save存整个模型,但这样有两个问题:一是加载时必须要有相同的PyTorch版本和类定义,换环境就挂;二是推理时没法做图优化。转成ONNX之后,模型变成一个独立文件,可以用ONNX Runtime加载,推理速度通常能提升20%到50%,而且部署时不需要装PyTorch,镜像能小好几个G。当然转换过程有坑,后面我会专门讲。
另一个值得说的是Pydantic Settings。以前我用YAML管配置,经常出现“本地跑得好好的,上线就报KeyError”的情况,因为环境变量没设对。Pydantic Settings会在启动时强制校验所有配置项,缺一个就报错,而且类型不对也报错。这看起来是小事,但能帮你省下大量排查配置的时间。
2.3 数据流设计:从原始数据到推理结果
整个系统的数据流我画过很多版,最终定下来的是双通道设计:训练通道和推理通道共享特征计算逻辑,但数据来源不同。训练通道从离线存储批量读取历史数据,推理通道从请求中实时获取单条数据。共享逻辑意味着特征计算的代码只有一份,避免了“训练时用A公式,推理时用B公式”这种经典事故。
具体来说,原始数据先经过清洗模块,处理缺失值、异常值、类型转换。然后进入特征模块,这里我拆成了三部分:数值特征做标准化和分桶,类别特征做编码,时间特征做周期展开。所有特征变换的参数(比如均值、标准差、分桶边界)都在训练时计算并保存下来,推理时直接加载,保证一致性。特征向量组装好后,训练通道送入模型训练,推理通道送入模型推理。推理结果再经过后处理模块,比如概率校准、阈值截断,最后返回给调用方。
这个设计里有个关键决策:特征变换参数必须持久化。我见过太多团队把标准化参数硬编码在代码里,结果换一批数据就全乱了。正确的做法是把这些参数当作模型的一部分,和模型文件一起版本化管理。我用的方案是每个模型版本对应一个目录,里面包含model.onnx、feature_params.json、config.yaml,加载时一起读进来。
3. 核心模块拆解与实操要点
3.1 数据管道:如何优雅地处理脏数据
数据管道是整套系统的地基,地基没打好,上面全是空中楼阁。我刚开始写的时候图省事,直接pd.read_csv然后dropna,结果上线后发现推理时遇到缺失值直接崩了。后来我重新设计了清洗流程,核心原则是:训练时怎么处理,推理时就怎么处理,而且处理逻辑必须可序列化。
具体来说,我把清洗分成了几个步骤。第一步是类型强制,所有字段按照预定义的schema转换,比如用户ID必须是字符串,金额必须是浮点数,转换失败就记录并置为缺失。第二步是缺失值处理,数值特征用中位数填充,类别特征用特殊标记“MISSING”填充,时间特征用前向填充。这里的关键是,中位数和特殊标记都要保存下来,推理时用同样的值。第三步是异常值处理,我用的是IQR方法,超出1.5倍四分位距的值截断到边界,边界值同样保存。
代码结构上,我定义了一个DataCleaner类,它的fit方法在训练数据上计算所有需要的参数,transform方法应用这些参数。这样训练时调用fit_transform,推理时只调用transform。下面是一个简化版的实现:
class DataCleaner: def __init__(self): self.medians = {} self.iqr_bounds = {} self.categories = {} def fit(self, df): for col in df.select_dtypes(include=[np.number]).columns: self.medians[col] = df[col].median() q1, q3 = df[col].quantile([0.25, 0.75]) iqr = q3 - q1 self.iqr_bounds[col] = (q1 - 1.5*iqr, q3 + 1.5*iqr) for col in df.select_dtypes(include=['object']).columns: self.categories[col] = df[col].unique().tolist() return self def transform(self, df): df = df.copy() for col, median in self.medians.items(): df[col] = df[col].fillna(median) low, high = self.iqr_bounds[col] df[col] = df[col].clip(low, high) for col, cats in self.categories.items(): df[col] = df[col].fillna('__MISSING__') df[col] = df[col].where(df[col].isin(cats), '__OTHER__') return df注意:
fit方法只能在训练数据上调用,绝对不能在推理数据上调用。我见过有人图方便在推理时也fit一下,结果每次请求的标准化参数都不一样,模型输出完全不可复现。
实操心得:清洗逻辑一定要写单元测试,特别是边界情况。我写了一个测试用例,构造了全空列、全相同值列、极端异常值列,确保清洗后不会出现NaN或inf。这个测试帮我提前发现了三个bug,其中一个是当某列全为空时中位数是NaN,导致后续计算全崩。
3.2 特征工程:从原始字段到模型可用的向量
特征工程是AI工程化里最考验功力的地方,也是最能体现“from scratch”价值的地方。很多教程直接甩一个ColumnTransformer就完事,但你真的理解每个变换在做什么吗?我坚持手写每个变换,就是为了把里面的门道讲清楚。
数值特征我做了三件事:标准化、分桶、交叉。标准化用Z-score,公式是(x - mean) / std,目的是让不同量纲的特征对模型有相同的贡献。分桶是把连续值离散化,比如年龄分成0-18、18-35、35-60、60+四段,这样做的好处是能捕捉非线性关系,而且对异常值不敏感。交叉是选两个重要特征做组合,比如“用户等级×活跃度”,能捕捉交互效应。每个变换的参数都在训练时计算并保存。
类别特征我用了目标编码加频率编码。目标编码是用类别对应的目标均值来替换类别值,但直接替换会泄露标签,所以必须用交叉验证的方式计算,我用了5折。频率编码是用类别出现的频率来替换,能帮助模型识别稀有类别。对于高基数类别(比如用户ID),我直接做了哈希分桶,把几万个ID映射到64个桶里,既保留了部分信息又控制了维度。
时间特征我做了周期展开,把小时、星期、月份分别转成sin和cos两个值。比如小时是0-23的循环,直接当数值用会让模型以为23和0差很远,但实际它们只差1。用sin/cos编码后,23和0在空间上就是相邻的。这个技巧在处理周期性数据时非常有用,我试过在销量预测任务上,加了周期编码后模型效果提升了近8%。
所有特征变换的代码我都放在FeatureEngineer类里,同样有fit和transform方法。这里有个细节:特征顺序必须固定。我用一个列表记录所有特征名,每次transform后按这个列表重排,确保训练和推理的特征顺序完全一致。这个坑我踩过,有一次加了个新特征忘了更新顺序,模型输出直接乱掉,排查了半天才发现。
3.3 模型训练与评估:不只是调参那么简单
模型训练这部分,很多人以为就是model.fit()一下,但工程化视角下要考虑的事情多得多。我设计了一个Trainer类,把训练过程拆成了数据加载、模型构建、训练循环、评估验证、模型保存五个阶段,每个阶段都有明确的输入输出和日志记录。
数据加载阶段,我用了PyTorch的DataLoader,但自己写了collate_fn来处理变长特征。这里有个经验:batch size不要设太大,我试过512,结果内存爆了,后来改成128,训练时间只多了15%但内存占用降了一半。学习率我用了余弦退火,初始值0.001,每10个epoch衰减到0.1倍。优化器选了AdamW,比Adam多了权重衰减,能稍微抑制过拟合。
评估阶段我坚持用独立验证集,而不是交叉验证。原因是交叉验证虽然更稳,但训练时间成倍增加,而且工程化场景下你最终还是要选一个模型上线,不如直接留出20%数据做验证。评估指标我同时看了AUC、KS、F1和校准曲线。AUC看排序能力,KS看区分度,F1看分类效果,校准曲线看概率是否准。这四个指标各有侧重,只看一个容易误判。
模型保存我用了ONNX导出,这里有个坑:PyTorch的torch.onnx.export需要提供dummy_input,而且输入维度必须固定。如果你的模型支持动态batch,需要在导出时指定dynamic_axes。我一开始没指定,结果推理时batch size变了就报错。正确的做法是这样:
torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}, opset_version=13 )提示:opset_version不要选最新的,选比你的ONNX Runtime版本低一到两个的,兼容性最好。我用的ONNX Runtime 1.15,opset选13没问题,选17就报错。
3.4 服务部署:让模型真正跑起来
模型训练完只是万里长征第一步,部署才是真正见真章的地方。我用FastAPI搭了一个推理服务,核心接口就一个/predict,接收JSON格式的特征,返回预测结果和置信度。看起来简单,但里面有不少讲究。
首先是模型加载。我用了懒加载加缓存,服务启动时不加载模型,第一次请求来了再加载,加载后缓存在内存里。这样做的好处是启动快,而且如果模型文件有问题,不会导致服务起不来。但坏处是第一次请求会慢,所以我加了个预热机制,启动后自动发一个假请求触发加载。
其次是请求校验。我用Pydantic定义了请求体模型,每个字段都有类型和范围校验。比如年龄必须在0到120之间,金额不能为负。校验不通过直接返回422,不会进入推理逻辑。这个设计帮我挡掉了大量脏请求,有次上游系统传了个负数金额,直接被拦下来了,否则模型输出会完全错乱。
然后是批处理。单条推理效率太低,我实现了一个简单的批处理机制:请求来了先放进队列,攒够32条或者等10毫秒就一起推理。这样吞吐量能提升5到8倍,而延迟只增加了不到10毫秒。实现上用了asyncio.Queue和后台任务,代码大概长这样:
async def batch_worker(): while True: batch = [] while len(batch) < 32: try: item = await asyncio.wait_for(queue.get(), timeout=0.01) batch.append(item) except asyncio.TimeoutError: break if batch: inputs = np.stack([item['features'] for item in batch]) outputs = model.run(inputs) for item, output in zip(batch, outputs): item['future'].set_result(output)最后是优雅关闭。服务收到SIGTERM时,不能直接退出,要先把队列里的请求处理完,再关闭模型。我用了FastAPI的shutdown事件,在里面等待队列清空,最多等30秒。这个细节很多教程不讲,但生产环境里很重要,否则会丢请求。
4. 监控、回滚与持续迭代
4.1 监控体系:别等用户投诉才知道模型挂了
模型上线不是终点,而是起点。我见过太多团队上线后就不管了,直到业务方打电话说“推荐结果怎么全是错的”才发现问题。监控体系要解决三个问题:模型有没有在跑、跑得对不对、跑得快不快。
“有没有在跑”是最基础的,我用Prometheus的Counter记录总请求数,用Gauge记录当前队列长度。如果请求数长时间不增长,或者队列长度持续大于0,就说明有问题。“跑得对不对”稍微复杂,我记录了预测值的分布,包括均值、标准差、分位数。如果当前分布的均值和训练时相比偏移超过2个标准差,就触发告警。这个能捕捉到数据漂移。“跑得快不快”用Histogram记录延迟,我设了三个桶:10ms、50ms、100ms,分别对应P50、P90、P99。
除了这些指标,我还记录了特征漂移。具体做法是每次请求的特征向量都存一份摘要(比如每个特征的均值和方差),每小时聚合一次,和训练数据的统计量对比。如果某个特征的分布偏移过大,就说明上游数据可能变了,需要重新训练。这个机制帮我提前发现过一次上游系统改字段的事故,当时那个字段的均值突然变了3倍,监控立刻报警,我们赶在业务受影响前回滚了。
4.2 模型回滚:出事了怎么快速恢复
回滚这件事,平时用不到,用到的时候就是救命的。我的设计原则是:任何一次部署都必须能在1分钟内回滚到上一个版本。实现方式很简单,模型文件按版本号命名,比如model_v1.onnx、model_v2.onnx,服务启动时读取一个current_version文件决定加载哪个。回滚就是改一下这个文件然后重启服务,或者更优雅一点,加个管理接口动态切换。
但光有文件版本还不够,配置也要版本化。我见过回滚了模型但忘了回滚特征配置的情况,结果模型和特征对不上,输出全是乱的。所以我把模型文件、特征参数、配置文件打成一个包,版本号统一。回滚时整个包一起切,保证一致性。
注意:回滚后一定要验证。我写了个脚本,回滚后自动发100条测试请求,对比回滚前后的输出差异。如果差异过大,说明回滚可能没生效或者有问题,需要人工介入。
4.3 持续迭代:怎么让模型越跑越好
模型上线后,最重要的资产是线上数据。我设计了一个反馈闭环:每次推理的输入特征、输出结果、以及后续的真实标签(如果能拿到)都记录下来,存到数据库里。积累一段时间后,用这些数据重新训练模型,再评估是否比线上版本好。如果好,就走一遍部署流程替换掉。
这里有个关键决策:什么时候重新训练。我的策略是双触发:一是定时触发,每周一次;二是漂移触发,当监控发现特征漂移或预测分布偏移超过阈值时立即触发。定时触发保证模型不会太旧,漂移触发保证模型能快速适应变化。重新训练不是全量重训,而是在原有模型基础上做微调,这样速度快而且不容易过拟合。
评估新模型时,我用了影子模式:新模型和旧模型同时跑,但只有旧模型的输出返回给用户,新模型的输出只记录不生效。跑一周后对比两个模型的指标,如果新模型显著更好,再切换。这个做法虽然多花点资源,但能避免“新模型上线后效果反而变差”的尴尬。
5. 常见问题与排查技巧实录
5.1 推理结果和训练时不一致怎么办
这是最经典的问题,没有之一。表现是:模型在验证集上AUC 0.9,上线后效果稀烂。原因通常有三个:特征计算不一致、数据预处理不一致、模型加载不一致。
排查步骤我总结了一个清单:第一步,拿一条训练数据,分别走训练时的特征计算和推理时的特征计算,对比输出向量是否完全一致。如果不一致,逐字段排查,通常是某个变换的参数没保存或者加载错了。第二步,检查数据清洗逻辑,特别是缺失值填充和异常值截断,训练时用的中位数和推理时用的是不是同一个。第三步,检查模型加载,用ONNX Runtime加载后跑一条数据,和PyTorch原始输出对比,差异应该在1e-5以内。
我踩过最坑的一次是特征顺序问题。训练时特征顺序是[A, B, C],推理时因为字典遍历顺序变了,变成了[C, A, B],模型输出完全错乱。后来我强制用列表固定顺序,这个问题再没出现过。
5.2 服务延迟突然飙升怎么排查
延迟飙升通常不是模型本身的问题,而是周边环节。我的排查顺序是:先看队列,如果队列长度在涨,说明处理速度跟不上请求速度,可能是batch size太大或者模型推理太慢。再看CPU和内存,如果CPU打满,可能是特征计算太复杂;如果内存涨,可能是缓存没清理。最后看模型,用ONNX Runtime的profiling工具看每个算子的耗时,通常瓶颈在矩阵乘法或者注意力层。
有一次延迟从20ms飙到200ms,查了半天发现是日志打太多了。每次请求都打完整特征向量,IO成了瓶颈。后来改成只打摘要,延迟立刻降回25ms。这个教训是:日志要克制,生产环境别打debug日志。
5.3 模型效果随时间下降怎么处理
效果下降的本质是数据分布变了,也就是漂移。处理方式分三步:检测、归因、应对。检测靠监控,前面说了。归因是看哪个特征漂移最严重,通常能找到业务上的原因,比如某个渠道的用户行为变了。应对分短期和长期:短期可以调阈值或者加规则兜底,长期是重新训练模型。
我遇到过一次效果下降,查下来是某个重要特征的缺失率从5%涨到了30%,原因是上游系统改了个字段名,新数据没映射上。这种问题监控能发现,但归因需要你对业务和数据流足够熟悉。所以我的经验是:监控指标要细到每个特征,不能只看整体。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 推理结果与训练不一致 | 特征顺序错、参数未保存 | 逐字段对比特征向量 | 固定特征顺序,参数持久化 |
| 延迟突然飙升 | 日志过多、batch过大 | 看CPU、IO、队列长度 | 减少日志,调小batch |
| 效果随时间下降 | 数据漂移 | 对比特征分布 | 重新训练,加监控 |
| 服务启动失败 | 模型文件损坏、配置缺失 | 看启动日志 | 校验文件完整性,配置必填 |
| 内存持续增长 | 缓存未清理、请求泄漏 | 看内存曲线 | 加缓存过期,检查异步任务 |
| ONNX加载报错 | opset版本不兼容 | 看错误信息 | 降低opset版本重新导出 |
提示:这张表建议打印出来贴在工位上,出问题时先对照排查,能省不少时间。
6. 一些掏心窝子的经验
整套系统搭下来,我最大的体会是:AI工程化的难点从来不在算法,而在工程。算法你可以调包,但工程化的每个决策都需要你理解背后的权衡。比如为什么用ONNX不用Pickle,为什么用Pydantic不用YAML,为什么batch size不能太大,这些选择没有标准答案,只有适合你场景的答案。
另一个体会是:测试比代码重要。我写了大量的单元测试和集成测试,特别是特征计算和数据清洗部分。每次改代码,跑一遍测试就能知道有没有破坏原有逻辑。没有测试的工程化系统,就像没有刹车的车,跑得越快越危险。
最后说个小事。有次我为了赶进度,跳过了一个特征校验步骤,结果上线后模型对某个特殊值输出全是NaN。排查了三个小时才发现是那个值在标准化时除以了零。从那以后,我养成了一个习惯:任何除法运算前都加一个极小值,比如(x - mean) / (std + 1e-8)。这个习惯帮我避免了至少五次类似的事故。
这套东西我还在持续迭代,最近在加模型解释模块,用SHAP值分析每个特征对预测的贡献。等弄好了再跟大家分享。如果你也在搭类似的系统,欢迎交流,踩过的坑就不用再踩一遍了。