从零构建 GPT 分词器

本文是 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 个字节,导致序列长度爆炸。
    • 单个字节无意义:模型很难从零散的字节中学习到语言规律。
python 复制代码
# 示例:将字符串编码为 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)问题。
python 复制代码
# 字符级编码示例
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 算法核心函数,以及完整的训练、编码、解码流程:

python 复制代码
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)
开发者 OpenAI Google Andrej Karpathy
核心优势 专为 GPT 系列优化,速度极快 支持多种算法,语言无关,可训练 极简实现,代码清晰,适合教学
算法支持 BPE BPE, Unigram, Word, Char BPE
训练能力 不支持,仅使用预训练词汇表 支持在原始文本上训练 支持
配置复杂度 高(配置项多,如 character_coverage

SentencePiece 配置示例(来自课程)

python 复制代码
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 不再是必需步骤。"

相关推荐
安科士andxe4 小时前
深入解析|安科士1.25G CWDM SFP光模块核心技术,破解中长距离传输痛点
服务器·网络·5g
九.九7 小时前
ops-transformer:AI 处理器上的高性能 Transformer 算子库
人工智能·深度学习·transformer
春日见7 小时前
拉取与合并:如何让个人分支既包含你昨天的修改,也包含 develop 最新更新
大数据·人工智能·深度学习·elasticsearch·搜索引擎
偷吃的耗子7 小时前
【CNN算法理解】:三、AlexNet 训练模块(附代码)
深度学习·算法·cnn
小白同学_C7 小时前
Lab4-Lab: traps && MIT6.1810操作系统工程【持续更新】 _
linux·c/c++·操作系统os
今天只学一颗糖7 小时前
1、《深入理解计算机系统》--计算机系统介绍
linux·笔记·学习·系统架构
儒雅的晴天8 小时前
大模型幻觉问题
运维·服务器
Faker66363aaa8 小时前
【深度学习】YOLO11-BiFPN多肉植物检测分类模型,从0到1实现植物识别系统,附完整代码与教程_1
人工智能·深度学习·分类
通信大师9 小时前
深度解析PCC策略计费控制:核心网产品与应用价值
运维·服务器·网络·5g
不做无法实现的梦~9 小时前
ros2实现路径规划---nav2部分
linux·stm32·嵌入式硬件·机器人·自动驾驶