这几年 AI engineering 这个词被反复提起,我见过不少从零上手的人,第一反应都是去找现成模型、跑通一个 notebook,觉得只要 loss 降下去就算是入行了。等真正把一个模型从实验脚本推到线上、还要持续迭代的时候,才会发现那条路比想象中宽得多,也深得多。"from scratch" 真正的含义,不是从轮子开始造,而是从零建立起一整套能支撑模型从数据到上线再到反馈的工程骨架。这篇文章就是按照我从零搭建一套 AI 工程链路的实际顺序来写的,覆盖数据、训练、评估、部署、监控,每一步都会给出具体做法和踩坑记录,适合准备把手头模型做成真正可用系统的工程师,也适合刚入门但不想只在 notebook 里玩的人。
1. 重新定义"AI工程":为什么 from scratch 考验的是管线思维而不是模型能力
1.1 跑通模型和做出系统之间的真实差距
你随便找一份公开的论文代码,跟着 README 把数据下下来、环境装好,大概率一个下午就能跑通一次训练。但这恰恰是陷阱所在:公共代码库里的数据是干净的、划分是现成的、特征预处理也都封装成函数了,你要做的只是执行。真正的工程场景完全不是这个样子。数据每天可能换一批新字段,特征表里突然多出几十万个新用户,请求方要求 P95 延迟在 100 毫秒以内,线上模型效果下滑时你得能马上回答三个问题:线上现在用的是哪个版本的模型?它训练时用的数据是哪一份?性能变差是因为模型本身退化了,还是数据分布变了?多数人不具备的,正是回答这三个问题的确定性。
用开餐厅来类比会更直观。会做几道拿手菜,那是手艺;能把菜品从备菜、炒制到出餐整个流程标准化,让任何一位厨师都能稳定复制出同一口味,那叫工程。AI 工程也一样:模型是你精心准备的那道"菜",但真正交给用户的,是背后一整条供应链。数据接入、清洗、校验、版本管理、训练、评估、部署、监控,任何一个环节松掉,最终端的体验都会变形。所以 from scratch 真正的起点,不是"开始写模型代码",而是"开始用工程的眼光重新看待整个模型生命周期"。
1.2 输入输出契约:先定边界再写代码
我见过太多项目,数据团队叫某个字段user_id,算法工程师在训练代码里写成uid,到了线上服务接口又变成userId。三套命名各自都能跑,联调的时候才发现对不上。这个问题最省事的解法,不是靠开会沟通,而是从一开始就定义好输入输出契约,用代码把它固定下来。
契约至少有三层。第一是数据契约:每条样本有哪些字段,字段类型是什么,取值范围是多少,缺失值怎么处理,异常值要不要拦截。第二是模型契约:模型输入的特征名和顺序是什么,输出是分类概率还是回归值,阈值怎么定,版本号挂在哪个字段。第三是服务契约:API 的请求 JSON 长什么样,响应 JSON 长什么样,出错时返回什么错误码,超时上限是多少。把这三层契约以 Schema 或配置文件的形式统一放在项目里,后续每个环节都围绕它来写,而不是每个环节各自理解一份。
我实际开发时会把 Schema 校验放在数据进入系统的第一道关口。这样做的理由很简单:脏数据越早拦截,修复成本越低。如果等数据已经进到训练脚本里,甚至已经变成模型效果不佳的"神秘原因"再去追溯,那个过程会让人极其崩溃。先定义契约,再写代码,省下来的是后面无数次的联调和返工时间。
2. 项目骨架先行:目录结构、依赖锁定与实验留痕
2.1 一个能撑到上线的目录长什么样
很多人入门时的项目结构就是一个main.ipynb加两个数据文件。自己玩没问题,但一旦开始做正事,这种结构会立刻成为负担。我比较推荐从第一天就搭一个稍微正规一点的目录,哪怕里面暂时只有空文件。这套结构并不死板,重要的是让"数据""代码""配置""实验结果"这几类东西有明确的归属。
my_ai_project/ ├── configs/ # 所有实验配置,按实验子目录存放 ├── data/ │ ├── raw/ # 原始数据,只读,不允许代码修改 │ ├── processed/ # 清洗后特征数据 │ └── manifest/ # 数据版本记录 ├── src/ │ ├── data/ # 数据接入与校验 │ ├── features/ # 特征工程 │ ├── models/ # 模型定义 │ ├── train.py # 训练入口 │ ├── evaluate.py # 评估入口 │ └── serve.py # 推理服务入口 ├── experiments/ # 每个实验一个子目录 ├── tests/ # 单元测试与集成测试 └── pyproject.toml有几个细节我强调了很多次。raw目录里的数据必须是只读的,任何预处理脚本都不能覆盖原始文件,否则数据出了问题你都说不清是哪一步改坏的。notebook 不放在源码根目录,统一放进experiments/notebooks,它只承担探索作用,不承担生产职责。模型文件不直接丢在根目录,按实验或者按版本放在固定目录,方便后续部署时准确定位。
这套骨架的价值在项目初期几乎看不出来,但它会在后面每一个环节帮你省时间。当你可以随时回答"这个模型的训练数据在哪、配置文件在哪、评估结果在哪"时,你才真正拥有一个可以被讨论和迭代的工程系统,而不是一堆碰巧能跑的脚本。
2.2 依赖锁定:AI项目里最容易失守的工程底线
AI 项目的依赖管理一直是个重灾区。numpy、pandas、torch、sklearn 这些库彼此之间有千丝万缕的版本约束,你很难预料一个月之后重装环境还能不能复现当时的实验。我见过不止一次,有人把代码交给同事,同事pip install之后跑出来的指标和原版差了几个点,最后发现是依赖版本不同导致的。
所以环境隔离和依赖锁定必须一开始就做。每个项目一个独立虚拟环境,这是底线。在此基础上,我推荐用uv或者pip-tools管理依赖:在requirements.in里写顶层依赖,编译出带哈希的requirements.txt,把传递依赖也全部锁定。这样重装环境时能保证拿到的是完全同一套依赖。
python -m venv .venv source .venv/bin/activate pip install uv uv pip install -r requirements.txt很多人嫌锁依赖麻烦,觉得"能跑就行"。但你要知道,训练实验的一次复现失败,往往会让你怀疑模型代码本身,浪费大量时间在一个根本不是模型问题的方向上。把依赖锁好,等于把"环境漂移"这个变量从实验里彻底移除。等团队变大之后,这一步会直接决定你能否在别人的机器上复现你的实验。
2.3 Seed、Config、Log:第一行代码就要建立的习惯
训练代码里,至少有三件事应该从第一次跑实验就固定下来:随机种子、配置管理和日志记录。随机种子是为了让结果在相同条件下可以复现,配置管理是为了让每次实验的参数有据可查,日志则是为了让训练过程可观测。
一个基础的随机种子设置长这样:
import random import numpy as np import torch def set_seed(seed: int = 42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False注意,这只解决了"我们控制的随机源",后面还有更隐蔽的随机源会导致不可复现,我在第四章会专门展开。配置管理则建议从一开始就引入配置文件,而不是用命令行参数传几层。日志方面,我在第一个实验就开始把数据版本、随机种子、模型结构、关键超参写进训练日志,这样每次训练完都能从日志里追溯这次的完整上下文。
这三个习惯叠加在一起的效果是:任何一次实验跑完,你都能回答"这次用了什么配置、基于什么数据、随机种子是多少、训练过程如何"。这件事比大多数模型技巧都重要,因为它构成了你后续所有改进工作的证据链。
3. 数据侧:真正的 from scratch 从看清数据长什么样开始
3.1 数据接入与 Schema 校验:脏数据要在一开始挡住
很多人把数据校验当成一件"测出来再说"的事,反正模型训练时丢几行数据、或者某些字段类型不对,也不会立刻报错。但问题在于,这种"隐性脏数据"会悄悄影响模型质量,而你往往在几周之后才发现,那时已经很难定位是哪一批数据混进来了。
我现在的做法是,在数据进入系统的那一刻就用 Pydantic 做完整的 Schema 校验,不合法数据要么被拦截,要么被单独记录。以点击日志为例:
from pydantic import BaseModel, Field, ValidationError class ClickSample(BaseModel): user_id: str item_id: str hour: int = Field(ge=0, le=23) click_ratio: float = Field(ge=0.0, le=1.0) def load_and_validate(path: str): with open(path, "r") as f: for line in f: try: yield ClickSample.model_validate_json(line) except ValidationError as exc: log_warning(f"skip bad line: {exc}")把校验放在入口而不是训练前,背后的逻辑是:数据管道的每一级都不该假设上游是可信的。越早发现数据问题,越早能用最小的代价修复。做 Schema 校验还有一个额外的好处,它强迫你和数据侧把字段定义、取值范围聊清楚,这个澄清过程本身就能避免大量后期返工。
3.2 数据版本化:为什么文件名加日期根本不够
我早期做数据管理时,常用的命名方式是user_click_20250101_v2.csv,看起来很规整,实际上隐患很大。且不说"v2"这个编号背后改了什么东西完全没有记录,单就文件内容而言,你根本没法确认这个文件是否被误改过,也没法确认训练时用的到底是哪个文件。
后来我改成"数据快照 + manifest"的方式。对每一份进入训练流程的数据,计算它的哈希值,并把文件信息、样本数、字段列表、切分范围统统写进一个 manifest 文件。例如:
{ "dataset": "user_click_20250101", "files": [ {"name": "raw/20250101.csv", "sha256": "ab12...", "rows": 123456} ], "features": ["user_id", "item_id", "hour", "click_ratio"], "train_range": ["2024-10-01", "2024-12-31"] }训练配置里引用的是这个 manifest 的唯一标识,而不是直接写文件路径。这样一来,"这份数据到底是谁""包含哪些内容"变成了一个可校验、可引用的对象。即使原始文件被移动过、改名过,只要哈希一样,它就是同一份数据。这个做法在数据量小的时候好像多此一举,但一旦开始做模型迭代和线上问题回溯,你会无比庆幸当初保留了这份"数据身份证"。
3.3 训练/验证/测试划分:三个想当然的泄漏坑
数据切分看起来是流水线中最不起眼的一步,但它也是数据泄漏最爱藏身的角落。最常见的三个坑,我每个都踩过。
第一个坑,直接用train_test_split随机切分。如果数据是用户行为流,同一个用户可能会同时出现在训练集和验证集里。模型学到了"只要见过这个用户 ID 就能判断",验证集上表现虚高,线上遇到新用户立刻现原形。这种场景应该用GroupShuffleSplit,把用户 ID 作为分组依据:
from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(X, y, groups=df["user_id"]))第二个坑,时间序列数据被 shuffle。广告点击、推荐、交易这类随时间变化的数据,本质上只能用过去预测未来。如果你随机打乱再切分,相当于让模型在验证阶段偷看了未来,上市之后的效果一定比离线评估差。正确做法是按时间窗口切分,比如前八周训练、第九周验证、第十周测试,并且保证窗口之间没有任何时间重叠。
第三个坑最隐蔽,特征工程在切分之前就整体 fit。比如你在全量数据上做了标准化或者缺失值填充,然后才切分成训练/测试,这会让测试集的信息在训练阶段就被模型间接看到。正确做法是只在训练集上计算统计量,再把它应用到验证集和测试集。无论如何切分,都要把切分规则写进 manifest,并把每个切分的记录保留下来,而不是每次用代码随机跑一次。
4. 训练环节:把"能跑"变成"可复现"
4.1 训练脚本配置化:命令行传参撑不过第 20 次实验
入门时我习惯在训练脚本里直接改超参,改一次跑一次。等实验做多了,你根本记不清当前这个结果是哪个版本的代码、哪组参数跑出来的。你也许会想着用命令行参数--lr 0.001 --batch_size 256来控制,但一旦参数超过十个,命令行本身就成了灾难。
我现在的做法是所有训练参数都收敛到一个 YAML 配置文件里,训练脚本只负责读配置、执行训练,命令行只传入配置文件路径。比如:
data: manifest_id: "user_click_20250101" splits: ["train", "val"] model: name: "tabnet" hidden_dim: 128 depth: 4 train: seed: 42 batch_size: 256 lr: 0.001 epochs: 30这样做的好处是,每一个实验目录下都有一份独立的配置快照,你和未来的自己始终能知道"这个实验到底怎么跑的"。如果你现在还在用命令行传一堆参数,建议尽快切换到配置文件。不一定非要上 Hydra 这类重型工具,哪怕只是一个自定义的配置加载函数也够了,关键是把"参数"和"代码"彻底分离。
配置化的另一个好处是方便做实验矩阵。当你想搜索超参时,只需要生成多份配置,分别启动训练即可,不需要反复修改代码。我见过太多项目因为参数管理混乱,实验记录和实际结果对不上,最后整个团队都在为一次不清晰的历史实验买单。
4.2 实验跟踪:工具选型背后真正重要的东西
实验跟踪是让"可复现"落到实处的最后一环。很多人刚开始不知道选什么工具,我的建议是:先想清楚你要记录什么,再选工具。需要记录的核心内容至少包括:配置文件哈希、数据版本、随机种子、关键指标、模型 artifact 的路径、日志位置。记录越完整,回溯越容易。
下面是我用过的几种方案对比,供参考:
| 工具 | 适合规模 | 主要优势 | 主要坑 |
|---|---|---|---|
| 自建 CSV + git | 个人极简起步 | 零依赖、完全可控 | 对比分析不方便,没有可视化 |
| TensorBoard | 单模型调试 | 轻量、和 PyTorch 结合好 | 不管理超参数和模型文件 |
| MLflow | 小团队 | 实验记录和模型注册一体 | tracking server 需要维护 |
| W&B | 团队协同 | 可视化强、协作方便 | 云端服务有成本,私有化要部署 |
我的个人体会是,不要一上来就追求最重的工具。先从"自建 CSV + git"开始,把记录内容的习惯养成,等你发现手动对比实验越来越繁琐,再平滑迁移到 MLflow。工具只是个载体,真正有价值的是你每次实验留下的上下文。如果没有完整的上下文,再花哨的可视化面板也只是好看而已。
4.3 固定了种子还是不可复现?从这五个地方排查
我遇到过一个很诡异的场景:明明已经调用了set_seed,同一个训练脚本在同一台机器上连续跑两次,验证集指标还是不一样。排查了很久才发现问题是 DataLoader 多进程打乱数据时的随机性。
这类"种子失效"问题通常有五个来源,按排查优先级列一下:
- DataLoader 多进程没有设置 worker seed,每次迭代的数据洗牌顺序不同。
- cuDNN 的 auto-tune 在不同硬件或驱动下选择的卷积算法不同。需要把
torch.backends.cudnn.benchmark设为False,并开启deterministic = True。 - 分布式训练时采样器没有设置全局 seed,各进程的数据分配不稳定。
- 模型初始化的随机权重受前一次训练产生的全局随机状态影响,脚本里没有在模型构建前重置随机源。
- 并行操作的浮点数累加顺序不同,多卡训练时尤其明显。
排查路径是先退到单进程、CPU、关闭 DataLoader 多进程,看能否复现;能复现就把变量一个个加回去,直到找到破坏复现的那一个。这个"减法排查"虽然繁琐,但比盲目改种子可靠得多。实践中我发现,绝大多数复现问题都集中在 DataLoader 和多卡训练,而不是模型本身。
5. 评估不只看排行榜:离线指标与线上表现的"剪刀差"
5.1 单点指标骗人:切片报告才能看见弱项
用一个总体准确率 99% 的模型去看业务效果,很容易被麻痹。但当你把预测结果按业务维度切片之后,可能会发现:新用户群体上的准确率只有 40%,某些渠道来源的样本几乎全被分为负类。总体指标把那些表现极差的薄片稀释掉了。
所以我的评估产出不只是跑一个accuracy或auc,而是生成一份"切片报告"。按关键业务维度分别算指标,比如新老用户、时段、来源渠道、特征缺失组:
def eval_by_group(df, group_col, y_true, y_pred): for name, grp in df.groupby(group_col): acc = (grp[y_true] == grp[y_pred]).mean() print(f"{group_col}={name}: acc={acc:.4f}, n={len(grp)}")切片的维度最好来自业务认知,而不是等到评估时随手挑几个字段。和业务方聊一聊哪类用户、哪个场景最关键,把这些维度提前写进评估脚本,模型上线前你就能对短板心里有数。这样做还有一个好处:当线上出现某个细分群体效果崩坏时,你可以在离线评估里快速复现问题,而不是对着整体指标猜原因。
5.2 先有 baseline 再有建塔:低起点不是坏事
不少人的第一个想法就是直接上一个复杂模型,仿佛这样才能体现工作量。但我强烈建议,任何新任务都先做一个最简单的 baseline,听起来越笨越好。可以是按历史均值预测,可以用逻辑回归,甚至可以用一条"总是预测多数类"的规则。baseline 有两个作用:一是验证你的整个数据和生产管道是否畅通,二是给后续所有复杂模型设置一个"必须击败的底线"。
更有价值的是"短板测试"。在 baseline 的错误样本中找出你希望后续模型改善的那部分,把它们单独存成一个评估集。以后每训练一个新模型,除了看总体指标,还要重点看这个短板集上的变化。我经常发现,一个复杂模型总体指标涨了一个点,但短板集上的表现反而退步了,这种 regression 如果只看总体结果根本发现不了。
把 baseline 作为第一个实验 artifact 保存下来,不要丢弃。它是你衡量所有后续改进的标尺,也是你和同事争论"这个模型到底有没有进步"时最客观的依据。
5.3 回放验证:让旧模型随时能被重新评估
模型迭代最烦的问题是:你改了预处理代码之后,以前那个模型还能复现出当时的指标吗?很多团队根本不检查这一点,结果某一天某个优化代码的改动悄悄改变了线上推理的输入分布,导致一连串新模型的线下指标看起来很好,线上效果却集体变差。
为了避免这个问题,我会把测试集冻结成一个带版本号的数据文件,并保证评估脚本能够对任意旧 checkpoint 重新计算指标。每次代码有较大改动,就跑一遍"旧模型回放",确认指标没有异常变化。
这个做法本质上类似软件工程里的回归测试。模型升级、特征改造、依赖升级,都属于可能破坏旧行为的变更,都应该触发回放验证。我吃过最大的亏就是忽视了回放验证,导致过了很久才发现预处理逻辑和训练时不一致。回放验证的成本很低,相比线上事故的代价,这笔投入极其划算。
6. 部署与服务化:从模型文件到稳定服务的最后一公里
6.1 封装服务前必须检查的三件事
模型服务的代码本身并不难,难的是把你训练时的种种隐藏假设一起带进服务。我在封装推理服务时一定会检查三件事。
第一,输入校验。线上请求进入服务后,必须用与训练数据相同的 Schema 进行校验,字段类型不对、取值范围越界、缺字段,都应该返回明确的错误码,而不是在模型推理时抛出一个五雷轰顶的 500。第二,预处理一致性。训练时的特征工程函数和线上推理必须使用同一份代码,不要让训练在 notebook 里写一套、线上服务又复制粘贴一套。特征顺序这种细节,最好通过特征列表配置来固定,避免手写索引。第三,资源限制。要给容器设置 CPU 和内存上限,否则一次突发流量就能打爆整个服务。
一个很简单的服务入口长这样:
@app.post("/predict") def predict(item: Item): features = build_features(item) # 必须复用 src/features 里的同一份函数 pred = model.predict(features) return {"prediction": float(pred), "model_version": model_version}第三件事尤其容易被忽略。很多人觉得机器配置够大就不用限制,但实际上一个没有资源上限的服务,在异常流量下会变成整个系统的不稳定源头。我的习惯是宁可限制偏保守,也不让一个请求拖垮全容器。
6.2 容器化推理:一个够用的 Dockerfile 和运行参数
容器化是让模型服务具备可移植性的关键。我的基础 Dockerfile 一般长这样:
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src ./src COPY models ./models RUN useradd -m appuser USER appuser CMD ["uvicorn", "src.serve:app", "--host", "0.0.0.0", "--port", "8080"]这里有几个容易被忽略的点。基础镜像不要用latest,要选明确的版本号,保证可复现。镜像里尽量只装运行需要的依赖,不要在推理镜像里装训练库,这会白白增加体积和漏洞面。最后用非 root 用户运行,是安全基线,也是很多基础设施团队会在 review 时盯的点。
运行时建议显式设置资源限制:
docker run --cpus=2 --memory=4g -p 8080:8080 my-ai-serve:1.2.0设置--cpus和--memory不只是保护宿主机,更重要的是让你的性能测试建立在已知的资源边界上。不同的资源限制下,并发能力和延迟表现完全不同,资源不固定,性能测试就失去了参照。
6.3 延迟、吞吐与算力:瓶颈往往不在模型本身
很多人一谈到推理部署就默认要上 GPU,但 GPU 不等于更快。当模型本身不是很大、单次推理只要几十毫秒时,GPU 的延迟优势几乎体现不出来,甚至因为要拷贝数据到显存,整体延迟反而更高。只有在模型够大、或者吞吐需求高、适合批量推理时,GPU 才真正发挥价值。
做性能决策前先量化需求:预期 QPS 是多少?P95 延迟要求是几个九?单次模型推理在 CPU 上要多久?这些数字一摆出来,很多争论自然就结束了。我的粗略判断是:模型小于 100MB、QPS 在 100 以内、不需要高并发批量推理时,CPU 方案往往更省事;大模型、高吞吐、可批处理,才值得上 GPU。
上线之后先观察瓶颈在哪里。如果请求排队时间远大于模型推理时间,先优化并发队列和 batch 策略,而不是急着换硬件。很多系统瓶颈出在服务框架、连接池、IO 模型上,换 GPU 只是用更贵的资源掩盖了真正的问题。
7. 上线才是工程起点:观测、漂移与迭代闭环
7.1 从离线准确率幻觉到线上可观测性
模型上线之后,监控往往被当成一件"有运维平台再做"的事,我见过太多项目把服务启动就当成终点。事实是,离线指标只是模型在历史样本上的表现,线上环境随时可能在变化。你可以离线评估指标再好,也回答不了"今天的输入分布是不是已经变了"这个问题。
所以上线第一天就要考虑观测。至少记录三类信息:请求日志,包括脱敏后的输入特征和预测结果;预测分布,和训练时的标签分布做对比;系统健康指标,包括延迟、错误率、排队长度。其中预测分布尤其重要,因为大多数模型在线上拿不到真实标签,但预测分布的剧烈变化往往是数据漂移的第一信号。
具体实现上,可以是结构化日志加上一个简单的指标面板。不要一开始就上大型监控平台,先把最基础的可观测性做出来,保证线上任何一个异常都有可以回去查的记录,这是从"模型实验"走向"AI工程"的分水岭。
7.2 数据漂移检测的最小实现:先盯五个特征
数据漂移有很多种检测方法,但工程上最有价值的不是做复杂的高维统计分析,而是先盯着最核心的几个特征。很多团队一上来就做全字段漂移分析,结果报了一堆无关痛痒的告警,真正重要的变化反而被淹没。
最小的漂移检测实现,可以是对连续特征做滚动窗口均值对比,或者做一个简单的 KS 检验:
from scipy.stats import ks_2samp def drift_score(recent, reference): stat, p = ks_2samp(recent, reference) if p < 0.05: alert("feature drift detected") return p但要提醒一点,不要只看 p 值。当样本量很大的时候,很小的差异也会变得"统计显著",所以我会同时看均值差、标准差差这类效应量指标,避免被无意义的报警轰炸。报警阈值也要根据业务节奏调整,没有一劳永逸的阈值。
我的建议是先从业务最重要的五到十个特征开始监控,观察一两周,理解了它们的波动节奏,再加维度。这个"少即是多"的做法会极大降低你的监控维护成本,同时保证关键漂移不会被错过。
7.3 模型版本、灰度发布与回滚缺一不可
模型本身就是会变化的代码,所以它必须像代码一样有版本、能回滚。很多团队训练模型时随便起个名字,部署时直接覆盖线上文件,一旦出问题想切回旧版本,发现旧文件已经不存在了。
我现在的做法是模型路径里包含明确的版本号,例如model_20250101_v3.pkl。上线流程至少走三步:第一步是影子模式,新模型和旧模型同时跑,但新模型的预测只记录不外发,对比两者差异;第二步是灰度发布,先切 10% 流量,观察核心指标没有异常再逐步放大;第三步是保留一键回滚能力,配置文件中心里切换版本号,而不是重新发一次版。
没有回滚能力的模型,不要上生产环境。这句话听起来很绝对,但它是我用真实事故换来的教训。模型迭代越快,回滚机制越重要,因为每次上线都有可能带入训练数据变化、特征变化、代码变化中任何一个风险。
8. 个人经验:一个人从零做到的顺序,以及我最想反悔的事
如果让我给"从零开始做 AI 工程"的人一条行动路线,我会建议按这个顺序来,而不是一上来就研究那些重型框架。
第一步,找一个二三十万条左右的数据集,先定义好数据契约和输出契约。第二步,搭一个最朴素的训练脚本,把配置、随机种子、日志、数据版本这四个基础能力做进去,哪怕模型只是逻辑回归。第三步,写评估脚本,生成切片报告,并保存一个 baseline 作为标尺。第四步,把模型封装成一个本地 API,先用真实请求测试一遍,再考虑容器化和监控。第五步,才是逐步引入 MLflow、Kubernetes 这类更重的工具。
我最后悔的事是,早期把精力大头放在了模型结构上,数据校验和版本管理拖到项目中期才补。那次重构几乎等于重写,而如果从一开始就做,成本可能只有后来的十分之一。另一个后悔是用了太多"临时方案",比如临时命名数据文件、临时改脚本参数,临时的东西积累多了,整个项目就变成一个巨大的技术债仓库。
这个内容后续还可以扩展的方向有很多:自动化的模型评估流水线、更细粒度的数据血缘追踪、线上反馈标签的闭环回收。但那些都是在基础骨架稳固之后才值得做的事。如果让我重新从零开始,我会把上面这套工程步骤当成大纲,模型只是这条流水线上可以被替换的零件,真正撑住系统的,是数据、评估、部署和监控这条完整链路。