GitHub 上有一个叫 AdderBoard 的排行榜,比的是谁能做出最小的 Transformer,让它把两个十位数加对1。榜首那一份提交只有两百来行 numpy 代码,我们把它下载下来,用排行榜自带的验证脚本跑了一遍:
$ python verify.py submission_6p_handcoded.pyParameters (unique): 6Architecture: 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,第三、五节再讲)。
词嵌入(embedding)。神经网络只会算数,不认识符号,所以第一步是查表:每个 token 对应一个固定长度的向量,比如 “5” 对应 [999.98, −5]。向量的长度叫模型宽度 ,大模型里是几千,这个榜上常见的是 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 按所在的位置转一个角度,位置 转 。两个向量都转过以后再做点积,结果只取决于它们相隔多少个位置,而不是各自在哪里3。记住“RoPE 是一个按位置转动的钟”,第五节会用到。
参数是什么
图里每个部件都离不开几张矩阵:词嵌入是一张 10× 的表,Q、K、V 各是一个矩阵,MLP 有两三个矩阵,归一化还有一组缩放系数。这些矩阵里的每一个数都是参数;训练,就是用梯度下降一点点调这些数,让预测越来越准。
以训练榜上一个 122 参数的模型为例(宽度 3,注意力头的维度 4,MLP 中间层 3),参数是这样分的4:
| 部件 | 形状 | 参数 |
|---|---|---|
| 词嵌入(与输出头共用) | 10 × 3 | 30 |
| 注意力 Q、K、V | 3 × 4,各一个 | 36 |
| 注意力输出投影 O | 4 × 3 | 12 |
| Q、K 各自的归一化 | 4 + 4 | 8 |
| 三处归一化(注意力前、MLP 前、输出前) | 3 + 3 + 3 | 9 |
| MLP(SwiGLU:门、升维、降维) | 3 × 3,三个 | 27 |
| 合计 | 122 |
RoPE 是固定的旋转角度,不算参数。
参数量是衡量模型大小最直接的尺子。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 在做加法”:
- 必须至少有一层自注意力。
- 必须自回归:一位一位地生成,每一位生成后接回输入。进位必须从这个过程里“长出来”,不能在 Python 里用一个变量把进位传给下一步。
forward()必须是标准的“张量进、分数出”,里面不能有按数位循环、存进位这类专门针对加法的控制流。- 生成循环必须是通用的:换一套权重、换一个任务,同样的解码代码照样能用。
一句话:算加法的必须是模型,不能是代码。在此之外,输入怎么写(倒序、加分隔符、补零)可以自选,只要格式固定、不泄露答案;位置编码如果是固定的(正弦、RoPE),不算参数;绑定在一起的权重只算一次。
验证方法也是固定的:verify.py 先跑 10 个边界用例(全 9 加全 9、一串连续进位之类),再跑 10000 道随机题,随机种子固定为 2025,准确率不低于 99% 才算合格。
题目定好了,可是在动手之前,还有一个更基础的问题:一个只会“看前面、猜下一个”的模型,做加法难在哪里?
三、加法对 Transformer 难在哪
三件事
列竖式做加法,我们其实在做三件事1:
- 对齐:把两个数同一位上的数字找出来。
- 逐位相加:算这一位的和,以及满不满十。
- 传递进位:把低一位的进位带到高一位。
前两件 Transformer 都有现成的部件:对齐靠注意力,逐位相加靠 MLP。麻烦在第三件。进位可能一路传下去:9999999999 + 1,个位的进位要连着翻过十位,一直传到最高位。而模型是从左往右一个一个写答案的。
现象:写答案的方向和进位的方向
按我们平常的习惯,和的最高位写在最前面。看 758 + 467 = 1225:
高位在前时,模型写下第一个数字 “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 上每次不到两分钟。
| 模型 | 写法 | 整题全对 | 每一位单独看 |
|---|---|---|---|
| 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 道。
低位在前,三个种子都在第 1000 步(约 11 秒)时就把 2000 道测试题全部做对了。高位在前,三个种子训完 1 万步,一道整题也没对:最高两位能猜对九成左右,往下的数位大多只比瞎猜(10%)好一点。把模型放大十倍到 7336 个参数,高位在前终于能学到每一位都有九成以上的正确率,可十一位要同时对,整题正确率只有 27% 和 66%;同样大小的模型换成低位在前,一千步就全对。
高位在前的小模型卡在哪里,我们没有深究,从“只有最高两位对”来看,它大概连每一位该看哪两个数字都没学会(这是推断)。但结论很清楚:同样的参数、同样的数据量,只是把数字倒过来写,就是学得会和学不会的区别。
榜上的每一个模型,无论训练的还是手写的,都用了低位在前的写法。格式解决以后,剩下的问题就是:模型能压到多小?
四、训练榜:从 6080 到 36
现象:五周,两条阶梯
把两张榜的榜首按时间连起来(日期取自仓库的提交记录),是两条往下走的阶梯:
训练榜从 Claude Code 的 6080 一路降到 36,手写榜从开榜时的 139 降到 6。两条线有一个共同点:越往后,每一步省下的参数越少,花的力气越大。那么,训练出来的模型是靠什么一步步变小的?
第一步:把模型变窄
最直接的办法是减小宽度 。参数量大致随 增长,宽度减半,参数少四分之三。但宽度不能无限减: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 |
| 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。
把整张表换成一个公式。到 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 的第 位在位置 ,b 的第 位在位置 ,同一位上的两个数字恰好相隔 19 个位置。这个 19 是后面一切的关键。
词嵌入:一个坐标存数字
宽度为 2,每个 token 只有两个坐标。数字 的嵌入是:
第二个坐标直接存着这个数字(取负号),第一个坐标几乎是常数 1000,只带一点二次项,最后输出时要用。整张表只用了 2 个参数:1000 和 0.001。
注意力:RoPE 的钟面上,两个加数重合
前面说过,RoPE 把每个位置转一个角度。这个模型把转速设成每 19 个位置转一整圈。于是“相隔 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。
值向量取的是嵌入的第二个坐标(数字本身)再乘一个系数,于是注意力的输出是:
十位这一步就是 。
这里有两个细节。一是那 9 个 0 的另一个作用:算最高位(第 11 位)时,a、b 都没有这一位了,查询指向的地方正好是补的 0,结果自然是 0。二是“注意力只看相对距离”这一点被用到了极致:每一步的查询都指向“10.3 个位置之前”,同一套参数对 11 个数位通用,不需要为每一位单独设计。
残差:进位自己冒出来
注意力的输出加回残差流以后,要和当前 token 自己的嵌入相加。当前 token 是上一步写下的那一位 ,嵌入第二个坐标是 。于是:
而上一位的竖式是 ,其中 是进位。代进去:
进位就这样冒了出来:上一位两个数字的和,减去上一位实际写下的数字,差的那 10 就是进位。模型没有任何地方专门存进位,它是从“自己刚写下的那个数字”里读出来的,正是第三节说的低位在前的好处。十位这一步,,正好是 ;百位那一步是 。末尾那个 最多是 −1,对后面的取整没有影响。
MLP:满十就减掉
现在是这一位的“竖式和”,范围 0 到 19。要写下的数字是它除以 10 的余数,这一步交给 MLP。宽度 2 的 SwiGLU 里有两个门,阈值设得只差一点点,两个门的输出相减就成了一个陡峭的台阶:
不到 95 时,MLP 输出几乎是 0;超过 95,它输出大约 −99。十位那一步,120 减掉 98.6,剩 21.4。
输出头:离哪个数字最近
最后,输出头拿 去和 10 个数字的嵌入做点积。还记得嵌入第一个坐标里那个 吗?它让每个数字的分数成为 的一个开口向下的抛物线,顶点落在 附近,所以分数最高的就是离 最近的那个数字。21.4 离 20 最近,输出 2。
把三步放在一起:
| 这一步 | 注意力输出 | 减去上一步写下的 | MLP 之后 | 写下 |
|---|---|---|---|---|
| 个位 | 10×(8+7) + 0 = 150 | 150 − 0 = 150 | 150 − 97.8 = 52.2 | 5 |
| 十位 | 10×(5+6) + 15 = 125 | 125 − 5 = 120 | 120 − 98.6 = 21.4 | 2 |
| 百位 | 10×(7+4) + 11 = 121 | 121 − 2 = 119 | 119 − 98.6 = 20.4 | 2 |
| 千位 | 10×(0+0) + 11 = 11 | 11 − 2 = 9 | 9(不到 95,不减) | 1 |
后面七位全是 0。每一格都是 probe6.py 打印出的实际数值(四舍五入到 0.1)。
破坏实验:每个零件都不能少
为了确认上面的解读不是看图说话,我们动了几处设定,各在 500 道验证题上重新跑7:
| 改动 | 破坏了什么 | 准确率 |
|---|---|---|
| 原样 | — | 100% |
| RoPE 周期 19 → 18 | a、b 不在同一格 | 0% |
| RoPE 周期 19 → 20 | a、b 不在同一格 | 0% |
| MLP 输出系数设为 0 | 不再满十减十 | 0.4% |
| 换数值精度(2000 道题) | ||
| float32 | — | 100% |
| float16 | 128000 超出上限 65504 | 0% |
改周期时,查询方向和注意力缩放按同样的公式重新算过,只是周期不同。
周期改成 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:
一个全加器是 5 个门。把 34 个全加器排成一排,每个的进位出接到左边那个的进位入,就是最简单的加法器,叫行波进位加法器(ripple-carry adder):
和十进制比,二进制的进位要频繁得多:同一道题,十进制进位 3 次,二进制进位 8 次,而且第 4 到第 9 位的进位连成一串。我们在 verify.py 的 10000 道题上数了一下,十进制里最长的连续进位平均 3.0 位,二进制里平均 7.5 位,最长一次有 31 位连着进位7。
进位链太长就慢。行波进位加法器里,最高位要等进位一个一个传过来,34 位就要传 34 级。所以真正的 CPU 用的是超前进位(carry-lookahead):每一位先各自算出两个信号,“这一位自己会不会产生进位”()和“右边来的进位会不会穿过这一位”(),再用一棵树形电路把所有位的进位同时算出来。以 64 位加法为例,进位要经过的级数从 64 级降到 log₂64 = 6 级左右11。这就是为什么 CPU 做一次 64 位加法通常只要一个时钟周期。
十进制硬件:BCD 的“加 6”
有些场合(老式计算器、金融系统里的十进制运算)要求按十进制算,常见的做法叫 BCD(二进制编码的十进制):每个十进制位单独用 4 个二进制位存12。4 个二进制位能数到 15,可十进制只用到 9,于是相加时多了一步修正:
| 位 | 4 位二进制相加(含进位) | 大于 9? | 加 6 修正 | 写下 / 进位 |
|---|---|---|---|---|
| 个位 | 1000 + 0111 = 1111(15) | 是 | 1111 + 0110 = 1 0101 | 5 / 1 |
| 十位 | 0101 + 0110 + 1 = 1100(12) | 是 | 1100 + 0110 = 1 0010 | 2 / 1 |
| 百位 | 0111 + 0100 + 1 = 1100(12) | 是 | 1100 + 0110 = 1 0010 | 2 / 1 |
| 千位 | 0000 + 0000 + 1 = 0001(1) | 否 | 不修正 | 1 / 0 |
4 位二进制能数到 15,十进制只用到 9;加 6 正好跳过 10–15 这 6 个用不上的编码,在 4 位里等于减 10。
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 的加法指令本身通常一个时钟周期。
把这张表横着读,三种算法的“一位怎么相加”“满了怎么办”都很像,真正的差别在两行。
对齐。竖式靠纸上的位置,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 参数模型多半要重新设计补零个数和钟面周期(这是我们的推断,没有实测)。它们回答的是“表示加法最少需要什么”,不是“怎么做一个更好的语言模型”。
小结
- Transformer 是什么? 一个预测下一个 token 的模型:词嵌入把符号变成向量,注意力往回看并取信息,MLP 逐位置计算,输出头给每个候选打分,残差流把各部件的结果累加起来;位置编码(如 RoPE)告诉它谁在前谁在后。答案一位一位地自回归生成。
- AdderBoard 比什么? 比谁的 Transformer 用最少的参数把十位数加法做到 99% 以上,分训练和手写两张榜,规则要求加法必须由模型算出来,不能由解码代码代劳。
- 加法难在哪? 难在进位。高位在前时,第一位就要算完整条进位链;低位在前时,进位可以从上一步写下的数字里读出来。所有上榜模型都用低位在前的写法。
- 训练模型怎么变小? 先变窄,再靠低秩分解、权重绑定、用公式代替整张表让参数共用;代价是训练越来越难,要靠顿悟、多阶段微调和定向微调,目前最少 36 个参数。
- 6 个参数怎么做加法? 周期 19 的 RoPE 让同一位的两个数字在钟面上重合;注意力算出 加上一位的和;减去上一步写下的数字得到进位;MLP 满十减十;抛物线输出头取整。
- 和普通加法有什么不同? 一位怎么加、满十怎么办都差不多,MLP 的“减十”就是 BCD 的“加 6”;差别在对齐和进位:CPU 靠导线对齐、靠导线传进位,Transformer 要用注意力去找同一位,进位则从自己上一步写下的数字里重新推出来。
- 6 个还是 10 个? 取决于把哪些数当作结构常数。手写榜证明“能表示”,训练榜证明“学得会”,两者回答的是不同的问题。
如果想自己看一遍,最省事的是下载 6 参数那份代码9,它只依赖 numpy,配合排行榜的 verify.py 半分钟就能跑完;在 _forward 里打印注意力权重,就能看到上面那张钟面。
参考资料
Footnotes
-
anadim/AdderBoardgithub.com ↗排行榜本体:README 里的两张榜、规则和
verify.py。本文依据 2026-03-26 的最新提交07061de,此后仓库没有更新。https://github.com/anadim/AdderBoard ↩ ↩2 ↩3 ↩4 ↩5 -
Attention Is All You Needarxiv.org ↗Transformer 的原始论文。排行榜“固定位置编码不计参数”的规矩就来自这里的正弦位置编码。https://arxiv.org/abs/1706.03762 ↩
-
RoFormer: Enhanced Transformer with Rotary Position Embeddingarxiv.org ↗RoPE(旋转位置编码),榜上小模型几乎都用它。https://arxiv.org/abs/2104.09864 ↩
-
tbukic/M10S-Transformergithub.com ↗训练榜前列的 36、41、44… 参数模型都出自这里,
paper/main.tex是附带的论文,讲参数构成、多阶段微调、“进位幽灵”和定向微调。https://github.com/tbukic/M10S-Transformer ↩ ↩2 ↩3 ↩4 ↩5 -
Addition Under Pressuredimitrisp.substack.com ↗排行榜的起点:同一段提示词交给 Claude Code 和 Codex,分别得到 6080 和 1644 个参数的模型。https://dimitrisp.substack.com/p/addition-under-pressure ↩ ↩2 ↩3
-
Teaching Arithmetic to Small Transformersarxiv.org ↗系统比较了加法数据的写法,倒序输出让小模型学得快得多。维护者在博客里说 Claude Code 选倒序正是受这篇影响。https://arxiv.org/abs/2307.03381 ↩
-
本文的实验代码与调研笔记
/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 -
Grokking: Generalization Beyond Overfitting on Small Algorithmic Datasetsarxiv.org ↗“顿悟”现象:训练损失早就降下来,测试准确率在很久以后才突然上升。凭领域知识整理,发文前需要核对。https://arxiv.org/abs/2201.02177 ↩
-
zcbtrak:6 参数手写加法器gist.github.com ↗纯 numpy 实现,本文的机制分析都在这份代码上跑。https://gist.github.com/zcbtrak/b9af065d6395a3ecd72e3b8d2e867ae9 ↩ ↩2
-
Lokimorty:10 参数手写加法器gist.github.com ↗6 参数版本的父版本,用 Apple MLX 写成,Linux 上跑不了;数值上和 6 参数版本完全相同。https://gist.github.com/Lokimorty/d54e5c61997e00fb922b6692739a0f6c ↩
-
Adder (electronics)en.wikipedia.org ↗全加器、行波进位加法器和超前进位加法器的标准介绍。https://en.wikipedia.org/wiki/Adder_(electronics) ↩ ↩2
-
Binary-coded decimalen.wikipedia.org ↗BCD 加法里“大于 9 就加 6”的修正规则。https://en.wikipedia.org/wiki/Binary-coded_decimal ↩
-
Submission #75: 6-parameter hand-coded addergithub.com ↗6 个参数该不该算 6 个的讨论,维护者 2026-03-25 以“6*”收录。https://github.com/anadim/AdderBoard/issues/75 ↩