数字与代码是分词器最容易「切坏」的两类文本:数字切分决定模型能否按位对齐做算术,代码的空白处理决定缩进语义是否还在。本文讲清 single-digit / 定长分组 / 科学计数三种数字策略的取舍、空白 run token 与 FIM 三件套的工程细节,以及为什么代码 tokenizer 绝不能吞掉空格。

数字:算术能力的瓶颈在切分,不在参数

竖式加法要求按位对齐。模型看到的不是字符序列而是 token 序列,切分一旦破坏位对齐,模型就得先在注意力里重建对齐关系,再做逐位运算——难度凭空高一档。三代切分策略的差别就在这里。

策略代表1234567890位对齐
贪心不限长 \p{N}+GPT-2 / GPT-3 (r50k_base)123 / 45 / 678 / 90无,切分由语料频率决定
定长分组 \p{N}{1,3}GPT-4 / 4o、Llama 3123 / 456 / 789 / 0L2R 对齐,块边界仍随长度漂移
单位数字Llama 2、Gemma、Qwen 2+10 个 token完美,位置索引即位权

贪心不限长是最坏的一档:3.14159 与 314159 会被切成完全无关的 token 组合,词表里塞满 1998、2020 这类高频年份的整体 token,而 4817 这种低频数字被切碎——同一个「四位数」概念在表征空间里散成上百种形状。

定长分组把不确定性消掉了,但引入新问题:分组从左往右做,123456 + 7890 在 Tiktoken 的 cl100k_base 下切成

'123' '456' ' +' ' ' '789' '0'

456 的末位是个位,789 的末位是十位。两个加数的低位块不在同一位权上,模型必须自己算出错位量。改成从右往左(R2L)分组即 123|456 对 7|890,低位块天然对齐。实测上 R2L 分组在多位加法上的准确率显著高于 L2R,这也是 prompt 里给大数插逗号(123,456)常能救回算术的原因——逗号强制了 R2L 边界。

单位数字是目前开源模型的主流选择:每位一个 token,位权等于倒数位置,模型只需学「第 个 token 对应 」这一条规则。代价是序列长度——一个 20 位数吃掉 20 个 token,长表格与财务数据的上下文开销成倍上涨。实现上,SentencePiece 走 --split_digits=true,Hugging Face tokenizers 走 pre_tokenizers.Digits(individual_digits=True),两者都必须在训练词表之前开启,事后无法补救。

import tiktoken
 
# 定长分组的对齐错位:两个加数的低位块位权不同
enc = tiktoken.get_encoding("cl100k_base")
for s in ["123456", "7890"]:
    ids = enc.encode(s)
    print(s, [enc.decode([i]) for i in ids])
# 123456 ['123', '456']   低位块 456 → 个位
# 7890   ['789', '0']     低位块 0   → 个位,但 789 跨十位到万位

科学计数与数值嵌入

纯符号切分有天花板:token 是离散的,1000001 与 1000002 的 embedding 没有任何「相差 1」的先验。时序预测与表格建模场景因此走另外两条路。

  • 科学计数规范化:把所有数字重写成固定格式 ,量级显式落在指数 token 上,且任意数值的 token 数恒定。代价是精度被截断、需要在输出侧反归一化。
  • 连续数值注入(xVal 一类):数字统一映射到单个占位 token,把真实数值乘进该位置的 embedding 幅度,输出端挂一个回归 head 预测标量。token 数压到 1,数值连续性由 embedding 保证;但模型结构被改了,不能直接套现成 checkpoint。

通用 LLM 仍停留在符号切分——改结构的收益不足以抵消与既有 Tokenizer 概述 管线解耦的成本。

代码:空白必须逐字节保留

Python 的缩进是语法,YAML 的缩进是结构,diff 的行首字符是语义。任何「规范化空白」的预处理在代码上都是破坏性操作。

最常见的踩坑点在 SentencePiece:它默认 remove_extra_whitespaces=True,会把连续空格压成一个。用它训练代码 tokenizer 必须显式关闭,否则模型永远见不到真实缩进。字节级路线(BBPE 字节级分词)在这点上天然安全——所有字节原样进入词表,不存在信息丢失。

空白 run 作为独立 token

保留空白不等于高效编码。逐个空格成 token 的代价极高,所以代码导向的词表会把不同长度的连续空格各占一个 token。p50k_base(Codex 词表)相对 r50k_base 只多 24 个 token,全部是长度 2–25 的空格 run:

import tiktoken
 
code = "def f(x):\n    if x:\n        return 1\n"
for name in ["r50k_base", "p50k_base", "cl100k_base"]:
    enc = tiktoken.get_encoding(name)
    ids = enc.encode(code)
    print(name, len(ids), [enc.decode([i]) for i in ids])
# r50k_base   23  缩进被切成一串单空格
# p50k_base   15  '   ' + ' if',空格 run 合成单 token
# cl100k_base 13  换行并入 '):\n',进一步压缩

23 → 15 是 35% 的压缩,几乎全部来自缩进。这就是为什么通用词表直接拿来做代码模型会白白浪费三分之一上下文。

注意上面输出里的细节:四空格缩进实际编码为 ' ' + ' if'——最后一个空格被后继词吸走了。缩进层级与 token 数不成整数倍关系,靠 token 计数反推缩进深度一定出错;同理,tab 缩进与空格缩进是完全不同的 token 序列,训练语料与推理输入的缩进风格不一致会直接掉性能。cl100k_base 还把 \s*[\r\n] 单独拆成正则分支,让换行与行首空白独立成 token,这也是它在代码上比 r50k_base 省得多的原因之一。

FIM 三件套

IDE 补全时光标前后都有代码,纯自回归的左到右生成用不上后文。Fill-in-the-Middle 的做法是在预训练阶段就把文档随机切成三段并重排,让模型学会条件于 suffix 生成 middle:

PSM:  <fim_prefix> prefix <fim_suffix> suffix <fim_middle> middle <eot>
SPM:  <fim_prefix> <fim_suffix> suffix <fim_middle> prefix middle <eot>

两种排列都要训(通常各半),推理时选哪种由缓存策略决定:SPM 把 prefix 紧贴 middle,用户继续打字时 prefix 增长只追加在末尾,KV cache 可以复用;PSM 则每次都要重算。关键实验结论是 FIM 免费——以约 50% 的比例混入 FIM 变换,不损害模型的左到右能力,所以主流代码模型(Codex、StarCoder、DeepSeek-Coder、Qwen-Coder 系列)全部默认带 FIM。

三个哨兵必须是词表里的原子保留 token,否则会被当普通文本切碎,模型看到的就不是结构信号而是一串尖括号——保留 token 的注册方式见 特殊 Token 与 Chat Template。cl100k_base 的 5 个特殊 token 里就有完整的 <|fim_prefix|> / <|fim_middle|> / <|fim_suffix|>。

仓库级补全还会额外加一层元数据 token:<repo_name>、<file_sep>、<filename> 之类,把多文件上下文按文件边界拼进同一个序列,让模型知道跨文件的引用关系。

两个实践红线:

  • FIM 的切分点在字符级而非 token 级。按 token 边界切,prefix 结尾永远是完整 token,与真实光标位置的分布不符——用户可能停在标识符中间。
  • prefix 末尾的半个标识符会打乱切分。def get_us 的 get_us 不是训练里见过的 token 形状,需要 token healing 回退重编码,细节见 Tokenizer 对推理的影响。

选型速查

场景数字空白
通用对话定长分组,兼顾长度与对齐常规压缩即可
数学 / 算术推理单位数字,或 R2L 分组—
代码补全单位数字(数值字面量不多)必须留 run token + FIM
时序 / 表格数值科学计数或数值嵌入—

这些决策全部发生在训练词表之前:预切分正则、split_digits、空白规范化开关、保留 token 列表,一旦词表落盘就只能重训,没有事后补救的余地。