如果让我用一个词来概括做 AI 工程最深的体会,那就是“地基”。市面上讲 AI 的教程不少,教你怎么调用现成接口的一抓一大把,可你真要拿它去解决一个实际业务问题——比如给电商平台做商品评论的意图识别,或者给内容社区做一个轻量级的推荐服务——你会发现,真正决定项目成败的,根本不是那几行模型代码,而是模型之外的一整套工程体系。数据怎么管理、训练怎么跑、模型怎么上线、上线之后怎么迭代,这些环节有一个掉链子,整个项目就卡在那里。“ai-engineering-from-scratch”想做的,就是把这套体系从头到尾走一遍。它不是高深的研究课题,而是一条务实的技术扫盲加实战路线。适合刚转行 AI、想系统性建立工程思维的人,也适合那些已经在用现成工具跑模型、但对数据管线、训练循环、部署上线这些环节仍然模糊的开发者。
1. 项目定位:AI 工程到底在解决什么问题
1.1 从标题拆解核心含义
先说说这个标题本身。“ai-engineering-from-scratch”直译过来是“从零开始做 AI 工程”。很多人一看到“from scratch”,第一反应是“要从零手写一个深度学习框架”。真要这么做,确实很硬核,但多数业务场景根本不需要。我理解的“from scratch”有两层意思:第一层,是不依赖现成的全栈解决方案,比如不直接套用某个 AutoML 平台,也不拿别人的训练代码改几个参数就交差;第二层,是所有关键环节都要亲手搭一遍,数据、训练、评测、部署、迭代,每一步都知道底层在干什么,出了问题知道去哪查。
以电商评论的意图识别为例。业务方给的需求是“把评论里用户吐槽的点自动分类,比如电池、屏幕、物流、售后”。表面看,这是典型的多分类任务,拿个预训练模型微调一下就能交差。可真做起来你会发现,光是“定义清楚什么是吐槽”这件事,就能和业务方开三次会。这还没有算上数据标注要怎么做、标注质量怎么把控、模型预测结果怎么跟人工标注对齐。AI 工程的核心不是“训练模型”,而是“交付一个可运行的、效果可衡量的系统”。
1.2 AI 工程思维与普通软件开发的区别
传统软件开发讲究的是确定性:代码逻辑是明确的,输入输出是可预测的,Bug 是可以复现和修复的。AI 工程不一样,它引入了一个“不确定的中间层”,就是模型。模型的输出不是代码逻辑直接决定的,而是由“数据分布 + 模型结构 + 训练过程”共同决定的。这就带来一个根本性的差异:你没法通过“读代码”来定位一个预测错误的原因,你必须通过“看数据”来排查。我见过太多从传统开发转过来的同事,第一反应是“模型效果不好?那我看看模型的代码哪里写错了”,盯着 loss 函数看半天,其实问题出在训练数据里有大量重复样本,验证集和训练集有重叠。
另一个区别是迭代方式。普通软件上线后,只要不出 Bug 就是稳定的。AI 系统上线后,效果一定会随着线上数据分布的变化而衰减,这不是 Bug,是常态。所以 AI 工程必须内置一套反馈闭环:线上日志怎么埋点、预测结果怎么回流、新数据怎么定期评估、模型怎么重训。这套闭环做得越顺,系统就越健康;做得越随意,模型就会一步步变成废铁。
1.3 项目的目标拆解与范围界定
任何工程都怕“什么都想做”。做这个项目的时候,我给自己划定的边界非常明确:不做研究,不做平台,只做一条能跑的端到端管线,而且一定要小。我选的是“电商评论意图识别 + 轻量级分类上线”,数据规模控制在几万条以内,模型用中小规模的预训练模型做微调,部署端用一个轻量级 HTTP 服务。为什么选这么小的范围?因为只有范围小,你才能在有限的时间里把每个环节都做扎实。管线拉得太长,最后一个环节还没来得及跑通,热情已经消耗光了。
目标拆下来大概是这样的:
| 环节 | 交付物 | 验收标准 |
|---|---|---|
| 数据工程 | 清洗后的数据集 + 标注规范 | 标注一致性达到 85% 以上,无数据泄漏 |
| 模型训练 | 微调后的分类模型 | 验证集 F1 达到 85 分以上 |
| 评测分析 | 分类报告 + 错误样本分析 | 每个错误类别有明确的改进建议 |
| 上线部署 | 封装好的推理服务 | 单次预测延迟 < 100ms,接口可压测 |
| 迭代机制 | 数据回流与模型重训脚本 | 新数据可一键重训并生成新版本 |
这套拆解方法可以复用到任何 AI 项目里。不要一开始就盯“最新最好的模型”,先盯“交付链路里最弱的一环”,把它补齐,再往前走。
2. 核心细节解析:数据、模型、训练的三厢马车
2.1 数据工程——AI 项目里最容易被低估的部分
业内有个共识:数据和特征决定了效果的上限,模型和算法只是去逼近这个上限。这话放在 AI 工程里尤其准确。我见到很多项目训练阶段报错,到最后发现原因很简单:数据格式不对、标签搞错了、时间切分有泄漏。你要是把这些问题排查清楚,往往能省下两倍于调模型的时间。
做数据工程,第一步是采集。评论数据从哪来?内部业务库、爬虫、第三方购买,都可以。采集的关键不是“越多越好”,而是“来源是否覆盖你预期的场景”。比如你做电商评论,必须覆盖多个类目、多个评分段、多个时间区间,否则模型很容易做成“罔顾事实的偏科生”。第二步是清洗,这一步最容易被跳过去。评论数据里通常有大量 HTML 标签、表情符号、重复字符,这类噪声会直接影响模型效果。清洗的原则是:能用一个确定性规则解决的,就不要留给模型去学。比如把“&”还原成“&”,这部分用正则一行就能解决,没必要耗费模型容量。
标注环节是最考验工程管理能力的地方。你以为标注就是“找人点点鼠标”?大错特错。同一个评论,不同标注员对“是否吐槽”可能有完全不同的判断。比如“价格比隔壁贵,但是质量好,总体来说还是满意的”,这句话到底算不算吐槽价格?所以必须提前写一份标注规范,把分类的边界定义清楚,把有分歧的 case 一次性讨论清楚。我常用的一个技巧是:先找 50 条典型样本让标注员试标,然后统计一致性,有分歧的部分直接开会讨论,形成制度,再进行大规模标注。这么做能显著提高后续模型的稳定性和可解释性。
2.2 模型选型——从基线到迭代的演进路径
模型部分,最容易犯的错误是一上来就用大模型。不是大模型不行,而是你不理解它为什么行。AI 工程有一个非常务实的建议:先做一个最简单的规则基线,再做一个统计基线,最后才上深度模型。规则基线最简单,比如“包含‘电池’和‘差’两个词,就判为电池吐槽”。它的意义不是效果好,而是验证数据标签的合理性——如果规则能到 60 分,说明数据本身有规律;如果规则连 40 分都不到,你得想想是不是标签体系出了问题。
统计基线可以用 TF-IDF + 逻辑回归,这套组合训练速度快、可解释性强,而且能非常直观地告诉你哪些词对分类最有贡献。我记得自己跑 TF-IDF + 逻辑回归的时候,发现模型特别看重“建议”“希望”这类词,去看了数据才发现,很多负向评论会写“建议加强品控”“希望电池能耐用一些”,这类表达其实非常值得业务方关注。这一步的发现,在后面做模型误差分析时给了我很大帮助。
深度模型是最后上场的。我的建议是用预训练模型做微调(比如中文场景下的中小规模预训练模型)。预训练模型的优势在于它已经学到了大量语言知识,微调只需要适配你的任务。这里有个关键点:模型结构可以复用,但数据处理必须自己来。数据和模型是分开的,你不能指望预训练模型的 tokenizer 帮你完成业务级的清洗。我的实操顺序是:先用自己的清洗规则跑数据,再交给 tokenizer 编码,最后才进模型。每一步都单独测试,确保没有隐性坑。
2.3 训练管线——让模型真正学起来的关键细节
训练管线是 AI 工程里最“玄学”的部分,很多问题看似是代码问题,其实是训练配置问题。我拆开说。
损失函数的选择不要想当然。多分类任务默认用交叉熵,这没毛病。但你要分清是“单标签多分类”还是“多标签多分类”,前者用 Softmax + CrossEntropyLoss,后者要用 Sigmoid + BCEWithLogitsLoss。这两个混淆是初学者最高频的错误之一。另外一个容易忽略的细节是类别不平衡。如果“电池吐槽”有 2 万条,“包装吐槽”只有 300 条,模型很容易把所有样本都预测成大类。解决办法有很多:最简单的就是调整损失里的类别权重 class_weight,让稀有小类被误判的时候付出更大的代价;也可以用阈值调整,在预测时对大类的判定阈值卡得更严一点。我建议先试 class_weight,它改动最小、效果最直接。
优化器和学习率是第二个高频踩坑区。Adam 确实是最稳妥的默认选择,但不代表它不需要调参。学习率设太大,loss 会直接爆掉;设太小,模型长时间不收敛。我习惯的做法是:先用一个较小的 batch(比如 16或32),跑 3-5 个 step 之后看 loss 曲线的下降速率,如果 loss 在第一个 step 就飞涨,说明学习率至少大了 10 倍;如果 100 个 step 之后 loss 几乎不变,说明学习率可以上调 3-5 倍。这个“快速试探法”比盲目搜索高效得多。
训练管线里还有个很不起眼但极其重要的细节:数据顺序。不要以为 dataset shuffle 是随便写写的事。在训练阶段,shuffle 能打乱样本顺序,避免模型学到顺序相关的假模式;在验证阶段,千万不要 shuffle,因为要保证评测的一致性。更要注意的是时间序列类任务,如果评论按时间排序,而训练集和验证集是从整体里随机切分的,那就会发生典型的数据泄漏——模型看到了“未来的信息”。我见过不止一次,验证集 F1 高得离谱,线上效果惨不忍睹,最后才发现是切分时没按时间切。
3. 实操记录:从零搭建一个能跑通的系统
3.1 环境准备与项目结构设计
环境这块,我强烈建议用虚拟环境管理依赖,不要直接把包装进全局 Python。具体用 venv、poetry 还是 uv 都行,关键是依赖版本要锁定。AI 项目里最怕的就是“在我机器上是好的”,而版本不一致是最常见的原因。一个可复现的项目,必须把 pyproject.toml 或 requirements.txt 的版本锁定信息提交到代码仓库里。
项目结构我建议按功能域划分,而不是按脚本市集堆放。下面这个结构是我反复调整后的版本,兼顾了开发速度与可维护性:
ai-engineering-from-scratch/ ├── data/ │ ├── raw/ # 原始数据,只读,不改动 │ ├── processed/ # 清洗后的数据 │ └── splits/ # 训练/验证/测试切分 ├── src/ │ ├── data_processing/ # 清洗、预处理、切分 │ ├── models/ # 模型定义 │ ├── training/ # 训练循环、评估逻辑 │ ├── serving/ # 上线推理服务 │ └── utils/ # 公共工具 ├── configs/ # 超参数配置文件 ├── scripts/ # 可执行脚本入口 ├── experiments/ # 实验记录 ├── models/ # 产出的模型文件和报告 └── notebooks/ # 探索性分析,一律不准进生产代码再强调一句:raw 目录里的原始数据永远不要改。清洗逻辑只允许在 processed 目录里生成新文件,这样任何时候你不会因为“某次清洗改了原始数据”而丢掉原始信息。这是数据工程的一条铁律。
3.2 数据加载与预处理实现
数据处理这一层,我的做法是把每一步都做成显式调用的函数,同时配合阶段输出测试。下面给一个简化但完整的参考实现:
import re import json from pathlib import Path def clean_comment(raw_text: str) -> str: """清洗一条评论数据""" # 去掉HTML标签 text = re.sub(r"<[^>]+>", "", raw_text) # 还原常见HTML实体 text = text.replace("&", "&").replace("<", "<").replace(">", ">") # 去掉URL text = re.sub(r"http\S+", "", text) # 压缩连续空白 text = re.sub(r"\s+", " ", text).strip() return text def process_raw_data(input_path: Path, output_path: Path) -> None: data = [] with open(input_path, "r", encoding="utf-8") as f: for line in f: row = json.loads(line) cleaned = clean_comment(row["content"]) if not cleaned: # 清洗后为空的直接丢弃 continue data.append({"id": row["id"], "content": cleaned, "label": row["label"]}) with open(output_path, "w", encoding="utf-8") as f: for item in data: f.write(json.dumps(item, ensure_ascii=False) + "\n")这段代码不复杂,但体现了一个核心原则:清洗逻辑显式化、可复用、可测试。你可以把这套函数在训练前跑一遍,然后在 serving 时也复用同一个函数,这样就避免了训练和线上数据预处理不一致的问题。实际上,无数“线上效果崩盘”的案例,根源都是训练时的预处理方式和上线时的不一致。
3.3 手工实现一个训练循环
很多人用惯了框架自带的高层 Trainer,导致自己不会写训练循环,遇到诡异报错根本看不懂。我建议每个做 AI 工程的人,至少要手写一次训练循环,把底层的五个步骤刻进 DNA。下面是用 PyTorch 风格手写一个训练 epoch 的骨架:
import torch from torch.utils.data import DataLoader from tqdm import tqdm def train_one_epoch(model, dataloader, optimizer, criterion, device, grad_clip=1.0): model.train() total_loss = 0.0 pbar = tqdm(dataloader, desc="training") for batch in pbar: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["label"].to(device) optimizer.zero_grad() # 1. 清零梯度 logits = model( # 2. 前向传播 input_ids=input_ids, attention_mask=attention_mask, ) loss = criterion(logits, labels) # 3. 计算损失 loss.backward() # 4. 反向传播 torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) # 梯度裁剪 optimizer.step() # 5. 更新参数 total_loss += loss.item() pbar.set_postfix({"loss": loss.item()}) return total_loss / len(dataloader)你可能觉得这段代码平平无奇,但它就是整个训练过程的内核。很多框架层 Trainer 帮你做的事情,其实就是把这五步封装起来。你自己写一遍之后,再去理解“梯度累积”(当一个 batch 太大时拆成多个小 batch 累计梯度)、“梯度裁剪”(防止梯度爆炸)这些操作,就会非常直观。看懂底层五步,之后所有高级技巧都只是在这个序列上做加法。
训练时还要配合一个评估循环,注意评估阶段要切到 model.eval() 模式,并包裹 torch.no_grad(),否则模型不会关闭 Dropout、BatchNorm 也会继续算训练状态的统计量,结果会失真:
def evaluate(model, dataloader, criterion, device): model.eval() total_loss = 0.0 all_preds = [] all_labels = [] with torch.no_grad(): for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["label"].to(device) logits = model(input_ids=input_ids, attention_mask=attention_mask) loss = criterion(logits, labels) total_loss += loss.item() preds = logits.argmax(dim=-1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(labels.cpu().tolist()) avg_loss = total_loss / len(dataloader) return avg_loss, all_preds, all_labels这段代码不长,但直接决定了你能不能相信自己的指标,所以在项目中我一直把它当成刚需模块来维护。
3.4 模型评测与推理服务化
评测阶段不能只看一个准确率。对于电商评论的场景,我更关注每个类别独立的精确率、召回率、F1,并生成一个分类报告。业界有一个常用函数直接可以输出这些指标,但建议你自己动手写一遍,理解每一列的含义,因为你会需要用这些表格去跟业务方沟通,你得讲清楚“为什么这个类别的召回率下降,会带来什么影响”。
服务化部分,我推荐用 FastAPI 这类轻量级框架。核心逻辑是加载一次模型,然后对外提供 predict 接口。下面是一个极简但完整的推理服务:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: str score: float # 这里加载模型和 tokenizer,全局只需要加载一次 MODEL, TOKENIZER = load_model_with_tokenizer("./models/best_model/") @app.post("/predict") def predict(req: PredictRequest) -> PredictResponse: # 训练时的清洗逻辑在这里一定要复用 text = clean_comment(req.text) inputs = TOKENIZER( text, max_length=128, truncation=True, padding=True, return_tensors="pt", ) with torch.no_grad(): logits = MODEL(**inputs).logits probs = torch.softmax(logits, dim=-1)[0] label_id = int(probs.argmax()) score = float(probs.max()) return PredictResponse(label=ID2LABEL[label_id], score=score)这里有一个非常重要的工程细节:服务里的 clean_comment 必须和训练时用的是同一个函数。很多团队线上效果不如离线,一查就是服务端预处理写“简洁版”,漏掉了几条规则。这个问题的唯一解法,就是把清洗逻辑打包成一个统一模块,训练和推理都从同一个入口引用。
4. 常见问题与排查技巧实录
4.1 训练不收敛的经典排查路线
训练不收敛几乎是每个新手都要经历一遍的“成人礼”。我踩过很多坑之后,总结出一个固定排查顺序。第一,看 loss 是不是一开始就 NaN 或非常大。如果是,大概率是学习率太大或者数据里有 NaN 值,优先检查数据和标签,再降学习率。第二,看训练 loss 和验证 loss 的差距。如果两者都高,说明模型欠拟合,先增加模型容量或训练时间;如果训练 loss 很低但验证 loss 很高,说明过拟合,先加正则、加 Dropout 或者减小模型容量。第三,看验证 loss 曲线是否剧烈震荡,如果剧烈震荡,可能是 batch size 太小,或者学习率需要加 warmup。
排查这类问题,我建议做一个“错误清单”文档,每次遇到新的问题都往里面加一条原因,下次遇到直接看表查。很土但很有效。AI 工程的很多知识,其实就是这样一点一滴沉淀下来的。
4.2 数据泄漏的几种隐蔽形态
数据泄漏是“验证集指标好看、线上效果翻车”的头号元凶。最容易出现的形式有三种。第一种是切分泄漏:时间序列数据随机切分,导致模型在训练时见过“未来”的样本。解决方案是按时间切分,保证训练集时间早于验证集。第二种是预处理泄漏:你做 TF-IDF 向量化时,如果是在全量数据上拟合之后再切分,验证集的信息就已经混进了词表统计里。正确的做法是先切分,再在训练集上拟合词表和统计量。第三种是特征泄漏:你构建了一个特征,但这个特征本身携带了标签的信息。举例来说,如果评论里包含“退款成功”这个关键词,几乎可以确定是正向,而这个词在训练集里大量出现,那模型很可能就学会了“看到‘退款成功’就判正类”,但它实际上并没有真正理解语义。
化解数据泄漏没有银弹之路,唯一的路径是严格遵守数据切分规则,以及每次做特征工程时都问自己一句:“这个特征,在预测时我真的能拿到吗?”
4.3 离线评测与线上预测不一致怎么查
离线 F1 有 90%,线上怎么连 70% 都不到?这是常见现象,但一定是哪里出了问题。我把排查点总结成一个固定的检查顺序。
| 检查项 | 做法 | 优先级 |
|---|---|---|
| 预处理一致性 | 训练和推理是否使用同一个预处理函数 | 高 |
| 数据分布差异 | 线上真实数据是否有新的写法(如新品牌名) | 高 |
| 模型版本不一致 | 线上服务加载的模型文件是否是评测版本 | 中 |
| 请求参数不同 | 在线预测时 max_length、padding 策略是否与训练一致 | 中 |
我试过一次很典型的排查过程:离线指标很好,上线后明显变差。检查后发现,训练时我对文本做了截断到 128 个 token,但线上服务为了“尽量保留信息”,把 max_length 设成了 256。看起来是“更宽容”,反而打破了模型训练时的分布。问题虽然不是特别严重,但足够让你明白:推理阶段所有超参数,都必须无条件对齐训练阶段。不要自作聪明。
4.4 模型迭代时的版本管理
模型不是一次性产物,它需要持续迭代。没有版本管理的机器学习项目,三个月后就会变成一团乱麻。我自己的做法是,每次训练产出一个独立目录,里面至少包含四样东西:模型权重、配置文件(超参、数据路径、模型结构说明)、评测报告(含指标和错误样例分析)、训练日志。目录命名用时间加 Tag,比如 best_model_20250615_v2_f1_86.5,一看就知道这个模型是怎么来的。
更正规一点的团队会引入 MLflow 或 DVC 这类工具,但我个人经验是:工具能帮你,但工具替代不了纪律。哪怕只是一个小团队,只要坚持“每次实验都有记录、每次产出都可复现”,项目的健康度就会有质的提升。
5. 工具链清单与技术选型思考
5.1 各环节工具的实用推荐
工具选型这件事,特别容易走两个极端:要么图省事一把梭,要么图新鲜持续开箱即用。我的原则很简单:优先选生态成熟、团队熟悉、出问题有资料查的。下面是个参考清单:
| 环节 | 可选工具 | 我的建议 |
|---|---|---|
| 依赖管理 | venv、poetry、uv | 新人用 venv,追求速度和现代用 uv |
| 数据处理 | pandas、polars | 数据量在几百万行内 pandas 足够 |
| 模型训练 | PyTorch | 生态好,调试灵活,优先选 |
| 超参配置 | YAML、OmegaConf | 把超参跟代码分离,不要写死在脚本里 |
| 实验记录 | MLflow、WandB | 小团队用 MLflow 自托管即可 |
| 推理服务 | FastAPI、Flask | FastAPI 异步支持更好,推荐 |
| 容器化 | Docker | 统一环境,隔离依赖,值得投入时间 |
选工具不是越复杂越好,关键是你能不能控制住它。一个工具如果连调试起来都要查半天文档,那它对项目的贡献可能就是负的。
5.2 提升开发效率的几个小细节
说几个花小力气但很有效的开发习惯。
第一,日志要设置好级别。开发时用 DEBUG 级别看细节,训练时用 INFO 级别记录 loss 曲线,上线时用 WARNING 级别屏蔽干扰。别把所有信息都混在一起,不然等你查问题的时候会被海量日志淹没。第二,做一个可复现的随机种子管理器。在项目入口统一设置 seed,保证每次实验初始化一致,这样你调参时才能确信效果差异来自超参数而不是随机初始化。第三,数据样本的可视化检查。训练前随机抽 20 条样本,打印出“预处理前的原文”和“预处理后的文本”放在一起对比,这一步能帮你快速发现清洗逻辑的 Bug。
这些细节单独拿出来都不值一提,但组合在一起,能让你的开发体验顺畅很多。
写在最后的一点个人体会
这个项目走完一遍,我最深的一个感受是:AI 工程里最贵的不是显卡,而是你的调试时间。很多人觉得模型效果不好,是模型结构不够新,于是不停换更强的模型;可实际上,大部分项目的瓶颈都在数据质量、标注一致性和工程链路的一致性上。如果你能把数据管线做到干净、可复现,把训练循环理解到骨子里,把评测和部署做成闭环,那不管换了什么模型、什么任务,你都能稳稳地把它做上线。
最后再分享一个我自己一直在用的小技巧:每次实验只改一个变量。要么只改学习率,要么只改数据清洗规则,不要一次性换模型、改数据、调超参,否则你永远不知道是哪个改动带来了提升。听起来很基础,但这是我从无数次失败里爬出来的经验,希望它能帮你少走几条弯路。