零基础入门 AI:1 token ~= 3/4 words?3 分钟理解大语言模型分词
ChatGPT API 按 token 数量收费,那么 1 个 token 究竟是多少?
按 OpenAI 的估算,1 token ~= 3/4 words,100 个 token 大约是 75 个单词。
输入的文本是如何被分解为 token 的呢?在大语言模型处理文本时,分词(Tokenization)是最基础又相对独立的一个环节,在整个流程中非常重要,但又经常容易被忽略。今天我们就一起来梳理分词(Tokenization)相关的知识。
简介
什么是分词(Tokenization)?
分词(Tokenization)是为了将自然语言转换为计算机可以理解的数值形式。分词的过程是将文本分解为更小单元的过程,这些更小的单元通常被称为 token。这些 token 可以是单词、子词或者字符,具体取决于所选用的分词方法。这些 token 将会进一步被转换为数字 ID,再转换为向量,从而成为计算机可以理解的输入形式。
分词粒度
分词(Tokenization)有三种主要的粒度:
- 词级别(Word-Based Tokenization):这是最自然的语言单元,对于像英文这样的语言,单词之间天然存在空格分隔,因此切分相对容易。但会造成词表过大,也一定会存在超出词汇表的词(OOV),而且还不能学到词根或词缀的关系。
- 字符级别(Character-Based Tokenization):这是最细粒度的 tokenize 方法,将文本切分为单个字符。这种方法在处理某些语言或任务时可能很有用,尤其是当文本中包含很多未知词或拼写错误时。然而,字符级别的 tokenize 会丢失一些词或短语级别的语义信息。
- 子词级别(Subword-Based Tokenization):这种方法介于词和字符之间,旨在解决词级别 tokenize 可能遇到的问题,如超出词汇表的词(OOV)问题和词表过大问题。子词方法可以将一个词进一步切分为更小的有意义的单元,如词缀或词根。这也是目前最主流的分词粒度。
Subword 分词方法
常见的 subword 分词方法大致可分为以下两类:
基于规则的分词方法:比如 python 中的 NLTK、spaCy 等等,通常是依赖于预定义的规则和模式来识别分词的边界。
from nltk.tokenize import word_tokenize
text = "Don't you love eating apples?"
word_tokens = word_tokenize(text)
print(word_tokens)
基于预训练模型的分词方法:比如 python 中的 transformers,可以加载不同的预训练模型,这些模型已经在大量文本上进行了训练,学习了单词的上下文表示。
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("lmsys/vicuna-7b-v1.5")
text = "Don't you love eating apples?"
print(tokenizer.tokenize(text))
在目前主流的大语言预训练模型中,常见的 subword 分词算法有三种:
- BPE(Byte Pair Encoding):BPE 是一种基于频率的子词分词算法,它从字符级别的分词开始,通过不断合并常见的相邻字符来生成新的子词。这个过程持续进行,直到达到预定的词表大小。
- WordPiece:WordPiece 也是通过合并子词来构建词表,但 WordPiece 不是基于频率,而是基于能够最大化语言模型概率的相邻子词对。常用于 BERT 系列模型。
- Unigram:Unigram 采用一种减量式的分词方法,首先构建一个非常大的初始词表,然后根据评估规则,不断从词表中移除子词,直到满足预定的词表大小。常用于 T5 等模型。
Transformers AutoTokenizer
分词 tokenize
使用 transformers 库可以方便地调用预训练的分词器。
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("lmsys/vicuna-7b-v1.5")
text = "Don't you love eating apples?"
print(tokenizer.tokenize(text))
text = "He is eating an apple."
print(tokenizer.tokenize(text))
注意输出中的 ▁ 符号,这通常代表空格。在 SentencePiece 等子词分词器中,为了区分单词开头的空格和单词中间的空格,会使用特殊标记。
编码 encode
可以使用 tokenizer.encode 直接将文本转换为词表中的 token ID 列表(带 cls 和 sep,带 truncation,带 padding):
text = "Don't you love eating apples?"
ids = tokenizer.encode(text)
print(ids)
也可以分两步,tokenize 和 convert_tokens_to_ids,将文本转换为词表中的 token ID 列表(不带 cls 和 sep):
text = "Don't you love eating apples?"
tokens = tokenizer.tokenize(text)
print(tokens)
ids = tokenizer.convert_tokens_to_ids(tokens)
print(ids)
解码 decode
大语言模型在完成推理后,输出的是词表中的 token ID,可以使用 decode 将其解码成文本。
text = "Don't you love eating apples?"
tokens = tokenizer.tokenize(text)
ids = tokenizer.convert_tokens_to_ids(tokens)
decoded_text = tokenizer.decode(ids)
print(decoded_text)
类似的,也可以用 convert_ids_to_tokens 和 convert_tokens_to_string 两步来实现:
output_tokens = tokenizer.convert_ids_to_tokens(ids)
decoded_text = tokenizer.convert_tokens_to_string(output_tokens)
print(decoded_text)
词表是啥样?
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("lmsys/vicuna-7b-v1.5")
print(list(tokenizer.vocab.items())[:5])
在前文的'编码 encode'步骤中,可以看到 ▁Don 被编码为 3872,t 被编码为 29873,现在可以在词表中验证一下:
print(tokenizer.vocab['▁Don'])
print(tokenizer.vocab['t'])
词表有多大?
print(len(tokenizer.vocab))
小羊驼(Vicuna)配置分析
config.json
小羊驼(Vicuna)的 config.json 如下:
{
"_name_or_path": "vicuna-7b-v1.5",
"architectures": ["LlamaForCausalLM"],
"bos_token_id": 1,
"eos_token_id": 2,
"hidden_act": "silu",
"hidden_size": 4096,
"initializer_range": 0.02,
"intermediate_size": 11008,
"max_position_embeddings": 4096,
"model_type": "llama",
"num_attention_heads": 32,
"num_hidden_layers": 32,
"num_key_value_heads": 32,
"pad_token_id": 0
从中也可以印证词汇表的大小是 32000。
tokenizer_config.json
小羊驼(Vicuna)的 tokenizer_config.json 如下:
{
"add_bos_token": true,
"add_eos_token": false,
"bos_token": {"__type": "AddedToken", "content": "<s>", "lstrip": false, "normalized": false, "rstrip": false, "single_word": false},
"clean_up_tokenization_spaces": false,
"eos_token": {"__type": "AddedToken", "content": "</s>", "lstrip":
从以上配置中可以看到,最大支持 4096 个 token,分词类使用的是 LlamaTokenizer,开始符定义为 <s>,结束符定义为 </s>,超出词汇表的定义为 <unk>。
在前文的'编码 encode'中使用 tokenizer.encode 时,会添加开始符,就是在最前端添加 <s>,验证如下:
text = "Don't you love eating apples?"
ids = tokenizer.encode(text)
print(ids)
print(tokenizer.vocab['<s>'])
print(tokenizer.vocab['</s>'])
print(tokenizer.vocab['<unk>'])
分词实战注意事项
在实际开发大模型应用时,除了理解基本的分词原理,还需要注意以下几点:
- 截断策略(Truncation):当输入长度超过
max_position_embeddings 时,模型无法处理。通常需要在编码时设置 truncation=True,并指定 max_length。例如 tokenizer.encode(text, truncation=True, max_length=512)。
- 填充策略(Padding):为了批量处理不同长度的文本,通常需要对较短的序列进行填充。可以通过
padding=True 自动填充到 batch 中最长的序列,或指定 max_length。注意填充 token 通常是 pad_token_id(如 Vicuna 中为 0)。
- 特殊 Token 处理:模型通常有特定的起始符(BOS)、结束符(EOS)和未知符(UNK)。在手动拼接 prompt 时,需要确保这些 token 的位置正确,否则会影响模型的推理效果。
- 多语言支持:不同语言的 tokenization 效率不同。中文通常每个字或词会被切分成多个 token,而英文单词可能被切分成更少的子词。在计算成本时,需考虑语言差异带来的 token 数量波动。
- 空间处理:如前文所示,HuggingFace 的某些分词器(如 SentencePiece)会将空格编码为特殊符号(如
▁)。在解码时,convert_tokens_to_string 会自动处理这些符号还原为正常空格,但在调试时需注意观察原始 token 列表。
总结
分词是大语言模型处理文本的第一步,直接影响模型的理解能力和 API 调用成本。理解 BPE、WordPiece 等算法原理,掌握 transformers 库中 AutoTokenizer 的使用,能够帮助开发者更好地优化提示词工程,控制上下文长度,并准确预估模型调用的费用。通过本文对 Vicuna 模型配置的分析,读者可以进一步了解分词器与模型架构之间的关联,为后续的微调和部署打下基础。