最近这几年“ai-engineering”这个词出镜率极高但我自己的体感是很多想从零开始入局的人把起点给搞错了。以为听到“AI工程”第一反应就是装一个大模型仓库、把开源项目里的 notebook 跑通就觉得已经在搞工程了。等手里真接了一个业务需求需要完全从零开始把一个识别、预测或者生成任务做上线才会发现最耗时、最决定成败的根本不是模型结构而是数据怎么管、环境能不能复现、效果怎么评估、模型怎么部署、线上怎么盯。这篇文章就是我想分享的“from-scratch 搭建完整AI工程”流程和打法没有平台依赖、没有花哨框架是一套我反复验证过、可以直接照做的完整链路。适合那些准备把AI能力真正嵌进业务系统、而不是停留在 demo 阶段的工程师也适合一直自学算法但没跑过完整闭环的同学。1. 开工之前先分清“工程”和“调参”是两回事1.1 AI工程师和算法工程师的日常差异很多人会把 AI 工程师直接等同于算法工程师实际上两者侧重点差别非常大。算法工程师的命题是“这个模型能不能 work”核心精力放在特征、结构、训练技巧上而 AI 工程师的核心命题是“这套系统能不能一直稳定地 work”模型效果只是全局链路里面的一环。一个在实际项目里很常见的情形模型离线指标做得很好看但部署上线之后接口偶发超时、特征数据在某个时段大量缺失、预测结果和业务系统对不上版本最后项目被业务方打回。这种问题不是把模型再训几轮能解决的而是工程结构本身出了问题。所以我带项目的时候会跟团队强调我们从零开始不是从模型开始是从“如何让模型在一个系统里可控地运转”开始。1.2 从零开始的四类边界同样是“从零开始”每个人卡住的点不同。我认为最值得先整理清楚的是下面四类边界边界类型常见症状对应的工程动作数据边界只有业务原始表标签还不齐全或者数据散落在多个系统里导出都是问题先搭数据采集、标注、清洗的完整管道模型边界没有可用的预训练权重或者业务数据分布和通用模型差距太大得自己从头训练先准备训练脚本、实验管理、算力资源系统边界业务团队希望你提供接口但鉴权、并发、日志体系都没有先设计服务化架构、部署方案业务边界业务方只说了“要更准”“要更好用”没有量化口径先把模糊目标翻译成可计算的评估指标这四类边界不梳理清楚哪怕模型训练出来了最后也很难落地。我见过最典型的情况是算法同学花了一个月把模型训到不错的离线效果然后才发现线上的推理环境连 GPU 都没有且业务接口只接受 HTTP 调用整个部署又返工两周。所以开工当天就应该把边界盘完而不是动手就写模型代码。1.3 最小可落地闭环先让链路转起来从零开始做 AI 工程不要一上来就追求大而全。我喜欢的策略是先定义“最小可落地闭环”让链路在尽可能短的时间内跑通一遍。这个闭环我通常定义为业务问题 → 数据定义 → 朴素基线 → 离线评估 → 部署上线 → 线上监控 → 数据回流。注意这里面没有“优化到SOTA”这一步。一个完整闭环跑通的意义在于它能暴露整个链条上所有隐藏的坑比如权限问题、数据质量、模型文件大小、线上环境依赖、接口返回格式不一致等等。这些坑早踩早解决否则后面全部会变成阻塞项。第一版模型效果差没关系链路通了后面所有优化都是在一条完整的跑道上进行而不会一直停留在单点试验。2. 环境与项目骨架让一次搭建能在三个月后复现2.1 工程起点为什么是环境而不是模型代码我早期带项目吃过一次大亏同事三月份训练了一个模型当时效果不错等到六月份项目要迭代发现原来的环境早就升级过依赖代码怎么都跑不起来而且没人记录当时用的 Python 和 torch 版本最后只能凭记忆去凑白白浪费了几天时间。所以现在我的原则是任何项目的第一步不是写模型而是把环境“钉死”。具体来讲至少要固定这几样Python 版本、CUDA 版本、深度学习框架版本、核心依赖库版本。如果团队用的是 uv可以这么做uv venv .venv --python 3.11 uv pip compile pyproject.toml -o requirements-lock.txt uv pip sync requirements-lock.txt如果是传统一点的方案用 requirements.txt 加 pip freeze 也行但必须把最终确定可用的版本单独导出成 lock 文件并且提交到代码仓库。还有一个实操细节很多人会忽略操作系统底层的差异同一个 Python 包在不同系统上表现可能不同尤其是涉及多进程和底层算子时。所以如果条件允许进一步建议把开发、训练、推理统一用同一套基础镜像直接用 Dockerfile 拉一个固定 CUDA 版本的镜像作为公共环境。2.2 配置管理把超参与路径全部从代码里抽出来从零搭项目时最容易养成的坏习惯是代码里硬编码数据路径超参数直接写在训练脚本顶部。这种代码跑一次没问题跑第二次可能就错了因为数据路径换了、批次大小改了结果完全对不上。我现在强制团队使用配置文件加统一的参数入口。一个典型的 config 如下# configs/base.yaml data: raw_path: data/raw/20250101_dump.parquet cleaned_path: data/cleaned/sample.parquet val_ratio: 0.15 seed: 42 model: name: mlp_baseline hidden_sizes: [128, 64] dropout: 0.2 train: epochs: 50 batch_size: 256 learning_rate: 1e-3 early_stopping_patience: 5 output: run_dir: experiments/exp001训练时用统一命令启动python -m src.train --config configs/base.yaml。这样每次实验对应一个 config 文件改实验就是改配置而不是改代码。好处非常直接每一次实验都能回溯“当时跑了什么参数”不会再出现“我觉得当时好像是这个学习率”这种情况。2.3 一个可以直接上手的目录结构我推荐的 AI 项目目录结构长下面这样不是唯一标准但适合绝大多数中小型项目project/ ├── configs/ # 所有实验配置 ├── data/ │ ├── raw/ # 原始数据只读DVC 管理 │ ├── cleaned/ # 清洗后数据DVC 管理 │ └── splits/ # 固定的 train/val/test 划分 ├── src/ │ ├── data/ # 数据加载与清洗 │ ├── models/ # 模型定义 │ ├── train.py # 训练入口 │ ├── evaluate.py # 评估入口 │ └── serve.py # 推理服务 ├── experiments/ # 每个实验一个目录 ├── scripts/ # 一次性脚本、定时任务 ├── notebooks/ # 探索性分析不入生产 └── requirements-lock.txt有几个容易忽略的点notebooks只用来做探索性分析严禁成为生产代码的事实来源。很多人喜欢在 notebook 里把数据清洗、特征工程、训练全做一遍最后要上线时发现代码根本没法抽出来。正确的姿势是notebook 里探索验证完逻辑立刻把核心逻辑沉淀到src/下的正式模块。experiments/下每个实验单独建目录里面放本次实验的 config 副本、评估结果、训练曲线。这样过几个月再回来看依然能还原当时发生了什么。scripts/用来放一次性数据导入、定时刷新之类的脚本跟核心业务代码区分开防止污染主流程。2.4 代码、数据、模型要分开管理代码用 git 管这个大家都有共识。但数据和模型往往被忽略。模型文件动辄几百 MB放进 git 会让仓库迅速膨胀数据可能还有隐私和一致性问题。我的做法是代码进 git数据和模型用 DVC 或直接放到对象存储然后记录版本信息。用 DVC 的起步流程非常简单dvc init dvc add data/raw/20250101_dump.parquet dvc push之后每次数据有变动都会生成新的文件哈希实验记录里关联这个哈希就够了。哪怕一个月后原始文件被覆盖也能从 DVC 缓存里找回对应版本。同样的道理训练产出的模型文件也建议统一登记在 experiments 目录下的版本记录里命名带上模型效果指标比如model_val_f1_0.812.pt这样找最优模型的时候一目了然。3. 数据工程先行把脏乱数据变成可复现的流水线3.1 原始数据必须当成只读资产很多项目翻车都是从“原始数据被改坏了”开始的。比如为了快速调试直接在原始 Excel 里删了某几行或者在下载下来的 dump 文件上原地洗数据等到重新训练时发现数据对不上又找不到源头。我的铁律是原始数据一落地就设成只读并且记录校验信息任何清洗操作都要从原始数据复制出新的一份再做。这样能保证任何时候重建数据管道产出的结果都和第一次一致。# scripts/ingest_raw.py import hashlib from pathlib import Path raw Path(data/raw/20250101_dump.parquet) # 对原始文件生成哈希记录版本 digest hashlib.md5(raw.read_bytes()).hexdigest() print(fraw data md5: {digest}) # 后续清洗时以此为原始输入绝不原地写回3.2 清洗逻辑要分层每层都要可观测数据清洗最忌讳“一锅炖”。我习惯把数据处理分成三层原始层 raw、清洗层 cleaned、拆分层 splits。每一层之间都有独立的脚本和输出物清洗层里的每一条规则都要记录并且要能回答一个问题这一步删了多少数据、改了多少数据。举个例子一个标准的清洗函数应该长这样# src/data/clean.py def clean_raw_data(df: pd.DataFrame) - pd.DataFrame: before len(df) df df.dropna(subset[user_id, item_id]) df[price] pd.to_numeric(df[price], errorscoerce) df df[df[price].between(0, 10000)] after len(df) print(f清洗保留比例: {after / before:.2%}) return df别小看这个“清洗保留比例”它是最廉价的数据质量监控。如果某一天这个比例突然从 85% 掉到 60%说明上游数据大概率出了问题早发现早定位。清洗完成后还要在管道末尾加校验断言让非法数据直接报错而不是带病进入训练# src/data/validate.py assert df[user_id].notna().all(), user_id 存在空值 assert df[price].notna().all(), price 存在非法数值 assert df[price].max() 10000, price 超出业务阈值3.3 数据版本化与固定拆分的必要性“训练集、验证集、测试集”的划分看起来很简单但真正执行到位的人不多。常见错误有每次运行脚本都重新随机划分导致两次实验的验证集不一样用了全局的归一化统计量切数据造成数据泄漏时间序列数据没有按时间切分而是随机切分让模型偷看了未来的信息。我的固定做法是一次性生成拆分文件存成data/splits/train.parquet、val.parquet、test.parquet以后所有实验都用同一套拆分不再重新划分。而且随机种子要在代码里全局固定保证划分结果可复现。dvc add data/splits dvc push对于时间序列场景我额外强调切分必须按时间顺序比如用前 80% 时间窗口做训练、中间 10% 做验证、最后 10% 做测试否则模型在训练时就已经见过“未来”上线后效果一定会暴跌。这个坑我见过太多次了。3.4 数据探索的可追踪原则做数据探索不是“画几张图看一下”就结束而是要能形成记录。我一般会在探索阶段输出一份字段档案包含每个字段的类型、缺失率、基本分布、业务含义以及清洗规则的代码注释。这份档案的价值会在后续排障时爆发某天线上效果下降你可以快速定位是哪个特征分布变了而不是对整个模型从头排查。4. 模型训练项目化从一个朴素基线写到实验报告4.1 先立一个朴素基线而不是直接上大模型很多 team 一上来就想跑大模型结果算力资源、数据量、迭代速度全都不匹配。我的经验正好相反第一个模型永远选择朴素基线比如逻辑回归、随机森林或者一个简单的 MLP。目的不是拿到好成绩而是用最低成本把训练、评估、部署整条链路跑通并给出一个“成绩参照系”。后续无论换成什么复杂模型只要效果没有显著超过朴素基线那么这个复杂模型的价值就要打问号。这在很多业务场景里非常重要一个用随机森林就能解决的预测问题没必要硬套大模型带来的是运维复杂度和延迟成本。4.2 训练脚本的模块化与随机种子固定训练脚本我通常拆成这几个模块train.py入口读取配置、组织训练流程model.py模型结构定义dataset.py数据加载与预处理evaluate.py评估逻辑serve.py线上推理随机种子必须在数据加载、数据打乱、模型初始化三个阶段都固定否则实验结果不可复现。一个标准的 seed 函数# src/utils.py import random import numpy as np import torch def set_seed(seed: int 42) - None: random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)训练中保存 checkpoint 也有讲究不能只存模型参数要把 epoch、优化器状态、验证指标一起存进去文件名直接体现当时的最佳指标。这样每份模型文件都自带“身份信息”后面回滚和对比时非常省事。4.3 实验追踪不靠记忆靠记录团队小的时候可以不用立刻上重量级的实验管理平台但至少要建立一个约定每个实验一个目录里面有 config 副本、评估结果 JSON、训练曲线图片、对应的 git commit 号。目录名从 exp001 开始递增。下面是一个标准的metrics.json{ experiment: exp001, git_commit: a1b2c3d4, dataset_version: 20250101_dump.v2, config_hash: 6a3f2e9b, metrics: { val_loss: 0.234, val_f1: 0.812, val_precision: 0.834, val_recall: 0.791 } }有了这套记录之后你和别人沟通任何实验结论都能直接拿出证据链而不是说“我印象里好像……”。等团队规模大了、实验数量上去了再去接 MLflow 或云端实验管理平台也不迟而且迁移成本很低因为你的记录结构已经完全规范了。4.4 评估的严肃性别让验证集骗了你模型训练完成之后评估环节是最容易犯糊弄的地方。我经常说一句话“训练指标是过程评估指标是结论”。用训练集损失来判断模型好坏是对自己的欺骗。一个严肃的离线评估至少要做到下面几点分类任务要输出混淆矩阵、准确率、精确率、召回率、F1而不仅仅是一个准确率。在样本不均衡场景下准确率几乎没有任何参考价值。回归任务不要只看 MSE要看残差分布判断是否存在系统性高估或低估。要对验证集分组评估比如按业务线、按时间周期分别计算指标避免“平均分好看、某个关键人群表现极差”的问题。如果用了早停早停依据的指标和最终汇报的指标要提前想清楚。常见的坑是早停看 val_loss但最终对外汇报 val_f1两个指标的变化趋势不完全一致导致选的 checkpoint 并不是 F1 最优的那个。4.5 先小后大的训练策略训练环节最浪费时间的动作就是对海量数据跑了一遍之后才发现代码里有 bug。我的方法是先用一小部分数据比如 1000 条跑通代码全流程确认 loss 在下降、模型能保存、评估能输出再切换全量数据跑正式训练。这一步小小的“预跑”能省掉几个小时的返工。正式训练期间记得监控资源使用情况nvidia-smi --query-gpuutilization.gpu,memory.used --formatcsv -l 5如果 GPU 利用率长期低于 80%就要检查是不是数据加载卡了 IO或者 batch size 太小导致算力闲置。这些细节点决定了模型迭代速度也是工程化和纯算法训练拉开差距的地方。5. 上线只是起点服务化部署与线上监控的及格线5.1 先搞清楚离线批量还是在线API很多人一想到“部署模型”默认就是要开 HTTP 服务。但实际项目中很多模型场景根本不需要在线接口。比如每日批量生成推荐候选、定时做风控打分这类任务对实时性要求不高用离线批量预测成本更低、更容易运维。真正需要在线 API 的场景是用户请求实时触发、预测结果要求毫秒级返回。如果确定走在线 API我建议第一版尽量做一个轻量服务用 FastAPI 就足够先不要引入太重度的框架。# src/serve.py from fastapi import FastAPI from pydantic import BaseModel app FastAPI() model load_model() class PredictRequest(BaseModel): features: list[float] app.post(/predict) def predict(req: PredictRequest): pred model.predict([req.features]) return {prediction: pred[0], model_version: model.version}上面这个版本虽然简陋但已经具备了接口、参数校验、模型加载、返回预测四个最基础的能力。后面逐步加上鉴权、日志、限流即可。5.2 推理性能的常见优化手段服务上线之前我强烈建议先做压测再谈优化。很多团队一上来就想上 GPU、上 TensorRT但实际请求量小得 CPU 都绰绰有余。优化的优先级应该按照性价比排序优化手段适用场景启动成本动态批处理请求量大且时间分布均匀中ONNX 导出模型框架推理速度不满足要求低FP16/量化GPU 推理且对精度损失可接受中高频请求结果缓存存在大量重复请求极低最被我“敲打”的场景是某些同学一上来就做量化结果精度掉了 3 个点业务方直接不满意但其实业务的真实并发量根本没到需要量化的程度。先压测、看瓶颈再决定优化手段才是工程化思维。5.3 线上监控指标不是“有了就行”要真的能预警模型部署上线之后真正的工程考验才刚开始。核心监控指标至少分成两类基础设施指标接口延迟 P99、错误率、每秒请求数。模型效果指标输入特征缺失率、特征分布漂移、模型置信度分布、预测结果分布。特征分布漂移可以用 PSIPopulation Stability Index来量化监控。举个例子训练时用户的年龄分布集中在 20-40 岁上线一段时间后由于渠道投放策略调整线上请求里 50 岁以上用户比例暴增这时模型的预测置信度会整体下降。如果没有监控可能要等到业务方反馈“效果变差了”才发现问题而那时已经过去了一两周。我曾经遇到过一个真实案例一个反欺诈模型上线第一周表现良好第二周开始预测分值整体走低但没有监控系统等到月底复盘才发现中间有一批上游特征因接口改造变成了默认值模型在“瞎猜”了一周多。这种问题只要在监控里加了输入特征缺失率和覆盖率告警就能在小时内发现并回滚。5.4 数据回流与再训练节奏模型不是训一次就结束的。线上推理日志要设计成结构化的格式至少要包含特征快照、预测结果、可能的真实标签、用户反馈等字段。这些日志落地后经过过滤和脱敏回流到数据管道成为下一轮训练的新数据。再训练的节奏可以按业务变化快慢决定用户行为类模型可能每周或每天更新风控类模型按月更新通用场景按季度更新也可以。但不管节奏怎样都要把模型更新设计成可重复执行的流程而不是每次手工跑脚本。我的习惯是把“数据更新 → 清洗 → 训练 → 评估 → 发布”这一步做成可触发的管道发布模型时保留上一版本先在灰度环境跑一段时间确认无异常再全量切流。6. 我反复踩的坑和现在的固定动作6.1 环境不一致为什么“我明明没改代码结果变了”一次项目里同事早上训练的模型 AUC 是 0.875下午怎么跑都只有 0.861代码完全没动数据也一模一样。最后排查了半天原因是下午的终端自动激活了另一个虚拟环境PyTorch 版本从 2.0 变成了 2.1底层算子的默认行为有变化。环境不一致就是这样所有“玄学”问题里十有八九是它。我现在的固定动作是所有项目在启动第一天就导出 lock 文件并且训练脚本启动时校验关键库版本不匹配直接报错。6.2 随机种子只管了训练没管数据拆分种子固定不完整是另一个常见问题。固定了 torch 的随机种子但 DataFrame 的 shuffle 用的是 Python 或者 NumPy 的随机数两者不在一个系统里照样不可复现。更隐蔽的是每次运行数据清洗脚本都会重新生成拆分文件导致不同实验的验证集根本不一致。现在的固定动作是数据拆分一次性生成并保存成独立文件之后所有实验直接读取不再动态划分。6.3 数据泄漏基线模型效果“好到不真实”有一次做客户流失预测基线模型的 AUC 高到了 0.99当时团队还挺高兴。后来我查了一下特征工程代码发现里面有一个特征是从“未来账单状态”计算出来的相当于模型拿答案考试训练时泄漏了未来信息。这种问题在时间序列和业务数据中都容易出现。现在的固定动作是先做数据拆分再在训练集上计算归一化统计量同时新加特征时必须检查该特征是否用到了标签或未来信息。6.4 离线指标和业务指标错位还有一次推荐模型的离线 AUC 从 0.78 提升到 0.81团队很高兴地全量上线结果业务侧的点击率纹丝不动。后来复盘发现离线 AUC 是按物品维度计算的而线上真正影响点击率的是排序位置和多样性模型只在 AUC 上变好并没有带来业务价值的提升。这件事之后我要求每个项目在立项阶段就明确“离线代理指标”与“业务最终指标”的对应关系并且每个月把离线和线上指标放在同一张表里跟踪一旦发现错位就调整评估方式。6.5 我的固定动作清单下面这张表是我在项目里的默认动作从零开始的项目直接照着查阶段固定动作要解决的问题环境锁定 Python/框架版本生成 lock 文件三个月后还能复现结构统一目录、统一配置入口代码可读、可迁移数据原始数据只读清洗分层拆分固定数据可复现、防泄漏训练固定随机种子先小后大保存 checkpoint 带指标实验结果可对比评估多指标评估分组验证防止被单一指标误导部署先压测再优化版本可回滚上线稳定、可灰度监控基础设施指标加分布漂移监控线上问题早发现如果让我重新做一次“从零开始”的 AI 工程我不会再从网上某个 SOTA 项目复制代码而是先把问题定义清楚、把环境钉死、把数据管道做好然后尽快用一个最笨的基线把完整闭环跑通。最后分享一个小技巧无论项目多复杂我都会在启动的第一周就部署一个“丑但完整”的版本到测试环境哪怕准确率不够看。因为这个版本能逼着我把链路里的所有隐性坑都暴露出来之后再优化模型才有真正可靠的对照基线。AI 工程最难的地方从来不是某个模型有多聪明而是一个笨模型也能在系统里稳定、可控、可衡量地运转。
阅读完成 · 觉得有帮助?