本文是 Andrej Karpathy 在「Let’s build the GPT Tokenizer」课程的学习总结,涵盖 Tokenization 的核心原理、BPE 算法实现、主流库对比、工程实践及前沿方向。


一、为什么 Tokenization 如此重要又“讨厌”?

Tokenization 是大语言模型(LLM)中最不优雅但又必不可少的一步,很多模型的奇怪行为都源于它。它是文本到数字序列的桥梁,是所有大模型 pipeline 的第一步。

  • 字符级分词:简单直接(如 65 个字符),但序列过长,效率低下。
  • 词级分词:能缩短序列,但会遇到 OOV(未登录词)问题。
  • 子词分词(BPE):折中方案,通过合并频繁出现的字符序列,在压缩序列长度的同时,有效处理未登录词。

老师吐槽:“希望未来能有一天,Tokenization 不再是 LLM 的必需步骤。”


二、Unicode、码点与字节级处理

在深入 BPE 之前,我们需要理解文本在计算机中的表示方式。

  • 码点(Code Point):每个 Unicode 字符的唯一数字标识,如字母 AU+0041,汉字「你」是 U+4F60
  • UTF-8 编码:将码点编码为 1-4 个字节,是文本在计算机中的实际存储方式。
  • 字节级 BPE:GPT 系列分词器从字节(256 个)开始,而不是从字符开始。这样能处理任何 Unicode 字符,从根本上避免了 OOV 问题。

1. UTF-8 字节编码

这是最底层的编码方式,将任何文本都转换为 0~255 的字节序列。

  • 优点:词汇表极小(仅 256 个 token),嵌入表非常小。
  • 缺点
    • 序列超级长:一个中文字符会被拆成 3 个字节,导致序列长度爆炸。
    • 单个字节无意义:模型很难从零散的字节中学习到语言规律。
# 示例:将字符串编码为 UTF-8 字节
text = "안녕하세요 👋 (hello in Korean!)"
bytes_list = list(text.encode("utf-8"))
# 输出: [236, 149, 136, 235, 133, 149, ..., 101, 41]

2. 字符级 Tokenization

将每个可见字符(如 ‘a’、‘你’、‘!’)作为一个独立的 token。

  • 优点:序列比字节短,语义更清晰,实现简单。
  • 缺点
    • 词汇表大:多语言场景下可能达到数万甚至数十万。
    • 生僻字处理困难:容易出现大量 OOV(Out-of-Vocabulary)问题。
# 字符级编码示例
chars = sorted(list(set(text)))
stoi = {ch:i for i, ch in enumerate(chars)}  # 字符到数字的映射
itos = {i:ch for i, ch in enumerate(chars)}  # 数字到字符的映射

encode = lambda s: [stoi[c] for c in s]
decode = lambda l: ''.join([itos[i] for i in l])

三、BPE 算法核心原理与手动实现

BPE(Byte Pair Encoding)是 GPT 分词器的核心算法,其本质是一种数据压缩技术,通过合并高频相邻 token 对,在字节和字符之间找到了极佳的平衡点。

1. 算法步骤

  1. 初始化词汇表:从所有单个字节(256 个)开始。
  2. 统计频率:遍历文本,统计所有相邻字节对的出现频率。
  3. 合并最频繁对:将出现次数最多的字节对合并为一个新的 token,并加入词汇表。
  4. 迭代:重复步骤 2-3,直到词汇表达到预设大小(如 50257)。

2. 核心代码实现

以下是老师在课上实现的极简 BPE 算法核心函数,以及完整的训练、编码、解码流程:

from collections import defaultdict

def get_stats(ids):
    """统计相邻 token 对的出现频率"""
    counts = {}
    for pair in zip(ids, ids[1:]):
        counts[pair] = counts.get(pair, 0) + 1
    return counts

def merge(ids, pair, idx):
    """将文本中的指定 token 对合并为新的 token idx"""
    newids = []
    i = 0
    while i < len(ids):
        if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]:
            newids.append(idx)
            i += 2
        else:
            newids.append(ids[i])
            i += 1
    return newids

# 训练 BPE
def train_bpe(text, vocab_size=512):
    tokens = list(text.encode("utf-8"))
    merges = {}
    id_to_token = {i: bytes([i]) for i in range(256)}
    
    for new_id in range(256, vocab_size):
        stats = get_stats(tokens)
        if not stats:
            break
        pair = max(stats, key=stats.get)
        merges[pair] = new_id
        tokens = merge(tokens, pair, new_id)
        id_to_token[new_id] = id_to_token[pair[0]] + id_to_token[pair[1]]
    
    return merges, id_to_token

# BPE 编码
def encode_bpe(text, merges):
    tokens = list(text.encode("utf-8"))
    while True:
        stats = get_stats(tokens)
        # 找到下一个要合并的对
        candidates = [p for p in stats if p in merges]
        if not candidates:
            break
        # 选择合并顺序最早的对
        pair = min(candidates, key=lambda p: merges[p])
        new_id = merges[pair]
        tokens = merge(tokens, pair, new_id)
    return tokens

# BPE 解码
def decode_bpe(tokens, id_to_token):
    byte_parts = [id_to_token[t] for t in tokens]
    full_bytes = b''.join(byte_parts)
    return full_bytes.decode("utf-8")

四、GPT-2 / GPT-4 分词器的工程实现

工业级的分词器并非简单的 BPE,而是在其基础上增加了许多优化和规则,以适应复杂的真实世界文本。

  • 正则预分割:在 BPE 之前,使用正则表达式 (?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+ 将文本分割成不同类别(字母、数字、标点等),防止跨类别合并,使分词结果更符合人类直觉。
  • 特殊 Token:如 <|endoftext|> 等特殊符号被直接加入词汇表,用于控制模型生成。
  • 字节回退(Byte fallback):当遇到词汇表外的字符时,分词器会将其拆分为单个字节进行编码,确保任何文本都能被处理。

五、主流分词库对比

在实际应用中,我们通常不会从零实现分词器,而是选择成熟的开源库。

特性Tiktoken (OpenAI)SentencePiece (Google)minBPE (Karpathy)
开发者OpenAIGoogleAndrej Karpathy
核心优势专为 GPT 系列优化,速度极快支持多种算法,语言无关,可训练极简实现,代码清晰,适合教学
算法支持BPEBPE, Unigram, Word, CharBPE
训练能力不支持,仅使用预训练词汇表支持在原始文本上训练支持
配置复杂度高(配置项多,如 character_coverage

SentencePiece 配置示例(来自课程)

import sentencepiece as spm
import os

options = {
    # output spec
    "model_prefix": "tok400",
    # algorithm spec
    # BPE alg
    "model_type": "bpe",
    "vocab_size": 400,
    # normalization
    "normalization_rule_name": "identity",
    "remove_extra_whitespaces": False,
    "input_sentence_size": 200000000,
    "max_sentence_length": 4192,
    "seed_sentencepiece_size": 1000000,
    "shuffle_input_sentence": True,
    # rare word treatment
    "character_coverage": 0.99995,
    "byte_fallback": True,
    # merge rules
    "split_digits": True,
    "split_by_unicode_script": True,
    "split_by_whitespace": True,
    "split_by_number": True,
    "max_sentencepiece_length": 16,
    "add_dummy_prefix": True,
    "allow_whitespace_only_pieces": True,
    # special tokens
    "unk_id": 0,
    "bos_id": 1,
    "eos_id": 2,
    "pad_id": -1,
    # systems
    "num_threads": os.cpu_count(),
}

spm.SentencePieceTrainer.train(**options)

六、词汇表大小(vocab_size)的选择

词汇表大小是一个重要的超参数,需要在效率和性能之间权衡。

  • 常见经验值
    • 小模型(<10B):3万-5万
    • 中大型模型(10B-70B):20万左右
    • GPT-4:约10万
  • 权衡
    • 词汇表大 → 序列短 → 模型效率高,但 embedding 层参数也会变多。
    • 词汇表小 → 序列长 → 模型效率低,但 embedding 层更紧凑。
  • 如何增大词汇表:重新在更大的语料上训练 BPE,或在现有词汇表基础上继续合并。

七、Tokenization 带来的“坑”与案例

Tokenization 有很多隐藏的陷阱,稍不注意就会导致模型行为异常。

  • Python 代码分词问题:GPT-2 对 Python 代码的分词很糟糕,因为代码中的符号和结构在训练语料中不常见。
  • 特殊字符串陷阱:如 <|endoftext|> 会被直接识别为特殊 token,导致文本截断。
  • 尾随空格:分词器对空格很敏感,"hello""hello " 可能被分成不同的 token。
  • “SolidGoldMagikarp”:这个奇怪的字符串在 GPT-2 词汇表中,是训练数据中的噪声,导致模型对它有奇怪的反应。

八、前沿方向:无分词 (Tokenization-Free)

尽管 BPE 是目前的主流,但研究者们一直在探索摆脱分词器的方法。

1. 为什么要无分词?

  • 避开分词器的坑:分词器存在多语言偏见、安全隐患和实现复杂性。
  • 真正端到端:让模型自己学习如何最优地切分和理解文本。
  • 大一统建模:可以直接处理文本、音频、图像等所有形式的字节流。

2. 代表工作:MEGABYTE

  • 提出了一种多尺度 Transformer 架构,直接对百万级字节序列进行建模。
  • 通过将长序列分块(patch),使用局部模型处理块内字节,全局模型处理块间关系,解决了序列过长的问题。
  • 证明了大规模无分词自回归序列建模的可行性。

九、最终建议与总结

1. 不要轻视 Tokenization

它有很多隐藏的陷阱,涉及安全和性能问题。

2. 应用建议

  • 优先复用:在自己的应用中,优先复用 GPT-4 的 tiktoken,避免自己造轮子。
  • 训练词汇表:如果必须训练,使用 SentencePiece + BPE,小心配置参数。
  • 关注发展:期待 minBPE 未来能达到 SentencePiece 的效率,成为更简洁的选择。

老师总结:“Tokenization 是 LLM 中最不优雅但又必不可少的一步,很多模型的奇怪行为都源于它。希望未来能有一天,Tokenization 不再是必需步骤。”

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐