从零构建大语言模型:AI工程全链路核心细节与实战踩坑 提到 ai-engineering-from-scratch 这个项目名我估计很多人在GitHub上刷到过类似的仓库。第一反应可能是又是一个“从零造LLM”的学习repo但如果你真打算走完这条路或者正在带团队做类似的技术预研你会发现它远不止“把Attention代码敲一遍”那么简单。这个标题里藏着的是一条完整的AI工程主线从数据怎么清洗、tokenizer怎么训练到模型架构怎么设计、训练怎么调参再到推理怎么加速、部署怎么落地。这篇文章我打算围绕这条主线把我在类似项目实操中积累的思路、步骤和踩过的坑一次讲透。适合两类人一是刚入门、想真正搞懂大模型底层原理而不是只调API的开发者二是已经在做上层应用、想补一补模型侧基本功的工程师。1. 为什么偏偏要从零开始AI工程的核心认知1.1 “from scratch”不等于重复造轮子很多人一听“从零实现”就下意识抵触觉得现在开源生态这么成熟PyTorch、HuggingFace Transformers、vLLM一装谁还手写Transformer我一开始也这么想直到有一次线上模型效果出了问题我对着训练日志完全无从下手——因为我看不懂loss曲线的异常意味着什么也不知道学习率调度改哪个参数会有什么后果。那一刻我才意识到用轮子不等于懂轮子。从零实现的价值在于它逼你把每一个环节都亲手过一遍。你亲手实现过BPE分词才会真正理解为什么词表大小会影响模型效果和推理速度你亲手写过自注意力才知道为什么Q和K要除以sqrt(dk)你亲手搭过训练循环才明白梯度裁剪、混合精度、学习率预热这些操作到底是用来解决什么问题的。这不是重复造轮子而是给轮子开模——等你回到生产环境用现成框架的时候你看问题的视角会完全不一样。而且坦白讲现在的门槛已经低很多了。一张消费级显卡比如RTX 3090/4090就能训练一个几千万参数的小模型配合《Build a Large Language Model from Scratch》这类书和nanoGPT这样的开源工程完全可以在一两个月内走通从数据到部署的完整链路。所以“from scratch”不是一种偏执而是一种性价比极高的学习策略。1.2 从零构建大语言模型的全链路认知如果只是学理论看论文和博客就够了但AI工程的核心是“跑起来”。从零实现一个能用的语言模型实际上要打通这么一条链路数据采集与清洗、分词器训练、数据管道构建、模型架构实现、训练循环搭建、效果评估、推理优化、部署服务化。任何一环的短板都会成为瓶颈。我见过不少自学的人卡在数据环节——他们以为模型训练最难的是一堆矩阵运算结果发现自己第一步就卡在“怎么把几GB的文本变成模型能吃的token序列”上。数据质量、去重逻辑、采样策略、序列长度分布这些都不是论文里会细讲的但恰恰是决定模型最终效果的关键。这一条链路走完你对AI工程的理解才算是完整的。还有一个容易忽略的点从零实现让你对“规模”有体感。你可能读过论文说学习率要预热但你不知道不预热会怎样你可能知道序列长度越长越吃显存但你没算过具体是O(n^2)的增长曲线。只有亲手把一个小模型从零训起来你才真正建立这种体感之后再去读那些几十亿参数模型的技术报告会看得懂他们话里话外的含义。1.3 推理模型是当前最值得从零攻的方向最近“build a reasoning model from scratch”这类热词关注度很高很多人问我已经会搭普通的大语言模型了接下来是不是就该挑战推理模型我的看法是如果你已经走通了基础语言模型的从零实现那么推理模型确实是下一个最值得攻的方向。推理模型和普通语言模型的本质区别不在架构而在训练方式。最典型的OpenAI o1系列以及DeepSeek-R1是通过强化学习让模型在回答问题时产出更长的思维链Chain of Thought并学会自我纠错与探索。这类模型的训练需要引入奖励模型、策略优化、采样探索等环节比单纯的next token prediction复杂得多。但基础架构的底座——Transformer、分词器、数据管道——是同一个东西。所以从学习路径上看最合理的方式是先把基础语言模型从零实现并训练起来再在这个底座上加推理训练逻辑。这样的递进不会让你一开始就面对过大的复杂度又能在每一步获得正反馈。我自己实践下来这套路径大概2到3个月可以走完前提是每天能保证两三个小时的高质量投入。2. 核心细节解析与实操要点2.1 数据工程喂给模型的每一行都决定上限数据是模型的上限模型只是逼近这个上限。这句话做AI工程的人都要记牢。从零实现时很多人随便找几本书的文本就开训结果模型loss降不下去或者生成内容全是乱码。原因十有八九出在数据上。数据工程里最基础也最要命的一件事是清洗。你需要处理HTML标签、特殊符号、重复段落、无效字符还要做去重。尤其是去重网上很多爬下来的语料里大量重复内容直接训练会让模型学会“背诵”而不是“泛化”还浪费计算资源。我常用的是MinHash去重原理简单讲就是通过哈希把文本转成签名再按相似度聚类几千万条文本也能在合理时间内跑完。另一个容易忽略的细节是语料的来源分布。如果你的目标是一个通用小模型就不能只喂单一类型的数据。代码、百科、新闻、图书的比例需要设计代码太少模型不会格式对齐代码太多则对话能力和常识会偏弱。语言模型本质是在学统计规律你不给它看过的分布它就永远学不会。这是一个取舍问题需要根据最终用途来配比。2.2 tokenizer为什么BPE是主流方案Tokenization是把文本切成模型能吃的最小单位的过程看似简单却是整个工程里最容易埋坑的地方。现在的语言模型几乎都用BPE或其变体原因在于它能在“词级别”和“字符级别”之间找到一个平衡点——高频词整词保留生僻词拆成子词这样词表不会大到离谱又能覆盖几乎无限的词汇组合。实现BPE算法本身不难核心是统计相邻token对的频率然后反复合并。但真正要做得好有几个细节需要注意。第一是词表大小我建议从几千到几万这个量级去踩坑太小了生僻词被拆得稀碎模型学起来费劲太大了embedding矩阵的参数量飙升小模型扛不住。以我现在常用的一个中文小模型为例词表选在8000到16000之间表现比较稳定。第二是预分词规则英文要注意空格和标点的处理中文则要考虑是否需要分词——其实做LLM用字符级或子词级直接处理中文问题不大强行分词反而会引入噪声。很多人图省事直接用了HuggingFace上的现成tokenizer但如果你在做从零项目我还是建议自己实现一遍因为你会因此理解词表、序列长度、embedding参数数量之间的联动关系。比如词表大小V和嵌入维度d共同决定了embedding层的参数量V×d很多人在估算模型参数时把这块漏掉一算才发现模型比预期大了不少。2.3 模型架构从attention到RoPE的细节模型架构是核心中的核心。从零实现时我建议直接实现一个Decoder-only的Transformer这已经是当前LLM的主流形态。架构的拆解其实不复杂Token Embedding Positional Encoding 若干层Decoder Block 输出头。每个Decoder Block又包含自注意力子层和逐位置的前馈网络子层子层之间有残差连接和归一化。自注意力机制里最值得说清楚的是缩放因子。Q和K的向量点积如果向量维度d_k很大点积的结果会变得很大进softmax之后梯度极度不平滑容易梯度消失或爆炸所以除以sqrt(d_k)把方差拉回一个稳定区间。我自己第一次实现的时候偷懒没除loss直接NaN后来回头看论文发现这点其实在《Attention Is All You Need》里讲得清清楚楚——手写一遍的价值就在这里你被迫去理解每个数字存在的意义。位置编码现在的主流是RoPE旋转位置编码它通过旋转矩阵把位置信息编码进Q和K好处是外推性比绝对位置编码好很多开源模型在长文本上表现好就是因为它。从零实现RoPE有个小技巧可以用复数乘法来实现旋转操作代码更简洁逻辑也更清晰。前馈网络的选择上大多会用MLP加激活函数比如GELU或SwiGLUSwiGLU是LLaMA系列的选择效果在多数场景下比GELU好一点但参数量也会相应增加需要权衡。2.4 训练工程优化器、学习率与混合精度模型架构写完之后真正的战斗才刚刚开始——训练工程。这一块是“看似在调用框架API实则全是玄学”的地方。最基础的工具是AdamW优化器它和Adam的区别在于把权重衰减和梯度更新解耦了落地方案就是weight decay只作用在参数上不进梯度。学习率调度通常用预热余弦退火前几百步线性上升避免训练初期梯度过大把模型带偏然后按余弦曲线衰减到接近零。如图的loss曲线如果前期快速下降又突然反弹往往就是预热没做好。混合精度是我强烈建议从第一天就启用的。它的原理很简单前向和反向计算用FP16参数更新用FP32既省显存又加速。但FP16有个出名的问题——精度溢出尤其是梯度值极端的时候。主流方案是Loss Scaling即对loss乘一个缩放因子在反向传播前放大梯度更新前再除回来避免梯度下溢为0。现在的PyTorch AMP和NVIDIA的AMP都封装好了但我建议做从零项目时把机制本身弄清楚不然遇到loss变NaN你都不知道是哪一步出的问题。另外一个必须掌握的技巧是梯度累积。如果单卡显存放不下较大的batch size就分成多个小batch分别前向反向把梯度累加起来再统一更新。我经常做的一个组合是物理batch size 8梯度累积4步等效batch size就是32。这个等效值事关切身的稳定性——LLM训练里batch size太小或者太大会影响收敛性通过梯度累积可以灵活调整不换硬件也能试出合适的值。3. 实操过程与核心环节实现3.1 环境搭建与工具选型在开始之前先把环境搞定。我个人的偏好是Python 3.11 PyTorch 2.x CUDA 12.x这套组合在2025年已经非常成熟。虽然是从零实现我们也没必要拒绝一切现成库——PyTorch的自动求导和Tensor操作还是要用的真正的“从零”指的是把模型结构、训练循环、数据处理逻辑亲手写出来而不是调Transformer库一行完事。显存方面如果你训练一个3000万到1亿参数的小模型用FP16混合精度配一块12GB显存的显卡就够了RTX 3060/4070这一档。如果你手里只有8GB显存也能跑但需要把序列长度限缩到256到512batch size调小用梯度累积来兜底。CPU训练不是不行但痛苦指数极高一条几百token的样本反向传播一次要等数分钟不太建议。工具链上我需要额外提两个组件一是WandB或TensorBoard选一个就行用来盯loss和梯度曲线这是排查问题的眼睛二是HuggingFace Datasets库来处理数据集的加载和shuffle虽然不是必须但能省下不少脏活。剩下的比如BPE实现、数据管道、Transformer模块、训练循环都自己写。3.2 数据准备构造一个小型但干净的训练语料这里我给出一个可以照抄的流程目标是准备一个100MB到500MB级别的训练语料足够训练出一个能“说人话”的微型模型。第一步收集文本。你可以在合法合规的前提下选择一些开放授权的文本资源比如维基百科的导出文件、公共领域的书籍、GitHub上的开源代码仓库等。我建议做双语混合——中文和英文都放一些这会让tokenizer学出来的BPE规则更均衡。第二步清洗。写一个脚本统一做这几件事UTF-8解码失败的行直接丢弃HTML标签用正则去掉连续空白压缩成单个空格删掉长度小于50字符的行太短多半是碎片文本按MinHash或者最简单的哈希去重做一遍。第三步切分。把清洗后的文本按比例切成train和val两个部分建议95%训练、5%验证。验证集一定不能和训练集有重叠否则你评估出的loss没有参考价值。第四步训练tokenizer。用自己实现的BPE在训练集上训练词表大小按你模型规模来定推荐从8000起步我下面给个示例。3.3 模型实现最简Decoder-only Transformer我直接给一份可以跑通的精简实现约120行核心代码它不含任何花活但能让你看清一个Transformer从输入到输出经历了什么。注意为了可读性我尽量用显式写法而不是PyTorch的简写封装。import torch import torch.nn as nn import torch.nn.functional as F import math class RMSNorm(nn.Module): def __init__(self, dim, eps1e-6): super().__init__() self.eps eps self.weight nn.Parameter(torch.ones(dim)) def forward(self, x): rms torch.sqrt(x.pow(2).mean(-1, keepdimTrue) self.eps) return x / rms * self.weight class RoPE(nn.Module): def __init__(self, dim, max_seq_len2048): super().__init__() inv_freq 1.0 / (10000 ** (torch.arange(0, dim, 2).float() / dim)) t torch.arange(max_seq_len, dtypetorch.float32) freqs torch.outer(t, inv_freq) self.register_buffer(cos_cached, freqs.cos()) self.register_buffer(sin_cached, freqs.sin()) def apply(self, x, seq_len): bs, heads, n, hdim x.shape cos self.cos_cached[:seq_len].unsqueeze(0).unsqueeze(0) # 1,1,n,hdim/2 sin self.sin_cached[:seq_len].unsqueeze(0).unsqueeze(0) x1, x2 x[..., 0::2], x[..., 1::2] return torch.cat([x1 * cos - x2 * sin, x1 * sin x2 * cos], dim-1) class CausalSelfAttention(nn.Module): def __init__(self, dim, n_heads): super().__init__() self.n_heads n_heads self.head_dim dim // n_heads self.qkv nn.Linear(dim, 3 * dim, biasFalse) self.proj nn.Linear(dim, dim, biasFalse) self.rope RoPE(self.head_dim) def forward(self, x): B, T, C x.shape qkv self.qkv(x).reshape(B, T, 3, self.n_heads, self.head_dim) q, k, v qkv.permute(2, 0, 3, 1, 4) # 3,B,H,T,head_dim q self.rope.apply(q, T) k self.rope.apply(k, T) att q k.transpose(-2, -1) / math.sqrt(self.head_dim) mask torch.triu(torch.ones(T, T, devicex.device), diagonal1).bool() att att.masked_fill(mask, float(-inf)) att F.softmax(att, dim-1) y att v y y.transpose(1, 2).contiguous().reshape(B, T, C) return self.proj(y) class FeedForward(nn.Module): def __init__(self, dim, hidden_mult4): super().__init__() hidden dim * hidden_mult self.w1 nn.Linear(dim, hidden, biasFalse) self.w2 nn.Linear(hidden, dim, biasFalse) self.w3 nn.Linear(dim, hidden, biasFalse) def forward(self, x): return self.w2(F.silu(self.w1(x)) * self.w3(x)) # SwiGLU class DecoderBlock(nn.Module): def __init__(self, dim, n_heads): super().__init__() self.attn CausalSelfAttention(dim, n_heads) self.ff FeedForward(dim) self.norm1 RMSNorm(dim) self.norm2 RMSNorm(dim) def forward(self, x): x x self.attn(self.norm1(x)) x x self.ff(self.norm2(x)) return x class GPT(nn.Module): def __init__(self, vocab_size, dim256, n_heads8, n_layers6): super().__init__() self.tok_emb nn.Embedding(vocab_size, dim) self.blocks nn.Sequential(*[DecoderBlock(dim, n_heads) for _ in range(n_layers)]) self.norm RMSNorm(dim) self.lm_head nn.Linear(dim, vocab_size, biasFalse) # 权重共享输入embedding和输出head共享参数常见做法能省大量参数 self.tok_emb.weight self.lm_head.weight def forward(self, idx, targetsNone): B, T idx.shape x self.tok_emb(idx) x self.blocks(x) x self.norm(x) logits self.lm_head(x) if targets is None: return logits, None loss F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1)) return logits, loss def generate(self, idx, max_new_tokens100, temperature0.8, top_k40): for _ in range(max_new_tokens): logits, _ self.forward(idx) logits logits[:, -1, :] / temperature if top_k is not None: v, _ torch.topk(logits, top_k) logits[logits v[:, -1]] -float(inf) probs F.softmax(logits, dim-1) idx_next torch.multinomial(probs, num_samples1) idx torch.cat([idx, idx_next], dim-1) return idx这套结构的参数规模怎么算以dim256、n_layers6、n_heads8、词表8000为例embedding参数是8000×256约205万个每个Decoder Block里注意力部分是QKV矩阵3×256×256约19.7万个输出投影6.5万个前馈部分因为是SwiGLU三倍宽约39.3万个这样一个block约65.5万个参数6层约393万加上RMSNorm和最后的lm_head共享参数整体在600万到700万这个量级。这个规模在12GB显卡上非常轻松CPU也勉强能跑。3.4 训练与评估从loss曲线到生成质量训练循环的写法比模型本身更需要注意细节。下面这段代码是我在类似项目中常用的最小可工作版本它覆盖了数据加载、优化器配置、梯度累积、混合精度、日志记录这些核心环节。import torch from torch.utils.data import Dataset, DataLoader from transformers import get_cosine_schedule_with_warmup class TextDataset(Dataset): def __init__(self, token_ids, seq_len): self.token_ids token_ids self.seq_len seq_len def __len__(self): return len(self.token_ids) // self.seq_len - 1 def __getitem__(self, i): start i * self.seq_len x self.token_ids[start:start self.seq_len] y self.token_ids[start 1:start self.seq_len 1] return torch.tensor(x, dtypetorch.long), torch.tensor(y, dtypetorch.long) def train(model, dataset, lr3e-4, epochs5, batch_size8, grad_accum_steps4): device next(model.parameters()).device loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue, drop_lastTrue) optimizer torch.optim.AdamW(model.parameters(), lrlr, weight_decay0.1) total_steps len(loader) * epochs // grad_accum_steps scheduler get_cosine_schedule_with_warmup(optimizer, num_warmup_stepsint(total_steps * 0.05), num_training_stepstotal_steps) scaler torch.cuda.amp.GradScaler() model.train() step 0 optimizer.zero_grad() for epoch in range(epochs): total_loss 0 for i, (x, y) in enumerate(loader): x, y x.to(device), y.to(device) with torch.cuda.amp.autocast(dtypetorch.float16): _, loss model(x, y) loss loss / grad_accum_steps scaler.scale(loss).backward() if (i 1) % grad_accum_steps 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() scheduler.step() optimizer.zero_grad() step 1 if step % 50 0: print(fepoch {epoch} step {step} loss {loss.item() * grad_accum_steps:.4f})训练过程中最值得盯的两样东西是训练loss和验证loss。训练loss稳步下降说明模型在学习验证loss如果开始回升那就是过拟合的信号这时候要么加数据、要么加dropout、要么提前停止。训练完后不要只看loss一定要做生成测试。把几个提示词丢进去看看输出重点观察以下几个方面是否出现无限重复的循环、是否在几个token之间死循环、语法是否通顺、有没有学到基本的常识关联。评估指标上除了loss可以用困惑度Perplexity它的定义是loss的e指数直观含义是“模型在每个token位置平均有多少个困惑的选择”越低越好。比如loss4.0对应困惑度约54.6看起来很高但在一个几千token词表上已经不错了loss降到3.0左右生成效果就能明显感觉到“像人话”了。4. 常见问题与排查技巧实录4.1 训练不收敛Loss为NaN这是从零训练最常遇到、也最打击信心的问题。loss突然变成NaN常见原因有这么几类学习率过大导致loss震荡到溢出数据里有异常值比如未清洗的Inf或超大数字模型中某个地方的数值精度出了问题尤其是FP16混合精度下的梯度下溢或溢出归一化层实现有bug分母出现0。排查的思路是分而治之。先把混合精度关掉用纯FP32跑几个step如果NaN没了就是精度问题接着把学习率调低一个数量级再试再检查输入数据里有没有非法的token id比如token id越界或者出现了负值。我一般还会在训练循环里打印梯度的范数如果梯度范数爆炸到1e10以上那基本可以确定是梯度爆炸加大梯度裁剪的力度或者调低学习率。一个非常实用的技巧是在模型forward里加一个assert检查attention的softmax输出是否包含NaN。这样可以精确锁定是哪个block哪一步出的问题省得整条链路瞎猜。4.2 显存OOM与训练太慢显存不够是每个做模型训练的人都逃不掉的痛。我自己的经验是先算清楚单条样本占多少显存再反推batch size。一个粗略的估算方法是模型参数量的字节数FP16下每参数2字节 优化器状态AdamW下每参数大约8到12字节 激活值和batch size、序列长度强相关。小模型阶段激活值占大头序列长度从512涨到1024显存占用不是翻倍而是接近四倍这是attention的平方复杂度带来的。解决方案按优先级排序第一开混合精度立刻省一半显存第二缩小序列长度从1024降到512效果立竿见影第三减小batch size并用梯度累积补足等效batch size第四如果还不行用梯度检查点技术用少量计算换显存。这些都试过之后还OOM那才需要考虑换更大显存的卡或者模型本身精简一点。训练太慢的问题除了混合精度之外可以检查一下是否在GPU上做了不必要的数据搬运比如每个step都把tensor从CPU挪到GPU。数据加载的瓶颈也很常见建议用DataLoader的num_workers参数开多进程加载并把数据预先tokenize成id序列存在内存或磁盘里不要每次读取都重新走一遍分词。4.3 生成文本“什么都对但什么都不通”这是我见过最多人困惑的现象loss在稳定下降验证集困惑度也很正常但生成的文字却像是“失了智”——语法大致是通的但整段话完全没有逻辑甚至出现中英文混杂、重复循环。这种情况的根源通常是模型容量远远小于数据复杂度。我们的微型模型只有几百万参数却要在一个很大的词表上学习语言规律信息容量不够分配结果就是学会了“表面形状”——词和词之间的局部统计规律——但没学会“深层结构”——谁能跟谁搭配、话题怎么推进。解决办法是扩大模型层数和宽度同时缩小词表把省下来的参数留给真正的语言建模能力。另外检查一下推理时的超参数temperature过高会让输出变得随机过低又会让模型重复top_k太小也会导致复读机现象。这一类问题没有一劳永逸的答案从零实现的意义就是让你在反复调试中找到那种“看到loss降了但生成还是烂”的感觉然后逼自己去想为什么——这种训练直觉是调API永远学不来的。4.4 推理速度太慢训练跑完之后部署阶段的新问题又来了生成一个token要几百毫秒体验极差。原因在于自回归生成是一步步的——每生成一个新token模型要把之前所有token重新过一遍计算。解决这个问题的标准方案是KV Cache把每个注意力头算出来的K和V缓存起来后续生成只需要算新的Q与新token的K、V再与缓存拼接做attention。从零实现的KV Cache其实不复杂给每个attention层维护一个缓存字典生成时把K和V追加进去但需要注意缓存的最大长度和显存管理。再往上走就是量化INT8/INT4和更专业的推理框架——vLLM、TensorRT-LLM这些工具已经能把吞吐做到很高。只是如果底层原理没有亲手摸过遇到性能问题你会连该搜什么关键词都不知道。5. 国内工程界的现实从零项目的进阶路线5.1 如何从“能跑”进阶到“能用”我见过太多人卡在“train起来了loss也降了但模型什么也干不了”这个阶段就打住了。这时候最重要的是给自己定一个“能用”的标准。什么叫能用我自己的标准是三件事能稳定生成指定长度的通顺文本能完成一个简单任务比如文本分类或者完形填空能通过一个公开数据集上的基线评估。为了达成这些标准有几个不复杂但极其有效的技巧可以分享。首先是数据多样性的设计——把你的语料从纯文本扩展到带结构的文本比如JSON、Markdown、代码哪怕你不做代码生成这也会让模型学到更丰富的模式。其次是数据配比——为了让模型输出更“有条理”可以故意混入一些章节结构清晰的长文让模型学会章节之间的衔接。最后是任务格式——如果你想让模型学会问答可以手工构造一批“问题答案”格式的语料混进训练集这也是一种非常轻量级的指令微调。5.2 从预训练到推理能力以DeepSeek-R1为参照从基础语言模型再进一步就是时下最热的“build a reasoning model from scratch”话题了。推理模型的训练路径可以以DeepSeek-R1的技术报告为参照来理解。它的核心思路是先做冷启动——用一批人工精心标注的长思维链数据做监督微调让模型先学会“像样子的思考过程”然后用强化学习进一步优化奖励函数里包含格式正确性和答案正确性两个维度通过策略梯度方法更新模型。在从零项目中复刻这个路径不需要照搬它的模型规模但可以用小模型验证方法论。以我们前面训练好的微型模型为基础可以用几千条人工写好的“思考过程”数据进行冷启动微调然后再用强化学习框架比如TRL库接一个奖励模型去训练。这个过程跑通后你会看到模型在生成时开始出现“嗯让我想想……再检查一下……”这样的推理痕迹。这是一件极有成就感的事。5.3 资源推荐书、开源工程与实践路径如果要从零开始系统性走这条路我最推荐的参考书是《Build a Large Language Model from Scratch》Sebastian Raschka 著。这本书从数据处理讲到预训练、微调和评估和这篇文章的路线基本一致但它有完整的代码实现适合按章节跟着敲一遍。另一本可以作为补充的是关于PyTorch深度学习基础的书哪怕你已经有经验快速翻一遍查漏补缺也值得。开源工程方面我最推荐的几个参照是nanoGPTKarpathy出品极简但完整、nanoqwen基于Qwen2架构的精简实现和真实生产模型的差距更小、以及LLM.c用C语言从零实现推理对理解底层极有帮助。读这些工程不要只读代码要问三个问题它每处设计解决什么问题如果我去掉它会怎样如果我想改掉它该怎么做带着这三个问题读代码收获会非常不一样。最后再分享一点个人体会做 from scratch 项目最大的敌人从来不是技术难度而是中途放弃的念头。我自己的经验是把目标切成很小的里程碑最初先跑通一个百万参数、三天能训完的玩具模型哪怕它只会输出“今天天气很好……很好……很好”也可以庆祝一下——因为这条路你已经走通了。之后再逐步扩大数据和模型每扩大一倍做好记录对比效果变化。我自己在踩了无数次坑之后最大的体会是debug模型训练不用把它当成一门玄学而是一套科学方法——每次只改变一个变量记录结果找到因果。这个小习惯比任何技巧都管用。