Y W R.Anno MMXXVI
Essay

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

Ye Weirui
Ye Weirui
Cover Image for 从零造一个语言模型,十一站

花了两天,用 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。但一个"在你能验证的任务上持续变强"的系统,它的每一个零件我都亲手跑通了一遍。

这大概就是这两天最大的收获:很多结论只有自己动手量一遍才是你的。 读到的时候你以为你懂了,直到你看着一条平线写下"没有学到东西",然后换个测法发现效果一直在那儿。