看到 ai-engineering-from-scratch 这个词时我第一反应是回想起自己第一次把模型从训练到上线跑通的那个凌晨。如果你也是那种不喜欢只调别人 API、想从零亲手搭一套 AI 工程链路的人那这篇内容就是为你准备的。它不是一份“30 天速成 AI”的鸡汤清单而是一个以项目为线索、从环境配置到部署监控完整走一遍的实战复盘。无论你是刚入行的算法实习生还是想转型 AI 工程的普通后端开发都能从这里面拿走一套可落地的思路和一堆我踩过的坑。我把它当成一个独立的开源项目来对待ai-engineering-from-scratch。名字听着有点唬人其实核心就一件事——把 AI 真正落地成产品时那些课本上不会细讲的工程问题全部用真实代码和流程串起来。接下来的章节我会按照项目推进的时间线来拆你会看到每个环节“为什么这么做”而不只是“怎么做”。1. 从零开始的工程化思维先搞清楚 AI 工程到底在解决什么问题1.1 为什么强调“from scratch”说实话现在的开源生态已经足够发达。你想用一个大模型几行代码就能调通 API想跑一个图像分类Hugging Face 上拖下来就能用。那为什么还要从零开始我的体会是调用现成模型和真正具备工程能力之间隔着一整条看不见的暗河。从零开始的意思不是让你去重新发明 Transformer也不是让你手写反向传播而是把 AI 系统中每一个环节的输入、输出、异常、瓶颈、取舍都亲手摸一遍。比如你只调用过别人训练好的接口那你不会知道数据清洗对整个项目工期的影响有多大你没亲手部署过一个模型就不会理解那 200MB 权重文件在 Docker 镜像里有多让人头疼。这个项目的价值就在于把这些“隐性知识”显性化。1.2 工程链路拆解从数据到上线缺一环都不行很多人一提 AI 工程就以为是“写模型”其实模型训练通常只占整个项目的三分之一精力。一个完整的 AI 工程链路包括业务问题定义、数据采集与标注、数据处理、特征工程、模型选型、训练调优、评估验证、模型打包、服务化部署、监控运维这十个环节。我会在项目里把这十个环节全部过一遍。最终的目标是产出一个可运行的 API 服务用户能调用它拿到预测结果并且我们能观测到它的运行状态。这种“端着盘子走完全程”的经历比看一百篇教程都来得深刻。后文所有代码和配置都是围绕这个目标展开的你可以直接复制到自己的机器上复现。2. 环境与工具链准备打好地基才能不慌2.1 2024 年我推荐的 Python 工程环境如果你使用的还是 Jupyter Notebook 写好一坨函数然后直接跑我强烈建议你切换成工程化的项目结构。下面是我在 ai-engineering-from-scratch 里使用的项目布局ai-engineering-from-scratch/ ├── data/ │ ├── raw/ # 原始数据 │ ├── processed/ # 清洗后数据 │ └── features/ # 特征工程产物 ├── src/ │ ├── data_preprocessing.py │ ├── train.py │ ├── evaluate.py │ └── serve.py ├── models/ # 模型产物 ├── tests/ ├── pyproject.toml # 依赖管理 └── Dockerfile这个结构的好处是数据、代码、模型、测试各自独立后续做版本管理、CI/CD、模型迭代都方便。我见过太多因为目录混乱导致后端同学不知道往哪儿加接口、算法同学不知道哪个脚本是新的情况。依赖管理方面我个人从 requirements.txt 转到了 Poetry。不是说 pip 不好而是在做 AI 项目时依赖冲突是家常便饭。Poetry 通过 lock 文件锁定每一个子依赖版本环境可复现性一下子提升了很多。建议使用 Python 3.10 或更高版本因为很多机器学习库的老接口在新版本里已经“弃用警告”很久了。2.2 容器化为什么 AI 工程必须拥抱 Docker很多初学者会问我本地能跑通为什么要用 Docker答案是你本地能跑通不代表别人能跑通。机器学习依赖链太长numpy、pandas、scikit-learn、torch 之间的版本兼容性极其脆弱。Docker 的好处是它把操作系统级别的依赖也打包进去了保证“在我这能跑在你那也能跑”。我用的是 Python 3.10-slim 作为基础镜像因为模型推理阶段并不需要编译器镜像体积能小一些。如果你要使用 GPU 做训练那就得用包含 CUDA 的镜像比如 nvidia/cuda:12.1.0-runtime-ubuntu22.04再在里面装 Python 环境。这里我多说一句训练环境和推理环境尽量分开训练镜像可以很大但推理镜像越干净越好因为每次上线时传输和拉取镜像的时间都是成本。2.3 版本管理不止是 Git模型和数据集也要管做 AI 工程最重要的就是把“可复现性”刻进 DNA。Git 管理代码但模型权重文件动辄几百 MB扔在 Git 里会让仓库膨胀。我的做法是使用 DVCData Version Control来管数据集和模型文件。它能把大文件的元信息记录在 Git 里实际文件存在本地或云存储中。每次训练完成后我会用 DVC 打一个标签比如 v1.0.3这样任何时刻只要 checkout 到那个 tag就能拉回当时的数据和模型把实验结果一比一复现出来。这个习惯在复现和回溯线上 bug 时救了我好几次。下面会提到的 MLflow 也是辅助管理实验的好工具它的定位跟 DVC 不完全一样DVC 管文件和版本MLflow 管实验参数和指标。3. 数据是项目的血液采集、清洗与特征的实战细节3.1 从业务问题到数据形态我们这次做的案例是“中英文短文本主题分类”。业务背景很简单一家出海公司需要把用户反馈自动归档到“物流、支付、产品体验、售后、其他”这几个类别里。这个任务非常典型既有传统机器学习可解的路径也方便后期扩展到深度学习适合作为 from-scratch 项目的主线。这里我先强调一个关键思路不要一上来就想着上多贵的模型。本案例中我们先从 TF-IDF 加逻辑回归开始因为它训练快、可解释、服务化成本低。只有当这个基线无法满足业务指标时才考虑升级到 BERT 这类预训练模型。这种“从简单到复杂”的思路才是 AI 工程的核心节奏。3.2 数据清洗的那些暗坑数据是最容易出现“垃圾进垃圾出”的地方。中文和英文混合的文本清洗规则跟纯英文完全不同。比如中文里没有空格分词如果直接套用英文的 tokenizer会被切成一堆无意义片段。在预处理阶段我做了几件事统一小写英文并保留中文原样去掉 HTML 标签和 URL但保留常见标点因为感叹号在投诉文本里可能代表情绪对中文连续数字、日期做统一归一化比如 2024-01-02 替换为date去除重复样本但要小心完全相同的文本可能在不同类别下都出现标签噪声需要人工抽样校验。其中第 4 点特别容易被忽略。很多公开数据集里都混着重复行和错误标签。我习惯在清洗后跑一个交验统计对每条文本统计出现次数对同一文本出现多个标签的样本做抽样人工审查。这个过程看着琐碎但能直接决定模型上线的效果。3.3 特征工程简单但极其有效的做法对于短文本分类我把特征分成三块基于 TF-IDF 的 n-gram 特征n 取 1 和 2这样能捕捉到“支付失败”这类双词搭配文本长度特征包括字符数、词数、标点数规则特征比如是否包含“退款”“客服”等行业词典关键词。我使用 scikit-learn 的 TfidfVectorizer 来构建特征里面有两个参数建议从零调起min_df 和 max_features。min_df2 表示至少在 2 个文档中出现过的词才保留这能滤掉只出现过一次的生僻词max_features50000 是为了控制向量维度防止特征矩阵大到内存爆炸。建议先用默认值跑一版再看词汇表的长度和稀疏程度再反向调整。3.4 训练集、验证集、测试集怎么切分才不骗自己切分数据这件事看起来简单实则处处是坑。对于分类任务我用的是分层抽样保证每个类别在训练集和测试集中的比例一致。数据量比较小时还要注意防止同一条文本的不同变体被切到两边造成数据泄漏。我的切分比例是 8:1:1。训练集用于拟合模型验证集用于调参和早停测试集只在最后评估时使用一次。这个原则我反复强调如果你频繁用测试集来调整模型那测试集就已经变成了验证集最终评估指标会虚高。在项目代码里我封装了一个split_data函数固定随机种子并把切分结果存到本地方便复现。4. 模型训练与评估从基线到迭代优化4.1 模型选型的决策逻辑在这个项目里我同时准备了两类模型逻辑回归作为基线BERT 作为升级选项。逻辑回归训练极快在 CPU 上几分钟搞定而且你能直接看到每个特征的权重比如“客服”这个词权重高那我们可以判断模型确实学到了业务信号。这对初期验证特征是否有效非常重要。BERT 这边用的是bert-base-multilingual-cased因为数据是中英混合的多语言模型更合适。我训练 BERT 时只冻结了底部三层其余层做 Fine-tune。这里有个经验如果数据量少于 1 万条全量微调很容易过拟合建议先冻结大部分层只训练顶部两层和分类头。下面是逻辑回归训练的代码片段基于 scikit-learn 的 Pipeline 实现。我特意把 TF-IDF 和分类器封装在一个 Pipeline 里这样可以避免数据泄漏也能在推理时保持同样的预处理流from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline model Pipeline([ (tfidf, TfidfVectorizer( ngram_range(1, 2), min_df2, max_features50000, sublinear_tfTrue, )), (clf, LogisticRegression( C1.0, solverliblinear, class_weightbalanced, random_state42, )), ]) model.fit(X_train, y_train)class_weightbalanced是我强烈建议加的参数。如果类别分布不均衡比如“物流”类占了 50%模型会倾向于把所有样本都预测成“物流”加了 balanced 后损失函数会自动调高少数类的权重f1-score 会明显改善。4.2 评估指标准确率并不代表靠谱我见过很多人只看 accuracy这是分类任务里最容易踩的坑。在样本不平衡时一个全部预测为多数类的模型准确率也能很高但业务上毫无用处。这个项目中我同时监控 precision、recall、f1-score 和 macro-F1。对于多分类macro-F1 对每个类一视同仁地算 F1 再取平均是衡量整体性能的好指标。评估结果用表格记录下来方便前后对比。比如我当前这版基线模型的测试结果类别PrecisionRecallF1-score物流0.870.910.89支付0.820.760.79产品体验0.780.810.79售后0.720.650.68其他0.830.850.84macro avg0.800.800.80“售后”类的 recall 偏低说明不少售后反馈被误分到了“产品体验”里。这时候我不是盲目调参而是回去看特征里“退货”“退款”这类词在两个类别中的权重差异再决定是否增加规则特征。这就是 AI 工程中的“迭代回路”。4.3 超参数调优不要迷信全自动搜索初学者拿到调参任务就会想到网格搜索(GridSearchCV)或随机搜索(RandomizedSearchCV)。但全自动搜索的代价非常大尤其是模型复杂或数据量大时一次搜索可能跑几个小时。我的建议是先手动跑几组理解趋势再用小规模随机搜索收尾。以逻辑回归为例我先固定 ngram_range(1,2)然后分别试 C0.1、1、10观察验证集指标变化。如果 C1 比 C10 好说明模型已经有轻微过拟合不需要继续增大 C。其实这里可以补充一个小技巧调参前先把max_iter调大比如 2000因为小数据集上 liblinear 可能不收敛报 warning 时会让你误以为是参数问题。对于 BERT 这类模型我只会调 learning rate、batch size、epochs 这三个核心参数。经验值learning rate2e-5batch size16epochs3。如果显存不够batch size 可以降到 8同时把 learning rate 也调低一点。过度调参没有意义深度学习模型的性能主要由数据质量和数据量决定。5. 模型部署与服务化让模型开口说话5.1 模型导出与封装pickle 不是唯一选择模型训练完成后需要把它保存下来。scikit-learn 模型可以用 joblib 保存BERT 模型则保存权重和 tokenizer 文件。有一个很多人忽略的问题不要在推理时再加载原始模型文件而是先做一次“推理预热”。我用 joblib 导出逻辑回归模型然后写了一个统一的预测函数把所有预处理逻辑清洗 → 特征 → 模型预测 → 标签映射都封装在一个类里。代码长这样import joblib import numpy as np class TopicClassifier: def __init__(self, model_path, vectorizer_path, label_map): self.model joblib.load(model_path) self.vectorizer joblib.load(vectorizer_path) self.label_map label_map def predict(self, text): text_clean clean_text(text) features self.vectorizer.transform([text_clean]) proba self.model.predict_proba(features)[0] idx int(np.argmax(proba)) return self.label_map[idx], float(proba[idx])这里的label_map是类别索引到类别名称的映射必须随着模型一起保存否则线上推理时就会张冠李戴。5.2 用 FastAPI 搭建推理服务部署我选择的是 FastAPI它是目前 Python 生态里最顺手的 Web 框架自带 OpenAPI 文档异步性能好。我写的服务端只暴露一个POST /predict接口接收 JSON 格式{text: ...}返回预测标签和置信度。下面是精简后的代码from fastapi import FastAPI from pydantic import BaseModel from topic_classifier import TopicClassifier app FastAPI() classifier TopicClassifier(...) class PredictRequest(BaseModel): text: str app.post(/predict) def predict(req: PredictRequest): label, prob classifier.predict(req.text) return {label: label, confidence: prob} app.get(/health) def health(): return {status: ok}注意两点第一加载模型是重操作一定要放在模块加载时执行而不是每次请求都加载第二对predict函数做并发安全处理因为 FastAPI 默认是多线程调用如果你的模型对象不是线程安全的需要在初始化时加锁或在依赖里控制并发。逻辑回归模型没有这个问题但 PyTorch 模型在推理时主要受 GIL 影响不需要额外加锁。5.3 Dockerfile 与启动脚本我最终的 Dockerfile 长这样FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD [uvicorn, src.serve:app, --host, 0.0.0.0, --port, 8000, --workers, 1]这里--workers 1是有意为之。模型刚加载时会占用一大块内存如果开多个 worker内存会成倍占用。但单 worker 也意味着并发能力有限所以实际生产里我往往会配合水平扩容来解决问题多开几个容器实例前面加负载均衡。这样比单容器内开多线程更稳定而且能做到平滑发布。启动容器后可以先用 curl 做一次健康检查curl -X POST http://localhost:8000/predict \ -H Content-Type: application/json \ -d {text: 我的快递什么时候能到}如果返回{label: 物流, confidence: 0.94}就说明从训练到部署的链路已经通了。6. 上线前后的监控与维护模型不是一锤子买卖6.1 日志与指标监控模型上线后如果不做监控就像一个蒙着眼睛开车的人。我至少会记录以下几类指标请求量、延迟、预测分布、置信度分布、输入文本长度。这些指标不仅反映了系统健康度更是数据漂移的前哨。在 ai-engineering-from-scratch 项目里我用 Python 的 logging 模块写结构化日志每一行都包含 timestamp、label、confidence、text_len 等字段。然后这些日志会被采集到 Loki 或 Elasticsearch 中做可视化展示。如果你刚起步不必一步到位上 Grafana先保证日志能持续记录能回溯已经是一个巨大的进步。6.2 模型性能下降的信号与应对一个很常见的场景模型上线后前三个月 F1 还行之后用户反馈越来越不准。这不一定是你模型坏了而是你的数据分布变了。比如“售后”类新出现了“仅退款”“仅退款不退货”等更复杂的表达而训练数据里没有。我通常用两个信号来感知漂移统计用户反馈里新词的占比跑一个简单的“词表覆盖率”监控模型预测的置信度均值如果置信度均值下降说明模型面对的是陌生分布。当漂移明显时最好的做法不是继续调阈值或规则补丁而是做一次主动学习把最近低置信度的预测样本拉出来人工标注然后增量训练或微调模型。这里再强调一点一定要在项目中留好数据回流的接口线上日志中实时收集的样本可以很自然地进入下一轮训练集。6.3 模型版本管理与回滚我会把每次训练产出的模型统一放在 models 目录下文件名带上时间戳和实验编号比如model_20240720_v3.joblib。同时在 MLflow 里记录下每次训练使用的数据版本、代码 commit 号、超参数、评估指标。一旦线上模型出现严重问题可以快速回滚到上一个稳定版本。这个环节里我发现最有用的习惯是每次上线前在 staging 环境用线上日志回放一遍预测结果把新旧模型对同一批历史请求的预测结果做 diff尤其关注哪些样本的标签发生了翻转。如果翻转比例超过 1%我会认真审查是否要上线新模型。7. 常见问题与排查实录那些让你深夜破防的坑7.1 环境依赖冲突问为什么我在自己的电脑上训练得好好的一到服务器上就报ModuleNotFoundError: No module named torch排查思路八成是环境没有同步。我之前用 requirements.txt 时因为没有锁版本pip install -r requirements.txt装的 torch 可能是 CPU 版本而本地装的是 GPU 版本两者 API 兼容但行为不同。后来改用 Poetry 锁版本后这种问题基本消失。还有一个小技巧在 Dockerfile 中先安装 torch 这类体积大、编译依赖少的包再安装其他包能利用 Docker layer 缓存加速构建。7.2 中文文本处理中的编码问题读 CSV 时如果出现乱码先检查文件编码是不是 UTF-8很多 Windows 下导出的数据是 GBK。我用 pandas 读文件时经常显式指定编码df pd.read_csv(data_raw.csv, encodingutf-8-sig)utf-8-sig会自动去掉开头的 BOM能避免一个看不见的\ufeff混进文本里。这个问题很隐蔽但会导致模型在推理和训练时对每个样本的特征向量都多一维噪声。7.3 类别不均衡加 class_weight 也没用时怎么办如果class_weightbalanced没起到作用很可能是少数类的样本量太低模型根本学不到足够的模式。这时候需要做数据增强尤其针对文本任务可以做同义词替换、随机插入、回译等。还有一招把问题拆成层次分类先做二分类判断是否需要售后再做更细的多分类这能在业务上减少类别不均衡的压力。7.4 部署后内存持续上涨这是服务化部署最常见的“隐藏杀手”。PyTorch 模型推理时如果每次请求都重新产生计算图或没有正确释放中间变量内存就会涨。解决办法使用torch.no_grad()包裹推理代码尽量把输入 batch 打成固定大小减少内存碎片设置容器内存上限并在内存使用超过阈值时重启容器。还有一点即使使用 joblib 加载的模型如果线上 QPS 很高进程也可能因为线程切换而变得慢。此时应优先考虑水平扩容而不是无限提高单实例的资源。7.5 测试集上指标很高线上效果差十个 AI 项目里有八个会撞上这个问题。可能原因包括数据泄漏、特征不一致、线上标注口径不同。我遇到最多的是预处理不一致训练时清洗了 URL线上推理时漏了这条结果模型表现明显变差。所以我把所有预处理逻辑收敛到一个模块里并且每次训练后都跑一个“批量回放”测试确保训练和推理使用完全相同的代码路径。8. 从零到一之后的进阶方向与个人心得如果 ai-engineering-from-scratch 这个项目让你跑通了第一条链路恭喜你。接下来我会继续在它上面做三件事第一把传统模型替换成基于 Transformer 的模型感受数据规模和模型能力之间的映射关系第二加入 MLflow 做更完整的实验追踪第三尝试把 FastAPI 服务迁移到 Kubernetes 上体验自动扩缩容和滚动发布。我个人在实际操作中的体会是AI 工程里最难的不是模型而是确定性。从数据版本到依赖版本从预处理代码到部署配置每个环节都必须确定、可复现。你越早把这个意识刻进工作习惯就越少吃线上事故的亏。最后再分享一个小技巧每次训练前先把随机种子固定了然后记录下当时的 Git commit 号这十几秒钟的操作能帮你省下未来无数个“我怎么复现不出结果”的深夜。如果你打算自己开一个 ai-engineering-from-scratch 项目不要贪多求大选一个小而完整的任务跑通这条链路比什么都重要。祝你在把模型送上线的那一刻享受那种从混乱到有序的踏实感。