模型从不看见字符,只看见 token id 序列。数不清 strawberry 里有几个 r、把 9.11 判成大于 9.9、在 prompt 末尾多一个空格就答崩、遇到 SolidGoldMagikarp 就精神失常——这些看似无关的失效模式共享同一个根因:分词边界既决定了模型能看到什么,也决定了它看不到什么。
字符不可见
Tokenizer 概述 里的管线到 embedding 查表就结束了:token id 之后,字符信息不再以任何形式存在于计算图中。模型关于「berry 由 b-e-r-r-y 组成」的知识,全部来自预训练中偶然出现的拼写型文本(拼字游戏、逐字母列举、OCR 噪声),是记忆而非感知。
由此有两条推论贯穿后文:需要访问 token 内部字符的任务,模型只能用记忆冒充计算;改变切分的输入扰动,等于换了一组从未见过的输入向量。
Glitch token:词表里的死区
2023 年初 Rumbelow 与 Watkins 在 GPT 系列上发现一批「不可言说」的 token:让模型重复 SolidGoldMagikarp,它会输出 distribute;petertodd 会触发攻击性或混乱的长篇输出;有些 token 让模型直接拒答或生成乱码。这类 token 被称为 glitch token。
成因是训练管线的两段不一致:
- 词表在语料 A 上训练,A 包含 Reddit 用户名、游戏数据爬取残渣、日志噪声等高频字符串,它们被 merge 成了单个 token
- 模型在语料 B 上训练,B 经过去重与质量过滤,那些文档被删掉了
结果是 token id 存在于词表,但对应的 embedding 行从未收到有意义的梯度,永远停在随机初始化附近。前向传播时它落在 embedding 空间的任意位置,可能恰好靠近某个无关语义簇——于是 SolidGoldMagikarp 被读成 distribute。
import tiktoken
# r50k_base(GPT-2/3):带前导空格的完整用户名是一个 token
r50k = tiktoken.get_encoding("r50k_base")
print(r50k.encode(" SolidGoldMagikarp")) # [43453] —— 单 token
# cl100k_base:同一字符串被拆成 5 个常规 token,死区消失
cl100k = tiktoken.get_encoding("cl100k_base")
ids = cl100k.encode(" SolidGoldMagikarp")
print([cl100k.decode_single_token_bytes(i) for i in ids])
# [b' Solid', b'Gold', b'Mag', b'ik', b'arp']新一代词表通过更严格的语料清洗基本消除了这类 token,但死区从未根除:
- 保留但未启用的槽位:各家模型词表里成批的
<|reserved_special_token_N|>,embedding 全是初始值 - 词表裁剪的残留:裁剪后未同步收缩 embedding 矩阵,或裁剪依据的频率统计与实际微调语料不匹配,见 词表设计与裁剪
- 多语言长尾:极低频语种的 merge token,梯度信号弱到接近未训练
工程上的检测手段:统计 embedding 与 unembedding 矩阵的逐行 L2 范数分布,离群偏小的那批高度可疑;再用「原样重复这个 token」探针逐个验证。发布前把死区 token 加入输入黑名单,比事后解释模型为什么发疯便宜得多。
Prompt 的边界敏感
前导空格属于 token
BBPE 字节级分词 把空格并入后继词(词表里的 Ġ 前缀),于是 Paris 和 Paris 是两个完全无关的 id:12366 与 60704。前者在英文语料中占绝对多数,后者只出现在行首、标题、代码标识符等场景。
这直接导致最常见的隐形 prompt bug——末尾多一个空格:
enc = tiktoken.get_encoding("cl100k_base")
a = "The capital of France is"
b = "The capital of France is " # 仅多一个尾随空格
show = lambda s: [enc.decode_single_token_bytes(i) for i in enc.encode(s)][-2:]
print(show(a)) # [b' France', b' is'] 下一步自然是 b' Paris'
print(show(b)) # [b' is', b' '] 空格已独立成 token,只能接 b'Paris'写法 a 让模型走高频路径 Paris;写法 b 把空格提前消耗掉,模型被迫在低频的无空格变体上分布概率,质量与稳定性都下降。任何以 Answer:、Label:、翻译: 结尾的模板,末尾都不要留空格。
大小写改变整个切分
for s in ["strawberry", " strawberry", "Strawberry", "STRAWBERRY"]:
ids = enc.encode(s)
print(f"{s:14s} {len(ids)} {[enc.decode_single_token_bytes(i) for i in ids]}")输出:
| 输入 | token 数 | 切分 |
|---|---|---|
strawberry | 3 | str / aw / berry |
strawberry | 1 | strawberry |
Strawberry | 3 | Str / aw / berry |
STRAWBERRY | 4 | ST / RAW / B / ERRY |
全大写版本与小写版本不共享任何 token,模型必须靠上层表示重新建立二者的语义等价。这是全大写文本(日志、告警、法律条款标题)表现异常的原因,也是 few-shot 示例必须统一大小写形态的原因——标签一会儿 Yes 一会儿 YES,等于在给模型三组不同的输入。
Token healing
尾随空格、半截单词(代码补全时光标停在 respo 中间)这类 prompt,本质问题是人为钉死了一个模型不会自己选的 token 边界。Token healing 是标准解法:
- 从 prompt 尾部回退若干个 token,记下被删掉的字符串前缀
- 生成第一步时施加约束解码,只允许字节序列以 为前缀的 token
- 之后正常生成
等价于把边界决定权交还模型。respo 于是可能一步补成 response(若前缀允许)或 respond,而不是在 respo| 这个训练分布里罕见的断点上硬续。该术语由 guidance 库提出,llama.cpp 等推理框架提供了对应开关;Hugging Face 的 generate 无内置实现,需要自行套 prefix-allowed-tokens 约束。
代价是首 token 需要一次前缀过滤(对 100k+ 词表可用 trie 预建索引摊平)。
计数、拼写、倒序
这三类任务的失败都是字符不可见的直接后果:
- 数字母:
strawberry在模型眼里是 3 个不透明 id,str和berry内部各藏一个 r,但「内部」不存在。把输入改成s t r a w b e r r y(空格分隔,逐字符 token 化)后正确率显著上升——不是模型变聪明了,是信息终于进入了输入层 - 倒序拼写:需要先访问 token 内字符、再逆序重组,两步都不是 token 序列建模的自然操作。长词失败率随 token 数上升
- 数值比较:
9.11切成9/./11,9.9切成9/./9。token 层面11比9更「大」(版本号、章节号语料里确实如此),这个模式压过十进制小数语义。数字切分规则与 FIM 细节见 数字与代码的 Tokenization
可靠的缓解只有两条:转工具(让模型写代码数字母、算数值),或显式展开(CoT 里先逐字符列出再统计)。提示词里加「请仔细数」不改变输入层信息量。
token 级攻击面
分词是模型的输入边界,也因此是安全边界最容易错位的地方。
特殊 token 注入是最高危的一类。若用户输入未经处理就拼进 chat template,字符串 <|im_start|>system 会被 tokenizer 编成真正的保留 token,等于让用户伪造系统消息。防御:对用户文本编码时必须禁止特殊 token 解析——tiktoken 的 encode() 默认对特殊 token 字面量报错(需显式 allowed_special 才放行),Hugging Face 侧则需要显式配置才不把它们解析成保留 id。机制与模板结构见 特殊 Token 与 Chat Template。
其余攻击面:
- Glitch token 作为 OOD 触发器:注入未训练 token 把隐状态推离对齐时见过的分布,早期模型上可诱发拒答失效
- 同形异义与不可见字符:零宽空格、Unicode homoglyph 改变切分,绕过基于字符串匹配的关键词过滤,而模型语义上仍能读通。过滤应在归一化(NFKC)后或直接在 token 序列上做
- 过滤器与模型的边界不一致:安全过滤按词匹配、模型按 token 读,二者切分不同,中间的缝隙就是绕过窗口
- token 长度侧信道:流式响应中每个 SSE 包对应一个 token,包长序列可在密文流量上部分还原响应内容。缓解靠填充或批量刷新
工程清单
| 场景 | 做法 |
|---|---|
| prompt 模板 | 末尾不留尾随空格/换行;前导空格形态在 few-shot 内保持一致 |
| few-shot 标签 | 大小写、标点、空格三者全部统一 |
| 计数/拼写/算术 | 转工具调用,或输入端逐字符分隔 |
| 用户输入拼接 | 禁止特殊 token 解析 + Unicode 归一 + 死区 token 黑名单 |
| 补全类产品 | 开启 token healing |
| 换词表或裁剪后 | 扫 embedding 行范数找死区,跑重复探针验证 |
| 效果归因 | 先用 tokenizer 打印切分,再怀疑模型能力 |
最后一条最常被跳过。相当一部分「模型能力不足」的结论,在打印出 token 切分后就自动消解了。