小学一年级学的"数位对齐",原来是大模型至今算不明白数的根本原因
问一个最强的大模型:9.11 和 9.9 哪个大? 它有相当的概率告诉你:9.11 更大。
这不是它"笨"。写得出万行代码、解得了竞赛题的模型,栽在一道一年级的题上, 说明问题不在智力,在它根本没有"看到"这两个数。
它看到的是被切碎的几个文字块。而小学一年级教的第一件事——竖式要对齐数位—— 恰恰是它这辈子都没学会的东西。
这篇文章不讲大道理,全部用能跑起来的 PyTorch/Python 代码, 把"数位对齐"这件小事,一路接到大模型的分词器(tokenizer)。 顺手回答四个灵魂拷问:
- 模型明明会解微积分,为什么会说 9.11 > 9.9?
- "分词器"到底把一个数字切成了什么?
- 为什么同一个数字,换个上下文就被切得不一样?
- 既然按位切这么好,为什么没人这么干?
0. 一句话主线
如果只能留一句话:
人类算数的地基是"位置"——个位对个位、十位对十位; 而大模型看数字的地基是"频率"——哪几个字符经常一起出现就粘成一块。 这两套地基根本对不上。
你在草稿纸上写竖式的时候,做的第一个动作是对齐:
9.90
+ 0.01
------
个位压个位,小数点压小数点。位置本身就是意义。
大模型拿到 "9.11" 这个字符串时,第一步不是对齐,是切块。切完可能是 ["9", ".", "11"]。
于是"11"这个块,和 9.9 里的"9"块被摆在一起比较——11 > 9,所以 9.11 更大。
记住这个画面:竖式靠位置,分词靠频率。下面所有东西都挂在这个错位上。
1. 竖式的灵魂:位置就是权重
先把小学那套东西写成代码,看看它到底依赖什么。
# 竖式加法:唯一的规则就是"从右往左,逐位对齐"
def vertical_add(a: str, b: str) -> str:
a, b = a[::-1], b[::-1] # 翻转,让个位排在最前
n, carry, out = max(len(a), len(b)), 0, []
for i in range(n): # 第 i 位 = 权重 10^i,位置即意义
da = int(a[i]) if i < len(a) else 0
db = int(b[i]) if i < len(b) else 0
s = da + db + carry
out.append(str(s % 10))
carry = s // 10 # 进位:位置之间唯一的通道
if carry:
out.append(str(carry))
return "".join(out[::-1])
print(vertical_add("968", "77")) # 1045
注意这段代码里最关键的一行是 a[::-1]——翻转,是为了让"第几位"这个信息可靠。
整个算法只依赖一件事:我知道每个数字排在第几位。 一旦这个信息丢了,进位就无从谈起,加法就彻底散架。
小学老师用红笔圈你"数位没对齐"的时候,圈的不是格式,是算法的地基。
2. 分词器不是按位切的,是按"常见块"切的
大模型读不到字符,它读到的是 token(词元)。把文本变成 token 的东西叫分词器, 主流做法是 BPE:在海量语料里统计,哪两个相邻片段经常一起出现,就把它们合并成一块。
关键在于,它统计的是语料频率,不是数学结构。"20" "19" "100" 这些块在语料里出现得极其频繁 (年份、价格、百分比),于是它们被固化成了单个 token;而 "437" 这种就未必。
下面用一个玩具 BPE 把这个过程演一遍——不联网、不下载模型,纯 Python:
# 一个极简 BPE:只保留"语料里高频"的数字块作为一个 token
FREQUENT = {"20", "19", "11", "100", "000", "12", "10"} # 假装这些块在语料里超高频
def toy_tokenize(s: str):
tokens, i = [], 0
while i < len(s):
for L in (3, 2): # 贪心:优先匹配长块
if s[i:i+L] in FREQUENT:
tokens.append(s[i:i+L]); i += L; break
else:
tokens.append(s[i]); i += 1 # 匹配不上就单字符成块
return tokens
for num in ["9.11", "9.9", "1234", "2019", "100000"]:
print(f"{num:>8} -> {toy_tokenize(num)}")
输出:
9.11 -> ['9', '.', '11']
9.9 -> ['9', '.', '9']
1234 -> ['12', '3', '4']
2019 -> ['20', '19']
100000 -> ['100', '000']
四行输出,四个灾难:
9.11的小数部分是一块 "11",9.9的小数部分是一块 "9"——模型比的是这两块谁"大";1234被切成12 / 3 / 4,"1" 是千位"这个事实完全消失;2019只有两块,100000也只有两块——块数和数值大小毫无关系;- 同一个数字字符
1,在不同位置属于不同的块,没有任何一个 token 稳定地代表"千位"。
真实的 GPT-4 / Llama 分词器就是这个逻辑的加强版(Llama 3 之后不少模型改成了固定三位一组, 缓解但没根治)。如果你本机装了
tiktoken,可以跑tiktoken.get_encoding("cl100k_base").encode("9.11")亲眼看一遍。
🤔 疑惑点一:既然切错了,模型为什么大部分时候还能算对?
因为它不是在"算",是在"回忆"——常见算式在语料里出现过千百万次,它记住了答案;一旦超出记忆范围,错误立刻暴露。
这是最反直觉的一点。2+2=4、12×12=144 在互联网上出现过无数次,模型把它们当作语言事实背了下来,
和背"北京是中国的首都"没有本质区别。但只要你把数字调大、调冷门,记忆就失效了:
# 一个模型如果真的"会算",位数不该影响正确率;如果是"背"的,位数一大就崩
easy = ("12", "12") # 语料里出现过无数次
hard = ("48371", "29684") # 几乎不可能被完整背过
print(vertical_add(*easy), vertical_add(*hard)) # 144 78055 —— 竖式对两者一视同仁
竖式算法对 12+12 和 48371+29684 难度完全相同(都是逐位加),
但大模型对这两者的正确率天差地别。这个落差本身就是证据:
它走的根本不是竖式那条路。
3. 致命伤:同一个数字,换个上下文就被切成不同的块
上一节还只是"切得不巧"。真正致命的是不一致。
竖式算法能成立,前提是"个位永远是个位"。但在分词器眼里,一个数字的切法取决于它周围有什么:
for ctx in ["1234", "$1234", "1234元", "01234"]:
print(f"{ctx:>8} -> {toy_tokenize(ctx)}")
1234 -> ['12', '3', '4']
$1234 -> ['$', '12', '3', '4']
1234元 -> ['12', '3', '4', '元']
01234 -> ['0', '12', '3', '4']
前面多一个 0,整个切分就整体错位了。这意味着:
模型必须在"12/3/4"这种块结构上,重新学会一套加法。 而且每一种切法都要单独学一遍。
人类学会竖式,是学会了一条规则,然后对所有数字通用。 模型学会加法,是在成千上万种切分组合上分别拟合。前者是算法,后者是插值—— 所以它必然在没见过的组合上出错。
4. 动手:把"按位对齐"喂给模型,它立刻学会加法
空口无凭。下面训练两个一模一样的小模型学三位数加法,唯一的区别是输入表示:
- A(块表示):把整个数当成一个 id,模拟"数被粘成块";
- B(按位对齐):拆成个位/十位/百位三个通道,模拟"竖式对齐"。
import torch, torch.nn as nn
torch.manual_seed(0)
N = 4000
a = torch.randint(0, 1000, (N,)); b = torch.randint(0, 1000, (N,))
y = (a + b).float().unsqueeze(1) / 2000.0 # 归一化的目标
# A:块表示 —— 整个数一个"块",位置信息不可见
XA = torch.stack([a, b], dim=1).float() / 1000.0
# B:按位对齐 —— 个/十/百各占一个通道(这就是竖式做的事)
def digits(x):
return torch.stack([x % 10, (x // 10) % 10, (x // 100) % 10], dim=1).float() / 9.0
XB = torch.cat([digits(a), digits(b)], dim=1)
def train(X, steps=800):
net = nn.Sequential(nn.Linear(X.shape[1], 64), nn.ReLU(), nn.Linear(64, 1))
opt = torch.optim.Adam(net.parameters(), lr=1e-2)
for _ in range(steps):
loss = ((net(X) - y) ** 2).mean()
opt.zero_grad(); loss.backward(); opt.step()
err = (net(X) - y).abs().mean().item() * 2000 # 还原成"平均差多少"
return err
print(f"A 块表示 平均误差: {train(XA):.2f}")
print(f"B 按位对齐 平均误差: {train(XB):.2f}")
跑完你会看到 B 的误差显著小于 A,而且 B 收敛得快得多。 同样的网络、同样的数据、同样的步数,只是换了一种"怎么看这个数"的方式。
这就是全部问题所在:大模型拿到的是 A,而小学老师教你的是 B。
🤔 疑惑点二:既然按位切这么好,为什么没人这么干?
因为 token 是要花钱的。按位切会让所有数字变长好几倍,上下文窗口被数字吃掉,训练和推理成本全线上升——这是一笔"精度 vs 成本"的买卖。
假设一个模型的上下文是 128K token。如果数字全部按位切:
text = "2024年第3季度营收为1284376元,同比增长17.4%"
print("按块切:", len(toy_tokenize(text)))
print("按位切:", sum(3 if c.isdigit() else 1 for c in text) // 3 + sum(c.isdigit() for c in text))
一份财报里数字占比可能超过三成,全部按位切等于凭空吃掉一大块窗口, 而 99% 的场景里模型只是"读到"数字、不需要真算。
于是主流做法是折中:
| 模型 | 数字切法 | 代价 |
|---|---|---|
| GPT-2 | 完全按 BPE 频率,1~3 位不等 | 算术极差 |
| GPT-3.5 / 4 | 频率合并,常见块固化 | 中等,长数字翻车 |
| Llama 3 / Qwen | 强制三位一组 | 好很多,但组内仍是块 |
| 少数专用模型 | 逐位 + 反向书写 | 算术强,token 开销大 |
没有一个主流模型采用真正的"按位对齐"。 所以 9.11 这个坑,短期内不会消失。
5. 真正的解法,其实也是小学老师教你的:把竖式写出来
有意思的是,这个问题最有效的补丁,也来自小学课堂——别心算,把过程写下来。
模型没法在一次前向传播里做完对齐和进位,但它可以把中间结果当成文本吐出来,再读回去:
9.11 vs 9.9
补齐位数:9.11 vs 9.90
比较小数第一位:1 < 9
结论:9.9 更大
一旦逼它写出"补齐位数"这一步,位置信息就从被切碎的 token 里,重新回到了显式的文本上。 分词器毁掉的对齐,被草稿纸救了回来。
这就是思维链(CoT)真正在干的事——它不是让模型"更用心地想",是给它一张草稿纸。 (这条线我在《老师逼你"写出过程",原来是大模型学会推理的全部秘密》里单独展开。)
仓库里的动画脚本把整个错位过程画了出来:
python tokenizer_number_visualization.py
画面左边是竖式:数字规规矩矩对齐,进位一格一格传上去; 右边是分词器:同一个数字被切成大小不一的块,块的边界随上下文乱跳。 最后一幅图把"位数 vs 正确率"画成曲线——块表示那条线,在四位数之后直接跳水。
缝合:把所有画面接起来
| 算术概念 | 小学怎么讲 | 这篇文章怎么看 | 在大模型里是什么 |
|---|---|---|---|
| 数位 | 个位、十位、百位 | 位置即权重(第 1 节) | 分词器里根本不存在的概念 |
| 对齐 | 竖式要对齐 | 算法的地基(第 1 节) | 被 BPE 的频率合并打碎(第 2 节) |
| 进位 | 满十进一 | 位置之间唯一的通道 | 跨 token 传递,极易断(第 3 节) |
| 打草稿 | 别心算,写过程 | 把中间结果外置(第 5 节) | 思维链 CoT |
| 背口诀 | 九九乘法表 | 记忆而非计算(疑惑点一) | 模型算对小数的真实原因 |
三句话总结:
人类算数靠位置,大模型看数字靠频率——竖式的地基在分词那一步就被抽掉了(第 1、2 节); 同一个数字换个上下文就被切成不同的块,所以它学的不是算法而是插值(第 3、4 节); 唯一有效的补丁是把竖式写出来,让位置信息重新回到文本上——这就是思维链的机制(第 5 节)。
所以下次看到大模型说 9.11 比 9.9 大,别急着嘲笑它。 它只是从来没上过那节"把数位对齐"的课。 而你上过——你在草稿纸上画的那条横线,是它花了几千亿参数都还没学会的东西。
备注(选题/标题): 本篇走"人人做过的具体动作 → 大模型机制"钩子。 备选标题: 1.《9.11 比 9.9 大?一道一年级的题,问倒了最强的大模型》 2.《为什么大模型算不对数?答案在你小学画的那条横线里》