1. 从零搭建AI工程能力:这个项目到底在解决什么问题
第一次看到ai-engineering-from-scratch这个标题,我脑子里蹦出来的第一个念头是:又一个“从入门到放弃”的教程合集?但翻了一圈社区讨论和实际动手跑过之后,我发现它切中的是一个特别真实的痛点——市面上讲AI的内容,要么是调包侠式的“三行代码调用API”,要么是论文精读式的数学推导,中间那层“工程落地”的环节几乎是断层的。
什么叫工程落地?举个最直白的例子。你在notebook里用sklearn跑通一个分类模型,准确率95%,皆大欢喜。但要把这个模型变成一个能扛住每天几十万次请求、延迟控制在50毫秒以内、模型更新时不停机、出问题能快速回滚的线上服务,这中间隔着的鸿沟,就是AI工程要解决的问题。ai-engineering-from-scratch这个项目,本质上是在填这条鸿沟。
它适合谁?我梳理了一下,大概三类人最该认真看:第一类是刚学完机器学习基础、能跑通demo但不知道下一步该干嘛的学生或转行者;第二类是后端或数据工程师,想往AI方向靠但被各种框架和术语绕晕的;第三类是小团队里“啥都得干”的全栈,老板说“我们搞个AI功能吧”,你得从数据清洗一路管到线上监控。这三类人的共同点是:不缺理论,缺的是把理论串成工程链路的那根线。
这个项目的核心价值,我总结成一句话:它不教你造轮子,但教你如何把轮子组装成一辆能上路跑的车,并且告诉你每个螺丝该拧多紧。从数据版本管理、特征工程流水线、模型训练的可复现性、到推理服务的性能优化、再到监控告警和灰度发布,它试图覆盖一个AI系统从“实验室”到“生产环境”的完整生命周期。接下来我会按我自己实际操作的顺序,把这个项目的骨架拆开,把每个环节的关键决策、踩过的坑、以及可以直接抄的配置都摊开来讲。
2. 整体架构拆解:为什么这样设计而不是那样
2.1 分层设计:把“变”和“不变”隔离开
ai-engineering-from-scratch在架构上最核心的一个决策,是严格区分了“实验层”和“生产层”。这个区分听起来像废话,但我见过太多团队把Jupyter Notebook直接当生产代码用,最后死得很难看。项目的做法是:实验层允许你随便折腾,用pandas做特征、用matplotlib画图、用pickle存模型,怎么快怎么来;但一旦要进生产层,所有东西必须走标准化的接口。
具体来说,它定义了三层结构:
- 数据层:负责原始数据的接入、清洗、版本化。这里的关键是数据快照——每次训练用的数据必须有一个唯一的版本号,能追溯到具体的文件列表和哈希值。我试过用DVC来做这件事,配合对象存储,效果很稳。
- 训练层:负责模型的定义、训练、评估。这一层的输出不是模型文件本身,而是一个训练产物包,里面包含模型权重、超参数配置、评估指标、以及训练时用的数据版本号。这样做的好处是,任何一个线上模型出问题,你都能精确复现它是怎么来的。
- 服务层:负责模型的加载、推理、监控。这一层不关心模型是怎么训练的,只关心输入输出格式和性能指标。模型文件通过一个注册中心来管理,服务层按版本号拉取。
注意:这个分层不是物理上的微服务拆分,而是一个逻辑约定。小团队完全可以在一个仓库里用目录结构来实现,关键是依赖方向要单向——服务层不能反向依赖训练层的代码。
2.2 工具选型:为什么是这些而不是那些
项目在工具选型上有一个很明确的倾向:优先选“无聊”的技术。什么叫无聊的技术?就是社区成熟、文档齐全、出问题能搜到答案的。比如:
| 环节 | 选型 | 备选 | 选择理由 |
|---|---|---|---|
| 数据版本 | DVC | Git LFS / 自建 | 与Git工作流无缝集成,支持远程存储 |
| 实验追踪 | MLflow | Weights & Biases | 可自托管,不依赖外部服务 |
| 模型服务 | FastAPI + ONNX Runtime | TorchServe / Triton | 轻量,CPU推理性能好,调试方便 |
| 监控 | Prometheus + Grafana | 自建日志系统 | 生态成熟,指标采集标准化 |
这个选型逻辑背后是一个很现实的考量:AI工程的最大成本不是写代码,而是维护和排查问题。你选一个冷门框架,当时可能省了两天开发时间,但后面每次出问题都要花一周去啃源码,这笔账怎么算都不划算。我个人的经验是,在AI工程领域,成熟度比先进性重要一个数量级。
2.3 可复现性:整个项目的灵魂
如果只能从ai-engineering-from-scratch里带走一个概念,我会选可复现性。这个项目里几乎每一个设计决策,最终都指向同一个目标:给定一个模型版本号,任何人都能在任何机器上复现出完全相同的训练结果和推理行为。
要做到这一点,需要控制四个变量:
- 代码版本:用Git commit hash锁定。
- 数据版本:用DVC或类似工具锁定文件哈希。
- 环境版本:用Docker镜像或conda环境文件锁定依赖。
- 随机种子:在代码里显式设置所有随机源的种子,包括Python、NumPy、框架层面。
我踩过的一个坑是:即使设置了随机种子,如果用了多线程数据加载,结果仍然可能不一致。解决办法是在DataLoader里设置worker_init_fn,确保每个worker的种子是确定的。这个细节在项目文档里没有明说,但我在实际调试时发现必须加上。
3. 核心环节实操:从数据到服务的完整链路
3.1 数据流水线:别让脏数据毁了一切
数据环节是整个链路里最不起眼但最容易出事的。我见过太多模型效果不好,最后排查下来是特征计算逻辑在训练和推理时不一致导致的。ai-engineering-from-scratch的做法是把特征计算逻辑抽成独立的、可测试的函数,训练和推理共用同一份代码。
具体操作上,我建议按这个顺序来:
- 定义数据契约:用Pydantic或类似工具定义输入数据的schema,包括字段名、类型、取值范围。这一步能挡掉80%的低级错误。
- 写特征函数:每个特征一个函数,输入是原始数据,输出是特征值。函数必须是纯函数,不能有副作用。
- 单元测试:对每个特征函数写测试,覆盖正常值、边界值、缺失值。
- 构建流水线:用sklearn的Pipeline或自建DAG把特征函数串起来。
from pydantic import BaseModel, validator class RawInput(BaseModel): user_id: int item_id: int timestamp: float context: dict @validator('timestamp') def timestamp_positive(cls, v): if v <= 0: raise ValueError('timestamp must be positive') return v def compute_user_avg_click_rate(user_history: list) -> float: if not user_history: return 0.0 clicks = sum(1 for x in user_history if x['clicked']) return clicks / len(user_history)提示:特征函数的测试用例要包含“空历史”“全点击”“全不点击”这三种边界情况,我实际跑下来这三种情况最容易暴露逻辑错误。
3.2 训练流水线:让每次实验都有迹可循
训练环节的核心不是模型结构,而是实验管理。ai-engineering-from-scratch推荐用MLflow来追踪每次实验的参数、指标和产物。我实际操作下来的流程是这样的:
- 每次训练开始前,用
mlflow.start_run()创建一个run。 - 用
mlflow.log_params()记录所有超参数,包括数据版本号。 - 训练过程中,用
mlflow.log_metrics()记录每个epoch的loss和评估指标。 - 训练结束后,用
mlflow.log_artifact()保存模型文件和配置文件。
这样做的好处是,三个月后你回头看某个线上模型,能精确知道它是用哪份数据、哪组参数、哪个代码版本训练出来的。我试过在没有这套系统的情况下排查一个效果下降的问题,花了整整两天才定位到是数据源换了。有了MLflow之后,同样的问题十分钟就能定位。
关于模型保存,有一个细节值得注意:不要只保存模型权重,要保存完整的推理管道。什么意思?就是如果你在推理前做了特征标准化,那这个标准化的参数(均值和方差)必须和模型一起保存。否则线上推理时用的标准化参数和训练时不一致,效果会断崖式下跌。我一般用sklearn的Pipeline把预处理和模型打包在一起,或者用ONNX把整个计算图导出。
3.3 模型服务:性能与稳定的平衡
模型服务环节,ai-engineering-from-scratch推荐用FastAPI做接口层,用ONNX Runtime做推理引擎。这个组合的好处是轻量、启动快、CPU推理性能好。我实测下来,一个中等规模的排序模型,用ONNX Runtime在4核CPU上能做到单次推理5毫秒以内,完全能满足大部分中小规模场景。
服务层的代码结构我建议这样组织:
from fastapi import FastAPI import onnxruntime as ort import numpy as np app = FastAPI() session = ort.InferenceSession("model.onnx") class PredictRequest(BaseModel): features: list[float] @app.post("/predict") def predict(req: PredictRequest): input_array = np.array([req.features], dtype=np.float32) input_name = session.get_inputs()[0].name output = session.run(None, {input_name: input_array}) return {"score": float(output[0][0])}这里有几个实操要点:
- 模型加载放在启动时,不要每次请求都加载。ONNX Runtime的session是线程安全的,可以复用。
- 输入校验必须做,用Pydantic定义请求体,挡掉格式错误。
- 超时和限流要配置,FastAPI可以用中间件实现简单的令牌桶限流。
- 健康检查接口必须有,返回模型版本号和加载状态,方便负载均衡器做探活。
注意:ONNX模型导出时要注意opset版本,不同版本的Runtime支持的opset不一样。我踩过的坑是导出时用了最新opset,结果线上Runtime版本旧,加载直接报错。解决办法是导出时指定一个保守的opset版本,比如11或13。
3.4 监控与告警:上线只是开始
模型上线不是终点,而是起点。ai-engineering-from-scratch强调要监控三类指标:
- 系统指标:QPS、延迟P99、错误率、CPU/内存使用率。这些用Prometheus采集,Grafana展示。
- 业务指标:预测分布、正样本率、特征缺失率。这些需要在服务层埋点,定期上报。
- 模型指标:如果能有反馈数据,计算线上AUC或准确率,和训练时对比。
我实际经验是,预测分布漂移是最早能反映问题的信号。比如一个推荐模型,正常情况下预测分数的均值在0.3左右,突然某天变成0.5,那大概率是上游特征出了问题。这时候不需要等业务指标下降,就应该开始排查。
告警阈值怎么定?我的做法是:上线第一周先只记录不告警,观察指标的日周期波动规律,然后取“均值±3倍标准差”作为阈值。这样能避免刚上线时频繁误报。
4. 常见问题与排查技巧实录
4.1 训练和推理结果不一致
这是最经典也最让人头疼的问题。表现是:离线评估指标很好,线上一跑效果差一大截。排查思路按这个顺序来:
- 检查特征计算逻辑:训练时用的特征和推理时用的特征是不是同一份代码算出来的?我遇到过训练时用pandas做one-hot,推理时用自写函数做,结果类别顺序不一致。
- 检查数据预处理:标准化、归一化的参数是不是从训练集算出来的?如果推理时重新算,分布就变了。
- 检查模型输入顺序:特征拼接的顺序在训练和推理时是否一致?这个错误特别隐蔽,因为模型不会报错,只是结果不对。
- 检查浮点精度:训练用float32,推理用float64,或者反过来,可能导致微小差异累积。
我的一般做法是:在训练结束后,用训练集的一条样本走一遍推理服务,对比输出是否和离线预测一致。如果不一致,逐层排查。
4.2 服务延迟突然升高
线上服务延迟升高,可能的原因和排查方法:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| P99升高但P50正常 | 个别请求慢 | 检查是否有大请求或超时重试 |
| P50和P99同时升高 | 资源瓶颈 | 看CPU/内存/IO是否打满 |
| 周期性升高 | 定时任务干扰 | 检查是否有定时批处理任务 |
| 逐渐升高 | 内存泄漏 | 看内存增长曲线,检查是否有未释放的对象 |
我遇到过一次延迟逐渐升高的问题,最后定位到是日志里累积了大量请求上下文对象没有释放。解决办法是用contextvars管理请求上下文,请求结束自动清理。
4.3 模型更新后效果下降
模型更新后效果下降,第一反应应该是回滚,而不是排查。ai-engineering-from-scratch强调要支持一键回滚,具体做法是:
- 模型文件按版本号存储,服务层通过配置切换版本。
- 回滚操作就是改配置加重启,或者用热加载机制动态切换。
- 回滚后保留现场,把出问题的模型版本和数据版本记录下来,离线慢慢排查。
排查方向一般有这几个:新模型训练数据是否有问题、新模型是否过拟合、新旧模型的特征处理是否一致、A/B实验的分流是否均匀。
4.4 数据漂移检测
数据漂移是指线上数据的分布和训练数据分布不一致。检测方法有很多,我常用的是PSI(Population Stability Index):
import numpy as np def calculate_psi(expected, actual, buckets=10): def scale_range(input_array, min_val, max_val): input_array += -(np.min(input_array)) input_array /= np.max(input_array) / (max_val - min_val) input_array += min_val return input_array breakpoints = np.arange(0, buckets + 1) / buckets * 100 breakpoints = scale_range(breakpoints, np.min(expected), np.max(expected)) expected_percents = np.histogram(expected, breakpoints)[0] / len(expected) actual_percents = np.histogram(actual, breakpoints)[0] / len(actual) def sub_psi(e_perc, a_perc): if a_perc == 0: a_perc = 0.0001 if e_perc == 0: e_perc = 0.0001 value = (e_perc - a_perc) * np.log(e_perc / a_perc) return value psi_value = sum(sub_psi(expected_percents[i], actual_percents[i]) for i in range(len(expected_percents))) return psi_valuePSI小于0.1说明分布稳定,0.1到0.25说明有轻微漂移,大于0.25说明漂移严重,需要重新训练模型。这个阈值不是绝对的,要根据业务场景调整。
5. 工程化落地的几个关键决策
5.1 什么时候该上Kubernetes
ai-engineering-from-scratch没有一上来就推Kubernetes,而是建议先用Docker Compose跑起来,等QPS超过单机承载能力再考虑K8s。这个建议非常务实。我见过太多团队在日请求量不到一万的时候就上K8s,结果运维成本比开发成本还高。
判断标准很简单:如果你的服务单实例能扛住峰值QPS,并且对可用性要求不是99.99%,那Docker Compose加一个反向代理就够了。等单实例扛不住了,再考虑水平扩展,这时候K8s才有价值。
5.2 模型版本管理策略
模型版本管理我推荐用语义化版本号:主版本号表示不兼容的变更(比如输入特征维度变了),次版本号表示功能增强(比如模型结构优化),修订号表示bug修复(比如重新训练)。服务层配置里指定版本号,支持精确版本和范围版本。
实操技巧:在模型文件里嵌入版本号和训练数据哈希,服务启动时校验,防止加载错模型。
5.3 灰度发布怎么做
灰度发布是降低上线风险的关键手段。我的做法是:
- 新模型先部署到一个单独的实例,不接流量。
- 用内部测试数据验证输出正常。
- 切1%的流量到新实例,观察24小时。
- 如果没有异常,逐步扩大到10%、50%、100%。
- 每一步都监控核心指标,一旦异常立即回滚。
这个流程用Nginx的权重配置或者服务网格的流量规则都能实现。关键是要有自动化的回滚触发机制,不能靠人盯着。
6. 我踩过的坑和给你的建议
6.1 不要过早优化
我刚开始做AI工程的时候,总想着一步到位:特征存储、在线学习、自动调参全上。结果系统复杂度爆炸,光是维护各个组件就耗尽了精力,核心的模型效果反而没时间优化。后来我学乖了,先用最简单的方案跑通闭环,再根据实际瓶颈逐步优化。大部分场景下,一个PostgreSQL加一个Redis加一个FastAPI服务,就能撑起相当规模的业务。
6.2 日志要打够但别打太多
日志是排查问题的命根子,但打太多会影响性能,打太少又查不到问题。我的经验是:请求入口和出口各打一条INFO日志,包含请求ID、耗时、关键参数摘要;异常打ERROR日志,包含完整堆栈和上下文。中间过程用DEBUG级别,线上默认关闭,需要时动态开启。
6.3 测试要覆盖推理链路
单元测试大家都会写,但AI工程里最重要的是端到端测试:从原始输入到最终输出,整条链路跑一遍,验证结果符合预期。我一般会准备一组固定的测试样本和期望输出,每次代码变更都跑一遍。这能挡住大部分集成问题。
6.4 文档要写给三个月后的自己
AI工程系统的文档特别容易过时,因为组件多、变更快。我的做法是:每个组件目录下放一个README,说明这个组件干什么、怎么跑、依赖什么、常见问题。另外维护一个全局的架构图和数据流图,用文字描述清楚。三个月后你回头看,这些文档能救你一命。
6.5 性能优化先找瓶颈
服务慢的时候,不要凭感觉优化。先用profiler找到真正的瓶颈。我常用的是py-spy做CPU profiling,用memory_profiler做内存分析。很多时候你以为的瓶颈(比如模型推理)其实不是,真正的瓶颈可能是JSON序列化或者日志写入。
7. 后续可以扩展的方向
这套框架跑通之后,有几个方向可以继续深入。特征存储是一个,当特征越来越多、计算越来越复杂时,需要一个统一的特征平台来管理特征的注册、计算和存储。在线学习是另一个,当数据分布变化很快时,模型需要能增量更新而不是全量重训。模型压缩也值得关注,量化、剪枝、蒸馏这些技术能让模型在更小的资源上跑出可接受的性能。
不过我还是那句话:先把基础链路跑稳,再考虑这些高级特性。我见过太多项目死在“什么都想要”上,而不是“做得不够多”。ai-engineering-from-scratch这个项目最大的价值,就是帮你把基础链路理清楚,让你知道每一步该做什么、为什么这么做。剩下的,就是动手去跑了。