第 1 讲:课程总览与分词(Overview, Tokenization)

目录 · ← l0 · l2 →

第 1 讲:课程总览与分词(Overview, Tokenization)

日期:3 月 30 日(周一,Spring 2026) | 讲师:Percy Liang | 材料:lecture_01.py(可执行讲义 + 代码)

概览

本讲先回答”这门课为什么存在”,梳理语言模型(LM)的历史脉络,然后进入第一个技术单元:分词(tokenization)——把原始文本(字节)转换成模型真正消费的整数序列的过程。讲义现场用 Python 实现并对比了字符级、字节级、词级以及 BPE(Byte-Pair Encoding,字节对编码) 分词器,其中包括一个从零写的 BPE 训练器。

核心概念与定义

  • 语言模型(language model):一个定义在 token 序列上的概率分布。2018 年”语言模型”是拿来微调的东西(BERT);2020 年是拿来 prompt 的东西(GPT-3);2022 年是拿来对话的东西(ChatGPT);2026 年是可以自主行动的东西(agents)。底层原理(attention、kernel、优化)没变,但规格变了(更长的上下文、推理效率更关键)。
  • 分词器(Tokenizer):一个提供两个方法的类——encode(string) -> list[int]decode(list[int]) -> string。它是”原始输入(字节)”与”模型操作的整数”之间的接口。
    • 类比:就像厨师备菜。你不能拿一整颗没洗的蔬菜下锅,得洗净、削皮、切成大小均匀的块(token),你的菜谱(模型)才好处理。不同的厨师(分词器)切法不同,切法直接决定菜好不好做。
  • Unicode / 码点(code point):原始文本是 Unicode 字符序列;每个字符对应一个码点(例如 ord("a") == 97ord("🌍") == 127757),用 chr 可以转回去。
    • 类比:码点就像 ISBN 书号——人类所有文字符号(书籍)都分到了一个全局唯一的编号,无论它属于哪种语言。
  • UTF-8:主流编码方式,把字符映射为 1–4 个字节。ASCII 字符占 1 字节,🌍 占 4 字节(\xf0\x9f\x8c\x8d)。
  • 压缩率(compression ratio):每个 token 对应的 UTF-8 字节数。压缩率越高,序列越短——这一点非常重要,因为 Transformer 的 attention 复杂度对序列长度是平方级的。
    • 类比:压缩率就像打包行李。一件行李箱装 10 件衣服(高压缩率)远好过 10 个小包(低压缩率),因为航空公司(attention)是按”件数”(token 数)收费的。
  • BPE(字节对编码):一种数据驱动的子词(subword)算法(1994 年 Philip Gage 为数据压缩提出,Sennrich 等人 2016 年引入 NLP,之后被 GPT-2 采用并成为现代模型的事实标准)。做法:以字节为初始 token,反复把出现频率最高的相邻 token 对合并成新 token,直到达到目标词表大小。
    • 机制统计相邻对 → 合并最高频对 → 重复。结果是:常见的字节序列被压缩成单个 token,罕见的序列则被拆成很多 token。
    • 类比:BPE 就像手机输入法的联想词——”brb”、”lol”、”omw” 因为在语料里高频出现而变成快捷词,而像 “supercalifragilisticexpialidocious” 这种罕见词只能一个字母一个字母打出来。
  • 分词效率视角(本讲的核心论证)
    1. 缩短上下文长度(约 1000 字节 → 约 250 个 token);
    2. 自适应算力分配(把更多模型容量给输入中”更有信息量”的部分)。
  • 无分词器架构(the dream):ByteT5、MegaByte、BLT 等模型直接在字节上操作——很有前景,但尚未扩展到前沿规模。

代码示例:Tokenizer 接口与三种朴素分词器

讲义定义了抽象接口,并给出三种具体(但次优)的分词器。

代码(Python):

from abc import ABC

class Tokenizer(ABC):
    """分词器的抽象接口。"""
    def encode(self, string: str) -> list[int]:
        raise NotImplementedError
    def decode(self, indices: list[int]) -> str:
        raise NotImplementedError

class CharacterTokenizer(Tokenizer):
    """把一个字符串表示成 Unicode 码点序列。"""
    def encode(self, string: str) -> list[int]:
        return list(map(ord, string))
    def decode(self, indices: list[int]) -> str:
        return "".join(map(chr, indices))

class ByteTokenizer(Tokenizer):
    """把一个字符串表示成字节序列。"""
    def encode(self, string: str) -> list[int]:
        string_bytes = string.encode("utf-8")
        indices = list(map(int, string_bytes))
        return indices
    def decode(self, indices: list[int]) -> str:
        string_bytes = bytes(indices)
        string = string_bytes.decode("utf-8")
        return string

代码做了什么:

  1. CharacterTokenizer.encodeord 把每个 Unicode 字符映射成码点整数(一个字符一个整数);decodechr 转回。
  2. ByteTokenizer.encode 先把整个字符串编码为 UTF-8 字节,再把每个字节(0–255)转成整数;decode 把整数转回字节并做 UTF-8 解码。
  3. 两者都能往返:decode(encode(s)) == s

实现深挖:

  • 为什么说这两种是”两头不讨好”:字符分词器的词表巨大(约 15 万个 Unicode 字符,绝大多数极罕见),压缩率却约等于 1;字节分词器词表很小(256),但压缩率恰好等于 1(一个字节一个 token),序列很长,直接引爆 attention 开销。讲义用 "Hello, 🌍! 你好!" 现场演示了字节分词器的压缩率恰好为 1.0。
  • 为什么必须正确处理 UTF-8:并非所有字符都能用一个字节表示,bytes("🌍", encoding="utf-8") == b"\xf0\x9f\x8c\x8d"。能否正确处理这一点,是”能用的分词器”和”坏掉的分词器”的分界线。
  • 为什么 decode 要容忍非法字节bytes.decode("utf-8") 遇到非法序列会抛异常;生产级分词器用 errors="replace"(讲义中的 output_tokenizer 就是这么做的)来容忍任意字节串。

与作业的联系:作业 1 要求实现完整的 BPE 分词器。这里的 Tokenizer 抽象类正是你的 BPETokenizer 必须满足的接口,而 UTF-8 处理是你将要写的字节级 BPE(加上预分词、special token、高速合并)的基础。

代码示例:BPE 的 merge、训练与分词器

代码(Python):

def merge(indices: list[int], pair: tuple[int, int], new_index: int) -> list[int]:
    """返回 indices,但把所有 pair 的实例替换成 new_index。"""
    new_indices = []
    i = 0
    while i < len(indices):
        if i + 1 < len(indices) and indices[i] == pair[0] and indices[i + 1] == pair[1]:
            new_indices.append(new_index)
            i += 2
        else:
            new_indices.append(indices[i])
            i += 1
    return new_indices

def count_adjacent_pairs(indices: list[int]) -> dict[tuple[int, int], int]:
    """返回字典:每个相邻 token 对 -> 出现次数。"""
    counts = defaultdict(int)
    for index1, index2 in zip(indices, indices[1:]):
        counts[(index1, index2)] += 1
    return counts

def train_bpe(string: str, num_merges: int) -> BPETokenizerParams:
    indices = list(map(int, string.encode("utf-8")))
    merges: dict[tuple[int, int], int] = {}          # 对 -> 合并后的新下标
    vocab: dict[int, bytes] = {x: bytes([x]) for x in range(256)}  # 下标 -> 字节

    for i in range(num_merges):
        counts = count_adjacent_pairs(indices)       # 统计所有相邻对
        pair = max(counts, key=counts.get)           # 取最高频的 pair
        new_index = 256 + i                          # 分配新下标
        merges[pair] = new_index
        vocab[new_index] = vocab[pair[0]] + vocab[pair[1]]  # 字节串拼接
        indices = merge(indices, pair, new_index)
    return BPETokenizerParams(vocab=vocab, merges=merges)

@dataclass(frozen=True)
class BPETokenizerParams:
    vocab: dict[int, bytes]                # 下标 -> 字节
    merges: dict[tuple[int, int], int]     # (i1, i2) -> new_index

class BPETokenizer(Tokenizer):
    def __init__(self, params: BPETokenizerParams):
        self.params = params
    def encode(self, string: str) -> list[int]:
        indices = list(map(int, string.encode("utf-8")))
        # 注意:这是一个非常慢的实现
        for pair, new_index in self.params.merges.items():
            indices = merge(indices, pair, new_index)
        return indices
    def decode(self, indices: list[int]) -> str:
        bytes_list = list(map(self.params.vocab.get, indices))
        return b"".join(bytes_list).decode("utf-8")

代码做了什么:

  1. train_bpe 先取训练字符串的字节序列,然后循环 num_merges 次:统计所有相邻对 → 取最高频者 → 分配全新下标(256 + i)→ 记录合并规则 → 在词表里把两段字节串拼接 → 用 merge 重写序列。
  2. BPETokenizer.encode 把学到的合并规则按顺序应用到新输入的字节序列上。
  3. BPETokenizer.decode 查词表拿到每个下标的字节串,拼接后做 UTF-8 解码。

实现深挖:

  • 为什么是 256 + i:前 256 个下标留给单字节;每个新合并 token 拿下一个可用下标,保证映射是单射。
  • 为什么必须按训练顺序应用 merge:BPE 是一个贪心的层次化过程;后学的合并可能包含先学的合并结果,只有按学习顺序应用,编码结果才与训练时的语义一致。
  • 为什么 decode 不需要 merge 规则:词表为每个 token 存了完整的字节串,所以解码是纯查表;merge 规则只在编码时用到。这形成了清晰的”训练期数据结构(merges)”与”推理期数据结构(vocab)”分离。
  • 复杂度问题(代码里明确标注)encode所有 merge 都遍历一遍序列,复杂度约 O(num_merges × 序列长度)。作业 1 要求你只应用”真正相关的” merge(例如序列里已不存在的对直接跳过)、支持 special token(如 <\|endoftext\|>)、加入 GPT-2 风格的预分词 regex,并把整体做快。
  • 为什么需要预分词与词尾标记:经典 Sennrich 版 BPE(以及你给的示例)会先按词切分并用 </w> 标记词尾,防止跨词合并;讲义这里的字节级版本则直接合并原始字节,更简单,也和 GPT 系分词器一致。

与作业的联系:这是作业 1 第 2 节(BPE 分词器)的绝对基础。你要扩展的正是这套逻辑:在大语料上学习 merge、构建含 special token 的词表、做预分词(GPT-2 regex)、实现高效编码器。讲义明确列出了作业 1 要求的四项升级:(1) 只遍历有意义的 merge;(2) special token;(3) 预分词;(4) 提速。

关键要点

  1. 分词是 LM 流水线的关键第一步:它决定词表大小、序列长度以及罕见词的处理方式,并且必须完美往返(decode(encode(s)) == s)。
  2. 字符/字节/词三种分词器都次优:字符词表巨大且压缩率低;字节词表小但压缩率恰好为 1;词级压缩率好但词表无界且存在未登录词(UNK)问题。
  3. BPE 是简单、数据驱动、被广泛使用的启发式算法:从字节出发,反复合并最高频相邻对,从而在词表大小与压缩率之间取得平衡。
  4. 本课的一切都是关于效率:分词之所以重要,是因为它缩短序列(attention 是平方复杂度!)并能自适应分配模型容量。
  5. 一个好的分词方案应当:① 让模型在”有意义的块(chunks)”上操作;② 让块是可变的,从而把更多容量分配给输入中有信息量的部分。

常见陷阱

  • 没有正确处理 Unicode:多字节字符必须以 UTF-8 字节形式编解码;忽略它会让非 ASCII 文本无法往返。
  • 不做往返测试:永远要断言 decode(encode(s)) == s,并覆盖 emoji、CJK、特殊空白等边界情况。
  • 合并顺序错乱:编码必须按训练顺序应用 merge,否则结果与学到的词表不匹配。
  • 编码复杂度接近 O(n²):朴素的 encode(对所有 merge 遍历整条序列)在真实语料上慢到不可用;作业 1 期望更聪明的做法。
  • 字节解码失败:token 序列未必构成合法 UTF-8;生产环境要用 errors="replace"
  • 忽视压缩率:压缩率 1.0 意味着序列很长,而 attention 是平方复杂度——序列长度是第一位的成本。

复习题

  1. 问: 为什么字节分词器的压缩率恰好是 1?这为什么是问题?
    • 答: 每个字节恰好映射成一个 token,所以”字节/token” = 1。这意味着 1000 字节的文档变成 1000 个 token;由于 attention 对序列长度是平方复杂度,模型开销会急剧膨胀——你希望大约 4 倍压缩(约 250 个 token)。
  2. 问:train_bpe 里,为什么 decode 用词表实现而不是用 merge 规则?
    • 答: 词表把每个 token 下标映射到它代表的完整字节串,所以解码就是直接查表;merge 规则描述的是”token 是如何被构造出来的”,只有编码新文本时才需要。此外,用词表解码能处理任意 token 序列,无需知道合并历史。
  3. 问: 如果 BPE 从不合并(词尾标记,下一个词的首字符)这样的跨词对——即在原始字节上做合并而不提供任何词边界信息——会发生什么?
    • 答: 分词器可能学到跨越词边界的 token,使 token 的语义性变差,甚至把不同上下文混在一起;预分词加边界标记就是用来防止这种情况的。