1. 项目概述1.1 AI工程到底在解决什么问题如果你点进ai-engineering-from-scratch这个标题大概率已经不再满足于调个API、套个开源模型、跑通demo的阶段了。你有过这样的困惑网上教程一大把跑完一个图像分类、跑完一个文本生成好像什么都学会了但换个数据集、换个业务场景立刻手足无措。数据稍微脏一点就崩模型训练一半Loss飞了不知道从哪排查想上生产环境更是各种踩坑。我太懂这种感觉了。做了多年AI工程相关的工作我最大的体会是AI工程不等于会写模型代码它是一整套从数据、训练、评估到部署的流水线能力是在资源有限的情况下,把模型稳定、高效、可维护地跑起来的能力。From scratch的核心含义就是把这些环节一个个亲手过一遍而不是依赖现成的全家桶糊弄过去。这个项目标题要传达的就是这么一件事从零开始建立AI工程的完整认知和实操能力不靠黑盒不靠封装好的库一把梭而是自己动手把每个零件造出来、拼起来、调通它。1.2 适合谁来啃这块硬骨头我把话放在前面这个方向不适合赶时髦的人适合下面这几类被调包侠困境困住的工程师会跑开源项目会改参数但模型一出问题就束手无策想真正掌握底层原理和工程细节。从算法转向工程的角色算法岗出身但工作中越来越发现训练出一版模型只是开始把它变成稳定服务才是大头。在小团队独当一面的人没有人给你搭好基础设施数据管道、训练脚本、推理服务全得自己来你需要一套从0到1的完整方法论。想转行做AI工程的学生或开发者简历上不能再只写熟悉TensorFlow/PyTorch得有能展示的、亲手从零做出来的项目。不管你是哪一种这个项目都会逼你把手弄脏。而只有把手弄脏你才会真正理解AI工程里那些套路背后的为什么。2. 内容整体设计与思路拆解2.1 从零开始的大方向先造轮子还是先调包做from-scratch项目最纠结的第一个问题就是要不要所有东西都自己写我的观点很明确关键组件必须自己造一遍但不必重复造所有轮子。比如你做一个文本分类项目底层的数据加载可以用现成的pandas、numpy训练框架完全可以用PyTorch甚至优化器也可以直接用AdamW。但有几个东西一定要亲自实现一遍数据预处理的完整逻辑、模型的前向传播结构、训练循环、评估指标计算、推理封装、模型保存与加载格式。这些是AI工程的骨架你亲手写一遍之后用任何高级框架都会觉得通透。为什么因为调包会给你一种学会了的错觉。你用transformers库加载一个BERT跑通fine-tune你觉得你懂BERT了。但实际问你几个问题输入tokenizer之后到模型之间发生了什么attention mask是怎么参与计算的为什么需要segment id训练时为什么要把label也pad到相同长度大概率答不上来。这就是典型的假会。from-scratch项目存在的意义就是把这些假会变成真会。2.2 我的工程化主线以最小可用系统为靶心这个项目我不建议做成一个超大而全的AI平台那样会陷入无止境的过度设计最后什么都没落地。我推荐的思路是选一个足够有代表性、但又不至于失控的小任务从数据到部署完整走通再逐步替换和加固每个环节。打个比方造房子你不能一上来就想着盖摩天大楼你得先盖一间小平房,把地基怎么打、砖怎么砌、水电怎么走全走一遍然后再谈高层。我选的主线任务是一个中文新闻文本分类系统。为什么选它三个理由第一数据获取简单不需要特殊许可第二任务足够经典分类模型结构从简单到复杂都有选择空间第三它覆盖了AI工程几乎所有的核心环节——文本清洗、分词、词表构建、词向量/词嵌入、模型训练、超参数调优、模型评估、服务化部署。整条链路走通之后你可以轻松地把这套骨架迁移到推荐、搜索排序、情感分析、命名实体识别等其他任务上因为工程骨架完全是一样的。3. 核心细节解析与实操要点3.1 数据集与预处理工程问题最密集的地带很多人一上来就急着写模型但我告诉你真实项目里80%的坑都在数据环节。我处理中文新闻数据的时候遇到的脏数据五花八门全角半角混乱、HTML标签残留、XML实体符、乱码、空文本、敏感词、广告灌水、重复样本。这里我分享一下预处理的标准管线长什么样原始数据探查先别急着清洗抽样看100条数据记录异常模式你才能写出有针对性的清洗规则。编码统一全部转为UTF-8避免后续工具链各种乱码问题。文本标准化统一全角转半角、去除控制字符、规整空白。别小看这一步中文文本里藏的全角空格和零宽字符能让你后续匹配全部翻车。规则去噪正则表达式去掉HTML标签、URL、用户名、连续重复字符。比如哈哈哈哈哈哈压缩成哈哈。质量过滤按长度过滤过短文本、去重用simhash做近似去重更稳、筛掉低质量样本。所有清洗步骤我强烈建议写成独立的Python脚本每一步处理都记录处理前后的样本数量。这样你的数据管道是可审计的出问题时能回溯定位。3.2 词表构建与文本序列化数据洗干净之后下一步是把文本变成模型能吃的东西。这一步我从零实现不借助transformers的tokenizer因为只有自己写一遍你才能理解所有tokenizer背后的本质。我的做法是分词中文先按字切分或者按词用jieba这一步的选择会影响后续模型上限。词表构建统计词频取Top-N我常用50000预留[PAD]、[UNK]、[CLS]三个特殊token。序列化每个样本转成token ids序列统一截断或填充到固定长度比如128。这里有个关键决策按字还是按词。我的经验是数据量小几十万条以内用字级别更稳因为词表小、OOV问题少数据量大了之后用词级别效果更好。这个没有绝对标准要自己实验对比。你可以把两个版本都做出来用同样的模型各训一版看指标说话。3.3 模型结构选择从零手写一个浅层分类器既然是从零开始第一步我建议你手写一个不带预训练模型的浅层分类网络——Embedding层加两层全连接或者加一个单层BiLSTM都行。别觉得低级这个结构能让你清晰理解学习到底是怎么发生的。核心代码如下import torch import torch.nn as nn class TextClassifier(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes, num_layers1, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( embed_dim, hidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout if num_layers 1 else 0 ) self.classifier nn.Sequential( nn.Linear(hidden_dim * 2, hidden_dim), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dim, num_classes) ) def forward(self, input_ids, lengths): embedded self.embedding(input_ids) # [B, T, E] packed nn.utils.rnn.pack_padded_sequence( embedded, lengths.cpu(), batch_firstTrue, enforce_sortedFalse ) packed_output, (hidden, cell) self.lstm(packed) output, _ nn.utils.rnn.pad_packed_sequence(packed_output, batch_firstTrue) # 取两个方向最后一个隐藏状态拼接 last_hidden torch.cat((hidden[-2], hidden[-1]), dim1) # [B, 2H] logits self.classifier(last_hidden) return logits注意上面几个细节padding_idx0是让[PAD]位置不参与梯度更新用pack_padded_sequence是为了不把padding位置算进LSTM传播取hidden[-2]和hidden[-1]拼接是双向LSTM的标准用法。这些都是工程中真实的细节书上不会讲得这么直接。3.4 训练循环与优化器选择训练循环是最容易出现莫名其妙问题的地方。我的标准模板是这样的def train_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss, total_correct, total 0, 0, 0 for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) lengths batch[length].to(device) labels batch[label].to(device) optimizer.zero_grad() logits model(input_ids, lengths) loss criterion(logits, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) # 梯度裁剪 optimizer.step() total_loss loss.item() * len(labels) total_correct (logits.argmax(1) labels).sum().item() total len(labels) return total_loss / total, total_correct / total两个优化器层面的细节提一下。第一梯度裁剪一定要加尤其用LSTM不裁剪的话偶尔一个梯度爆炸就能让Loss从1.2飞升到几十前面所有训练全白费。第二学习率用warmup加线性衰减我用的schedule是前10%的steps线性升到峰值之后线性降到底。这个策略在Transformer时代已经被验证非常有效用在浅层网络上也不吃亏。3.5 评估体系准确率不是终点训练完成之后你必须搭建自己的评估模块。只跑一个整体准确率在生产上是远远不够的。我做分类任务至少会输出三样东西每个类别的precision、recall、F1很多不平衡数据集上整体准确率会骗人。比如欺诈检测里99%正常样本你全预测成正常也有99%准确率但毫无价值。混淆矩阵直观看到哪些类别容易互相打架比如娱乐和体育经常因为明星新闻混在一起。错误案例分析随机抽样20~50条预测错误的样本人工看一遍你会发现自己数据的特殊性——某些类别天然重叠某些label打错了。我实际跑出来的一个教训是有个房产类别的样本里充斥了大量XX楼盘开盘的广告文本模型学歪了把楼市新政也分到房产导致政策类目精度暴跌。这类问题只有看错误案例才能发现指标全绿的时候恰恰最危险。4. 实操过程与核心环节实现4.1 第一步搭建可复现的代码结构从一个空目录开始我通常的工程结构如下ai-engineering-from-scratch/ ├── data/ │ ├── raw/ # 原始数据 │ └── processed/ # 清洗后数据 ├── src/ │ ├── data/ # 数据加载与预处理 │ │ ├── clean.py │ │ ├── vocab.py │ │ └── dataset.py │ ├── models/ # 模型定义 │ │ ├── base.py # TextClassifier │ │ └── bert_ft.py │ ├── train.py # 训练入口 │ ├── evaluate.py # 评估入口 │ └── predict.py # 推理封装 ├── configs/ # 超参数配置yaml或json ├── experiments/ # 每次实验的日志和结果记录 ├── scripts/ # 一键运行脚本 └── requirements.txt这个结构是我多次试错后比较顺手的。核心原则是数据处理、模型定义、训练逻辑、推理逻辑完全分离。你后续换模型、换数据、换任务时改动的范围会非常小。4.2 第二步用配置文件管理所有超参数刚开始写项目的时候我也习惯把超参数硬编码在训练脚本里直到有一天我把learning_rate1e-3改成了1e-4却忘了记录第二天看实验结果一头雾水不知道哪来的差异。从那之后我养成了习惯所有超参数全部进配置文件。# configs/base.yaml data: train_path: data/processed/train.csv valid_path: data/processed/valid.csv max_len: 128 min_freq: 3 vocab_size: 50000 model: name: bilstm embed_dim: 200 hidden_dim: 256 num_layers: 2 dropout: 0.3 train: batch_size: 64 epochs: 20 lr: 0.001 scheduler: linear_warmup warmup_ratio: 0.1 weight_decay: 0.01 grad_clip: 5.0 seed: 42 output: exp_dir: experiments/bilstm_v1每次跑实验之前固定随机种子seed42这样保证实验可复现。我见过很多同事复现不了自己实验全是随机性没控制好。训练脚本里加三行import random, numpy as np, torch random.seed(config[train][seed]) np.random.seed(config[train][seed]) torch.manual_seed(config[train][seed])4.3 第三步训练观测与分析训练的时候我强烈推荐用TensorBoard或WandB实时监控不必等到全训完再看结果。我至少会盯这几条曲线train loss / valid loss观察过拟合train acc / valid acc观察泛化learning rate曲线确认scheduler生效梯度范数确认没有梯度爆炸这里有一个非常实用的判断标准如果valid loss先降后升而train loss持续下降说明模型开始过拟合了。此时不是继续闷头训练而是应该回到配置里去调dropout、weight_decay或者早停。我在这个项目里就实测到第12个epoch时valid F1达到峰值0.912之后再训F1反而下滑到0.905。如果不做早停我最后保存的就是一个效果更差的模型。所以训练循环里我加了early_stopping_patience3连续3个epoch valid F1不提升就自动停掉并保留最佳checkpoint。4.4 第四步推理服务化封装模型训练好后下一个问题是怎么让别人用起来我分了三层来做。第一层是离线批量推理加载模型对一批文件里的文本做预测输出结果到CSV。这个最简单适合日志分析、离线挖掘场景。第二层是在线HTTP服务我用FastAPI封装一个极简预测接口from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class PredictRequest(BaseModel): texts: list[str] app.post(/predict) def predict(req: PredictRequest): inputs [tokenizer.encode(text) for text in req.texts] results model.predict_batch(inputs) return {predictions: results}注意一个细节在线服务要提前把模型加载到内存不能每个请求都重新加载模型否则服务根本扛不住。另外model.eval()模式要设置好并且整个推理过程用torch.no_grad()包裹省显存也加速。第三层是性能与稳定性加一个简单的接口超时控制用gunicorn多进程启动服务用/health探活。如果QPS要求高就上批量推理和缓存同一条文本在短时间内重复预测结果直接走缓存。4.5 第五步模型保存与版本管理模型保存这件事很多人直接torch.save(model.state_dict(), model.pt)就完事了但这在生产上是个坑。我见过同事实测踩坑训练环境用Python 3.9、PyTorch 1.13部署环境用Python 3.11、PyTorch 2.1加载老checkpoint直接报错。我的保存方案是三件套打包model_state.pt模型权重。config.json模型结构参数vocab_size、embed_dim、hidden_dim等这样加载时能重建模型结构。vocab.json词表文件推理时tokenizer必须有和训练时完全一致的词表。加载模型时这样写def load_model(model_cls, state_path, config_path, vocab_path): with open(config_path, r) as f: model_config json.load(f) model model_cls(**model_config) model.load_state_dict(torch.load(state_path, map_locationcpu)) with open(vocab_path, r) as f: vocab json.load(f) return model, vocab另外每次实验的产物用实验ID命名存放比如experiments/bilstm_v1_run3/model_state.pt。你永远想不到两天之后你还能不能记得experiments/model_final.pt到底是哪次训练产生的。5. 常见问题与排查技巧实录5.1 Loss不降或下降缓慢这是新手问得最多的问题之一。我的排查顺序是固定的先看数据标签是否严重倾斜是不是某个类别的样本数比其他类别多两个数量级赶紧做类别重采样或调整loss权重。再看学习率太大直接震荡不收敛太小龟速下降。我的排查方法是先跑50个step打印每步loss如果前20步loss完全没变化赶紧把lr调大10倍再试。看看模型输出模型是不是输出全零或者全同一个值检查初始化方式。还有一个很多人忽略的点默认的CrossEntropyLoss自带Softmax不要再在模型输出层额外加Softmax否则梯度会变得很怪。我排查过一个同事的loss不下降问题就是因为输出层加Softmax后又接了CrossEntropyLoss。5.2 显存OOM实际操作中80%的OOM其实不是模型太大而是batch内padding太长。比如一批数据里有一条长达500字的文本其他都是20字左右你按max_len512统一塞进去显存直接爆炸。解决方案就是动态paddingdef collate_fn(batch): input_ids, labels zip(*batch) lengths torch.tensor([len(ids) for ids in input_ids]) max_len_in_batch lengths.max().item() padded torch.zeros(len(input_ids), max_len_in_batch, dtypetorch.long) for i, ids in enumerate(input_ids): padded[i, :len(ids)] torch.tensor(ids) return {input_ids: padded, length: lengths, label: torch.tensor(labels)}这样每批只按本批最长文本pad而不是全部pad到512显存占用能降低一半以上。5.3 模型上线后线上效果远差于离线这个问题我见得太多。离线指标97%上线之后客户反馈一塌糊涂。最核心的原因通常是训练数据分布和线上真实分布不一致。比如新闻分类的模型训练数据来自某一类新闻源但线上全量新闻包含大量地方媒体内容用词习惯完全不同效果自然崩。应对方案上线前一定要分析模型在OOD样本out-of-distribution分布外样本上的表现。实操里我会专门收集一批线上真实数据打标后当作额外的测试集来评估绝不自欺欺人地说离线指标很好就行。5.4 词表不一致导致预测错乱假设你训练时词表有50000个词但推理时不小心用了一个重新构建的只有30000词的词表预测结果会完全错乱且你很难发现原因。我建议在predict.py里加一道校验加载模型时对比vocab_size是否和模型配置一致不一致直接抛异常。这种防御式编程在工程上能省下好几个小时的排查时间。我再分享一个细节别用pickle保存词表json虽然慢一点但可读、可查、跨版本兼容。pickle的问题在于如果训练时的Python版本和部署时不同可能加载失败而且它是不可读的出了问题你根本不知道里面装的是啥。6. 工程化进阶方向6.1 从单模型到多模型管理当你手里的模型多起来之后会发现每一项工程问题都值得再深入学习。比如模型A在提升但模型B在退化怎么统一管理实验记录推荐用mlflow记录每个实验的参数、指标、产物路径。多个模型上线怎么灰度切换流量先切10%线上流量到新模型观察业务指标没问题再逐步放量。模型版本回滚新模型上线后效果比旧的差怎么快速切回模型服务层要支持按版本号加载。这些是MLOps的领域但from-scratch项目一定会把你逼到这个阶段因为亲手做过一遍你才知道哪一环最薄弱。6.2 数据管道自动化到后期人工跑清洗、训练、评估脚本已经不够用了。我会把整个流程做成定时任务每天凌晨拉取新增数据自动清洗并做质量校验空文本比例超过阈值就报警触发增量训练或定期全量重训自动评估如果F1超过线上版本则自动上线这套流水线用apscheduler或airflow都能搭核心是每步的输出都要落盘并记录元数据方便追溯。6.3 模型可解释性的工程应用最后提一个很多人忽视的方向可解释性不是学术爱好而是工程刚需。我在新闻分类项目里就遇到了一个场景客户质疑模型为什么把他的文章分错了。如果没有解释工具你只能干瞪眼。我用了一个非常朴素但有效的方法注意力权重可视化。对BiLSTM模型来说没有注意力机制所以我会用LIME一种局部可解释方法生成解释扰动输入文本中的字词观察预测变化找出对预测结果影响最大的几个词。实际操作中我把LIME的输出整理成一个重点词列表返回给业务方很多纠纷就能快速定位到是数据噪声还是模型问题。这个方法虽然朴素但在排障场景下比任何花哨的论文算法都好用。工程上解决问题不追求最先进只追求最有效。7. 学习路线与关键资源7.1 三阶段进阶法如果让我把这个项目拆成三个循序渐进的阶段我建议这样分配阶段一基础轮子期1~2周手写数据清洗和词表构建手写Embedding LSTM/CNN分类器跑通完整训练评估循环目标理解数据怎么变成张量、模型怎么学习、评估指标怎么计算阶段二进阶模型期2~3周手写一个简化版Transformer只保留self-attention和前馈网络替换LSTM分类器对比效果差异加入学习率调度、早停、梯度裁剪等稳定训练技巧目标搞懂现代模型核心机制并掌握训练稳定性技巧阶段三系统工程期2~4周封装推理服务加入模型版本管理和实验记录部署到测试服务器跑通完整上线流程目标从训练出模型跨越到做出一个可用系统每个阶段结束都要能完整回答别人针对细节的追问。回答不了说明这关还没过完。7.2 参考资源与避坑书目关于参考资料我建议少看速成性质的博客多看源头资料PyTorch官方文档与教程数据加载、优化器、分布式训练的用法全都以官方文档为准别信二手教程。《Dive into Deep Learning》动手学深度学习这本书配合代码看分类、序列建模、注意力机制讲得都很清楚适合通读两遍以上。《Designing Machine Learning Systems》Chip Huyen这本书偏工程讲特征工程、模型部署、监控适合阶段三再看。各路开源项目源码多读distilbert、minGPT这类精简实现注意我说的是读源码不是复制黏贴最好能自己重写一遍。一个重要的原则遇到概念别急着搜中文博客先看官方文档或英文原版。中文社区的二手知识质量参差不齐很多文章连代码都跑不通就在那里普及概念你跟着学只会浪费时间和信心。8. 写在最后的经验之谈这个项目走到这里你已经把数据管道、模型构建、训练调优、评估分析、服务部署整条链路亲手走了一遍。说句心里话做完这套东西之后你再去看那些3小时上手AI的教程心态完全不一样了——你会清清楚楚地知道那些教程教你的只是自动挡的踩油门而你已经知道发动机的每个零件是怎么协同工作的。最后分享一个我自己重复过无数次的判断标准如果让你在能跑通一个预训练模型的fine-tune和能用numpy和基础库从零手写一个简单模型并部署上线之间选一个作为简历能力证明我一定选后者。前者证明你会用工具后者证明你理解工程。别怕慢from-scratch这条路慢就是快。那些你亲手填过的坑会在未来不知道哪个项目里变成你最快定位问题的直觉。这种直觉没有任何速成班能教给你。