1. 从零开始做AI工程:先搞清楚你学的到底是什么
我最早接触AI工程这个概念的时候,跟很多人一样,以为就是调库、跑模型、看指标。后来真动手做了才发现,AI工程和AI研究是两码事。研究的核心是探索新方法,工程的核心是把已有方法变成稳定、可靠、可复用的系统。而"from scratch"这条路,恰恰是理解这两者差异的最快方式——不是用现成框架搭积木,而是从矩阵乘法、梯度传播、注意力机制这些最底层的东西开始,亲手把一个大语言模型从零建起来。
这篇文章想跟你聊的,就是我走完这条路之后的一些体会:为什么要从零开始、需要补哪些知识、怎么做tokenizer、怎么搭Transformer、怎么把模型真正训练起来,以及那些只看论文绝对学不到的工程坑。不管你是想转入AI方向的学生,还是已经在做业务想补底层功底的开发者,这套路线都可以直接参考。
网上关于"build a large language model from scratch"的资源不少,也有不少人在做"build a reasoning model from scratch"这样的实践。我的建议是:别急着冲"推理模型"这种高难度副本,先把语言模型本身跑通,再一步步往上加东西。下面我把整条路线拆开讲。
1.1 AI工程的核心不是模型,是系统
很多人第一次接触AI工程,会把注意力全放在模型结构上,觉得只要把Transformer背熟就算入门了。这其实是个误区。真实生产环境里的AI系统,模型只是其中一个环节。数据管道怎么处理脏数据、训练脚本怎么保证可复现、推理服务怎么控制延迟、模型版本怎么管理、线上效果怎么监控,这些都属于AI工程的范畴。
我见过不少团队,模型在离线评测里效果不错,一上线就崩,原因往往不在模型本身,而在工程链路。比如训练数据和推理数据的分布不一致、预处理逻辑写了两套、服务端和训练端的tokenizer版本对不上,这些问题都只能靠工程手段去解决。所以从零开始构建一个语言模型,真正的价值不在于你复现了一个ChatGPT,而在于你把整条链路都走了一遍,知道每个环节为什么存在、出问题时该往哪儿查。
1.2 为什么"从零构建"是最高效的学习方式
直接调库当然快,比如用Hugging Face的transformers,几行代码就能加载一个预训练模型。但这样做有个问题:很多关键细节被封装掉了,你根本不知道模型是怎么吃数据的、损失是怎么算的、推理时的KV Cache是怎么工作的。一旦遇到线上问题,你连定位的思路都没有。
从零构建就完全不一样。你得自己写分词器,才会理解BPE为什么要那样合并词元;自己实现注意力机制,才会明白为什么QKV要分开投影,为什么要有缩放因子;自己写训练循环,才会对batch size、学习率、梯度裁剪这些超参数产生直觉。这个过程确实慢,但慢有慢的价值——构建出来的是一个完整的知识体系,而不是一堆零散的API调用经验。
我还想强调一点:从零构建不等于忽略现有工具。PyTorch该用就用,数据并行该上就上。重点是"模型本身"要自己搭,核心算法要自己写。这跟学手排挡一样,不是为了不开自动挡,而是为了在自动挡出问题时,你知道变速箱里发生了什么。
2. 动手前的技术地基:哪些知识必须补,哪些可以边做边学
我见过不少人兴致勃勃地开始从零写模型,结果卡在第一步——连梯度回传都看不明白。为了避免这种情况,我把动手前需要的地基知识整理了一下,分成"必须提前掌握"和"可以边做边补"两类。
2.1 数学与编程基础:不要求深,但必须能用
先说数学。你不需要成为数学系博士,但线性代数、微积分、概率论这三块的基本概念必须扎实。线性代数里,矩阵乘法、转置、形状变换是日常操作;微积分里,链式法则必须形成肌肉记忆,因为反向传播就是链式法则的工程实现;概率论里,交叉熵、softmax、采样这些概念会一直陪着你。
我的建议是:不需要系统刷教材,而是边写代码边补。比如当你实现softmax时,发现自己对"为什么要求exp的和"这个细节不清楚,就去翻一翻概率论中关于分布归一化的部分。这样带着问题学,效率远高于从头啃一本600页的教材。
编程基础方面,Python是绕不开的。除了基本语法,你还得熟练操作NumPy和PyTorch的Tensor。我建议你在动手写模型之前,先做几个小练习:用NumPy实现一个线性回归、手写一个两层的MLP做MNIST分类、用PyTorch重写一遍并对比结果。这些练习能帮你建立"张量操作+自动求导"的直觉,后面写Transformer会顺畅很多。
2.2 语言模型的核心概念:先建立整体认知
在写第一行模型代码之前,有几个概念你必须先建立整体认知,否则很容易在实现细节里迷失。
第一个是语言建模任务本身。所谓语言模型,本质上就是在做一件事:给定前文,预测下一个词的概率分布。这个任务看似简单,却撑起了整个生成式AI。你需要理解为什么"预测下一个词"能学到语法、事实、推理能力——这背后是压缩和表示学习在起作用。
第二个是分词(tokenization)。模型不是直接吃文本的,而是把文本切分成子词单元,每个子词映射成一个整数ID。BPE(Byte Pair Encoding)是目前最主流的算法,你需要理解它是怎么从字节对合并开始的,vocab size怎么定,未知词怎么处理。
第三个是上下文与注意力。Transformer的核心是注意力机制,它让模型在处理当前位置时,能够"关注"到序列中其他位置的信息。你需要理解Q、K、V三个矩阵的来历,理解为什么注意力分数要除以根号下维度(这是为了防止softmax输入过大导致梯度消失),理解因果掩码(causal mask)为什么让当前位置只能看到左侧的token。
这些概念不是背下来就行,而是要在实现过程中反复对照。当你把每个概念都亲手写成代码,它们才会真正变成你的工具。
3. 从零实现一个迷你语言模型:完整实操路线
现在进入正题。我按自己实操的顺序,把从零构建一个语言模型的完整路线分成四步:数据准备与分词、模型架构搭建、训练循环、评估与生成。每一步我都会给出关键代码思路和踩坑经验。
我用的是"小规模但完整"的策略——在一个几十MB的文本语料上,训练一个参数量在千万级别的模型。这个规模能在消费级显卡上跑通,同时保留了完整的技术链路。你别小看这个"迷你"模型,它麻雀虽小五脏俱全,走完一遍之后,再看那些几十B参数的大模型论文,你会发现自己能跟上思路了。
3.1 数据准备与分词器:被大多数人低估的环节
第一步是找数据和处理数据。我用的是一份开源的中文语料,大概50MB的纯文本,清洗掉HTML标签、多余空白和乱码之后,剩下约30MB有效内容。这里有个经验:数据清洗的质量直接决定训练效果,脏数据比数据量小更致命。
数据清洗完成后,就要写分词器了。我建议自己实现一个BPE,不要直接调现成库。BPE的核心逻辑不复杂:
- 先把文本转成字节序列,初始词元表就是256个字节
- 统计相邻字节对的出现频率,每次合并频率最高的一对,生成新词元
- 重复合并直到达到目标词元数
def bpe_merge(texts, vocab_size): # 统计初始词元频率 tokens = [list(text.encode("utf-8")) for text in texts] vocab = {i: bytes([i]) for i in range(256)} while len(vocab) < vocab_size: # 统计所有相邻pair的频率 pair_freq = {} for token_list in tokens: for pair in zip(token_list, token_list[1:]): pair_freq[pair] = pair_freq.get(pair, 0) + 1 if not pair_freq: break # 找频率最高的pair best_pair = max(pair_freq, key=pair_freq.get) new_id = len(vocab) vocab[new_id] = vocab[best_pair[0]] + vocab[best_pair[1]] # 合并所有出现的位置 new_tokens = [] for token_list in tokens: merged = [] i = 0 while i < len(token_list): if i < len(token_list) - 1 and (token_list[i], token_list[i+1]) == best_pair: merged.append(new_id) i += 2 else: merged.append(token_list[i]) i += 1 new_tokens.append(merged) tokens = new_tokens return vocab, tokens这里有几个容易踩的坑。第一个是编码方式:中文直接用UTF-8字节做BPE,效果比先按字符切分要好,因为UTF-8天然解决了未登录字的问题。第二个是词表大小:迷你模型建议4000到8000,太大了模型容量不够学不好,太小了压缩率不够。我试过用2048,效果明显变差,很多常用词被切得很碎,模型很难学到稳定的词级表示。
分词器做好之后,要把文本转成ID序列并打包成训练样本。每个样本是一个固定长度的token序列,比如512。这里有个知识点:需要设置步长(stride),让相邻样本有重叠。比如步长设为256,那么第一个样本是token 0到511,第二个样本是256到767。这样能充分利用语料,避免样本边界处的上下文信息被浪费。
3.2 模型架构搭建:一个极简但完整的GPT
模型架构这块,我选择实现一个标准的decoder-only transformer,也就是GPT的结构。它的构成可以拆成几个模块:token嵌入层、位置编码层、若干层decoder block、输出投影层。每个decoder block里又包含多头自注意力、前馈网络、LayerNorm和残差连接。
我用一个15M参数的小模型做演示,具体配置如下:
| 参数 | 取值 | 说明 |
|---|---|---|
| vocab size | 6000 | 与分词器对应 |
| hidden size | 384 | 嵌入和隐藏层维度 |
| layers | 6 | decoder block数量 |
| heads | 6 | 注意力头数 |
| sequence length | 512 | 最大上下文长度 |
这里有一个工程细节:为了在消费级显卡上稳定训练,我建议把hidden size和head数量设成能整除的关系(384除以6等于64,每个头的维度就是64)。这样张量形状在拆分和合并时不会出错,也方便后续调试。
注意力机制的实现是核心。它的公式是:
[ \text{Attention}(Q,K,V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V ]
d_k是每个头的维度,除以d_k的平方根是为了稳定梯度。我见过有的实现漏掉了这个缩放因子,小模型上可能不明显,但模型变大后训练会非常不稳定。因果掩码的实现也要注意:用一个上三角矩阵,把未来位置设为负无穷的概率值,这样softmax之后那些位置的概率就趋近于零。
def attention(q, k, v, mask=None): d_k = q.size(-1) scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k) if mask is not None: scores = scores.masked_fill(mask == 0, float("-inf")) weights = F.softmax(scores, dim=-1) return torch.matmul(weights, v)LayerNorm和残差连接的位置也要注意。现代Transformer普遍采用Pre-LN结构,也就是先做LayerNorm再做注意力/前馈计算,最后加残差。这个顺序选择很重要:Pre-LN在训练时更稳定,允许用较大的学习率,而Post-LN(原始Transformer论文的结构)对学习率非常敏感,调参难度大很多。我一开始用的是Post-LN,结果训练loss一直在1.5左右下不去,换成Pre-LN之后很快就降到1.0以下。
3.3 训练循环:理解损失、优化器和超参数
模型搭好之后,就该写训练循环了。这个部分看起来平淡无奇,却是最考验工程能力的地方。核心组件有三个:损失函数、优化器和学习率调度。
损失函数用交叉熵。因为语言模型是预测下一个token,所以要把模型输出的logits和真实token序列做交叉熵计算。这里有一个细节:logits的形状是(batch, seq_len, vocab_size),真实标签的形状是(batch, seq_len),计算损失前要把logits的前两维合并,变成(batch * seq_len, vocab_size),再和展平的标签对比。
优化器我用的是AdamW。相比经典Adam,AdamW把权重衰减和梯度更新解耦,能更有效地控制过拟合。学习率设置方面,我建议使用"warmup + cosine decay"策略:前2000步从0线性上升到峰值,之后按余弦曲线衰减到峰值的10%。
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1) scheduler = torch.optim.lr_scheduler.LambdaLR( optimizer, lr_lambda=lambda step: min((step + 1) / warmup_steps, 1.0) * (0.5 * (1 + math.cos(math.pi * step / total_steps))) )我踩过的一个大坑是学习率太大。刚开始图省事,把学习率设为标准的1e-3,结果训练不到几百步loss就发散到了NaN。后来把峰值学习率降到3e-4,配合梯度裁剪(max_grad_norm=1.0),才稳定下来。经验是:如果你的模型loss突然变成NaN,优先检查学习率,其次检查是否有除零或者log(0)的操作。
训练过程中的监控也很重要。我建议每100步记录一次训练loss,每1000步在验证集上算一次验证loss。如果训练loss下降但验证loss上升,说明过拟合了,可以考虑加dropout或增大数据量。如果两个loss都不下降,问题可能出在模型结构或数据上,需要回头检查。
3.4 文本生成:让模型输出有意义的内容
训练完成后,就要写生成逻辑了。这一步很有成就感,因为模型的输出能直观反映训练效果。
最基本的生成策略是贪心解码:每次取概率最大的token作为下一个输出。但贪心解码有个问题,就是容易陷入重复。原因是按概率取最大值会让模型倾向于输出自己反复见过的模式。更常用的方法是温度采样(temperature sampling):先对logits除以一个温度系数,再做softmax,然后从分布中随机采样。
温度参数很有意思。温度趋近于0时,分布变得尖锐,输出接近贪心;温度偏高时,分布变得平坦,输出更随机,但也会引入更多错误。我在实践中发现,0.8左右的温度对中文文本生成效果比较平衡,既不会太死板,也不至于胡言乱语。
重复惩罚(repetition penalty)也是必须加的。实现方式不复杂:对已经生成的token,在计算logits时把它们的分数乘以一个惩罚系数(比如0.5),降低它们再次被选中的概率。这个技巧能显著改善长文本生成的重复问题。
def generate(model, tokenizer, prompt, max_new_tokens=100, temperature=0.8): model.eval() input_ids = tokenizer.encode(prompt) with torch.no_grad(): for _ in range(max_new_tokens): # 截取最后seq_len个token作为输入 inputs = torch.tensor([input_ids[-seq_len:]]).to(device) logits = model(inputs)[0, -1, :] logits = logits / temperature probs = F.softmax(logits, dim=-1) next_token = torch.multinomial(probs, num_samples=1).item() input_ids.append(next_token) return tokenizer.decode(input_ids)我训练的那个迷你模型,在几百万条短文本上跑了大约3个epoch之后,已经能生成通顺的短句了。当然,它不具备真正的知识储备,但语法结构是正确的——这说明语言建模这个任务本身,确实能让模型学到语言的统计规律。
4. 从单模型到AI系统:推理、微调与评估的工程化
模型训练完,AI工程的路才走了一半。接下来的推理部署、微调和评估,才是实际工作中花时间最多的地方。这一节我讲三块:推理效率优化、微调的实操方法、以及评估体系的搭建。
4.1 推理部署:把模型变成可用服务
训练时的模型是浮点权重加优化器状态,推理时只需要权重。要把模型变成一个能响应请求的服务,有几个工程问题要处理。
第一个问题是KV Cache。在自回归生成时,每一步都要用前面所有token的K和V向量做注意力计算。如果不缓存,每生成一个token都要重新计算前面所有的K和V,复杂度是二次方增长。实现KV Cache时要注意:缓存长度是动态的,生成时要维护两个列表分别存K和V,每一步把新的K、V追加进去,再和新的query做注意力。
第二个问题是精度。训练时用FP32或混合精度,推理时可以把模型转成FP16甚至INT8,显存占用和推理速度都会有明显改善。PyTorch里一行model.half()就能转FP16,但要注意某些算子(比如LayerNorm)在FP16下可能不稳定,需要保持FP32计算。INT8量化稍微复杂一些,需要做校准,但效果很值得。
第三个问题才是服务化。最简单的方案是用FastAPI包一层HTTP接口,把生成逻辑封装成一个POST请求。但生产环境要考虑并发控制、请求排队、超时处理,这些就不是模型层面的问题了,需要按常规后端工程的思路去做。
我在本地部署这个迷你模型时,用FP16精度后显存占用从原来的约2GB降到了1GB左右,推理速度提升了接近一倍。这个优化思路在大模型上同样适用,而且收益更明显。
4.2 微调的思路:在预训练模型上做领域适配
如果你是从零训练了一个模型,下一步很自然的需求就是让它适应特定领域。微调(fine-tuning)就是干这个的。
最基础的微调方法叫做全参数微调(full fine-tuning),把预训练好的权重作为初始化,在领域数据上继续训练。这个方法效果最好,但计算成本高,而且存下来的每个领域版本都是一整套完整权重,存储成本大。
更实用的方案是LoRA(Low-Rank Adaptation)。思路是冻结原模型的权重不动,在注意力层的Q和V投影旁边插入低秩分解的适配矩阵。训练时只更新这些新增的参数,参数量可能只有原来的1%甚至更少,但效果能接近全参数微调。
class LoRALayer(nn.Module): def __init__(self, in_features, out_features, rank=8): super().__init__() self.lora_a = nn.Parameter(torch.randn(in_features, rank) * 0.01) self.lora_b = nn.Parameter(torch.zeros(rank, out_features)) def forward(self, x): return x @ self.lora_a @ self.lora_b用LoRA微调时,有几个细节值得注意。排名(rank)决定了新增参数量,一般8到64之间够用,不是越大越好。学习率要比全参数微调高一些,通常1e-4左右。另外,LoRA只适配了部分层的权重,所以推理时要记得把LoRA的输出加到原始权重上,或者用框架的合并功能把适配矩阵融回原模型。
我在自己的迷你模型上试过用LoRA做古诗词风格微调。只用了约5万条古诗文本,训练了几千步,模型生成的文本风格就明显往古诗方向偏移了。这说明LoRA的容量虽然小,但足够捕获领域风格特征。
4.3 评估体系:没有评估就没有工程
模型做出来不是用来发朋友圈的,要上线就得有评估。评估这件事,很多入门者会忽略,但它其实是AI工程里最接近"工程"的部分。
离线评估通常分成两类。一类是量化指标,比如困惑度(Perplexity)。它衡量的是模型对文本的预测能力,值越低越好。困惑度算起来很直接:模型在测试集上的平均交叉熵的指数。但我建议不要只看困惑度,因为它和文本生成质量不是严格对应的关系——一个困惑度很低的模型,生成内容也可能很无聊。
另一类是指标生成了,但需要人工或LLM来评。比如生成文本的流畅度、信息准确度、指令遵循度。这类评估一般要建立评测集和评分规则。我常用的做法是:准备几十到几百条提示词,跑完生成之后,要么人工打分,要么用一个更强的模型按评分规则自动打分。后者在实操上成本低、可复现,逐渐成了主流。
更重要的还有线上评估。模型上线后,要看真实用户的使用数据:请求成功率、平均响应时长、用户反馈、内容安全合规率。线上评估才是AI工程的终点,因为只有真实流量能告诉你模型在实验室里没暴露出来的问题。我见过离线指标很漂亮的模型,一上线就开始输出重复内容,原因就是训练数据分布和用户实际输入分布差别太大。这类问题,只能靠监控和反馈闭环来发现和修复。
5. 常见问题与排查技巧实录
最后分享一些我在实操中遇到的典型问题。这些问题在教程和文档里很少看到,但几乎每个人都会踩到。
5.1 训练Loss不下降或发散
这是最多人问的问题。Loss不下降,先查数据管道。我遇到过好几次语料加载函数写错,导致模型看到的每个batch内容都一样,loss自然纹丝不动。排查方法是打印几个batch的输入文本,肉眼确认多样性。
Loss发散成NaN,优先查学习率和数值稳定性。学习率太高是最常见的原因。另外注意检查模型里有没有log(0)的运算,比如某些实现里计算归一化概率时,分子分母都可能是0。写代码时适当在分母上加一个小常量epsilon,能避免很多坑。
5.2 显存不够
小模型还好,但如果要把序列长度加到1024或2048,显存很快就不够用了。几个可行的方案按优先级排序:降低batch size、使用梯度累积(gradient accumulation)、开启混合精度训练、用序列打包(把多个短文本拼成一个长序列)。梯度累积是我最推荐的方法,因为它几乎不影响模型效果,只是训练时间稍微变长。
5.3 生成的文本重复
生成文本重复的原因通常有两个:一是训练不充分,模型没有学到足够丰富的模式;二是解码策略问题。如果是后者,调整采样温度、加重复惩罚就能有效缓解。如果是前者,回头多训几个epoch比调参数更管用。
6. 一些实际的体会
我做完这个从零到一的AI工程项目之后,最深的一个体会是:AI工程的瓶颈往往不在模型,而在系统思维。你能写出一个能跑的Transformer,和你能构建一个可靠、可维护、可迭代的AI系统,中间隔着大量"看不见"的工程工作。数据治理、实验管理、评估体系、监控告警,这些才是真正决定一个AI项目能否长期运转的东西。
另外学习路线方面,我建议先完成一个"最小闭环"——哪怕模型很小、数据很少,也要把数据、训练、评估、推理的完整链路走通。然后再逐步放大规模,尝试微调、推理优化、分布式训练。别一上来就去看那些几十B参数模型的架构设计,没有亲手实现过基础模型,看那些只会觉得云里雾里。
从零构建一个模型,这条路确实慢,但走过一遍之后,你会发现以后再接触任何新的模型结构、任何新的训练技巧,都能很快定位它在整个系统中的位置。这种"我大概能猜到它内部在发生什么"的感觉,就是从零开始练习的价值所在。希望这篇文章能帮你少走一些弯路,更快找到这种感觉。