分词器:文本与模型之间的桥梁

LLM无法直接处理文本——它需要将文本切分为离散的token序列,每个token对应一个整数ID。分词器(Tokenizer)就是完成这一转换的组件。分词器的选择直接影响模型的词表大小、序列长度、多语言能力和推理效率。

2026年主流LLM使用的分词器虽然名称各异,但核心算法主要分为BPE、WordPiece和Unigram三大类。

BPE(Byte Pair Encoding)

算法原理

BPE最初是一种数据压缩算法,被Sennrich等人(2016)引入NLP作为子词分词方法。其训练过程是自底向上的——从字符开始,逐步合并最频繁的字符对:

def train_bpe(texts, vocab_size=32000):
    """BPE训练算法"""
    # 1. 将文本拆分为字符序列
    word_freqs = {}
    for text in texts:
        words = text.split()
        for word in words:
            chars = tuple(word)
            word_freqs[chars] = word_freqs.get(chars, 0) + 1
    
    # 2. 初始化词表为所有字符
    vocab = set()
    for word in word_freqs:
        for char in word:
            vocab.add(char)
    
    # 3. 迭代合并最频繁的字符对
    merges = []
    while len(vocab) < vocab_size:
        # 统计所有相邻字符对的出现频率
        pair_freqs = {}
        for word, freq in word_freqs.items():
            for i in range(len(word) - 1):
                pair = (word[i], word[i+1])
                pair_freqs[pair] = pair_freqs.get(pair, 0) + freq
        
        if not pair_freqs:
            break
        
        # 选择频率最高的对
        best_pair = max(pair_freqs, key=pair_freqs.get)
        new_token = best_pair[0] + best_pair[1]
        
        # 合并
        merges.append(best_pair)
        vocab.add(new_token)
        
        # 更新词频
        new_word_freqs = {}
        for word, freq in word_freqs.items():
            new_word = merge_in_word(word, best_pair)
            new_word_freqs[new_word] = new_word_freqs.get(new_word, 0) + freq
        word_freqs = new_word_freqs
    
    return vocab, merges

编码过程

训练好的BPE分词器在编码新文本时,按训练时的合并顺序逐步合并:

def bpe_encode(text, merges, special_tokens):
    """BPE编码"""
    tokens = []
    
    for word in text.split():
        # 先检查是否是特殊token
        if word in special_tokens:
            tokens.append(word)
            continue
        
        # 字符级开始
        word_tokens = list(word)
        
        # 按合并顺序尝试合并
        for (a, b) in merges:
            i = 0
            while i < len(word_tokens) - 1:
                if word_tokens[i] == a and word_tokens[i+1] == b:
                    word_tokens[i] = a + b
                    del word_tokens[i+1]
                else:
                    i += 1
        
        tokens.extend(word_tokens)
    
    return tokens

Byte-level BPE

GPT-2引入的改进——在字节级别而非字符级别进行BPE。这使得词表可以处理任意UTF-8字符,彻底解决了未登录字符(OOV)问题:

def byte_level_bpe_encode(text):
    """Byte-level BPE"""
    # 将文本编码为UTF-8字节
    byte_seq = text.encode('utf-8')
    
    # 将每个字节映射到Unicode字符(避免控制字符冲突)
    mapped = ''.join(byte_to_unicode[b] for b in byte_seq)
    
    # 在映射后的字符序列上应用BPE
    return bpe_encode(mapped)

Byte-level BPE的优势:

  • 零OOV:任何文本都可以被编码
  • 多语言支持:天然处理任何语言的UTF-8编码
  • 可逆性:可以完美还原原始文本

BPE的使用者

  • GPT系列(GPT-2, GPT-3, GPT-4):Byte-level BPE
  • LLaMA系列:Byte-level BPE(基于SentencePiece实现)
  • Qwen系列:Byte-level BPE

WordPiece

与BPE的区别

WordPiece与BPE类似,但选择合并对的准则不同:

  • BPE:选择频率最高的字符对
  • WordPiece:选择似然增益最大的字符对

似然增益衡量的是合并某个字符对后,语言模型似然的提升:

def wordpiece_score(pair, pair_freq, char1_freq, char2_freq, total_freq):
    """WordPiece合并分数:互信息"""
    return pair_freq / (char1_freq * char2_freq)

WordPiece本质上是选择互信息最高的字符对——即这两个字符共同出现的概率远高于独立出现的概率。

编码方式

WordPiece的编码与BPE不同——它使用贪心最长匹配:

def wordpiece_encode(word, vocab, max_chars=100):
    """WordPiece编码:贪心最长匹配"""
    if len(word) > max_chars:
        return ['[UNK]']
    
    tokens = []
    start = 0
    
    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 vocab:
                cur_substr = substr
                break
            end -= 1
        
        if cur_substr is None:
            return ['[UNK]']
        
        tokens.append(cur_substr)
        start = end
    
    return tokens

WordPiece的使用者

  • BERT系列
  • DistilBERT
  • ELECTRA

Unigram Language Model

算法原理

Unigram分词器采用与BPE相反的方向——自顶向下。它先从一个大词表开始,逐步删除对整体似然贡献最小的子词:

def train_unigram(texts, vocab_size=32000, initial_factor=100):
    """Unigram训练算法"""
    # 1. 构建初始大词表(所有可能的子串)
    substrings = {}
    for text in texts:
        for word in text.split():
            for i in range(len(word)):
                for j in range(i+1, len(word)+1):
                    substr = word[i:j]
                    substrings[substr] = substrings.get(substr, 0) + 1
    
    # 2. 保留频率最高的子串作为初始词表
    vocab = sorted(substrings.items(), key=lambda x: -x[1])
    vocab = [item[0] for item in vocab[:vocab_size * initial_factor]]
    
    # 3. 迭代删除对似然贡献最小的子词
    while len(vocab) > vocab_size:
        # 计算当前词表的对数似然
        base_likelihood = compute_likelihood(texts, vocab)
        
        # 尝试删除每个子词,计算似然下降
        deltas = []
        for i, token in enumerate(vocab):
            reduced_vocab = vocab[:i] + vocab[i+1:]
            new_likelihood = compute_likelihood(texts, reduced_vocab)
            delta = base_likelihood - new_likelihood
            deltas.append((i, delta))
        
        # 删除似然下降最小的子词(按比例删除,而非逐个)
        deltas.sort(key=lambda x: x[1])
        n_remove = max(1, len(vocab) // 10)  # 每轮删除10%
        
        remove_indices = set(idx for idx, _ in deltas[:n_remove])
        vocab = [t for i, t in enumerate(vocab) if i not in remove_indices]
    
    return vocab

编码方式

Unigram分词器使用维特比算法找到最优的分词方式:

def unigram_encode(word, vocab, vocab_probs):
    """Unigram编码:维特比最优分词"""
    n = len(word)
    
    # dp[i] = (对数概率, 前驱位置, token)
    dp = [(-float('inf'), -1, None) for _ in range(n + 1)]
    dp[0] = (0, -1, None)
    
    for end in range(1, n + 1):
        for start in range(end):
            substr = word[start:end]
            if substr in vocab_probs:
                prob = vocab_probs[substr]
                score = dp[start][0] + math.log(prob)
                if score > dp[end][0]:
                    dp[end] = (score, start, substr)
    
    # 回溯获取最优分词
    tokens = []
    pos = n
    while pos > 0:
        _, prev_pos, token = dp[pos]
        tokens.append(token)
        pos = prev_pos
    
    return list(reversed(tokens))

Unigram的优势

  1. 概率模型:每个子词有概率,可以处理多种分词方式
  2. 鲁棒性:对噪声和拼写错误更鲁棒(可以找到次优分词)
  3. 多分词候选:可以输出多种分词候选,用于数据增强

Unigram的使用者

  • T5、mT5
  • ALBERT
  • XLNet

SentencePiece

统一框架

SentencePiece不是一个新算法,而是BPE和Unigram的统一实现框架。它解决了传统分词器的两个问题:

  1. 空格问题:传统分词器先按空格分词再子词化,SentencePiece直接在原始文本上操作
  2. 可逆性:SentencePiece保证编码可以完美解码回原始文本
# SentencePiece将空格替换为特殊字符▁(U+2581)
# "Hello world" → "▁Hello▁world"

# 这意味着:
# 1. 不依赖预分词(空格切分)
# 2. 编码-解码完全可逆
# 3. 语言无关(中日韩等无空格语言同样处理)

SentencePiece + BPE

LLaMA使用SentencePiece包装的BPE:

import sentencepiece as spm

spm.SentencePieceTrainer.train(
    input='corpus.txt',
    model_prefix='llama_tokenizer',
    vocab_size=32000,
    model_type='bpe',        # 使用BPE算法
    character_coverage=0.9998,
    byte_fallback=True,      # 字节回退
    normalization_rule_name='identity',  # 不做归一化
)

SentencePiece + Unigram

T5使用SentencePiece包装的Unigram:

spm.SentencePieceTrainer.train(
    input='corpus.txt',
    model_prefix='t5_tokenizer',
    vocab_size=32000,
    model_type='unigram',    # 使用Unigram算法
    character_coverage=0.99995,
    byte_fallback=True,
)

2026年分词器对比

词表大小趋势

模型分词器词表大小特点
GPT-4BPE~100K多语言,压缩率高
LLaMA-3BPE128K扩大词表提升多语言
Qwen-3BPE152K中英文优化
MistralBPE32K紧凑词表
T5Unigram32K概率模型

压缩效率对比

在英文文本上:

分词器词表大小压缩率(tokens/word)示例
BPE (GPT-4)100K1.15“tokenization” → [“token”, “ization”]
BPE (LLaMA-3)128K1.20“tokenization” → [“token”, “ization”]
WordPiece (BERT)30K1.45“tokenization” → [“token”, “##ization”]
Unigram (T5)32K1.35“tokenization” → [“token”, “ization”]

在中文文本上:

分词器压缩率(chars/token)说明
BPE (Qwen-3)2.8中文优化词表
BPE (LLaMA-3)1.5通用多语言
BPE (GPT-4)1.8多语言覆盖

工程实践建议

  1. 选BPE:2026年最主流的选择,生态最完善
  2. Byte-level:务必使用字节级BPE,避免OOV
  3. 词表大小:32K-128K,多语言场景建议128K+
  4. 特殊token:预留特殊token位置(如 <|im_start|>, <|endoftext|>
  5. 归一化:谨慎使用文本归一化,避免信息丢失
  6. 评估:用目标语言的压缩率评估分词器质量

结语

分词器是LLM基础设施中容易被忽视但影响深远的组件。BPE以其简洁和高效成为2026年的主流选择,Unigram的概率模型提供了独特的优势。SentencePiece作为统一框架,使得不同算法可以在同一框架下公平对比和互换。随着多语言需求的增长,分词器的设计将继续演进。

加入讨论

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

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