Token 分割攻击:把敏感词拆成多个 token,让模型不认识但人能拼出来

有一次我在做模型安全评估,把一个请求里的 bomb 换成 b o m b——字母一个没变,只是中间多了几个空格。我本来以为输出不会有任何变化,毕竟任何人一眼都能把字母拼回去。

AI technology illustration

但模型开始回答了。它没有拒绝。

我盯着屏幕愣了几秒。最让我困惑的不是“模型被骗了”,而是:为什么几个空格,就能让同样的字符串在模型眼里变成完全不同的东西?人在这件事上几乎不可能被干扰,模型却好像“看不见”本该组合起来的字母。

这个困惑一直到我重新捋了一遍 tokenizer 的工作原理才消失。这篇文章就把想通的过程写出来。

两个字符总是一起出现,干脆合体成一个 token

模型不能直接吃字符串,它只能处理数字。tokenizer 负责把文本切成小块,每块对应一个数字 ID,这个 ID 就是 token。现代大模型大多用字节级 BPE 分词,一种源自 2016 年 《Neural Machine Translation of Rare Words with Subword Units》 的算法。

BPE 的思路很朴素:统计语料里哪些字符或子词经常挨在一起,然后把高频组合逐步合并成一个 token。训练结束后,一个词在词表里出现得越多,就越可能是一个独立的 token。

举一个教科书写烂了的例子:语料里只有 low、lower、lowest 三个词,初始词表是每个独立字符。统计相邻字符对,“lo”出现了三次,最多,于是合并成 lo;再统计,“low”又出现了三次,合并。循环几轮以后,low 成了一个整体。

low  → l o w
lower → low e r
lowest → low e s t

放到真实模型里也一样。英文高频词 kill,在 GPT-4 使用的 cl100k_base 分词器里就是 1 个 token。这件事不需要我替你说,OpenAI 官方的 tokenizer 页面可以当场验证。

同一个请求,在模型眼里是两份完全不同的清单

现在关键字来了。同样是 kill,如果在中间加空格变成 k i l l,分词器会怎么切?

kill     → [kill]       1 个 token
k i l l → [k] [i] [l] [l]  7 个 token(4 个字母 + 3 个空格)

四个字母加三个空格,一共七个 token。模型第一层看到的不是“一个动词”,而是四个孤立的字母 token,外加三个表示空格的 token。

你可能觉得没关系,它可以使用注意力机制把字母拼回去。这恰恰是问题所在。

注意力机制确实可以让每个 token“看到”其他 token,但看到不等于理解。模型融合信息的方式是训练出来的,预训练语料里 k i l l 这种怪异写法的出现概率极低,模型几乎没机会学到“把这几个 token 合并成 kill”的映射。而那些常见的、被训练过无数次的写法,才是安全对齐认得的东西。

安全模块为什么没被触发?

现代大语言模型的安全对齐,基本靠 RLHF,即基于人类反馈的强化学习。人类标注者把大量“危险请求 → 拒绝回答”的样本喂给模型,模型学到的其实是一个统计模式:当输入的 token 分布和训练时的危险请求相似,就输出拒绝。

但当你把 bomb 换成 b o m b,token 分布完全脱离了安全训练见过的样子。安全对齐没有被激活,因为模型根本不认为这个词出现在句子里。

这个现象在论文 《Jailbroken: How Does LLM Safety Training Fail?》 里被归纳为失配泛化(mismatched generalization):安全训练提高了模型在训练分布附近的防护能力,但对分布外的输入,防护可能完全失效。

为什么你能拼出来,模型拼不出来?

人类读 k i l l 时,大脑会自动把字母重组成 kill,而且这个过程你根本关不掉——这是视觉语言系统长期演化的结果。

模型没有这套机制。它的输入是一串离散的 token 编号,不存在“字母拼成单词”的显式重建过程。你可以把 token 想成一个个孤岛:它们之间有注意力桥梁,但桥往哪个方向修、修多宽,完全取决于训练数据里这种 pattern 出现过多少次。

所以“人能拼出来、模型不认识”不是巧合,而是两套信息处理系统的结构性差异。

不想用空格?武器库比你想的大

空格只是最简单的手段。安全研究者整理出了一整套拼写层面的扰动方式。最隐蔽的是零宽空格(U+200B)——你看到的是一个完全正常的完整单词,但字母与字母之间藏了不可见字符,分词器会把它切成完全不同的 token 序列。

对比一下这些变体:

攻击变体 示例 你看到的 模型看到的
空格分割 b o m b b-o-m-b,轻松拼出 四个字母 token + 三个空格 token
零宽空格 b[零宽]o[零宽]m[零宽]b bomb,完全无缝 字母之间夹着不可见字符 token
同形字符 bомb(西里尔 о) bomb,几乎看不出区别 编码完全不同的西里尔字母 token
Leet 替换 b0mb bomb,轻松读出 数字 0 和字母混合成的新 token 组合
大小写交替 bOmB bomb,稍微别扭但能读 非常规大小写的陌生 token 序列

这些变体在人类读者眼里几乎没有障碍,但在 token 空间里,每一个都意味着安全训练没有覆盖过的分布。拆词攻击的有效性也因此高度依赖具体模型:BPE 合并规则不同,同一个写法在 GPT-3.5、Claude、LLaMA 上产生的 token 序列完全不同。

从手工拆词到自动化攻击

手工拆词是按人的直觉在试。更系统化的攻击方式是 2023 年 Zou 等人提出的 GCG 攻击(《Universal and Transferable Adversarial Attacks on Aligned Language Models》):直接在目标模型上计算输入 token 的梯度,反复替换和搜索,最终找出一串像乱码的后缀,把它拼到任何有害请求末尾,都能让模型输出不安全内容。

GCG 和拆词攻击的底层逻辑是同一个:在 token 空间制造安全训练没见过的输入。区别在于,拆词靠人类直觉发现,GCG 靠梯度自动搜索,能找到人类完全想不到的奇怪 token 组合。

防御其实很难做

常见的防御思路无非这几条:

  • 输入归一化:把连续空格合并、去掉零宽字符。问题是,攻击者永远能找到一条你没想到的新规则。
  • 困惑度过滤:正常文本在语言模型下的概率高,攻击性文本概率低。问题是,攻击者可以反过来优化,让对抗输入的概率表现得很正常。
  • 对抗训练:把已发现的变体写进安全训练数据。这是目前最有效的办法,但成本极高,而且只能覆盖已知变体。

说实话,我最早觉得输入归一化就能根治。把多个空格合并、把零宽字符合成普通字符,规则写清楚不就行了?后来发现太天真。每当一条归一化规则发布,攻击者就拿到了一条明确的对立面——他只需要找到这条规则覆盖不到的形式。归一化本质上不是在消灭攻击面,而是在给攻击面重新画一条边界。

现实是,今天你在 GPT-4o 里输入 b o m b,大概率会被拒绝,OpenAI 早就把它补进了训练集。但这只是把攻击面往后推了一步,并没有取消这个攻击面。你补一个空格变体,别人就试两个空格;你补两个空格,别人就试零宽字符。安全训练永远在打追逐战。

跟这个漏洞纠缠得越久,我越觉得它暴露的是 token 化范式本身的结构性短板:文本被切成离散符号的那一瞬间,拼写层面的信息就丢了。模型无论多大,都是在离散符号之上做统计推断。安全对齐能覆盖的分布是有限的,而输入变体是无限的。

所以这根本不是“模型不够聪明”的问题,而是当前这套架构在安全问题上的一种结构性脆弱。只要 tokenizer 还在,这个矛盾就一直在。你能做的只是不断加宽防御的边界,但永远无法证明边界之外已经没有攻击者。

原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/455.html

(0)
上一篇 4天前
下一篇 4天前

相关推荐