模型从不看见字符,只看见 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 数切分
strawberry3str / aw / berry
strawberry1 strawberry
Strawberry3Str / aw / berry
STRAWBERRY4ST / RAW / B / ERRY

全大写版本与小写版本不共享任何 token,模型必须靠上层表示重新建立二者的语义等价。这是全大写文本(日志、告警、法律条款标题)表现异常的原因,也是 few-shot 示例必须统一大小写形态的原因——标签一会儿 Yes 一会儿 YES,等于在给模型三组不同的输入。

Token healing

尾随空格、半截单词(代码补全时光标停在 respo 中间)这类 prompt,本质问题是人为钉死了一个模型不会自己选的 token 边界。Token healing 是标准解法:

  1. 从 prompt 尾部回退若干个 token,记下被删掉的字符串前缀
  2. 生成第一步时施加约束解码,只允许字节序列以 为前缀的 token
  3. 之后正常生成

等价于把边界决定权交还模型。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 切分后就自动消解了。