一个 19 格的钟面,两个数字落在同一格 左边写着 params: 6、10010 / 10010 和 758 + 467。右边是一个 19 格的钟面,指针指向第 10 格,那里叠着两块强调色的数字牌 5 和 6;钟面周围散落着输入序列里的其他数字牌。 8 7 0 4 0 9 0 1 5 6 params: 6 10010 / 10010 758 + 467 = 1225

GitHub 上有一个叫 AdderBoard 的排行榜,比的是谁能做出最小的 Transformer,让它把两个十位数加对1。榜首那一份提交只有两百来行 numpy 代码,我们把它下载下来,用排行榜自带的验证脚本跑了一遍:

$ python verify.py submission_6p_handcoded.py
Parameters (unique): 6
Architecture: 1L Qwen-derived decoder, d=2, 1h, hd=2, ff=2 (fixed Q, folded norm)
...
Results: 10010/10010 correct (100.00%)
Time: 23.9s (419 additions/sec)
Status: QUALIFIED (threshold: 99%)

一万零十道题,全对;而整个模型只有 6 个参数。作为对比,ChatGPT 这一类大模型的参数以千亿计。

看到这个结果,很自然会冒出几个疑问:Transformer 到底是个什么结构,“参数”指的是什么?6 个数字怎么就能把十位数加对?榜上还分了“训练出来的”和“手写的”两张表,训练榜的第一名要 36 个参数,为什么差这么多?我们先把 Transformer 本身讲清楚(原问题里的 “transfer”,我们按 Transformer 理解),再顺着排行榜一层层往下看。

一、Transformer 的基本结构

从“预测下一个字”说起

不管是聊天机器人还是这个排行榜上的小模型,Transformer 做的事情都只有一件:给它一串符号,它预测下一个符号。这些符号叫 token(词元),可以是一个字、一个词,在加法这件事上就是一个数字 0–9。

预测完一个,把它接到序列末尾,再预测下一个,一直重复到答案写完。这种“边写边把自己写的东西当输入”的方式叫自回归(autoregressive)。所以“让 Transformer 做加法”的意思是:把两个加数写成一串数字喂给它,它一位一位地吐出和。十位数加十位数,和最多十一位,于是要预测 11 次。

那么,每一次预测,模型内部发生了什么?

一步预测里的四个部件

一个最简单的 Transformer 由四样东西串起来。下面这张图画的是 758 + 467 这道题,模型已经写出了个位 5、正在预测十位的那一步(数值取自后文那个 6 参数模型;输入为什么要倒着写、中间为什么有一串 0,第三、五节再讲)。

Transformer 预测一个数字的一步 上方是 758 加 467 的输入序列:起始 0、a 倒序 8 5 7、九个 0、b 倒序 7 6 4、结尾 0,以及已经写出的个位 5。下方四张卡片依次是词嵌入、注意力、MLP 和输出头。注意力从卡片连回序列里的 a 的十位 5 和 b 的十位 6,各给 0.45 的权重。输出头给出最高分的数字 2,写回序列末尾的空格,再进行下一步。 输入序列(预测十位的这一步) 一步预测:四个部件 0 8 5 7 0 … 0 ×9 7 6 4 0 … 0 5 ? a = 758 倒序 b = 467 倒序 已写出 ← 这一步要写的 词嵌入 "5" 查表 [999.98, −5] 注意力 5、6 各 0.45 8、7 各 0.05 MLP 满十没有? 满了,减掉 98.6 输出头 0–9 各打一分 最高:2 四个部件的结果都加在同一个向量上(残差流),不是一个替换一个。 写出的 2 接到序列末尾,整条序列再算一遍,得到百位;11 步后答案写完。
图 1 · Transformer 每一步只做一件事:看完前面的序列,给下一个数字打分;写下最高分的那个,再看一遍

词嵌入(embedding)。神经网络只会算数,不认识符号,所以第一步是查表:每个 token 对应一个固定长度的向量,比如 “5” 对应 [999.98, −5]。向量的长度叫模型宽度 dd,大模型里是几千,这个榜上常见的是 2 到 7。

注意力(attention)。这是 Transformer 的标志部件。每个位置都可以“往回看”序列里所有更早的位置,决定从谁那里取多少信息。具体做法是:当前位置算一个查询向量 Q,每个更早的位置各算一个键向量 K 和一个值向量 V;Q 和每个 K 做点积,得到一个“相关程度”的分数,经过 softmax 变成加起来等于 1 的权重,再用这组权重把各位置的 V 加权平均。图里,预测十位时,模型把 0.45 的权重分给第一个加数的十位 5,0.45 分给第二个加数的十位 6,各 0.05 分给两个个位 8 和 7。对加法来说,注意力就是“把同一位上的两个数字找出来”的那只手。

前馈网络(MLP)。注意力只负责搬运和混合信息,真正的非线性计算交给每个位置各自的一个小网络:先乘一个矩阵升维,过一个激活函数(ReLU、SiLU 之类),再乘一个矩阵降回来。在加法里,判断“这一位满没满十”就靠它。

输出头。最后把向量和词表里 10 个数字各自的向量做点积,得到 10 个分数,分数最高的就是预测结果。很多模型让输出头和词嵌入共用同一张表,这叫权重绑定(weight tying),后面会反复出现。

还有两个不显眼但必需的零件。一是残差连接:每个部件的输出不是替换原来的向量,而是加到它上面,所以可以把整个过程看成一条“残差流”,各部件往上面累加东西。二是归一化(LayerNorm 或 RMSNorm),把向量的大小拉回一个固定范围,防止数值越算越大。

位置从哪里来

上面的注意力有一个漏洞:Q 和 K 的点积只看两个向量的内容,不看它们在序列里的位置。把序列打乱,每个位置得到的结果完全一样。可加法偏偏最在乎位置,“5” 在个位还是在十位,意思差了十倍。

所以 Transformer 还要加位置编码。2017 年的原始论文用一组固定的正弦波,加到每个位置的词嵌入上2;现在的大模型(包括这个榜上几乎所有小模型)多用 RoPE(旋转位置编码):把 Q 和 K 按所在的位置转一个角度,位置 pp 转 pωp\omega。两个向量都转过以后再做点积,结果只取决于它们相隔多少个位置,而不是各自在哪里3。记住“RoPE 是一个按位置转动的钟”,第五节会用到。

参数是什么

图里每个部件都离不开几张矩阵:词嵌入是一张 10×dd 的表,Q、K、V 各是一个矩阵,MLP 有两三个矩阵,归一化还有一组缩放系数。这些矩阵里的每一个数都是参数;训练,就是用梯度下降一点点调这些数,让预测越来越准。

以训练榜上一个 122 参数的模型为例(宽度 3,注意力头的维度 4,MLP 中间层 3),参数是这样分的4:

122 参数模型的参数都花在哪(d=3,注意力头维度 4,MLP 中间层 3)
部件形状参数
词嵌入(与输出头共用)10 × 330
注意力 Q、K、V3 × 4,各一个36
注意力输出投影 O4 × 312
Q、K 各自的归一化4 + 48
三处归一化(注意力前、MLP 前、输出前)3 + 3 + 39
MLP(SwiGLU:门、升维、降维)3 × 3,三个27
合计122

RoPE 是固定的旋转角度,不算参数。

表 1 · 一个最小的单层 Transformer,参数主要花在词嵌入和注意力的几个投影矩阵上

参数量是衡量模型大小最直接的尺子。GPT-2 最小的版本有 1.24 亿个参数,这个榜上的模型比它小一百万倍。那么,用这把尺子量,能做十位数加法的 Transformer 最小可以多小? 这就是 AdderBoard 要回答的问题。

二、AdderBoard 的规则

起点:两个编程智能体的比赛

排行榜的维护者是 Dimitris Papailiopoulos。2026 年 2 月,他把同一段提示词分别交给 Claude Code 和 Codex:训练一个尽可能小的 Transformer,十位数加法的准确率至少 99%,不许把答案编码进输入,不许调用计算器,输出必须自回归地生成5。

Claude Code 从 79.5 万个参数开始一路往下扫,发现宽度 12(4176 个参数)完全学不会,宽度 16(6080 个参数)就能 100% 正确,最后交出一个两层、宽度 16 的模型。Codex 第一轮交出的是 36.6 万个参数;维护者把“最小化参数”的要求加重再跑一次,Codex 想出了一个办法:把同一位上的两个数字合成一个 token(第 3 位上的 3 和 7 记作 P37),词表从十几个涨到 114 个,序列短了一半,最后只用 1644 个参数。维护者的评价是,这样做没有违规,但为了“参数少”这个写明的目标,牺牲了“通用”这个没写明的目标,“审美上有点不舒服”5。

文章发出去以后,很多人开始接力往下压,于是有了这个排行榜。

两张榜

榜分成两类1:

  • 训练榜:参数由通用的训练算法从数据里学出来,梯度下降、进化搜索都可以,但算法本身不能是为加法专门设计的。
  • 手写榜:参数是人用纸笔推出来、直接填进去的。它不证明模型学得会,只证明这个结构表达得了加法,算是一种构造性证明。

规则在防什么

规则里写得最细的,是“什么才算 Transformer 在做加法”:

  1. 必须至少有一层自注意力。
  2. 必须自回归:一位一位地生成,每一位生成后接回输入。进位必须从这个过程里“长出来”,不能在 Python 里用一个变量把进位传给下一步。
  3. forward() 必须是标准的“张量进、分数出”,里面不能有按数位循环、存进位这类专门针对加法的控制流。
  4. 生成循环必须是通用的:换一套权重、换一个任务,同样的解码代码照样能用。

一句话:算加法的必须是模型,不能是代码。在此之外,输入怎么写(倒序、加分隔符、补零)可以自选,只要格式固定、不泄露答案;位置编码如果是固定的(正弦、RoPE),不算参数;绑定在一起的权重只算一次。

验证方法也是固定的:verify.py 先跑 10 个边界用例(全 9 加全 9、一串连续进位之类),再跑 10000 道随机题,随机种子固定为 2025,准确率不低于 99% 才算合格。

题目定好了,可是在动手之前,还有一个更基础的问题:一个只会“看前面、猜下一个”的模型,做加法难在哪里?

三、加法对 Transformer 难在哪

三件事

列竖式做加法,我们其实在做三件事1:

  1. 对齐:把两个数同一位上的数字找出来。
  2. 逐位相加:算这一位的和,以及满不满十。
  3. 传递进位:把低一位的进位带到高一位。

前两件 Transformer 都有现成的部件:对齐靠注意力,逐位相加靠 MLP。麻烦在第三件。进位可能一路传下去:9999999999 + 1,个位的进位要连着翻过十位,一直传到最高位。而模型是从左往右一个一个写答案的。

现象:写答案的方向和进位的方向

按我们平常的习惯,和的最高位写在最前面。看 758 + 467 = 1225:

758 + 467 的两种书写顺序 左边按习惯高位在前,答案 1225 从千位写起,而进位从个位一路向左传到千位,与写字方向相反。右边把数字倒过来写,答案写成 5221,个位先写,进位从左向右传,与写字方向相同。 高位在前:先写 1 a b 和 进位 写的顺序 千 0 0 1 ① 百 7 4 2 ② 十 5 6 2 ③ 个 8 7 5 ④ 写第 ① 位之前,要先把三次进位都算完。 进位从右往左走,写字从左往右走。 低位在前:先写 5 a b 和 进位 写的顺序 个 8 7 5 ① 十 5 6 2 ② 百 7 4 2 ③ 千 0 0 1 ④ 进位和写字同一个方向。 写十位时,个位的 5 已经写在序列里了。
图 2 · 把数字倒过来写,进位就和模型写字的方向一致:每一位只需要看上一位刚写下的结果

高位在前时,模型写下第一个数字 “1” 之前,要先知道百位有没有进位,而百位有没有进位取决于十位,十位又取决于个位。也就是说,第一步就得在脑子里把整条进位链算完;十位数加法里,这条链最长有十节,全都要压在一次前向计算里完成。

低位在前时,情况反过来:先写个位 5,再写十位。写十位的时候,个位已经写在序列里了,“个位有没有进位”可以从刚写下的 5 里读出来(8 + 7 = 15,写出来的是 5,说明进了一位)。每一步只需要看当前这一对数字和上一位,进位随着自回归一步步往前传,正好借用了第 2 条规则要求的那个过程。

这件事早有人系统地研究过。维护者自己参与的论文《Teaching Arithmetic to Small Transformers》比较了多种数据写法,结论是倒序输出让小模型学得又快又好6。Claude Code 在那次比赛里第一次用不定长的 123+45= 格式时失败了,换成补零到定长、结果倒序以后立刻成功5。

尝试:用同一个模型对比两种写法

光看图还不够有说服力,我们自己训了一下7。模型结构照搬训练榜上那篇论文的代码(单层、RoPE、SwiGLU),宽度 8、688 个参数;数据格式除了数字顺序,其余完全相同:同样的分隔 0,同样补零到十位,同样一次出 128 道随机题。每种写法用 3 个随机种子各训 1 万步,在 CPU 上每次不到两分钟。

同一个模型,只改数字的书写顺序(训练 1 万步,每步 128 道题)
模型写法整题全对每一位单独看
688 参数
d=8,3 个种子
低位在前100%、100%、100%1000 步时就已全对
高位在前0%、0%、0%最高两位约 90%,其余多在 10%–40%
7336 参数
d=32
低位在前100%(1 个种子)1000 步时就已全对
高位在前27%、66%(2 个种子)每位 88%–100%,错误散在各位

模型结构取自 M10S-Transformer(单层,RoPE θ=3,SwiGLU,2 个查询头),学习率 0.01 余弦退火;测试题是 verify.py 用种子 2025 生成的前 2000 道。

表 2 · 低位在前,688 个参数一千步就学会;高位在前,放大十倍也只对一部分

低位在前,三个种子都在第 1000 步(约 11 秒)时就把 2000 道测试题全部做对了。高位在前,三个种子训完 1 万步,一道整题也没对:最高两位能猜对九成左右,往下的数位大多只比瞎猜(10%)好一点。把模型放大十倍到 7336 个参数,高位在前终于能学到每一位都有九成以上的正确率,可十一位要同时对,整题正确率只有 27% 和 66%;同样大小的模型换成低位在前,一千步就全对。

高位在前的小模型卡在哪里,我们没有深究,从“只有最高两位对”来看,它大概连每一位该看哪两个数字都没学会(这是推断)。但结论很清楚:同样的参数、同样的数据量,只是把数字倒过来写,就是学得会和学不会的区别。

榜上的每一个模型,无论训练的还是手写的,都用了低位在前的写法。格式解决以后,剩下的问题就是:模型能压到多小?

四、训练榜:从 6080 到 36

现象:五周,两条阶梯

把两张榜的榜首按时间连起来(日期取自仓库的提交记录),是两条往下走的阶梯:

AdderBoard 两张榜第一名随时间的变化 横轴是 2026 年 2 月 19 日到 3 月 25 日,纵轴是参数个数的对数。训练榜从 6080、1644 降到 311、234、89、62,3 月 25 日到 36;手写榜从 2 月 24 日的 139 降到 36、12、10,3 月 25 日到 6。 两张榜的第一名(参数个数,对数坐标) 10 100 1000 10000 2月19日 2月24日 3月3日 3月25日 6080 · Claude Code 1644 · Codex(成对 token) 311 234 89 62 36 139 36 12 10 6* 训练榜(强调色):变窄 → 低秩 → 绑定 → 圆弧嵌入 手写榜(灰色):开榜两天就压到 36,之后只剩个位数可争 Codex 第一轮交出 366,320 个参数,在图的上方之外。日期为提交被收录的日子;6* 的星号见第六节。
图 3 · 五周里,训练榜从 6080 降到 36,手写榜从 139 降到 6;后半程每省一个参数都更难

训练榜从 Claude Code 的 6080 一路降到 36,手写榜从开榜时的 139 降到 6。两条线有一个共同点:越往后,每一步省下的参数越少,花的力气越大。那么,训练出来的模型是靠什么一步步变小的?

第一步:把模型变窄

最直接的办法是减小宽度 dd。参数量大致随 d2d^2 增长,宽度减半,参数少四分之三。但宽度不能无限减:Claude Code 发现宽度 12 一下子就完全学不会,社区里也有好几个人在 800 个参数附近看到准确率断崖式下降1(断崖的位置和具体结构有关,第三节那个用 RoPE 的 688 参数模型就没有遇到)。早期几支队伍不约而同地停在了宽度 7。

再往下走,需要换一种思路:不是把矩阵变小,而是让矩阵里的数不再各自独立。

第二步:让参数共用

低秩分解。一个 7×7 的矩阵有 49 个数,如果把它写成一个 7×3 矩阵乘一个 3×7 矩阵,只要 42 个;宽度越大省得越多。秩为 3 的分解是宽度 4 到 7 这一档模型的主要手段,311 参数的模型靠它把宽度压到了 41。

权重绑定。前面说过,词嵌入和输出头可以共用一张表。同样的思路可以推到注意力内部:让 V 直接等于 K(tieKV),让输出投影等于 Q 的转置(tieQO),让几处归一化共用一组系数。122 参数的那个模型,就是这样一步步减到 89、86、83 的4:

同一个 122 参数模型,靠共用参数逐步变小
改动省掉了什么参数
基础版只有词嵌入和输出头共用122
MLP 中间层 3 → 2三个矩阵各少一列113
O = Qᵀ(tieQO)输出投影不再独立101
V = K(tieKV)值投影不再独立89
两处块内归一化共用少一组 3 个系数86
三处归一化全部共用再少一组 3 个系数83

数字取自 M10S-Transformer 论文表 1,公式为 95 + 9×(MLP 中间层) − 12×tieKV − 12×tieQO − 3 或 6。

表 3 · 每绑定一处,模型就少一块独立的矩阵;代价是剩下的参数要身兼数职

把整张表换成一个公式。到 36 参数这一档,词嵌入那 30 个数也被拿掉了:10 个数字不再各有一个自由的向量,而是等间距地排在一段圆弧上(第三个坐标恒为 0),整张表只由半径、起始角、间隔角 3 个参数决定。K 也不再是独立的矩阵,而是 Q 旋转一个角度(1 个参数),MLP 的降维矩阵是升维矩阵转置后再转一个角度4。这些都是“结构上的先验”:人知道数字之间是有顺序的、等距的,就直接把这个形状写进模型,不让它自己去学。

第三步:模型越小,越难训练

参数省下来了,代价是训练变得非常难。36 参数这一系列的作者把过程写成了一篇论文,里面有几组数字值得细看4。

顿悟。这些小模型的训练曲线很特别:训练损失很早就降到接近 0,测试准确率却在很长一段时间里停在接近 0,然后某一刻突然跳到接近 100%。这叫 grokking(顿悟)8。122 个参数时,10 个随机种子里有 4 个能在 20 万步内顿悟;减到 89 个参数,从头训练 15 次,一次也没有。

多阶段微调。解决办法是接力:第一阶段从头训练,拿到一个 50%–85% 准确率的模型;第二阶段从它出发,换小一点的学习率、大一点的批量再训;一直接到第四阶段,89 参数的模型才到 100%。从一个只错一题的模型出发再微调,24 次里 24 次都能顿悟。

进位幽灵。四个独立训练出来、各只错一题的 89 参数模型,错的是同一道题,写出的错答案也一模一样:6,028,846,396 + 90,949,567,正确答案 6,119,795,963,四个模型都写成 5,119,795,963。这道题的进位是“有、有、无、有、无、有、无、有、无、无”交替出现的,模型在一个本该截断进位的位置,让一点残余的进位信号漏了过去。把这道题加进训练批次,50 步梯度更新就修好了。作者的结论是:这不是容量不够,而是随机出题时这种进位模式太少见,训练数据没覆盖到。

定向微调。顺着这个思路,作者干脆在评测集上找出模型做错的题,把它们混进训练批次反复训,修一轮、再找、再修。83 参数的模型修了 4 轮、累计约 170 道题才清零;榜上这个 36 参数模型的提交说明里写的是“2 轮,累计 70 道错题”4。这里要注意,用来找错题的评测集(种子 42)和官方验证用的种子 2025 是分开的,所以官方验证仍然是没见过的题;但论文自己也承认,定向微调证明的是这个结构装得下正确答案,不是它能自己学会。

我们用官方脚本复跑了这个 36 参数模型:10010 道题全对,用时 47.7 秒7。

训练榜到这里已经很挤了,每少一个参数都要配一套复杂的训练流程。可是手写榜的榜首只有 6 个参数,比 36 少了六分之五。不靠训练、直接把数填进去,6 个参数是怎么做加法的?

五、手写榜:6 个参数怎么做加法

我们把 6 参数模型的代码逐行读了一遍,又在每一步打印了模型内部的数值9。它其实是 10 参数版本的“记账改版”,计算过程一模一样,下一节再说差在哪里10。整个模型宽度只有 2,一层,一个注意力头。还是用 758 + 467 这道题,看它怎么一位位算出 1225。

输入的写法

输入一共 31 个 token:

位置 0 1 … 10 11 … 19 20 … 29 30
[0] a 倒序(10 位) 9 个 0 b 倒序(10 位) [0]
758 + 467:
0 8 5 7 0 0 0 0 0 0 0 0 … 0 7 6 4 0 0 0 0 0 0 0 0

和训练榜常用的“中间垫两个 0”不同,这里在两个加数之间垫了 9 个 0。于是 a 的第 ii 位在位置 1+i1+i,b 的第 ii 位在位置 20+i20+i,同一位上的两个数字恰好相隔 19 个位置。这个 19 是后面一切的关键。

词嵌入:一个坐标存数字

宽度为 2,每个 token 只有两个坐标。数字 dd 的嵌入是:

e(d)=[ 1000−0.001 d2,  −d ]e(d) = \big[\,1000 - 0.001\,d^2,\ \ -d\,\big]

第二个坐标直接存着这个数字(取负号),第一个坐标几乎是常数 1000,只带一点二次项,最后输出时要用。整张表只用了 2 个参数:1000 和 0.001。

注意力:RoPE 的钟面上,两个加数重合

前面说过,RoPE 把每个位置转一个角度。这个模型把转速设成每 19 个位置转一整圈。于是“相隔 19 个位置”在钟面上就等于“没隔”:

周期 19 的 RoPE 让同一位的两个数字重合 左边是一个 19 格的钟面,刻度表示往回隔了几个位置除以 19 的余数。查询指向 10.3 格;10 格上是 a 的十位和 b 的十位,11 格上是两个个位。右边列出预测十位时被看的位置和权重:b₁=6 和 a₁=5 各 0.454,b₀=7 和 a₀=8 各 0.045,注意力输出 125。 RoPE 的钟面:19 个位置转一圈 预测十位:位置 31 往回看 刻度 = 往回隔了几个位置,满 19 归零 0 5 15 查询 10.3 10:a₁ 与 b₁ 11:a₀ 与 b₀ 被看的位置权重 位置 21b₁ = 6隔 100.454 位置 2a₁ = 5隔 29 = 10 + 190.454 位置 20b₀ = 7隔 110.045 位置 1a₀ = 8隔 30 = 11 + 190.045 其余 28 个< 0.002 注意力输出10×(5+6) + (8+7) = 125 两个加数之间垫 9 个 0,同一位的两个数字 正好隔 19 个位置,在钟面上落在同一格。 查询指向 10.3 而不是 10,上一位的两个数字 恰好分到十分之一的权重,捎上了进位的线索。
图 4 · 隔 10 和隔 29 在周期 19 的钟面上是同一格:注意力一次就把同一位的两个数字都取到了

在预测十位的这一步,序列最后一个 token 是刚写下的个位 5,位置 31。它往回看:b 的十位在位置 21,相隔 10;a 的十位在位置 2,相隔 29,比 10 多一整圈。在钟面上,a₁ 和 b₁ 落在同一个刻度上。查询向量的方向被设在 10.3 这个刻度:离 10 最近,所以 a₁ 和 b₁ 各拿到 0.454 的权重;11 刻度上的 a₀、b₀(也就是上一位的两个数字)离得稍远,各拿到 0.045,恰好是 1/10。其余位置加起来不到 0.002。

值向量取的是嵌入的第二个坐标(数字本身)再乘一个系数,于是注意力的输出是:

注意力输出=10 (ai+bi)+(ai−1+bi−1)\text{注意力输出} = 10\,(a_i + b_i) + (a_{i-1} + b_{i-1})

十位这一步就是 10×(5+6)+(8+7)=12510 \times (5+6) + (8+7) = 125。

这里有两个细节。一是那 9 个 0 的另一个作用:算最高位(第 11 位)时,a、b 都没有这一位了,查询指向的地方正好是补的 0,结果自然是 0。二是“注意力只看相对距离”这一点被用到了极致:每一步的查询都指向“10.3 个位置之前”,同一套参数对 11 个数位通用,不需要为每一位单独设计。

残差:进位自己冒出来

注意力的输出加回残差流以后,要和当前 token 自己的嵌入相加。当前 token 是上一步写下的那一位 si−1s_{i-1},嵌入第二个坐标是 −si−1-s_{i-1}。于是:

h=10 (ai+bi)+(ai−1+bi−1)−si−1h = 10\,(a_i + b_i) + (a_{i-1} + b_{i-1}) - s_{i-1}

而上一位的竖式是 ai−1+bi−1+ci−1=si−1+10 cia_{i-1} + b_{i-1} + c_{i-1} = s_{i-1} + 10\,c_i,其中 cc 是进位。代进去:

h=10 (ai+bi+ci)−ci−1h = 10\,(a_i + b_i + c_i) - c_{i-1}

进位就这样冒了出来:上一位两个数字的和,减去上一位实际写下的数字,差的那 10 就是进位。模型没有任何地方专门存进位,它是从“自己刚写下的那个数字”里读出来的,正是第三节说的低位在前的好处。十位这一步,h=125−5=120h = 125 - 5 = 120,正好是 10×(5+6+1)10 \times (5 + 6 + 1);百位那一步是 121−2=119=10×(7+4+1)−1121 - 2 = 119 = 10 \times (7 + 4 + 1) - 1。末尾那个 −ci−1-c_{i-1} 最多是 −1,对后面的取整没有影响。

MLP:满十就减掉

h/10h/10 现在是这一位的“竖式和”,范围 0 到 19。要写下的数字是它除以 10 的余数,这一步交给 MLP。宽度 2 的 SwiGLU 里有两个门,阈值设得只差一点点,两个门的输出相减就成了一个陡峭的台阶:

6 参数模型里 MLP 的台阶 横轴是进入 MLP 前的 h,等于 10 乘这一位的竖式和,从 0 到 190;纵轴是 MLP 之后的值。h 小于 95 时输出等于输入,h 超过 95 时整条线向下跳约 99。圆点标出竖式和为 0 到 19 时的取值,竖式和不小于 10 的点用强调色。 MLP 前后:h 的第二个坐标 0 30 60 90 MLP 后 0 50 95 150 190 MLP 前 h = 10 × 这一位的竖式和 十位:120 → 21.4 → 写 2 h 过了 95(竖式和 ≥ 10),MLP 减掉约 99, 只留下个位;输出头再取离 h/10 最近的整数。
图 5 · MLP 只做一件事:竖式和满十,就把 h 减掉约 99(相当于竖式和减 10),剩下的就是这一位要写的数字

hh 不到 95 时,MLP 输出几乎是 0;超过 95,它输出大约 −99。十位那一步,120 减掉 98.6,剩 21.4。

输出头:离哪个数字最近

最后,输出头拿 hh 去和 10 个数字的嵌入做点积。还记得嵌入第一个坐标里那个 −0.001 d2-0.001\,d^2 吗?它让每个数字的分数成为 dd 的一个开口向下的抛物线,顶点落在 h/10h/10 附近,所以分数最高的就是离 h/10h/10 最近的那个数字。21.4 离 20 最近,输出 2。

把三步放在一起:

758 + 467:6 参数模型前四步的内部数值
这一步注意力输出减去上一步写下的MLP 之后写下
个位10×(8+7) + 0 = 150150 − 0 = 150150 − 97.8 = 52.25
十位10×(5+6) + 15 = 125125 − 5 = 120120 − 98.6 = 21.42
百位10×(7+4) + 11 = 121121 − 2 = 119119 − 98.6 = 20.42
千位10×(0+0) + 11 = 1111 − 2 = 99(不到 95,不减)1

后面七位全是 0。每一格都是 probe6.py 打印出的实际数值(四舍五入到 0.1)。

表 4 · 竖式里的每一步都能在残差流里找到:同位相加、读出进位、满十减十、取整

破坏实验:每个零件都不能少

为了确认上面的解读不是看图说话,我们动了几处设定,各在 500 道验证题上重新跑7:

改动 6 参数模型的一处设定,500 道验证题的准确率
改动破坏了什么准确率
原样—100%
RoPE 周期 19 → 18a、b 不在同一格0%
RoPE 周期 19 → 20a、b 不在同一格0%
MLP 输出系数设为 0不再满十减十0.4%
换数值精度(2000 道题)
float32—100%
float16128000 超出上限 655040%

改周期时,查询方向和注意力缩放按同样的公式重新算过,只是周期不同。

表 5 · 任何一个零件失灵,整台“加法电路”就停摆

周期改成 18 或 20,a 和 b 就不在同一个刻度上了,一道也做不对;把 MLP 的输出系数设成 0,只剩“恰好一路都不用进位”的那 2 道题对。还有一个顺带的发现:把全部计算换成 float32 照样全对,换成 float16 则全错,因为门的系数 128000 超出了 float16 能表示的最大值 65504。6 个参数里,每一个都是精心挑选的大数或小数。

到这里,6 参数模型是怎么算加法的已经拆完了。可是计算机本来就会做加法,CPU 里的加法器是怎么算的、怎么进位的?我们平时列竖式又是怎么算的?把这几种做法摆在一起看,Transformer 的算法有什么不一样?

六、与普通加法的对比

十进制竖式:查表,加上进位

先看最熟悉的。列竖式时,我们从个位开始,每一位做两件事:从背熟的加法表里查出两个数字的和,再加上右边进过来的 1;结果满十,就只写个位,把 1 记到左边。758 + 467:8 + 7 = 15,写 5 进 1;5 + 6 + 1 = 12,写 2 进 1;7 + 4 + 1 = 12,写 2 进 1;最后写 1。一共进位 3 次。

这里的进位只有 0 和 1 两种可能,方向固定从右往左,图 2 里画的就是这个过程。

二进制:全加器串成一条链

CPU 里的数是二进制存的,每一位只有 0 和 1。十位数最大是 9,999,999,999,要 34 个二进制位才放得下。758 写成二进制是 1011110110,467 是 111010011。

二进制的一位相加只有 8 种情况(a、b、进位各取 0 或 1),所以根本不用查表,用几个逻辑门就能算出来。这个小电路叫全加器:这一位的和是 a、b、进位三者的异或,进位出是“a 和 b 都是 1,或者进位是 1 且 a、b 恰好有一个是 1”11:

si=ai⊕bi⊕ci,ci+1=(ai∧bi)∨(ci∧(ai⊕bi))s_i = a_i \oplus b_i \oplus c_i, \qquad c_{i+1} = (a_i \wedge b_i) \vee \big(c_i \wedge (a_i \oplus b_i)\big)

一个全加器是 5 个门。把 34 个全加器排成一排,每个的进位出接到左边那个的进位入,就是最简单的加法器,叫行波进位加法器(ripple-carry adder):

758 加 467 的二进制行波进位 从左到右是第 10 位到第 0 位。a 是 758 的二进制 1011110110,b 是 467 的二进制 0111010011,和是 1225 的二进制 10011001001。每一位一个全加器,下方的箭头把进位从右边的位传到左边的位;第 1、2、4 到 9 位产生进位,用强调色表示。 758 + 467 在 CPU 里:11 个全加器串成一条链 位 a b 和 进位 0 1 1 0 1 1 1 1 1 1 0 0 1 1 1 1 1 0 2 1 0 0 3 0 0 1 4 1 1 0 5 1 0 0 6 1 1 1 7 1 1 1 8 0 1 0 9 1 0 0 10 0 0 1 每个全加器只接三根线:a、b 和右边传来的进位。和 = a⊕b⊕进位,进位出 = (a∧b)∨(进位∧(a⊕b)),共 5 个门。 同一道题,十进制只进位 3 次;二进制进位 8 次,第 4 到第 9 位连成一串(虚线表示进位为 0)。
图 6 · CPU 里的加法:每一位一个固定的小电路,进位顺着导线从低位流到高位,不用“找”也不用“写下来再读”

和十进制比,二进制的进位要频繁得多:同一道题,十进制进位 3 次,二进制进位 8 次,而且第 4 到第 9 位的进位连成一串。我们在 verify.py 的 10000 道题上数了一下,十进制里最长的连续进位平均 3.0 位,二进制里平均 7.5 位,最长一次有 31 位连着进位7。

进位链太长就慢。行波进位加法器里,最高位要等进位一个一个传过来,34 位就要传 34 级。所以真正的 CPU 用的是超前进位(carry-lookahead):每一位先各自算出两个信号,“这一位自己会不会产生进位”(ai∧bia_i \wedge b_i)和“右边来的进位会不会穿过这一位”(ai⊕bia_i \oplus b_i),再用一棵树形电路把所有位的进位同时算出来。以 64 位加法为例,进位要经过的级数从 64 级降到 log₂64 = 6 级左右11。这就是为什么 CPU 做一次 64 位加法通常只要一个时钟周期。

十进制硬件:BCD 的“加 6”

有些场合(老式计算器、金融系统里的十进制运算)要求按十进制算,常见的做法叫 BCD(二进制编码的十进制):每个十进制位单独用 4 个二进制位存12。4 个二进制位能数到 15,可十进制只用到 9,于是相加时多了一步修正:

758 + 467 用 BCD(每个十进制位占 4 个二进制位)相加
位4 位二进制相加(含进位)大于 9?加 6 修正写下 / 进位
个位1000 + 0111 = 1111(15)是1111 + 0110 = 1 01015 / 1
十位0101 + 0110 + 1 = 1100(12)是1100 + 0110 = 1 00102 / 1
百位0111 + 0100 + 1 = 1100(12)是1100 + 0110 = 1 00102 / 1
千位0000 + 0000 + 1 = 0001(1)否不修正1 / 0

4 位二进制能数到 15,十进制只用到 9;加 6 正好跳过 10–15 这 6 个用不上的编码,在 4 位里等于减 10。

表 6 · BCD 的“加 6”和 6 参数模型 MLP 的“减 99”是同一件事:满十,就从这一位拿走十

8 + 7 用 4 位二进制相加得 1111,也就是 15,不是一个合法的十进制位;规则是大于 9 就再加 6。加 6 等于跳过 10 到 15 这 6 个用不上的编码,在 4 位里就相当于减 10,溢出来的那一位正好就是进位。

这个“满十就减十”,和 6 参数模型里 MLP 做的事一模一样(图 5):h 是竖式和的 10 倍,过了 95 就减掉约 99。也就是说,Transformer 做的其实是十进制加法,它的 MLP 扮演的就是 BCD 里那个“加 6”修正电路。

三种算法放在一起

同一道加法,三种算法
十进制竖式(人)二进制加法器(CPU)6 参数 Transformer
数怎么表示0–9 十个符号0/1 两种电平,十位数要 34 位一个 token 一个数字,嵌入成 [1000−0.001d², −d]
同一位怎么对齐竖式上下对齐导线固定:第 i 位接第 i 位注意力去“找”:周期 19 的钟面让两位重合
一位怎么相加背加法表异或门:a⊕b⊕进位注意力加权求和:10(aᵢ+bᵢ)
怎么知道要进位和 ≥ 10与门、或门:(a∧b)∨(进位∧(a⊕b))上一位两数之和,减去上一步写下的数字,差 10 就是进位
满了怎么办只写个位二进制每位本来只有 0/1,进位直接出去MLP 台阶:h ≥ 95 时减约 99
进位怎么传记在脑子里或写个小 1沿导线传给左边的全加器;快的 CPU 用超前进位并行算出写进序列,下一步从刚写下的数字里重新读出来
代价(我们的测量)—34 位行波加法器 170 个门;Python 里一次 a+b 约 29 纳秒numpy 实现每道题约 2.4 毫秒,慢约 8 万倍

门数按每个全加器 5 个门估算;Python 的 29 纳秒大部分是解释器开销,CPU 的加法指令本身通常一个时钟周期。

表 7 · 三种算法做的是同一件事;不同的是“对齐”和“进位”分别靠什么完成

把这张表横着读,三种算法的“一位怎么相加”“满了怎么办”都很像,真正的差别在两行。

对齐。竖式靠纸上的位置,CPU 靠导线,第 i 位和第 i 位从一开始就接在一起。Transformer 没有导线,序列里所有位置都只是排成一行的 token,它得用注意力在每一步重新“找”到同一位的两个数字,6 参数模型为此专门设计了 9 个 0 和周期 19 的钟面。

进位。CPU 的进位是一根导线上的电平,从低位流到高位,从来不离开电路。Transformer 里没有任何地方能存一个进位:每一步算完,模型只留下一个写出来的数字。下一步要知道进位,只能把上一位的两个数字再看一遍,和自己刚写下的数字比一比,差了 10 就说明进过位。进位不是传过去的,而是每一步重新推出来的。这也可以解释第三节里高位在前的写法为什么学不会:那样写时,进位还没“写下来”,模型就没有地方去读它(这是我们的理解)。

代价。一个 34 位的行波加法器只有 170 个门;而 6 参数模型用 numpy 跑,每道题要 2.4 毫秒,比 Python 里一次普通的 a + b(约 29 纳秒,大部分还是解释器开销)慢约 8 万倍7。这本来就不是用来比速度的:AdderBoard 问的是“Transformer 这种结构能不能表示加法、最少要几个参数”,不是“用 Transformer 做加法划不划算”。

看完和普通加法的对比,再回头数 6 参数模型里的数:钟面的周期 19、查询指向的 10.3、归一化的系数……这些难道不也是模型里的数吗?为什么只算 6 个?

七、参数该怎么数

6 还是 10

这正是 6 参数提交引起的争论13。它的父版本有 10 个参数,6 参数版本做了两处改动:

  • 查询投影的 2 个数固定下来。作者认为这两个数决定的是“指向哪个刻度”,和 RoPE 的周期一样属于位置编码的一部分,按规则固定位置编码不计参数。
  • 最后一层归一化的 2 个系数并进输出头。归一化后再乘系数、再和嵌入表点积,等价于先把系数乘进嵌入表;而嵌入表本来就是由 2 个参数算出来的,系数并进去以后不再单独占参数。

维护者认为第二处在数学上站得住,第一处“更有争议”,因为父版本明明把这两个数当参数算了。最后的处理是收录,但在 6 后面打一个星号,注明“按严格算法是 10 个”。提交者也说,如果争议太大可以撤回,这两个技巧只是他的智能体在研究别的问题时顺手发现的。

这场争论说明,在这个尺度上,“参数个数”已经不只是模型的属性,也取决于记账规则:哪些数算结构常数,哪些数算参数。从 10 到 6,模型算的东西一点没变。

能表示,不等于学得会

另一个容易混淆的地方是两张榜之间的差距。手写的 6(或 10)和训练的 36,并不意味着训练方法落后了五六倍。手写模型里的数是人推出来的:1000、0.001、128000,跨了八个数量级,台阶陡到几乎是一个开关。梯度下降很难从随机起点走到这样一个尖锐的解上,训练榜上的模型都要用平滑得多的方式实现同样的功能。

所以两张榜回答的是两个问题:手写榜问“这个结构能不能表示加法”,训练榜问“通用的训练方法能不能找到它”。手写榜说明,一层、宽度 2 的 Transformer 就足以表示十位数加法;训练榜说明,要让梯度下降自己找到解,目前至少要 36 个参数,外加一整套多阶段、定向的训练流程。

八、回到开头

回到开头那段输出:6 个参数,10010 道题全对。现在可以说清楚它是怎么回事了。

它不是一个“学会了加法”的模型,而是一个用 Transformer 零件搭出来的加法电路:RoPE 的钟面负责把同一位的两个数字对齐,注意力把它们加起来,顺便捎上上一位;残差流里减掉上一步写下的数字,进位自然冒出来;MLP 的台阶负责满十减十;抛物线形的输出头负责取整。每个零件都是标准 Transformer 里本来就有的,只是参数被设成了特定的值。

这个排行榜还有几个值得带走的观察。

格式比容量重要。从第三节的实验可以看到,同一个模型,只是把数字倒过来写,就从学不会变成很快学会。

智能体参与了大部分提交。榜上 52 条记录里,有 27 条在“Built with”一栏填了 Claude Code、Codex、Gemini 之类的工具,排行榜本身也起源于让两个智能体比赛。它们会不会钻规则的空子(成对 token、参数记账)也是这个榜的一部分故事:规则写不到的地方,“最优”会往意想不到的方向走。

这里的“小”有边界。这些模型只会十位数加法,宽度、周期、补零的个数都是为这道题量身定做的;换成十一位数,6 参数模型多半要重新设计补零个数和钟面周期(这是我们的推断,没有实测)。它们回答的是“表示加法最少需要什么”,不是“怎么做一个更好的语言模型”。

小结

  1. Transformer 是什么? 一个预测下一个 token 的模型:词嵌入把符号变成向量,注意力往回看并取信息,MLP 逐位置计算,输出头给每个候选打分,残差流把各部件的结果累加起来;位置编码(如 RoPE)告诉它谁在前谁在后。答案一位一位地自回归生成。
  2. AdderBoard 比什么? 比谁的 Transformer 用最少的参数把十位数加法做到 99% 以上,分训练和手写两张榜,规则要求加法必须由模型算出来,不能由解码代码代劳。
  3. 加法难在哪? 难在进位。高位在前时,第一位就要算完整条进位链;低位在前时,进位可以从上一步写下的数字里读出来。所有上榜模型都用低位在前的写法。
  4. 训练模型怎么变小? 先变窄,再靠低秩分解、权重绑定、用公式代替整张表让参数共用;代价是训练越来越难,要靠顿悟、多阶段微调和定向微调,目前最少 36 个参数。
  5. 6 个参数怎么做加法? 周期 19 的 RoPE 让同一位的两个数字在钟面上重合;注意力算出 10(ai+bi)10(a_i+b_i) 加上一位的和;减去上一步写下的数字得到进位;MLP 满十减十;抛物线输出头取整。
  6. 和普通加法有什么不同? 一位怎么加、满十怎么办都差不多,MLP 的“减十”就是 BCD 的“加 6”;差别在对齐和进位:CPU 靠导线对齐、靠导线传进位,Transformer 要用注意力去找同一位,进位则从自己上一步写下的数字里重新推出来。
  7. 6 个还是 10 个? 取决于把哪些数当作结构常数。手写榜证明“能表示”,训练榜证明“学得会”,两者回答的是不同的问题。

如果想自己看一遍,最省事的是下载 6 参数那份代码9,它只依赖 numpy,配合排行榜的 verify.py 半分钟就能跑完;在 _forward 里打印注意力权重,就能看到上面那张钟面。

参考资料

Footnotes

  1. anadim/AdderBoardDimitris Papailiopoulos · GitHubgithub.com ↗排行榜本体:README 里的两张榜、规则和 verify.py。本文依据 2026-03-26 的最新提交 07061de,此后仓库没有更新。https://github.com/anadim/AdderBoard ↩ ↩2 ↩3 ↩4 ↩5

  2. Attention Is All You NeedAshish Vaswani 等 · NeurIPS 2017 · 2017-06-12arxiv.org ↗Transformer 的原始论文。排行榜“固定位置编码不计参数”的规矩就来自这里的正弦位置编码。https://arxiv.org/abs/1706.03762 ↩

  3. RoFormer: Enhanced Transformer with Rotary Position EmbeddingJianlin Su 等 · arXiv · 2021-04-20arxiv.org ↗RoPE(旋转位置编码),榜上小模型几乎都用它。https://arxiv.org/abs/2104.09864 ↩

  4. tbukic/M10S-TransformerTom Bukic · GitHubgithub.com ↗训练榜前列的 36、41、44… 参数模型都出自这里,paper/main.tex 是附带的论文,讲参数构成、多阶段微调、“进位幽灵”和定向微调。https://github.com/tbukic/M10S-Transformer ↩ ↩2 ↩3 ↩4 ↩5

  5. Addition Under PressureDimitris Papailiopoulos · Substack · 2026-02-19dimitrisp.substack.com ↗排行榜的起点:同一段提示词交给 Claude Code 和 Codex,分别得到 6080 和 1644 个参数的模型。https://dimitrisp.substack.com/p/addition-under-pressure ↩ ↩2 ↩3

  6. Teaching Arithmetic to Small TransformersNayoung Lee, Kartik Sreenivasan, Jason D. Lee, Kangwook Lee, Dimitris Papailiopoulos · ICLR 2024 · 2023-07-07arxiv.org ↗系统比较了加法数据的写法,倒序输出让小模型学得快得多。维护者在博客里说 Claude Code 选倒序正是受这篇影响。https://arxiv.org/abs/2307.03381 ↩

  7. 本文的实验代码与调研笔记/mnt/project-files/adderboard-research/:probe6.py 逐步打印 6 参数模型的内部数值,period.py 做破坏实验,train2.py 是自己训练的对照实验,binary.py 算二进制和 BCD 的进位并测速,输出在 logs/。PyTorch 2.14.1 CPU 版,4 核。 ↩ ↩2 ↩3 ↩4 ↩5

  8. Grokking: Generalization Beyond Overfitting on Small Algorithmic DatasetsAlethea Power 等 · arXiv · 2022-01-06arxiv.org ↗“顿悟”现象:训练损失早就降下来,测试准确率在很久以后才突然上升。凭领域知识整理,发文前需要核对。https://arxiv.org/abs/2201.02177 ↩

  9. zcbtrak:6 参数手写加法器GitHub Gistgist.github.com ↗纯 numpy 实现,本文的机制分析都在这份代码上跑。https://gist.github.com/zcbtrak/b9af065d6395a3ecd72e3b8d2e867ae9 ↩ ↩2

  10. Lokimorty:10 参数手写加法器GitHub Gistgist.github.com ↗6 参数版本的父版本,用 Apple MLX 写成,Linux 上跑不了;数值上和 6 参数版本完全相同。https://gist.github.com/Lokimorty/d54e5c61997e00fb922b6692739a0f6c ↩

  11. Adder (electronics)Wikipediaen.wikipedia.org ↗全加器、行波进位加法器和超前进位加法器的标准介绍。https://en.wikipedia.org/wiki/Adder_(electronics) ↩ ↩2

  12. Binary-coded decimalWikipediaen.wikipedia.org ↗BCD 加法里“大于 9 就加 6”的修正规则。https://en.wikipedia.org/wiki/Binary-coded_decimal ↩

  13. Submission #75: 6-parameter hand-coded adderGitHubgithub.com ↗6 个参数该不该算 6 个的讨论,维护者 2026-03-25 以“6*”收录。https://github.com/anadim/AdderBoard/issues/75 ↩