
如果你的预算只有一台单卡 GPU、一个下午的时间你能亲手训练出一个语言模型吗最近技术社区里流传着一个很有冲击力的实验结论I trained a small transformer in 1.5hrs and it beats many LLMs。只看标题很容易把它理解成“小模型逆袭大模型”的爽文。可一旦把前提拆开你会发现它真正值得讨论的根本不是参数攀比而是另一个问题当一个语言模型从零开始训练的时间被压缩到 1.5 小时这个量级时我们到底能观察到什么、学到什么。我的判断是这类实验的价值被很多转发者讲歪了。小型 transformer 能取得让人意外的表现不是因为缩放定律失效也不是某个开源实现自带魔法而是因为它把大模型的能力来源拆得更清楚了。数据和任务边界决定了模型会在哪里变强而 transformer 的自回归训练目标本身就是一种很强的高效压缩器。只要语料足够干净、评测足够聚焦一个几十 M 到几百 M 参数的小模型完全可以在特定分布上表现出接近大系统的水平。这篇文章不自称能复现那个实验的每一个细节而是把它的工程逻辑拆成你可以直接上手的方案先讲清楚小型 transformer 为什么能“以小博大”再给出一套在单卡 GPU 上能够完成的 Mini Transformer 训练流程最后讨论如何避免“看 loss 好像学会了拿去做生成却什么都不行”的典型误区。读完以后你可以自己开一轮 1.5 小时级别的小型预训练并且能更理性地判断模型到底强在哪里、弱在哪里。1. “1.5 小时训练小 transformer”真正想表达的东西先给一个冷静判断大多数普通开发者对大模型的敬畏其实来自两个地方。一是参数规模动辄几十 B、上百 B光是权重文件就让人望而却步二是训练成本印象中预训练是大型实验室的专属游戏个人开发者只能站在旁边调 API。这个标题之所以传播得广就是因为它戳中了这两点它告诉你如果只在一个窄而干净的任务分布上做实验小模型也可以在很短的时间内得到可用结果。但“beats many LLMs”这个说法需要限定才能成立。从常见实验设计看至少有三层容易被忽略的限制第一是评测范围。小型 transformer 通常是在某个特定语料分布内训练比如小故事、编程题、特定格式的 JSON、固定风格的代码片段。如果拿通用对话基准去测一个几十 M 参数的小模型无论怎么训练都很难打过百亿级大模型。真正“击败”的场景往往是那些通用大模型并不见长的窄任务。第二是比较口径。一个只训练了 1.5 小时的小模型比较对象常常是“没有针对该领域做过适配的通用模型”或者是“未充分训练时的中途模型”。在数据分布内的语言建模能力上小模型可以做到很低 loss但这不代表它在零样本推理、指令跟随、复杂规划上同样出色。第三是能力来源。transformer 本质上是一个做条件概率估计的序列模型通过不断预测下一个 token 来学习训练语料中的结构。当语料范围很聚焦时模型需要用到的“世界知识”并不多参数规模不必很大训练时长也可以显著缩短。这并不违背缩放定律它只是说明缩放不是万能的任务边界本身就是一种极强的先验。所以1.5 小时实验真正告诉我们的不是“大模型不行了”而是“小模型在受限分布内可以非常有战斗力”。这给个人开发者带来的机会是很具体的想研究训练机制的人不用再背负超大集群的成本想验证数据工程想法的人可以先在小模型上快速迭代再移植到大模型想解决某个垂直场景问题的人甚至可以放弃调用昂贵通用 LLM改训一个轻量专用小模型。很多读者看到这个标题都会联想到近年来开源社区里流行的 nanoGPT 式小型实现以及 Andrej Karpathy 的一系列公开分享。他的核心主张就是不要把一个 GPT 当黑盒而是把它看成一个可以亲手控制、亲手训练的基础系统。这正好是理解“1.5 小时训练”的最佳姿势小模型的价值不主要在成绩单而在你可以亲手控制每一个变量。2. Transformer 基础为什么小模型也能学会“语言”聊训练之前有必要把 transformer 的核心机制快速过一遍。很多读者可能已经在用 LLM API甚至看过《Attention Is All You Need》但到训练环节还是会卡壳原因往往是几个概念之间的因果链没有串起来。2.1 token、embedding 与上下文所谓 token是模型处理文本的最小单元。英文里可以是单词也可以是子词片段中文里常见做法是把字或词切分成 id。模型并不直接读文本而是读取一串 token id。这一步里最容易忽略的是词表大小词表越大embedding 和输出层占用的参数量就越高小型实验里如果盲目使用大词表模型容量会被非核心参数吃掉很多。token id 会先经过一个 embedding 层变成向量。每个 token 的向量表示并不是固定的语义词典它会在训练中不断被上下文修正。然后transformer 在每一层中都会让序列里的 token 两两交互更新自己的向量表示。所谓“上下文长度”一般也叫 block_size指模型最多能看到多长的 token 序列。这个参数直接影响注意力矩阵的大小因此对显存和训练速度影响极大。2.2 因果自注意力只能看过去的自我修正自注意力是 transformer 最核心的算子。它会给序列中的每个 token 计算三个向量query、key、value。可以这样理解query 表示“我现在想找什么信息”key 表示“我手里有什么信息”value 表示“我真正要输出的信息”。模型通过 query 与所有 key 做点积得到注意力权重再按权重融合所有 value。语言模型必须使用因果掩码预测第 t 个位置时只能看到第 1 到第 t-1 个位置不能偷看未来。这个设计直接对应训练目标——给定前面的 token预测下一个 token。你可能会奇怪为什么这么简单的自监督目标最后能产生会写代码、会做数学推理的模型一个很直观的解释是为了稳定预测下一个 token模型必须把语料里的语法、语义、事实知识、思维方式都压缩进参数里。预测得越准压缩得越好模型内部表示就越接近语料的深层结构。2.3 层、头数与残差连接单层自注意力不足以捕捉复杂规律所以标准实现会把多个 transformer block 堆叠起来。每个 block 里通常包含一个多头注意力和一个前馈网络MLP。多头注意力让模型可以在不同子空间里并行关注不同类型的模式有的头可能关注局部语法有的头可能关注远距离指代关系。真正让深层网络可以训练的是残差连接和层归一化。残差的意思是每层输出会加上输入x x sublayer(norm(x))。这让梯度可以从最后一层直接回传到第一层避免深层网络训练时梯度消失。LayerNorm 则起到了稳定数值分布的作用。很多新手训练小模型时第一轮 loss 不下降往往不是理论问题而是这些基础设施层面的细节没有对齐。2.4 缩放定律与小模型的生存空间大语言模型领域有一个广为人知的观察当参数、数据、算力都同步放大时模型 loss 往往按幂律下降。这也是大家拼命“堆规模”的底层逻辑。但这条规律不等于说小模型没有研究价值也不等于说任何任务都只能靠大模型解决。更重要的是“训练充分度”这个概念。一个几百 B 的大模型如果只训练了很少的 token它可能还不如一个在自己的数据上被反复训练过的小模型。反过来一个小模型如果训练数据过大、任务过宽它很快就会触到容量天花板表现为 loss 怎么降也降不下去。小型 transformer 的聪明打法是在一个窄分布里把容量用到极致。这里可以看一张对比表维度通用大模型 LLM小型 Transformer目标分布覆盖大量通用语料聚焦某个窄领域训练成本极高通常集群级低单卡数小时可迭代推理成本高需要大显存或量化低适合端侧和边缘部署可控制性行为复杂难预测更容易定位数据集影响评测表现通用 benchmark 更强特定分布内可以很强迭代速度轮次少、试错成本高可以频繁做实验对小模型来说“知识”主要来自数据分布的直接刻画。如果训练语料里全是某个领域的文本它会很快变成那个领域的“专家”。这也解释了为什么“1.5 小时训练”能成立你只需要让模型学会一个相对规则的世界而不是全世界。3. 环境准备与前置条件在开始训练之前你需要先确定自己的实验边界。这里给出一套单机可复现的配置思路版本号不写死因为 PyTorch 和 CUDA 的版本组合变化很快最稳妥的方式是参考你本地环境与官方文档。3.1 硬件与运行环境推荐使用一张至少 8GB 显存的 NVIDIA GPU。显存是训练小模型最硬性的约束它直接决定了你同时能放多少 token 进模型。8GB 适合跑几十 M 参数、短上下文的实验12GB 或 16GB 会更从容可以适当扩大 batch size 或上下文长度。如果没有 GPU也可以拿 CPU 跑微型 demo 验证代码逻辑但不适合冲“1.5 小时训练”的目标。操作系统方面Linux 是最省心的选项不少云厂商提供按小时计费的 GPU 实例。macOS 和 Windows 也可以跑但需要注意驱动、CUDA 与 PyTorch 的匹配。整体实验对 IDE 没有特殊要求一个命令行终端加一个编辑器足够。3.2 Python 环境与依赖建议使用 conda 或 venv 创建独立环境避免污染系统 Python。代码主要依赖 PyTorch另外 numpy 用于数据处理tiktoken 是可选的 BPE tokenizer 工具。演示字符级流程时甚至不需要 tiktoken一个普通的 Python 文件就能完成。conda create -n minigpt python3.10 -y conda activate minigpt pip install torch numpy tiktoken安装完成之后可以快速检查 GPU 是否可用import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else CPU only)如果torch.cuda.is_available()返回 False不要急着调代码优先排查 PyTorch 版本是否正确、CUDA 驱动版本是否兼容。这台机器的训练速度直接决定了“1.5 小时”是轻松完成还是严重超时。3.3 项目目录结构为了让后续实验更清晰建议先建立这样的目录mini-gpt-lab/ ├── data/ │ └── input.txt ├── prepare.py ├── model.py ├── train.py └── sample.pydata/input.txt是训练语料prepare.py负责把文本切分成 id 并保存model.py定义 transformer 模型train.py执行训练循环sample.py用来加载 checkpoint 并做生成验证。这样划分的好处是每替换一份语料只需要重新运行prepare.py和train.py模型代码基本不用动。4. 数据设计小 transformer 实训里最不该省的思考很多人第一次训练自己的 GPT 模型时最容易犯的错误是把注意力全放在模型代码上轻视数据准备。真实情况恰恰相反当模型只有几十 M 参数训练时间只有 1.5 小时量级时数据质量直接决定实验成败。一个杂乱无章的语料会让模型的大部分容量都浪费在学习噪声上。4.1 语料选择要“窄而深”不要“广而浅”如果你的目标是让模型“击败某些 LLM”至少得先明确它在哪个维度上击败。对小型 transformer 来说最合适的研究对象是有清晰结构和一致风格的文本集合某类代码文件的函数与注释适合研究代码补全同一作者或同一风格的中短文章适合研究文风生成某种固定格式的日志或 JSON适合研究结构化输出TinyStories 这类语法简单但叙事完整的小故事集适合观察语言能力 emergence。数据量不是越大越好。一个 100 M 以内的小模型假设上下文长度为 256训练几千万 token 已经能达到相当可观的分布内效果。如果数据量过大在有限时间内模型根本训练不充分最终结果看着 loss 还在下降但离“好好采样生成”还很远。4.2 先用字符级流程跑通再考虑更真实的 tokenizer从零训练一个小 transformer字符级 tokenizer 是一个很好的起点。它不是工业界的最终方案但能让你快速理解训练链路把语料变成 id、输入模型、计算 loss、反向传播、采样输出。训练中文文本时字符级会让序列长度明显变长但作为教学和跑通实验可接受。下面这份prepare.py的核心逻辑就是读取一个文本文件统计字符表然后切分成训练和验证两份 id 序列# prepare.py import random data_path data/input.txt train_out_path data/train.bin val_out_path data/val.bin with open(data_path, r, encodingutf-8) as f: text f.read() chars sorted(list(set(text))) vocab_size len(chars) print(f数据集字符总数: {len(text)}) print(f词表大小: {vocab_size}) stoi {ch: i for i, ch in enumerate(chars)} itos {i: ch for i, ch in enumerate(chars)} encode lambda s: [stoi[c] for c in s] decode lambda ids: .join(itos[i] for i in ids) data encode(text) split int(0.9 * len(data)) train_data data[:split] val_data data[split:] with open(train_out_path, wb) as f: f.write(bytearray(train_data)) with open(val_out_path, wb) as f: f.write(bytearray(val_data))很多字符级 demo 会把全量数据作为单个张量加载然后随机采样 batch。当语料非常小时这没问题但如果文本有几万行最好还是把处理后的 id 落盘训练脚本再去加载。这个设计也为以后换成 BPE 或 SentencePiece 保留了空间。4.3 数据质量比单纯加大数据量更关键对 1.5 小时训练来说数据工程优先做三件事去重、清洗、切分。去重很重要因为模型会背诵重复片段导致验证集上 loss 虚低真实生成却表现不好。如果语料来自网络抓取至少要按行或按段落做一次近似去重。清洗则需要删除无关 URL、广告噪声、乱码字符。训练集和验证集最好按文档级别切分避免同一文档的上下文同时出现在两边否则验证 loss 会给出过度乐观的估计。最后要说的是小模型特别擅长学习格式一致的数据。如果你希望它生成结构良好的 JSON那么训练语料里就只放结构良好的 JSON而不是混入大量解释性文本。语料越纯净模型越容易发现规律这也正是“1.5 小时能出效果”背后最实在的贡献。5. 模型架构与最小实现接下来进入代码部分。一个小型 transformer 并不复杂核心组件是配置类、因果自注意力、MLP 层、Transformer Block以及把它们串起来的 GPT 主体。下面的代码会尽量保持完整和可运行但不会刻意堆叠工程技巧。5.1 模型配置先定义一个配置类。block_size是上下文长度vocab_size必须等于你的 tokenizer 词表大小n_layer是 block 数量n_head是注意力头数n_embd是向量维度。# model.py from dataclasses import dataclass dataclass class MiniGPTConfig: block_size: int 256 vocab_size: int 512 n_layer: int 6 n_head: int 8 n_embd: int 384 dropout: float 0.1 bias: bool True这些参数的搭配需要根据显存来调整。block_size的显存成本是平方级增长的所以当 GPU 只有 8GB 时把block_size从 512 降到 256 通常比减小 batch size 更有效。n_embd与n_layer决定模型容量但如果语料很小太深的模型反而容易过拟合。5.2 因果自注意力实现下面是最核心的注意力类。为了直观演示这里没有做 Flash Attention也没有过度优化只保留了最关键的因果掩码逻辑# model.py import torch import torch.nn as nn from torch.nn import functional as F class CausalSelfAttention(nn.Module): def __init__(self, config: MiniGPTConfig): super().__init__() assert config.n_embd % config.n_head 0 self.n_head config.n_head self.n_embd config.n_embd self.c_attn nn.Linear(config.n_embd, 3 * config.n_embd, biasconfig.bias) self.c_proj nn.Linear(config.n_embd, config.n_embd, biasconfig.bias) self.dropout nn.Dropout(config.dropout) def forward(self, x): B, T, C x.size() qkv self.c_attn(x) q, k, v qkv.split(self.n_embd, dim2) head_dim C // self.n_head q q.view(B, T, self.n_head, head_dim).transpose(1, 2) k k.view(B, T, self.n_head, head_dim).transpose(1, 2) v v.view(B, T, self.n_head, head_dim).transpose(1, 2) att (q k.transpose(-2, -1)) / (head_dim ** 0.5) mask torch.tril(torch.ones(T, T, devicex.device, dtypetorch.bool)).view(1, 1, T, T) att att.masked_fill(mask 0, float(-inf)) att torch.softmax(att, dim-1) att self.dropout(att) y att v y y.transpose(1, 2).contiguous().view(B, T, C) return self.c_proj(y)这段代码里要重点理解两个地方。第一qkv self.c_attn(x)把同一个输入映射成三份向量这是 GPT 系列实现中常见的合并写法比分别写三个线性层更高效。第二因果掩码用torch.tril构造了一个下三角布尔矩阵它确保当前位置只能和当前位置及之前的位置计算注意力。如果你去掉 mask模型训练时会偷看未来 token最终生成效果会非常差。5.3 MLP 与 Transformer BlockTransformer Block 里的 MLP 通常是一个两层的全连接网络中间用非线性的 GELU 激活函数扩展维度# model.py class MLP(nn.Module): def __init__(self, config: MiniGPTConfig): super().__init__() self.c_fc nn.Linear(config.n_embd, 4 * config.n_embd, biasconfig.bias) self.c_proj nn.Linear(4 * config.n_embd, config.n_embd, biasconfig.bias) self.dropout nn.Dropout(config.dropout) def forward(self, x): x self.c_fc(x) x F.gelu(x) x self.c_proj(x) x self.dropout(x) return x class Block(nn.Module): def __init__(self, config: MiniGPTConfig): super().__init__() self.ln_1 nn.LayerNorm(config.n_embd, biasconfig.bias) self.attn CausalSelfAttention(config) self.ln_2 nn.LayerNorm(config.n_embd, biasconfig.bias) self.mlp MLP(config) def forward(self, x): x x self.attn(self.ln_1(x)) x x self.mlp