基础 第 1 讲 / 共 17 讲

第 1 讲:课程概述与 Tokenization

1. 课程目标与结构

CS336 的核心理念是 "from scratch"——从零开始构建一个完整的语言模型。 不同于大多数 NLP 课程仅使用现成的 API 或预训练模型,本课程要求你深入理解每一个环节的工程细节, 从数据处理、Tokenizer 构建、模型架构、训练系统,到最终的评估与对齐。

课程共分为五个模块,每个模块对应一次作业:

模块内容作业
BasicsTokenizer, Transformer, Training LoopAssignment 1
SystemsFlash Attention, DDP, KernelsAssignment 2
ScalingScaling Laws, IsoFLOP AnalysisAssignment 3
DataCommon Crawl Processing, DeduplicationAssignment 4
AlignmentSFT, RLHF, GRPOAssignment 5

学习建议

本课程的每次作业都建立在前一次的基础之上。建议按顺序完成,并确保每一步都能独立运行和验证。

2. 语言模型是什么

语言模型(Language Model)本质上是一个概率分布:给定一个 token 序列, 模型输出下一个 token 的概率分布。形式化地说:

P(xt+1 | x1, x2, ..., xt)

这个看似简单的定义蕴含了极大的能力。通过 autoregressive generation(自回归生成), 模型可以逐步生成任意长度的文本。而通过在海量文本上训练,模型能够学到语言的语法结构、 世界知识、推理能力等。

现代大语言模型(LLM)的核心思想可以总结为:

3. 训练流程概述

从原始数据到可用的语言模型,完整的 pipeline 包含以下环节:

  1. Data Collection & Processing:从互联网(如 Common Crawl)收集文本,进行清洗、去重、过滤
  2. Tokenization:将文本转换为 token 序列(本讲的重点)
  3. Model Architecture:定义 Transformer 网络结构
  4. Training:在 GPU 集群上进行大规模分布式训练
  5. Evaluation:通过 benchmark 评估模型能力
  6. Alignment:通过 SFT 和 RLHF 让模型更安全、更有用

Pipeline 视角

Data → Tokenizer → Model → Training → Evaluation → Alignment。每一个环节的决策都会影响最终模型的质量。例如,Tokenizer 的词表大小直接影响模型的参数量和多语言能力。

4. Tokenization 基础

Tokenization 是将原始文本转换为模型可以处理的整数序列的过程。这看起来是个"简单"的预处理步骤, 但实际上它对模型的性能有深远影响。

4.1 为什么需要 Tokenization

神经网络不能直接处理文本字符串,它需要数值输入。Tokenizer 的作用就是建立 文本 ↔ 整数序列 之间的双向映射。这涉及两个核心操作:

4.2 三种 Tokenization 策略

常见的 Tokenization 策略有三种,各有优劣:

策略粒度词表大小优点缺点
Character-level 单个字符 ~256 词表极小,无 OOV 问题 序列过长,难以捕获语义
Word-level 完整单词 100K+ 语义明确 词表庞大,无法处理新词(OOV)
Subword-level 子词片段 32K~128K 平衡词表大小和序列长度 需要训练算法来确定词表

现代 LLM 几乎全部采用 Subword Tokenization。其核心思想是: 高频词保留为完整 token(如 "the"、"is"),低频词拆分为更小的子词片段(如 "un" + "believ" + "able")。 这样既能控制词表大小,又能处理任意文本。

直觉理解

Subword tokenization 类似于信息论中的 Huffman 编码:高频模式用短码(单个 token),低频模式用长码(多个 token 拼接)。这在固定词表大小下最大化了编码效率。

5. BPE (Byte Pair Encoding) 算法详解

BPE 是目前最主流的 Subword Tokenization 算法,被 GPT 系列、LLaMA 等模型广泛采用。 它的核心思想非常简洁:反复合并出现频率最高的相邻 token pair。

5.1 训练阶段:构建词表

BPE 词表构建的完整算法如下:

  1. 初始化:将训练语料中的每个字符(或字节)作为初始 token,词表大小为 256(对应所有可能的字节值)
  2. 统计 Pair 频率:遍历语料,统计所有相邻 token pair 的出现次数
  3. 合并最高频 Pair:找到频率最高的 pair (A, B),将其合并为新 token "AB",加入词表
  4. 更新语料:将语料中所有的 (A, B) 替换为 "AB"
  5. 重复:回到第 2 步,直到词表大小达到目标值(如 32,000 或 128,000)

来看一个具体的例子。假设我们的训练语料只有一句话:

# Training corpus
text = "aababcab"

# Step 0: Initialize as bytes
tokens = ['a', 'a', 'b', 'a', 'b', 'c', 'a', 'b']
vocab = {'a', 'b', 'c'}

# Step 1: Count pairs
# ('a', 'b') appears 3 times
# ('a', 'a') appears 1 time
# ('b', 'a') appears 1 time
# ('b', 'c') appears 1 time
# ('c', 'a') appears 1 time

# Step 2: Merge most frequent pair ('a', 'b') -> 'ab'
tokens = ['a', 'ab', 'ab', 'c', 'ab']
vocab = {'a', 'b', 'c', 'ab'}

# Step 3: Count pairs again
# ('a', 'ab') appears 1 time
# ('ab', 'ab') appears 1 time
# ('ab', 'c') appears 1 time
# ('c', 'ab') appears 1 time

# ... continue until target vocab size is reached

5.2 关键实现细节

实际的 BPE 实现比上述简化版本复杂得多,以下是几个关键的工程细节:

Byte-level BPE

现代 BPE 实现(如 GPT-2/GPT-4 使用的)基于 字节 而非字符。 这意味着初始词表包含 256 个 byte token(0x00 到 0xFF),可以表示任意二进制数据。 UTF-8 编码的文本会被拆分为字节序列,然后在此基础上执行 BPE 合并。

这种设计的优势是:

Regex-based Pre-tokenization

直接在整个文本上执行 BPE 会导致不合理的合并(例如跨单词的合并)。 为了避免这个问题,GPT 系列在 BPE 之前先用正则表达式将文本切分为"词块"(chunks), BPE 合并只在每个词块内部进行。

# GPT-2 style pre-tokenization regex
import regex
pat = regex.compile(
    r"""'s|'t|'re|'ve|'m|'ll|'d"""
    r"""| ?\p{L}+"""
    r"""| ?\p{N}+"""
    r"""| ?[^\s\p{L}\p{N}]+"""
    r"""|\s+(?!\S)"""
    r"""|\s+"""
)

text = "Hello, world! I'm learning."
chunks = regex.findall(pat, text)
# ['Hello', ',', ' world', '!', " I", "'m", ' learning', '.']

注意这个 regex 的设计:它将标点、数字、字母、空格分别归入不同的块, 防止 BPE 将空格和字母合并为一个 token。

Special Tokens

除了从数据中学到的 token,Tokenizer 还需要一些特殊 token:

这些特殊 token 在 BPE 合并中不参与,它们有独立的固定 ID。

6. BPE 的编码与解码流程

6.1 编码(Encoding)

给定一段文本和训练好的 BPE 词表,编码流程如下:

  1. 用 pre-tokenization regex 将文本切分为词块
  2. 将每个词块转换为字节序列
  3. 按照训练时的合并顺序,依次应用每条合并规则
  4. 将最终的 token 映射为整数 ID
def encode(text: str, merges: list, vocab: dict) -> list[int]:
    # Step 1: Pre-tokenize
    chunks = regex.findall(PRE_TOKENIZE_PATTERN, text)

    token_ids = []
    for chunk in chunks:
        # Step 2: Convert to bytes
        tokens = list(chunk.encode('utf-8'))

        # Step 3: Apply merges in order
        for (a, b), new_token in merges:
            i = 0
            new_tokens = []
            while i < len(tokens):
                if i < len(tokens) - 1 and tokens[i] == a and tokens[i+1] == b:
                    new_tokens.append(new_token)
                    i += 2
                else:
                    new_tokens.append(tokens[i])
                    i += 1
            tokens = new_tokens

        # Step 4: Map to IDs
        token_ids.extend([vocab[t] for t in tokens])

    return token_ids

合并顺序很重要

BPE 的合并规则必须严格按照训练时的顺序应用。不同的应用顺序可能产生不同的 tokenization 结果。这是因为前面的合并会改变后续可用的 pair。

6.2 解码(Decoding)

解码相对简单:将每个 token ID 映射回对应的字节序列,然后拼接并用 UTF-8 解码。

def decode(token_ids: list[int], vocab_reverse: dict) -> str:
    # Map each ID back to bytes
    byte_sequences = [vocab_reverse[id] for id in token_ids]
    # Concatenate all bytes
    all_bytes = b''.join(byte_sequences)
    # Decode UTF-8, handling errors gracefully
    return all_bytes.decode('utf-8', errors='replace')

7. Tokenizer 的实际影响

7.1 词表大小的权衡

词表大小(vocabulary size)是 Tokenizer 最重要的超参数之一。 它直接影响模型的多个方面:

方面词表较小(如 32K)词表较大(如 128K)
Embedding 参数量 较少 较多(vocab_size × d_model)
序列长度 较长(同样的文本需要更多 token) 较短
计算成本 Attention 成本更高(序列更长) Softmax 输出层成本更高
低频 token 学习 每个 token 训练充分 部分 token 训练不足

实践中,GPT-2 使用 50,257 的词表,GPT-4 使用约 100,000 的词表, LLaMA 2 使用 32,000 的词表,LLaMA 3 将词表扩大到 128,000。 更大的词表通常有利于多语言支持和代码理解。

7.2 多语言 Token 效率

不同语言在同一个 Tokenizer 下的效率差异很大。以 GPT-2 的 Tokenizer 为例, 同一句话的英文版本可能只需要 10 个 token,而中文版本可能需要 30 个以上。 这是因为训练数据以英文为主,中文字符在 BPE 合并中获得的合并较少。

# Token efficiency comparison (approximate)
# English: "Hello, how are you?"
#   → 5 tokens: ["Hello", ",", " how", " are", " you?"]

# Chinese: "你好,你怎么样?"
#   → 可能 10+ tokens, 因为每个汉字可能被拆分为多个 byte tokens

# This means: for the same context window (e.g., 4096 tokens),
# Chinese text carries less information than English text.

Token 效率的实际影响

Token 效率不仅影响模型处理文本的能力,还直接影响 API 调用成本。如果一段中文文本需要 3 倍于英文的 token 数量,那么使用按 token 计费的 API 时,中文用户的成本就是英文用户的 3 倍。这也是为什么 LLaMA 3 大幅扩充了词表——为了提升非英语语言的 token 效率。

7.3 Tokenization 的陷阱

Tokenization 的一些已知问题:

8. 实践:实现你自己的 BPE Tokenizer

在 Assignment 1 中,你需要从零实现一个 BPE Tokenizer。以下是一些实现建议:

8.1 数据结构选择

# Core data structures for BPE training
class BPETokenizer:
    def __init__(self):
        # Merge rules: ordered list of (pair, new_token)
        self.merges: list[tuple[tuple[int, int], int]] = []

        # Vocabulary: token_id -> bytes
        self.vocab: dict[int, bytes] = {
            i: bytes([i]) for i in range(256)
        }

        # Reverse vocab for encoding
        self.token_to_id: dict[bytes, int] = {
            bytes([i]): i for i in range(256)
        }

    def train(self, text: str, vocab_size: int):
        """Train BPE on the given text."""
        # Pre-tokenize
        chunks = pre_tokenize(text)

        # Convert to byte sequences
        sequences = [list(chunk.encode('utf-8')) for chunk in chunks]

        while len(self.vocab) < vocab_size:
            # Count all adjacent pairs
            pair_counts = count_pairs(sequences)
            if not pair_counts:
                break

            # Find the most frequent pair
            best_pair = max(pair_counts, key=pair_counts.get)

            # Create new token
            new_id = len(self.vocab)
            new_bytes = self.vocab[best_pair[0]] + self.vocab[best_pair[1]]

            # Update data structures
            self.vocab[new_id] = new_bytes
            self.token_to_id[new_bytes] = new_id
            self.merges.append((best_pair, new_id))

            # Apply merge to all sequences
            sequences = [
                apply_merge(seq, best_pair, new_id)
                for seq in sequences
            ]

8.2 性能优化提示

调试技巧

实现时建议先用一个非常小的语料(如 "aababcab")验证合并逻辑的正确性,然后逐步扩大到真实数据。可以参考 OpenAI 的 tiktoken 库来验证你的实现结果是否一致。

9. 本讲总结