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

资讯详情

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

AI工程从零实战:构建模型到上线的完整闭环

AI工程从零实战:构建模型到上线的完整闭环

最近很多人问我一个问题:作为一个刚开始接触 AI 的人,到底该怎么规划自己的学习路径,才能不变成“只会调库的调包侠”。我的答案一直很明确——如果真有所谓“ai-engineering”这条路,那它的起点一定不是某个框架的官方文档,而是你亲手把一个最基础的模型,从零开始推到能稳定上线运行的完整闭环。这篇文章就围绕我的“ai-engineering-from-scratch”个人项目,讲讲我这段时间踩过的坑、拆过的轮子,以及最终沉淀下来的一套思路。它不是什么标准教材,更像是我自己重新定义“入门”的一份实操笔记,适合那些已经看过一些 Python 和机器学习概念、但始终觉得“没真正上手”的人。

如果只是追求“能跑通一个 demo”,那么真没什么好写的,随便找个开源项目克隆下来就能出结果。但工程的本质从来不是“能跑”,而是“可控、可解释、可复现、可迭代”。所以我给自己定的目标是:不依赖任何一站式平台,不跳过任何关键环节,把所有内容补全成一套自己完全理解的最小系统。接下来我会把我当时的设计思路、技术选型、以及后来反复推翻重做的地方,完整拆开来讲。

1. 先从最容易被忽略的定义讲起:AI 工程和机器学习建模是两回事

1.1 为什么我坚持用“工程”这个词,而不是“建模”

市面上百分之八十的入门教程,其实都在教“建模”:拿一份现成的数据集,做特征工程,跑一个模型,然后看一下准确率。这套流程当然重要,但是你会发现,一旦要处理真实场景里的脏数据、实时请求、模型过期、AB 实验、灰度发布这些问题,教程里的那一套立刻就会失灵。因为建模解决的是“从数据到预测”的问题,而工程解决的是“从预测到交付”的问题。

我的体会是,AI 工程的“从零开始”,真正的难点不在于推导某一个数学公式,而是要建立一个能把模型放进生产环境、并且持续服务用户的系统能力。这个能力至少包括:数据如何更新、代码如何回滚、监控如何报警、特征如何对齐、模型如何重新训练。对,听起来很无聊,但这就是工程的核心。

为了不让自己逃课,我给自己定的项目边界是:

  • 不使用任何 AutoML 平台自动生成的 pipeline
  • 不直接调用预训练大模型的 API 来做“套壳”项目
  • 不使用一站式机器学习平台来管理数据和模型
  • 所有核心代码,从数据处理到模型推理,必须自己一行一行写出来

这个边界看起来很激进,但是它的价值在于——你被迫面对每一个细节。比如一个简单的数据去重,用 pandas 一行代码就完了,但当我要求自己用 Python 原生的方式去实现时可以倒排索引、内存占用控制、哈希冲突处理,才真正理解了那句“所有数据库都能做成 apis”的老话不是随便说的。

1.2 重新拆分“from scratch”的含义

这个项目名字里的“from scratch”,对不同的人来说几乎有不同含义。对初学者,它可能是“不用现成框架,从头写神经网络”;对资深工程师,它可能是“一切从需求文档开始,包括基础设施选型”。我的理解放在了中间层:不用整套平台,但允许使用底层的库和工具,因为你不可能真的从晶体管开始造一台计算机。

具体来说,我的“scratch”边界是这样划定的:

  • 框架层面:允许用 PyTorch,但不用它的高层封装(比如 Lightning)
  • 数据处理层面:允许用 numpy、pandas,但不用现成的特征平台
  • 部署层面:允许用 Docker 和云服务,但每次部署必须知道每一行配置的含义
  • 监控层面:允许用 Prometheus 这类监控系统,但报警规则必须自己设计

这个“允许”列表是经过认真权衡的。因为工程的本质是控制复杂度,如果你连底层框架都重写,那项目大概率做不完;但如果你什么都用现成的,项目就变成单纯地“拼接”,没有真正内化知识。在这两者之间找到平衡,是“ai-engineering-from-scratch”这个项目给我的第一个启发。

2. 环境构建的第一课:你的开发环境本身就是一个“AI 工程问题”

2.1 从裸机到可复现环境,我花了 3 天时间试错

很多人会忽略环境搭建的意义,觉得装个 conda、pip install 一把梭就是了。但我想说,环境搭建这件事本身就是一次“从零开始”的锻炼,因为它涉及到依赖管理、版本冲突、跨平台兼容性,而这恰好是工程能力的缩影。

我当时给自己出了一道题:在一台全新的 Ubuntu 服务器上,不借助任何云厂商的预装镜像,从裸系统开始搭出一套可以跑完整 AI 流水线的环境。我最开始以为半天就能搞定,结果实际用了 3 天多。

踩的第一个大坑是 Python 版本。系统自带的 Python 版本常常是 3.8 这类版本,但很多新库已经要求 3.10 以上,而某些老库反而还不支持太新的版本。如果你直接改系统 Python,后面排查起来非常痛苦。我的建议是:无论什么情况下,都使用虚拟环境来隔离项目依赖。这里我用的是 conda 的虚拟环境,因为它不仅管理 Python,还能管理 CUDA、cuDNN 这些 GPU 相关的底层库。

第二个坑是 GPU 驱动和 PyTorch 的匹配问题。当时我在一台只装了 NVIDIA 驱动、但没装 CUDA toolkit 的机器上跑训练,发现 PyTorch 会静默地回退到 CPU 模式,因为它在用 pip 安装时默认带了一套 CUDA runtime,但如果驱动版本过低,运行时会直接警告但不报错。这个“警告但不报错”非常迷惑人。直到我加了这几行代码才定位到问题:

import torch print(torch.cuda.is_available()) # 这里会显示 False,原因很隐蔽 print(torch.version.cuda) # 查看编译时捆绑的 CUDA 版本 print(torch.backends.cudnn.enabled) # cudnn 是否启用,是另一个静态开关

如果你发现自己训练速度异常缓慢,而 CPU 占用率又很高,八成就是出现了这个情况。关键是,报错信息永远不如你想的直接,因为很多时候它只是默默地在 CPU 上执行。

2.2 依赖管理的三条军规

经过那几天折腾,我总结出三条之后一直遵守的规则,现在分享出来:

第一,环境文件必须代码化。不要靠“在一个机器上装好了,然后人工记忆装了什么”这种方式。我最后把所有依赖用一个 environment.yml 文件管理,包括 pip 部分和 conda 部分分开写。这样任何同事或未来的我,都能通过一条命令还原相同环境。

第二,不要吞噬警告。我在项目初期为了图省事,在脚本开头加入了warnings.filterwarnings('ignore'),后来这成为我最后悔的决定之一。因为很多依赖库的版本问题,最开始都是以警告形式出现的,你忽略多了,系统就变成一个“不知道哪个环节已经悄悄不对劲”的黑盒。AI 工程里最怕的就是这种黑盒。

第三,把来自控制台的输出全部保存下来。我写了一个统一的日志模块,所有训练的中间输出、指标变化、异常堆栈都写入本地文件,并且按时间戳归档。这个习惯后的好处是你可以在很多天之后回过头来看某一版模型当时训练时到底发生了什么,而不用凭记忆猜。

列举一个当时非常典型的依赖陷阱:numpy的版本浮动一度导致scikit-learn行为不一致,两个库基于不同版本的 BLAS 接口,最终模型结果虽然没变,但训练时间忽快忽慢。我排查了很久才意识到,问题根本不在代码逻辑,而在底层的线性代数库。这件事真的让我明白——AI 工程的环境构建不是“一次性的准备动作”,它是整个项目里必须持续维护的基础设施。

3. 用最笨的代码搭数据管道:从解析到清洗都不假手于人

3.1 拒绝直接用现成数据集

很多学习项目首选是 Kaggle 上已经整理好的 CSV 文件,但我故意避开了这条路。我从网上找了一个非常混乱的原始数据源,它是一个第三方平台爬下来的用户行为日志,里面包含拼写错误、缺失字段、格式不统一、时间戳来自不同时区等等问题。这个决定让我后面对“真实数据”有了非常具象的认知——真实世界的数据从来不是整齐的。

这既然是“from scratch”项目,我坚持自己写数据解析逻辑。举个例子,数据源返回的是多行的 JSON Lines 格式,但部分行是破损的。pandas 的read_json遇到这种情况通常直接抛出异常。解决办法是逐行读取,尝试解析,解析失败的先搁置到一个 error bucket。实现起来也不复杂:

import json from pathlib import Path def load_jsonl_with_tolerance(file_path: Path, error_bucket: list): valid_records = [] with open(file_path, 'r', encoding='utf-8') as f: for line_num, line in enumerate(f, 1): line = line.strip() if not line: continue try: record = json.loads(line) valid_records.append(record) except json.JSONDecodeError as e: error_bucket.append((line_num, line[:200], str(e))) return valid_records, error_bucket

这段代码当然不算聪明,但它提供了“逐行可追踪”的能力。之后我又写了一个独立的离线脚本对 error bucket 做统计分析,看出错集中在哪些行、哪些字段,再去源头排查。你会发现,这个笨办法比用一个大而全的库一次性加载后dropna(),要更接近真实工程的诉求:你可视化地知道每一步丢了什么、为什么丢。

3.2 数据库选型的思考

数据清洗完不能总是放在 CSV 里,需要有一个本地存储层。我当时在 SQLite 和 DuckDB 之间犹豫了很久,最后还是选了 SQLite。虽然 DuckDB 在分析查询上性能更好,但 SQLite 足够简单、稳定,而且没有混淆“工程问题”和“性能问题”。

我建立了几张核心表:raw_events存储原始日志,clean_events存储清洗后的数据,feature_store存储特征计算的中间结果。理念也很直白:原始层、清洗层、特征层必须严格分离,任何下游任务都只能读清洗层和特征层,不能碰原始层。

这个设计让我后续的调试变得异常顺畅。当发现模型效果异常时,我可以逐步回溯数据是在哪一层出了偏差。如果你从一开始就把所有中间结果都混在一起,这种回溯能力根本不存在。

3.3 清洗规则里的“领域知识”远比“代码技巧”重要

有一类问题是我花了很多时间才意识到的:清洗规则本质上不是你代码写得多漂亮,而是你对业务场景的理解足够深。比如我处理的那份用户行为日志,里面有一个字段表示“用户在上一个页面的停留时长”,它出现了大量-1,一开始我以为是缺失值的占位符,后来才通过查看原始日志的上下文,发现-1其实是一种合法的状态标志,表示“用户通过外部链接直接进入,没有上一个页面”。

如果直接做缺失值填充,等于把一部分真实信息给抹掉了。这种理解层面的门槛,库函数是帮不了你的。所以我想说的是,真正难的数据工程,不是你会不会用各种数据框架,而是你是否能敏锐地识别“数据里藏着什么样的业务状态”。这里我的建议是:拿到任何数据,不要急着写代码清洗,先花足够多的时间做探索性分析,打印出异常值的取值分布、频率、以及和上下文的关联。

4. 特征工程和模型训练的实战复盘:让模型先“笨笨地跑通”,再“聪明地优化”

4.1 第一版模型:故意不优化,只求闭环

很多做项目的人容易一下子陷入某个环节的“完美主义”。比如有人会花一周做特征,也有人会花一周调参。我给自己立的规矩是:第一版模型必须用一个最简单的方法(比如逻辑回归或小型决策树),在三天内跑通整个链路。为什么这么设置?因为工程是要看到“系统运转起来”才能继续讨论优化。你不可能在零闭环的基础上评估任何环节的好坏。

当我把第一版逻辑回归跑通后,精确实的 baseline 就已经固化了。之后所有的改进,包括换模型、做特征选择、调超参数,都拿这个 baseline 做对比。这很重要——没有 baseline,所有的“优化”都是自说自话。

我的第一版 pipeline 大体是这样的:

  1. 从 SQLite 清洗层读出事件数据
  2. 时间窗口聚合生成基础特征(计数、均值、最近一次行为距离当前时长)
  3. 用逻辑回归做用户分类
  4. 输出准确率、精确率、召回率
  5. 把模型权重序列化保存

这个流程非常粗糙,但关键是它完整地走了一圈。我明确记得那个时刻的真实感觉:看着终端里打印出第一版评估指标,突然意识到“从数据到模型再到结果”这条链路已经真正打通了。

4.2 “白盒优先”的训练策略

我在训练模型时给自己加了一个硬性要求:每一次训练都必须产出模型的可解释性报告。逻辑回归阶段很容易,可以打印权重系数。后来切换到 Tree 模型,我就必须输出特征重要性。

这个“白盒优先”策略的直接好处是:你能在第一时间发现特征泄漏。我第一次做特征重要性分析时,发现排名第一的特征是“目标变量的滞后值”,准确率飙升到了接近完美。表面看是模型效果极好,差点就准备上线了。但仔细思考后我意识到,这个特征只有在事后才能得到,在真实预测场景中根本无法获取。这种泄漏问题,如果你只看准确率数字,你几乎永远不会发现。

所以,这里给我的深刻教训是:AI 工程的评估不只是看一个结果数字,而是必须把“结果为什么好/坏”作为第一优先级。换句话说,模型的黑箱可以存在,但工程师不能黑箱化自己的思考过程。

4.3 超参数调优的“机制性理解”碾压“暴力搜索”

关于调参,我的观点可能跟主流不太一样。我认为你用 Grid Search 或者 Random Search 找出来一组参数,如果不能解释“为什么这些参数在这个数据集上有效”,那这个调参过程的意义就非常有限。因为换一批数据,这套参数可能完全失效。

我拿我项目的决策树模型举个例子。当我调整max_depth时,我观察到深度从 4 增加到 8,训练集准确率上升很多,但验证集准确率开始下降——这是一个典型的过拟合信号。于是我不再继续增大深度,并且反过来设置了min_samples_split的最小值来抑制叶子节点的进一步分裂。这个过程的关键不是“找到最优参数”,而是通过调参的过程去验证你对模型偏差-方差权衡的理解。这就是“机制性理解”和“暴力搜索”最大的差别。

5. 部署与监控:AI 工程真正拉开差距的地方

5.1 一次痛苦的上线经历教会我的事

项目的第一个模型在测试环境表现完美,但上线后的第三天,整体准确率突然下降。我当时的第一反应是模型出了问题,于是重启服务、回滚代码、检查硬件,都没找到原因。最后查了半天才发现:上游数据源的字段格式偷偷改了,一个数值型字段变成了带单位后缀的字符串,而我们的特征解析代码直接将其忽略。

这个经历给我留下一个非常深的教训——“上线”不是一个动作,而是一个持续的过程。模型的服务化部署只是开始,更重要的是一套完整的监控体系。你不仅需要监控服务器 CPU、内存,还需要监控模型的输入分布、预测分布、特征缺失率,一句话,你必须在模型的“生命周期”里盯住数据的变化。

5.2 用 FastAPI 构建轻量推理服务

在服务化方面,我没有选择 Serverless 或复杂的微服务框架,而是用 FastAPI 写了一个轻量级推理接口。选择它有几个原因:

  • 原生异步支持,性能足够跑中小流量的推理
  • 自带 OpenAPI 文档,调试方便
  • 结合 Pydantic 做请求参数校验,能直接把脏请求挡在外面

一个要点是,推理接口里不应该包含任何数据处理逻辑。所有的特征转换逻辑都被封装成一个独立的模块,在模型加载时一并初始化,输入进来之后直接走同一套转换逻辑。如果你把特征转换散落在请求函数里,后面做离线训练和在线推理时,特征不一致的问题就会出现。离线在线特征不一致是一个很隐蔽但杀伤力巨大的坑,它排名我在 AI 工程里遇到过的“最隐蔽的 bug”前三名。

训练时的习惯是:所有特征计算逻辑单独抽成一个特征工程库,训练时调用,推理时也调用,两端必须使用同一版本。我的做法是把这个特征库按版本号打成一个独立 Python 包,严格要求训练环境和推理环境安装的版本一致。这样虽然麻烦,但保证了线上预测用的特征变换方式和训练时完全一致。

5.3 模型更新策略:我为什么不直接用最新模型

另一个容易被忽视的工程问题是对模型版本的管理。很多人的直觉是“新模型效果更好就立刻替换”。但在我的实践里,直接替换风险很大,因为你无法确定新模型在哪些样本上的表现变差了,可能总体指标上升,但在某个细分群体上发生严重退化。

我采取的策略是“灰度替换 + 影子对比”。具体做法是让新模型在后台“影子模式”运行一段时间,也就是它同步接收线上请求、同步计算预测结果,但不会真正影响线上业务决策。等积累了足够的对比样本之后,再根据指标决定是否放量。这套流程听上去简单,实现起来也不难:写一个中间层,把请求同时发给新旧两个模型,然后把两者的预测结果都存入一条日志表。

这个过程让我体会到:模型迭代的本质不是追求“绝对最优”,而是追求“有把握的升级”。如果每一次模型变更都能清晰地解释哪些方面变好了、哪些方面变差了,那么这套系统就是一个工程师真正可以把控的系统。否则,即使暂时效果不错,也随时可能因为一次数据波动而翻车。

6. 完整项目管理复盘:时间投入、卡点以及如果重来会做什么

6.1 时间都花在哪里了

把整个“ai-engineering-from-scratch”项目从头到尾做完,我大约用了两个月左右的业余时间。如果按模块拆分,时间消耗大概是这样的:

阶段耗时占比主要内容
环境与基线搭建15%反复折腾 GPU 驱动、依赖管理、项目结构
数据清洗与存储设计30%解析原始日志、错误数据回溯、字段语义研究
特征工程与第一版模型20%从逻辑回归 baseline 到特征迭代
模型评估与调优15%特征泄漏排查、过拟合实验、异常分析
服务化部署与监控20%API 封装、影子模式、日志与报警

有几个地方让我比较意外:原本以为只占 5% 的环境搭建,实际占了 15% 多;原本以为要抠很久的模型调参,反而只花了 15%。这印证了一个观点:在真实的 AI 工程项目中,数据工作所耗费的时间,一般在所有环节中占比最大。

6.2 那些让我想砸键盘的卡点

回顾整个过程,最痛苦的一件事发生在数据处理阶段的某天晚上。由于原始日志里时间戳来自不同时区,而我一开始没注意到,直接把所有字符串当成 UTC 格式解析了,导致“时间间隔”特征大面积出现负数,模型训练出来的权重也是歪的。这个 bug 其实只要在清洗代码里加一行时区转换就能避免,但我因为没有做足够的抽样检查,白白浪费了一周。

这件事后来让我养成一个习惯:每完成一个阶段的数据处理,都要抽出少量样本做人工检查,比如打印出最大值、最小值、均值、异常值比例。数据质量检查的成本其实远低于事后排查的成本,但很多人(包括我自己)总是在吃过亏之后才明白这个道理。

6.3 如果重新开始,我唯一会大幅改变的事

如果让我重新开始这个项目,我唯一会大幅改变的,是在项目启动的第一周就引入“端到端骨架”。什么意思?就是不要先花一个月把数据处理、特征工程做到完美再开始跑模型。更好的做法是:第一周就用最简单的逻辑回归、几个手工构造的粗糙特征,跑出一个完整可用的、能出预测结果的端到端链条。即使准确率只有 60%,资产也清晰存在。后面做的所有事,都基于这条“已有链路”进行迭代式改进。

我当初是反过来的,花了大量时间把数据基础设施打磨到自认为“满意”,才开始接模型。结果在模型训练阶段意外发现,某些数据清洗规则并不合理,导致不得不回头重新修改上游逻辑,改完数据后又要重新验证特征。这就是典型的“瀑布式开发”在 AI 项目里的反面教材。在数据、特征和模型高度耦合的场景中,先打通闭环、再做精细打磨,永远是更优策略。

7. 这套方法带给我的可迁移经验

这个项目做完之后,我在自己的能力地图里增加的不只是“会训练模型”,更多的是一套如何处理未知复杂问题的思考框架。这里有几个我认为具备可迁移性的经验,值得展开聊。

第一个经验:把“不可见”变成“可见”。AI 工程里很多问题是不可见的,比如特征泄漏、数据漂移、依赖条件悄悄变化。对抗这些问题的唯一武器是可视化与日志记录。我的体会是:所有中间产物,包括清洗后的数据、特征矩阵、模型预测输出、模型性能报告,都应该被保存下来,并且具备按版本回溯的能力。你需要能看到“某个时间点上某个模型用的到底是什么特征和什么数据”,这个能力在调试问题时会节约你大量时间。

第二个经验:用“假设驱动”代替“无脑优化”。很多刚入门的人,看到模型效果不好,第一反应是换一个更强的模型或增加更多层。但我觉得,每一步优化之前,都应该先写下你的假设。例如:“我假设增加这个特征可以改善模型,因为当前的模型对于新用户的预测偏差较大,而这个特征可以直接刻画用户的新旧程度。”有了这个假设后,你再去做实验验证。如果实验结果与假设不符,你得到的反馈也很有价值——它能帮你修正对数据或模型的理解。

第三个经验:给自己设计“安全网”。在工程里,安全网是报警系统、灰度发布、回滚机制、影子模式。在学习过程中,安全网同样重要。我的做法是一切改动都从 baseline 分支出发,先保留一个稳定版本,在稳定的撑下再进行任何实验性探索。这样就算实验失败了,也可以快速回到稳定状态,而不用从头来一遍。

这些经验让我在处理 AI 之外的项目时也有了更多底气,因为它们的根基不在于某个具体工具,而在于一套思维模式:永远为不确定性留有余地,永远让过程可回溯,永远把“假设”摆到台面上检验。

最后再分享一个很小但贯穿始终的实践细节:我坚持给每个实验写一份极简的 README,记录当天的实验目的、改了什么、结果如何、下一步打算。几个月后回看这些笔记,整条思维链路清晰得令人感慨。这种感觉就像是给未来的自己留下了一份完整的地图,避免在同一条路上反复迷路。如果你也想走一遍类似的项目,不用急着追求多复杂的模型,试着从最朴素的方式开始,把链路完整跑通,你学到的东西会比想象中多得多。

返回列表