
这几年 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(ge0, le23) click_ratio: float Field(ge0.0, le1.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(fskip 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_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(X, y, groupsdf[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 需要维护WB团队协同可视化强、协作方便云端服务有成本私有化要部署我的个人体会是不要一上来就追求最重的工具。先从自建 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 --cpus2 --memory4g -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 这类更重的工具。我最后悔的事是早期把精力大头放在了模型结构上数据校验和版本管理拖到项目中期才补。那次重构几乎等于重写而如果从一开始就做成本可能只有后来的十分之一。另一个后悔是用了太多临时方案比如临时命名数据文件、临时改脚本参数临时的东西积累多了整个项目就变成一个巨大的技术债仓库。这个内容后续还可以扩展的方向有很多自动化的模型评估流水线、更细粒度的数据血缘追踪、线上反馈标签的闭环回收。但那些都是在基础骨架稳固之后才值得做的事。如果让我重新从零开始我会把上面这套工程步骤当成大纲模型只是这条流水线上可以被替换的零件真正撑住系统的是数据、评估、部署和监控这条完整链路。