1. 为什么 Tokenization 至关重要

LLM 不能直接处理文本,必须将文本切分为离散的 Token 序列,再将每个 Token 映射为向量。Tokenizer 直接影响:

  • 词汇表大小:影响 Embedding 层参数量和 Softmax 计算量
  • 序列长度:同一段文本,不同 Tokenizer 产生的 Token 数不同,影响上下文窗口利用率
  • 多语言公平性:中文一个字 vs 英文一个 word,Tokenizer 决定了编码效率
  • OOV 问题:未知 Token 的处理能力

2. BPE (Byte Pair Encoding)

2.1 算法思想

BPE 最初是数据压缩算法,被 Sennrich 等人(2016)引入 NLP。核心思路:从一个字符级词汇表开始,反复合并出现频率最高的相邻 Token 对。

2.2 训练过程

输入语料: {"low": 5, "lower": 2, "newest": 6, "widest": 3}

步骤 1: 拆分为字符序列
  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

步骤 2: 统计相邻 Token 对频率
  (l, o): 7, (o, w): 7, (e, r): 2, (n, e): 6, ...

步骤 3: 合并最高频对 (l, o) → "lo"
  lo w </w> : 5
  lo w e r </w> : 2
  n e w e s t </w> : 6
  w i d e s t </w> : 3

重复直到词汇表达到目标大小

2.3 编码过程

对新文本,按训练时的合并规则顺序应用:

import re
from collections import Counter, defaultdict

class BPETokenizer:
    def __init__(self):
        self.merges = []  # 训练得到的合并规则序列
        self.vocab = set()
    
    def train(self, corpus, vocab_size=1000):
        # 初始化:将每个词拆分为字符
        word_freqs = Counter()
        for text in corpus:
            for word in text.split():
                word_freqs[' '.join(list(word)) + ' </w>'] += 1
        
        # 转为字符级表示
        splits = {word: word.split() for word in word_freqs}
        
        while len(self.vocab) < vocab_size:
            # 统计相邻 pair 频率
            pair_freqs = defaultdict(int)
            for word, freq in word_freqs.items():
                symbols = splits[word]
                for i in range(len(symbols) - 1):
                    pair_freqs[(symbols[i], symbols[i+1])] += freq
            
            if not pair_freqs:
                break
            
            # 合并最高频 pair
            best_pair = max(pair_freqs, key=pair_freqs.get)
            self.merges.append(best_pair)
            new_token = best_pair[0] + best_pair[1]
            self.vocab.add(new_token)
            
            # 更新所有 splits
            for word in word_freqs:
                splits[word] = self._merge_pair(splits[word], best_pair, new_token)
    
    def _merge_pair(self, symbols, pair, new_token):
        i = 0
        result = []
        while i < len(symbols):
            if i < len(symbols) - 1 and (symbols[i], symbols[i+1]) == pair:
                result.append(new_token)
                i += 2
            else:
                result.append(symbols[i])
                i += 1
        return result
    
    def encode(self, text):
        # 对新文本按 merges 顺序逐步合并
        symbols = list(text) + ['</w>']
        for pair, new_token in [(m, m[0]+m[1]) for m in self.merges]:
            symbols = self._merge_pair(symbols, pair, new_token)
        return symbols

2.4 BBPE (Byte-level BPE)

GPT-2/3/4 使用 Byte-level BPE:先 将文本编码为 UTF-8 字节序列(256 个基础 Token),再在字节上做 BPE。好处是天然支持所有语言和符号,无 OOV 问题

3. WordPiece

3.1 与 BPE 的区别

WordPiece(Google,用于 BERT)与 BPE 类似,但合并准则不同:

  • BPE 选择频率最高的 pair
  • WordPiece 选择似然增益最大的 pair

合并 pair $(A, B)$ 的评分:

$$ \text{score}(A, B) = \frac{P(AB)}{P(A) \cdot P(B)} $$

这等价于 PMI(Pointwise Mutual Information),选择共现概率远高于独立出现概率的 pair。

3.2 编码差异

WordPiece 编码时使用最长前缀匹配(贪婪左到右),而非 BPE 的按合并规则顺序应用:

词汇表: ["play", "##ing", "##ed", "un", "##believ", "##able"]

输入: "unbelievable"
→ "un" + "##believ" + "##able"

## 前缀表示该 Token 是词的中间/结尾部分,不能出现在词首。

4. Unigram Language Model

4.1 核心思想

Unigram LM(SentencePiece 的一种模式)采用概率生成模型视角:假设每个 Token 独立生成,选择使语料似然最大的词汇表。

4.2 训练过程

  1. 用所有可能的子串初始化候选词汇表
  2. 用 EM 算法估计每个 Token 的概率 $P(t)$
  3. 计算移除每个 Token 对语料似然的损失
  4. 移除损失最小的若干 Token(保留基础字符)
  5. 重复 2-4 直到词汇表达到目标大小

语料似然:

$$ L = \prod_{s \in \text{corpus}} P(s) = \prod_{s} \sum_{t \in V} \prod_{i} P(t_i) $$

其中每个句子 $s$ 有多种分词方式,取最优分词(Viterbi 解码)。

4.3 编码

对于新文本,Unigram LM 通过 Viterbi 算法找概率最大的分词方式:

def viterbi_segment(text, token_probs):
    n = len(text)
    dp = [0.0] * (n + 1)
    back = [None] * (n + 1)
    
    for i in range(1, n + 1):
        for j in range(i):
            token = text[j:i]
            if token in token_probs:
                score = dp[j] + math.log(token_probs[token])
                if score > dp[i] or dp[i] == 0:
                    dp[i] = score
                    back[i] = j
    
    # 回溯
    tokens = []
    i = n
    while i > 0:
        j = back[i]
        tokens.append(text[j:i])
        i = j
    return tokens[::-1]

5. SentencePiece

5.1 设计目标

SentencePiece(Google)解决了一个关键问题:语言无关的分词。传统分词依赖空格预分词,对中文/日语等无空格语言不友好。SentencePiece 直接在原始 Unicode 字符串上操作。

5.2 核心特性

  • 不依赖空格预分词:直接处理原始文本
  • 可逆性encode → decode 完全还原原文
  • 支持 BPE 和 Unigram 两种模式
  • 特殊 Token 管理<unk>, <s>, </s>
import sentencepiece as spm

# 训练
spm.SentencePieceTrainer.train(
    input='corpus.txt',
    model_prefix='tokenizer',
    vocab_size=32000,
    model_type='unigram',  # 或 'bpe'
    character_coverage=0.9995,
    # 特殊 token
    pad_id=0, unk_id=1, bos_id=2, eos_id=3
)

# 使用
sp = spm.SentencePieceProcessor(model_file='tokenizer.model')
tokens = sp.encode('Hello, 世界!', out_type=str)
ids = sp.encode('Hello, 世界!', out_type=int)
decoded = sp.decode(ids)

5.3 多语言分词的挑战

不同语言的编码效率差异巨大。以 LLaMA-2 的 Tokenizer 为例:

语言每词平均 Token 数相对中文比例
英文1.2-1.5-
中文1.5-2.51.7x
日文2.0-3.02.0x
俄文2.0-2.51.7x
阿拉伯文2.5-3.52.3x

这意味着同样上下文窗口下,中文用户实际可用的信息量比英文少约 40-70%。这也是多语言模型需要特别优化词汇表的原因。

6. 各方案对比

特性BPE/BBPEWordPieceUnigram LM
合并准则频率最高PMI 最大似然损失最小
编码方式按规则顺序贪婪前缀匹配Viterbi 最优
逆向兼容是(##标记)
多语言BBPE 好需 SentencePiece
代表模型GPT 系列BERTT5, LLaMA

7. 工程实践建议

  1. 新语言模型:推荐 SentencePiece + Unigram 或 BBPE,训练前确保语料覆盖目标语言
  2. 词汇表大小:32K-128K 是常见范围,多语言建议 64K+
  3. 特殊 Token:预留 <pad>, <unk>, <bos>, <eos> 以及领域特殊 Token
  4. 评估指标:Fertility(每词 Token 数)、Compression Ratio、OOV Rate
  5. 迭代优化:在特定领域语料上增量训练 Tokenizer 可以提升编码效率

加入讨论

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

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