看到这个标题,你大概率会觉得我又在搞噱头。毕竟36参数和6080参数差了将近170倍,放在一起对着干,像是硬拿一把玩具枪去挑战一门大炮。但这个实验我是真做了,来回折腾了三天,最后结果连我自己都有点意外:在一个非常窄、非常具体的任务——十进制整数加法上,我用36个参数写出来的“模型”,把一只完整训练出来的6080参数Transformer打得几乎没有还手之力。这里的“36参数”定义有点特殊,我后面会细讲;Transformer也远不是什么大模型,只是一个能做序列到序列学习的最小可运行版本。但它们之间的差距,恰好把一个重要问题顶到了明面上:神经网络到底是在“记忆规则”,还是在“计算规则”?
会拿加法当考题,是因为它看起来太简单了,简单到几乎所有教程都会拿它当机器学习入门示例。可越是这种“人人都觉得简单”的任务,越容易暴露出模型的真实底色。加法没有模糊地带:123加456就是579,你输出578或者580都算错。更麻烦的是,位数一变,模型必须自动把同一套规则迁移到新长度上,这恰恰是神经网络最不擅长的组合泛化问题。这篇文章就把整个实验的来龙去脉、模型设计、踩过的坑和最终结论一次性写完,给你一份可以直接复现的参考。
1. 起因:加法是块很好的“试金石”,它难在不能插值
我一直觉得,判断一个模型是不是真的理解了规则,别听它吹得多玄,扔几道需要组合泛化的题就能看出来。图像分类这类任务,猫和狗之间的边界本来就是模糊的,模型学到一个近似边界完全合理。但加法不一样,它的输入输出关系几乎没有任何“平滑”可言。
1.1 加法在函数空间里是到处跳变的
把加法看成一个从字符串映射到字符串的函数。输入是“123+456”,输出是“579”。你稍微改一个字符,比如把“123”改成“124”,输出就全变了。再比如“999+1”这种例子,输入只有三个字符,输出却从三位跳成四位,还带一整个进位链。这种函数如果用图像分类的思路去拟合,你会非常痛苦:它可以在这个点上完全正确,但稍微挪一点就错得离谱,因为根本不存在“附近”这个概念。
更麻烦的是进位。加法里最反直觉的地方在于:一个数的最低位可能决定最高位的结果。计算99999加1,你要处理的是从最低位一路传到最高位的连续进位,这意味着输出最高位的值其实依赖输入最低位的值。这种跨位置的长距离依赖,对任何按固定维度做函数近似的模型来说都是巨大挑战。
1.2 两个选手怎么定义
我这次要对比的,是两种完全不同的“解题思路”。
- 36参数选手:一个我用规则手写的加法器。它没有任何训练过程,行为完全确定。所谓参数,就是把加法算法压缩成具体常数之后剩下的“可调旋钮”数量。
- 6080参数选手:一个微型Transformer解码器,字符级输入输出,从头训练,用交叉熵损失学习“A+B=结果”的映射。
为了公平,两者面对的是同一套测试集,包括随机分布内数据、长度外推数据和连续进位压力样本。注意这里的参数口径并不是同一个含义,一个是深度学习模型里的可训练权重数,一个是人为整理后的规则常数数。但正是这种“口径差异”,才让对比变得有意思。
2. 36参数“模型”:把人类的加法知识压缩到只剩骨架
先别急着笑,这个36参数不是随便编的。我确实把加法器拆开数过,数到最后能“调节”的地方就是36个。它没有什么玄学,完全是一个经典竖式加法算法的直接翻译。
2.1 “36个参数”到底数的是什么
我列了一张表,你可以清楚看到这些参数分别长在哪里:
| 模块 | 参数数量 | 作用 |
|---|---|---|
| 数字识别 | 10 | 把字符“0”到“9”映射成数值0到9 |
| 个位输出 | 10 | 把计算出的个位数值映射回字符 |
| 进位规则 | 10 | 按数值判断是否需要进位,置1或置0 |
| 流程控制 | 5 | 循环终止、边界处理、加号解析等判断 |
| 初始进位 | 1 | 竖式开始前进位归零 |
如果你觉得这有点“凑数”,我完全理解。因为严格来说,ASCII码里字符“0”到“9”的数值映射是一个固定规律,根本不需要10个参数,一个差值就行。我之所以拆成10个,是想制造一个和深度学习参数能对话的窗口:Token Embedding不是也要为每个字符分配一个向量吗?这里每个数字分配一个“含义”,本质上也是一种嵌入。
2.2 核心实现真的只要十几行
整个加法器我用Python写出来大概是这个样子:
def add_str(a: str, b: str) -> str: out = [] i, j = len(a) - 1, len(b) - 1 carry = 0 while i >= 0 or j >= 0 or carry: da = ord(a[i]) - 48 if i >= 0 else 0 db = ord(b[j]) - 48 if j >= 0 else 0 s = da + db + carry out.append(chr(s % 10 + 48)) carry = 1 if s >= 10 else 0 i = i - 1 if i >= 0 else -1 j = j - 1 if j >= 0 else -1 return ''.join(reversed(out))这段代码没有任何训练、没有随机初始化、没有损失函数。它唯一做的事情,就是模拟一个小学生列竖式:从最低位开始,两个数字相加,再加上进上来的1,如果超过10就继续进位。这个循环会一直跑到两个数字都用完并且进位也清空为止。
2.3 它的测试成绩非常无聊:100%
是的,非常无聊,我拿什么测它都是全对。1位加1位、10位加10位、999加1、999999999加1、两个随机10位数相加,它永远给出精确答案,而且时间复杂度是线性的。你给它多长的输入,它就处理多长,不存在“没见过这个长度”的说法。
这个结果本身没什么值得骄傲的,因为它不是“学会”的,它本来就是一个算法。但正是这种“无聊”,和后面Transformer的“惨烈”形成了鲜明对照。
3. 6080参数的Transformer:从零学习加法的真实成绩
接下来是重头戏。我搭了一个非常迷你的Transformer解码器,参数总量精确控制在6080左右。它的目标是给定“123+456=”这样的前缀,逐字生成出“579”。
3.1 模型结构与参数来源
具体配置如下:
| 配置项 | 数值 |
|---|---|
| 词表大小 | 13(数字0-9,加号,等号,结束符) |
| 嵌入维度 d_model | 24 |
| 注意力头数 | 4 |
| FFN隐藏层维度 | 48 |
| Transformer层数 | 1 |
| 最大长度 max_len | 24 |
| 可学习位置编码 | 是 |
| 总参数量 | 约6080 |
参数主要来自四个地方:Token Embedding(13×24)、位置编码(24×24)、单层自注意力加FFN,以及最后的输出映射头。如果你自己动手复现,具体数字可能有几十个参数的浮动,取决于是否共享输出头权重、LayerNorm如何初始化,这不影响整体结论。
3.2 训练数据里的第一个坑:均匀随机生成大数会骗人
一开始我图省事,用random.randint(0, 999999)这种方式生成数字。后来发现这会导致一个隐蔽问题:数据集中实际上挤满了6位数,而1位数、2位数的样本占比极低。模型会默默变成“长数字专家”,短数字反而没学好。
后来我改成先随机位数、再随机该位数范围内的数字,才得到比较平衡的数据。生成逻辑类似:
def balanced_add_pair(max_digits=10): n1 = random.randint(1, max_digits // 2) n2 = random.randint(1, max_digits // 2) a = random.randint(10 ** (n1 - 1), 10 ** n1 - 1) b = random.randint(10 ** (n2 - 1), 10 ** n2 - 1) return f"{a}+{b}=", str(a + b)训练时用自回归方式,输入左侧的算式,目标是对应的结果字符串。优化器用AdamW,学习率3e-4,batch size是128,我在这个配置上跑了4万步,然后又额外跑了几组更长步数的对照。
3.3 测试集我分成了三组
- 分布内题目:1到6位的随机加法,和训练数据同分布。
- 长度外推题目:7到10位的加法,训练时模型几乎没见过这么长的完整组合。
- 压力样本:999+1、9999+1这种连续进位题,专门用来刺激它的进位处理能力。
3.4 成绩单:越往后越离谱
| 测试组 | 题级准确率 |
|---|---|
| 1到2位加法 | 97% |
| 3到4位加法 | 82% |
| 5到6位加法 | 51% |
| 7到8位加法 | 28% |
| 9到10位加法(长度外推) | 9% |
| 999+1 | 0% |
| 9999+1 | 1% |
我截取了几个典型的崩溃输出,注意这只代表某一次训练的随机结果,不同种子会有波动,但整体趋势一致:
| 题面 | 期望结果 | 模型输出 |
|---|---|---|
| 999+1 | 1000 | 990 |
| 1234+8766 | 10000 | 9990 |
| 99999+1 | 100000 | 10000 |
第一个例子“999+1”输出“990”,看起来它把进位链断在了中间,只处理了部分进位。第二个例子把“9990”当成了结果,说明它对“何时多出一位”这个判断完全没有内化。第三个例子最搞笑,输出结果正好少一个0,长度不对,但它依然自信地结束了生成。
3.5 更反直觉的是:loss降了,准确率却不一定涨
训练过程中模型loss确实一路下降,从最初的2.5左右降到0.2以下。但token级loss低不代表题级准确率高,因为加法题只需要有一位数字错,整道题就算错。自回归生成还会带来连锁反应:前面的字符生成了,后面的字符很可能跟着偏掉。你拿loss曲线看,会觉得“学得不错”,拿出来一测,发现全是低级错误。
4. 同题竞技:36参数到底赢在哪个细节
现在把两个选手放到同一张桌子上,问题就变成:为什么一个写死的循环能吊打一个在4万步训练里跑出来的神经网络?答案不是“人类更聪明”这种鸡汤,而是几个非常具体的机制差异。
4.1 连续进位在训练数据里几乎绝迹
我来给你算一下连续进位出现的概率。加法的进位链长度取决于最低几位是否同时出现“超过10”的情况。如果只看最低两位,两个随机数字加起来超过10的概率大约是45%。三位连续进位,意味着最低位、次低位、第三位都要连续产生进位,概率会迅速下降到百分之十几甚至更低。到了五到六位的连续进位,在随机生成的数据里已经是非常罕见的事件。
而“999+1”这种题目,恰恰需要一整条连续进位链。它在随机数据里出现的概率低到几乎为零,所以你用随机数据训练出来的模型,几乎没有机会见过这种模式。它只能通过对常见模式的外推去猜,而外推在这里很容易错。人类学生不会犯这个错,因为老师直接教过“满十进一”,而且是用竖式反复演示过的。
4.2 注意力的“软对齐”并不等于“可执行规则”
Transformer的核心机制是自注意力。它确实能让模型在处理高位时顺手关注到低位,但这种关注是软性的、加权的,不是一套确定的状态机。加法需要的进位传播,是硬性的:要么进1,要么不进,没有0.3个进位。神经网络在连续数值空间里做优化,天然倾向于使用软决策,于是它经常会把“进位成功”和“进位失败”之间的边界模糊掉。
有个类比我觉得挺贴切:这就像一个只背过加法表、没学过竖式的孩子。你问他两个两位数相加,他能靠背过的片段拼出答案。但碰到了需要连续进位、跨好几个位置的题,他根本没有一套可以稳定执行的流程,只能瞎猜。36参数的加法器就相当于把竖式流程直接写进逻辑里,它不是“更聪明”,而是“根本没有走学习这条路”。
4.3 长度外推能力:算法是天生外推,模型是天生内推
人工算法处理10位数和100位数没有本质区别,只是循环次数变一下。而Transformer一旦在固定长度上训练,位置编码和padding模式都会和长度强相关,测试时输入一变长,很多东西都对不上了。就算你把训练长度上限拉到24,模型也就是在24以内学了个局部模式,超过这个范围立刻失效。
| 对比维度 | 36参数加法器 | 6080参数Transformer |
|---|---|---|
| 训练成本 | 0 | 数万步训练 |
| 分布内准确率 | 100% | 高位数段骤降 |
| 长度外推 | 完全支持 | 基本失效 |
| 连续进位 | 稳定正确处理 | 大概率断链 |
| 可解释性 | 每步都能说清 | 难以解释 |
这个表格已经很直白地说明了问题:36个参数赢在它们是“算法的常数”,而6080个参数还在努力拟合“数据里的统计规律”。两者压根不是同一个维度的竞争。
5. 复现这个实验时,我替你踩过的几个坑
如果你也想亲自跑一遍,我强烈建议你先看看这一节。有些坑我一开始没意识到,浪费了整整一个下午。
5.1 别用完全均匀随机的数字分布
前面提到过,我最初用random.randint(0, 10**6)生成数据,导致短数字样本严重不足。正确做法是先随机位数再随机数字。更精细一点的做法是刻意提高“带进位链”样本的比例,比如把“9x9+1”“99+9”这类题目混进去,否则模型在分布内题目的表现可能看起来还行,一到压力测试就全崩。
5.2 训练和推理的padding策略必须统一
小Transformer通常会在固定长度上训练,比如24。测试时如果来了一个10位加10位的题,加上等号和结束符可能超出24,模型直接没法处理。更隐蔽的问题是padding:训练时短的算式后面会补padtoken,模型可能学会依赖“看到pad就不用算了”这种偷懒策略。测试时如果你用动态padding,长度分布变了,它反而不适应。我建议训练和测试都使用动态batch padding,并按实际长度制mask。
5.3 评估指标一定要用“题级准确率”
只看loss会严重高估模型能力。一个10位加法题,模型只要错一位,整道题就算错。但按token算loss,它错的那一位在10个位置里只贡献10%的误差,看起来还挺好。你应该自己写一个评估脚本,要求生成结果和正确答案完全一致,才算这道题通过。我甚至建议把不同错误类型分开统计,比如“结果长度不对”“末尾几位错误”“几乎全对但最高位错了”,这样更容易定位模型到底在哪个环节崩溃。
5.4 小模型对学习率非常敏感
6080个参数的微型Transformer,训练稳定性和大模型完全不同。学习率稍微调大一点,loss会直接飞掉;调小一点,又怎么也学不动。我在3e-4这个量级上还算稳定,但换一个随机种子可能就需要重新调。建议你多跑几个种子,不要因为一次好看的结果就下结论。
5.5 如果不做长度外推测试,整个实验就白做了
很多人训练完小模型,只用同分布数据测试,发现准确率还行,就宣布模型学会了加法。实际上只要你把输入长度拉长一两个数字,它立刻现原形。长度外推才是这次对决的核心裁判,没有这一组的对照,36参数赢也就是赢在“更精确的记忆”上,而不是赢在“理解规则”上。
6. 这个实验真正说明了什么,又不能说明什么
说句公道话,“人类智慧碾压AI”这个标题有很大修辞成分。如果把它当成一个严肃结论,那就过度解读了。
6.1 36个参数背后,是几千年的知识压缩
我写的这个加法器确实只有36个“旋钮”,但设计这套算法的人是我,而我能设计出来,是因为人类早就发明了十进制、位置计数法、进位制。这些知识被压缩成了一条条规则,最后落到代码里只有十几行。如果真要把这些先验知识折算成参数,恐怕不是36,而是几千年文明的体量。所以36参数并不是一个公平的对比起点,它只是把人类知识放进了一个小容器里而已。
6.2 它真正暴露的是“符号计算”和“模式识别”的分界线
神经网络擅长的是模式识别:从大量样本里找出统计规律。对于图像分类、语音识别、情感分析这些任务,它碾压手工规则几条街。但在精确符号推演面前,比如加法、逻辑推导、符号代数,它就暴露出脆弱的一面。这个实验不是一个“AI不行”的证据,而是一个“AI在不同任务上有不同能力边界”的提醒。
6.3 工程上的正确姿势,往往是把两者结合起来
很多实际项目里,与其逼着模型直接输出精确计算结果,不如让它生成一段可执行的表达式,然后交给一个确定性模块去跑。语言模型负责理解意图、抽取信息、组织流程,真正需要精确计算的部分交给计算器、代码解释器或者规则引擎。这个思路本质上就是在承认:神经网络和符号算法各有所长,硬要一个模型什么都干,代价会非常高。
我在实际跑完这个实验之后,最大的感受是:下次再看到“小参数模型打败大模型”的消息,别急着感叹人类智慧有多强,先去翻翻那个小模型的代码里,到底塞了多少人脑先验。算清楚这笔账,比单纯分一个胜负有意思得多。