1. 从零搭建AI工程能力:这个项目到底在解决什么问题
第一次看到 "ai-engineering-from-scratch" 这个标题,我脑子里蹦出来的不是某个具体框架,而是一类人——那些已经会调model.fit()、能跑通官方 demo,但一旦要自己从零搭一条完整的 AI 工程链路就发怵的开发者。这个项目标题的核心,其实是在回答一个很朴素的问题:当你不依赖任何现成的“全家桶”脚手架时,一个可用的 AI 工程系统,最小骨架应该长什么样?
我做了十多年一线开发,带过不少从算法转工程、或者从后端转 AI 的同学,发现大家卡住的地方高度一致:模型本身能跑,但数据怎么进来、特征怎么存、训练怎么调度、推理怎么上线、线上怎么监控,这一整条链路是断的。ai-engineering-from-scratch这类项目的价值,就在于它把这条链路拆成一块块可以独立理解、独立替换的积木,让你从“会调库”变成“懂系统”。
它适合谁?三类人最该认真看:一是刚入行、只会跑 notebook 的算法同学,需要补工程这一课;二是后端或数据工程师,想切入 AI 方向但不知道从哪下手;三是小团队里那个“什么都得管一点”的全栈角色,需要一套能自己掌控、不黑盒的最小系统。这篇文章我会按真实搭建顺序,把数据层、训练层、服务层、监控层逐层拆开,讲清楚每一步为什么这么做、参数怎么定、坑在哪。
2. 整体架构设计与技术选型思路
2.1 为什么“从零”比“用现成”更难也更有价值
很多人第一反应是:现在有那么多成熟的 MLOps 平台,为什么还要从零搭?这个问题我认真想过。用现成平台,你学到的是“这个按钮点哪里”;从零搭,你学到的是“这个按钮背后发生了什么”。前者让你能干活,后者让你在出问题时能定位、在需求变化时能改造。
从零搭建的核心难点不在写代码,而在做决策。每一个环节都有无数种选法:数据存 CSV 还是 Parquet?特征实时算还是离线预计算?模型服务用 Flask 还是 FastAPI?监控用 Prometheus 还是自己打日志?这些决策没有标准答案,只有“在你的场景下合不合理”。ai-engineering-from-scratch的架构思路,我理解成一句话:用最少的组件,覆盖一条完整链路,每个组件都保持可替换。
我推荐的骨架是四层:数据层、训练层、服务层、可观测层。四层之间通过明确的接口通信,任何一层换实现,其他层不受影响。这个“接口清晰”的原则,比具体用什么技术重要得多。
2.2 四层架构的职责边界与选型对照
先把四层的职责说清楚,这是后面所有实操的基础。
| 层级 | 核心职责 | 常见选型 | 选型关键考量 |
|---|---|---|---|
| 数据层 | 数据接入、清洗、特征存储、版本管理 | Parquet + DuckDB / PostgreSQL / 对象存储 | 读写模式是批还是流,数据量级 |
| 训练层 | 实验管理、训练调度、模型版本 | 脚本 + 配置管理 / 轻量调度器 | 复现性、资源隔离 |
| 服务层 | 模型加载、请求处理、批处理 | FastAPI / 异步队列 | 延迟要求、并发量 |
| 可观测层 | 日志、指标、数据漂移监控 | 结构化日志 + 指标采集 | 排查效率、告警及时性 |
这张表不是让你照抄,而是给你一个决策框架。比如数据层,如果你每天只处理几十万行,Parquet 加 DuckDB 就够了,上分布式存储纯属浪费;但如果你要做实时特征,那流处理组件就绕不开。选型的本质是匹配你的真实数据量和延迟要求,而不是追新。
提示:新手最容易犯的错是“一步到位”,上来就搭一套重型架构。我的建议是先跑通最小闭环,等真的遇到瓶颈再替换组件。架构是长出来的,不是设计出来的。
2.3 接口设计:让每一层都能被替换
从零搭建最怕的是“牵一发动全身”。解决办法是定义清晰的接口。比如数据层对外只暴露两个方法:read_features(entity_ids, feature_names)和write_features(df)。训练层只依赖这两个方法,不关心底层是 CSV 还是数据库。这样你哪天想把存储从本地换成云,训练代码一行不用改。
服务层同理,它只依赖一个predict(input)接口,内部是加载 PyTorch 还是 ONNX 模型,对调用方透明。这种“面向接口”的思路,是ai-engineering-from-scratch这类项目能长期演进的关键。我在实际项目里吃过亏:早期图省事,训练脚本直接读数据库连接,后来换存储时改了十几个文件,全是泪。
3. 数据层:特征管道的搭建与实操要点
3.1 数据接入与清洗的最小可用方案
数据层是一切的起点,也是最容易被低估的一层。我见过太多项目,模型效果上不去,最后发现是数据管道有脏数据没清干净。从零搭建时,数据接入我建议分三步走:落地原始数据、定义清洗规则、产出特征表。
落地原始数据,原则是“原样保存,不做任何加工”。用带时间戳的文件名存到对象存储或本地目录,比如raw/2024-01-15/events.parquet。为什么强调原样?因为清洗规则会变,但原始数据只有一份,保留它你才有重跑的机会。清洗规则我习惯写成一个独立的配置文件,而不是硬编码在脚本里,这样调整阈值不用改代码。
清洗的核心动作就几个:去重、处理缺失值、类型转换、异常值截断。缺失值处理要分情况——数值特征用中位数填充,类别特征用“未知”单独成一类,时间序列特征用前向填充。异常值我一般用分位数截断,比如把超过 99.9 分位和低于 0.1 分位的值拉回边界,而不是直接删掉,因为删数据可能引入偏差。
3.2 特征存储的格式选择与性能权衡
特征存什么格式,直接决定训练和推理的效率。我实测下来,Parquet 是离线特征的最优解:列式存储、压缩率高、读取时能只取需要的列。对比 CSV,同样一千万行特征,Parquet 读取速度快 5 到 10 倍,体积小 3 到 5 倍。这个差距在迭代频繁时非常明显。
如果你需要按实体 ID 快速查询单条特征(推理时常见),那 Parquet 就不合适了,得上键值存储。轻量方案是 SQLite 或 DuckDB,重一点是 PostgreSQL 或 Redis。我的经验是:离线训练用 Parquet,在线推理用键值库,两者通过一个同步任务保持一致。同步任务要幂等,重复跑不会产生脏数据。
特征版本管理也别忘了。每次产出特征表,记录三个信息:数据版本号、生成时间、依赖的清洗规则版本。这样模型出问题时,你能回溯到“这个模型是用哪版特征训的”。我习惯在特征表旁边放一个_meta.json,记录这些元信息,简单但救命。
3.3 数据质量校验:上线前的最后一道闸
数据质量校验是很多人跳过的一步,但它是防止“垃圾进垃圾出”的关键。我建议至少做四类校验:空值率、取值范围、分布偏移、主键唯一性。
空值率校验:某个特征空值率超过阈值(比如 30%)就告警,说明上游可能出问题了。取值范围校验:数值特征是否落在合理区间,比如年龄不该是负数。分布偏移校验:新一批数据和历史数据比,均值、方差有没有剧烈变化,这是数据漂移的早期信号。主键唯一性:确保没有重复记录。
这些校验写成断言,跑在数据管道末尾,不通过就中断流程。别小看这几行断言,我在生产环境靠它拦下过好几次上游数据异常,避免了用脏数据重训模型。校验结果要落库,形成历史记录,方便看趋势。
4. 训练层:实验管理与可复现训练
4.1 训练脚本的工程化改造
从 notebook 到可复现的训练脚本,中间隔着一整套工程习惯。我总结下来,一个合格的训练脚本必须满足:配置外置、随机种子固定、中间产物落盘、日志结构化。
配置外置是指所有超参数、路径、模型结构参数都从配置文件读,不写死在代码里。用 YAML 或 JSON 都行,我偏好 YAML,因为可读性好、支持注释。随机种子要固定 Python、NumPy、框架三层的种子,否则同样的代码两次跑结果不一样,实验没法对比。
中间产物落盘包括:每个 epoch 的模型权重、训练曲线数据、验证集预测结果。这些东西平时看着占地方,但当你需要复现某个“当时效果特别好”的实验时,它们就是救命稻草。日志结构化是指用 JSON 格式打日志,每条日志带时间戳、级别、模块名,方便后续用工具检索分析。
4.2 实验追踪:不装平台也能管好实验
不是每个团队都需要 MLflow 这种重型实验平台。小团队用“目录约定 + 元数据文件”就能管得很好。我的做法是每次实验生成一个独立目录,命名规则是exp_{日期}_{简短描述}_{随机后缀},目录里放:配置文件副本、训练日志、模型权重、评估指标 JSON。
关键在评估指标 JSON,它记录这次实验的所有关键数字:验证集准确率、损失、训练耗时、参数量。然后写一个简单的汇总脚本,扫描所有实验目录,把指标汇总成一张表。这样你一眼就能看出哪次实验效果最好,不用一个个翻。
| 实验目录内容 | 作用 | 是否必须 |
|---|---|---|
| config.yaml | 复现配置 | 必须 |
| train.log | 排查训练过程 | 必须 |
| metrics.json | 对比实验效果 | 必须 |
| model.pt | 部署或继续训练 | 视情况 |
| predictions.csv | 分析错误案例 | 推荐 |
这套土办法的好处是零依赖、透明、可迁移。等团队大了、实验多了,再迁移到专业平台也不迟,因为你的数据组织方式本来就是清晰的。
4.3 模型版本与回滚机制
模型上线后出问题,能不能快速回滚,是工程成熟度的试金石。我的做法是:每次上线都保留上一个稳定版本,版本号用语义化命名,回滚操作一条命令完成。
具体来说,模型文件按model_v{major}.{minor}.{patch}.pt命名,上线时把当前版本软链接到model_current.pt,服务层只读这个软链接。回滚时把软链接指回上一个版本,重启服务即可。整个过程不超过一分钟。这个机制我在生产环境用过好几次,尤其是模型效果突然下降时,先回滚止损,再慢慢排查,心态完全不一样。
版本元数据也要存:训练数据版本、代码 commit、评估指标。这样回滚后你能知道“回滚到的是哪个状态”。别嫌麻烦,这些都是出事后能快速定位的保障。
5. 服务层:模型推理服务的搭建与优化
5.1 推理服务的框架选择与接口设计
服务层是把模型变成产品的关键一步。框架选择上,FastAPI 是我目前的首选:异步支持好、自动生成接口文档、类型校验强。对比 Flask,FastAPI 在高并发下表现更稳,而且 Pydantic 做请求校验能省掉大量手写校验代码。
接口设计要遵循“简单、稳定、可扩展”。核心接口就一个POST /predict,请求体包含输入特征,响应体包含预测结果和模型版本号。为什么带版本号?因为排查问题时你需要知道“这个结果是哪个模型给的”。接口要支持批量预测,单条和批量走同一个接口,用请求体结构区分,避免维护两套逻辑。
注意:接口的输入校验一定要严格。我见过因为没校验输入类型,导致模型收到字符串却当数值处理,直接崩溃的案例。Pydantic 的校验能拦住大部分这类问题。
5.2 模型加载与推理性能优化
模型加载策略直接影响服务启动速度和内存占用。我的经验是:服务启动时加载模型到内存,常驻不释放。每次请求都重新加载模型是性能杀手,一个几百 MB 的模型加载一次要好几秒,根本扛不住并发。
推理性能优化有几个实用手段。一是批处理,把短时间内到达的多个请求攒成一批一起推理,GPU 利用率能提升好几倍。二是模型量化,把 FP32 转成 FP16 或 INT8,推理速度提升明显,精度损失通常在可接受范围。三是 ONNX 导出,把训练框架的模型转成 ONNX 格式,推理时用 ONNX Runtime,跨框架、性能好。
| 优化手段 | 速度提升 | 精度影响 | 适用场景 |
|---|---|---|---|
| 批处理 | 2-5 倍 | 无 | 高并发 |
| FP16 量化 | 1.5-2 倍 | 极小 | GPU 推理 |
| INT8 量化 | 2-4 倍 | 小 | 边缘部署 |
| ONNX Runtime | 1.2-2 倍 | 无 | 跨框架 |
这些手段可以叠加使用,但要注意测试精度损失是否可接受。我一般先在验证集上对比优化前后的指标,确认损失在容忍范围内再上线。
5.3 并发处理与资源隔离
并发处理是服务层的难点。Python 有 GIL,纯计算任务多线程跑不满 CPU,所以推理服务要么用多进程,要么把计算密集部分交给底层库(它们通常释放 GIL)。FastAPI 配合 Uvicorn 的多 worker 模式,能利用多核,但每个 worker 会各自加载一份模型,内存占用翻倍,要权衡。
资源隔离是指把推理服务和训练任务分开部署。训练吃满 GPU 时,推理服务如果共享同一台机器,延迟会飙升。我的做法是推理服务独占资源,训练任务排队或放到别的机器。如果资源紧张,至少给推理服务设资源上限,保证它不被训练任务挤垮。
6. 可观测层:监控、日志与问题排查
6.1 结构化日志与关键指标采集
可观测层是系统的“眼睛”,没有它,线上出问题你就是瞎子。日志我坚持结构化,每条日志是一个 JSON,包含时间戳、级别、模块、请求 ID、耗时、结果状态。请求 ID 特别重要,它能把一次请求在多个模块的日志串起来,排查时一目了然。
关键指标至少采集这几类:请求量、延迟分布、错误率、模型输入分布、预测结果分布。请求量和错误率反映服务健康度,延迟分布(P50、P95、P99)反映用户体验,模型输入和预测分布反映数据是否漂移。这些指标用 Prometheus 采集,Grafana 展示,是业界标准组合,搭建成本不高。
6.2 数据漂移与模型退化的早期发现
模型上线不是终点,效果会随时间退化,因为线上数据分布和训练数据分布会逐渐偏离。早期发现漂移,能让你在业务指标恶化前就介入。我的做法是定期(比如每天)计算线上输入特征的统计量,和训练时的基准对比,偏差超过阈值就告警。
预测结果分布也要监控。如果模型突然大量输出同一个类别,或者置信度普遍下降,都是异常信号。这些监控不需要复杂工具,一个定时脚本加一个对比逻辑就能实现。关键是建立基准、定期对比、设置合理阈值。阈值太松漏报,太紧误报,我一般先用历史数据跑一段时间,看正常波动范围,再定阈值。
6.3 常见线上问题速查表
线上问题排查,经验比工具重要。我把踩过的坑整理成一张速查表,遇到问题先对照,能省不少时间。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 延迟突然升高 | 模型变大、并发增加、资源争抢 | 看资源使用率、请求量 |
| 错误率上升 | 输入格式变化、依赖服务故障 | 看错误日志、输入校验 |
| 预测结果异常 | 数据漂移、模型版本错误 | 对比输入分布、确认版本 |
| 服务启动失败 | 模型文件损坏、依赖缺失 | 看启动日志、检查文件 |
| 内存持续增长 | 内存泄漏、缓存无上限 | 看内存曲线、检查缓存 |
这张表是我多年经验的浓缩,每一条背后都是真实事故。建议你也在自己项目里维护一张,每次出问题就补充,慢慢就成了团队的财富。
7. 从零搭建的实操心得与避坑经验
7.1 新手最容易踩的五个坑
从零搭建 AI 工程系统,新手踩的坑高度相似。我列五个最常见的,帮你提前避开。
第一个坑是过早优化。还没跑通闭环就想着上分布式、上容器编排,结果复杂度爆炸,啥也没做成。正确做法是先单机跑通,遇到瓶颈再优化。第二个坑是忽略数据版本。模型效果不对时,根本不知道用的是哪版数据,排查无从下手。第三个坑是硬编码配置。路径、超参写死在代码里,换个环境就崩。第四个坑是不做输入校验。线上收到脏数据直接崩,其实几行校验就能拦住。第五个坑是没有回滚机制。模型出问题只能干等修复,有回滚能先止损。
这五个坑我都踩过,每一个都付出了代价。现在回头看,它们的共同点是“图省事”。工程这件事,省事的地方迟早要还回来。
7.2 小团队资源有限时的取舍策略
小团队资源有限,不可能什么都做。我的取舍原则是:保核心链路,砍锦上添花。核心链路是数据到模型到服务这条主线,必须稳。锦上添花是花哨的监控大屏、复杂的实验平台、自动化的超参搜索,这些可以后补。
具体来说,数据校验和模型回滚必须有,因为它们是止损手段;实验追踪可以用目录约定代替平台;监控可以先用日志加简单脚本,等有精力再上专业工具。资源永远不够,关键是知道什么不能省。我的底线是:能复现、能回滚、能排查,这三条满足了,系统就算及格。
7.3 后续可扩展的方向
跑通最小闭环后,扩展方向很多,但别贪多。我建议按这个顺序演进:先加自动化测试,保证改动不破坏现有功能;再加 CI/CD,让模型上线流程标准化;然后考虑特征平台,统一离线在线特征;最后才是分布式训练和自动调参。
每一步扩展都要有明确的触发条件,比如“当手动上线出错超过三次,就上 CI/CD”。没有触发条件的扩展都是耍流氓,只会增加维护负担。ai-engineering-from-scratch的精神不是一次搭完,而是持续演进、按需生长。我在实际项目里最大的体会是:系统的价值不在于用了多少先进技术,而在于它能不能稳定地解决业务问题,能不能在出问题时被快速修复。把这条想明白,很多技术选型的纠结就迎刃而解了。