拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI工程从零到上线全流程:环境、数据、训练与部署实战

AI工程从零到上线全流程:环境、数据、训练与部署实战

去年我给自己定了一个小目标:不看任何“AI速成”资料,踏踏实实把AI工程的完整链条自己动手过一遍。当时为什么会有这个念头?因为工作里天天跟模型、数据、训练、上线打交道,但我发现很多人(包括当时的我)其实都卡在同一个地方——代码能跑,模型能练,可一旦涉及到“别人也要用”“换台机器能复现”“上线了能稳定服务”,整个流程就跟纸糊的一样。这个“ai-engineering-from-scratch”项目就这么来的:不追求把每个算法推导到极致,而是把AI从想法到稳定服务这条路上所有绕不开的环节,一个个亲手做一遍。

这篇文章就是那个项目全过程的复盘。内容更适合那些已经能跑通一个简单训练脚本、但想把这件事做得更“工程化”的人,也适合准备从纯算法岗位转向AI工程方向的朋友。我会从环境搭建、数据管道、训练追踪、模型部署到端到端案例,把为什么这么做、怎么做、踩过什么坑,一次性讲清楚。

1. 先把“可复现”这件事刻进骨子里:环境与项目结构

1.1 从一开始就按“别人能跑起来”的标准搭环境

我先说一个最反常识的经验:AI工程的第一步不是装PyTorch,不是下数据集,而是先决定“这套代码三个月后还能不能跑”。很多人觉得这是废话,但实际操作中,十个项目有八个死在环境漂移上——今天能跑的notebook,换台电脑就报错;上周训练好的模型,这周要复现结果却对不上了。

我自己这套“from scratch”项目第一步就是选环境管理工具。现在的选择其实很成熟:conda适合快速做实验,善于管理Python环境和非Python依赖;Docker适合标准化交付,把CUDA、系统库、Python版本全部固定下来。我的建议很直接:日常研究用conda,但一旦项目要交给别人或者上服务器,立刻切换成Docker。原因是conda解决的是“Python包版本”问题,Docker解决的是“整个操作系统长什么样”的问题——后者才是真正的可复现。

我用一个文本文件记录整个环境的依赖策略,而不是靠肌肉记忆。核心就两条:一是把主要依赖的版本范围写清楚,而不是装完就忘了;二是所有安装命令都沉淀成一个脚本,从零到能训练的完整步骤,一条命令跑完。

1.2 让项目结构一开始就接近生产标准

很多人喜欢建个train.py就开始写,一路写到底。这种单文件脚本在自己的小项目里没问题,但项目一旦变大,就会陷入“改一个参数要翻遍全文件”的窘境。我在这个项目里用的目录结构是:

ai-engineering-from-scratch/ ├── src/ # 核心代码,包含模型定义、训练逻辑、数据加载 │ ├── data/ │ ├── models/ │ ├── training/ │ └── serving/ ├── configs/ # 所有可配置参数,用yaml管理 ├── data/ # 数据集存放,不管原始还是处理后 ├── experiments/ # 每次实验的日志、指标、checkpoint ├── scripts/ # 数据下载、预处理、评估等一次性脚本 ├── tests/ # 单元测试和集成测试 ├── Makefile # 统一命令入口 └── README.md # 告诉别人怎么跑

为什么这样拆?因为AI项目和普通软件项目最大的不同是:它有很多“试错性”的东西——模型结构、超参数、数据处理方式都在频繁变化。如果你把这些变化和业务代码混在一起,最后必然是一团乱麻。configs里面放所有可变参数,src里只放带逻辑的代码,这样一来,改实验设置不用动代码,代码改动也更容易被测试覆盖。

还有一个细节值得提:experiments目录我坚持用时间和描述命名,比如20250115_baseline_lstm,每次实验独立成目录,里面放完整的配置副本、日志和checkpoint。不要嫌占用空间,后面你会发现这个习惯在对比结果时有多值钱。

2. 数据管道是AI工程里最容易被低估的关卡

2.1 数据清洗的边界感:不要上来就把数据“洗没了”

我见过不少新手的做法:拿到数据先一顿操作,删空值、删异常值、做归一化,然后扔进模型训练。结果模型指标还挺好,一上线就崩。为什么?因为训练时的数据分布跟线上真实数据分布压根不是一回事。数据清洗这件事,必须带着“业务上下文”做,不是纯技术活。

我自己的流程是先把数据分成三层:原始层(永久保留原样)、清洗层(做脱敏、格式统一、异常标记)、特征层(做模型真正吃的东西)。每一层都用独立脚本生成,这样随时能追溯某个字段是怎么变来的。

举一个实际例子:我在这个项目里处理过一份带缺失值的用户行为数据。大多数人的第一反应是均值填充。但我看数据时发现,某些字段的缺失其实跟用户活跃度强相关——活跃用户这些字段几乎全有,沉默用户则大量缺失。如果直接用均值填充,等于告诉模型“大家都差不多”,直接把最有区分度的信息抹掉了。正确做法是把“是否缺失”本身作为一个特征,缺失值先用一个特殊占位符。这个小小的区别,直接让AUC提升了大概三个百分点。

还有一点,清洗要克制。异常值不要急着删,先单独拉出来看它是不是数据记录错误,还是真实的极端场景。前者该修,后者该留,甚至该当成单独一类样本处理。处理数据时我建议你始终给自己留一个后手:哪怕要删,也只删“副本”,原始数据永远不动。这条习惯能救你的命。

2.2 数据版本管理与可复现:用写代码的思路管理数据

代码有git来管版本,多数人都做得很好。但数据呢?数据集被谁覆盖了?上次实验用的是哪个版本的数据?这些问题用git解决不了。因为git服不了大数据,而数据的“变更记录”跟代码一样重要。

我在这个项目里引入了dvc做数据版本管理。核心思路是:把数据文件放在一个独立的目录里(比如data/processed),用dvc add把数据文件的元信息登记进去,然后dvc在git里存一个很小的指针文件。这样每次数据变更,都会有对应的git提交记录,随时可以切回到历史版本。

实际使用中,在GPU机器上训练、在本地写代码的情况下,我会给每条数据记录加上一个hash值作为ID,同时对每个数据版本记录一个整体校验值。这带来的最大好处是:训练时,如果代码读到的数据跟预期版本不一致,立刻就能发现,而不是默默跑上好几天,最后发现结果对不上。

3. 训练环节:不追求SOTA,先追求“结果能对上”

3.1 实验追踪:每个数字背后都得有一本账

在“from scratch”项目里,我没有用特别复杂的平台,就选了mlflow做了实验追踪。为什么不是直接用tensorboard或者干脆print?因为print只能应付单机单卡调试,而真正工程化的项目,你得随时回答几个问题:当前最优模型是谁跑出来的?用了什么超参数?训练了多久?数据版本是什么?如果这些问题的答案只存在某个人脑子里,那这个项目就还没到“工程化”的程度。

用mlflow之后,我养成了一个非常简单的习惯:每次训练启动前,先把关键的配置参数用mlflow.log_params记录下来;训练过程中,每多少个step记录一次指标的log_metrics;训练结束,模型artifact也通过mlflow记录。这样自动形成了一个实验台账,上面说的所有问题,查一下就全知道了。

另外,随机种子这件事,我把它提到跟超参数同等的地位。代码里所有涉及随机的地方都用同一个种子初始化,并在日志里记录版本号。不要小看这一步,很多训练结果“复现不了”,不是代码错了,而是随机性没管好。你在跑基线的时候也许觉得无所谓,但等到你做模型对比、分享结果给同事时,一个没有固定种子的实验结论是没法让人信服的。

3.2 训练代码里的“防呆设计”:checkpoint、断点续跑、资源感知

在实际训练时有一些常见的工程坑。比如训练跑到一半机器挂了,如果没做checkpoint,前面几十个小时全都白费;又比如同一份代码在CPU机器上也要能跑,哪怕慢一点,至少能验证数据流程有没有问题。

我写训练脚本的时候,会先做一个“最小可跑通”的版本:模型缩小、数据只取一小部分,目标是把代码逻辑跑通,而不是一开始就上全量数据。在确认没有语法错误、数据格式错误之后,再把模型规模和batch size提上来。这个流程看似多了一步,但省下来的调试时间远超想象。

checkpoint方面,我建议除了保存“最后一个epoch”的模型,还要固定保存“验证集指标最好”的模型,两个分开存。原因很简单:模型训练过程中验证指标是波动的,最后的epoch往往不代表最好的泛化性能。如果只留最后一个,你相当于给模型挑了一个中间的随机状态。

还有一个小细节:训练代码要能感知当前设备,自动决定用CUDA还是CPU,不要硬编码cuda:0。否则别人拿到你的代码在一个没有GPU的环境根本跑不起来。这不算什么高级技巧,但能体现一个AI工程的素养:你的代码不是只有你能跑。

4. 模型验证不能只看测试集分数

4.1 数据泄漏:所有“不合理高分”的元凶

训练环节最容易犯且后果最隐蔽的错误,就是数据泄漏。它的本质是:训练时模型“偷看”了本不该看到的信息,导致训练指标虚高,而真实场景里这个信息不存在,模型自然就崩了。

我在这个项目里专门设立了一个环节来排查数据泄漏。最典型的一种是:做时间序列任务时,我把未来时间段的数据混进了训练集,导致模型“预知未来”;另一种常见的是:数据预处理时用了全量数据的均值做标准化,相当于训练时知道了未来的统计信息。

做一个简单的清理逻辑:凡是涉及全局统计量的预处理(归一化、标准化、缺失值填充),必须放在交叉验证的每一折内部做,而不是先对整个数据集做预处理再分训练集测试集。这条规则,说三遍都不够。

4.2 验证指标的选择:准确率是一个“陷阱”

再聊一个几乎人人都踩过的坑:分类问题一律看accuracy。准确率这个指标有一个默认假设——各类别样本数量差不多,而且错分代价一样。但现实世界的AI项目根本不是这样。

在这个项目里我处理的是一个垃圾邮件分类问题:垃圾邮件占比大概15%。如果你做一个模型,把所有邮件都预测成正常邮件,准确率照样有85%。看着好像还不错,实际上这个模型一点用都没有。正确做法是用精确率、召回率、F1,甚至经济成本加权,把“漏掉一封垃圾邮件”和“误杀一封正常邮件”这两种错设成不同的代价,再去选模型阈值。我建议每次做模型评估时,至少给出混淆矩阵,再把threshold调一遍,找到真正符合业务需求的平衡点。

另外还有一个容易被忽略的点:模型的验证集分布要尽量贴近上线后的真实分布。很多人从公开数据集里随机切出20%作为验证集,但线上数据往往有时间漂移,这时用随机切分的验证集来评估,会过于乐观。比较好的做法是按时间切分,用最后一段数据做验证,或者至少做一个敏感性分析,看看模型性能在不同时间段上的波动情况。

5. 部署上线:从“能跑”到“能稳定服务”的质变

5.1 用FastAPI包一层:模型服务化的最小闭环

我自己做部署的路线是先用FastAPI写一个最小可用的推理服务。为什么是FastAPI?因为它性能足够、写起来快、自带API文档,对于工程初学者或者要快速迭代的场景,是不二选择。

写推理服务有几个容易忽略的结构问题。第一,模型加载和推理请求必须分离:模型加载一次,在进程启动时完成,不要每个请求都重新load一次模型,否则延迟会高到离谱。第二,输入数据要做合法性校验:不能假设所有调用方都会按你定义的格式传数据,要有明确的报错信息。第三,既要提供在线API,也要准备一个离线批量推理脚本,方便做压测和数据回放。

下面是一个很基础的FastAPI推理服务骨架:

from fastapi import FastAPI, HTTPException from pydantic import BaseModel app = FastAPI() class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: int score: float model = None @app.on_event("startup") def load_model(): global model model = load_from_artifact("runs:/best_model") @app.post("/predict") def predict(req: PredictRequest): if model is None: raise HTTPException(status_code=500, detail="model not loaded") label, score = model.predict_proba([req.text]) return PredictResponse(label=label, score=score)

这里有个细节:startup事件里加载模型,避免请求到来时才去读checkpoint。还要把模型文件路径之类的信息放到环境变量或配置里,而不是写死在代码中,否则部署到不同环境就得改代码。API设计上,我倾向于输出score而不是只输出label,因为业务方往往需要针对阈值做二次判断,直接给最终结果反而限制了灵活性。

5.2 性能、成本与降级:架构设计里的“反脆弱”

模型服务上线之后,下一步要面对的就是性能和成本问题。一个模型在GPU上单次推理可能要几十毫秒,听起来很快,但如果线上QPS很高,GPU显存和算力都吃紧,成本会直线上升。这时候常见的优化手段有:batch推理、量化、模型蒸馏、用更小的模型做初筛。

我在这个项目里试过把多个请求合并成batch再送进模型推理,吞吐量提升了接近一倍,延迟也没有变差太多。如果你的模型支持动态shape,这是性价比很高的优化手段。另外,ONNX Runtime或者TensorRT导出模型后,推理速度往往比原框架快不少,尤其是推理脚本跟训练框架已经解耦的情况。

还要谈谈降级策略。模型服务再稳定,也会有上游数据格式变更、特征缺失、模型效果变差这些情况。我的做法是给线上服务加一个“安全模式”开关:当模型置信度整体低于某个阈值,或者特征异常率超过警戒线时,服务自动切回旧的规则兜底,而不是让模型硬着头皮输出。这件事很多团队会忽略,直到线上事故出现才想起来——但等到那时候,代价就大了。

6. 一个完整的端到端示例:垃圾邮件分类器的工程化

6.1 问题定义与基准线:先定“赢”的标准再动手

把前面这些串起来,我实际做了一个垃圾邮件分类的端到端项目。这个项目本身不算复杂,但它完整覆盖了从数据到线上的全过程,很适合做“from scratch”的样板。

最开始我做的事情是定义什么是“好”。业务方说“我不想漏掉任何一封垃圾邮件”——这听上去很美好,但它的代价是大量正常邮件会被误伤。所以我没有直接设定“精确率越高越好”或者“召回率越高越好”,而是跟“假想用户”做了一个模拟权衡:假设每漏掉一封垃圾邮件损失10元,每误杀一封正常邮件损失1元,那么目标就变成一个——让总期望损失最小。

在这个目标下,基线模型可以很简单。我用了一个基于词频的朴素逻辑作为baseline,记录它的总损失,然后后面的每个模型都在这个基准上比较,而不是单纯比accuracy。这样做的好处是:模型迭代有了一个清晰的“经济标尺”,你在向非技术同事解释模型好坏时,他们也能直观理解。

6.2 从MVP到生产系统:差的不是代码,是流程

MVP版本我控制在三天内完成:一个朴素分类器、一个FastAPI服务、一个简单的评估脚本。这版虽然糙,但已经很完整了。而把它往真正生产级推的时候,我陆续加了四样东西:

  • 模型注册与版本管理:每次训练的模型都带版本号,线上跑的一旦有问题,可以秒级回滚到上一个稳定版本。
  • 数据监控:对线上推理请求的特征分布做统计,一旦发现分布大幅偏移就报警。虽然不能完全替代人工检查,但至少能让你“有感知地”发现问题。
  • CI/CD思路:训练流水线和部署流水线尽量自动化。代码合入后自动跑测试、自动训练、自动评估,评估达标才允许部署。
  • 反馈闭环:收集线上模型的低置信度样本,定期抽样人工标注,补充进训练集。

这里最花时间的不是写代码,而是让大家习惯这些流程。很多人觉得加一个模型注册很麻烦,但真正线上事故发生时,你看着一个无法回滚的模型服务,才会明白“可回滚”这三个字有多值钱。

7. 这些坑,我替你先踩一遍

最后把几个我在这个项目中最痛的教训整理成一张表。这些都是常规教程不会告诉你的东西,但每一条都是真实操作中血和泪换来的。

问题表象真实原因正确做法
训练结果对不上同一份代码两次结果不一样随机种子没固定、GPU浮点累加顺序不同固定种子,必要时用确定性模式
测试集分数很高,线上效果差离线指标和线上指标差距巨大数据泄漏、验证集划分不合理按时间切分验证集,检查预处理是否引入了全局信息
换台机器代码跑不了报ModuleNotFoundError或版本冲突环境只存在于一个人电脑上用Docker固定整个环境
模型上线后某天突然变差模型还是那个模型,但效果肉眼可见下滑线上数据分布漂移做输入特征监控,及时发现漂移
模型响应越来越慢服务偶尔超时,但没人动过代码请求量增大,模型显存不足引发排队做压测,及时上batch推理或扩容

有一次我印象特别深:训练好的模型在测试集上F1高达0.97,结果上线不到一周,业务方投诉说“模型把很多正常邮件都扔进垃圾箱了”。排查后发现,训练数据里有一类特殊模板的邮件占了大多数,模型学会了“看到这个模板就判垃圾”,而这个模板在线上真实流量里几乎不出现——这就是極典型的训练分布和线上分布不一致。

从那以后,我现在做项目时都会问一个问题:如果我今天拿到一批线上真实数据,模型表现会跟离线评测差多少?这个问题不一定当场有答案,但问得多了,你就会发现“离线认真做好验证”有多么重要。

如果让我重新做一遍“ai-engineering-from-scratch”这个项目,我会把前面环境搭建和数据版本管理的时间再往前压一压,更早点进入端到端的循环。因为AI工程这件事,光想是学不会的,你只有在“亲手搭一次环境、亲手把模型送上线、亲眼看到它出问题又修好之后”——那些看似琐碎的工程细节才会真正内化成你的肌肉记忆。这个项目本身没有什么高深算法,但走完它,你会对“AI从想法到落地”这件事有一个完整的体感。而这份体感,恰恰是“ai-engineering”这个词真正值钱的部分。

返回列表