☰
AI工程从零搭建:可复现、可追踪的完整实践
2026/9/28 13:53:11 网站建设 项目流程

做 AI 工程,我最怕听到一句话:“我在 Notebook 里已经跑通了,接下来只要搬上线就行。”

这个说法坑过我不止一次,也坑过我带过的不少新人。跑通一条训练脚本,和交付一个 AI 工程,中间隔着环境复现、数据版本、实验记录、服务封装、监控回溯一大堆事。这也是我为什么坚持把手上这个内部项目命名为ai-engineering-from-scratch——不靠现成全流程平台,不靠 Copilot 自动生成的训练模版,而是自己从零把整条链路搭一遍。

这个项目的前身其实是个很常规的任务:帮团队做一个销售预测模型。数据量不大,特征也谈不上复杂,任何一个熟悉 sklearn 的人花两周都能交差。但问题恰恰出在“交差”这两个字上。模型是跑通了,可三个月后另一名同事接手,先花两天装环境,再花一天找数据版本,又因为原来的脚本里写死了相对路径,推理端调用时特征顺序对不上,直接预测出一堆负数。于是我把项目推倒重来,目标是:一个人能从零开始把整条链路搭出来,也能让任何陌生人在一台干净机器上快速复现结果。

这篇文章就是我复盘之后的完整记录。适合正在从“调参跑脚本”往“正经做 AI 工程”方向过渡的同学,也适合小团队里需要一个人扛起算法加开发的工程师。我不写大而全的架构,只想把每一步为什么这样设计、踩过哪些坑、最终怎么落地讲清楚。

1. 为什么我认认真真选择了“从零开始”的路线

1.1 从零开始不等于造轮子,它更像是把地基挖开看一遍

很多人一听“从零开始”,第一反应是拒绝:框架不香吗?现成平台不好用吗?没必要重复造轮子吧。

这个反应没问题,但没有区分“从零实现算法”和“从零搭建工程链路”。前者我完全反对,如今深度学习框架已经足够成熟,自己手写反向传播除了练手没有工程意义。后者则是另一回事:用现成平台虽然省事,但你对内部机制的理解会停留在“按钮能点通”的程度。一旦平台升级、某个组件不再维护、或者你被要求部署到另一个受限环境里,整个项目直接变成黑箱。

我在这个项目里所谓的“从零开始”,指的是不依赖任何模型训练平台、不依赖某个全家桶式解决方案,而是自己组装开源组件,把关键环节用一套轻量代码衔接起来。这样做初期确实慢,但每一条依赖、每一个缓存、每一行配置我都心里有数。就像自己装修一套老房子,拆墙重砌的时候确实费劲,但水电管线在哪、哪堵墙是承重墙,你会比请装修公司的人清楚得多。

1.2 什么时候不该从零开始?哪些模块可以直接借用

如果我只强调“一切自研”,那就成了另一种教条。事实上,这个项目里我借用了大量成熟的底层库,PyTorch 做模型训练,FastAPI 做服务暴露,uv 管环境,pytest 做测试。这些都是市面上久经考验的组件,我用它们,不代表违背“从零开始”的初衷。

真正的边界是:凡是“通用能力”都尽量复用,凡是“项目独有的流程逻辑”尽量自己写。通用能力包括依赖管理、网络通信、单元测试框架、深度学习算子;项目独有逻辑包括数据切分、实验命名规范、模型配置加载、指标口径、服务输入验证。把这些区分开,你才不会陷入“造轮子”和“踩平台黑箱”两个极端。

再举个例子。特征工程部分,我一开始也想直接套用某个特征平台,后来发现我们的特征更新频率低、总量也不大,引入一个独立平台纯属增加运维成本。于是我用一个简单的特征配置文件加一段缓存脚本自己维护,反而一个月跑下来都稳定。此后我坚定了一个准则:在这个量级,别为不存在的规模买单。

1.3 这个项目的“最小目标”:一条可以被陌生人接手复现的流水线

在正式写代码前,我给自己定了一个验收标准,不是“模型准确率多高”,而是下面这几条:

  • 新同事拿到仓库,能照着 README 在半天内跑通训练脚本。
  • 训练产物(模型文件、指标、日志)能从磁盘完整找回来,知道是哪份数据、哪次实验产生的。
  • 服务上线后,能对在线请求做输入校验,能区分“模型判断错了”和“输入根本不是预期格式”。

这三条都达成,我才认为这个项目配得上“AI 工程”四个字。模型精度是结果,工程能力是保障。很多团队只盯着前者,最后吃亏的往往是后者。

2. 项目基础设施:环境、依赖和“可复现性”

2.1 环境管理:我为什么从 conda 换到了 uv

一开始我也随大流用 conda,毕竟教程里到处都是。但几轮下来,conda 最让我头疼的点有两个:环境解析速度慢,以及它对项目依赖的显式化支持不够干净。我经常遇到这种场景:别人给我的 environment.yml 里明明写着 python=3.9,但我装完一跑就缺某个包,原因是他当时在 base 环境里多装了一些包,直接 pip install 进了当前环境,却忘记写进配置文件。

后来我换成了 uv。这个工具最大的价值在于它把依赖声明和锁文件分得清清楚楚:pyproject.toml 声明你直接依赖什么,uv.lock 则锁住所有传递依赖的具体版本。有了这层保证,“环境一模一样”从口号变成了可验证的事实。

uv venv .venv uv pip install -e ".[dev]" uv pip compile pyproject.toml -o requirements.lock

我习惯在项目里同时维护三份文件:

  • pyproject.toml:声明顶层依赖,标记哪些是核心依赖、哪些是 dev 依赖。
  • requirements.lock:锁定完整依赖树,直接传递给他人或 CI 使用。
  • requirements.in(可选):如果团队更熟悉传统 pip 流程,可以保留这个入口。

如果你已经在用 conda,也不一定非要立刻迁到 uv。我想说的核心其实是:无论用什么工具,依赖的“声明”和“锁定”必须分成两个动作。否则环境问题迟早会变成吞时间的无底洞。

2.2 锁依赖:requirements.lock 才是工程第一安全网

我第一次被项目坑,就是死在没有锁依赖上。同一个项目,一周前还能正常训练,一周后重新跑训练,PyTorch 的一个小版本自动升级了,损失函数数值出现细微抖动,导致我一度以为是模型出了 bug,排查了两天才意识到是底层算子精度变了。

这些教训让我对“依赖锁定”近乎偏执。每次跑正式实验前,我都会把依赖树完整导出一份,和代码版本绑定。这在后来的问题排查中帮我省了大量时间。

# 把当前环境依赖完整导出 uv pip freeze > requirements.lock

你可能觉得 lock 文件又长又乱,看不出来有什么意义。但它最大的价值不是给人读的,而是给环境构建时用的。别人拿到 lock 文件,可以原样安装,而不是从一个模糊的 home 环境里 pip install 一个版本不确定的包。

再递进一层,如果你的项目里用到 CUDA 相关的底层库,光锁 Python 包还不够,还要把 CUDA 运行时版本、驱动兼容性写进说明文档。环境问题最常见的事故,往往不在于 Python 包缺失,而是底层原生库不匹配。

2.3 用 Makefile 把高频命令变成肌肉记忆

刚开始我自己也是手动敲命令:uv sync、python -m src.data.prepare、python -m src.train配置文件更新后又要再敲一遍。这些命令一多,就会出现一个人敲错、另一个人照着他敲错的情况。

所以我给项目加了一个 Makefile,把高频流程收敛成几个固定目标。不需要解释,看到目录就知道能跑什么。

PYTHON := .venv/bin/python .PHONY: setup data train eval serve test lint setup: uv sync data: $(PYTHON) -m src.data.prepare --config configs/data.yaml train: $(PYTHON) -m src.train --config configs/experiment.yaml eval: $(PYTHON) -m src.evaluate --config configs/experiment.yaml serve: $(PYTHON) -m src.serve test: $(PYTHON) -m pytest tests/ -q lint: $(PYTHON) -m ruff check src/ tests/

Makefile 的好处不只是给你一个统一入口,它还把命令里的参数、路径这些隐式知识变成了显式约定。新人不用去猜“数据准备脚本在哪”,直接 make data 就行。而且 Makefile 可以顺便校验一些前置条件,例如在 train 之前检查 data 目录是否存在,否则直接报错,省去运行时才爆出“文件不存在”的尴尬。

2.4 把 Docker 拉进来之前,先让本地构建能三次成功

容器化是环境复现的终极手段,但它没法替代依赖管理。如果你本地连三遍安装都无法成功,Docker 只能帮你把这个“不稳定”的问题复制到每一个环境里。

我的实测建议是分两步走:先在开发机上把 uv、锁文件、Makefile 这套跑顺,然后再把同样的步骤搬进 Dockerfile。这样构建失败时,你至少能把问题定位到“容器环境特有问题”和“项目配置问题”这两个不同的区间,而不是一锅乱炖。

这里还涉及一个细节:不要在 Dockerfile 里使用通配符 COPY 整个项目目录。更好的是先只 COPY pyproject.toml 和 requirements.lock,把依赖层构建好、缓存住,再 COPY 代码。这样只要依赖没变,镜像重建就不会每次都重新装包,构建速度和缓存命中率都会大幅提升。

3. 数据工程部分:从“有一份 CSV”到“数据集版本”的跨越

3.1 数据清单:文件指纹、源、版本、修改时间

很多工程师在“数据”上有个错觉:我认为数据不就是 CSV 吗,Excel 打开看一眼,能读就完了。但一旦进入工程化,数据文件至少需要四个属性:来源、指纹、版本、修改时间。

来源包含原始文件路径、下载链接、上游系统表名。指纹是数据的唯一标识,通常是文件内容的哈希值。版本是业务意义上的划分,比如 2024-06-v1。修改时间是操作时间轴上的一道标记,也是排查数据异常时的重要线索。

我当时踩过一个大坑:分析师直接在本地 Excel 里改了一列数据,没有更新版本号,也没有修改描述。我们把那一版跑出来的模型上线后,指标一直不达预期,后来才发现是因为特征数据口径变了,但大家都浑然不知。从那时起,我就立了一条规矩:任何数据变更必须伴随指纹变化,任何模型实验必须记录所用数据的指纹。

3.2 写一个轻量数据版本文件(manifest)

流行的做法是引入 DVC 之类的数据版本管理库。如果你团队已经熟悉它,完全可以用。但我在这个项目里决定自己写一个轻量 manifest,原因是对数据大小、平台依赖和团队学习成本做了一次平衡。

所谓 manifest,其实就是一个 JSON 或 YAML 文件,记录每个数据文件的详细信息。核心代码很短,但效果很直接。

import hashlib import json from pathlib import Path def file_md5(path: Path) -> str: h = hashlib.md5() with path.open("rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return h.hexdigest() def build_manifest(root: Path, patterns: list[str]) -> dict: records = [] for p in root.rglob("*.csv"): if p.name.endswith(patterns) or not patterns: records.append({ "path": str(p.relative_to(root)), "md5": file_md5(p), "bytes": p.stat().st_size, "modified": p.stat().st_mtime, }) return {"generated_at": __import__("datetime").datetime.now().isoformat(), "files": records}

数据准备脚本每跑一次,就重新生成一遍 manifest,并且把它复制到名为 artifacts/metadata/ 的目录中。训练脚本在启动时先加载这个 manifest,校验所有用到的文件指纹是否一致。不一致就拒绝训练。这样数据被误改时,你永远不会在训练到一半时才发现问题。

3.3 训练/验证/测试切分要做的三件事

切分数据看起来简单,但工程化切分和 Notebook 里的随机打乱完全不同。我在项目里坚持做到三件事。

第一,切分必须基于一个稳定键,保证同一用户、同一订单不会同时出现在训练集和验证集。业务上这叫防数据泄漏。大部分模型在验证集上指标虚高,往往就是因为切分时忽略了实体级别的重复。例如销售预测里,同一天同一个 SKU 销售记录会被随机切到两边,模型相当于直接背答案了。

第二,切分结果要持久化。不是每次准备数据时现场打乱,而是先按规则生成一份切分映射文件,后续训练、验证、服务都用同一份映射。这样复现实验时才不会因为随机种子差异导致结论不可比。

第三,切分必须产出三条独立数据路径,并且对路径成文。我在项目里用一个 data/processed/ 目录存放最终产物,里面固定有 train.parquet、val.parquet、test.parquet,而原始数据放在 data/raw/ 里。这样任何人接手时,不需要靠记忆去猜哪个是清洗后的数据。

3.4 用 datasets 库还是自己写加载器?我的取舍

Hugging Face 的 datasets 库功能很强,支持缓存、流式读取、转换。但用在业务型表格数据上,反而有一点重。尤其是当你只需要按列读取、处理时间特征、做 groupby 操作时,datasets 的抽象层级会让人觉得被框架带着走。

我在这个项目里用 pandas 加 pydantic 做数据加载。pydantic 不直接加载数据,而是用来定义字段约束和类型校验。例如一个销售记录,日期列必须解析成 datetime,金额列必须大于等于零,类目列缺失时默认 unknown。这样在数据进入特征工程之前,就已经被强制清洗过一轮。

from pydantic import BaseModel, field_validator from datetime import datetime from typing import Optional class SalesRecord(BaseModel): shop_id: str sku_id: str date: datetime amount: float category: Optional[str] = "unknown" @field_validator("amount") @classmethod def amount_positive(cls, v: float) -> float: if v < 0: raise ValueError("amount cannot be negative") return v

数据加载器读入原始 CSV 后,逐行转成这个模型,校验失败的行直接进错误清单而不是悄悄丢弃。这个设计最初是为了防止脏数据累积,但后来发现它还有一个附带好处:能让数据分析师和算法工程师之间少吵很多架。双方看到同一份“哪些行不合法”的清单,问题就成了标准问题,而不是“你代码有 bug”。

4. 训练 pipeline:把模型脚本变成可调度任务

4.1 配置驱动训练:dataclass 与 yaml 的边界

训练脚本如果全是硬编码参数,比如在代码里写lr = 1e-3、batch_size = 32、hidden_size = 64,那每做一次实验都要改代码、提交版本、再跑。这会让实验变得不可追溯:你跑了十次实验,每次代码都不一样,哪个版本的代码对应哪次结果,压根说不清楚。

我在项目里采用“YAML 配置文件 + dataclass 配置对象”的组合方案。YAML 负责人类可读的参数修改,dataclass 负责把 YAML 转成运行时强类型对象。配置文件的路径本身作为训练命令的参数传入。

from dataclasses import dataclass, field from typing import Optional @dataclass class TrainConfig: data_manifest: str = "artifacts/metadata/manifest.json" split_file: str = "artifacts/metadata/split.json" model_name: str = "lightgbm" learning_rate: float = 1e-2 max_depth: int = 6 n_estimators: int = 300 batch_size: int = 256 epochs: int = 10 seed: int = 42 experiment_name: str = "baseline"

实际加载时,我实现了一个简单的配置加载函数,把 YAML 内容与 dataclass 字段做映射,并检查有没有多出来的未知字段。这样能防止写错配置名,比如把learning_rate误写成learn_rate,程序会在启动阶段直接报错,而不是默默使用默认值。

4.2 训练生命周期钩子:train、eval、checkpoint、metric

真正的训练脚本不是 100 行代码全部塞进 main,而应该按生命周期分块。每个阶段有明确的输入输出,方便局部替换和单测。

我把训练流程拆成五个阶段:

  • prepare: 加载配置和数据,生成运行目录。
  • train: 执行模型训练循环,记录每一步关键指标。
  • eval: 在验证集上计算离线指标,输出对比基线。
  • checkpoint: 保存模型权重、配置快照、数据指纹。
  • report: 汇总运行信息,写入实验记录。

在代码层面,我用一个简单的 PipelineRunner 类把阶段串联起来。难点不在于串联,而在于每个阶段都能“独立失败、独立恢复”。比如训练到第 8 个 epoch 时,数据文件被同事误替换了,我的脚本应该立刻校验指纹失败并终止,而不是继续产出不可信的结果。

def run_stage(stage: str, config: TrainConfig) -> None: if stage == "prepare": prepare_run(config) elif stage == "train": run_train(config) elif stage == "eval": run_eval(config) elif stage == "checkpoint": save_checkpoint(config) elif stage == "report": write_experiment_report(config)

这样写还有个好处:可以在 MAKEFILE 里单独调试某个阶段,比如只跑 eval,反复调整评估指标定义,而不必每次从头训练一遍。

4.3 记录实验:每一条跑出来的记录,都该能被回放

实验记录是 AI 工程里最容易被忽视的一部分。很多人习惯只看 tensorboard 的曲线,但那些曲线数据是临时的,过了几天就没了。即使没丢,你也没法从一条曲线反推出当时的完整环境。

我在这个项目里没有引入重量级实验平台,而是用一套轻量记录机制:每次训练启动时,在experiments/{experiment_name}/{run_id}/目录下写入三个文件:

  • config.yaml:本次实验用到的完整配置。
  • command.txt:启动本次实验的完整命令和 commit id。
  • metrics.json:验证集指标、训练时间、日志路径。

run_id 可以用时间戳加随机短码,我一般用YYYYMMDD-HHMMSS-abc这种格式。训练完成后,这套记录不会动,任何人想回放某次实验,只需用 command.txt 里的命令和 config.yaml 即可。如果业务上还需要更复杂的指标对比,再把 metrics.json 汇总进一个 sqlite 表或 pandas DataFrame。切忌小团队一开始就搭 dashboard,成本高,收益低,很容易变成没人看的装饰品。

4.4 冒烟测试:用 3 个样本跑通全流程,避免深夜翻车

很多模型脚本的第一次“崩溃”往往发生在正式训练启动后两小时,原因可能是某个参数组合、某个数据处理分支没有被覆盖。为了避免这种深夜翻车,我在项目里加入了冒烟测试。

冒烟测试的思路很简单:把完整流程的数据量缩小到几十条,跑一遍 prepare、train、eval、checkpoint,确认没有异常就通过。这不是性能测试,只是“流程连通性测试”。

# tests/test_smoke_training.py def test_training_pipeline_on_small_data(tmp_path): config = TrainConfig( data_manifest=str(tmp_path / "manifest.json"), split_file=str(tmp_path / "split.json"), epochs=1, n_estimators=5, ) run_stage("prepare", config) run_stage("train", config) run_stage("eval", config) assert (tmp_path / "experiment" / "model.pkl").exists()

冒烟测试要放一个比较小的 batch size,比如 8,这样不仅验证流程,还能暴露维度错误、类型错误这类基础问题。建议把它挂到 CI 里,作为每次提交的默认检查项。这样做至少能把低级错误消灭在提交阶段,而不是留到自动训练任务里烧算力。

5. 模型服务与在线监控:把位置从“训练机”挪到“生产”

5.1 模型导出:保存为 ONNX 还是继续用 PyTorch?

模型训练完只是工程的前半段,后半段是能不能稳定地提供推理服务。导出格式的选择直接影响服务性能和部署自由度。

我的建议是分场景:如果模型最终要跑在资源受限的边缘端,或者需要跨语言调用,ONNX 是一个稳妥选择。它规范了计算图,并且推理速度通常比原始框架导出方式更快。但 ONNX 对算子兼容性有要求,某些自定义层可能不支持导出,这时候就得回到公平比较:继续用 PyTorch 原生推理,或在服务里嵌入 TorchScript。

我自己在表格类模型上更倾向 ONNX,因为它可以脱离 Python 环境,用一个 C++ 推理进程提供服务,训练和推理彻底解耦。不过在导出前,我会先写一个 golden test:固定输入,对比导出前后模型输出的差值是否在可接受范围内。这个测试能提前发现算子误差,而不是上线后让用户替你发现。

5.2 FastAPI 封装成服务:输入校验比模型本身更重要

在线推理和离线推理安全是两回事。离线测试数据是干净的,在线请求则充满噪音。缺失字段、非法字符串、奇奇怪怪的时间格式,都可能让模型推断崩溃或返回异常结果。

我在服务层直接使用 FastAPI + pydantic 做输入校验,把请求体定义成一个严格的模型。字段缺失、类型错误、数值越界都会在进入模型前被拦截。

from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field app = FastAPI(title="sales-forecast-api", version="0.1.0") class PredictRequest(BaseModel): shop_id: str = Field(..., min_length=1, description="店铺ID不能为空") sku_id: str = Field(..., min_length=1) date: str = Field(..., pattern=r"^\d{4}-\d{2}-\d{2}$") amount: float = Field(..., ge=0) @app.post("/predict") def predict(req: PredictRequest): try: features = featurize(req) pred = model.predict(features) return {"pred": pred} except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc))

输入校验容易给模型“额外安全感”,但别误以为它是唯一的保护。真正上线前,我还会用一个小规模的影子流量来验证服务路径。做法很简单:把线上请求复制一份,转发到新服务,只记录预测结果,不影响真实线上逻辑。等影子结果和旧服务结果对比稳定后再切换流量。

5.3 在线监控三件套:延迟、输入分布、预测漂移

模型上线后真正需要盯的不是“准确率”,而是三件事:响应延迟、输入分布、预测漂移。

响应延迟是直接的用户体感指标,如果 p95 超过服务承诺的上限,说明需要优化模型推理或增加并发处理。输入分布监控则是看请求字段的统计量是否发生突变。比如平时 date 字段都是近 30 天的日期,突然出现大量 90 天前的日期,说明上游业务逻辑变了,模型预测很可能会失真。

预测漂移相对复杂。一个简单做法是维护“近期预测均值”窗口,用 EWMA 或简单滑动平均记录,一旦偏离基线超过阈值就报警。不一定要用复杂的统计检验,很多业务问题用均值、分位数加阈值就足够暴露。

监控和日志要联合设计。每条预测记录除了输出结果,还要附带模型版本号、特征哈希、请求接收时间。对故障排查来说,模型版本号的缺失会让线上问题根本无法定位。这个坑我踩过:线上模型更新了,但日志里没有任何标记,过了两周才发现有一半流量还在走旧模型。

6. 常见问题与排查技巧实录

6.1 复现失败:为什么换一台机器结果就变了一个模样

这是最经典的问题。很多人第一反应是代码问题,但我建议按优先级排查四个地方:依赖版本、资源限制、随机种子、数据读取顺序。

依赖版本最容易锁定,用 requirements.lock 对比即可。资源限制指 CPU 核数、内存、显存差异。这些参数会影响 PyTorch DataLoader 的并发行为,可能改变批次顺序,进而影响训练结果。随机种子的话,需要在所有涉及随机数的环节显式设置 torch、numpy、random,并且确保在分布式环境下每个 worker 的种子设置也有差异。

数据读取顺序其实比想象中更隐蔽。如果数据文件是 dict 形式遍历的,Python 版本不同可能导致字典排序不同,从而影响训练集顺序。这个坑我遇到过,最后用固定字段排序解决。

6.2 训练卡住:如何区分死锁、缺数据和寻优震荡

训练卡住不一定是死锁,症状不同,排查路径完全不同。

如果 loss 一直不动,且 GPU 利用率很高,可能是模型在某个数值区间反复震荡。此时观察学习率是否过大、梯度是否消失。如果 GPU 利用率很低,CPU 时间却很长,多半是数据加载瓶颈,重点关注 DataLoader 的 num_workers 和 prefetch 设置。如果进程直接阻塞,微信群里怎么叫都没反应,那可能是死锁,比如多个 dataloader 在共享内存或锁文件上互相等待。

我自己的排查顺序很简单:先看 CPU 和 IO 曲线,再看 GPU 利用率,最后才动模型代码。绝大多数“训练卡住”都不是模型本身的问题,而是数据管线。

6.3 内存爆掉:dataloader 和缓存设计引起的连锁反应

内存爆掉有时是模型过大,但更多时候是数据预处理惹的祸。我在一个项目里把整个训练集一次性读进内存做特征工程,跑一次是 8GB,当时觉得没问题。后来数据集翻倍,服务器直接 OOM。那之后我改成流式加载:训练过程只保留当前 batch 的特征,中间特征缓存按需落盘。

引发内存爆炸的另一个原因是缓存设计不合理。比如在 dataloader 的__getitem__里做全量特征拼接,虽然看起来优雅,但每个 worker 都持有一份副本,并发一多内存就爆。把通用的预处理抽到离线阶段,训练阶段只做加载和 batch 组装,更有利于规模扩展。

6.4 服务上线即失败:请求路径里最容易被忽略的隐式转换

FastAPI 启动很快,但服务上线时经常出现“离线测试好好的,上线就报错”这种魔幻情况。排查后发现,多数原因都集中在隐式类型转换和特征顺序上。

举例:模型训练时特征顺序是[shop_id编码, sku_id编码, date_ordinal, amount],但服务端构造特征时把 date_ordinal 放在了最后。模型表面看输入维度没变,但实际上每一列含义错位,预测结果自然不可用。这种问题最稳妥的解法是给训练和推理共用同一个特征编码脚本,而不是各写一份。我工程上一直遵守单一事实来源原则,特征编码逻辑只允许存在一份,训练和服务都调用它。

6.5 检查清单速查表

检查对象关键问题推荐工具/做法
环境依赖能否用锁文件在新机器复现uv + requirements.lock
数据版本数据文件是否被悄悄修改manifest + 文件指纹
训练脚本是否配置驱动且可回放YAML + dataclass + run_id
模型导出导出前后输出是否一致golden test
服务输入非法请求是否被提前拦截FastAPI + pydantic
线上监控是否能定位问题请求日志带版本号 + 输入/预测监控

这张表基本就是我在每个 AI 工程开始前会贴在工位上自查的清单。不需要一次全做到,但至少以它为目标,越早补齐越省事。

7. 最后想说的个人体会

从零开始搭一套 AI 工程链路,最明显的收获不是模型精度提升,而是我终于可以放心地说“这个问题我能追踪到底”。新同事接手项目,不用再靠“你跑一下试试”来摸索;线上出了异常,不用再靠玄学去猜是模型还是数据还是环境的问题。

现在我反而觉得,“从零开始”真正练的不是能力,而是判断力。你知道什么时候该自己写、什么时候该用现成组件;知道哪些环节值得花时间投资、哪些只配一个简单的脚本;也知道一个工程最怕的不是功能少,而是不可追踪、不可复现、不可解释。

如果你正打算做自己的第一个 AI 工程项目,我建议不要直接找一套全家桶平台把流程全部封装掉。先从一条最简单的数据管线开始,自己把训练脚本写出可复现的样子,把服务封装成能对外提供稳定接口的样子,再把你踩过的每一次坑记下来。这个过程会慢,但慢过之后,你对 AI 工程的理解就不再是某一个框架的使用说明,而是一套属于自己的方法论。

最后分享一个小技巧:给你的每次实验起一个“可读”的名字,别用什么 exp_1、exp_final。我在项目里用的格式是{model_name}-{feature_set}-{timestamp},例如lightgbm-v2-20250112-1530。看起来只是命名习惯,但它会让三个月后的你少掉很多头发。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询