资讯动态

从零构建迷你LLM:AI工程核心能力与实操路线

发布时间:2026/10/2 5:38:43 来源:尧图企业网站定制
做AI工程最容易被低估的是从零开始亲手搭一遍完整流程的价值。我的意思是不是把Transformers库里的GPT2模型拿来训练而是自己写数据集、分词器、注意力、训练循环最后让一个不到100MB的“玩具模型”在本地跑出像样的句子。很多人觉得这是重复造轮子但我可以很肯定地说如果没有这种from scratch的过程我对AI工程的理解会一直停留在“调参数”的层面。这篇文章就围绕ai-engineering-from-scratch这件事展开结合我最近从零构建一个可推理的迷你LLM约500万参数的完整过程讲讲数据、架构、训练、推理、排错这条链路上那些真正需要动手的地方。适合已经有Python基础、用过大模型API或开源库但想真正理解AI工程内部机制的人。看完你会发现从零构建一个能跑的小模型没有想象中难但坑也远比教程里写的多。1. 为什么“从零开始”是AI工程的最短路径1.1 从“调库”到“造轮子”一个分水岭第一次接触AI工程的人大多从from transformers import AutoModel开始。这没什么不对但有个隐患框架把太多东西藏起来了。你明明调通了model.generate()却不知道内部到底发生了什么你改了temperature和top_p但不确定为什么生成结果变长变短。一旦遇到问题唯一能做的是去翻文档、搜错误码很难真正定位到模型内部。我自己的转折点是复现一个3亿参数GPT的论文实验。从十几行调库代码变成自己写数据加载器、手写Attention、布置causal mask、管理梯度那一刻我才意识到以前所谓“会AI”其实只是会用工具。“从零实现”像是把一辆车的发动机拆开再装回去之后再去开车你会知道异响从哪里发出、油耗为什么升高、转速为什么不稳。AI工程行业的绝大多数问题恰恰都出在这些底层细节上。1.2 这条路线解决哪些具体问题从零实现一次AI系统核心能打通这几块能力数据管线如何清洗、去重、分桶、截断、padding这些在真实业务里比重很大但调库时几乎无感。张量形状Attention里Q、K、V怎么变化为什么维度是[batch, heads, seq_len, head_dim]在写代码时每个reshape都必须想清楚。训练动态loss为什么先降后升学习率曲线怎么设计梯度消失还是爆炸只有自己训练过才会有直觉。推理优化KV cache为什么能省重复计算采样参数为什么影响生成质量这些直接决定线上服务的成本和体验。排错能力当你写的模型第一次loss等于log(vocab_size)时排查过程学到的经验远超十遍教程。这些能力在日常后端开发、全栈项目里很少会遇到但一旦进入AI应用层几乎每个项目都会撞上。所以对想做AI工程的人来说from scratch不是绕远路反而是最直接的通路。1.3 成本与收益不是重复造轮子有人会问现在开源模型那么多直接微调不就行了我完全同意生产环境中不应该自己从零预训练大模型。但“学习”和“生产”是两件事。我建议每个人至少做一次“最小必要模型”用一个公开小数据集训练一个参数量百万级、能在单张消费级显卡几小时内跑完的语言模型。这样做成本很低收益却很大。你会明白为什么别人说“数据质量比模型结构重要”也会理解为什么一个看似简单的mask错误会让模型彻底学废。如果不想自己从头写注意力可以参考Sebastian Raschka的《Build a Large Language Model (From Scratch)》里的路线但不建议直接复制代码。最好是先自己写一遍卡住了再对照效果完全不同。2. 内容整体设计AI工程的六个环节2.1 目标先于技术很多初学者拿到一个AI项目就急着找模型这是顺序错了。第一步是定义目标你到底要做一个文本生成器还是要做一个能完成某种推理的小模型如果目标是“理解原理”那就选一个体量最小的Transformer decoder如果目标是“跑通一条线上服务”那就要考虑数据接口、评测、部署。目标不同后续所有技术选型都会不同。我这次的目标很简单构建一个能从零生成连贯文本的自回归语言模型并最终封装成一个能交互的demo。所以我选择的是decoder-only架构而不是encoder-decoder。理由很实际自回归语言模型的训练和推理链路最标准网上资料多、调试容易而且它就是现在大模型的基础形态。先把这个逻辑彻底跑通以后再看MoE、RLHF这些扩展都能把问题拆回到“数据、模型、训练、推理”这四个框里。2.2 整体拆解数据、模型、训练、推理、部署我把整个工程拆成六个环节后面每一节都会详细展开环节核心问题我的选型数据工程怎么拿到、清洗、编码文本TinyStories数据集 自训练BPE分词器架构设计用什么模型结构约500万参数的GPT风格Decoder训练工程怎么让loss下降AdamW cosine schedule 单卡混合精度推理工程怎么把模型输出变成可用文本温度top-p采样启用KV cache评估迭代怎么判断模型是否真的会validation loss 人工生成样例部署工程怎么把模型交给别人用FastAPI封装成HTTP服务每个环节都有大量的“看起来简单做起来烦”的细节。比如数据工程里光是一个空行过滤就能影响训练稳定性推理工程里一个错误的pad_token_id可能让生成结果尾部全是乱码。下面挑核心细节说。2.3 为什么先从语言建模开始如果你想做的是“reasoning model from scratch”我建议还是从语言建模开始。原因很简单Reasoning能力不是凭空出现的它建立在对语言数据分布的良好建模之上。一个小模型如果能在足够多的推理步骤数据上学到“因为、所以、先计算、然后验证”这类模式自然会产生一部分推理行为。而要做到这一步你首先要有一个能训练、能生成、能干预的完整工程链路。链路的起点就是最普通的next token prediction。3. 核心细节解析与实操要点3.1 数据准备训练你自己的BPE分词器大模型的第一步不是模型是分词。词表怎么构建直接决定模型要学多难。常见选择是BPEByte Pair Encoding它从字符级开始不断合并出现频率最高的相邻token对直到达到目标词表大小。我这里没有直接用tiktoken而是用tokenizers库训练了一个词表大小为10000的BPE tokenizer训练语料就是我要用的文本数据。为什么要自己训练因为如果直接套用通用大模型的tokenizer它会把很多你没见过的词拆成碎片词表里充满了训练中根本不会出现的token降低有效表达能力。一个容易踩的坑是vocab_size选太小。比如选1000每个token颗粒度太大会出现大量token无法表达的词选50000又会让embedding矩阵占太多参数。我最终选了10000并添加了pad和unk两个特殊token。分词器训练完成后一定要在全新文本上测一下看恢复的文本是否完整这一步能提前发现很多编码错误。3.2 Transformer结构拆解每个组件为什么存在我实现的模型结构是一个非常标准的GPT风格decoderEmbedding层把token id变成稠密向量位置编码用可学习的nn.Embedding而不是固定的正弦编码。可学习向量在小型模型上更容易优化。Causal Self-Attention每个位置只允许看到当前位置及之前的token。实现时通过一个上三角为0的attention mask来屏蔽未来信息。前馈网络FFN每个token独立经过两个线性层和GELU激活相当于对每个位置的表示做非线性变换。LayerNorm 残差连接在每个子层前后做归一化和残差相加目的是让深层网络训练更稳定。关于Attention我用一个日常类比在一个句子里每个词都要“参考”其他词来更新自己的含义。比如“它拿起了苹果因为饿了”这里的“因为”需要看到“它”才能理解主语。Attention做的就是这件事Q表示“我在找什么”K表示“我能提供什么”V表示“我实际的信息内容”。Q和K的点乘算出相关度再用softmax变成权重最后加权求和V。实际代码中我会先把Q、K、V拆成多个头每个头在不同的子空间里做注意力再拼回去。多头的作用是让模型同时关注不同关系比如一个头关注语法搭配另一个头关注指代关系。3.3 训练目标与标签构造语言模型的训练目标是给定前t个token预测第t1个token。实现时输入input_ids和标签labels并不是两个不同的训练句而是同一个序列错开一位。比如序列是[我, 爱, 深, 度, 学, 习]输入[我, 爱, 深, 度, 学]标签[爱, 深, 度, 学, 习]模型在每个时间步输出整个词表上的概率分布然后和标签计算交叉熵。这里最容易犯的错误是标签没有做错位导致模型看到当前token却要预测当前token形成“抄答案”机制loss会异常低但生成效果一塌糊涂。另一个常见问题是ignore_index没设置好padding位置也会参与loss计算拉低训练效果。3.4 推理采样temperature、top-k、top-p模型训练完成后推理时不能总是选概率最高的token否则容易进入重复循环。采样策略里有三个核心参数参数作用经验值temperature控制分布尖锐程度0.7~0.9 效果较自然top_k只保留概率排名前k个token40~100top_p按累积概率截断保留到概率和超过p0.9~0.95这里要注意它们的优先级通常先top_k再top_p最后再结合temperature缩放。如果不加任何限制低概率token占比很大生成内容会漂如果温度太低又容易每个位置都选高概率的常用词出现复读机现象。4. 实操过程与核心环节实现4.1 从零构建一个最小GPT模型下面是我这次实现的核心骨架只保留关键部分。模型使用PyTorch全程不到200行。import torch import torch.nn as nn class CausalSelfAttention(nn.Module): def __init__(self, d_model, n_heads, max_seq_len): super().__init__() self.n_heads n_heads self.d_head d_model // n_heads self.wq nn.Linear(d_model, d_model) self.wk nn.Linear(d_model, d_model) self.wv nn.Linear(d_model, d_model) self.wo nn.Linear(d_model, d_model) self.register_buffer(mask, torch.triu( torch.ones(1, 1, max_seq_len, max_seq_len) * float(-inf), diagonal1 )) def forward(self, x): batch, seq_len, d_model x.shape q self.wq(x).view(batch, seq_len, self.n_heads, self.d_head).transpose(1, 2) k self.wk(x).view(batch, seq_len, self.n_heads, self.d_head).transpose(1, 2) v self.wv(x).view(batch, seq_len, self.n_heads, self.d_head).transpose(1, 2) attn q k.transpose(-2, -1) / (self.d_head ** 0.5) attn attn self.mask[:, :, :seq_len, :seq_len] # 屏蔽未来 attn torch.softmax(attn, dim-1) out attn v out out.transpose(1, 2).contiguous().view(batch, seq_len, d_model) return self.wo(out) class MiniGPT(nn.Module): def __init__(self, vocab_size, d_model192, n_layers6, n_heads8, d_ff768, max_seq_len256): super().__init__() self.token_emb nn.Embedding(vocab_size, d_model) self.pos_emb nn.Embedding(max_seq_len, d_model) self.layers nn.ModuleList() for _ in range(n_layers): self.layers.append(nn.ModuleDict({ attn: CausalSelfAttention(d_model, n_heads, max_seq_len), ffn: nn.Sequential( nn.Linear(d_model, d_ff), nn.GELU(), nn.Linear(d_ff, d_model) ), ln1: nn.LayerNorm(d_model), ln2: nn.LayerNorm(d_model), })) self.ln_f nn.LayerNorm(d_model) self.lm_head nn.Linear(d_model, vocab_size, biasFalse) # 共享 token embedding 与 lm_head 权重省参数且效果更稳 self.lm_head.weight self.token_emb.weight def forward(self, idx): batch, seq_len idx.shape positions torch.arange(seq_len, deviceidx.device).unsqueeze(0) x self.token_emb(idx) self.pos_emb(positions) for layer in self.layers: x x layer[attn](layer[ln1](x)) x x layer[ffn](layer[ln2](x)) logits self.lm_head(self.ln_f(x)) return logits这样实现的关键点有三个一是attention mask注册成buffer不会参与梯度更新二是lm_head和token_emb共享权重参数量少了一大截三是FFN里的两个线性层中间用GELU而不是ReLU训练更平滑。4.2 参数量与超参数计算用上面这个配置vocab_size10000max_seq_len256d_model192n_layers6n_heads8d_ff768。我来手算一遍参数量token embedding10000 * 192 1,920,000位置编码256 * 192 49,152每个Attention块4个线性层每个约192*19219237,056合计约148,224每个FFN192*768768 768*192192约295,872每个LayerNorm2*192*2weightbias约768单层合计约444,8646层约2,669,184最后的LayerNorm和共享输出层不产生额外大头总参数量约4.7M4.7M参数大概占20MB内存训练过程中的激活值和梯度会更大但用一张RTX 3090完全没问题。我的训练配置是batch_size32seq_len128学习率3e-4先做200步warmup再用cosine schedule逐渐降到接近0。这样训练5000步后验证loss大约能从初始的log(10000)≈9.21降到4.0左右生成结果已经能看到简单语法结构。4.3 训练循环与生成函数训练循环本身不复杂但有几个地方要小心label错位、ignore_index、梯度裁剪。def train_step(model, optimizer, batch): input_ids, target_ids batch[input_ids], batch[target_ids] logits model(input_ids) loss nn.functional.cross_entropy( logits.view(-1, logits.size(-1)), target_ids.view(-1), ignore_index0 # 0是pad ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() return loss.item()生成时如果要启用KV cache需要改模型接口这里先给出最简单的逐token生成def generate(model, tokenizer, prompt, max_new_tokens100, temperature0.8, top_p0.9): model.eval() ids tokenizer.encode(prompt).ids for _ in range(max_new_tokens): input_ids torch.tensor([ids[-128:]]) # 只保留最近128个token logits model(input_ids)[0, -1, :] # 取最后一个位置的logits logits logits / temperature # top-p采样 sorted_logits, sorted_indices torch.sort(logits, descendingTrue) cum_probs torch.softmax(sorted_logits, dim-1).cumsum(dim-1) remove_mask cum_probs - sorted_logits.softmax(dim-1) top_p sorted_logits[remove_mask] float(-inf) probs torch.softmax(sorted_logits, dim-1) next_token sorted_indices[torch.multinomial(probs, 1)] ids.append(next_token.item()) if next_token.item() tokenizer.token_to_id(/s): break return tokenizer.decode(ids)这一步最容易忽略的是“只保留最近128个token”这个窗口。如果序列太长位置编码会越界或者注意力开销猛增。真实线上推理会用KV cache避免每次都重新算前面所有位置的K、V但在最小demo里先窗口截断完全够用。4.4 从模型到AI工程系统训练完模型后我把它封装成了一个小服务。流程很简单加载checkpoint - 初始化tokenizer - 用FastAPI暴露一个/generate接口 - 把prompt和采样参数传进去返回生成结果。真正让我觉得“从零构建”有价值的一步是在这个服务里我不得不考虑超时、并发、最大token数、错误处理。这些在notebook里根本不会出现。整个闭环走完你会意识到AI工程不是“训练一个模型”而是“让模型在一个真实系统里稳定地产生价值”。模型训练只是其中一个环节前后还有数据更新、灰度评测、监控告警。从零构建这个小系统让我对这些问题第一次有了体感。5. 常见问题与排查技巧实录5.1 问题速查表下面是我这次实操中遇到的高频问题整理成速查表方便以后复用现象可能原因排查方法loss不降稳定在9.2附近标签错位、模型没有学到信息检查input_ids与target_ids是否相差一位loss下降但生成全是重复词温度太低、模型太小、训练步数不够提高温度增大模型或加长训练训练显存OOMbatch_size或seq_len太大缩小seq_len开梯度累积或开混合精度loss一直很低但生成垃圾数据集泄漏或ignore_index没设置重新划分数据集检查padding位置生成结果里有unk词表含unk且分词不完整使用byte-level BPE加unk但避免用它效果正常但速度很慢每次都重算全部K/V实现KV cache或限制生成长度5.2 损失不下降的定位方法如果训练了100步loss还在ln(10000)≈9.21附近基本等于模型什么都没学。这时候不要急着调参先做两个检查过拟合一个batch拿一个固定batch让模型反复训练200步看loss能不能降到接近0。如果能降到接近于0说明训练逻辑没问题否则问题出在模型或数据接口。打印梯度检查最后一层梯度是否全为0或极大。如果全为0可能模型没有正确连接如果是NaN学习率可能过高。我遇到过最隐蔽的问题是pad token的embedding参与输出。因为padtoken id是0模型很容易预测0来降低loss最终生成乱码。解决办法是在loss中设置ignore_index0并在生成时强制把pad替换为正常token。5.3 显存不足的工程应对消费级显卡训练小模型最容易炸的就是显存。我的经验是第一步先缩seq_len从256降到128显存立刻少一半第二步用混合精度也就是Apex或PyTorch自带的torch.cuda.amp日常训练速度提升明显第三步是梯度累积当成千上万的小batch累计更新一次效果接近大batch但显存开销小很多。这里要注意梯度累积时学习率也要适当调整。因为有效batch变大学习率可以从2e-4提到3e-4但不要直接翻几倍容易导致训练震荡。5.4 重复生成与采样策略的解决小模型最常见的生成问题是复读。不是因为“学会了复读”而是它确实只学到了低风险的高频路径。复读时当前位置的token往往就是前文高频词的延续。解决优先级是先看训练是否充分再调整采样最后考虑增大模型。采样调整的经验是温度0.8、top_p0.9如果依然重复可以把温度降到0.7同时把top_k提高到100。惩罚项比如重复n-gram惩罚也是一个技巧但会破坏连贯性我一般只在最后一步用。5.5 调试AI模型的心法最后分享一个让我少走很多弯路的小技巧从零构建时第一版一定要小到能在3分钟内跑完一个训练epoch。我当时把模型缩到2层、d_model64数据只取5000条先用CPU训练确认数据流和loss曲线正常再逐步放大。如果直接上完整模型一次训练要几个小时一个mask错误能浪费你整整一天。调试时还有两个好用的工具set_detect_anomaly(True)可以定位NaN梯度的产生位置torchinfo打印每一层输出shape快速发现view和transpose之后维度对不上。最终你会发现AI工程的大部分问题不是模型数学有多难而是“一个数字在错误的地方多了一维”或者“一个mask少了一行”。这些坑没亲手踩过一遍永远都以为AI工程光靠调包就够了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑