
做了这么多年AI工程我一直有个习惯接到一个项目先不看别人怎么实现而是逼自己从零推一遍主链路。不是说重复造轮子有什么优越感而是只有亲手从空白目录开始把数据处理、模型构建、训练、部署这条主线跑通你才能真正理解每个框架帮你省掉的到底是什么。这篇文章记录的就是我从零搭一套AI工程体系的全过程包括技术选型时的纠结、踩过的坑、以及为什么某些步骤必须这么设计。如果你也正在准备从零构建自己的AI项目这份手记应该能帮你少走不少弯路。1. 项目定位到底什么是“从零开始”1.1 先拆解一下“from scratch”的真实含义很多朋友一听到“从零构建AI”第一反应是要从神经元反向传播的数学公式开始手写。但坦白讲这不是工程这是学术研究。工程意义上的“from scratch”指的是不依赖现成的端到端解决方案而是亲手串起整个技术链路。举个例子你用PyTorch搭一个Transformer调通训练脚本这算不算from scratch我觉得算一半。真正的完整链路至少包含这样几个环节数据处理管线从原始文本到token ids包括清洗、分词、采样策略模型架构定义自己用张量操作写出Attention、FFN、LayerNorm而不是直接调nn.Transformer训练循环包括学习率调度、梯度累积、混合精度、断点续训推理与部署包括模型量化、服务化封装、并发控制评估体系包括loss曲线分析、生成质量评测、bad case归因我这次做的项目“ai-engineering-from-scratch”目标就是把这五部分全部用最原始的方式实现一遍然后把过程中的关键决策记录下来。为什么这么做因为当你真正手动实现了一个组件的每一行代码以后再遇到框架更新、接口变动、性能瓶颈时你脑子里会有一个清晰的“内部地图”而不是一个只会调库的“黑盒使用者”。1.2 这个项目适合谁、不适合谁先泼一盆冷水这个方向不适合纯新手直接上手。如果你连Python都没写过几行建议先去刷一遍基础语法和PyTorch官方教程再来。因为from scratch项目的本质是“用已知验证未知”如果底层基础不牢很容易被一堆细节淹没最后变成复制粘贴代码但完全不知道为什么这么写。它真正适合的是这几类人已经用现成框架跑过一些模型但想深入理解内部机制的人需要做模型定制化改造的工程师比如改Attention结构、自定义loss做AI基建、平台开发的人需要给团队提供训练、推理的底层能力准备面试AI工程岗位的人这类项目是很好的技术深度证明项目本身的目标也很明确跑通一条从数据到上线的小规模AI应用全链路模型规模不需要很大参数量千万量级就够但每一个步骤都必须亲手实现并搞清楚原理。做完之后你收获的不是一个“demo”而是一套可复用的工程方法论。2. 技术选型与环境准备2.1 硬件和框架选型别一上来就梭哈做from scratch项目最容易犯的错误就是第一步就被硬件劝退。我见过很多人非要用A100才肯动手结果设备没到位一拖就是几个月。我的建议是根据模型规模反推硬件需求而不是反过来。我这次用的是一张24GB显存的消费级显卡训练一个1.2亿参数的模型12层Transformer、12个注意力头、hidden size 768配合梯度累积和混合精度完全跑得动。如果你的显卡只有12GB那就把层数降到8层、hidden size降到512同样能把链路跑通。框架选择上我坚持只用PyTorch作为底层张量库故意不用HuggingFace Transformers来做模型定义。这不是说HuggingFace不好而是要锻炼“手写架构”的核心能力。数据处理上我倒是用了HuggingFace Datasets因为数据格式转换和缓存机制做得确实好没必要重复造这个轮子。训练加速库我选了DeepSpeed它的Zero Stage 2和Offload机制在单卡场景下也能让显存利用率明显提升。2.2 环境配置的完整清单我的环境配置供参考如果版本不同不必强求一致但原理是通用的操作系统是Ubuntu 22.04Python 3.10CUDA 12.1PyTorch 2.1.0DeepSpeed 0.12.3HuggingFace Datasets 2.15.0分词器用的是自己实现的BPE版本基于tokenizers库的底层API有一个非常重要的点装完环境后第一件事不是跑模型而是跑通一个最小样例。我每次新起项目都会先定义一个大张量运算比如torch.mm一个8000x8000的矩阵乘法统计耗时。如果这一步的速度和理论算力匹配比如RTX 3090的FP16理论算力是142 TFLOPS实际跑大矩阵乘能达到100 TFLOPS以上就算正常说明CUDA、cuDNN、PyTorch的版本是匹配的。如果差距太大八成是驱动或PyTorch版本的问题修好再往下走否则所有训练性能分析都是空中楼阁。2.3 项目目录设计的工程规范我见过太多AI项目所有脚本堆在一个目录里文件名是final_v2.py、final_v3_really.py这种看着就头大。from scratch项目由于代码量庞大结构设计尤其重要。推荐这样组织ai-engineering-from-scratch/ ├── configs/ # 所有yaml配置文件 ├── data/ # 数据原始文件和中间缓存 ├── tokenizer/ # 分词器实现 ├── model/ # 模型架构代码 ├── training/ # 训练循环、优化器、调度器 ├── inference/ # 推理脚本和服务封装 ├── eval/ # 评估脚本 ├── scripts/ # 启动脚本记录实验参数 └── logs/ # 训练日志和tensorboard输出每个阶段结束都要把“当时的代码状态”打一个tag比如v0.1-data-ready、v0.2-model-forward、v0.3-step-overfit。这样万一后面改坏了可以快速回退到某个已知正常的状态对调试效率的提升是巨大的。3. 数据工程从原始文本到训练样本3.1 数据源选择和清洗策略数据是整个AI工程的根基一个模型的效果上限基本由数据质量决定。我这次选用了中英混合的公开文本数据集包括维基百科语料、开源新闻、书籍章节等总计约5GB的原始文本。为什么不直接拿别人处理好的数据因为from scratch的目的就是亲手走一遍流程数据清洗中的各种脏数据情况需要亲自面对。原始文本的脏乱程度远超想象。我做完一轮清洗后写了下面的“黑名单”记录网页抓取的文本里藏着大量HTML实体nbsp;、amp;需要通过html.unescape处理中英文之间缺空格、标点符号不统一中文用全角英文用半角需要做正则归一化大量重复内容比如新闻网站会在每篇文章底部带推荐阅读列表广告文本夹杂在正文中特征往往是包含URL或特殊字符组合我的清洗管线分三层第一层用正则做基础清理去HTML标签、控制字符、奇怪空白第二层做语言检测丢弃非中英文的段落第三层做去重和长度过滤长度小于20个字符的段落直接丢弃基于MinHash的近似去重把重复段落剔除。3.2 分词器实现BPE算法的完整逻辑分词器看起来不起眼但它决定了模型“看到”什么样的输入。现代大模型基本都用的是Byte-Pair EncodingBPE思路是把文本先拆成单字或者UTF-8字节然后反复合并出现频率最高的相邻符号对直到词表达到预设大小。这次我实现了两种分词器做对比基于字节的BPE和基于Unicode的BPE。工程上的关键差异是基于字节的BPE天然不存在OOV问题任何输入都能被编码但缺点是会把一个中文汉字拆成3个字节导致序列长度膨胀。基于Unicode的BPE对中文友好但需要处理“输入中含有生僻字”的边界情况。实现BPE有个细节必须注意合并规则的计算应该在第一遍扫描时统计数据而不是每次合并都全量统计。如果每合并一次就重新遍历全部语料5GB数据可能要跑十几个小时。正确做法是遍历一遍文本建立相邻对pair到频率的映射合并后只更新受影响局部的统计数据这样训练时间能从十几小时压缩到半小时以内。词表大小我定为32000这个数字的考量是太小会导致每个token承载的信息过少、序列过长太大则会让embedding矩阵异常庞大。32000是性价比比较均衡的位置。训练完BPE后有一个必须做的验证步骤把一段文本编码成ids再用这些ids解码回文本确认能完全还原除了特殊token这能暴露很多对齐性的bug。3.3 训练样本构建序列长度与attention mask分词之后要构建模型的输入序列。我设置的max sequence length是512这个长度考量的有两个因素。第一是显存序列长度和显存占用线性相关512的长度下batch size设为16、加上梯度累积4步等效batch size就是64对1.2亿参数的模型来说显存刚好够用。第二是数据分布我看了数据集中句子的长度分布90%以上的文本段落都短于512字符选512意味着大部分样本不需要截断。构建样本时最容易忽略的是attention mask的构造。对于一个batch里长度不足512的样本要pad到统一长度但padding部分在计算Attention时必须mask掉否则模型会学到“关注无效位置”的错误模式。我最初把这部分逻辑写错了一次导致loss一直降不下去花了大半天才找到原因。Debug方法也分享给大家把attention mask打印出来人工检查前几个样本确认每个pad位置都是0、有效位置都是1不要盲目相信代码逻辑。另一个值得做的操作是样本打乱。如果你把数据集按原始顺序切分样本相邻样本大概率来自同一篇文章内容高度相似这会让每个batch内部的样本缺乏多样性影响梯度估计的质量。我在构建Dataset时加入了全局shuffle并固定随机种子保证实验可复现。4. 模型架构手动实现Transformer4.1 从零写出多头注意力手写Transformer是from scratch项目中最核心、也最有收获的部分。很多朋友用PyTorch的时候一行nn.MultiheadAttention就完事了但面试或改论文时被问到“KV Cache怎么实现”就会卡壳。自己手写一遍以后这些概念会变得非常具体。核心多头注意力的实现逻辑是输入x形状batch、seq、hidden经过三个权重矩阵分别得到Query、Key、Value将Q、K、V重塑为多头的形状拆分到不同注意力头计算QK^T除以sqrt(d_head)得到attention score对score施加mask再经过softmax得到注意力权重用注意力权重加权求和Value再合并多头经过输出权重矩阵投影回去实现中最容易出错的是维度变换。建议在任何张量变形操作后加debug断言验证形状比如assert q.shape (batch, num_heads, seq_len, head_dim)。这种防御式编程在复杂模型里能避免大量隐蔽的形状bug。4.2 残差连接与LayerNorm的顺序问题框架用一行代码就能搞定的LayerNorm自己实现时才会注意到细节。我做的是Pre-LayerNorm结构也就是每个子层先Norm再进Attention或FFN输出加上残差。Post-LayerNorm原始Transformer风格是先过Attention再加残差最后Norm两者效果差异在深层网络中非常明显。Pre-LN的好处是训练更稳定因为梯度可以在残差路径上畅通无阻Post-LN在深层网络中容易出现梯度爆炸。现在业界主流大模型基本都是Pre-LN或其变体所以我也直接采用这个方案。另一个细节是LayerNorm的epsilon参数。LayerNorm计算方差时要在分母上加一个小数防止除零。默认值一般是1e-5到1e-6但混合精度训练时如果epsilon太小容易出现数值不稳定。我遇到过fp16下loss变成NaN的情况排查后就是将epsilon从1e-6调到1e-5解决的。这类问题极其坑人因为看起来是“玄学”实际上是数值精度在作祟。4.3 位置编码为什么不能直接用绝对位置Transformer没有循环结构如果所有token在同一个矩阵里“一视同仁”模型根本不知道词序。位置编码就是给每个token加一个“位置信号”。我实现的是经典的Sinusoidal绝对位置编码公式是pos维度用不同频率的正余弦函数。为什么不用可学习的绝对位置编码在小模型上两者性能差距不大但Sinusoidal的优势是能在推理时外推到比训练时更长的序列——虽然外推能力有限但至少不会像可学习位置编码那样遇到“超出训练长度就崩”的尴尬。不过说句实话如果你想把模型做到更长上下文比如几千甚至上万绝对位置编码都不够用最好上RoPE旋转位置编码这类相对位置方法。我这次训练长度固定512所以Sinusoidal完全够用。选型建议是固定短序列用Sinusoidal追求长度外推用RoPE这两者在工程实现复杂度上RoPE略高一些但也不是什么大难题。4.4 显存优化的几个“白嫖”技巧手写模型还有一个隐性收益——你能清晰地知道显存花在哪里了。我的1.2亿参数模型在未做任何优化时训练显存占用约16GB通过几个手段降到了11GB混合精度训练用torch.autocast把大部分计算的精度降到fp16显存减半的同时速度还更快梯度累积batch size设小一些通过多步累积梯度模拟大batch显存压力直接降低激活检查点不保存所有中间激活值反向传播时重新计算以时间换空间这三个技术是标准操作框架都已经支持了。但我还是建议在from scratch阶段手动试一遍实现逻辑比如混合精度和纯fp32在哪些矩阵上有精度损失、累积梯度时什么时候要缩放loss scale理解了之后用框架的自动版本才有的放矢。5. 训练循环让模型真正学起来5.1 优化器与学习率配置训练是玄学最多的环节我经常说一句话能用一份稳定训练配方跑通全流程比追求“极致效果”更重要。我采用的组合是AdamW优化器加上预热和余弦退火学习率调度。关键超参数经验值超参数推荐值我的理由学习率峰值1e-41.2亿参数量级比较稳的起点Warmup步数总步数的5%-10%让训练初期梯度保持平稳AdamW epsilon1e-8默认值但在mixed precision下不要更小Weight decay0.01标准值能轻微抑制过拟合梯度裁剪max_norm1.0防止个别大梯度破坏训练稳定性为什么设置Warmup因为在训练刚开始时模型权重的随机性让梯度方差很大如果直接用大学习率很容易把loss“打飞”导致后续怎么训都回不来了。从很小的学习率比如1e-7开始几千步内线性增长到峰值能保证训练平滑启动。5.2 DeepSpeed配置文件的三层优化我用了DeepSpeed来做训练加速配置文件是AI工程里很关键的一个基本功。配置分为三层optimizer、scheduler、fp16或bf16。train_batch_size: 64 gradient_accumulation_steps: 4 fp16: enabled: true loss_scale: 0 initial_scale_power: 16 zero_optimization: stage: 2 offload_optimizer: device: cpu这里的initial_scale_power: 16表示初始loss scale是2的16次方也就是65536。混合精度训练有个窗口期问题loss scale设太大可能出现精度溢出直接NaN设太小梯度下溢到0模型根本不动。DeepSpeed的动态loss scale会自动调整但我们还是要理解这个机制的原理。我建议从2048到65536多试几个值观察训练日志里loss scale的变化趋势找到稳定的区间。5.3 训练信号解读loss不是唯一指标很多人训练时只看loss这是不够的。我实现了一套更完整的训练监控体系当前step的loss值和滑动平均值滑动平均比瞬时值更可靠梯度范数如果过大说明要崩如果为0说明梯度消失或精度问题学习率当前值Warmup和退火阶段确认调度器在正确执行当前loss scale混合精度下监控数值稳定性吞吐量token/s判断训练效率最值得关注的是梯度范数。正常情况下它应该在一个稳定区间波动比如0.1到5之间如果突然飙升到上百甚至上千马上就能预判接下来几轮loss也会爆炸而不是等到loss已经飙升了才去查。这套指标记录到自己搭的日志系统里训练过程就可以“看着仪表盘开车”而不是黑盒式的祈祷。5.4 断点续训与实验复现训练一个模型动辄十几个小时甚至几天如果断点续训没做一次断电就能让人心态全崩。我的断点保存策略是每500步保存一次checkpoint保存内容包括模型权重、优化器状态、学习率调度器状态、当前step数、随机数生成器状态。恢复训练时不仅能继续跑还能“无缝衔接”保证实验连续性。这里有一个很多人忽略的坑恢复训练后loss有时会异常跳变。原因往往是随机数状态没保存导致数据加载顺序变了模型又经历了一次“数据顺序冲击”。保存随机数种子状态能解决这个问题。更稳妥的做法是给每个样本的index加上一个全局计数器按计数器排序决定采样顺序而不是完全依赖shuffle。6. 推理与部署把模型从实验环境送到线上6.1 自回归生成与KV Cache训练完成后要验证模型效果这时就要写推理代码。GPT类模型的生成是自回归的每次生成一个token把它拼到输入里再预测下一个token。显然如果每一步都把全部历史重新算一遍效率会非常低。KV Cache就是缓存之前步骤计算好的Key和Value矩阵新一步只需要算新token的QKV把KV追加到缓存里用于Attention计算。我实现KV Cache后生成长度平均提速了3到5倍具体取决于序列长度。同时要处理一个边界逻辑当序列长度超过训练长度时位置编码会越界。我的方案是限制生成长度不超过1024在超出的位置复用最后位置的位置编码向量并加一个在线插值。虽然不完美但至少能保证不报错。解码策略我默认用温度采样temperature0.8加top-pp0.9生成更有多样性。如果追求确定性输出则用greedy解码。这里强烈建议把采样逻辑写好单元测试因为采样涉及随机数分支很容易写出“看似正常但无法复现”的bug。6.2 模型服务化官方推理引擎方案我选择用官方推理服务器来做服务化通过HTTP接口对外提供推理能力启动参数里要配置KV Cache的显存上限、最大批处理大小、跨序列并发等参数。生产环境有个小细节默认的启动端口和默认并发限制都比较“内敛”建议在配置里显式调大max_batch_size否则高并发时排队现象会比较明显。单机单卡实在太慢的话还可以上模型并行。把模型切分到多张卡上每张卡负责一部分Transformer层推理请求在卡间流水线传递。这个方案对显存紧张、但又要跑大一点模型的场景非常实用。6.3 模型量化从fp16到int8的取舍部署阶段最直接的加速手段是量化。我对比了两种方案PTQ训练后量化直接把训练好的fp16模型转成int8操作简单但精度有损失GPTQ基于校准集的训练后量化用一小批校准数据通过参数重构让量化误差最小化效果比简单PTQ好我最后用的是GPTQ方案精度损失控制在1%-2%以内但显存占用直接降了一半推理速度提升了约两倍。对于一个小规模demo应用来说这个性价比非常合适。量化后有一个重要验证环节量化模型和原模型的输出分布对比。不能只看一两个例子的结果就下结论要跑至少几百条测试样本统计生成文本的KL散度差异。如果差异过大说明量化过程某些敏感层损失太大这时候需要锁定特定层不做量化比如关键的Attention层或者调整校准集规模。7. 评估体系模型效果不能靠“感觉”7.1 客观指标怎么选评估环节最容易犯的错误是“凭感觉”——人工看几个生成结果觉得“还行”就宣布成功。这样不严谨。我搭建的评估体系分三层Loss层面在保留的验证集上计算困惑度Perplexity衡量模型对数据的建模能力任务层面针对下游具体任务评测。如果做文本生成可以用BLEU、ROUGE如果做分类任务用准确率、F1。小模型还要测“常识问答”类指标生成质量层面人工对随机抽样的模型输出做多维打分流畅性、相关性、安全性客观指标的价值不是“数字好看”而是方便定位bad case。比如当BLEU高但ROUGE低时说明模型生成语义相似但字面差异大那可以调整解码策略反之则要加强训练数据多样性。7.2 Bad Case分析与失败的归因一次测试中我对模型输入“为什么天空是蓝色的”模型输出的内容逻辑基本通顺但它给出的“原因”引到了一些虚无缥缈的内容这其实是训练数据里混入了大量低质网文导致的。这就是Bad Case分析的典型过程不只看模型“答错了”要找到它“用哪部分参数答错的”再回溯到数据或训练层面的原因。我记录的Bad Case归因分类大概是这样数据层面知识缺失、标注错误、数据分布偏差模型层面模型容量不足、过拟合某个训练集子集训练层面学习率策略不当、收敛不充分推理层面解码策略参数问题、上下文窗口截断导致信息丢失针对每一类问题我都在项目文档里写下了可行的修复路径。数据问题去补数据模型问题去调结构或参数推理问题去调解码参数。这套“归因-修复-回归”闭环是AI工程里最重要的工程素养之一。8. 拓展走向更大模型和推理型AI的路径8.1 从语言模型到推理模型做完了基础的语言模型下一步很多人的目标是“让模型会推理”。这在实际工程里不是模型架构要推倒重来而是要在训练数据、训练目标、解码范式三个层面做增量调整。推理模型的核心是让模型学会“思考过程”。现在主流的做法是训练数据里加入带步骤的推理样本思维链训练目标从“直接给答案”改成“先生成推导步骤再给出结论”。推理时模型会先把中间的推理链条列出来这比直接输出答案更容易产生正确结果。工程实现上推理模型的训练可以走两条路一是用现成的推理数据集做继续预训练二是在通用模型之上做指令微调。前者改变的是模型的“底层能力”后者改变的是“应答方式”。实际操作时我一般先把推理数据嵌入到预训练语料中做增量训练得到一个更强的基座再对基座做小规模微调。这个过程并不神秘它是一套标准的数据与训练流程难点在于数据配比、训练步数、以及如何评估推理能力的真实提升。8.2 给“从零开始”项目的扩展建议这个from scratch项目跑通后你做任何大模型相关的工程底子都会比只会调API的人扎实得多。因为它把整条链路都“激活”到了你的长期记忆里。下一步扩展方向可以是把单卡训练改为多卡分布式训练理解DP、TP、PP的差异加上指令微调和RLHF做Chat模型的完整对齐流程做几轮“数据配比实验”系统研究不同数据来源对模型能力的影响把推理模型的思想融入现有基座做个垂直领域专精版本这里说点我个人最想分享的经验手写Transformer那会前向传播里有一处num_heads和head_dim的维度乘法搞反了。当时模型能跑、loss也能降但训练两天后怎么也降不动了检查日志和代码反复对比才定位到是维度顺序问题。这个坑让我深刻理解了一件事——深度学习框架帮我们处理了大量边界问题但也让我们越来越难用“直觉”去洞察bug。所以现在无论用什么框架我都会刻意手写一段关键代码或者画出张量流动图把问题逼到表面上来。做from scratch的整个过程是痛苦的但走完后那种“我对这堆数学和代码有掌控力”的感觉确实是调API完全无法替代的。如果你正在犹豫要不要开始我的建议很简单找一个你平时最依赖、最想搞懂的原理从今天开始翻开它的源码一行一行地读然后关掉源码自己从头实现一遍。坚持下来你得到的将不仅仅是一个能跑的模型而是一套面对未知问题敢拆敢解的底气。