
1. 从零搭建AI工程能力这个项目到底在解决什么问题第一次看到 ai-engineering-from-scratch 这个标题我脑子里蹦出来的第一个念头是终于有人把这件事说清楚了。市面上讲AI的教程铺天盖地但绝大多数要么停留在调包层面——import torch 然后跑个预训练模型要么直接跳到论文精读中间那一大段“工程化落地”的空白地带几乎没人系统性地讲。这个项目标题里的 from-scratch 才是真正的题眼它瞄准的不是“会用AI”而是“能把AI系统从零搭起来并且跑稳”。说白了这个项目要解决的核心问题是一个具备基本编程能力的人如何在不依赖大量现成高级框架封装的前提下理解并亲手实现AI工程链路中的关键环节。这包括数据处理管线的搭建、模型训练循环的编写、推理服务的部署、性能监控与调优等等。它适合谁适合那些已经会写Python、懂一点机器学习基础概念但在面对“把模型真正跑在生产环境”这件事时感到无从下手的人。也适合那些想真正搞明白“框架底层到底干了什么”的开发者。我自己带过不少刚入行的工程师最常见的困境就是用HuggingFace的pipeline三行代码就能跑通一个情感分析但一旦线上QPS上来了、延迟飙了、显存爆了就完全不知道从哪查起。这个项目的价值就在于它逼着你从最底层开始理解每一个环节当你亲手写过一次数据加载器、手写过一次反向传播、手写过一次批处理推理逻辑之后再回头看那些高级框架你就能一眼看穿它在帮你做什么、在哪些地方做了取舍。关键词 ai-engineering-from-scratch 拆开来看三个词各有分量。AI 界定了领域engineering 强调的是工程实践而非理论研究from-scratch 则明确了方法论——从零开始、亲手实现。这三个词组合在一起指向的是一条学习路径不是先学理论再学工程而是通过工程实践来反向驱动对理论的理解。这个思路我个人非常认同因为纯理论学起来太容易陷入“每个字都认识但连起来不知道在说什么”的状态而带着工程问题去学每个知识点都有落脚点。2. 整体设计思路为什么选择“从零手写”这条路2.1 手写实现与框架调用的取舍逻辑很多人会问都什么年代了为什么还要从零手写直接用PyTorch、TensorFlow不香吗这个问题我在不同场合被问过不下几十次。我的回答一直是手写的目的不是让你在生产环境里真的手写而是让你具备“看穿抽象层”的能力。打个比方你学开车教练会让你先了解离合器怎么工作、发动机转速和档位怎么配合但实际开车的时候你用的是自动挡。手写AI工程链路也是一样的道理。当你亲手实现过一个简化的Transformer推理过程你就知道为什么KV Cache能加速、为什么batch size增大会导致显存非线性增长、为什么有些操作在GPU上反而比CPU慢。这些直觉光靠调API是永远建立不起来的。这个项目的设计思路我推测基于常见实践大概率是沿着“数据→模型→训练→推理→部署→监控”这条主线每个环节都先讲清楚原理然后给出一个最小可运行的手写实现最后再对比工业级框架的做法。这种“先手写再对比”的节奏比一上来就讲框架源码要友好得多也比纯讲原理要实用得多。2.2 技术栈选择的底层考量从工程角度这类项目通常会选择Python作为主语言原因很直接AI生态的绝大多数工具链都是Python优先的。但“from-scratch”的精神意味着项目可能会刻意限制对高级封装的依赖。比如数据处理环节可能只用NumPy和原生Python而不是直接上pandas的pipeline模型训练环节可能用PyTorch但要求手写训练循环而不是用Lightning这样的高级封装。这种限制看起来是“自找麻烦”但实际上是在帮你建立对底层机制的肌肉记忆。我自己的经验是当你手写过一次带梯度累积的训练循环之后你再看到任何框架的训练API都能在脑子里还原出它背后在做什么。这种能力在排查诡异bug的时候特别管用——比如loss突然变成NaN你知道该从哪几个地方入手查。另一个关键选择是项目会不会涉及分布式训练和推理。从“from-scratch”的定位来看初期大概率不会涉及太复杂的分布式场景但可能会涉及单机多卡的简单并行。这个取舍是合理的因为分布式系统的复杂度太高如果一开始就陷进去很容易劝退。先把单机链路跑通跑透再扩展到分布式这是更符合学习曲线的路径。2.3 项目结构的组织逻辑一个设计良好的“from-scratch”项目目录结构本身就在传递知识。我猜测它的组织方式可能是按工程阶段划分模块每个模块内部再按“原理说明→手写实现→框架对比→练习任务”来组织。这种结构的优势在于你可以按顺序线性学习也可以挑自己薄弱的环节跳着看。比如数据模块可能包含如何手写一个支持shuffle和batch的DataLoader、如何实现数据增强的pipeline、如何处理变长序列的padding和masking。模型模块可能包含如何手写一个简化的Attention机制、如何实现残差连接和LayerNorm、如何做参数初始化。训练模块可能包含如何手写梯度下降的各种变体、如何实现学习率调度、如何做梯度裁剪。推理模块可能包含如何实现动态batch、如何做模型量化、如何写一个简单的推理服务。每个模块之间不是孤立的而是有明确的依赖关系。数据模块的输出是模型模块的输入模型模块的输出是训练模块的输入以此类推。这种端到端的连贯性是“from-scratch”项目相比零散教程最大的优势。3. 核心细节解析数据、模型、训练三大环节的手写要点3.1 数据管线从原始文件到模型可用的Batch数据环节是整个AI工程链路里最容易被低估、但实际上最耗时的部分。我自己的经验是一个AI项目70%的时间花在数据处理上这话一点都不夸张。从零手写数据管线的核心挑战在于如何在保证灵活性的同时不让代码变得一团乱麻。第一步是数据读取。假设你的原始数据是一堆JSON文件或者CSV文件手写读取逻辑的时候要考虑几个问题文件编码是否统一、字段是否可能缺失、数据量是否大到无法一次性加载进内存。对于小数据集直接全量加载没问题对于大数据集就需要实现流式读取或者分片加载。这里的一个常见坑是很多人会忽略文件句柄的释放导致在Windows上出现“文件被占用无法删除”的问题。用context managerwith语句可以避免这个问题。第二步是预处理和清洗。这一步的复杂度取决于你的数据质量。手写预处理逻辑的时候建议把每个处理步骤拆成独立的函数然后用一个pipeline把它们串起来。这样做的好处是每个步骤都可以单独测试出问题的时候容易定位。比如文本清洗可能包括去除HTML标签、统一编码、处理特殊字符、截断超长文本。每个步骤都写成一个纯函数输入输出都是明确的数据结构这样调试起来非常方便。第三步是批处理batching。这是数据管线里最核心的工程问题之一。手写一个batcher需要考虑如何实现shuffle、如何保证每个epoch的数据顺序不同、如何处理最后一个不完整的batch、如何实现多进程加速。一个常见的做法是继承PyTorch的Dataset和DataLoader但“from-scratch”的精神可能要求你先手写一个简化版的实现理解collate_fn的作用、理解num_workers的坑比如在Windows上多进程可能出问题。注意手写DataLoader的时候shuffle的实现不要用random.shuffle直接打乱原始数据而是打乱索引。因为原始数据可能很大打乱索引的开销小得多而且不会破坏原始数据的顺序。第四步是特征工程。对于文本任务可能涉及tokenization对于图像任务可能涉及归一化和resize对于表格数据可能涉及归一化和离散化。手写这些逻辑的时候关键是要保证训练集和推理时的处理逻辑完全一致。我见过太多因为训练时用了某种归一化、推理时忘了用同样归一化导致效果暴跌的案例。解决办法是把预处理逻辑封装成一个可序列化的对象训练完保存下来推理时直接加载。3.2 模型实现手写Attention和训练循环的关键细节模型环节是“from-scratch”项目里最有技术含量的部分。以Transformer为例手写一个简化版的Self-Attention机制你需要理解Q、K、V三个矩阵的来源、缩放点积注意力的计算过程、mask的作用机制。这些概念在论文里看起来清晰但真正手写代码的时候维度对不齐、mask广播错误、softmax数值不稳定等问题会一个接一个冒出来。手写Attention的时候第一个要解决的问题是维度管理。假设输入是(batch_size, seq_len, d_model)经过线性变换得到Q、K、V维度分别是(batch_size, seq_len, d_k)。计算attention score的时候Q和K的转置做矩阵乘法得到(batch_size, seq_len, seq_len)。这个过程中最容易出错的是转置的维度一定要在脑子里或者纸上把维度变化画清楚。第二个问题是数值稳定性。softmax在输入值很大的时候会溢出标准做法是先减去最大值。这个技巧在框架里是自动处理的但手写的时候如果忘了训练初期可能看不出问题训练到后期loss突然变成NaN排查起来很痛苦。我自己的习惯是任何涉及exp运算的地方都先做max subtraction。第三个问题是mask的实现。Padding mask和causal mask是两种最常见的mask。Padding mask用来忽略padding token的影响causal mask用来防止模型看到未来信息。手写的时候mask的shape要和attention score的shape对齐然后用一个很大的负数比如-1e9填充需要mask的位置这样softmax之后这些位置的权重就接近零。训练循环的手写同样有很多细节。首先是loss的计算交叉熵损失在PyTorch里有现成的实现但手写一遍能帮你理解log_softmax和nll_loss的关系。其次是梯度累积当显存不够大的时候可以通过多次前向传播累积梯度、再一次性更新参数来模拟更大的batch size。手写梯度累积的关键是每次反向传播后不要立即清零梯度而是等累积够了再清零。学习率调度也是训练循环里的重要环节。最常见的是warmup加cosine decay训练初期用较小的学习率预热然后逐渐增大到峰值再按cosine曲线衰减。手写这个调度器的时候要注意step的计数方式——是按batch计数还是按epoch计数这会影响调度的粒度。提示手写训练循环的时候建议把每个epoch的训练loss和验证loss都记录下来画成曲线。很多问题比如过拟合、学习率过大都能从loss曲线上看出来。3.3 推理部署从模型文件到可用服务推理部署是很多从零学AI的人最容易忽略的环节但恰恰是工程化落地的关键。手写推理逻辑的核心挑战在于如何在保证延迟和吞吐的前提下正确处理各种边界情况。第一步是模型加载。训练好的模型需要保存成文件推理时再加载。手写保存和加载逻辑的时候要考虑保存哪些内容模型参数、优化器状态、训练配置、用什么格式PyTorch的state_dict、ONNX、还是自定义格式、如何处理版本兼容。一个常见的坑是训练时用了某个自定义的层或函数推理时忘了带上对应的代码导致加载失败。第二步是预处理和后处理的封装。推理时的预处理逻辑必须和训练时完全一致这一点前面已经强调过了。后处理则取决于具体任务分类任务可能需要取argmax生成任务可能需要beam search或者采样策略。手写这些逻辑的时候建议把它们和模型推理分开做成独立的模块方便测试和替换。第三步是批处理推理。线上服务通常需要同时处理多个请求如果每个请求单独推理GPU利用率会很低。手写动态batch的逻辑是维护一个请求队列当队列长度达到阈值或者等待时间超过阈值时把队列里的请求打包成一个batch一起推理。这个逻辑看起来简单但实际实现的时候要考虑不同请求的输入长度可能不同需要padding到相同长度padding会引入无效计算需要用mask来忽略。第四步是性能优化。手写推理的时候性能优化手段包括使用半精度fp16减少显存占用和加速计算、使用KV Cache避免重复计算、使用算子融合减少kernel launch开销。这些优化在框架里可能有现成的开关但手写一遍能帮你理解它们到底在做什么、在什么场景下有效。4. 实操过程一个完整的最小可运行示例4.1 环境准备与依赖管理动手之前先把环境搭好。我推荐用conda创建一个独立的环境避免和系统Python或者其他项目的依赖冲突。基础依赖包括Python 3.9以上、NumPy、PyTorchCPU版或GPU版看你的硬件。如果要做文本任务可能还需要tokenizers如果要做图像任务可能还需要Pillow。conda create -n ai-from-scratch python3.10 conda activate ai-from-scratch pip install numpy torch依赖管理的一个经验是不要一上来就装一大堆包。每引入一个依赖都要问自己“这个包解决什么问题、我能不能手写替代”。比如数据处理pandas很方便但如果你只是读CSV用Python内置的csv模块就够了。这种“能手写就不引包”的习惯在“from-scratch”的学习阶段特别重要。4.2 手写一个简化版DataLoader下面是一个简化版DataLoader的实现支持shuffle和batchimport random import numpy as np class SimpleDataLoader: def __init__(self, data, batch_size, shuffleTrue): self.data data self.batch_size batch_size self.shuffle shuffle def __len__(self): return (len(self.data) self.batch_size - 1) // self.batch_size def __iter__(self): indices list(range(len(self.data))) if self.shuffle: random.shuffle(indices) for i in range(0, len(indices), self.batch_size): batch_indices indices[i:i self.batch_size] batch [self.data[idx] for idx in batch_indices] yield batch这个实现虽然简单但涵盖了DataLoader的核心逻辑索引打乱、按batch切分、支持迭代。你可以在此基础上扩展加入多进程加速、支持自定义的collate函数、加入数据预取。4.3 手写一个带mask的Attentionimport torch import torch.nn as nn import math class SimpleAttention(nn.Module): def __init__(self, d_model, d_k): super().__init__() self.d_k d_k self.W_q nn.Linear(d_model, d_k) self.W_k nn.Linear(d_model, d_k) self.W_v nn.Linear(d_model, d_k) def forward(self, x, maskNone): Q self.W_q(x) K self.W_k(x) V self.W_v(x) scores torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores scores.masked_fill(mask 0, -1e9) attn torch.softmax(scores, dim-1) output torch.matmul(attn, V) return output, attn这段代码的关键点缩放因子1/sqrt(d_k)是为了防止点积结果过大导致softmax梯度消失mask用-1e9填充而不是负无穷是为了避免NaNsoftmax的dim-1表示在最后一个维度上归一化。4.4 手写训练循环与梯度累积def train_one_epoch(model, dataloader, optimizer, criterion, accum_steps4): model.train() total_loss 0 optimizer.zero_grad() for step, batch in enumerate(dataloader): inputs, targets batch outputs model(inputs) loss criterion(outputs, targets) / accum_steps loss.backward() if (step 1) % accum_steps 0: optimizer.step() optimizer.zero_grad() total_loss loss.item() * accum_steps return total_loss / len(dataloader)梯度累积的核心逻辑是loss除以accum_steps这样累积的梯度等价于大batch的梯度每accum_steps步更新一次参数并清零梯度。这个技巧在显存有限但需要大batch训练的时候非常实用。5. 常见问题与排查技巧实录5.1 训练不收敛的排查思路训练不收敛是新手最常遇到的问题。排查的时候我通常按以下顺序检查排查项常见问题解决方法学习率过大导致震荡过小导致收敛慢从1e-4开始试配合warmup数据标签错误、预处理不一致打印几个batch的输入输出人工检查初始化参数全零或过大用Xavier或Kaiming初始化梯度梯度爆炸或消失加梯度裁剪检查每层梯度范数Loss函数用错loss或忘记reduction确认loss和任务匹配我自己的经验是80%的不收敛问题出在学习率和数据上。学习率的问题可以通过学习率扫描快速定位数据的问题则需要耐心地一个batch一个batch地看。5.2 显存不足的应对策略显存不足是工程化落地时的常见瓶颈。应对策略按优先级排序第一减小batch size。这是最直接的方法但可能影响训练稳定性。第二使用梯度累积模拟大batch。第三使用混合精度训练fp16或bf16能显著减少显存占用。第四使用梯度检查点gradient checkpointing用计算时间换显存空间。第五优化模型结构比如减少层数或隐藏维度。注意使用混合精度训练的时候loss scaling是必须的否则梯度可能下溢。PyTorch的amp模块会自动处理这个问题。5.3 推理延迟过高的优化手段推理延迟过高的时候先定位瓶颈在哪是预处理慢、模型前向慢、还是后处理慢。定位方法很简单在代码里加时间戳分别记录每个阶段的耗时。如果是模型前向慢优化手段包括使用更小的模型、使用量化int8、使用算子融合、使用KV Cache生成任务。如果是预处理慢考虑把预处理逻辑用C或者CUDA实现或者用多进程并行。如果是后处理慢检查是否有不必要的循环或者重复计算。我踩过的一个坑是推理时用了Python的for循环逐样本处理导致GPU利用率极低。改成batch推理之后吞吐量直接翻了十几倍。这个教训让我深刻理解了“batch是GPU的朋友”这句话。5.4 模型保存与加载的版本兼容问题模型保存和加载的版本兼容是个容易被忽略但很致命的问题。我建议的做法是保存模型的时候同时保存模型的配置信息层数、隐藏维度等和训练时的预处理参数归一化均值方差等。加载的时候先根据配置重建模型结构再加载参数。另一个坑是如果模型定义代码发生了变化比如改了层名旧版本的checkpoint可能加载失败。解决办法是给checkpoint加上版本号加载的时候做版本检查和兼容处理。6. 从手写到工程后续可以怎么扩展手写实现跑通之后下一步就是逐步引入工业级工具但每一步都要问自己“这个工具帮我解决了什么问题、它的代价是什么”。比如数据处理可以从手写DataLoader过渡到PyTorch的DataLoader享受多进程加速和丰富的sampler模型训练可以从手写循环过渡到Lightning享受自动化的分布式训练和日志管理推理部署可以从手写Flask服务过渡到TorchServe或Triton享受动态batch和模型版本管理。但过渡的前提是你已经理解了手写版本的每一个细节。否则当框架出问题的时候你只能干瞪眼。我自己的做法是每引入一个框架都先花时间读它的核心源码搞清楚它在手写版本的基础上做了什么优化、引入了什么新的抽象。这样框架对你来说就不是黑盒而是一个你可以随时拆开看的透明盒子。这个项目后续还可以往几个方向扩展加入分布式训练DDP或FSDP、加入模型压缩剪枝、蒸馏、量化、加入A/B测试和线上监控。每个方向都够写好几篇深度文章但核心思路是一样的先理解原理再动手实现最后对比工业方案。这条路走下来你对AI工程的理解会比单纯调包的人深好几个层次。