从零造一个语言模型,十一站


花了两天,用 MLX 从零写了一个语言模型。不是调库,是把注意力、分词器、训练循环、采样一行行敲出来,然后一站一站往前推:字符级 bigram、手写 transformer、自己实现的 BPE、在 3.9 亿 token 上预训练出一个 29.9M 的小模型,再往后是 KV cache、量化、强化学习、好奇心,最后换成真正的开源模型、接上工具和网页搜索。
十一站。真正值钱的不是最后那个能跑的东西,是中间量出来的一堆数字 —— 其中好几个和我原本的直觉正好相反。
一个 25 倍的对照
同一个任务:2 位数乘 3 位数,让模型直接答,不给草稿纸。
先用强化学习训。LoRA + GRPO,120 轮,跑了 21 分钟:
37.3% → 39.7% +2.3 个百分点
然后换个做法:什么都不训,给它一个计算器,让它自己决定要不要调用。
39.8% → 99.2% +59.4 个百分点
128 道题里 76 道变对,0 道变坏
25 倍的差距,而且后者权重一个字节都没改。
这个对照把整个项目的主线摆了出来。
那条主线
强化学习放大已有的行为,创造不了不存在的能力。 不存在的能力,从外面装。
这句话不是我读来的,是四个实验从两个方向量出来的。
第一个方向是失败。我让模型学着写含"龙"的故事 —— 基线只有 0.4%,训了几百轮,纹丝不动;系统自己的课程算法算出这个任务"没有学习进展",把它放弃了。这不是算法失败,是算法看对了:权重里根本没有足够的"龙"可供放大。
心算乘法是同一件事的另一个版本。要求模型在一次前向传播里完成多步计算,而它 37% 的正确率大概是靠部分积的模式匹配凑出来的。强化学习能重新加权已有的行为,它造不出一个新算法。所以只挪动了 2.3 个百分点。
第二个方向是成功。给计算器,即刻 +59.4。更有意思的是最后一站:我用强化学习教模型"什么时候该查资料"。
它原本对 62% 它已经知道的事实也要去搜一遍 —— 法国首都、金的化学符号,张口就能答的东西,它也调用搜索。训完之后:
| 题目类别 | 工具调用率 | 准确率 |
|---|---|---|
| 事实题(它本来就知道答案) | 62% → 0% | 94% → 100% |
| 算术题(确实需要计算器) | 100% → 100% | 100% → 100% |
两类同时最优,准确率一分没丢。而我从来没有标注过任何一道题"该不该搜" —— 奖励只有一句"答对得 1 分,每调用一次工具扣 0.2 分",该不该用工具是它自己推出来的。
为什么这次成得了?因为"什么时候该查资料"是模型本来就偶尔做对的事(38% 的事实题它确实没搜),那是策略,不是缺失的能力。
四个放在一起,形状就出来了:
| 实验 | 目标 | 这个能力在权重里吗 | 结果 |
|---|---|---|---|
| 写含"龙"的故事 | 基线 0.4% | 否 | 0 —— 课程算法自己放弃了它 |
| 心算 2 位 × 3 位乘法 | 缺失的算法 | 否 | +2.3 个百分点 |
| 给一个计算器 | (完全不训练) | — | +59.4 个百分点,即刻 |
| 什么时候该查资料 | 本来就偶尔做对 | 是 | 62% → 0%,准确率没掉 |
所以判断一件事该训还是该装,只要问一句:它现在是不是已经偶尔能做对? 是,就训;否,就装。
三次我差点把结论看反
这部分比结论本身更值得记。
第一次。 那个 +2.3 的强化学习实验,训练中每 20 轮评估一次,得到:
43.8% 42.7% 39.6% 41.7% 49.0% 43.8% 基线 42.2%
一条平线,还带一个 49.0% 的假信号。我当时写下的原话是"没有学到东西"。
后来换了个测法:同一批 512 道题、同一份权重,只切换 adapter 的开关。
关掉 adapter : 37.30% 错→对 19 道
开启 adapter : 39.65% 对→错 7 道
效果是真的。之前看不见,是因为每次评估都换一批新题,不同题目的难易差异(±5%)把 +2.3% 完全淹没了。
要比较两个模型,必须让它们做同一张卷子。
这条教训在这个项目里出现了三次。第一次是比较两个模型的困惑度,得先把评估用的批次固定死;第三次是比较有工具和没工具。每一次不这么做,结论都会反过来。
第二次。 教模型"什么时候查资料"那一轮,前 20 轮几乎不动(62% → 56%)。我当时说:如果第 30 轮还不动就停掉重调参数。
结果第 20 到 40 轮之间突然塌到 0%,而且此后 30 轮完全稳定。
强化学习常常是"平台期 + 骤变"—— 前期在积累信号,策略跨过某个阈值后迅速收敛到新的稳定点。在平坦段宣判"学不动"是不安全的。
第三次。 训练日志里写着算术准确率 72%,同一时刻正式评估是 100%。因为训练采样故意用了高温度(不这样的话同一道题的多次采样答案完全一致,组内没有差异,梯度精确为零),代价就是训练时的答案质量下降。
训练时的数字是采样噪声下的表现,不是成绩。
三次翻车
比成功更有意思的部分。每一次都不是 bug,是机制的必然后果。
奖励黑客。 我故意写了一个有漏洞的奖励:"故事不超过 120 个 token 得 1 分",只字未提"故事还得是个故事"。200 轮后成功率 100%,平均长度从 169 降到 30。
而它找到的漏洞比我预想的更妙 —— 它没有写短故事,它跳过整个故事直接写结尾:
提示词:Once upon a time
续写 :, Timmy was very tired. He went home and slept well. The end.
"最后两句"既满足长度、又是它熟悉的文本分布,于是收敛到了那里。
奖励函数写的是你说的话,不是你想要的东西。
Noisy TV。 我想给模型加"好奇心",第一直觉是奖励惊讶:让它写自己都觉得意外的东西。跑了 120 轮,内在奖励从 2.3 一路涨到 6.2 —— 从奖励函数的视角,这是一次彻底的成功。
而它写出来的东西是:
d cubs rush enthusiasmHarry reots m streng than Jenny was
乱码。因为噪声永远最不可预测,追求惊讶的智能体会一直盯着电视雪花看。这在强化学习里有专名。惊讶不等于新奇 —— 好奇心必须能区分"我还不知道"和"这东西本来就不可知"。
顺带一提,衡量多样性的 distinct-2 指标当时涨到了 0.967,创了新高。它被乱码刷满了。 任何单一指标都能被刷,是困惑度和另外三个任务同时揭穿了它。
方向搞反的 KL。 为了不让模型在强化学习里跑偏,要加一个约束把它拽回原始模型附近。最直觉的写法在期望上是对的,但作为一个要求导的量是错的 —— 它有符号且下无界,梯度下降只会把概率一路压低。
单元测试直接抓到了:给定参考模型比当前策略自信 2 个 nat,一步更新之后差距变成了 3.06。朝反方向走了。
一个意外的副产品
量化(把 16 位的权重压成 4 位)在小模型上完全没有加速,我当时的解释是"权重读取只占单步耗时的 11%,剩下是固定开销"。
换成 1.7B 的模型之后重做同一件事:
| 精度 | 权重体积 | 生成速度 | 每 token 耗时 |
|---|---|---|---|
| bf16 | 3.8 GB | 61.8 tok/s | 16.2 ms |
| 8-bit | 1.7 GB | 103.1 tok/s | 9.7 ms |
| 4-bit | 934 MB | 167.5 tok/s | 6.0 ms |
拿这三个点拟合"每 token 耗时 = 固定开销 + 权重体积 / 内存带宽",解出来的带宽是 281 GB/s。
M4 Pro 的标称内存带宽是 273 GB/s。误差 3%。
我从三个生成速度里,量出了这台机器的内存带宽。
同一个道理也解释了为什么 KV cache 在小模型上只快 3.2 倍(理论上该是十几倍),以及批量生成为什么 64 路只快 8 倍。瓶颈在哪里,决定一切。
天花板
我一开始的想法是做一个能自我学习、自己上网找知识、总结经验的系统。
前半段被证明了:知识和能力可以在推理时补,不必塞进权重。计算器、搜索、网页抓取,是同一个循环挂不同的函数。29.9M 的模型问它"法国首都是哪",它会答"a sign of friendship and friendship";换上 1.7B 加一个搜索工具,它能告诉我一个训练数据截止之后才发布的模型叫什么。
但天花板由两样东西定死,而且两样都被实验证实了:
一是底座模型的能力。自训练放大不出不存在的东西 —— 那条学不会的龙就是证据。
二是验证器能检查什么。模型永远无法超过"能被检查的东西"。我的奖励函数只能验证"故事里有没有猫",那模型就只会在"有猫"这一维上进化到 94%,然后停住。
自我改进的闭环是真的:生成 → 验证 → 保留好的 → 训练 → 更强。但没有验证器锚定的自训练会崩溃,上面那两次翻车就是这个病的两种形状。
没有人知道怎么造 AGI。但一个"在你能验证的任务上持续变强"的系统,它的每一个零件我都亲手跑通了一遍。
这大概就是这两天最大的收获:很多结论只有自己动手量一遍才是你的。 读到的时候你以为你懂了,直到你看着一条平线写下"没有学到东西",然后换个测法发现效果一直在那儿。