前言:大模型的"第一步"
你有没有想过,当你输入 "今天天气真好" 给 ChatGPT 时,它看到的是什么?
不是汉字,不是字母,而是一串数字:
"今天天气真好" → [36661, 34208, 37955, 30008]这个把文本变成数字的过程,就是 Tokenization(分词/标记化)。
看起来很简单对吧?其实这里面的学问大了去了:
- 为什么 "今天" 是一个 token,而 "天气" 也是一个 token?
- 为什么 GPT-4 算 token 数和 Claude 不一样?
- 为什么大模型数学不好?(剧透:和 Tokenization 有关)
- 为什么同样一句话,中文要用更多 token?
这篇文章,我们来彻底搞懂 Tokenization。
一、为什么需要 Tokenization?
1.1 模型只认识数字
神经网络的输入必须是数字(向量)。文本是离散的符号,必须转换成数字才能处理。
文本: "Hello"
↓ Tokenization
Token IDs: [15496]
↓ Embedding
向量: [0.023, -0.156, 0.892, ...] # 768 维
↓
输入到 Transformer1.2 词表大小的权衡
最直观的方案:每个词一个 ID。
vocab = {"今天": 0, "天气": 1, "真好": 2, "我": 3, "爱": 4, "学习": 5, ...}问题来了:词表会有多大?
- 英语单词:约 17 万(Webster 词典)
- 中文词语:约 50 万+(现代汉语词典 + 网络新词)
- 加上各种专业术语、人名、地名...
词表太大会导致:
- Embedding 层巨大:vocab_size × embed_dim,50 万词 × 768 维 = 3 亿参数(光 Embedding 层就这么大)
- 稀疏性问题:很多词出现频率极低,学不好
- OOV 问题:新词、拼写错误、专业术语 → 无法处理
1.3 OOV:未登录词问题
# 假设词表里没有 "ChatGPT"
text = "ChatGPT 很强大"
tokens = tokenize(text) # → ["[UNK]", "很", "强大"]所有不在词表里的词都变成 [UNK](Unknown),信息直接丢失了。
这在以前的 NLP 模型中是个大问题。
1.4 另一个极端:字符级别
那每个字符一个 ID 呢?
text = "Hello"
tokens = ["H", "e", "l", "l", "o"] # 5 个 token优点:
- 词表很小(英语只需要 ~100 个字符)
- 没有 OOV 问题
缺点:
- 序列太长:一个词要拆成好多字符
- 语义稀释:单个字符几乎没有语义
# 字符级别的问题
"Tokenization" → ["T", "o", "k", "e", "n", "i", "z", "a", "t", "i", "o", "n"]
# 12 个 token,模型需要从这些字符中"拼凑"出词的含义1.5 Subword:最佳平衡
现代的解决方案:Subword(子词)分词。
核心思想:
- 高频词:保持完整(如 "the", "今天")
- 低频词:拆成更小的单元(如 "unhappiness" → "un" + "happiness")
# Subword 分词示例
"Tokenization" → ["Token", "ization"] # 2 个 token
"unhappiness" → ["un", "happi", "ness"] # 3 个 token
"今天天气" → ["今天", "天气"] # 2 个 token
"深度求索" → ["深度", "求", "索"] # 3 个 token(如果"求索"不在词表)优点:
- 词表大小可控(通常 32K - 128K)
- 没有 OOV(任何词都可以拆成子词)
- 保留了一定的语义信息
这就是为什么叫 Token 而不是 Word——一个 Token 可能是一个词、一个子词、甚至一个字符。
二、BPE:字节对编码
BPE(Byte Pair Encoding) 是最流行的 Subword 分词算法,GPT 系列就用它。
2.1 算法原理
BPE 的思想很简单:不断合并出现频率最高的相邻字符/子词对。
初始词表: 所有单字符
重复以下步骤:
1. 统计所有相邻 token 对的出现频率
2. 找出频率最高的 token 对
3. 合并这个 token 对,加入词表
4. 直到达到目标词表大小2.2 BPE 训练过程示例
假设我们的训练语料是:
low low low low low
lower lower
newest newest newest newest newest newest
widest widest widestStep 0: 初始化
把每个词拆成字符,加上词尾标记 </w>:
l o w </w> : 5 次
l o w e r </w> : 2 次
n e w e s t </w>: 6 次
w i d e s t </w>: 3 次初始词表:{l, o, w, e, r, n, s, t, i, d, </w>}
Step 1: 第一次合并
统计相邻对频率:
(e, s): 6+3 = 9 次 ← 最高!
(s, t): 6+3 = 9 次
(l, o): 5+2 = 7 次
(o, w): 5+2 = 7 次
...合并 (e, s) → es:
l o w </w> : 5 次
l o w e r </w> : 2 次
n e w es t </w> : 6 次 ← e+s 变成 es
w i d es t </w> : 3 次词表:{l, o, w, e, r, n, s, t, i, d, </w>, es}
Step 2: 第二次合并
统计:
(es, t): 6+3 = 9 次 ← 最高!
(l, o): 7 次
...合并 (es, t) → est:
l o w </w> : 5 次
l o w e r </w> : 2 次
n e w est </w> : 6 次
w i d est </w> : 3 次词表:{..., es, est}
Step 3, 4, ...:继续合并
合并 (est, </w>) → est</w>
合并 (l, o) → lo
合并 (lo, w) → low
合并 (low, </w>) → low</w>
合并 (n, e) → ne
合并 (ne, w) → new
合并 (new, est</w>) → newest</w>
...最终,高频词如 "low"、"newest" 会变成单个 token,低频的部分保持拆分。
2.3 BPE 编码过程
训练好词表后,如何对新文本编码?
def bpe_encode(text, merges):
"""
BPE 编码
merges: 合并规则列表,按训练时的顺序
"""
# 初始化:拆成字符
tokens = list(text) + ['</w>']
# 按顺序应用合并规则
for (a, b), merged in merges:
i = 0
while i < len(tokens) - 1:
if tokens[i] == a and tokens[i+1] == b:
tokens = tokens[:i] + [merged] + tokens[i+2:]
else:
i += 1
return tokens
# 示例
merges = [
(('e', 's'), 'es'),
(('es', 't'), 'est'),
(('l', 'o'), 'lo'),
(('lo', 'w'), 'low'),
# ...
]
print(bpe_encode("lowest", merges))
# → ['low', 'est', '</w>'] 或类似2.4 手撕 BPE 训练
from collections import defaultdict
import re
def get_stats(vocab):
"""统计相邻 token 对的频率"""
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):
"""合并词表中的 token 对"""
new_vocab = {}
bigram = ' '.join(pair)
replacement = ''.join(pair)
for word, freq in vocab.items():
new_word = word.replace(bigram, replacement)
new_vocab[new_word] = freq
return new_vocab
def train_bpe(corpus, num_merges):
"""训练 BPE"""
# 初始化词表:每个词拆成字符
vocab = defaultdict(int)
for word in corpus:
# 加空格分隔字符,加词尾标记
word_with_end = ' '.join(list(word)) + ' </w>'
vocab[word_with_end] += 1
merges = []
for i in range(num_merges):
pairs = get_stats(vocab)
if not pairs:
break
# 找最高频的 pair
best_pair = max(pairs, key=pairs.get)
# 合并
vocab = merge_vocab(best_pair, vocab)
merges.append(best_pair)
print(f"Merge {i+1}: {best_pair} (freq: {pairs[best_pair]})")
return vocab, merges
# 训练示例
corpus = ['low'] * 5 + ['lower'] * 2 + ['newest'] * 6 + ['widest'] * 3
vocab, merges = train_bpe(corpus, num_merges=10)
print("\n最终词表:")
for word, freq in sorted(vocab.items(), key=lambda x: -x[1]):
print(f" {word}: {freq}")输出:
Merge 1: ('e', 's') (freq: 9)
Merge 2: ('es', 't') (freq: 9)
Merge 3: ('est', '</w>') (freq: 9)
Merge 4: ('l', 'o') (freq: 7)
Merge 5: ('lo', 'w') (freq: 7)
Merge 6: ('w', 'e') (freq: 6)
Merge 7: ('n', 'e') (freq: 6)
Merge 8: ('ne', 'we') (freq: 6)
Merge 9: ('newe', 'st</w>') (freq: 6)
Merge 10: ('low', '</w>') (freq: 5)
最终词表:
newest</w>: 6
low</w>: 5
w i d est</w>: 3
low e r </w>: 22.5 Byte-level BPE
GPT-2 开始使用 Byte-level BPE:不是在字符级别,而是在字节级别做 BPE。
# 字符级 BPE
text = "Hello 你好"
chars = ['H', 'e', 'l', 'l', 'o', ' ', '你', '好'] # 需要巨大的字符表支持中文
# 字节级 BPE
text = "Hello 你好"
bytes = [72, 101, 108, 108, 111, 32, 228, 189, 160, 229, 165, 189] # UTF-8 字节
# 基础词表只需要 256 个(0-255)优点:
- 基础词表固定 256 个(所有可能的字节)
- 天然支持任何语言、任何字符(emoji 也行 🚀)
- 不会有 OOV
缺点:
- 非 ASCII 字符会被拆成多个字节 token
- 中文每个字可能变成 2-3 个 token
三、WordPiece:BERT 的选择
3.1 与 BPE 的区别
WordPiece(由 Google 提出)和 BPE 很像,但合并策略不同:
- BPE:合并频率最高的 pair
- WordPiece:合并使语言模型困惑度下降最多的 pair
数学上,WordPiece 选择最大化以下分数:
$$ \text{score}(a, b) = \frac{\text{freq}(ab)}{\text{freq}(a) \times \text{freq}(b)} $$
这其实就是在看:$ab$ 一起出现的频率,是否高于它们独立出现的期望?
3.2 WordPiece 的特殊标记
WordPiece 用 ## 前缀表示"这不是词的开头":
# WordPiece 分词
"Tokenization" → ["Token", "##ization"]
"unhappiness" → ["un", "##hap", "##pi", "##ness"]这样在解码时,只需要去掉 ## 并拼接:
def decode_wordpiece(tokens):
text = ""
for token in tokens:
if token.startswith("##"):
text += token[2:] # 去掉 ##
else:
text += " " + token
return text.strip()
tokens = ["Token", "##ization", "is", "fun"]
print(decode_wordpiece(tokens)) # "Tokenization is fun"3.3 BERT Tokenizer 使用
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
text = "Tokenization is the first step of NLP."
tokens = tokenizer.tokenize(text)
print(f"Tokens: {tokens}")
# ['token', '##ization', 'is', 'the', 'first', 'step', 'of', 'nl', '##p', '.']
# 转换为 ID
input_ids = tokenizer.encode(text)
print(f"IDs: {input_ids}")
# [101, 19204, 3989, 2003, 1996, 2034, 3357, 1997, 17953, 2361, 1012, 102]
# 101 = [CLS], 102 = [SEP]
# 解码回文本
decoded = tokenizer.decode(input_ids)
print(f"Decoded: {decoded}")
# [CLS] tokenization is the first step of nlp. [SEP]四、SentencePiece:语言无关的分词
4.1 为什么需要 SentencePiece?
BPE 和 WordPiece 都有一个假设:文本已经被预分词(按空格切开)。
这对英语没问题,但对中文、日语等没有空格的语言就麻烦了:
英语: "I love NLP" → ["I", "love", "NLP"] → BPE
中文: "我爱自然语言处理" → ??? → BPESentencePiece 的解决方案:直接在原始文本上训练,不需要预分词。
4.2 SentencePiece 的特点
- 把空格当作普通字符:用特殊符号
▁(U+2581)表示空格
# SentencePiece
"I love NLP" → ["▁I", "▁love", "▁NL", "P"]
# ▁ 表示"这个 token 前面有空格"- 语言无关:不依赖任何语言特定的预处理
两种算法:
- BPE 模式(和 Byte-level BPE 类似)
- Unigram 模式(概率模型,下面介绍)
4.3 Unigram Language Model
SentencePiece 还支持 Unigram LM 算法,这是一种概率方法。
核心思想:
- 从一个很大的初始词表开始
- 计算每个 subword 的概率(出现频率)
- 去掉那些"贡献小"的 subword
- 重复直到词表达到目标大小
"贡献小"的定义:去掉这个 subword 后,语料的总概率下降最小。
# Unigram 的分词:找概率最大的切分方式
text = "unaffable"
# 所有可能的切分
option1: ["un", "aff", "able"] → P = P(un) × P(aff) × P(able)
option2: ["una", "ff", "able"] → P = P(una) × P(ff) × P(able)
option3: ["unaffable"] → P = P(unaffable)
...
# 选择概率最大的切分4.4 SentencePiece 实战
import sentencepiece as spm
# 训练
spm.SentencePieceTrainer.train(
input='corpus.txt', # 训练语料
model_prefix='my_tokenizer', # 输出文件前缀
vocab_size=32000, # 词表大小
model_type='bpe', # 'bpe' 或 'unigram'
character_coverage=0.9995, # 字符覆盖率
pad_id=0, # padding token id
unk_id=1, # unknown token id
bos_id=2, # begin of sentence id
eos_id=3, # end of sentence id
)
# 加载
sp = spm.SentencePieceProcessor()
sp.load('my_tokenizer.model')
# 编码
text = "今天天气真好"
tokens = sp.encode_as_pieces(text)
ids = sp.encode_as_ids(text)
print(f"Tokens: {tokens}") # ['▁今天', '天气', '真', '好']
print(f"IDs: {ids}") # [1234, 5678, 9012, 3456]
# 解码
decoded = sp.decode_pieces(tokens)
print(f"Decoded: {decoded}") # 今天天气真好4.5 主流大模型的 Tokenizer 选择
| 模型 | Tokenizer | 词表大小 | 特点 |
|---|---|---|---|
| GPT-2 | Byte-level BPE | 50,257 | 基于字节 |
| GPT-3/4 | tiktoken (BPE) | 100,277 | cl100k_base |
| BERT | WordPiece | 30,522 | ##前缀 |
| LLaMA | SentencePiece (BPE) | 32,000 | 语言无关 |
| LLaMA 2 | SentencePiece (BPE) | 32,000 | 同上 |
| Qwen | 自研 (BPE) | 151,936 | 大词表,中文友好 |
| Claude | BPE 变体 | ~100K | 具体未公开 |
| DeepSeek | SentencePiece | 100,000 | 中英平衡 |
五、中文分词的特殊挑战
5.1 没有天然分隔符
英语有空格,中文没有:
英语: "I love machine learning"
→ 自然切分: ["I", "love", "machine", "learning"]
中文: "我爱机器学习"
→ 怎么切?["我", "爱", "机器", "学习"]?
["我", "爱", "机器学习"]?
["我爱", "机器", "学习"]?5.2 字 vs 词 vs 子词
中文有三种粒度:
# 字粒度
"自然语言处理" → ["自", "然", "语", "言", "处", "理"] # 6 个 token
# 词粒度
"自然语言处理" → ["自然", "语言", "处理"] # 3 个 token
# 子词粒度(BPE/SentencePiece)
"自然语言处理" → ["自然", "语言", "处理"] # 3 个 token(如果这些词频足够高)
→ ["自然", "语", "言", "处理"] # 4 个 token(如果"语言"拆开了)5.3 Token 效率问题
由于大多数大模型的词表是英语主导的,中文的 token 效率通常较低:
from transformers import AutoTokenizer
# GPT-2 tokenizer
gpt2_tok = AutoTokenizer.from_pretrained("gpt2")
# 比较 token 数量
en_text = "Machine learning is amazing."
zh_text = "机器学习非常神奇。"
print(f"英文: {len(gpt2_tok.encode(en_text))} tokens") # 5 tokens
print(f"中文: {len(gpt2_tok.encode(zh_text))} tokens") # 12 tokens
# 同样的语义,中文需要更多 token!这导致:
- 成本更高:API 按 token 计费,中文更贵
- 上下文更短:同样的 token 限制,中文能输入的内容更少
5.4 各家模型的中文优化
# 对比不同模型的中文 token 效率
from transformers import AutoTokenizer
text = "大型语言模型正在改变人工智能的未来发展方向。"
tokenizers = {
"GPT-2": "gpt2",
"LLaMA": "meta-llama/Llama-2-7b-hf",
"Qwen": "Qwen/Qwen-7B",
"ChatGLM": "THUDM/chatglm3-6b",
}
for name, model_name in tokenizers.items():
try:
tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokens = tok.encode(text)
print(f"{name:10s}: {len(tokens):3d} tokens | {tok.convert_ids_to_tokens(tokens)[:5]}...")
except:
print(f"{name:10s}: 无法加载")
# 大概结果:
# GPT-2 : 25+ tokens(对中文很不友好)
# LLaMA : 15+ tokens
# Qwen : 12 tokens(专门优化了中文)
# ChatGLM : 12 tokens(专门优化了中文)5.5 为什么 Qwen 中文效果好?
Qwen(通义千问)的一个重要优化就是 词表设计:
- 大词表:151,936 个 token(GPT-2 只有 50,257)
- 中文优先:词表中包含大量中文词语
- 更多完整中文词:减少拆分
# Qwen 的分词效果
text = "大型语言模型"
# GPT-2: ["大", "型", "语", "言", "模", "型"] # 拆成单字
# Qwen: ["大型", "语言", "模型"] # 保持完整词六、Tokenization 的坑
6.1 数字处理问题
这是个著名的问题:大模型数学不好,Tokenization 要背一半锅。
from transformers import GPT2Tokenizer
tok = GPT2Tokenizer.from_pretrained("gpt2")
# 数字的分词
print(tok.tokenize("123456789"))
# → ['123', '456', '789'] ← 被随机切开了!
print(tok.tokenize("1000"))
# → ['1000'] ← 这个是完整的
print(tok.tokenize("1001"))
# → ['100', '1'] ← 又切开了
print(tok.tokenize("42"))
# → ['42'] ← 完整
print(tok.tokenize("43"))
# → ['43'] ← 完整
print(tok.tokenize("8723"))
# → ['87', '23'] ← 切开了问题:
- 同一个数字,切法不一致
- 模型很难学习到数字的"值"
- 加减乘除变得困难(不同的数字有不同的 token 表示)
解决方案:
- 一些模型会把每个数字单独作为 token
- 或者用特殊的数字编码方式
6.2 空格和特殊字符
# 空格的处理
tok.tokenize("Hello World") # → ['Hello', 'ĠWorld']
tok.tokenize("Hello World") # → ['Hello', 'ĠĠWorld'] # 两个空格
# Ġ 是 GPT-2 用来表示"前面有空格"的特殊字符这会导致:
- "Hello World" 和 "HelloWorld" 的 token 不同
- 多个空格会产生奇怪的 token
- 代码缩进可能被奇怪地处理
6.3 大小写问题
# BERT 的 uncased 模型
bert_tok = BertTokenizer.from_pretrained("bert-base-uncased")
print(bert_tok.tokenize("Hello WORLD"))
# → ['hello', 'world'] # 全变小写了!
# BERT 的 cased 模型
bert_tok = BertTokenizer.from_pretrained("bert-base-cased")
print(bert_tok.tokenize("Hello WORLD"))
# → ['Hello', 'WORLD'] # 保留大小写对于需要区分大小写的任务(如命名实体识别),要用 cased 模型。
6.4 编程语言的处理
代码有自己的特殊性:
# Python 代码的分词
code = "def hello_world():\n print('Hello')"
tok.tokenize(code)
# 可能的结果:
# ['def', 'Ġhello', '_', 'world', '():', 'Ċ', 'Ġ', 'Ġ', 'Ġ', 'Ġprint', "('", 'Hello', "')"]
# Ċ 表示换行符
# 缩进被切成多个空格 token代码专用模型(如 CodeLlama, StarCoder)通常会:
- 在代码上训练 tokenizer
- 特殊处理缩进(把 4 个空格作为一个 token)
- 更好地处理标识符命名
6.5 多语言混合
# 中英混合
text = "我正在学习Machine Learning"
gpt2_tok.tokenize(text)
# 中文部分会被切成很多小块
# 英文部分相对正常七、tiktoken:OpenAI 的高效实现
OpenAI 开源了他们的 tokenizer 库:tiktoken。
7.1 基本使用
import tiktoken
# GPT-4 使用的 tokenizer
enc = tiktoken.encoding_for_model("gpt-4")
# 或者直接指定 encoding
enc = tiktoken.get_encoding("cl100k_base")
# 编码
text = "Hello, world! 你好,世界!"
tokens = enc.encode(text)
print(f"Tokens: {tokens}")
print(f"Token count: {len(tokens)}")
# 解码
decoded = enc.decode(tokens)
print(f"Decoded: {decoded}")
# 查看每个 token
for token_id in tokens:
token_bytes = enc.decode_single_token_bytes(token_id)
print(f"{token_id:6d} → {token_bytes}")7.2 不同模型的 Encoding
import tiktoken
# 不同模型使用的 encoding
encodings = {
"gpt-3.5-turbo": "cl100k_base",
"gpt-4": "cl100k_base",
"gpt-4o": "o200k_base", # 更新的 encoding
"text-embedding-ada-002": "cl100k_base",
"gpt-2": "gpt2",
"davinci": "p50k_base",
}
text = "Machine learning is transforming the world. 机器学习正在改变世界。"
for model, encoding_name in encodings.items():
enc = tiktoken.get_encoding(encoding_name)
tokens = enc.encode(text)
print(f"{model:30s} ({encoding_name:15s}): {len(tokens):3d} tokens")7.3 计算 Token 数量(估算 API 成本)
import tiktoken
def count_tokens(text, model="gpt-4"):
"""计算文本的 token 数量"""
enc = tiktoken.encoding_for_model(model)
return len(enc.encode(text))
def estimate_cost(text, model="gpt-4", is_input=True):
"""估算 API 成本"""
tokens = count_tokens(text, model)
# GPT-4 定价(2024年,可能已变化)
prices = {
"gpt-4": {"input": 0.03, "output": 0.06}, # per 1K tokens
"gpt-4-turbo": {"input": 0.01, "output": 0.03},
"gpt-3.5-turbo": {"input": 0.0005, "output": 0.0015},
}
price_type = "input" if is_input else "output"
price_per_1k = prices.get(model, {}).get(price_type, 0)
cost = (tokens / 1000) * price_per_1k
return tokens, cost
# 使用
text = open("my_document.txt").read()
tokens, cost = estimate_cost(text)
print(f"Token count: {tokens}")
print(f"Estimated cost: ${cost:.4f}")八、实战:构建自己的 Tokenizer
8.1 使用 HuggingFace tokenizers 库
from tokenizers import Tokenizer, models, trainers, pre_tokenizers, decoders
def train_custom_bpe_tokenizer(corpus_files, vocab_size=32000, output_path="my_tokenizer"):
"""训练自定义 BPE tokenizer"""
# 初始化 BPE 模型
tokenizer = Tokenizer(models.BPE(unk_token="[UNK]"))
# 预处理器:按空格和标点切分
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
# 训练器配置
trainer = trainers.BpeTrainer(
vocab_size=vocab_size,
special_tokens=["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"],
min_frequency=2,
show_progress=True,
)
# 训练
tokenizer.train(files=corpus_files, trainer=trainer)
# 解码器
tokenizer.decoder = decoders.ByteLevel()
# 保存
tokenizer.save(f"{output_path}.json")
return tokenizer
def test_tokenizer(tokenizer, texts):
"""测试 tokenizer"""
for text in texts:
output = tokenizer.encode(text)
print(f"\n文本: {text}")
print(f"Tokens: {output.tokens}")
print(f"IDs: {output.ids}")
print(f"解码: {tokenizer.decode(output.ids)}")
# 使用
if __name__ == "__main__":
# 假设有训练语料
corpus_files = ["corpus_zh.txt", "corpus_en.txt"]
# 训练
tokenizer = train_custom_bpe_tokenizer(corpus_files, vocab_size=50000)
# 测试
test_texts = [
"今天天气真好",
"Machine learning is fun",
"我正在学习 Transformer 架构",
]
test_tokenizer(tokenizer, test_texts)8.2 词表分析工具
import json
from collections import Counter
def analyze_tokenizer(tokenizer_path):
"""分析 tokenizer 的词表"""
with open(tokenizer_path, 'r', encoding='utf-8') as f:
tokenizer_data = json.load(f)
vocab = tokenizer_data.get('model', {}).get('vocab', {})
print(f"词表大小: {len(vocab)}")
# 分析 token 长度分布
lengths = [len(token) for token in vocab.keys()]
length_dist = Counter(lengths)
print("\nToken 长度分布:")
for length in sorted(length_dist.keys())[:10]:
print(f" 长度 {length}: {length_dist[length]} 个")
# 分析字符类型
chinese_tokens = [t for t in vocab.keys() if any('\u4e00' <= c <= '\u9fff' for c in t)]
english_tokens = [t for t in vocab.keys() if t.isascii() and t.isalpha()]
digit_tokens = [t for t in vocab.keys() if any(c.isdigit() for c in t)]
print(f"\n中文相关 token: {len(chinese_tokens)}")
print(f"纯英文 token: {len(english_tokens)}")
print(f"包含数字的 token: {len(digit_tokens)}")
# 展示一些示例
print("\n中文 token 示例:", chinese_tokens[:20])
print("英文 token 示例:", english_tokens[:20])
# 使用
# analyze_tokenizer("my_tokenizer.json")8.3 Token 效率对比工具
def compare_tokenizers(text, tokenizers_dict):
"""对比不同 tokenizer 的效率"""
print(f"测试文本: {text[:100]}..." if len(text) > 100 else f"测试文本: {text}")
print(f"文本长度: {len(text)} 字符\n")
results = []
for name, tokenizer in tokenizers_dict.items():
if hasattr(tokenizer, 'encode'):
# HuggingFace tokenizer
tokens = tokenizer.encode(text)
if hasattr(tokens, 'ids'):
token_count = len(tokens.ids)
else:
token_count = len(tokens)
else:
# tiktoken
token_count = len(tokenizer.encode(text))
efficiency = len(text) / token_count # 字符/token 比
results.append((name, token_count, efficiency))
# 排序(按 token 数量升序,数量少的效率高)
results.sort(key=lambda x: x[1])
print(f"{'Tokenizer':<20} {'Token数':<10} {'字符/Token比':<15}")
print("-" * 45)
for name, count, eff in results:
print(f"{name:<20} {count:<10} {eff:.2f}")
return results
# 使用示例
import tiktoken
from transformers import AutoTokenizer
tokenizers = {
"GPT-4 (tiktoken)": tiktoken.encoding_for_model("gpt-4"),
"GPT-4o (tiktoken)": tiktoken.get_encoding("o200k_base"),
}
# 添加 HuggingFace tokenizers
try:
tokenizers["LLaMA-2"] = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
except:
pass
try:
tokenizers["Qwen"] = AutoTokenizer.from_pretrained("Qwen/Qwen-7B", trust_remote_code=True)
except:
pass
# 测试
test_text = """
人工智能(Artificial Intelligence,简称AI)是计算机科学的一个分支,
它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。
Machine learning is a subset of AI that enables systems to learn and improve from experience.
"""
compare_tokenizers(test_text, tokenizers)九、总结
核心概念回顾
关键 Takeaway
- Tokenization 是大模型的第一步:把文本变成模型能理解的数字
- Subword 是现代主流:平衡词表大小和语义完整性
- BPE 最常用:GPT 系列、LLaMA 都用它
- 中文需要特别关注:词表设计决定了 token 效率
- 数字处理是个坑:这是大模型数学能力弱的原因之一
- 不同模型 tokenizer 不同:同样的文本,token 数量可能差很多
Tokenizer 选择建议
| 场景 | 推荐 | 原因 |
|---|---|---|
| 英语为主 | GPT 系列 tokenizer | 成熟稳定 |
| 中文为主 | Qwen / ChatGLM tokenizer | 中文优化 |
| 多语言 | SentencePiece | 语言无关 |
| 代码 | CodeLlama tokenizer | 代码优化 |
| 自定义 | HuggingFace tokenizers 库 | 灵活可控 |
下一步学习
- [ ] 预训练:如何"喂"出一个大模型
- [ ] Embedding:从 token 到向量
- [ ] 位置编码:让模型理解顺序
参考资料
- BPE 原论文 - Neural Machine Translation of Rare Words with Subword Units
- SentencePiece - Google 开源实现
- tiktoken - OpenAI 的高效 tokenizer
- HuggingFace tokenizers - 快速 tokenizer 库
- The Tokenizer Playground - OpenAI 可视化工具