云镜收藏

稍后阅读

清单保存在当前浏览器,方便下次回来继续阅读。

清单还是空的

在笔记卡片或正文页点击“加入稍后阅读”即可收藏。

卷2 · LLM 内功

2.2 BPE tokenizer 原理与实现

约 6 分钟阅读
全书目录

展开本章目录与同卷章节1. 为什么需要 tokenizer,以及三条路线2. 字节级 BPE:算法(必须能白板实现)3. tiktoken 实操:把 token 变成看得见的东西4. tokenizer 造成的经典翻案(懂机理,见怪不怪)5. 词表大小的权衡6. 自测题7. 动手作业案例 · BPE 三步合并全记录8. 延伸资源同卷章节2.1 语言模型前史:n-gram → RNN → 注意力(20 分钟走完 30 年)2.2 BPE tokenizer 原理与实现2.3 GPT 架构工程版:从 TinyGPT 到 nanoGPT2.4 预训练:数据、Scaling Law 与训练稳定性2.5 SFT 与指令微调实战2.6 参数高效微调:LoRA 与 QLoRA2.7 对齐技术:RLHF → DPO → GRPO2.8 推理优化与 vLLM:把模型变成服务2.9 架构前沿:MoE、长上下文与注意力变体2.10 小模型时代的科研机会:1B–7B 甜点区

本章目标:搞清模型真正”看到”的东西——token。tokenizer 是所有诡异行为(数字比较、字母计数、计费差异)的第一嫌疑人。学完标准:能手写 BPE 的训练与编码,能用 tiktoken 检查任何文本的 token 边界,能解释”strawberry 数不出 r”的机理。

1. 为什么需要 tokenizer,以及三条路线

模型做的是对离散符号的 softmax(0.1 章的分类分布),输入必须变成有限词表里的 id。三个候选粒度的 trade-off:

粒度词表序列长度缺点
词巨大,且总有新词(OOV)短开放词汇不可行
字符极小(~百级)极长序列长 → 注意力平方爆炸(1.2 的账)
子词(BPE)数万–15万中——现代共识

子词的哲学:常见序列合并成单元,罕见内容退回字节。英文单词 ≈ 1 个 token,生僻词拆成几块,任何语言任何符号最终都有字节兜底——永不出 OOV。

2. 字节级 BPE:算法(必须能白板实现)

BPE(Byte Pair Encoding)本质是贪心的数据压缩:

训练(从语料学”合并表”):

  1. 把所有文本转成 UTF-8 字节序列(起点:256 个基础 token);
  2. 统计当前序列中相邻 token 对的出现频率;
  3. 把最高频的一对合并成新 token(id = 257, 258, …),记录到合并表;
  4. 重复第 2–3 步,直到词表达到目标大小(如 15 万)。

编码(推理时用):把输入转成字节,按训练时学到的合并顺序逐条贪心重放合并。

极简实现(~40 行,Karpathy minbpe 的骨架):

def merge(ids, pair, new_id):
    out, i = [], 0
    while i < len(ids):
        if i < len(ids) - 1 and (ids[i], ids[i+1]) == pair:
            out.append(new_id); i += 2          # 命中:两个 token 换成一个
        else:
            out.append(ids[i]); i += 1
    return out

def train_bpe(text, vocab_size):
    ids = list(text.encode("utf-8"))            # 起点永远是 0..255 字节
    vocab = {i: bytes([i]) for i in range(256)}
    merges = {}
    for step in range(vocab_size - 256):
        cnt = {}
        for a, b in zip(ids, ids[1:]):          # 1. 统计相邻对频率
            cnt[(a, b)] = cnt.get((a, b), 0) + 1
        best = max(cnt, key=cnt.get)            # 2. 最高频对
        new_id = 256 + step
        merges[best] = new_id                   # 3. 记入合并表
        vocab[new_id] = vocab[best[0]] + vocab[best[1]]
        ids = merge(ids, best, new_id)          # 4. 全量重放
    return merges, vocab

def encode(text, merges):
    ids = list(text.encode("utf-8"))
    for pair, new_id in merges.items():         # 按学习顺序贪心重放
        ids = merge(ids, pair, new_id)
    return ids

def decode(ids, vocab):
    return b"".join(vocab[i] for i in ids).decode("utf-8", errors="replace")

细节红线:合并表必须按学习顺序重放(顺序即优先级);errors="replace" 处理”半个中文字”的截断(token 边界可能切开 UTF-8 多字节字符)。

3. tiktoken 实操:把 token 变成看得见的东西

import tiktoken                                   # pip install tiktoken
enc = tiktoken.get_encoding("o200k_base")         # GPT-4o 系词表
ids = enc.encode("多模态大模型是真香")
print(ids)                                        # 每个 id 一个 token
print([enc.decode([i]) for i in ids])             # token 边界可视化
print(len(enc.encode("一句话")))                   # token 数 = 计费/上下文的单位

必做观察:中文字符通常 1 字 = 1~2 token(国产模型用大中文词表压到约 1.5 字/token——Qwen 15.2 万词表对中文更省,这是”国产模型中文便宜”的底层原因之一);英文平均 1 词 ≈ 1.3 token;代码和数字切分有自己的脾气(下节)。

4. tokenizer 造成的经典翻案(懂机理,见怪不怪)

  1. “9.11 和 9.9 哪个大”:老 tokenizer 把数字按 2–3 位一切,模型看到的可能是 [9.1][1] vs [9.9]——它做的是 token 序列比较,不是数值比较。现代 tokenizer 改进数字切分 + 训练数据加了算术,但机理要懂;
  2. “strawberry 有几个 r”:模型看到的是 2–4 个 token 的 id,不是 10 个字母——字符级信息被 tokenizer 抹掉了。它必须靠训练里学到的”间接知识”回答;
  3. 特殊 token 注入:<|endoftext|> 这类保留 token 拥有特权(终止符、系统标记)。用户输入里出现它们可能逃逸出当前角色——所以推理框架把 special token 与普通文本分开编码(tiktoken 的 encode(..., allowed_special=) 参数就是干这个的);
  4. 中文/代码的序列长度差:同样的内容换 tokenizer,上下文占用和 API 费用差几十个百分比。

一句话:模型的一切能力都作用在 token 上,token 是 tokenizer 决定的——分析任何诡异行为,先看 token 边界。

5. 词表大小的权衡

  • 词表大 → 序列短(省注意力平方的钱)、中文友好;代价是嵌入层参数大(V×dV \times d,15 万词表 × 4096 维 ≈ 6 亿参数,比很多模型还大)和 softmax 计算大;
  • 词表小 → 模型小序列长;
  • 现代主流:12.8 万–15.2 万(LLaMA 128k,Qwen 152k),多语料时代的甜点区。

6. 自测题

  1. BPE 训练和编码各自在干什么?(训练学合并表:反复合并最高频相邻对;编码按学习顺序贪心重放)
  2. 为什么字节级起点保证”永不出 OOV”?(任何 UTF-8 都是字节序列,256 基础 token 兜底)
  3. “半个中文字”是什么现象?(token 边界切开 UTF-8 多字节字符;decode 用 errors=“replace”)
  4. 为什么数字和字母计数是 LLM 的经典弱项?(字符级结构被 token 化抹掉,模型看到的是块 id)
  5. 词表加大的两个收益两个代价?(序列短/中文省 token vs 嵌入参数大/softmax 开销)
  6. special token 为什么要和普通文本分开编码?(防用户输入伪造终止符/系统标记越权)

7. 动手作业

  1. 用第 2 节代码在一段中文+英文混合文本上训练 BPE(vocab_size=512),decode 自己的 encode,验证无损往返;
  2. 用 tiktoken 分别统计:同一段中文新闻、同长度英文新闻、一段 Python 代码的 token 数,算”字符/token 比”并解释差异;
  3. 找出 o200k_base 里”多模态”三个字的切分边界,画出每个 token 的字节构成;
  4. 挑战:读 Karpathy《Let’s build the GPT Tokenizer》前半段,把你的实现升级为支持 special token 的版本。

案例 · BPE 三步合并全记录

语料(词频):low ×3,lower ×1,slower ×1(词尾统一加 </w> 标记,从 256 个字节 token 起步)。

轮次统计相邻对频次最高频对合并动作词表大小
1l+o 出现 5 次(low×3 + lower×1 + slower×1)(l, o)新 token lo257
2lo+w 出现 4 次(low×3 + lower×1)(lo, w)新 token low258
3e+r 出现 2 次(lower + slower)(e, r)新 token er259

三轮之后,low 已是单个 token;lower 只差最后一并(low+er → lower,频次 2,第 4 轮完成)。

读出 BPE 的品格:高频模式永远优先获得 token 席位——常见词先被”压缩成词”,罕见组合后合并。词表大小就是压缩轮数的预算:15 万词表 = 允许 15 万次贪心合并。中文同理,“的""是”这类高频字会极早合并成整字 token——这就是 2.3 说”国产大词表对中文更省”的机理演示。

8. 延伸资源

  • Karpathy《Let’s build the GPT Tokenizer》——与本章一一对应
  • minbpe repo(~100 行的生产级参考实现)
  • tiktoken repo / HuggingFace tokenizers——工业实现(Rust 底座)