分词器详解-Token才是LLM的基本单位
> **本文适合谁**
分词器详解:Token 才是 LLM 的基本单位
本文适合谁
想搞清楚"为什么 API 按 token 计费"、"为什么中文比英文贵"、"上下文窗口为什么是 128K token"的开发者。理解 token,就能理解 LLM 的成本模型和能力边界。
LLM 处理语言的基本单位不是字符,也不是单词,是 Token。不理解 Token,就不理解 LLM 的边界和成本。
Token 是什么? 可以理解为"语言的碎片"——比单词小、比字母大的文字片段。"strawberry" 这个单词可能被切成 "st"、"raw"、"berry" 三个 Token;一个汉字通常对应 1-2 个 Token。LLM 读文章、写回答,全程操作的都是 Token,不是你看到的完整文字。API 调用按 Token 数量计费,上下文窗口也以 Token 为单位计算。
Token 可以解释两个常见现象:为什么让 ChatGPT 数 "strawberry" 里有几个字母 r,它经常给出错误答案?为什么同样一段文字,中文版的 API 调用费用比英文版贵得多?
1.1 Token 不是字符,也不是单词
1.1.1 三种粒度的权衡:字符、单词、子词
在理解 Token 化的具体算法之前,先建立对"为什么是子词"的直觉。
图 6.5:BPE 分词算法四步过程——从原始文本到最终词表的迭代合并
如果用字符作为基本单位:词汇表只有几百个(字母 + 符号),绝不会遇到未知字符。但每个词都被拆散了,"cat"变成 [c, a, t],模型需要从三个独立字符中推断出这是一个有意义的词,学习效率极低。而且序列长度急剧增大,"a book about history"变成 20 个字符,自注意力的 O(n²) 复杂度使成本暴增。
如果用单词作为基本单位:每个词保留完整含义,"cat"就是"cat",语义清晰。但词汇表会无限膨胀——英语有 50 万以上的单词,加上各种变形、新词、专有名词,永远也无法穷举。更严重的是 OOV(Out of Vocabulary)问题:遇到词汇表里没有的词,模型就完全不认识了。
子词(Subword)是折中:常见词保持完整("the"、"is"等),罕见词拆成有意义的片段("unbelievable" = "un" + "believ" + "able")。词汇表大小可控,遇到新词也能组合成已知片段。
Token 是 Tokenizer(分词器)把文本切割之后得到的片段。英文单词 "cat" 通常是 1 个 token,"unbelievable" 可能被切成 "un"、"believ"、"able" 三个 token。中文的情况更特殊:一个汉字通常对应 1 到 3 个 token(常用汉字多为 1-2 个,生僻字可达 3 个),因为中文字符在训练语料里出现频率没有英文高,编码效率更低。
用 OpenAI 的 tiktoken 库可以直接观察:
import tiktoken
enc = tiktoken.get_encoding("cl100k_base") # GPT-4 使用的编码
# 英文的 token 化
text_en = "The quick brown fox jumps over the lazy dog"
tokens_en = enc.encode(text_en)
print(f"英文原文:{text_en}")
print(f"Token 数:{len(tokens_en)}")
print(f"Token 列表:{[enc.decode([t]) for t in tokens_en]}")
# 中文的 token 化
text_zh = "敏捷的棕色狐狸跳过了懒狗"
tokens_zh = enc.encode(text_zh)
print(f"\n中文原文:{text_zh}")
print(f"Token 数:{len(tokens_zh)}")
print(f"字符数:{len(text_zh)}")
print(f"Token/字 比例:{len(tokens_zh)/len(text_zh):.2f}")
# strawberry 的 token 化
word = "strawberry"
tokens_word = enc.encode(word)
print(f"\n'{word}' 的 token:{[enc.decode([t]) for t in tokens_word]}")
# 输出类似:['st', 'raw', 'berry']
上面的代码验证了中英文 token 效率的差异:中文约需要英文 2 倍的 token 数来表达相同信息量。
"strawberry" 被切成了 "st"、"raw"、"berry" 三段,模型看到的是三个 token,而不是十个字符。让它数里面有几个 r,等于让你看三张碎片化的纸片,然后数原文有几个特定字母——确实会数错。
中文呢?英文大约 0.75 个 token 对应一个单词,中文大约 1.5-2 个 token 对应一个汉字。同样的信息量,中文消耗的 token 更多,账单自然更高。
1.2 三种分词策略:都有缺陷
在子词分词普及之前,有两种极端方案,各有各的问题。
按词分词。 词表里列出所有词,"running"和"ran"是两个不同的词。问题是词表会无限膨胀,而且遇到新词(OOV,Out Of Vocabulary,即不在词表中的词)就不认识了——"GPT-4o" 这种新词在 2018 年的词表里根本没有。
按字符分词。 词表只有 26 个字母加标点,绝对不会有未知词。但每个词都变成了一串字母,语义信息完全碎片化,模型需要从 "c-a-t" 学出"猫"的含义,难度大很多,序列也变得很长。
子词分词。 常见词保持完整,罕见词拆开。"cat" 是一个 token,"categorization" 可能被拆成 "categor" + "ization"。词表大小可控,同时保留了语义信息,遇到新词也能拆成已知子词组合。
这是目前所有主流 LLM 采用的方案。
1.3 BPE 算法:迭代合并最高频字符对
1.3.1 直觉:频繁出现的字符组合就应该成为一个单元
BPE(Byte Pair Encoding,字节对编码)的核心直觉非常简单:如果两个字符经常一起出现,就把它们合并成一个新的单元。如果"th"在文本中总是连在一起出现,那它们应该成为一个 token,而不是两个独立的字符。
这个过程反复迭代:先把所有文本拆成单个字符,然后不断找出出现最频繁的相邻字符对,合并它们。常见的字符组合(如英语中的"th"、"ing"、"tion")最终会成为独立的 token;罕见的组合则保持在字符级别。
最主流的子词分词算法叫 BPE(Byte Pair Encoding,字节对编码)。逻辑很简单:反复找出当前最常见的字符对,把它合并成一个新单元。
from collections import defaultdict
def get_vocab(corpus):
"""把词拆成字符序列,末尾加 </w> 标记词边界"""
vocab = defaultdict(int)
for word, freq in corpus.items():
chars = ' '.join(list(word)) + ' </w>'
vocab[chars] += freq
return vocab
def get_pairs(vocab):
"""统计所有相邻字符对的频次"""
pairs = defaultdict(int)
for word, freq in vocab.items():
symbols = word.split()
for i in range(len(symbols) - 1):
pairs[(symbols[i], symbols[i+1])] += freq
return pairs
def merge_vocab(pair, vocab):
"""把指定字符对合并"""
new_vocab = {}
bigram = ' '.join(pair)
replacement = ''.join(pair)
for word in vocab:
new_word = word.replace(bigram, replacement)
new_vocab[new_word] = vocab[word]
return new_vocab
# 示例语料:词 -> 频次
corpus = {
'hug': 10,
'pug': 5,
'pun': 12,
'bun': 4,
'hugs': 5,
}
vocab = get_vocab(corpus)
print("初始词表:")
for word in vocab:
print(f" {word}: {vocab[word]}")
# 执行 5 轮 BPE 合并
num_merges = 5
for i in range(num_merges):
pairs = get_pairs(vocab)
if not pairs:
break
best_pair = max(pairs, key=pairs.get)
vocab = merge_vocab(best_pair, vocab)
print(f"\n第 {i+1} 轮合并:{best_pair[0]} + {best_pair[1]} -> {''.join(best_pair)}")
for word in vocab:
print(f" {word}: {vocab[word]}")
上面的代码演示了 BPE 的迭代合并过程,展示了高频字符对如何逐步成为独立 token。
第一轮,"u" 和 "n" 出现最频繁(pun 12次、bun 4次,共 16 次),合并成 "un"。第二轮,"h" 和 "ug" 或其他高频对接着合并。最终,高频词组合成了完整 token,低频词保持碎片化。
算法终止的时机由目标词表大小决定,GPT-4 的词表大约是 10 万个 token。
1.4 WordPiece vs SentencePiece:两种变体
1.4.1 为什么需要多种分词算法
BPE 是一种贪心算法——每次合并频率最高的字符对。但"频率最高"不等于"最有利于语言理解"。
WordPiece 的改进思路:不是找出现最频繁的字符对,而是找合并后能让整体语料的语言模型概率提升最多的字符对。这是一种更"有原则"的合并策略,考虑的是合并对整体语言模型效果的影响,而不只是出现频率。
BERT 用的是 WordPiece,和 BPE 思路相近,但合并的标准不是频次,而是语言模型概率:合并哪对字符能让整体语料的概率提升最多,就合并哪对。细节不同,但效果类似。
LLaMA、Mistral 等模型用 SentencePiece,区别在于:它把空格也当作普通字符处理,"▁hello"(带空格前缀的 hello)和 "hello" 是不同 token。好处是语言无关——不管是中文、日文还是阿拉伯文,都能用同一套规则处理,不需要依赖语言特定的空格分词规则。
对开发者来说,这个区别一般不需要深究,用哪个模型用哪个分词器就行。但有一点值得注意:不同模型的分词器不通用,GPT-4 的 token 数和 Claude 的 token 数对同一段文字可能不一样。
1.5 对开发者的实际影响
理解 Token 不是学术练习,有几件实际的事需要知道。
上下文窗口是 token 数,不是字符数。 GPT-4o 的 128k 上下文指的是 128000 个 token。一段 10 万字的中文文档,可能需要 15-20 万个 token——放不进去。做 RAG 或处理长文档时,先算 token 数,别估字数。
API 按 token 计费,中文更贵。 同样传达一个意思,中文可能需要更多 token,成本相应更高。做成本估算时要用 token 数而不是字数。
大小写和格式影响 token 分配。 "Running"(大写 R)和 "running" 可能是不同的 token。"2024-01-01" 和 "20240101" 的 token 化方式也不同。设计 Prompt 时,格式统一有助于稳定 token 消耗。
字符级任务对 LLM 天然困难。 模型在 token 层面操作,字符级任务(数字母、反转字符串、判断是否回文)对它来说非常反直觉。遇到这类任务,要么别指望 LLM,要么在 Prompt 里明确让它先拆字符再操作。
# 实际测量一段文本的 token 消耗
import tiktoken
def count_tokens(text: str, model: str = "gpt-4o") -> dict:
"""统计文本的 token 数"""
encoding_name = "cl100k_base" # GPT-4/GPT-4o 使用的编码
enc = tiktoken.get_encoding(encoding_name)
tokens = enc.encode(text)
return {
"text_length": len(text),
"token_count": len(tokens),
"ratio": len(tokens) / len(text),
}
texts = {
"英文": "The large language model processes tokens, not characters.",
"中文": "大型语言模型处理的是Token,而不是字符。",
"代码": "def hello():\n print('hello world')",
}
for lang, text in texts.items():
result = count_tokens(text)
print(f"{lang}: 字符数={result['text_length']}, "
f"token数={result['token_count']}, "
f"比例={result['ratio']:.2f}")
上面的代码可以实际测量不同语言和格式的 token 消耗,帮助在设计 Prompt 时做成本预估。
1.6 Token 是成本和能力的双重边界
Token 是 LLM 看世界的基本单位,不是字符,不是词,是介于两者之间的子词序列。
理解这件事有两层价值。
一是控制成本。按 token 计费的 API,想省钱就要减少 token 消耗:Prompt 精炼、避免重复、中英文混用时有意识地选择词汇。
二是理解能力边界。LLM 不擅长字符级操作,是因为它的感知粒度就不是字符。遇到模型表现奇怪的地方,先问一句:这个任务在 token 层面是不是本来就难?
1.7 小结
| 概念 | 核心要点 | 对开发者的影响 |
|---|---|---|
| Token 是子词 | 比词小,比字符大;常见词完整,罕见词拆分 | API 计费单位;中文约 1.5x 英文 token 数 |
| BPE 算法 | 频繁出现的字符对合并为一个 token | 解释为什么"strawberry"被拆成3个token |
| 上下文窗口 = token 数 | 128K token ≠ 128K 字符 | 长文档处理时用 token 数估算,不用字数 |
| 字符级任务天然困难 | 模型以 token 为单位感知,不是字符 | 数字母、反转字符串等任务易出错 |
| 中文比英文贵 | 中文 token 效率低于英文 | 成本估算时需要考虑语言差异 |