把项目取名叫ai-engineering-from-scratch的时候,其实带着点倔强。市面上讲 AI 应用、讲调 API、讲 prompt 技巧的内容已经够多了,但真正想从零理解一条数据是怎么变成模型、再变成线上服务的,反而找不到一条清晰的路。这个项目就是干这件事的:不靠框架黑盒,不靠拖拽平台,靠手写、靠实验、靠一步步把 AI 工程的最小闭环跑通。如果你是个想入门的开发者,或者已经在业务里被各种概念术语砸得头晕的工程师,这篇博文就是我当时梳理这套知识体系时留下的笔记,希望能帮你把“AI工程”这四个字落到地上。
1. 项目定位与整体设计思路
1.1 为什么选择“从零手写”而不是直接上框架
动手之前我纠结过很久:现在 PyTorch、Transformers 库这么成熟,几行代码就能加载一个预训练模型,为什么还要从零开始?答案是——框架屏蔽了太多细节,而这些细节恰恰是工程化时逃不掉的问题。
举一个很实际的例子:你调model.generate()拿到一段回复,但你知道温度参数到底在控制什么吗?知道为什么同一个 prompt 每次回答都不一样吗?如果不知道生成策略、采样算法、概率分布这些底层逻辑,那你连“调参”都算不上,只能叫“试数”。从零手写不是要重复造轮子,而是为了获得对系统的解释能力。框架负责效率,理解负责判断,两者不冲突。
所以这个项目的设计原则很明确:每个模块先用最朴素的代码实现一遍,再切换到成熟工具链做工程化。比如分词器,我先用 Python 手写一个基于字符的简单版本,搞清楚词表和编码的关系之后,再引入tokenizers或 Transformers 自带的分词器。这样遇到报错的时候,你脑子里的地图是全的,知道问题出在哪一层。
1.2 项目核心链路:从数据到部署的六大环节
整个项目的范畴可以用一条链路概括,后面所有实践都是围绕这条链路展开的:
- 数据工程:获取、清洗、标注、划分、构建数据管道
- 模型训练:网络结构设计、损失函数、优化器、训练循环
- 评测体系:指标设计、验证集策略、误差分析方法
- 部署服务:模型导出、推理优化、API 服务、并发处理
- 监控迭代:数据漂移、效果回归、持续训练
- 工程治理:实验追踪、版本管理、配置管理、团队协作
这条链路对应一个 AI 产品的完整生命周期。很多人以为 AI 工程等于“训练模型”,实际上训练可能只占整个周期 20% 的时间,数据准备和部署调优才是大头。这个项目把每一环都做了一个最小可运行的小型实践,并且刻意不先引入重量级工具,比如不用 Airflow 做数据管道,就是让你习惯用最简单的方式思考问题,等复杂度压上来你再决定是否引入更重的系统。
1.3 适合哪些人参考,以及需要什么基础
先说谁不适合:如果你只想知道怎么调用 GPT 接口做业务,看完这篇文章肯定觉得绕了远路。但如果你属于下面几类人,这个项目大概率对你有用:
- 做后端的工程师,想往 AI 工程方向迁移,但缺少一个系统性的落地路径
- 算法工程师的初级状态,能把模型跑通但总被工程化问题卡住,比如显存不够、上线延迟高
- 技术团队负责人,想评估“从零自研 AI 能力”和“采购外部服务”之间到底有多大差距
前置基础不需要很高:熟悉 Python 语法、懂一点线性代数和概率论就足够了。高等数学里的梯度推导在实际工程里用得不算深,大部分时间你在跟数据格式、资源调度、评测逻辑打交道,数学门槛远没有想象中高。我甚至在项目里专门准备了一个“可能用到但不用死磕”的基础概念清单,把特征值、梯度下降这些概念的直觉讲清楚,但不要求读者做公式推导。
1.4 方案的边界:什么场景适合自研,什么场景不该自研
说白了,自研 AI 能力是要付出巨大的时间成本的,你得在动手之前先衡量值不值。我的建议是:如果业务核心在于“模型能力本身”,比如你做一个特定领域的问答系统,模型效果直接决定产品竞争力,那值得投入自研;如果 AI 只是你产品里的一个辅助功能,比如给评论做情绪分析、做文本摘要,那直接调用现成的 API 或开源模型微调就够了,自研训练整个模型纯属浪费。
这个项目从设计上就鼓励这种理性判断。我在链路里专门加了一节“需求评估”,让你在写第一行代码之前先回答五个问题:数据从哪来、错误是否可容忍、模型表现需要达到什么指标、计算资源预算是多少、上线后的维护人力有多少。答案不清晰,后面每一步都是空中楼阁。
2. 核心细节解析与实操要点
2.1 数据工程:先让数据长成模型能看懂的样子
所有 AI 问题本质上都是数据问题,这句话不是口号。我在项目里踩过的最大坑就是在数据上过于乐观:以为拿到一批文本就能直接开训,结果脏数据、标签噪声、类别不平衡、泄漏问题全在后期爆炸。数据工程的核心动作是四步:采集、清洗、转换、划分。
清洗这一步容易低估。一个很典型的场景:从网页上抓下来的文本,里面全是 HTML 标签、转义符号和广告噪声,如果你不去除掉,模型会在无关信息上浪费大量参数空间。转换这一步要把文本变成 Tensor,核心概念是 tokenization(分词)和 vocabulary(词表)。我的项目里用最简代码实现了一个基于字符的 tokenizer,逻辑是把每个字符映射到一个整数 ID,再把句子 pad 到等长。
数据划分是另一个很容易被轻视但后果严重的环节。传统机器学习里把数据分成 train/val/test 就够了,但在 AI 工程里你还要特别防止数据泄漏——比如训练集和验证集里出现了来自同一条新闻的不同段落,那模型在验证集上的分数就虚高了。最常见的数据泄漏来源是:文本去重没做干净、特征工程时用了未来信息、样本划分没有按业务维度隔离。
2.2 模型结构:从 Embedding 到 Transformer 的直觉建立
模型结构是这个项目里最需要“直觉”的部分。我的办法是:不求在数学上完全严谨,但一定要建立从输入到输出的数据流直觉。一个输入文本先经过 tokenizer 变成 ID 序列,ID 序列经过 Embedding 层变成向量序列,向量序列经过多头注意力层和信息融合层,最后接一个输出层产生预测结果。
Embedding 层的本质是一个查询表(lookup table),每个 token ID 对应一个可学习的向量。项目里我会先忽略随机初始化,直接用 nn.Embedding 这个模块,但会和读者解释清楚,这个模块其实就是维护了一个形状为[vocab_size, hidden_size]的矩阵,查询操作等价于矩阵索引。
总参数量是工程估算的第一步。参照 GPT-2 small 的尺寸,vocab_size约 5 万,hidden_size为 768,num_layers为 12,num_heads为 12。注意力层参数量约等于4 * hidden_size * hidden_size(四个权重矩阵),再加上 Embedding 约5万*768=3840万,整体参数量在 1.2 亿左右。算这个不为了装,是为了估算显存占用和推理延迟,这在部署阶段尤其关键。
2.3 训练策略:学习率、Batch Size 与损失函数的选择
训练策略决定了模型能不能收敛、收敛得稳不稳,以及最终效果上限。我的经验是:学习率是第一个要调的参数,Batch Size 是第二个,两者之间存在互动关系——增大 Batch Size 通常会降低梯度的噪声,这时可以适当增大学习率;但如果学习率过大会导致训练后期震荡,就必须要配合学习率衰减策略。
项目里我给了一个默认配置模板:AdamW 优化器,初始学习率 5e-5,线性衰减加 warmup 比例 10%,Batch Size 设为 32,训练 3 个 epoch。这组参数在中小规模文本分类数据上稳定性很高,它的意义在于:让你先跑通一个“能工作的系统”,再基于验证集表现做定向调优,而不是一上来就陷入调参的无底洞。
损失函数的选择要依据任务类型。二分类用 BCE(二元交叉熵),多分类用 CE(交叉熵),回归任务用 MSE。很多新人会忽略一个细节:在你处理文本分类时,模型输出的 logits 和 label 的形状要对齐,否则 PyTorch 会自动广播,然后悄悄算出个错误的值,报错都没有。这也是我坚持从零手写训练循环的原因——每一步张量形状的变化都必须清清楚楚。
正则化手段同样值得重视。Dropout 在 Transformer 结构中已经成为标配,一般设置在 0.1 到 0.3 之间。权重衰减(weight decay)对防止过拟合也有帮助,但要注意它不应该作用于 Embedding 层和 LayerNorm 层,这一点在实现上要用 parameter group 区分,AdamW 里通过no_decay参数名列表来实现。
2.4 评测体系:别让准确率骗了你
这是项目里我花了很多笔墨强调的一节。单一指标在真实业务场景里是危险的,尤其是在类别不平衡的数据集上。项目里我拿垃圾邮件分类举例:垃圾邮件只占 1%,如果一个模型把所有邮件都预测为“正常邮件”,准确率是 99%,但这个模型毫无价值。所以要在工程实践里同时看精度、召回率、F1 分数,还要画混淆矩阵观察错误模式。
评测的另一层是分析 bad case 的模式。我在项目里做了一个固定动作:每轮验证结束,把预测错误的样本里“模型置信度最高”的前 20 条打印出来,逐个分析。这个动作看着简单,却是模型优化的核心驱动力,很多数据问题会在这一步暴露出来,比如标注员标准不一致、某类样本和另一类的边界天然模糊、某个槽位的真实分布与训练集分布不符。
离线评测过不了关就别考虑上线。但我见过很多团队在离线指标和线上业务指标上脱节,原因通常是“离线指标不等于业务结果”。比如你做文本分类,离线 F1 达到 0.9,看起来很好,但线上用户真正在意的是脏话文本是否被漏判,而这条恰恰是召回率最差的子类。所以我建议在项目里额外定义一两个关键业务指标,比如“高危样本漏判率”,在评测报告里单独展示。
2.5 部署与服务:推理优化和并发设计
模型训练完后,部署环节的弯路相当多。推理和训练虽然用的同一套模型,但完全可以是两套优化逻辑:训练追求梯度精度,推理追求低延迟、稳定吞吐。项目里做了几个优化动作:半精度推理、batch 推理、动态 shape 处理。
半精度(FP16)推理是性价比最高的一步,在支持 FP16 的 GPU 上速度提升明显且不需要太多额外改造。要注意的是少数数值敏感的层,比如 LayerNorm,使用 FP16 可能出现精度问题,这块可以在导出模型时拆出来保留 FP32。另一个建议是不要单独处理单条请求,尽量把多请求拼成 batch,因为 GPU 并行能力很强,batch=1 与 batch=8 的耗时差距远小于 8 倍,这笔账值得算清楚。
写推理服务时,我强烈建议在服务内部维护一个显存预算表。比如 GPU 显存是 16GB,模型权重 FP16 占 1.2GB,激活值中等长度序列占 2GB,KV cache 预留 3GB,再给接口并发预留 3GB,剩余空间归缓冲。算这个数字不需要很精确,但心里没谱,线上可能在下班高峰期突然 OOM。
2.6 监控与迭代:模型上线只是开始
模型上线之后,监控的价值甚至超过训练。我见过太多项目模型部署后就没有人再回头看效果,结果数据分布发生了漂移,模型的表现在用户侧悄悄下滑,等察觉到时已经造成了业务损失。监控体系要关注三种漂移:数据漂移、概念漂移、行为漂移。
数据漂移最直观,就是线上输入的分布与训练集不一致了。比如训练时用户输入是长句式,上线一段时间后短文本占比猛增,模型效果自然会下降。概念漂移更隐蔽,比如垃圾邮件的形式变了,模型还是按旧形式判断,而业务定义的“垃圾”也在变。行为漂移则是指用户因为模型的行为而改变了自己的行为,比如推荐系统越推越窄,用户在反复看到同质内容后干脆不点击了——这反过来又让模型收到“用户不感兴趣”的假信号,形成恶性循环。
监控不只是画图表,要有明确的告警动作。我的项目里给模型预测结果留了抽样存储,每天自动抽样一定比例线上请求,计算当日的 F1 或业务关键指标,与前一天对比,偏差超过阈值就触发告警,并把当天的 bad case 转储到数据集里供后续模型迭代用。这个闭环是模型效果能否长期稳定的关键。
3. 实操过程与核心环节实现
3.1 搭建训练环境:别让版本问题浪费半天时间
进入实操阶段,第一件事就是搭环境。Python 版本选择 3.10 或 3.11,PyTorch 使用 2.x 稳定版,CUDA 版本与你机器 GPU 驱动匹配即可。这里最重要的一条经验是:用虚拟环境,别在全局环境里装任何 AI 依赖。我习惯用conda或uv起一个独立的虚拟环境,每次项目新建一个,即使环境炸了也能干净重建。
conda create -n ai-eng python=3.11 conda activate ai-eng pip install torch --index-url https://download.pytorch.org/whl/cu121 pip install transformers datasets accelerate evaluate这里有一个很实际的选择:到底用 GPU 还是 CPU 训练。小规模 demo 用 CPU 完全能跑,就是慢一点;但如果数据集稍大,CPU 训练三个 epoch 足够让你怀疑人生。我的建议是,哪怕你在云上租一个 T4 GPU 实例,成本也就一杯咖啡的价格,它能为你的调试迭代节省大量时间。本地开发做代码验证,远程 GPU 做正式训练,这套模式是小团队最舒服的工作流。
3.2 第一步实践:训练一个文本分类模型
下面用一个文本分类任务(比如影评情感分类)串起整个流程。这是项目里最基础也最能打通全链条的实验。数据集用的是 IMDb 影评数据集,但以下代码同样适用于任何 CSV 格式的文本分类数据。
先定义一个简单数据集类:
import torch from torch.utils.data import Dataset class TextDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): encode = self.tokenizer( self.texts[idx], truncation=True, max_length=self.max_len, padding="max_length", return_tensors="pt", ) return { "input_ids": encode["input_ids"].squeeze(), "attention_mask": encode["attention_mask"].squeeze(), "label": torch.tensor(self.labels[idx], dtype=torch.long), }再写好训练循环的主体:
def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss = 0 for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["label"].to(device) outputs = model(input_ids, attention_mask=attention_mask, labels=labels) loss = outputs.loss optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)训练规模不要小看,但也不要贪大。对小数据集,我用的是一个三层的 Transformer Encoder,隐藏层 128、注意力头 4 个,这个配置用 CPU 也能在几个小时内完成训练,效果足够做实验验证。模型正文代码略去,因为直接引用了 HuggingFace 的AutoModelForSequenceClassification来加载基础模型,这里更关键是理解训练流程本身。
3.3 评测脚本:用一个脚本把多维度指标算出来
训练完之后写一个评测脚本,一次性输出准确率、精确率、召回率和 F1,另外打印混淆矩阵和 bad case 列表:
from sklearn.metrics import ( accuracy_score, precision_score, recall_score, f1_score, confusion_matrix ) def evaluate_model(model, dataloader, device): model.eval() 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) outputs = model(input_ids, attention_mask=attention_mask) preds = torch.argmax(outputs.logits, dim=-1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(labels.cpu().tolist()) metrics = { "accuracy": accuracy_score(all_labels, all_preds), "precision": precision_score(all_labels, all_preds, average="weighted"), "recall": recall_score(all_labels, all_preds, average="weighted"), "f1": f1_score(all_labels, all_preds, average="weighted"), } cm = confusion_matrix(all_labels, all_preds) return metrics, cm这组脚本会贯穿项目后续的所有实验,无论你换模型、换数据集还是换任务类型,核心测评逻辑都不会变。我建议你一定要把它做成一个可复用的模块,而不是每次都临时写一遍。
3.4 模型导出与服务化:从 PyTorch 模型到 HTTP 接口
训练结束不代表事情做完了,你要把模型变成线上可用的服务。老版本 PyTorch 导出模型常用torch.save,但这个格式对线上部署并不友好。当前推荐的做法是把模型转成 ONNX 或者直接使用 PyTorch 的 TorchScript 导出。ONNX 的好处是可以借 ONNX Runtime 在 CPU 上获得数倍加速,而且对后端环境的依赖更小。
import torch dummy_input = { "input_ids": torch.randint(0, 30000, (1, 128)), "attention_mask": torch.ones(1, 128, dtype=torch.long), } torch.onnx.export( model, (dummy_input["input_ids"], dummy_input["attention_mask"]), "model.onnx", input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch_size"}, "attention_mask": {0: "batch_size"}, }, opset_version=17, )对比一下三种部署路径:torch.save最方便但依赖 PyTorch 环境;ONNX 格式最通用但需处理某些算子的兼容性;直接 Python 装载模型并封装 Flask/FastAPI 服务最灵活,但并发性能偏弱。小团队做 MVP 可以先从 Python 服务开始,等流量大了再引入 Seldon、KServe 等更重的推理平台。
3.5 一个小而完整的调度实验:让批量推理的吞吐翻倍
部署环节最立竿见影的优化就是批量推理。我做一个对比实验:同一个模型,400 条测试样本,一条一条发请求 vs 分成 8 个 batch 发送。在同等 GPU 上,batch=1 的总耗时 4.2 秒,batch=8 的总耗时 1.1 秒,吞吐提升接近 4 倍,延迟却没有显著恶化。原因在于 GPU 的并行计算能力在 batch 较小时被大量浪费了。
这个实验也在提醒你:评估推理性能时不要只盯单条延迟,吞吐量同样重要。对于大部分包含 AI 能力的业务系统,能扛住高峰并发比单条快几十毫秒更有价值。设计接口时我建议预留一个可配置的max_batch_size参数,网关层做微批聚合,把同窗口内的请求拼批送进模型,这样能更好地利用 GPU 计算资源。
4. 常见问题与排查技巧实录
4.1 显存溢出:先算账再调参数
显存溢出(OOM)是训练和推理阶段遇到频率最高的报错。我的排查习惯是先把“账”算清楚再动手改代码。以 12 层 Transformer、hidden_size 768、序列长度 128、batch size 32 为例,模型权重约 1.2 亿个参数,FP32 下占 480MB;一次前向传播的激活值约占 2.5GB;优化器状态(Adam 需要动量和二阶矩)再加 1.4GB;梯度本身 480MB。加起来的训练显存需求大概在 4.8GB 左右,16GB 显存完全够用,但如果把 batch size 直接从 32 提到 128,激活值会线性涨到 10GB,那就危险了。
所以遇到 OOM 时,我的顺序是:
- 先降 batch size 到原来的一半,验证能不能跑通
- 打开混合精度训练(AMP),训练显存立减约 30%-40%
- 检查是否使用了 gradient accumulation,用几个小 batch 模拟大 batch 更新
- 最后才考虑换更大的设备
混合精度训练的实现很简单,PyTorch 自带torch.cuda.amp.autocast和GradScaler,两行代码就能接上。要注意的是并非所有算子都适合 FP16,比如 softmax 和 layernorm 容易出现数值下溢,PyTorch 会在 autocast 模式下自动选择合适精度,所以大部分场景你是安全的,但自定义算子时需要留意。
4.2 训练 Loss 不下降:按层逐步排除故障
Loss 一直不降是新手最容易慌的情况。我的排查路径是:先看数据流是否正常,再看到底是模型的问题还是优化器的问题。最简单的方法是用一个极小的数据集(比如 64 条样本)做单 batch 的过拟合实验,如果模型在这么小的数据上都学不到东西,那问题大概率在模型结构或代码逻辑上。
常见的无效原因其实非常初级,比如 label 没归一化、学习率过大或过小、数据里包含大量 NaN 值、优化器状态没有清零等。我见过最啼笑皆非的一次:有人在训练循环里忘了调用optimizer.zero_grad(),导致梯度一直累积,loss 反复震荡永不收敛。这种问题写一百页文档都不如亲自踩一次坑来得深刻。
Loss 出现异常值时,第一反应应该是“反着检查数据”,而不是去改模型。用几行代码单独打印输入样本和对应 label,确认 tokenizer 结果与文本语义一致。因为 transformer 本身很稳定,大部分“loss 飞了”的根因是数据异常,比如某个文本里塞了二进制文件内容、编码坏了、标签序号越界等。
4.3 过拟合与欠拟合:实验记录中找规律
过拟合和欠拟合的区分有一点经验可循。过拟合的典型信号是训练集 loss 持续下降但验证集 loss 在某个 epoch 开始上升;欠拟合则是两者的 loss 都居高不下。过拟合的应对手段有增大数据量、加 Dropout、权重衰减、减小模型容量、做数据增强;欠拟合则是反向操作,加大模型容量、减少正则、多训练几个 epoch。
真正难的不是识别,而是记录。我在项目里保持一个强制习惯:每个实验都会记录配置、数据集版本、训练时长、最终指标、bad case 摘要到一张表里。没有实验记录,你连过拟合是从哪个 epoch 开始出现的都无从判断,更别说什么调优策略了。这种记录本身也是一种工程素养,后面换人接手时价值会被放大很多倍。
4.4 推理延迟过高:先拆时间分布,再找优化点
如果线上请求的平均延迟超过预期,不要凭感觉优化,先做 profile。我写了一个简单的计时器,将一次请求切分成“网络传输”“tokenize”“模型推理”“反 tokenize”“响应返回”五段,逐段统计耗时。大部分情况下你会发现瓶颈在模型推理,但偶尔也会有意外,比如 tokenizer 在生产环境意外地慢、GIL 锁导致并行失效、内存分配抖动带来延迟毛刺。
定位到瓶颈后,优化优先级依次是:
- 模型量化 / 半精度推理
- 动态 batch 聚合
- 服务层缓存(对完全重复的输入直接返回缓存结果)
- 转移部分计算到非关键路径
当你的服务真的需要支撑高并发时,考虑异步推理框架,把推理任务丢进队列,由 worker 批量执行,而不是靠多线程硬扛。这个方案能同时解决延迟和 GPU 利用率的问题,但要引入消息队列组件,复杂度会略有上升,属于业务量到那个阶段再动刀的事。
4.5 新手最容易忽视的“数据泄漏”与“标签错位”
数据泄漏这个坑我几乎每次做项目都会提醒。第一次做文本分类时,把全量数据做了随机切分,然后训练集和验证集之间出现了大量相似文章,模型在验证集上的 F1 高达 0.98,兴高采烈地拿到业务方一测,直接崩到 0.6。原因就是验证集泄露了训练信息。
数据泄漏的关键是去重和按业务逻辑切分。比如新闻分类业务,一条新闻的不同栏目版本不应同时出现在训练集和验证集;用户对话分析业务,同一个用户的多次对话不应被拆开到两边。这里给的硬建议是:切分之前,先基于文本相似度做一次全库去重,再按业务主键做 StratifiedSplit。这个过程几行代码就能实现,但带来的收益远比提升一个模型结构大。
标签错位则是另一个高频问题,常见于人工标注流程不规范时。你看着数据是 CSV 文件,文本在第 3 列,标签在第 7 列,但某几行有一处多余的逗号,导致整行解析错位,模型居然也在这种错位数据上收敛了,只是效果差得莫名其妙。所以我做数据加载一定会先写一个“数据体检”脚本,打印若干行样本的文本内容和标签,人工扫一眼,这个习惯帮我拦下过许多次无意义的训练浪费。
收尾:一点个人体会
在把这个项目从零搭起来的过程里,我最大的体会是:AI 工程能力不是靠背 API 学来的,而是靠亲手把哪怕一个小功能完整地走一遍学会的。从那之后我再看到某个新框架发布,第一反应不是去刷它的文档,而是用已有的知识结构去对照:它解决的是这条链路里哪一环的问题?它替代了哪部分重复劳动?它带来了哪些新的边界条件?当你脑子里有了一副端到端的地图,学习任何新工具的 cost 都会大幅下降。
最后分享一个小习惯:每次实验跑完,我都会把这次的配置文件、训练日志、评测输出整理进 git commit,并在 commit message 里写下“参数变动原因 + 实验结论”,哪怕结论是“无效”。三个月后回看这些 commit,你会发现它们拼凑出来的就是你在这个领域里真正的积累曲线。希望这篇把 ai-engineering-from-scratch 拆解开来的笔记,能帮你少走一些我走过的弯路。