1. 从零手搓AI工程:为什么我不建议你直接调包
第一次看到ai-engineering-from-scratch这个项目名的时候,我正坐在工位上啃一个调了三天都没收敛的推荐模型。当时第一反应是:又来了一个“从零实现”的教程仓库。干这行十来年,见过太多“从零手写神经网络”的项目,点进去一看,无非是把nn.Linear换成手写的矩阵乘法,然后跑个 MNIST 就收工。但这次我花了一个周末把整个仓库的脉络捋了一遍,发现它想做的事情不太一样——它不是在教你“手写一个算子”,而是在教你“从零搭一条能上线的AI工程链路”。
这个区别很关键。手写算子解决的是“我懂不懂反向传播”的问题,而工程链路解决的是“模型训完了怎么让它在生产环境里稳定跑起来”的问题。前者是面试造火箭,后者才是日常搬砖。ai-engineering-from-scratch的核心价值在于,它把数据管道、特征存储、训练编排、模型注册、推理服务、监控告警这一整条链路,用最朴素的工具从零拼出来,不依赖任何重型框架。适合谁看?我觉得有三类人:一是刚转行做AI工程、被各种平台工具绕晕的新人;二是想搞清楚“平台背后到底干了什么”的算法同学;三是团队里负责搭基础设施、但不想一上来就上Kubernetes的工程师。
我打算按我自己复现这个项目的顺序来写,从整体设计思路讲到每个环节的实操细节,中间穿插我踩过的坑和验证过的参数。文章会比较长,因为工程链路本身就是个长链条,每个环节都有值得展开的地方。你可以按需跳读,但建议至少把数据管道和推理服务这两块看完,这两块是实际工作中出问题最多的地方。
2. 整体架构设计与技术选型思路
2.1 为什么选择“朴素工具链”而不是全家桶
这个项目最让我认同的一点,是它在技术选型上的克制。现在市面上搭AI工程链路,默认方案就是MLflow做实验追踪、Airflow做编排、Feast做特征存储、Triton做推理、Prometheus加Grafana做监控。这套组合拳打下来,光是环境配置就能耗掉两周,而且每个组件都有自己的抽象层,出了问题排查起来像剥洋葱。
ai-engineering-from-scratch走的是另一条路:用Python标准库加少量轻量依赖,把每个环节的核心逻辑暴露出来。数据管道用生成器和迭代器实现,特征存储用SQLite加Parquet文件,训练编排用简单的任务队列,模型注册用文件系统加JSON元数据,推理服务用FastAPI,监控用日志加自定义指标。这套方案的优势在于,每一层的实现你都能在半小时内读完,出了问题直接断点调试,不用去翻框架源码。
我实测下来,这套朴素方案在中小规模场景下完全够用。所谓中小规模,我指的是单次训练数据量在千万级样本以内、推理QPS在几百这个量级。超过这个量级,确实需要上分布式框架,但那时候你已经对每个环节的原理了如指掌,迁移过去只是换个实现,思路是通的。反过来,如果一上来就用全家桶,你学到的是“怎么配置MLflow”,而不是“实验追踪到底要记录什么”。
2.2 分层设计:把“工程”和“算法”解耦
项目在架构上做了一个很重要的分层:把数据层、训练层、服务层彻底解耦,层与层之间只通过约定好的数据格式通信。数据层输出的是标准化的Parquet文件加schema描述,训练层只认这个格式,服务层只认模型注册表里的产物。这个设计的好处是,你可以单独替换任何一层而不影响其他层。
举个例子,我一开始用项目自带的SQLite做特征存储,后来数据量涨到单表两千万行,查询开始变慢,我就把特征存储换成了DuckDB,因为两者都走SQL接口,训练层的代码一行没改。再后来推理服务需要支持批量请求,我把FastAPI的单条推理改成批处理,服务层的改动也没有波及训练层。这种解耦在真实项目里太重要了,因为AI工程的需求变化极快,今天要加个新特征,明天要换个模型结构,如果各层耦合在一起,每次改动都是牵一发动全身。
分层带来的另一个好处是测试变得简单。数据层可以单独用构造的样本测试,训练层可以用固定的小数据集测试收敛性,服务层可以用mock模型测试接口。我在实际项目中把这套分层照搬过去,CI流水线的测试时间从原来的二十多分钟降到了三分钟,因为每层可以并行测试,不用等整条链路跑完。
2.3 目录结构与模块划分
项目的目录结构很清晰,我按自己的理解重新梳理了一下:
ai-engineering-from-scratch/ ├── data_pipeline/ # 数据采集、清洗、特征计算 │ ├── collectors/ # 数据源适配器 │ ├── processors/ # 清洗与转换逻辑 │ └── feature_store/ # 特征存储实现 ├── training/ # 训练相关 │ ├── datasets/ # 数据集加载与切分 │ ├── models/ # 模型定义 │ └── orchestrator/ # 训练任务编排 ├── registry/ # 模型注册与版本管理 ├── serving/ # 推理服务 │ ├── api/ # 接口层 │ └── runtime/ # 推理运行时 └── monitoring/ # 监控与告警这个划分方式有个细节值得注意:feature_store放在data_pipeline下面,而不是单独拎出来。这是有道理的,因为特征存储本质上是数据管道的产物,它的schema应该由数据管道来定义和维护。我见过一些项目把特征存储独立成一个大模块,结果特征定义和数据管道脱节,训练时用的特征和线上服务的特征对不上,这种问题排查起来非常痛苦。
3. 数据管道:从原始数据到可用特征
3.1 数据采集的幂等性设计
数据采集这块,项目强调的第一个原则是幂等性。什么意思?就是同一个数据源,不管你采集多少次,结果应该是一样的,不会因为重复采集产生重复数据。这个原则听起来简单,做起来容易翻车。
项目的做法是给每条数据生成一个内容指纹,用哈希值作为唯一标识,写入时做去重。具体实现上,它没有用复杂的布隆过滤器,而是用了一个简单但有效的方案:在SQLite里建一张指纹表,采集时先查指纹是否存在,不存在才写入。这个方案在千万级数据量下性能可以接受,因为指纹表可以建索引,查询是O(log n)。
我实测下来,这个方案有个坑需要注意:如果数据源本身会更新(比如用户修改了资料),内容指纹会变,导致同一条记录被当成新数据写入。项目的处理方式是在指纹计算时排除掉更新时间戳字段,只对业务字段做哈希。这个细节很关键,我在实际项目中就因为没排除时间戳,导致用户改个昵称就产生一条新记录,数据量翻了三倍。
提示:指纹字段的选择直接决定了去重效果。建议只对业务主键和核心属性做哈希,把系统字段(创建时间、更新时间、操作人)排除在外。
3.2 清洗与转换的管道模式
数据清洗这块,项目用了一个很经典的管道模式:每个清洗步骤是一个独立的函数,接收DataFrame返回DataFrame,多个步骤串成一条链。这种模式的好处是每个步骤可以单独测试,也可以灵活组合。比如处理用户行为数据时,我可能需要“去重→过滤异常值→填充缺失→类型转换”这条链,而处理商品数据时,我可能需要“去重→标准化类目→价格分桶”这条链。
项目自带的清洗步骤覆盖了常见场景:空值处理、异常值检测、类型转换、文本标准化、时间解析。我重点说一下异常值检测这块,它没有用简单的3σ原则,而是用了IQR(四分位距)方法。原因是3σ假设数据服从正态分布,但真实业务数据往往长尾严重,用3σ会把大量正常的长尾数据误判为异常。IQR方法对分布没有假设,更稳健。
具体计算方式是:Q1是25%分位数,Q3是75%分位数,IQR等于Q3减Q1,正常范围是Q1减1.5倍IQR到Q3加1.5倍IQR。超出这个范围的标记为异常。我在实际使用中把这个系数从1.5调到了2.0,因为业务上一些高价值用户的行为本身就偏离常规,用1.5会把他们误伤。这个系数没有标准答案,需要根据业务理解来调。
3.3 特征存储的schema管理
特征存储这块是项目的亮点之一。它用了一个很朴素但很有效的schema管理方式:每个特征集对应一个JSON文件,里面定义了字段名、类型、默认值、计算逻辑描述。这个JSON文件就是特征集的“契约”,数据管道按它写入,训练和服务按它读取。
为什么schema管理这么重要?因为特征不一致是AI工程里最隐蔽的bug来源。训练时用的特征叫user_age,线上服务时取的字段叫userAge,这种问题在代码层面看不出来,只有模型效果下降时才会暴露。有了schema契约,写入和读取都做校验,字段名对不上直接报错,把问题扼杀在早期。
项目还做了一个细节处理:schema里记录了每个特征的版本号。当特征计算逻辑变更时,版本号递增,旧版本的特征数据保留,新版本写入新分区。这样训练时可以选择用哪个版本的特征,服务时也可以灰度切换。这个设计在特征迭代频繁的场景下非常有用,我见过太多团队因为特征逻辑变更导致线上线下不一致,最后只能回滚模型。
3.4 增量计算与全量重算的取舍
数据管道绕不开的一个问题是:每次跑是全量重算还是增量计算?项目默认走增量,但保留全量重算的入口。增量计算的实现方式是记录每个数据源的最后处理位置(比如时间戳或偏移量),下次从该位置继续。
增量计算的优势是快,劣势是容易出错。我踩过的坑包括:数据源有延迟到达的数据,增量处理时漏掉了;数据源的时间戳有乱序,按时间戳增量会丢数据。项目的处理方式是引入一个“回看窗口”,每次增量处理时多处理最近N天的数据,用幂等性保证不重复。这个N需要根据数据源的延迟特性来定,我一般设成3天,因为业务上数据延迟很少超过72小时。
全量重算的触发条件项目也做了设计:当特征计算逻辑变更、或者增量处理连续失败超过阈值时,自动触发全量重算。全量重算比较慢,但能保证数据一致性。我在实际项目中把全量重算放在凌晨低峰期跑,避免影响白天的增量任务。
4. 训练编排:让模型训练可复现可追溯
4.1 实验追踪的最小实现
实验追踪这块,项目没有用MLflow,而是用了一个极简方案:每次训练生成一个实验目录,目录里包含配置文件、指标日志、模型文件、环境快照。配置文件记录了所有超参数和数据版本,指标日志是逐epoch追加的JSON Lines文件,环境快照记录了Python版本和依赖包版本。
这个方案的好处是,所有信息都在文件系统里,用ls和cat就能查看,不需要起服务。我实测下来,对于个人和小团队,这比MLflow更实用。MLflow虽然功能全,但它的数据库和artifact存储需要维护,而且查询界面偶尔会卡。文件系统方案虽然原始,但胜在可靠,而且可以直接用git管理实验配置。
指标日志用JSON Lines格式是个细节。相比CSV,JSON Lines每行是独立JSON对象,追加写入不会破坏已有内容,而且可以嵌套结构,记录更复杂的指标。比如我记录的不只是loss和accuracy,还有每个类别的F1、混淆矩阵、学习率变化,这些用CSV表达起来很别扭,用JSON就很自然。
4.2 训练任务的编排与调度
训练编排这块,项目用了一个基于文件锁的简单任务队列。任务定义写在JSON文件里,调度器扫描任务目录,发现有未执行的任务就加锁执行,执行完写结果文件。这个方案在多机场景下也能用,只要任务目录放在共享存储上。
为什么不用Celery或者Airflow?因为那些工具引入了额外的依赖和运维成本。对于训练任务这种低频、长时的任务,文件锁方案足够用。我实测下来,单机同时跑三个训练任务没问题,再多就会争抢GPU。如果需要跑更多任务,可以加机器,每台机器扫描同一个任务目录,文件锁保证任务不会被重复执行。
任务编排里有个细节值得说:项目支持任务依赖。比如特征计算任务完成后才能触发训练任务,训练任务完成后才能触发评估任务。依赖关系用DAG描述,调度器按拓扑序执行。这个DAG实现得很轻量,没有用networkx之类的库,就是简单的邻接表加递归。我一开始觉得太简陋,后来发现够用,因为训练任务的依赖关系通常不复杂,深度很少超过五层。
4.3 超参数搜索的朴素方案
超参数搜索这块,项目没有用Optuna或者Ray Tune,而是用了网格搜索加随机搜索的组合。网格搜索用于参数空间小的情况,随机搜索用于参数空间大的情况。随机搜索的实现方式是每个参数给定分布,采样N次,取最好的结果。
这个方案在参数维度低的时候够用,但维度一高就吃力。我的经验是,参数维度超过五个,随机搜索的采样效率就明显下降。这时候可以引入贝叶斯优化,但项目没有内置,需要自己接。我接了一个轻量的贝叶斯优化库,改动不大,因为训练编排的接口是开放的,只要实现“给定参数返回指标”这个接口就行。
超参数搜索还有个工程问题:并行化。项目支持多任务并行搜索,每个任务用不同的参数组合,结果汇总到同一个实验目录。并行度受限于GPU数量,我一般设成GPU数减一,留一张卡给推理服务。这个细节在资源紧张的时候很重要,我见过有人把GPU占满导致线上服务不可用。
4.4 模型版本管理与回滚
模型注册这块,项目用文件系统加JSON元数据实现。每个模型版本是一个目录,目录名是模型名加版本号,目录里包含模型文件、元数据JSON、评估报告。元数据记录了训练数据版本、超参数、评估指标、创建时间、创建人。
版本管理的关键是回滚能力。项目支持按版本号回滚,也支持按指标回滚(比如回滚到验证集F1最高的版本)。回滚操作只是修改一个指针文件,指向要使用的版本目录。推理服务启动时读这个指针,加载对应版本的模型。
我实测下来,这个方案有个坑:模型文件可能很大,多个版本占满磁盘。项目的处理方式是保留最近N个版本,更早的版本归档到冷存储。N的取值需要根据磁盘容量和回滚需求来定,我一般设成10,因为业务上很少回滚到10个版本之前。归档用tar压缩,压缩比通常能到3比1,能省不少空间。
5. 推理服务:从模型文件到线上接口
5.1 服务框架的选择与性能考量
推理服务这块,项目选了FastAPI。为什么不是Flask或者Django?因为FastAPI原生支持异步,而且自带请求校验和文档生成。对于推理服务这种IO密集加计算密集的混合场景,异步能显著提升吞吐。我实测过,同样的模型,FastAPI的QPS比Flask高30%左右,因为Flask是同步阻塞的,请求排队时CPU在空转。
但FastAPI的异步也有个坑:如果推理本身是CPU密集的(比如树模型),异步反而会拖慢,因为事件循环被计算阻塞了。项目的处理方式是把推理放到线程池里执行,事件循环只负责接收请求和返回响应。这个细节很关键,我见过有人直接用async def包推理逻辑,结果QPS比同步还低。
服务框架还有个选择是Triton,但Triton的配置复杂度高,而且对模型格式有要求。项目的定位是“从零理解”,所以选了FastAPI,把推理逻辑暴露出来。如果你追求极致性能,可以在理解原理后迁移到Triton,那时候你知道每个配置项对应的是什么。
5.2 批处理与动态批处理
推理服务的性能优化,批处理是最有效的手段。项目实现了静态批处理和动态批处理两种模式。静态批处理是请求攒够一批再推理,动态批处理是根据请求到达速率自动调整批大小。
动态批处理的实现逻辑是:维护一个请求队列,当队列长度达到阈值或者等待时间超过阈值时,触发一次批推理。阈值需要根据模型推理时间和延迟要求来定。我实测下来,对于延迟要求100毫秒以内的场景,批大小设成8比较合适;对于延迟要求500毫秒的场景,批大小可以设到32。
批处理有个坑:不同请求的输入长度可能不同,padding到最大长度会浪费计算。项目的处理方式是按长度分桶,同一桶内的请求一起批处理。这个优化在文本模型上效果明显,我实测能提升40%的吞吐。分桶的粒度需要权衡,桶太多管理复杂,桶太少浪费计算,我一般设成5个桶。
5.3 模型热加载与灰度发布
模型热加载是推理服务的刚需。项目实现的方式是:服务启动时加载模型,同时启动一个文件监听器,监听模型指针文件的变化。指针变化时,后台线程加载新模型,加载完成后原子替换。这个过程中服务不中断,正在处理的请求用旧模型,新请求用新模型。
灰度发布是在热加载基础上做的。项目支持按流量比例灰度,比如新模型接收10%的流量,旧模型接收90%。实现方式是在请求入口做路由,根据请求ID的哈希值决定用哪个模型。灰度比例可以动态调整,观察新模型指标后再决定全量还是回滚。
我踩过的坑是:热加载时如果新模型加载失败,服务会处于什么状态?项目的处理是保留旧模型,记录错误日志,继续用旧模型服务。这个降级策略很重要,我见过有人热加载失败后服务直接不可用,因为代码里把旧模型释放了。记住一个原则:新模型没准备好之前,旧模型不能释放。
5.4 推理服务的监控指标
监控这块,项目定义了四个核心指标:请求量、延迟、错误率、模型指标。请求量和延迟是工程指标,错误率和模型指标是业务指标。
延迟指标要分位数,不能只看平均值。平均值会被长尾请求拉高,掩盖大部分请求的真实体验。项目记录P50、P90、P99三个分位数。P50反映典型体验,P90反映较差体验,P99反映最差体验。我一般关注P99,因为P99超标说明有用户遇到了明显卡顿。
模型指标这块,项目记录的是预测分布的统计量,比如均值、方差、各类别占比。这些指标用于检测数据漂移。如果线上请求的预测分布和训练时的分布差异过大,说明数据分布变了,模型可能需要重新训练。我设的告警阈值是分布差异超过20%,这个阈值需要根据业务容忍度来调。
6. 常见问题与排查技巧实录
6.1 数据管道常见问题速查
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 数据量突然翻倍 | 幂等性失效,重复写入 | 检查指纹字段是否包含时间戳 | 排除系统字段,只对业务字段哈希 |
| 特征值全为空 | schema不匹配 | 对比写入和读取的schema | 统一schema定义,加校验 |
| 增量任务丢数据 | 数据延迟到达 | 检查数据源延迟分布 | 增加回看窗口 |
| 全量重算超时 | 数据量过大 | 检查单次处理的数据量 | 分批处理,加进度记录 |
6.2 训练环节的坑与解法
训练环节最常见的问题是复现性。同样的代码和数据,两次训练结果不一样。原因通常有三个:随机种子没固定、数据切分没固定、GPU非确定性操作。项目的处理是固定所有随机种子,数据切分用固定种子,GPU操作开启确定性模式。但开启确定性模式会降低性能,我一般只在调试时开启,生产训练关闭。
第二个坑是显存溢出。项目的处理是动态调整批大小,显存不够时自动减半。这个方案在大多数情况下有效,但有个边界情况:模型本身太大,批大小减到1还是溢出。这时候需要模型并行或者梯度累积。梯度累积是把多个小批的梯度累加后再更新,效果等价于大批,但显存占用小。
第三个坑是训练中断。长时训练可能因为各种原因中断,项目的处理是定期保存检查点,中断后从最近的检查点恢复。检查点保存频率需要权衡,太频繁影响训练速度,太稀疏丢失进度。我一般设成每epoch保存一次,如果epoch很长,就每半小时保存一次。
6.3 推理服务的性能瓶颈定位
推理服务变慢时,定位瓶颈的顺序是:先看CPU和GPU利用率,再看请求队列长度,最后看模型推理时间。如果CPU利用率高但GPU利用率低,说明瓶颈在数据预处理;如果GPU利用率高,说明瓶颈在模型计算;如果队列长度持续增长,说明吞吐不够,需要加机器或优化批处理。
我遇到过一个典型案例:推理服务延迟突然从50毫秒涨到500毫秒。排查发现GPU利用率只有20%,CPU利用率90%。进一步排查发现是特征预处理里的一个正则表达式写得太复杂,成了瓶颈。把正则改成字符串查找后,延迟降回60毫秒。这个案例说明,推理服务的瓶颈不一定在模型,预处理往往是被忽视的环节。
6.4 监控告警的误报与漏报
监控告警的难点是平衡误报和漏报。阈值设太松会漏报,设太紧会误报。项目的处理是用动态阈值,基于历史数据的均值和标准差计算,超过3倍标准差才告警。这个方案比固定阈值好,能适应业务的周期性波动。
但动态阈值也有坑:如果历史数据本身就有异常,阈值会被带偏。项目的处理是定期清洗历史数据,剔除异常点后再计算阈值。我一般每周清洗一次,用IQR方法剔除异常。另外,告警要分级,P0告警立即通知,P1告警汇总通知,P2告警只记录。分级能避免告警疲劳,我见过有人把所有告警都设成P0,结果大家都不看告警了。
7. 我在实际项目中的改造与扩展
7.1 接入对象存储
项目默认用本地文件系统存数据,我在实际项目中改成了对象存储。改动点主要在数据读写层,把本地路径换成对象存储的URL。对象存储的优势是容量无限、多机共享,劣势是延迟比本地高。我的处理是加一层本地缓存,热数据缓存在本地,冷数据放对象存储。
缓存策略用的是LRU,缓存大小设成磁盘容量的70%。这个比例是经验值,留30%给系统和临时文件。缓存命中率我实测能到85%以上,因为训练任务通常反复读取同一批数据。缓存失效用TTL加版本号,数据更新时版本号变化,缓存自动失效。
7.2 增加数据质量校验
项目自带的数据校验比较基础,我加了一层更严格的质量校验。校验规则包括:字段非空率、值域范围、分布一致性、唯一性。校验不通过时阻断管道,避免脏数据流入训练。
分布一致性校验特别有用。它对比当前批次数据和历史数据的分布,用KL散度衡量差异。差异超过阈值时告警,提示可能有数据源变更。我设的阈值是0.1,这个值需要根据业务调整。KL散度计算需要先做分桶,桶的数量我一般设成20,太少不敏感,太多噪声大。
7.3 支持多模型并行服务
项目默认一个服务实例加载一个模型,我改成了多模型并行。实现方式是用模型池,每个模型一个推理线程,请求按模型名路由。这个改造让一个服务实例能同时服务多个模型,节省了资源。
多模型并行的挑战是显存管理。多个模型同时加载可能显存不够。我的处理是设置模型优先级,高优先级模型常驻,低优先级模型按需加载,闲置超时后卸载。这个策略在模型多但请求分散的场景下很有效,我实测显存占用降低了60%。
7.4 增加A/B测试能力
灰度发布只能按流量比例分流,不能做A/B测试。我加了一层A/B测试,支持按用户分组分流,并且记录每组的业务指标。A/B测试的关键是分组要随机且稳定,同一个用户每次请求都分到同一组。实现方式是用用户ID的哈希值对100取模,根据模值分组。
A/B测试的统计显著性需要计算。我用了简单的双样本t检验,置信度设95%。样本量不够时检验结果不可靠,所以A/B测试需要跑够时间。我一般至少跑一周,覆盖工作日和周末的差异。这个时间要求经常被业务方挑战,但统计上确实需要,样本不够得出的结论可能是噪声。
8. 一些零散但重要的经验
关于代码组织,我建议把配置和代码分离。项目的配置文件是YAML,我改成了环境变量加YAML覆盖。环境变量用于区分环境(开发、测试、生产),YAML用于业务配置。这样同一份代码可以在不同环境跑,不用改代码。
关于日志,我建议结构化日志。项目的日志是文本格式,我改成了JSON格式。结构化日志便于检索和聚合,出问题时能快速过滤。日志级别要合理使用,DEBUG用于开发,INFO用于关键流程,WARN用于可恢复异常,ERROR用于需要人工介入的异常。我见过有人把所有日志都打成INFO,结果关键错误被淹没。
关于测试,我建议分层测试。数据层用单元测试,训练层用集成测试,服务层用端到端测试。测试数据要小而精,能覆盖边界情况。我一般会构造一些“刁钻”的测试数据,比如全空、全相同、极值、超长文本,这些数据往往能暴露隐藏的bug。
关于文档,我建议代码即文档。项目的注释比较充分,但文档更新不及时。我的做法是把关键设计决策写在代码注释里,用# NOTE:和# TODO:标记。这样改代码时不容易忘记改文档,因为文档就在代码旁边。
关于协作,我建议约定提交规范。项目的提交信息比较随意,我引入了约定式提交,feat、fix、docs、refactor等前缀。这样生成变更日志时能自动分类,review时也能快速判断改动性质。这个规范一开始大家不习惯,跑两周后就顺了。
最后说一个心态上的体会。做AI工程,工具会过时,框架会迭代,但底层的原理和工程思维不会变。ai-engineering-from-scratch这个项目最大的价值,不是教你用某个具体工具,而是让你理解每个环节为什么存在、解决什么问题。理解了这些,换任何工具你都能快速上手。我在带新人的时候,都会让他们先把这个项目跑一遍,再去看公司内部的平台,他们往往能提出一些平台设计上的改进建议,因为他们知道底层发生了什么。