花 3 块钱,能不能从零训练一个“大语言模型”?我把引号打在“大”上,是因为 64M 参数在今天的语境里真的不算大,但它确实是一个完整的、从头预训练出来的语言模型。这个实验做完之后我最大的感受是:模型训练没有秘密,只有你还没亲手跑过的细节。
这篇文章会完整记录这次实验的整个过程:怎么选数据、怎么训分词器、Transformer 各层参数怎么分布、3 块钱账单怎么算、loss 曲线每个阶段长什么样,以及中途踩过的所有坑。适合想搞懂大模型训练、但一直没有机会真正动手的开发者,也适合已经会调 API、想往下一层看看“黑盒”内部的人。
先交代一下实验环境:一张 12GB 显存级别的入门计算卡,按某云平台活动价折算大约 1 元/小时;训练脚本是自己搭的一个极简 GPT 风格 decoder-only 模型;语料是从开放文本里切出来的中英混合约 1.2 亿字符。整趟训练跑了大概 3 个小时,总费用不到 3 元。下面把每个环节拆开讲。
1. 先把账算明白:64M 参数的模型到底长什么样
1.1 一张配置表看懂结构
很多人一听到“从零训练语言模型”,第一反应是“这得上百亿参数吧”。其实常规的预训练语言模型,参数规模从几千万到几千亿都有。这次实验的目标是本地可复现,所以我把规模定在 64M 附近。这个数量级的模型,结构上与主流大模型并没有本质区别——它就是一个 decoder-only 的 Transformer,用旋转位置编码保留顺序信息,用 RMSNorm 做归一化,用 SwiGLU 作为前馈网络的激活形式。
下面是我实际使用的配置:
| 配置项 | 数值 |
|---|---|
| 层数 | 8 |
| 隐藏维度 | 768 |
| 注意力头数 | 8 |
| 前馈中间维度 | 2048 |
| 词表大小 | 8000 |
| 最大序列长度 | 512(训练时用 256) |
| 位置编码 | RoPE 旋转位置编码 |
| 归一化 | RMSNorm |
| 激活函数 | SiLU / SwiGLU |
这里每个选择都不是拍脑袋定的。8 层、8 头、768 维,是很多经典中小模型的常见组合,让单卡训练和推理都很轻松,同时又保留了深层 Transformer 应有的特征提取层次。词表取 8000 是权衡结果:词表太小会让文本被切得过分零碎,模型要花更多精力学“拼接”;词表太大,embedding 层的参数会迅速膨胀,64M 的预算会被吃掉一截。8000 对中英文混合语料来说,是这个小规模下比较平衡的量。
1.2 参数量是怎么被一块块攒起来的
64M 这个数听着抽象,我把它拆开算一下,你就能理解参数都花在哪儿了。首先是 embedding 矩阵:8000×768,等于 614 万个参数,约占总参数的 10%。然后是 8 个 Transformer block,每个 block 里主要有三部分:自注意力、前馈网络、归一化。
自注意力里 Q/K/V 三个投影矩阵都是 768×768,合起来约 177 万参数,输出投影再增加约 59 万。前馈网络用 SwiGLU 之后实际上有三组矩阵:gate 和 up 都是 768×2048,down 是 2048×768,合起来约 471 万参数。这样每个 block 大约 700 万参数,8 个 block 约 5600 万。加上 embedding 和最后的归一化层,总参数在 6300 万附近,进位之后就是 64M。
这里有个细节值得注意:注意力部分占的参数反而比前馈网络少。原因是 attention 的矩阵是正方形的(768×768),而前馈网络是先把维度放大到 2048 再压回来,相当于在“更宽”的空间里做非线性变换。这也是现代大模型普遍把大部分参数花在 MLP 上的原因——模型的“知识”很大程度上存在前馈网络里,注意力主要负责信息路由。
1.3 64M 模型能干点什么,不能干什么
必须诚实地说,64M 参数训练出来的模型,能力非常有限。它能学会中文句子的大致结构,能续写几个词或一句短话,但记不住复杂事实,也写不了长文。拿人做类比,它就像一个记性不太好、但已经学会磕磕绊绊说话的学徒:你和它聊天气,它能接两句;问它某个具体产品的参数,它就胡说了。
但反过来说,正因为能力有限,它反而适合做教学实验。所有大模型训练里最关键的观察对象——loss 下降、过拟合、生成退化——在这个规模上都清晰可见。你不会像训大模型那样,等了几天只看到一条平滑的下降曲线,而是两三个小时就能看到完整的“炼丹”过程。这种反馈速度,是 64M 相比大模型最大的优势。
2. 三块钱怎么覆盖训练成本
2.1 算力选型:哪些便宜卡能跑
很多人以为训模型必须要顶配 GPU,其实 64M 参数远没有到那个量级。这个模型的显存占用大约在 6GB 到 8GB 之间,所以任何 12GB 甚至部分 8GB 显存的计算卡都能跑。如果只是短期实验,我建议直接用云平台按小时租,提前看看有没有新人券或者活动价。这次我折算下来的价格大约就是 1 元/小时,如果你有免费额度,总成本直接归零。
我自己的选择标准是三条:显存不低于 12GB、支持 fp16 或 bf16 混合精度、按小时计费。显存太低会频繁 OOM,不支持混合精度会让训练时间大幅拉长,按小时计费则是控制成本的关键。至于具体是哪个厂商,不重要,能用就行。
2.2 训练时长、数据量和显存的核算
三块钱能覆盖成本,核心在于把训练时长压进 3 小时以内。我当时的估算过程是这样的:
| 项目 | 数值 |
|---|---|
| 模型参数(bf16 存储) | ≈ 128MB |
| 优化器状态(AdamW 的 fp32 副本) | ≈ 768MB |
| 梯度 | ≈ 128MB |
| 激活值(batch=32,seq_len=256) | 约 3-5GB |
| 单卡峰值显存 | 约 6-8GB |
| 总训练步数 | 约 12000 步 |
| 每步训练时长 | 0.7 秒左右 |
| 总训练时长 | 约 2.5-3 小时 |
这里的显存大头其实不是模型参数,而是优化器状态和激活值。很多初学者拿着“64M 参数才 128MB”这个数字来估算显存,结果一跑就 OOM,就是因为没把 AdamW 的三份 fp32 状态算进去。这也是为什么混合精度几乎是现在训练模型的标配——它省下的不是参数本身的内存,而是优化器状态和计算开销。
2.3 为什么不上更大的模型
这是很多人的第一反应:64M 太小,我又不差这 10 块钱,能不能训一个 500M 的?
问题不在钱,在时间。模型参数从 64M 涨到 200M,训练的计算量大概要翻 4 倍以上,显存需求也跟着涨。3 块钱能做到的事,200M 参数可能需要 20 块钱、一整晚才能出结果;更重要的是,如果你还没有完整观察过一次小模型的训练动态,直接把规模拉大,遇到 loss 不降、生成重复这些问题时,你根本不知道是数据问题、超参问题还是架构问题。小模型的意义不只是便宜,而是每个变量都能快速验证。先在小规模上把训练链路跑通,再往大了去,这条路我看着无数人验证过。
3. 数据准备:喂给模型的第一口饭
3.1 数据从哪里来,数据量取多少
语言模型的“知识”全部来自数据。这一次我没有用现成的清洗好的数据集,而是从一个开放语料包里手动切了一块,大概 1.2 亿字符,包含新闻、百科、小说、社区问答四类文本。新闻给模型提供规范性表达,百科提供事实性的高频搭配,小说提供丰富的场景和对话,社区问答则让语料更像普通人日常说的话。四类混在一起,比只拿单一类型文本训出来的效果要好得多。
数据量不是越多越好,要看训练预算。64M 参数的小模型,训练 3 小时大约能见到 1 亿到 1.5 亿个 token;我准备的这 1.2 亿字符清洗后大约有 7000 万 token,训练时大概过两遍。如果数据只有几百万 token,模型在一个 epoch 内就会把语料“背下来”,后面全在过拟合;如果数据到几十亿 token,又远远超出训练预算,大部分数据根本没机会被看到。
3.2 清洗与合规:动手之前先过滤
这一步最无聊,但最不能跳过。原始文本里如果混着广告、乱码、重复段落、个人信息,模型学到的就不是语言规律,而是噪声。
我的清洗流程是四步。第一步,统一编码为 UTF-8,把全角字符转成半角,中文繁体统一转简体;第二步,丢 HTML 标签、广告模板、无意义的换行和连续标点;第三步,按长度过滤,去掉小于 50 个字符和大于 2000 个字符的片段,太短的没有上下文信息,太长的往往主题混乱;第四步,做粗略去重,用哈希值筛掉完全相同的段落。最后还要做合规检查:剔除包含个人身份信息的文本,对敏感内容做关键词过滤加人工抽检。这一步不仅是安全问题,也是训练质量问题——干净的数据才能让模型学到稳定的分布。
清洗完成后,我把语料按 98:2 切成训练集和验证集。验证集在训练时不会参与梯度更新,只用来观察模型有没有过拟合。这个比例对小规模实验足够了。
3.3 训练一个够用的 BPE 分词器
大模型通常不会直接拿字符训练,而是先把文本切成 token。这次我用 BPE 算法训练了一个词表大小为 8000 的分词器。BPE 的原理很好懂:初始把所有字符拆开,统计相邻 token 对出现的频率,每次把最高频的一对合并成一个新 token,重复这个过程直到词表达到目标大小。比如“训练”和“模型”这两个词如果经常挨在一起,就可能被合并成“训练模型”这个 token 的中间状态。
训练分词器用的是清洗后的语料,而不是随便找一段文本。因为 BPE 的词表是从语料里统计出来的,用不匹配的语料训出来的分词器,切出来的效果会很差。中文还好,英文单词会被切得七零八落,OOV 问题会非常严重。
实操中还碰到一个细节:词表里要保证常见中文单字稳定出现。8000 的容量下,常用汉字基本都能覆盖,一些生僻字会退化成未登录字,但影响不大。如果你用的语料以中文为主,可以试着把词表里英文 subword 的比例压低一点,方法是训练时把英文文本的权重调低。这个调整在测试时很容易观察出来:如果英文句子被切成大量单字母,说明分词器对英文的合并还不够充分。
4. 模型结构和训练代码:把 Transformer 一块块拼起来
4.1 初始化配置与模型骨架
在主流开源实现里,这样的配置可以直接套用一个标准 GPT 风格骨架。模型定义本身并不复杂,关键是几处细节:embedding 初始化的标准差、RoPE 的频率设置、RMSNorm 的 eps 值。我用的配置字典长这样:
config = { "vocab_size": 8000, "hidden_size": 768, "num_hidden_layers": 8, "num_attention_heads": 8, "intermediate_size": 2048, "max_position_embeddings": 512, "rms_norm_eps": 1e-6, "tie_word_embeddings": True, }tie_word_embeddings=True表示输出层的权重和输入 embedding 共享,少算了 614 万参数,这也是我前面说总参数约 63M 的原因。对小模型来说,共享 embedding 不仅省内存,还有正则化效果,能缓解过拟合。
初始化标准差我习惯用 0.02,这是很多 Transformer 模型的默认值。如果标准差设得太大,深层输出的方差会叠加放大,导致训练初期 loss 异常偏高;设得太小,梯度信号又太弱。用 0.02 配合 warmup,通常不需要额外做 layer-wise 学习率缩放。
4.2 训练循环里的关键选择
训练超参直接决定模型能不能收敛,下面是这次用的关键参数:
| 超参 | 取值 | 说明 |
|---|---|---|
| 优化器 | AdamW | betas=(0.9, 0.95) |
| 峰值学习率 | 6e-4 | 配合 500 步 warmup |
| 学习率调度 | cosine decay | 最终降到峰值 10% |
| 批大小 | 32,gradient accumulation 到 64 | 模拟更大 batch |
| 序列长度 | 256 | 单卡显存友好 |
| 梯度裁剪 | 1.0 | 防止梯度爆炸 |
| 混合精度 | bf16 或 fp16 | 根据卡的支持情况选择 |
学习率是最容易出问题的超参。6e-4 这个量级在 64M 模型上是比较稳的起点,新手我建议干脆用 3e-4,慢一点但安全。训练循环的核心逻辑只有十几行,用伪代码写出来是这样的:
for step, batch in enumerate(train_loader): input_ids = batch["input_ids"].to(device) logits = model(input_ids) loss = F.cross_entropy( logits.view(-1, vocab_size), input_ids.view(-1) ) optimizer.zero_grad() loss.backward() clip_grad_norm_(model.parameters(), 1.0) optimizer.step() lr_scheduler.step()这里唯一“不常规”的地方是模拟 batch:由于显存有限,我一次只进 32 条序列,但通过梯度累积让优化器每两步更新一次,等效 batch 是 64。这种做法在不增加显存的情况下扩大了训练稳定性,代价是训练时间略增。
4.3 开跑前的三项检查
正式训练之前,有三项检查我每次都会做,能省下大量排查时间。
第一项,检查初始 loss 是否接近理论值。对 8000 词表来说,随机初始化模型的交叉熵约等于 ln(8000),也就是 8.99 左右。如果你跑第一步 loss 偏低,说明数据泄漏或者 mask 有问题;偏高太多,说明初始化不稳定或学习率过大。看到初始 loss 在 8.9 附近,基本可以放心。
第二项,用小 batch 试跑 50 步。这一步是验证数据管道和模型能完整跑通,不用管 loss 降不降,只要不爆 NaN、不 OOM,就说明链路正常。
第三项,用同一个 batch 连续训练 10 步,看 loss 能不能降到接近 0。如果降到接近 0,说明模型有足够的容量去拟合一个小 batch,反向传播逻辑基本正确;如果卡在某个很高的值,那大概率是数据处理环节出了 bug。这个检查能帮你把“模型坏了”和“数据坏了”分开。
5. 训练实录:看到 loss 降下去的那一刻
5.1 观察 loss 的下降过程
训练开始后,我记录下每个阶段的 loss 变化,大致是这样的:
| 训练步数 | train loss | 对应困惑度 | 观察到的现象 |
|---|---|---|---|
| 0 | 8.95 | ≈ 7700 | 接近随机猜测 |
| 500 | 5.80 | ≈ 330 | 开始出现词级规律 |
| 2000 | 4.90 | ≈ 134 | 能拼出部分短语 |
| 5000 | 4.20 | ≈ 67 | 句子结构逐渐成形 |
| 8000 | 3.95 | ≈ 52 | 能续写出短句 |
| 12000 | 3.85 | ≈ 47 | train loss 仍在缓慢下降 |
loss 下降不是均匀的。前几百步掉得飞快,是从“完全随机”进入“知道哪些字更常出现”的阶段;到后面曲线越来越平缓,是在慢慢优化更细的语言规律。如果你画的 loss 曲线是一条直线,那大概率是学习率太低或者数据有问题。
这里有个非常重要的经验:训练后期要同时盯 train loss 和 val loss。我在第 10000 步之后发现 train loss 还在降,val loss 反而从 4.0 回升到 4.2 左右。这是典型的过拟合信号,说明模型开始在训练集的重复模式上钻牛角尖了。遇到这种情况,不要硬着头皮继续训,先停一下,看看 val loss 的最低点在哪里。
5.2 生成效果从乱码到短句的演化
训练过程中的乐趣在于采样生成。我每隔 500 步保存一个 checkpoint,然后用同一个 prompt 去测试。prompt 就用最简单的“今天天气”四个字,每次生成 50 个 token,采样参数是 temperature=0.8、top-k=40。
200 步时,模型输出基本是乱码,比如“的是一种不发了”这种没有语义的片段,但它至少学会了“的”这种高频字会经常出现。800 步时开始有中文词的雏形,偶尔能看到“今天”“天气”被正确拼出来了,但整句还是不通。3000 步时,输出变成了“今天天气很好,我们今天去山上看,有一个”这种勉强能读、但逻辑断裂的句子。到 8000 步,已经能生成“今天天气很好,适合出去走走,不过下午有阵雨。”这样完整连贯的短句。
这个演化过程比任何 loss 曲线都直观。如果你发现自己训了 5000 步生成还是一堆乱码,先别急着怀疑模型,用较低的温度(比如 0.5)再试试;如果低温度下仍然乱,再回头看数据和训练曲线。
5.3 预算用完前,怎么判断该不该继续训练
训练快满 3 小时的时候,我面临一个选择:是继续花钱训下去,还是就此打住?
判断依据其实很简单。如果 val loss 还在明显下降,生成效果每 500 步都有可感知的提升,那说明模型还有得学,后续投入值得。如果 val loss 已经开始回升,或者生成结果的多样性明显下降——翻来覆去就是那几句话——那就是过拟合或数据多样性不足的信号,再加预算也只是在背题,不如把语料扩充一下再训。
对我这次实验来说,val loss 在 10000 步后回升,生成效果在 8000 步后已经没有质变,所以 12000 步收手是合理的。省下的钱够再喝一杯奶茶。
6. 常见问题与避坑记录
6.1 高频问题速查表
训练小模型时踩过的坑,大部分是可复现的。我把遇到率最高的问题整理成了一张速查表,方便你对照排查:
| 问题 | 可能原因 | 解决思路 |
|---|---|---|
| loss 完全不降 | 学习率太低/太高、数据未 shuffle | 检查初始 loss 是否接近 ln(vocab),调整 LR 到 3e-4~6e-4 |
| loss 出现 NaN | 学习率过大、混合精度溢出 | 降低 LR、检查 GradScaler、开启梯度裁剪 |
| 显存 OOM | 显存估算只算了模型参数 | 减小 batch、开梯度累积、降低序列长度 |
| 生成的文本严重重复 | 语料重复率高、欠拟合、采样温度太低 | 数据去重、延长训练、提高 temperature |
| 中文全是乱码 | 语料编码不统一、分词器训练语料不匹配 | 统一转简体、清洗后重新训练 tokenizer |
| 训练速度很慢 | 用了 fp32、DataLoader 成为瓶颈 | 开混合精度、加大 num_workers、预取数据 |
这六个问题里,我遇到最多的是第三个和第五个。显存 OOM 几乎每个新手都会碰到,因为大家习惯拿模型参数量估算显存,忘了优化器状态和激活值才是真正的大头。中文乱码则多半是因为偷懒,想跳过分词器训练直接用现成词表,结果输入和词表风格不一致,效果自然很差。
6.2 三个让我多花冤枉钱的教训
第一个教训是无脑用 fp32 训练。一开始我觉得 64M 模型这么小,直接 fp32 跑就行,结果同样的训练量硬生生多花了一倍时间。后来开了 fp16 混合精度,训练速度明显提升,loss 曲线反而更稳。现在的 GPU 对 fp16/bf16 的优化已经非常成熟,小模型也能从混合精度里获得巨大收益。
第二个教训是尝试了 3e-3 的学习率,结果训练不到 200 步 loss 直接冲到 NaN。原因是这个规模下 AdamW 对学习率非常敏感,3e-3 配合 768 维度已经属于激进区间。后来我把峰值学习率降回 6e-4,再配合 500 步 warmup,NaN 问题再没出现过。学习率不是越大越好,warmup 的真正作用是让优化器在初期积累稳定的梯度统计量。
第三个教训是数据没去重就开跑。当时偷懒,直接从原始语料里切了一批样本,结果训练到后期,模型生成的内容翻来覆去只有那几个高频句式,明显是训练集里重复文本太多。我后来在清洗脚本里加了哈希去重,又做了基于段落首尾的近似去重,重新训练之后生成多样性肉眼可见地变好了。
7. 几点实操体会
这次花 3 块钱训练 64M 模型,最大的收获不是“我也能训模型了”,而是把训练链路里的每个环节都亲手验证了一遍。数据清洗花的时间比训练本身还长,这不是小题大做;分词器词表的 8000 还是 16000,直接影响模型表现;学习率差 3 倍,结果就是 NaN 和正常收敛的天壤之别。
想给同样准备动手的人几个建议。第一,不要一上来就追求模型有多大,先把一个小模型从数据到生成完整跑通,形成对“loss、过拟合、生成质量”的直觉。第二,所有超参数都记下来,每次只改一个变量,否则出现问题根本没法定位。第三,训练过程中不要只盯 loss,定期保存 checkpoint,每 500 步用同一个 prompt 采样生成,你观察到的变化比任何指标都实在。
如果你已经调过不少 API、但对训练本身一直觉得像黑盒,我建议你从这样一个 64M 的小模型开始,把盒盖掀开。它虽然小,五脏俱全,能让你花一顿早饭的钱,看到大模型训练里那些最重要的现象。