前阵子有人问我:想做 AI 工程方向,但不想只会调 API,该从哪里开始?我给的回答很简单——从 scratch 开始。这个 scratch 不是少儿编程那个积木平台,虽然名字撞了,但底层逻辑是一回事:真正把一件事从零搭起来,你才能理解每一个零件为什么在那里。这篇内容我就用自己的实操经历,把"ai-engineering-from-scratch"这件事拆开讲透。从环境搭建、数据构造,到训练流水线、部署上线,再到迭代复盘,完整走一遍,适合刚入门想建立工程全局观的读者,也适合已经在调 API 但想往下钻一层的人。
我这里说的"从零",不是让你推数学公式、手写反向传播,也不是让你从第一行代码就把 Transformer 从零实现一遍,而是指:不跳过任何一个工程环节,亲手把一条 AI 流水线的每一段都跑通、调通、部署通。我会用到尽量小的数据、尽量简单的模型,但工程链路一个不少。这条路我走过,坑也踩过不少,下面都是实打实的经验。
1. "from scratch" 的真实含义:先破除两个极端认知
1.1 调包侠与造轮子之间,存在一条中间路线
我见过两类人。一类是"调包侠",张口闭口 huggingface、langchain、OpenAI SDK,代码写得飞快,但一旦离线环境、模型报错、效果不好,立刻抓瞎,因为底层发生了什么他完全没有感知。另一类是"理论洁癖",非要手写反向传播、从零实现 attention,结果三个月过去还在和数据加载搏斗,工程交付遥遥无期。
我的判断是:AI 工程最需要的既不是只会调包,也不是重复造轮子,而是具备"从零还原链路"的能力。什么意思?就是给你一台裸机、一份数据、一个明确任务,你能独立把整个系统搭起来。技术上你不必重写框架,但你得知道每一层在干什么,出了问题有排查思路。这种能力,靠看教程看不出来,只能靠亲手从 scratch 跑一遍全流程来建立。
所以这个项目的定位就很清晰了:任务是一个小规模文本分类模型,从数据处理、模型训练、效果评估到服务部署,全部自己动手实现和串联。模型用最简单的结构,数据量控制在几千条,整个过程在一台普通笔记本上就能完成。
1.2 为什么我建议用"最小系统"练全链路
很多人上来就搞大模型微调,7B 参数的模型还没下载完就泄气了,更别提搞清楚每一步背后发生了什么。我自己的经验是:工程能力的建立,在于完整闭环,而不在于模型规模。你哪怕只用一个几万参数的小模型,把数据处理、训练循环、验证评估、部署上线、监控反馈这五个环节全部走通,收获比跑通一个大模型要多得多。因为大模型很多环节被框架隐藏了,小模型反而逼你必须自己面对所有细节。
这个概念,跟少儿编程里的 Scratch 积木很相似。孩子刚开始不需要懂编译原理,而是通过拖拽积木完成一个动画、一个游戏,建立起"逻辑顺序""循环""条件判断"的直觉。之后学真正的代码就顺畅多了。成人学 AI 工程也是一样,先用最小系统建立"全链路直觉",再去碰分布式训练、大模型微调、推理优化这些复杂主题,你心里才会有坐标。
1.3 搭建前的全局路线图
在动手之前,我先给你看一张我实际执行过的路线图,保持简单但完整:
- 环境准备:Python 版本管理、依赖隔离、Docker 镜像
- 数据工程:原始文本收集、清洗、划分训练集/验证集/测试集
- 基线模型:先跑一个最简单的基线,保证链路通畅
- 正式模型:训练一个小型分类模型,记录完整实验日志
- 评估诊断:产出一份评估报告,判断问题出在数据还是模型
- 部署上线:用 FastAPI 封装模型,加一个简单的 Web 接口
- 迭代复盘:记录本次项目的可复用检查清单
别看这七步简单,我后面踩的每一个坑都在这些步骤里。现在开始逐步展开。
2. 环境与数据准备:这里藏了工程化思维的第一课
2.1 环境搭建的"省心配置"
先说结论,我现在做 AI 项目首选的环境组合是:uv 管理 Python 依赖 + Docker 保证可复现 + 本地 CPU 跑通小模型。
为什么用 uv 而不是 conda?因为它快。Conda 创建一个环境经常等半天,uv 秒级解决依赖解析。我自己实测,在小项目上 uv 的依赖安装速度比 conda 快一个数量级。安装 uv 的方式很简单:
curl -LsSf https://astral.sh/uv/install.sh | sh然后初始化项目:
uv init ai-engineering-from-scratch cd ai-engineering-from-scratch uv add torch scikit-learn pandas fastapi uvicorn pytest这里我故意没有加 transformers,目的就是逼自己直接面对数据加载、分词、训练循环这些底层环节。等把基础链路跑通,再引入大模型工具库,你会发现自己完全能读懂它们的源码逻辑了。
2.2 Docker 镜像:不要因小失大
还有人觉得小项目没必要上 Docker。我的看法是:一旦部署,你就需要。本地能跑和服务器能跑是两回事。我一开始偷懒,直接在裸环境跑训练,结果换一台机器复现时,各种各样的兼容问题扑面而来。
你应该在项目一开始就写好Dockerfile,哪怕是最基础的。我用的配置是这样:
FROM python:3.11-slim WORKDIR /app COPY pyproject.toml ./ RUN pip install uv RUN uv sync --frozen COPY src/ src/ COPY models/ models/ CMD ["python", "src/train.py"]这个 Dockerfile 非常简单,但它给项目上了一道保险:任何人在任何机器上都能跑出同样的环境,这就是工程化思维的起点。我强烈建议你把这个习惯从一开始就养成。
2.3 构造"小而真"的数据集
数据是 AI 工程的灵魂,但在 from scratch 阶段,我不建议去下载几十 GB 的大数据集,那样你会在等待和预处理中消耗掉全部热情。我选的是一个小型情感分类数据集,大约 2000 条影评文本,正负样本均衡,存在本地 CSV 文件里。结构非常简单:
text,label "Absolutely wonderful film...",1 "Waste of time and money...",0数据准备阶段有两点必须认真对待。第一,划分数据集,而且要划成三份:训练集、验证集、测试集。比例我用的是 70/15/15。这里不是说随便切一下就行,而是要用分层抽样,保证正负样本比例在三份数据里都接近,不然验证结果会失真。第二,清洗策略要克制。我当时给自己定的规则是:去除 HTML 标签、去除多余空白、统一小写。停用词我保留了,因为对情感分类来说,"not good" 这种否定表达中的 "not" 极其重要,粗暴去掉会直接损伤效果。
一个我后来才意识到的细节:清洗步骤的代码必须保存下来,并且和训练代码放在同一个项目里。这样测试阶段你才能用同一套处理逻辑,保证数据分布一致。你以后会感激这个习惯的。
3. 从零实现训练流水线:模型可以简单,链路不能缺失
3.1 数据处理管道:把文本变成张量
第一个关键决策:怎么把文本变成模型能读的数字。为了不引入太重的东西,我用的是最简单的词袋模型加 TF-IDF 加权。它没有深度学习嵌入那么强,但足够展示完整的数据流,训练结果也完全能说明问题。
处理流程是这样:
- 用
CountVectorizer构建词表,设置max_features=5000,控制特征维度 - 用
TfidfTransformer做加权,降低常见词的干扰 - 把结果转成 PyTorch 的 Tensor
关键代码逻辑如下:
from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer import torch def build_features(texts, vectorizer=None, tfidf=None): if vectorizer is None: vectorizer = CountVectorizer(max_features=5000, lowercase=True) X_counts = vectorizer.fit_transform(texts) else: X_counts = vectorizer.transform(texts) if tfidf is None: tfidf = TfidfTransformer() X_tfidf = tfidf.fit_transform(X_counts) else: X_tfidf = tfidf.transform(X_counts) return torch.tensor(X_tfidf.toarray(), dtype=torch.float32), vectorizer, tfidf这里有一个很重要的工程细节:fit_transform只能用在训练集上,验证集和测试集上只用transform。否则会造成数据泄漏,验证集的信息提前混进了特征工程,评估结果就会虚高。这个坑我后面还会细说。
3.2 训练循环:亲手写一遍之后,你才看得懂框架
模型结构我用的是一个两层的全连接网络,隐藏层 128 个神经元,配合 ReLU 激活和 Dropout:
import torch.nn as nn class SimpleClassifier(nn.Module): def __init__(self, input_dim, hidden_dim=128, num_classes=2): super().__init__() self.net = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden_dim, num_classes) ) def forward(self, x): return self.net(x)训练循环我坚持手写,不用 PyTorch Lightning,理由很简单:只有自己写一遍损失计算、反向传播、梯度更新和参数保存,你才能理解框架帮你做了什么。
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() for epoch in range(10): model.train() total_loss = 0 for batch in train_loader: optimizer.zero_grad() outputs = model(batch["features"]) loss = criterion(outputs, batch["labels"]) loss.backward() optimizer.step() total_loss += loss.item() avg_loss = total_loss / len(train_loader) val_acc = evaluate(model, val_loader) print(f"epoch {epoch+1}: loss={avg_loss:.4f}, val_acc={val_acc:.4f}")我用的损失函数是CrossEntropyLoss,优化器是AdamW,学习率 1e-3,训练 10 个 epoch,batch size 32。这套参数不是拍脑袋定的,而是基于小数据集的常见取值范围。后面你会看到,即便参数合理,依然会踩到一些典型的坑。
3.3 评估与日志:工程与实验的分水岭
训练完模型,第一次测试集准确率跑出来差不多 0.86。说实话这个数字不算惊艳,但重要的是我知道这个数字怎么来的、代表什么。我把评估结果完整记录下来,包括准确率、精确率、召回率、F1 值,以及每一类别的混淆矩阵。
这里我分享一个习惯:每次实验,我都在experiments/目录下生成一个带时间戳的子目录,里面存放:
config.json:记录超参数metrics.json:记录评估指标model.pt:模型权重predictions.tsv:测试集的逐条预测结果notes.md:自己的实验备注
这个习惯一开始会觉得很麻烦,但你需要它去规模化复盘。没有日志的实验等于没做,这句话是 AI 工程领域的老话,我深以为然。
4. 训练阶段踩过的五个坑:每一个都让你怀疑人生
4.1 数据泄漏:最隐蔽的"虚假繁荣"
第一次跑完,我的验证准确率高达 0.93,但测试集只有 0.86。差距这么大,直接触发了我对数据泄漏的排查。果然,问题出在TfidfTransformer上——我刚开始写代码时,在训练和验证阶段都调用了fit_transform,导致验证集词语的权重被验证集自身的数据拟合了。
这就像考试前你已经看到了答案的一部分,虽然只是"边缘信息",但你的成绩已经不是真实水平了。正确的做法很简单:任何特征工程器,都只能在训练集上fit,在其他所有集上只做transform。
这也是我为什么在 3.1 的代码里单独保留了vectorizer和tfidf对象,并在验证、测试阶段复用的原因。修复后,验证准确率降到 0.87,和测试集基本一致。看到这个我更确信:评估指标与测试集差距过大,第一反应应该查数据泄漏,而不是调模型。
4.2 损失不降:学习率与数据顺序的联合作用
有一次训练时,loss 前三个 epoch 一直停在 0.69 不动。这个数值很有意思,ln(2)约等于 0.693,正好是二分类的随机猜测损失。模型完全没有在学习。我排查了一圈,最后定位到两个原因:
一是学习率设置偏高,梯度在最优解附近震荡,损失降不下来;二是数据未经 shuffle,训练集前 100 条全是同一个类别,模型在这种"偏食"效应下学不到判别边界。
我随即把学习率从 5e-3 调低到 1e-3,并在DataLoader里设置shuffle=True。损失立刻开始下降。后来我自己总结了一个实操规则:当损失卡在随机值附近,先检查数据顺序,再调整学习率,不要一上来就换模型结构。这两个原因的概率远高于模型结构问题。
4.3 过拟合的判断套路:训练集 vs 验证集的距离
到第五个 epoch 时,训练集准确率已经冲到 0.97,但验证集还在 0.86 徘徊。这是我特意没加 Dropout 时跑出来的典型过拟合症状。加上 Dropout 0.3 后,差距缩小到可以接受的范围。
怎么判断过拟合?我习惯用"距离感",就是训练集指标和验证集指标的差值。差值超过 0.05,基本可以考虑正则化;超过 0.1,属于严重过拟合。这个阈值在 NLP 分类任务里比较实用,当然具体情况要具体分析。如果训练集指标都不高,不要先谈过拟合,那大概率是欠拟合或数据问题,顺序搞反了,你会浪费很多时间调一个根本不需要的正则化。
4.4 随机种子:不固定种子,你的实验永远无法复现
我第一次训练时没有设置随机种子,连续跑两次同一代码,准确率分别是 0.85 和 0.88。这差距不小,你完全无法判断 0.88 是模型改进带来的还是偶然波动。
固定随机种子的代码要写完整:
import random, numpy as np, torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True在训练脚本的第一行调用set_seed(42),从此所有实验都在同一条起跑线上。你能真正开始对比"模型结构 A vs 模型结构 B"之间的差异,而不是被噪声迷惑。这一步虽然不起眼,但它是 AI 工程实验纪律的起点。
4.5 Batch Size 的隐性作用
还有一次模型收敛速度极慢,我反复检查数据、学习率都没发现问题,后来才发现是 batch size 从 32 被改成 256 后带来的连锁反应:在这么小的数据集上,大 batch 导致每个 step 的梯度更接近"全量梯度",而学习率没有相应调大,收敛自然就慢。
这里让我补一个基本的理论直觉:batch size 越大,梯度估计越稳定,但每一步更新的次数也越少;想要同样的收敛效果,通常需要搭配更大的学习率。在小项目上用 32 就够了,数据量大的场景可以按需调整。
5. 把模型部署成服务:最后一步才是"工程"的试金石
5.1 FastAPI 封装模型接口
训练只是开始,真正的 AI 工程一定要把模型变成别人能调用的服务。我用的方案是 FastAPI,它在轻量服务上上手极快,并且自带接口文档。
模型服务的核心代码:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: int confidence: float @app.post("/predict", response_model=PredictResponse) def predict(req: PredictRequest): features, _, _ = build_features([req.text], vectorizer, tfidf) logits = model(features) probs = torch.softmax(logits, dim=1) label = int(torch.argmax(probs, dim=1).item()) confidence = float(torch.max(probs, dim=1).values.item()) return PredictResponse(label=label, confidence=confidence)这里你必须注意一个关键点:部署时的特征处理必须和训练时完全一致。也就是说,vectorizer和tfidf是训练阶段保存下来的对象,不是部署时重新构建的。我第一次部署时图省事,在接口里直接重新 fit,结果模型接收的数据分布完全不同,预测结果一团糟。正确的做法是在训练脚本里保存这些对象:
import joblib joblib.dump(vectorizer, "models/vectorizer.joblib") joblib.dump(tfidf, "models/tfidf.joblib") torch.save(model.state_dict(), "models/model.pt")5.2 Docker 部署:让服务在任何机器上跑起来
接口写好后,我把整个服务容器化。这里用到了第 2.2 节那个 Dockerfile 的变体:
FROM python:3.11-slim WORKDIR /app COPY pyproject.toml ./ RUN pip install uv && uv sync --frozen COPY src/ src/ COPY models/ models/ EXPOSE 8000 CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]构建并运行:
docker build -t ai-eng-demo . docker run -p 8000:8000 ai-eng-demo然后调用接口测试:
curl -X POST http://localhost:8000/predict \ -H "Content-Type: application/json" \ -d '{"text": "A truly moving story with brilliant performances."}'返回结果示例:
{"label": 1, "confidence": 0.91}到了这一步,一条完整的 AI 服务链路才算打通。很多人觉得部署是最无聊的环节,但它恰恰是最"工程"的环节。能训练模型是一回事,能把它稳定地提供服务是另一回事。
5.3 接口之外:加一个健康检查
部署时我还加了一个GET /health接口,用来给监控系统探活:
@app.get("/health") def health(): return {"status": "ok"}加上这个接口之后,后续接 Prometheus 或云监控就方便多了。不要觉得这一步多余,线上服务一旦挂掉,没有健康检查,你只能等用户反馈发现问题,那时候损失已经发生了。
6. 从"能跑通"到"会迭代":AI 工程的核心资产是流程
6.1 实验管理:像记账一样管理你的每次改动
项目跑通之后,我的模型准确率停在 0.87 附近。这时我面临一个选择:是继续调模型碰运气,还是建立一套能支持我系统性改进的流程?我选了后者。
我在config.json里记录每次实验的完整配置,包括数据路径、清洗策略、特征维度、模型结构、学习率、batch size、epoch、随机种子。这样当我做下一次改进时,我能精确回答三个问题:改了什么?为什么改?结果比上次好还是差?
我曾经遇到过一种情况:连续三组实验看起来都在改进,后来发现是因为我忘记固定随机种子,那些"改进"不过是随机波动。有了完整的实验配置记录之后,这种问题一眼就能看出来。
6.2 消融实验:证明每一步都不是白做的
当你想在系统里加一个新模块时,比如在词袋基础上加一个词性统计特征,正确做法不是直接替换旧系统,而是做一组消融实验:
- 基线:词袋 + TF-IDF + 简单分类器
- 实验组:基线 + 新特征
跑完之后对比两组指标,才能确定新特征是否真的有价值。我自己的经验是:很多时候你认为有效的改进,在严格的对比测试下其实没有统计显著性。所以消融实验不是学术界的专利,工程实践里同样必须坚持。不经过对比实验就上线的改动,本质上是赌博。
6.3 项目复盘清单:让下一轮 from scratch 更轻松
第一轮从零到一跑通之后,我沉淀了一份自己的项目复盘清单,现在分享给大家:
- 数据层面:原始数据是否可靠?标签噪声如何?划分是否严格分层?
- 特征层面:
fit是否只发生在训练集?清洗规则是否与部署一致? - 模型层面:随机种子固定了吗?损失是否降到合理范围?过拟合控制住了吗?
- 实验层面:每次改动的配置有记录吗?对比实验的控制变量做到了吗?
- 部署层面:模型与特征对象是否一起打包?接口能处理空文本、超长文本等边界情况吗?
- 监控层面:服务有健康检查吗?接口延迟有多少?置信度低于多少时需要人工兜底?
有了这份清单,我下一个项目从零开始时,不再是无头苍蝇,而是带着一套已经验证过的节奏去执行。这才是 from scratch 项目真正沉淀下来的财富——不是那个模型,而是你迭代项目的流程和手感。
我在实际做这个项目的过程中,最大的体会就是:AI 工程里最难的从来不是某个算法技巧,而是把数据处理、模型训练、评估、部署、迭代这一整条流水线,变成你可以闭着眼走完的肌肉记忆。从 scratch 跑通一次,也许只花费你几天时间,但它带给你的全局视角,比任何单独学一个框架都值。你可以先照着这个最小系统做一遍,遇到问题欢迎交流,下一轮再往里面加复杂度,方向就会清晰很多。