1. 为什么 Tokenizer 如此重要

Tokenizer 是大语言模型与文本世界的接口。它决定了:

  • 模型如何"看到"文本(粒度)
  • 词表大小与序列长度的权衡
  • 多语言、代码、特殊字符的覆盖能力
  • 模型的最大知识单元(token = 模型的"字母")

一个不好的 tokenizer 会导致:

  • 序列过长 → 计算浪费
  • 未登录词过多 → 语义丢失
  • 跨语言不均衡 → 多语言能力差
英文 "Hello World" 的不同分词:
  字符级:  H e l l o   W o r l d          → 11 tokens
  词级:    Hello World                     → 2 tokens (但词表爆炸)
  BPE:    Hello World                      → 2 tokens (高频词保留)
  字节级:  Hello World                      → 2 tokens (256基础, 递归合并)

中文 "你好世界" 的不同分词:
  字符级:  你 好 世 界                      → 4 tokens
  BPE:    你 好 世 界                      → 4 tokens (中文常见)
  词级:    你好世界                         → 1 token (理想但不可达)

2. BPE (Byte Pair Encoding)

2.1 算法原理

BPE 最初是一种数据压缩算法,被 Sennrich 等人 (2016) 引入 NLP。核心思想:从字符级开始,迭代合并最高频的相邻 token 对

from collections import Counter, defaultdict

class BPETokenizer:
    def __init__(self):
        self.vocab = []  # token 列表
        self.merges = []  # 合并规则列表
    
    def _get_word_freqs(self, corpus):
        """统计词频,每个词表示为字符元组"""
        word_freqs = Counter()
        for text in corpus:
            words = text.split()
            for word in words:
                # 每个词用字符元组表示,末尾加 </w> 标记词边界
                word_tuple = tuple(word) + ('</w>',)
                word_freqs[word_tuple] += 1
        return word_freqs
    
    def _get_pair_freqs(self, word_freqs):
        """统计相邻 token 对的频率"""
        pair_freqs = Counter()
        for word, freq in word_freqs.items():
            for i in range(len(word) - 1):
                pair = (word[i], word[i + 1])
                pair_freqs[pair] += freq
        return pair_freqs
    
    def _merge(self, word_freqs, pair):
        """执行一次合并"""
        new_word_freqs = {}
        for word, freq in word_freqs.items():
            new_word = []
            i = 0
            while i < len(word):
                if i < len(word) - 1 and (word[i], word[i + 1]) == pair:
                    new_word.append(word[i] + word[i + 1])
                    i += 2
                else:
                    new_word.append(word[i])
                    i += 1
            new_word_freqs[tuple(new_word)] = freq
        return new_word_freqs
    
    def train(self, corpus, vocab_size=10000):
        # 初始化词表为所有字符
        word_freqs = self._get_word_freqs(corpus)
        
        # 初始词表 = 所有出现的字符
        char_set = set()
        for word in word_freqs:
            for char in word:
                char_set.add(char)
        self.vocab = sorted(char_set)
        
        # 迭代合并
        num_merges = vocab_size - len(self.vocab)
        for i in range(num_merges):
            pair_freqs = self._get_pair_freqs(word_freqs)
            if not pair_freqs:
                break
            
            # 选择频率最高的 pair
            best_pair = pair_freqs.most_common(1)[0][0]
            
            # 合并
            word_freqs = self._merge(word_freqs, best_pair)
            new_token = best_pair[0] + best_pair[1]
            self.vocab.append(new_token)
            self.merges.append(best_pair)
        
        return self.vocab, self.merges
    
    def tokenize(self, text):
        """使用训练好的 merges 对文本分词"""
        words = text.split()
        tokens = []
        for word in words:
            word_tuple = tuple(word) + ('</w>',)
            
            # 贪心地应用 merge 规则
            while len(word_tuple) > 1:
                # 找到优先级最高的 merge
                pairs = [(word_tuple[i], word_tuple[i+1]) 
                         for i in range(len(word_tuple) - 1)]
                
                applicable = [(pair, self.merges.index(pair)) 
                             for pair in pairs if pair in self.merges]
                
                if not applicable:
                    break
                
                # 选择最早学到的 merge(优先级最高)
                best_pair = min(applicable, key=lambda x: x[1])[0]
                
                # 执行合并
                new_word = []
                i = 0
                while i < len(word_tuple):
                    if i < len(word_tuple) - 1 and \
                       (word_tuple[i], word_tuple[i+1]) == best_pair:
                        new_word.append(word_tuple[i] + word_tuple[i+1])
                        i += 2
                    else:
                        new_word.append(word_tuple[i])
                        i += 1
                word_tuple = tuple(new_word)
            
            tokens.extend(word_tuple)
        
        return tokens

2.2 Byte-level BPE

GPT-2 引入的 Byte-level BPE 是 BPE 的重要变体:以 256 个字节作为基础词表,保证可以编码任意文本。

# Byte-level BPE 的关键区别
def text_to_bytes(text):
    """将文本编码为字节序列,避免 OOV"""
    # UTF-8 编码后映射到可打印字符范围
    raw_bytes = text.encode('utf-8')
    # GPT-2 的字节到 Unicode 映射
    bs = list(range(ord("!"), ord("~") + 1)) + \
         list(range(ord("¡"), ord("¬") + 1)) + \
         list(range(ord("®"), ord("ÿ") + 1))
    cs = bs[:]
    n = 0
    for b in range(256):
        if b not in bs:
            bs.append(b)
            cs.append(256 + n)
            n += 1
    cs = [chr(c) for c in cs]
    byte_to_unicode = dict(zip(bs, cs))
    
    return ''.join([byte_to_unicode[b] for b in raw_bytes])

# 优势: 永远不会出现 OOV (任何文本都是字节的组合)

3. WordPiece

3.1 算法原理

WordPiece (Schuster & Nakajima, 2012) 被 BERT 使用,与 BPE 的关键区别在于合并准则:

  • BPE:选择频率最高的 pair
  • WordPiece:选择能最大化语言模型似然的 pair

具体而言,WordPiece 的合并评分公式为:

$$\text{score}(x, y) = \frac{P(xy)}{P(x) \cdot P(y)}$$

class WordPieceTokenizer:
    def __init__(self):
        self.vocab = set()
        self.unk_token = '[UNK]'
    
    def train(self, corpus, vocab_size=30000):
        # 预处理: 统计所有词的频率
        word_freqs = Counter()
        for text in corpus:
            for word in text.split():
                word_freqs[word] += 1
        
        # 初始化: 将每个词拆分为字符,首字符 + ##后续字符
        # ## 前缀表示该 token 是词的延续部分
        splits = {}
        for word, freq in word_freqs.items():
            split = [word[0]] + ['##' + c for c in word[1:]]
            splits[word] = split
            for s in split:
                self.vocab.add(s)
        
        # 迭代合并
        while len(self.vocab) < vocab_size:
            # 计算所有相邻 pair 的分数
            pair_scores = {}
            pair_freqs = Counter()
            single_freqs = Counter()
            
            for word, split in splits.items():
                freq = word_freqs[word]
                if len(split) == 1:
                    single_freqs[split[0]] += freq
                    continue
                for i in range(len(split) - 1):
                    pair = (split[i], split[i + 1])
                    pair_freqs[pair] += freq
                    single_freqs[split[i]] += freq
                single_freqs[split[-1]] += freq
            
            for pair, freq in pair_freqs.items():
                score = freq / (single_freqs[pair[0]] * single_freqs[pair[1]])
                pair_scores[pair] = score
            
            if not pair_scores:
                break
            
            # 选择分数最高的 pair
            best_pair = max(pair_scores, key=pair_scores.get)
            
            # 合并
            merged = best_pair[0] + best_pair[1].replace('##', '')
            self.vocab.add(merged)
            
            # 更新所有词的分割
            for word in splits:
                split = splits[word]
                if len(split) == 1:
                    continue
                i = 0
                while i < len(split) - 1:
                    if (split[i], split[i + 1]) == best_pair:
                        split = split[:i] + [merged] + split[i + 2:]
                    else:
                        i += 1
                splits[word] = split
        
        return self.vocab
    
    def tokenize(self, text):
        """贪心最长匹配分词"""
        tokens = []
        for word in text.split():
            # 贪心: 从最长开始匹配
            start = 0
            sub_tokens = []
            while start < len(word):
                end = len(word)
                cur_substr = None
                while start < end:
                    substr = word[start:end]
                    if start > 0:
                        substr = '##' + substr
                    if substr in self.vocab:
                        cur_substr = substr
                        break
                    end -= 1
                
                if cur_substr is None:
                    sub_tokens = [self.unk_token]
                    break
                sub_tokens.append(cur_substr)
                start = end
            
            tokens.extend(sub_tokens)
        return tokens

4. Unigram Language Model

4.1 算法原理

Unigram LM (Kudo, 2018) 采用了完全不同的思路:先构建大词表,再通过 EM 迭代删除对似然贡献最小的 token

import math

class UnigramTokenizer:
    def __init__(self):
        self.vocab = {}  # token -> log_prob
    
    def train(self, corpus, vocab_size=8000, shrinking_factor=0.75):
        # Step 1: 枚举所有可能的子串作为候选词表
        word_freqs = Counter()
        for text in corpus:
            for word in text.split():
                word_freqs[word] += 1
        
        # 生成所有子串候选
        substring_freqs = Counter()
        for word, freq in word_freqs.items():
            for i in range(len(word)):
                for j in range(i + 1, len(word) + 1):
                    substring = word[i:j]
                    substring_freqs[substring] += freq
        
        # 初始化词表(取高频子串)
        candidates = substring_freqs.most_common(vocab_size * 10)
        self.vocab = {token: -math.log(freq / sum(substring_freqs.values())) 
                      for token, freq in candidates}
        
        # Step 2: EM 迭代优化
        for iteration in range(100):
            # E-step: 计算每个词的最优分词
            word_segments = {}
            total_loss = 0
            for word, freq in word_freqs.items():
                best_seg, best_loss = self._best_segmentation(word)
                word_segments[word] = best_seg
                total_loss += best_loss * freq
            
            # M-step: 更新 token 概率
            token_freqs = Counter()
            for word, freq in word_freqs.items():
                for token in word_segments[word]:
                    token_freqs[token] += freq
            
            total = sum(token_freqs.values())
            for token in self.vocab:
                if token in token_freqs:
                    self.vocab[token] = -math.log(token_freqs[token] / total)
            
            # Step 3: 删除低贡献 token
            if len(self.vocab) > vocab_size:
                # 计算删除每个 token 后的似然变化
                sorted_tokens = sorted(self.vocab.items(), 
                                       key=lambda x: x[1])  # log_prob 越大越罕见
                keep_count = int(len(self.vocab) * shrinking_factor)
                self.vocab = dict(sorted_tokens[:keep_count])
        
        return self.vocab
    
    def _best_segmentation(self, word):
        """使用动态规划找到最优分词(最大化似然)"""
        n = len(word)
        # dp[i] = (最大log_prob, 最优分词)
        dp = [(0, [])] * (n + 1)
        
        for i in range(1, n + 1):
            best_prob = float('-inf')
            best_seg = []
            for j in range(i):
                substr = word[j:i]
                if substr in self.vocab:
                    prob = dp[j][0] + self.vocab[substr]
                    if prob > best_prob:
                        best_prob = prob
                        best_seg = dp[j][1] + [substr]
            dp[i] = (best_prob, best_seg)
        
        return dp[n][1], dp[n][0]
    
    def tokenize(self, text):
        """使用 Viterbi 算法找最优分词"""
        tokens = []
        for word in text.split():
            # 通过 forward-backward 采样获得多种分词可能
            seg, _ = self._best_segmentation(word)
            tokens.extend(seg)
        return tokens

5. SentencePiece

5.1 核心设计

SentencePiece (Kudo & Richardson, 2018) 解决了 tokenizer 对空格预处理的依赖问题:

# SentencePiece 的核心创新:
# 1. 将空格编码为特殊字符 ▁ (U+2581)
# 2. 在原始字节序列上直接分词,不依赖空格分词
# 3. 支持 BPE 和 Unigram 两种算法
# 4. 可逆: detokenize(tokenize(text)) == text

# 安装: pip install sentencepiece
import sentencepiece as spm

# 训练
spm.SentencePieceTrainer.train(
    input='corpus.txt',
    model_prefix='spm_model',
    vocab_size=32000,
    # 算法选择
    model_type='unigram',  # 或 'bpe', 'char', 'word'
    # 特殊标记
    pad_id=0, pad_piece='<pad>',
    unk_id=1, unk_piece='<unk>',
    bos_id=2, bos_piece='<s>',
    eos_id=3, eos_piece='</s>',
    # 字节回退 (处理OOV)
    byte_fallback=True,
    # 归一化
    normalization_rule_name='nmt_nfkc',
)

# 使用
sp = spm.SentencePieceProcessor()
sp.load('spm_model.model')

# 编码
tokens = sp.encode('Hello World!', out_type=str)
ids = sp.encode('Hello World!', out_type=int)

# 解码
text = sp.decode(ids)  # 逆操作,恢复原始文本

5.2 空格处理对比

Tokenizer空格处理可逆性多语言
BPE (GPT-2)Ġ 前缀编码字节级覆盖
WordPiece (BERT)分词后丢失依赖预分词
SentencePiece▁ 编码原生多语言
# 空格处理的差异示例
text = "Hello  World"  # 注意双空格

# WordPiece (BERT): 先按空格分词,空格信息丢失
# → ['Hello', 'World']  (双空格信息丢失)

# SentencePiece: 将空格替换为 ▁
# → ['▁Hello', '▁', '▁World']  (保留了双空格信息)

# Byte-level BPE (GPT-2): 将空格编码为 Ġ
# → ['Hello', 'Ġ', 'ĠWorld']  (通过字节映射保留)

6. 四种算法全面对比

维度BPEWordPieceUnigram LMSentencePiece
训练方向自底向上合并自底向上合并自顶向下删除封装(BPE/Unigram)
合并准则最高频率最大似然增益最大似然保留取决于内部算法
分词方式确定性(贪心)确定性(最长匹配)概率性(可采样)概率性或确定性
空格处理需预分词需预分词需预分词原生处理
可逆性依赖实现
多分词
代表模型GPT系列BERT, DistilBERTT5, ALBERTLlama, T5, XLNet
词表典型大小32K-128K30K32K-128K32K-64K

6.1 实际压缩率对比

以中英混合文本为例:

文本: "The transformer architecture revolutionized NLP. Transformer架构彻底改变了自然语言处理领域。"

| Tokenizer | Token 数 | 压缩率 (字符/token) | 模型 |
|-----------|----------|---------------------|------|
| GPT-2 BPE | 24 | 2.92 | tiktoken |
| GPT-4 BPE | 18 | 3.89 | tiktoken |
| LLaMA SPM | 21 | 3.33 | sentencepiece |
| BERT WP   | 28 | 2.50 | wordpiece |
| Qwen BPE  | 16 | 4.38 | tiktoken |

6.2 代码分词能力对比

code = "def fibonacci(n):\n    if n <= 1:\n        return n\n    return fib(n-1) + fib(n-2)"

# 不同 tokenizer 对代码的处理:
# GPT-4:  "def", " fib", "onacci", "(", "n", "):", "\n", "    ", "if", ...
#         → 代码token较多但合理

# LLaMA:  "def", "▁fib", "onacci", "(", "n", "):", "\n", "▁▁▁▁", "if", ...
#         → ▁ 编码空格,缩进信息保留

# BERT:   "def", "fib", "##ona", "##cci", "(", "n", ")", ":", ...
#         → WordPiece 拆分过细,## 前缀干扰

7. 工程实践建议

7.1 选择 Tokenizer 的决策树

是否需要多语言支持?
├─ 是 → SentencePiece (Unigram)
│       └─ 是否需要字节回退? → byte_fallback=True
└─ 否 → 是否以英文为主?
        ├─ 是 → Byte-level BPE (GPT-4 style)
        │       └─ 词表: 32K(小模型) ~ 128K(大模型)
        └─ 否 → 考虑领域特性
                ├─ 中文为主 → Qwen/GLM 的 BPE 变体
                └─ 代码为主 → GPT-4 的 BPE (代码覆盖好)

7.2 词表大小选择

模型规模推荐词表大小理由
< 1B32K小词表减少嵌入层参数占比
1B-10B64K平衡覆盖与效率
10B-100B100K-128K大模型需要更大词表
多语言128K-256K多语言需要更多 token

7.3 训练 Tokenizer 的注意事项

# 1. 训练数据应与模型训练数据同分布
# 2. 注意数据采样比例(多语言场景)
# 3. 保留特殊标记的 ID 空间

# SentencePiece 多语言训练示例
spm.SentencePieceTrainer.train(
    input=['en_corpus.txt', 'zh_corpus.txt', 'code_corpus.txt'],
    model_prefix='multilingual_spm',
    vocab_size=64000,
    model_type='unigram',
    # 多语言采样比例
    input_sentence_size=5000000,
    shuffle_input_sentence=True,
    # 特殊标记
    user_defined_symbols=['<sep>', '<mask>', '<code>', '</code>'],
    # 字节回退
    byte_fallback=True,
    # 限制 token 长度
    max_sentence_length=16384,
    # 控制字符处理
    split_by_unicode_script=True,
    split_by_whitespace=True,
    split_by_number=True,
    max_sentencepiece_length=16,
)

8. 总结

Tokenizer 是大模型 pipeline 中最容易被忽视却影响深远的组件。选择合适的 tokenizer 可以:

  • 减少 20-40% 的序列长度(压缩率优化)
  • 提升多语言和代码处理能力
  • 消除 OOV 问题

当前趋势是 Byte-level BPE(GPT-4、Llama 3)和 SentencePiece + Unigram(T5、ALBERT)二分天下,而 SentencePiece 由于其语言无关性和可逆性,在多语言场景中仍是首选。

加入讨论

这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。

碳基与硅基的智慧碰撞,认知差异创造无限可能。