大语言模型分词技术解析:从BPE到实战应用
1. 从“词”到“Token”:理解大语言模型的第一道门槛
如果你最近开始接触大语言模型,无论是想自己动手微调一个,还是单纯想理解ChatGPT、Claude这些模型到底是怎么“读”懂我们说的话的,那么“Tokenization”(分词/词元化)这个概念,就是你绕不开的第一个、也是最基础的技术环节。它远不止是把句子拆成单词那么简单,而是决定了模型如何看待和理解文本世界的“世界观”。Stanford CS336这门关于大语言模型基础与对齐的课程,把Tokenization作为开篇第一讲,其重要性不言而喻——地基打不牢,后面所有关于模型架构、训练、对齐的讨论都将是空中楼阁。
简单来说,Tokenization就是将一个原始的文本字符串(比如“Hello, world!”)转换成一串数字序列(比如[15496, 11, 995, 0])的过程。这串数字,就是模型真正“吃”进去的东西。你可能会觉得,这不就是像查字典一样把单词映射成编号吗?但现实要复杂和微妙得多。为什么“ChatGPT”可能会被拆成["Chat", "G", "PT"]三个token?为什么同一个中文句子,用不同的分词器会得到完全不同的token序列?这些选择背后,直接影响了模型的词汇量大小、训练效率、对生僻词或新词的处理能力,甚至决定了模型在某些语言上的表现优劣。今天,我们就抛开复杂的数学公式,从工程实践和模型设计的角度,深入聊聊Tokenization的那些核心门道、常见陷阱以及在实际项目中的选型思考。
2. 为什么需要Tokenization?不仅仅是压缩文本
在深入技术细节之前,我们必须先回答一个根本问题:为什么大语言模型不直接处理字符(比如英文字母a-z或Unicode码点)?直观上看,字符序列是最自然的表示,26个英文字母加上标点,词汇表(vocabulary)极小,似乎很简单。但这样做,模型将面临巨大的学习挑战。
2.1 字符级模型的困境:效率与语义的缺失
想象一下,如果模型以字符为单位学习。单词“apple”需要模型从5个连续的字符a,p,p,l,e中自己归纳出这是一个表示“苹果”的语义单元。这要求模型具备非常强大的序列建模能力,去捕捉这种远距离的字符组合模式。更重要的是,这极其低效。模型需要处理的序列长度会变得非常长(一篇文章可能有数万个字符),而Transformer架构的核心注意力机制的计算成本与序列长度的平方成正比。处理一个长文档,字符级表示带来的计算开销将是灾难性的。
此外,字符本身几乎不携带明确的语义信息。字母p单独出现时,你无法推断任何意思。模型必须从海量的字符共现中费力地重新“发明”出单词和词根的概念,这相当于让模型从零开始重建一门语言的词汇体系,是对训练数据和学习能力的巨大浪费。
2.2 词级模型的局限:词汇表爆炸与未知词问题
那么,直接用空格分割的单词(Word-level)作为token如何?这确实解决了字符级的语义模糊问题,“apple”作为一个整体token,其语义是明确的。但这种方法有两大硬伤:
词汇表爆炸(Vocabulary Explosion):一种语言中的单词数量是巨大的,并且是开放的。想想英语中的各种时态(play, plays, playing, played)、复数、所有格,以及无数的专业术语和网络新词。如果每个词形都作为一个独立的token,词汇表大小会轻易膨胀到几十万甚至上百万。这会导致两个问题:一是模型嵌入层(Embedding Layer)的参数会变得极其庞大,占用大量内存;二是每个token在训练初期被见到的次数非常少,学习不充分,模型泛化能力差。
未知词(Out-of-Vocabulary, OOV)问题:无论你把词汇表设得多大,总会有新的、没见过的单词出现。对于这些OOV词,传统的词级模型通常用一个特殊的
<UNK>(unknown)token来替代。这意味着模型完全失去了对这个词的信息,句子“I used the latest框架名”如果框架名被替换成<UNK>,整个句子的含义就可能丢失或扭曲。
2.3 子词折衷方案:Byte Pair Encoding (BPE) 的崛起
正是为了在“字符的灵活性”和“单词的语义性”之间取得平衡,子词分词(Subword Tokenization)成为了主流。其核心思想是:将单词拆分成更小的、有意义的片段(子词),这些片段既能覆盖大多数常见单词(作为整体),也能通过组合来表示罕见词或新词。
其中,Byte Pair Encoding (BPE)是当前最流行、影响最深远的算法,被GPT系列、RoBERTa等众多顶尖模型采用。它的思想非常巧妙,来源于数据压缩领域。BPE的训练过程可以概括为:
- 以一个巨大的文本语料库作为输入。
- 初始时,将每个单词拆分成字符(包括一个特殊的单词结束符如
</w>),并统计所有相邻字符对(bigram)的频率。例如,“low”初始为l, o, w, </w>,字符对有(l, o),(o, w),(w, </w>)。 - 找到语料库中出现频率最高的字符对(比如
e和s经常连在一起出现),将它们合并成一个新的符号(比如es)。 - 将这个新符号加入词汇表,并在所有单词中用这个新符号替换原来的字符对。
- 重复步骤3和4,直到合并了预定的次数(即词汇表达到预定大小)。
通过这个过程,高频的字符组合(如ing,ed,tion,pre)会被优先合并成子词。最终,常见单词如“playing”可能被整体保留为一个token,而罕见单词“tokenization”则可能被拆分为token,ization两个已知的子词token。这完美解决了词汇表爆炸和OOV问题:词汇表大小可控(通常5万-10万),且任何新词理论上都可以用已有的子词拼凑出来。
注意:BPE有一个关键变体是Byte-level BPE (BBPE),由GPT-2引入。它不是在Unicode字符级别进行合并,而是在字节(Byte)级别进行。这使得它的词汇表很小(256个字节作为基础),但通过合并可以表示任何Unicode文本,实现了真正的“全字符集覆盖”,在多语言混合文本处理上表现更鲁棒。这也是当前许多先进模型的选择。
3. 主流分词算法巡礼:BPE、WordPiece与Unigram
虽然BPE是事实上的霸主,但了解其“竞争对手”有助于我们理解不同设计哲学。在实际项目中,选择哪种分词器往往是“拿来主义”——直接使用预训练模型配套的分词器。但知其所以然,能帮你更好地理解模型的某些行为特性。
3.1 WordPiece:BERT家族的沉默功臣
WordPiece是Google为BERT模型开发的分词算法,其整体流程与BPE非常相似。关键区别在于合并字符对的标准。
- BPE的标准:合并频率最高的字符对。
- WordPiece的标准:合并能最大程度提升语言模型概率的字符对。具体来说,它计算合并一对符号后,对整个训练语料库的似然值(likelihood)的提升,选择提升最大的进行合并。
从结果上看,WordPiece产生的词汇表与BPE往往大同小异,但在处理某些边界情况时可能有细微差别。由于BERT的巨大成功,WordPiece也被广泛应用于后续的Transformer模型(如ALBERT、ELECTRA)。使用Hugging Face的transformers库时,如果你加载一个BERT模型,其配套的BertTokenizer通常就是WordPiece分词器。
3.2 Unigram:一种“自上而下”的 probabilistic 视角
Unigram语言模型分词法采取了与BPE/WordPiece“自下而上”合并相反的思路。它是一种“自上而下”的概率模型。
- 初始化:它首先用一个很大的种子词汇表(比如所有字符和常见子串)开始。
- 训练:它假设一个句子是由词汇表中的token根据一个unigram语言模型(即每个token独立出现)生成的。然后,它用EM算法等方法来优化两个东西:a) 每个token的概率;b) 给定当前词汇表和概率,句子的最佳分词方式。
- 剪枝:逐步移除那些对整体似然值贡献最小的token(例如,移除后,用剩余token重新分词,语料库的总似然值下降最少),直到词汇表缩小到目标大小。
Unigram的优势在于它是一个显式的概率模型,非常灵活。你可以通过调整token的概率来轻松地采样不同的分词结果(这在数据增强中有用)。SentencePiece工具包(由Google发布)同时实现了BPE和Unigram算法,并且它的一大特点是不依赖空格进行预分词,直接将原始文本(包括空格)当作一个字符流来处理,这对于中文、日文等不以空格分词的语言尤其友好。许多多语言模型(如T5、mT5)都使用基于SentencePiece的Unigram分词器。
3.3 算法对比与选型启示
为了更直观,我们用一个表格来对比这三大算法:
| 特性 | BPE (Byte-Pair Encoding) | WordPiece | Unigram (with SentencePiece) |
|---|---|---|---|
| 核心思想 | 自下而上,迭代合并最高频字符对 | 自下而上,迭代合并最大似然提升字符对 | 自上而下,基于概率模型迭代剪枝词汇表 |
| 训练目标 | 频率最大化 | 语言模型似然最大化 | 语言模型似然最大化 |
| 空格处理 | 通常依赖预分词(空格分隔单词) | 通常依赖预分词(空格分隔单词) | 无需预分词,空格作为普通字符处理 |
| 典型代表模型 | GPT系列, RoBERTa, Llama | BERT, ALBERT | T5, mT5, ALBERT (部分版本) |
| 主要优势 | 简单高效,广泛适用 | 与BPE类似,在BERT生态中成熟 | 灵活,支持概率分词,对无空格语言友好 |
| 实操关注点 | 需处理字节级(BPE) vs 字符级 | 与BPE tokenizer基本可互换使用 | 配置更复杂,但功能强大,尤其适合多语言 |
选型心得:对于绝大多数应用者来说,你不需要从头训练一个分词器。你的选择通常被锁定在你想要使用的预训练模型上。如果你用Llama,那就用它的BPE分词器;如果你用BERT,那就用WordPiece。重要的是理解你所用分词器的特性,比如它的词汇表大小、是否区分大小写、如何处理数字和标点。这些细节会直接影响你预处理数据、处理模型输入输出以及进行提示工程(Prompt Engineering)的方式。
4. 分词器的实战陷阱与细节剖析
理解了原理,我们来看看在实际编码和模型使用中,分词器会给你埋下哪些“坑”。这些经验往往不会写在官方文档的显眼位置。
4.1 词汇表与特殊Token:看不见的“基础设施”
加载一个分词器(例如AutoTokenizer.from_pretrained("gpt2")),你得到的不仅仅是一个切割文本的函数,而是一个包含了几万到几十万参数(嵌入向量)的“基础设施”。其中,有几个特殊的token至关重要:
<bos>/[CLS]:序列开始(Beginning of Sequence)或用于分类的特殊token。在自回归模型(如GPT)中,<bos>常作为生成起点;在BERT中,[CLS]位的输出用于分类任务。<eos>/[SEP]:序列结束(End of Sequence)或分隔符(Separator)。<eos>用于标记文本结束,也是生成停止的信号;[SEP]用于分隔句子对。<pad>:填充token,用于将一批(batch)中不同长度的序列补齐到相同长度,以便并行计算。<unk>:未知token,虽然子词分词大大减少了它的出现,但依然存在。
踩坑记录1:忽略add_special_tokens参数。当你调用tokenizer(text)时,默认行为(add_special_tokens=True)可能会自动加上<bos>和<eos>。这在训练或微调时通常是需要的。但在进行文本相似度计算、或者单纯想查看原始文本的分词结果时,这个自动添加的行为会导致意想不到的偏差。比如,你计算两个句子的嵌入向量余弦相似度,如果它们都自动加上了相同的<bos>,这个无关的token会拉高相似度。务必根据场景显式设置add_special_tokens=False。
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("gpt2") text = "Hello world" # 默认情况(可能加特殊token) ids_with_special = tokenizer.encode(text) # 例如 [50256, 15496, 995, 50256] # 关闭特殊token ids_raw = tokenizer.encode(text, add_special_tokens=False) # 例如 [15496, 995]4.2 长度限制与截断策略:输入管道的“守门员”
Transformer模型有严格的最大序列长度限制(如512或2048)。分词器负责执行长度管控。
max_length:设置模型能处理的最大token数。truncation=True:当文本超过max_length时,启用截断。truncation_side:决定从哪边截断。'left'(截首)通常用于保留更重要的后半部分(如问答的答案),'right'(截尾)更常见。对于长文档摘要,可能需要从中间截断,这需要自定义处理。padding=True与padding_side:批处理时进行填充。padding_side='right'是默认且最常用的。但在某些自回归生成场景,为了缓存优化,可能会需要padding_side='left'。
踩坑记录2:截断导致语义断裂。这是最隐蔽的坑。假设你有一段代码或一个JSON字符串,被无情地从中间截断,导致括号不匹配、语法错误。模型接收到的输入是无效的,其输出自然也是无意义的。对于非自然语言(代码、结构化数据),简单的头部或尾部截断是危险的。解决方案包括:1) 使用滑动窗口将长文本分块处理;2) 针对特定结构(如代码)设计更智能的截断点(例如在函数边界、完整语句后截断)。
4.3 分词不一致性:同一个词,不同的“命运”
这是子词分词的一个固有特性,也是提示工程需要特别注意的地方。
- 大小写敏感:有些分词器(如原始的BERT)是大小写不敏感的,会将“Hello”和“hello”都转换为“hello”。而GPT系列的分词器通常是大小写敏感的。这会导致“Hello”和“hello”在模型眼中是两个不同的token,拥有不同的嵌入向量。在构建搜索系统或进行精确匹配时,需要做统一的大小写规范化(lowercasing)。
- 空格与标点的微妙处理:分词器如何处理空格前的标点?例如,“Hello,world”和“Hello, world”的分词结果可能不同。前者可能被分成
["Hello", ",", "world"],后者可能是["Hello", ",", " world"](注意“world”前的空格可能被合并或保留)。这种细微差别在要求精确复现的生成任务(如代码生成)中可能带来问题。 - 数字的处理:数字“123”可能被整体作为一个token,也可能被拆成
["12", "34"]或每个数字一个token。这取决于它在训练语料中的出现频率。这会影响模型对数学和数值推理的能力。
实操技巧:在开始任何重要工作前,务必用你的目标分词器对一批有代表性的样例文本进行分词,并仔细检查结果。使用tokenizer.tokenize()方法查看token列表,而不是直接看ID。这能帮你提前发现许多潜在的数据清洗和预处理问题。
5. 分词如何影响模型训练与性能
分词的选择不是中立的,它直接塑造了模型的“认知”能力。
5.1 词汇表大小:一个关键的超级参数
词汇表大小(Vocab Size)是一个需要在训练前就确定的超参数。它是一场权衡:
- 词汇表过大(例如10万以上):每个token的语义更具体,模型可能对常见表达学习得更快。但缺点是:1) 嵌入矩阵巨大,增加模型参数量和内存消耗;2) 每个token在训练数据中出现的平均次数变少,可能导致学习不充分,泛化能力下降;3) 序列长度可能更短(因为平均每个token代表的字符更多),但这不是绝对的。
- 词汇表过小(例如1万以下):嵌入矩阵小,每个token被看到的次数多,学习更充分。但几乎所有单词都需要被拆分成多个子词,导致序列长度变长,增加了计算成本(注意力复杂度O(n²)),并且模型需要学习更多关于子词组合的规律。
经验之谈:对于主流的大语言模型,词汇表大小通常在3万到10万之间。例如,GPT-2是50257,BERT-base是30522,Llama 2是32000。这个范围被实践证明能在计算效率、模型容量和泛化能力之间取得较好的平衡。当你从头开始为特定领域(如医学、法律)训练一个分词器时,基于领域语料统计选择一个合适的词汇表大小是关键一步。
5.2 对序列长度与计算效率的影响
这是最直接的影响。给定一段文本,分词器产生的token数量直接决定了模型需要处理的序列长度。由于Transformer注意力机制的计算复杂度与序列长度的平方成正比,更长的序列意味着指数级增长的计算和内存开销。
- 中英文混合文本:一个中文字符在UTF-8中通常占3个字节,在BBPE分词器下,很可能被拆成多个字节token。而一个常见的英文单词可能只是一个token。这导致相同字符长度的中英文文本,中文产生的token数通常远多于英文。这也是为什么在处理中文长文本时,更容易遇到长度限制问题。
- 子词分词 vs 字符分词:显然,子词分词能显著缩短序列长度。例如,“tokenization”作为一个单词是1个token,作为字符序列可能是13个token(t, o, k, e, n, i, z, a, t, i, o, n)。
优化思路:对于需要处理超长文本的应用(如长文档摘要、书籍分析),除了使用具有更长上下文窗口的模型(如128K),在数据预处理阶段可以考虑:1) 使用更“激进”的分词器(在相同词汇表大小下,倾向于生成更长子词的算法);2) 对文本进行智能分块,并在模型架构层面引入层次化注意力或记忆机制。
5.3 分词与模型能力边界:以代码和数学为例
分词器对非自然语言数据的处理能力,直接决定了模型在该领域的上限。
- 代码生成:代码具有精确的语法结构。变量名
userAuthenticationHandler如果被不幸地拆分成user,Authent,ication,Handler,模型学习变量名整体含义和用法的难度就增加了。好的代码分词器应该在常见编程语言的命名习惯(如驼峰命名、下划线命名)上进行优化,尽可能保持标识符的完整性。例如,Codex/GPT-3.5系列使用的分词器在这方面就做了特殊处理。 - 数学推理:数字和公式的分词至关重要。“3.14159”是作为一个token,还是拆成
["3", ".", "14", "15", "9"]?后者显然不利于模型理解这是一个连续的数值。复杂的LaTeX公式更是分词器的噩梦。不合理的分词会严重损害模型的数学能力。
因此,领域适配的分词器是一个重要的研究方向。如果你要在特定领域微调模型,使用该领域语料(如GitHub代码、arXiv论文)重新训练或微调一个分词器,可能会带来显著的性能提升。
6. 在真实项目中操作分词器:以Hugging Face Transformers为例
理论说了这么多,最后我们落到代码上,看看如何在项目中使用分词器。Hugging Face的transformers库提供了统一的接口。
6.1 加载与基本使用
from transformers import AutoTokenizer # 加载预训练模型的分词器 tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf") # 以Llama 2为例 # 或者指定具体的分词器类 # from transformers import LlamaTokenizer # tokenizer = LlamaTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf") text = "Your input text here." # 方法1: encode -> 得到input_ids (token IDs) input_ids = tokenizer.encode(text, return_tensors="pt") # 返回PyTorch张量 # 方法2: 直接调用tokenizer,得到包含所有信息的字典 encoding = tokenizer(text, return_tensors="pt") # encoding 包含: # - input_ids: token ID序列 # - attention_mask: 注意力掩码(1表示真实token,0表示padding) # - 可能还有 token_type_ids (用于区分句子对,如BERT) print(encoding)6.2 批处理与填充
在实际训练或推理中,我们几乎总是处理一个批次的样本。
batch_texts = ["Text one.", "This is a longer text example.", "Short."] # 自动进行分词、截断、填充,并返回张量 batch_encoding = tokenizer( batch_texts, padding=True, # 启用填充 truncation=True, # 启用截断 max_length=512, # 最大长度 return_tensors="pt", # 返回PyTorch张量 ) print(batch_encoding["input_ids"].shape) # 例如 torch.Size([3, 512]) print(batch_encoding["attention_mask"]) # 查看哪些是真实内容,哪些是填充6.3 解码:从Token IDs回到文本
生成模型输出的是token ID序列,我们需要将其解码回人类可读的文本。
# 假设 model_output 是模型生成的一串 token IDs (形状为 [1, seq_len]) generated_ids = model_output # 简单解码 decoded_text = tokenizer.decode(generated_ids[0], skip_special_tokens=True) print(decoded_text) # skip_special_tokens=True 是关键参数,它会过滤掉 <pad>, <bos>, <eos> 等特殊token,只留下有意义的文本。6.4 处理分词器中的“坑”:一个完整示例
假设我们正在构建一个问答系统,需要处理用户可能输入的很长的问题。
def preprocess_for_qa(question, context, tokenizer, max_seq_length=512): """ 预处理问答对,处理截断问题。 策略:优先保留问题完整,从上下文尾部截断。 """ # 1. 拼接问题和上下文,用 [SEP] 分隔(假设是类似BERT的分词器) encoded_question = tokenizer.encode(question, add_special_tokens=False) encoded_context = tokenizer.encode(context, add_special_tokens=False) # 2. 计算预留空间: [CLS] + question + [SEP] + context + [SEP] reserved_tokens = 3 # [CLS], [SEP], [SEP] max_context_len = max_seq_length - len(encoded_question) - reserved_tokens if max_context_len <= 0: # 问题本身就超长了,必须截断问题(这是一个极端情况,需要业务逻辑处理,比如返回错误) # 这里我们简单地从尾部截断问题 encoded_question = encoded_question[:max_seq_length - reserved_tokens] max_context_len = 0 encoded_context = [] else: # 从上下文尾部截断,保留最新的信息(对于QA,答案可能在末尾) encoded_context = encoded_context[-max_context_len:] # 3. 构建最终的input_ids input_ids = [tokenizer.cls_token_id] + \ encoded_question + \ [tokenizer.sep_token_id] + \ encoded_context + \ [tokenizer.sep_token_id] # 4. 创建attention_mask和token_type_ids (对于BERT风格) attention_mask = [1] * len(input_ids) token_type_ids = [0] * (len(encoded_question) + 2) + [1] * (len(encoded_context) + 1) # [CLS] Q [SEP] C [SEP] # 5. 填充到最大长度 padding_length = max_seq_length - len(input_ids) if padding_length > 0: input_ids = input_ids + [tokenizer.pad_token_id] * padding_length attention_mask = attention_mask + [0] * padding_length token_type_ids = token_type_ids + [0] * padding_length # 填充部分的token type通常为0 return { "input_ids": torch.tensor([input_ids]), "attention_mask": torch.tensor([attention_mask]), "token_type_ids": torch.tensor([token_type_ids]) # 如果模型需要 }这个例子展示了在实际应用中,你需要根据任务逻辑(如QA中问题和上下文的重要性不同)来定制截断策略,而不是依赖分词器的默认行为。
Tokenization作为大语言模型流水线的第一步,其重要性怎么强调都不为过。它不是一个简单的“预处理步骤”,而是模型数据表示的核心组成部分,与模型架构、训练目标紧密耦合。理解你使用的分词器,了解它的词汇表、特殊token、截断和填充行为,是进行有效的模型开发、调试和优化的基础。下次当你看到模型产生一个奇怪的输出时,不妨先看看输入文本被切分成了什么样子——答案往往就隐藏在这些小小的token之中。
