消费级硬件上从零训练并部署一个25.8M参数的迷你GPT 如果你手头只有一块游戏显卡或者一台普通的笔记本却想完整地把一个GPT从0训出来再部署成服务这个目标听起来有点疯狂但它的确可行。我最近就在消费级硬件上跑通了一套轻量级语言模型的全流程训练与部署最终模型的参数量是25.8M约两千六百万放在今天的LLM圈子里属于“迷你型”但它具备GPT的核心机制token embedding、位置编码、因果自注意力、逐token交叉熵训练以及temperature / top_k / top_p采样生成。这篇内容适合这样的人想在本地研究Transformer工作原理、想做垂直领域小模型、想将AI能力嵌入离线工具以及所有对大模型训练流程好奇但不想一开始就碰几百亿参数的开发者。文章不讲空道理全部是实测过的东西包括数据清洗、分词器训练、模型搭建、显存控制、loss调参、部署量化这些环节。今天就用这个25.8M参数的GPT为例把整条链路捋一遍。1. 为什么选25.8M参数这个量级1.1 “25.8M参数”到底意味着什么不少人一听到“25.8M”第一反应是“这也太小了能干嘛”。我先说结论它确实生成不了一口流利的漂亮文章但作为全流程训练与部署的载体这个体量非常合适。25.8M参数有几个很直观的物理含义如果以FP32保存模型权重大约占用103MB内存换成FP16不到52MB做动态量化后可以压到30MB左右。也就是说推理端根本不挑设备普通CPU就能跑连树莓派级别的硬件都有机会。从训练端来看25.8M参数配合AdamW优化器需要保存的参数副本包括模型参数、梯度和一阶二阶动量粗算一下就是参数量乘以4再乘以4字节大约413MB。再加上激活值、中间结果我用的RTX 3060 12G显存跑起来很从容8G显存的卡通过缩小batch也能跑。这种“一个人用消费级显卡就能全流程复盘”的体验在动辄几十亿参数的大模型项目里是不可想象的。能力边界也要说清楚。25.8M的GPT没法写出有逻辑的长文但在足够干净的小语料上它能学会基本的词语搭配、标点习惯、领域术语的局部规律甚至能模仿出一点“像模像样”的短句。这个水平适合做教学实验、边缘设备上的文本补全、特定格式生成以及作为你理解大模型内部机制的“解剖样本”。1.2 跑通全流程的价值不在模型本身而在链路我自己见过很多朋友一上来就租卡跑Llama或者ChatGLM的微调数据处理用现成脚本训练用deepspeed推理用vLLM看似什么都碰了但遇到问题依然没有排查方向。原因很简单链路里太多环节被封装掉了你没有建立起“数据 - 分词 - 模型 - 训练 - 部署”这条完整链路的直觉。从零构建一个25.8M参数的GPT价值就在于把每个环节都暴露在你面前。你会亲手处理语料理解脏数据对loss的影响你会训练一个BPE分词器知道词表大小如何影响模型参数和中文表现你会手写Transformer Block搞清楚因果mask到底在哪一步起作用你会自己调学习率、weight decay、梯度裁剪看到loss从高到低的过程。这些经验是可以迁移到更大模型的。说句实在话模型结构本身早就不是秘密真正拉开差距的是训练感知和数据工程能力而这些东西只有亲手做过才会长在身上。2. 整体方案与硬件软件选型2.1 消费级硬件选型我的配置和更省的方案我先交代自己的实测环境CPU是i5-12490F显卡是RTX 3060 12G内存32G系统Ubuntu 22.04。这套配置放在今天算是入门级二手市场也很常见。12G显存跑这个项目属于“非常宽裕”我把batch size拉到64、序列长度256训练时峰值显存大约9G没有触发OOM。如果你的显卡是8G显存比如RTX 4060或者3060 Ti也完全能跑只需要把batch size降到16或32再配合梯度累积来模拟大batch。如果你手头没有N卡只有Apple Silicon也可以尝试用PyTorch的mps后端训练但速度会比N卡慢不少建议把数据量压缩到5000万token左右当成一个验证性质的实验。纯CPU训练我真的不建议25.8M虽然小但要训练上亿tokenCPU版本大概要几十个小时等出结果的时候热情基本也耗完了。我整理了一张参考表方便你预估训练时间硬件配置数据规模预计训练时间RTX 3060 12G6层、512维、约1亿token6~8小时RTX 4060 8G6层、512维、约6000万token8~12小时Apple M1/M2系列6层、384维、约3000万token10小时以上纯CPU不建议体验太差可能数十小时这个预估基于我自己的实测模型越大、序列越长时间线性往上走。如果你只是跑通流程我建议先拿2000万token试水确认全链路没问题后再扩大数据量效率会高很多。2.2 软件栈和项目结构软件方面我尽量少用重型框架核心就几个Python 3.10、PyTorch 2.1、HuggingFace Tokenizers、numpy、tqdm、wandb可选、FastAPI和onnxruntime部署用。说实话在25.8M参数这个规模上完全不需要DeepSpeed或者Megatron那些分布式框架只会让你分心。项目结构也很简单按模块拆分gpt-mini/ ├── config.py # 所有超参数 ├── data/ │ ├── clean.py # 数据清洗 │ └── dataset.py # 构建训练样本 ├── tokenizer/ │ └── train_tokenizer.py ├── model/ │ ├── gpt.py # 模型定义 │ └── train.py # 训练脚本 ├── deploy/ │ ├── generate.py # 推理函数 │ ├── export_onnx.py │ └── server.py # FastAPI服务 └── checkpoints/ └── best.pt这个结构不是为了好看而是为了让每个环节独立可调试。我踩过最深的坑就是所有代码堆在一个脚本里数据改了没重新生成、模型参数改了没同步最后连自己都搞不清当前跑的是哪一版。3. 数据准备与分词器最容易翻车的环节3.1 语料从哪来怎么清洗数据工程是整个流程里最不性感但最重要的一环。我这次用的是中文维基dump 开源中文语料的一个子集原始文件加起来大约1.8GB清洗后大概剩1.1GB。为什么强调清洗因为模型学的是数据分布垃圾数据会让loss乱跳甚至生成出大量无意义内容。清洗规则我总结成了一套固定流程先把HTML标签去掉然后过滤掉所有空行、过长日志和无意义的重复行对中文内容做简繁转换保证语料统一接着把所有全角符号转成半角统一标点最后按段落切分丢掉少于20个字的碎片。还有一条容易被忽略要给语料做一次近似去重尤其是爬虫数据里经常有大段重复文本会让模型在评估时显得“很厉害”实际生成却只会复读。清洗后的语料会按文档边界切成段落存成一行一条的文本格式。这一步不要省它会直接影响后续Tokenizer训练和样本构建的质量。3.2 训练一个8000词表的BPE分词器现在很多教程直接加载HuggingFace上现成的GPT2Tokenizer但既然项目叫“从零构建”分词器我也建议自己训练。BPEByte Pair Encoding是目前最主流的子词分词算法核心思路是从字符级开始反复合并出现频率最高的相邻字符对直到达到目标词表大小。它在中文上有个优势不需要预置词典会按数据自动切出“中文词组”或“单字”灵活性很好。我用的代码很简单from tokenizers import ByteLevelBPETokenizer tokenizer ByteLevelBPETokenizer() tokenizer.train( files[data/corpus.txt], vocab_size8900, min_frequency2, special_tokens[unk, bos, eos, pad], ) tokenizer.save_model(tokenizer)关于词表大小我特意选了8900而不是常见的3万或5万。原因很实在token embedding矩阵的大小是vocab_size × n_embd词表每多1000个tokenembedding层就多出50万参数。在总参数25.8M的预算里词表太大会挤占Transformer层的参数空间词表太小则会让一句话被切成很多碎片训练效率低。8900是我试过8000、9000、10000之后比较平衡的点。训练完一定要打印几个样例看看encoded tokenizer.encode(人工智能正在改变世界) print(encoded.ids) print(encoded.tokens)这一步能发现很多问题比如特殊token id是否连续、中文是否被切得稀碎、unk有没有泛滥。一旦分词器出问题后面的所有实验都会被污染而且很难排查。3.3 把文本切成长度256的训练样本分词器训练好后接下来要把清洗过的语料切成模型能吃的样本。这里有两个细节第一我的模型支持的最大上下文长度block_size是256也就是每次只看到前256个token第二样本之间不能乱接否则模型会学到跨文档的虚假关联。我采用的方案是每个完整段落先做分词如果token数超过256就按256的窗口切分成多条样本如果不够256就跨到下一段继续拼但中间插入一个换行符作为分隔。这个策略简单稳定比较适合小模型训练。Dataset类的骨架大概是这样class TextDataset(torch.utils.data.Dataset): def __init__(self, token_ids, block_size): self.data token_ids self.block_size block_size def __len__(self): return len(self.data) - self.block_size def __getitem__(self, i): x self.data[i : i self.block_size] y self.data[i 1 : i self.block_size 1] return torch.tensor(x, dtypetorch.long), torch.tensor(y, dtypetorch.long)x是输入tokeny是右移一位的目标token这正是语言模型“预测下一个token”的训练方式。我建议在正式训练前先随机抽样几个batch打印shape和token id范围确认数据构建没有越界。4. 模型搭建只依赖PyTorch的极简GPT4.1 整体结构拆解GPT的本质是一个Transformer解码器输入一串token id输出每个位置对下一个token的预测概率。从代码层面看整个模型由四大部分组成token embedding、position embedding、N个Transformer Block、最后的LayerNorm和输出投影。这里有一个很容易混淆的点GPT和BERT的Transformer Block不一样。GPT用的是Decoder Block内部是“LayerNorm - Attention - 残差 - LayerNorm - MLP - 残差”的结构而且Attention是带因果mask的当前位置只能看到它之前的位置。BERT用的是Encoder Block每个位置都能看到整句。如果你写模型时不小心漏掉因果maskloss会异常低但生成效果会稀烂因为信息从未来泄漏到了过去。我为什么选择Pre-LN结构LayerNorm放在Attention之前而不是Post-LN因为Pre-LN在小模型上训练更稳定对学习率不那么敏感收敛也更快。Post-LN虽然在大模型时代也有回归但在25.8M参数规模下Pre-LN仍是更省心的选择。4.2 核心代码实现模型定义我尽量精简核心只有三个类。第一个是LayerNorm虽然PyTorch自带nn.LayerNorm但自己写一遍能更清楚它做了什么import torch import torch.nn as nn class LayerNorm(nn.Module): def __init__(self, dim, eps1e-5): super().__init__() self.eps eps self.gamma nn.Parameter(torch.ones(dim)) self.beta nn.Parameter(torch.zeros(dim)) def forward(self, x): mean x.mean(-1, keepdimTrue) var x.var(-1, keepdimTrue, unbiasedFalse) return (x - mean) / torch.sqrt(var self.eps) * self.gamma self.beta第二个是带因果mask的多头注意力。对于25.8M参数的小模型我使用6个头每个头的维度是512/6≈85。Attention计算可以用一句话概括用query和key计算相关度得分经过softmax变成权重再对value加权求和。因果mask就是把上三角矩阵全部变成负无穷让softmax后的注意力权重变成0。class CausalSelfAttention(nn.Module): def __init__(self, n_embd, n_head, block_size): super().__init__() assert n_embd % n_head 0 self.n_head n_head self.c_attn nn.Linear(n_embd, 3 * n_embd, biasFalse) self.c_proj nn.Linear(n_embd, n_embd, biasFalse) self.register_buffer( causal_mask, torch.tril(torch.ones(block_size, block_size)).view( 1, 1, block_size, block_size ), ) def forward(self, x): B, T, C x.shape q, k, v self.c_attn(x).split(C, dim-1) k k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) q q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) v v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) att (q k.transpose(-2, -1)) / (C // self.n_head) ** 0.5 att att.masked_fill(self.causal_mask[:, :, :T, :T] 0, float(-inf)) att torch.softmax(att, dim-1) y att v y y.transpose(1, 2).contiguous().view(B, T, C) return self.c_proj(y)第三个是前馈网络MLP和Transformer Block。MLP是Transformer中容易被低估的部分它的参数量通常占模型总量的三分之二。我在实际训练中把MLP中间维度从标准的4倍改成了约4.5倍512 × 4.5 ≈ 2304实际取整到2400发现小模型在这种“稍宽”的配置下训练loss会略微更低一些代价是过拟合速度也快一点。class MLP(nn.Module): def __init__(self, n_embd): super().__init__() self.c_fc nn.Linear(n_embd, int(4.5 * n_embd)) self.c_proj nn.Linear(int(4.5 * n_embd), n_embd) self.act nn.GELU() def forward(self, x): return self.c_proj(self.act(self.c_fc(x))) class Block(nn.Module): def __init__(self, n_embd, n_head, block_size): super().__init__() self.ln_1 LayerNorm(n_embd) self.attn CausalSelfAttention(n_embd, n_head, block_size) self.ln_2 LayerNorm(n_embd) self.mlp MLP(n_embd) def forward(self, x): x x self.attn(self.ln_1(x)) x x self.mlp(self.ln_2(x)) return x最后把整个GPT组装起来class GPT(nn.Module): def __init__(self, vocab_size, block_size, n_layer, n_head, n_embd): super().__init__() self.vocab_size vocab_size self.block_size block_size self.token_embedding nn.Embedding(vocab_size, n_embd) self.position_embedding nn.Embedding(block_size, n_embd) self.blocks nn.Sequential( *[Block(n_embd, n_head, block_size) for _ in range(n_layer)] ) self.ln_f LayerNorm(n_embd) self.lm_head nn.Linear(n_embd, vocab_size, biasFalse) # 共享输入输出embedding省掉约450万参数 self.lm_head.weight self.token_embedding.weight def forward(self, idx, targetsNone): B, T idx.shape x self.token_embedding(idx) x x self.position_embedding(torch.arange(T, deviceidx.device)) x self.blocks(x) x self.ln_f(x) logits self.lm_head(x) loss None if targets is not None: loss nn.functional.cross_entropy( logits.view(-1, self.vocab_size), targets.view(-1) ) return logits, loss这里有一个关键操作self.lm_head.weight self.token_embedding.weight也就是输入和输出的embedding权重共享。这样能省掉大约8900×512≈450万参数在25.8M总预算里占比相当可观而且共享embedding在GPT类模型里被证明不会显著伤害效果。4.3 参数量验证训练前一定要打印参数量model GPT(vocab_size8900, block_size256, n_layer6, n_head6, n_embd512) total_params sum(p.numel() for p in model.parameters()) print(fTotal params: {total_params / 1e6:.2f}M)我实测下来的配置是6层Transformer、512维hidden、6个注意力头、8900词表、256上下文长度、MLP中间层2400参数量约25.74M对外就说25.8M。这个量级既不会让显存局促又能承载完整的训练流程是一个研究“小模型全流程”很理想的粒度。5. 训练细节与超参数调优5.1 训练配置和超参数表训练阶段我用到的超参数如下这是一版在中文语料上实测比较稳的配置超参数数值选择理由batch size64每步处理64×25616384个token3060 12G可承受优化器AdamW最主流选择配合weight decay效果好峰值学习率3e-4小模型常用区间过大容易loss起飞weight decay0.1缓解过拟合对LN和bias通常不生效betas(0.9, 0.95)更平滑的二阶矩估计warmup步数1000避免训练初期loss剧烈震荡学习率调度cosine衰减从峰值平滑降到接近0梯度裁剪1.0防止单batch异常导致梯度爆炸dropout0.1数据量不大适当增加正则训练数据约1亿token3个epoch约18000步训练循环的骨架如下import torch from torch.optim import AdamW from torch.optim.lr_scheduler import OneCycleLR optimizer AdamW(model.parameters(), lr3e-4, betas(0.9, 0.95), weight_decay0.1) scheduler OneCycleLR( optimizer, max_lr3e-4, total_steps18000, pct_start0.05, anneal_strategycos, ) for step, (x, y) in enumerate(train_loader): x, y x.to(device), y.to(device) logits, loss model(x, y) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step()OneCycleLR同时完成了warmup和cosine衰减warmup比例pct_start设为0.05也就是前900步左右线性升到峰值。这里要提醒一点学习率是训练中影响最大的超参数不要随便套。25.8M模型在1亿token数据上3e-4是一个不错的起点如果你觉得loss下降太慢可以试着提到5e-4但要盯紧是否出现loss尖峰。5.2 训练过程中的观察点训练不能只盯着终端里的loss数字我通常会做几件事每500步在验证集上计算一次val loss每1000步用当前模型生成几个短句子把“训练loss、val loss、生成样例”一起记录到wandb或本地日志。一个小模型正常训练的loss曲线大概是这样的初始阶段loss快速下降比如从10左右降到5因为模型在快速学习高频词和句法规律中期下降变缓从5到4可能需要几千步后期如果verification loss开始回升而train loss继续下降那就是过拟合信号需要增加数据、加大dropout或者提前停止。我当时训练到约15000步时train loss在3.4左右val loss在3.7左右差距不大说明泛化还可以。此时生成出来的句子已经有“词语搭配正确、但逻辑内容空洞”的雏形。这也是小型GPT的正常水平不必苛求。5.3 显存控制与加速技巧如果你的显卡是8G显存我建议把batch size降到16然后用梯度累积模拟batch size 64的效果grad_accum_steps 4 for micro_step in range(grad_accum_steps): x, y next(iter(train_loader)) x, y x.to(device), y.to(device) logits, loss model(x, y) loss loss / grad_accum_steps loss.backward() optimizer.step() optimizer.zero_grad()这样做的逻辑是小batch计算出的梯度方向有噪声累积4次后等效于用一个大的batch更新一次参数能保持训练稳定。代价是训练时间变长但在8G显存的限制下这是最实用的方案。半精度训练也可以用起来。在PyTorch里最简单的做法是with torch.autocast(device_typecuda, dtypetorch.float16): logits, loss model(x, y)由于输入输出embedding共享梯度的dtype需要保持兼容PyTorch会自动处理大部分细节。我实测fp16在小模型上主要收益是省显存速度提升不一定明显。省出来的显存可以换更大的batch size这对最终效果更有帮助。6. 部署推理从PyTorch到本地服务6.1 checkpoint与推理函数训练结束后我建议保存完整的checkpoint而不是只保存state_dicttorch.save( { model_config: { vocab_size: 8900, block_size: 256, n_layer: 6, n_head: 6, n_embd: 512, }, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), step: step, best_val_loss: best_val_loss, }, checkpoints/best.pt, )这样以后恢复推理时不需要靠记忆去猜当时的词表大小和上下文长度非常省事。推理函数的核心是自回归生成拿到prompt的token id反复预测下一个token把新token接在序列末尾继续预测。注意每次输入给模型的序列长度不能超过block_size超过时要只保留最后256个token。torch.no_grad() def generate( model, tokenizer, prompt, max_new_tokens128, temperature0.8, top_k40, top_p0.9, ): model.eval() input_ids tokenizer.encode(prompt).ids for _ in range(max_new_tokens): input_ids input_ids[-model.block_size :] idx torch.tensor([input_ids], dtypetorch.long) logits, _ model(idx) logits logits[:, -1, :] / temperature # top_k过滤 if top_k is not None: k min(top_k, logits.size(-1)) topk_values, topk_indices torch.topk(logits, k) logits[logits topk_values[:, -1]] float(-inf) # top_p过滤 if top_p is not None: sorted_logits, sorted_indices torch.sort(logits, descendingTrue) probs torch.softmax(sorted_logits, dim-1) cumsum probs.cumsum(dim-1) mask cumsum - probs top_p sorted_logits[mask] float(-inf) logits.scatter_(1, sorted_indices, sorted_logits) probs torch.softmax(logits, dim-1) next_id torch.multinomial(probs, num_samples1).item() input_ids.append(next_id) return tokenizer.decode(input_ids)采样策略我习惯三者搭配temperature控制分布的尖锐程度越小越保守0.7~0.9适合短句生成top_k限制候选数量防止低概率垃圾词被抽到top_p按累计概率截断生成时也不会太死板。只用argmax的话模型非常容易陷入重复循环。6.2 ONNX导出与量化模型规模小PyTorch直接推理其实已经够用。我在CPU上用i5-12490F测试PyTorch FP32的生成速度大约每秒30~60个token已经可以接受。但如果要做轻量级部署还是值得导出ONNX再用onnxruntime推理。ONNX导出的基本写法dummy_input torch.randint(0, 8899, (1, 256), dtypetorch.long) torch.onnx.export( model, dummy_input, gpt25m.onnx, input_names[input_ids], output_names[logits], dynamic_axes{ input_ids: {0: batch, 1: seq}, logits: {0: batch, 1: seq}, }, opset_version17, )导出后可以用动态量化把模型进一步压缩from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( gpt25m.onnx, gpt25m_quant.onnx, weight_typeQuantType.QUInt8, )FP32的25.8M模型ONNX文件大约103MB动态量化后可以降到30MB左右。需要注意一点小模型对量化误差更敏感int8量化后生成质量可能轻微下降。如果你不是特别在意体积我建议优先用FP16而不是int8。6.3 部署成FastAPI服务最后一步是把推理封装成HTTP接口。FastAPI是首选代码量极少自带文档页面非常适合本地工具集成。from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class GenerateRequest(BaseModel): prompt: str max_new_tokens: int 128 temperature: float 0.8 app.post(/generate) def generate_api(req: GenerateRequest): text generate( model, tokenizer, req.prompt, max_new_tokensreq.max_new_tokens, temperaturereq.temperature, ) return {text: text}启动服务后调用curl -X POST http://localhost:8000/generate \ -H Content-Type: application/json \ -d {prompt: 人工智能的未来是, max_new_tokens: 64}我在部署阶段最容易翻车的点是prompt过长导致模型收到超过block_size的序列以及生成时忘记调用model.eval()。前者会让模型索引越界或行为异常后者会让dropout在推理时继续生效生成结果充满随机噪声。7. 常见问题速查与踩坑记录7.1 高频问题表整个项目跑下来我整理了一张问题速查表基本都是亲测踩过或者帮朋友排查过的现象可能原因解决办法训练loss出现NaN或剧烈尖峰学习率过大、数据里有脏样本降低lr、做梯度裁剪、检查数据异常行显存OOMbatch size或序列长度太大缩小batch、开梯度累积、用fp16生成全是重复词语欠拟合或temperature过低减少训练步数或提高temperature/top_pval loss迟迟不降分词器和模型词表不匹配检查tokenizer的vocab_size、特殊token id中文乱码BPE分词器decode错误或词表太小确认ByteLevel配置、decode测试、适当增大词表推理速度太慢未开启eval模式、未使用no_grad加model.eval()和torch.no_grad()生成结果语句通顺但内容空洞模型太小正常现象不要过度期待小模型定位是局部规律学习恢复checkpoint后生成效果变差加载配置和训练时不一致保存checkpoint时把model_config一并存下来7.2 几个必须避开的坑我在训练和部署过程中有几个坑如果不总结换了别人肯定还要再踩一遍。第一个坑是忘了因果mask。注意力模块里的mask不是可选项而是GPT结构的一部分。如果mask写错了模型在训练时能看到当前位置后面的tokenloss会异常地低让你误以为训练很成功。我在第一次写完模型后专门构造了一个只有两个token的样本做单步forward测试检查输出是否只依赖前面token确认无误才正式训练。这个习惯建议所有做Transformer的人都保留。第二个坑是tokenizer和模型vocab_size不一致。我自己训练BPE分词器时指定了vocab_size8900但训练完成后实际词表可能是8912因为特殊token会被追加。如果PyTorch模型里写死nn.Embedding(8900, ...)而tokenizer编码出来的id却可能大于8899会直接索引越界。所以模型的vocab_size应该取tokenizer.get_vocab_size()的值而不是自己拍脑袋写的数字。第三个坑是数据集里混入重复文本。如果你从小语料里重复采样几轮模型会更容易复读。我第一次采样时写错了Dataset的边界条件导致每条样本之间重叠了大部分token训练出来的模型生成一句话后会机械地重复后半段。排查方法很简单随机打印几个batch检查x和y的移位关系是否正确。7.3 给想做更大模型的读者一点建议如果你已经跑通了这个25.8M的小项目下一步想尝试更大规模的模型我个人体会最深的几个建议是第一把数据工程和训练流程固化下来这比堆GPU更重要。小模型上踩过的数据坑在大模型上会放大十倍。第二珍惜你的实验记录。每次改动超参数或数据都记录下train loss、val loss、生成样例慢慢你会建立“什么数据和超参组合能带来什么效果”的直觉。第三不要跳过部署环节。哪怕只是部署到本机CPU它也会逼你去思考模型推理时真正占用资源的地方在哪这是纯训练给不了的经验。这个小项目虽然模型不大但它把大模型时代的每一个核心步骤都过了一遍语料清洗、子词分词、Embedding与位置编码、因果注意力、Pre-LN残差结构、交叉熵损失、学习率调度、梯度裁剪、自回归采样、ONNX导出、量化、HTTP服务。跑完之后你会发现所谓“大模型”本质上仍然是数据和工程细节的不断叠加。这个25.8M的迷你GPT就是我理解这一切最好的起点。