从零构建LLM 第2章:Tokenization与文本处理

📅 2026-03-02📖 ~8 min readLLMNLPTokenization
本文是 Build a Large Language Model From Scratch(Sebastian Raschka 著)第2章的学习笔记。在大语言模型能「理解」文字之前,它需要先把文字变成数字。本章从最基础的正则表达式分词开始,一路走到 BPE 编码滑动窗口数据加载,最终生成可以喂给 LLM 的嵌入向量
Ch3: 注意力机制 →

1. 什么是词嵌入 (Word Embeddings)

LLM 不认识文字,只认识数字。但把「猫」编码为 1、「狗」编码为 2,这种简单映射无法表达语义关系 —— 模型不知道「猫」和「狗」都是动物,而「桌子」是家具。

嵌入 (Embedding) 解决了这个问题:它把每个词映射到高维空间中的一个向量。语义相近的词,在这个空间里距离也更近。下图用一个简化的二维空间来示意:

2D embedding space visualization

二维嵌入空间示意 — 实际 LLM 使用数百甚至上千维 (图源: LLMs from Scratch)

本章的核心流程就是:原始文本 → Token → Token ID → 嵌入向量。下面这张图展示了第2章在整体LLM构建中的位置:

Chapter 2 overview in LLM pipeline

第2章负责将原始文本转化为 LLM 可以处理的输入格式 (图源: LLMs from Scratch)

2. 手搓一个分词器

分词 (Tokenization) 是把一段文本拆分成更小单元(Token)的过程。最直觉的做法是按空格拆分,但我们还需要处理标点符号。书中用正则表达式逐步完善分词逻辑:

Tokenization concept

分词将文本拆分为独立的 token (图源: LLMs from Scratch)

2.1 正则表达式分词

核心思路:用正则表达式的捕获组把标点符号也当作独立 token 分出来。

Python
import re

text = "Hello, world. Is this-- a test?"

# 用捕获组 () 把标点也拆出来作为独立token
result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
result = [item.strip() for item in result if item.strip()]
print(result)
# ['Hello', ',', 'world', '.', 'Is', 'this', '--', 'a', 'test', '?']

有了分词结果,下一步是建立词汇表 (Vocabulary):把所有唯一 token 排序后,分配一个整数 ID。

2.2 实现 SimpleTokenizerV1

书中实现了一个完整的分词器类,包含 encode(文本 → ID 列表)和 decode(ID 列表 → 文本)两个核心方法:

Python — SimpleTokenizerV1
class SimpleTokenizerV1:
    def __init__(self, vocab):
        self.str_to_int = vocab  # {"word": 0, "hello": 1, ...}
        self.int_to_str = {i: s for s, i in vocab.items()}

    def encode(self, text):
        # 分词:把文本拆成token列表
        preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
        preprocessed = [item.strip() for item in preprocessed if item.strip()]
        # 查表:token → 整数ID
        ids = [self.str_to_int[s] for s in preprocessed]
        return ids

    def decode(self, ids):
        # 反查:整数ID → token,再拼回文本
        text = " ".join([self.int_to_str[i] for i in ids])
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)
        return text
Token to ID mapping

每个 token 通过词汇表映射到唯一的整数 ID (图源: LLMs from Scratch)

ℹ️ 关键概念:分词器的本质就是一个双向映射表 — encode 查正向表 (str→int),decode 查反向表 (int→str)。整个过程是确定性的、无损的(理想情况下)。


3. 特殊 Token 的妙用

SimpleTokenizerV1 有个致命问题:如果碰到词汇表里没有的词(比如 “Hello”),直接报 KeyError。为此需要引入特殊 Token (Special Tokens)

特殊 Token 用途 GPT-2 是否使用
<|unk|> 代替词汇表中不存在的未知词 不使用(BPE 不需要)
<|endoftext|> 标记文档边界,分隔不同的训练文本 使用
[BOS] 标记序列开头 (Beginning of Sequence) 不使用
[PAD] 填充短序列到统一长度 <|endoftext|> 代替
endoftext token usage

<|endoftext|> 用于分隔训练集中的不同文档 (图源: LLMs from Scratch)

GPT-2 的设计哲学是极简:它只用了一个 <|endoftext|> 特殊 token,没有 [UNK](因为 BPE 能把任何词拆成子词),没有 [PAD](训练时用 attention mask 忽略填充位置)。


4. BPE:GPT-2 的分词方案

前面的 SimpleTokenizer 基于「整词匹配」— 词汇表里没有的词就没办法处理。而 Byte Pair Encoding (BPE) 彻底解决了这个问题:它把未知词分解为更小的子词 (subword) 甚至字符。

BPE subword breakdown

BPE 将未知词拆分为已知的子词单元 (图源: LLMs from Scratch)

实际使用中,我们不需要从头实现 BPE — OpenAI 开源了 tiktoken 库(核心用 Rust 编写,速度很快):

Python — tiktoken BPE
import tiktoken

# 加载 GPT-2 的 BPE 分词器
tokenizer = tiktoken.get_encoding("gpt2")

text = "Hello, do you like tea? <|endoftext|> In the sunlit terraces"

# encode: 文本 → token ID 列表
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(integers)
# [15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114]

# decode: token ID 列表 → 文本
print(tokenizer.decode(integers))
# "Hello, do you like tea? <|endoftext|> In the sunlit terraces"

⚠️ BPE 的核心思想:先把所有文本拆成单个字节(字符),然后反复合并最频繁出现的字节对,形成越来越长的子词。GPT-2 的词汇表有 50,257 个 token —— 包括基础字符、常见子词和完整单词。这样任何输入文本都能被编码,不存在 “unknown word” 问题。


5. 滑动窗口:训练数据的组织方式

LLM 的训练目标是预测下一个 token。所以训练数据需要成对出现:输入序列 (input) 和向右偏移一位的目标序列 (target)。

Next word prediction

输入和目标相差一个位置 — 目标就是「下一个词」(图源: LLMs from Scratch)

书中用滑动窗口 (Sliding Window) 的方式从长文本中提取训练样本。两个关键参数:max_length(窗口大小 / 上下文长度)和 stride(每次滑动的步长)。

Sliding window approach

滑动窗口以固定步长从文本中提取训练样本 (图源: LLMs from Scratch)
Python — GPTDatasetV1 (核心逻辑)
class GPTDatasetV1(Dataset):
    def __init__(self, txt, tokenizer, max_length, stride):
        self.input_ids = []
        self.target_ids = []

        # 先把整个文本编码成 token ID 序列
        token_ids = tokenizer.encode(txt, allowed_special={"<|endoftext|>"})

        # 滑动窗口:每次取 max_length 个 token 作为输入
        # 目标是输入向右偏移 1 位
        for i in range(0, len(token_ids) - max_length, stride):
            input_chunk = token_ids[i:i + max_length]
            target_chunk = token_ids[i + 1: i + max_length + 1]
            self.input_ids.append(torch.tensor(input_chunk))
            self.target_ids.append(torch.tensor(target_chunk))

stride < max_length 时,窗口之间有重叠 — 同一段文本会被多次采样,有助于学习但也可能导致过拟合。当 stride = max_length 时,窗口不重叠,每个 token 只出现一次。


6. Token 嵌入 + 位置编码 = 输入嵌入

到目前为止,我们有了 token ID 序列。但 LLM 需要的是连续向量,而且相同的词在不同位置应该有不同的表示(「我吃饭」和「饭吃我」含义完全不同)。

6.1 Token 嵌入层 (Token Embedding)

Embedding 层本质上是一个查找表:给定 token ID,直接查出对应的向量。这个表是模型参数的一部分,在训练中不断更新。

Embedding lookup

Embedding 层:token ID → 向量,本质是矩阵行查找 (图源: LLMs from Scratch)

6.2 位置嵌入 (Positional Embedding)

GPT-2 使用绝对位置嵌入:为序列中的每个位置(0, 1, 2, …)也学习一个向量。最终的输入嵌入 = Token 嵌入 + 位置嵌入

Token + positional embeddings

Token 嵌入与位置嵌入相加,形成最终输入 (图源: LLMs from Scratch)
Python — 生成输入嵌入
vocab_size = 50257   # GPT-2 词汇表大小
output_dim = 256     # 嵌入维度
context_length = 4   # 上下文长度

# Token 嵌入层:50257 个词,每个映射到 256 维向量
token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)

# 位置嵌入层:4 个位置,每个也是 256 维向量
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)

# token_embeddings.shape = [batch_size, context_length, 256]
token_embeddings = token_embedding_layer(inputs)

# pos_embeddings.shape = [context_length, 256],通过广播相加
pos_embeddings = pos_embedding_layer(torch.arange(context_length))

# 最终输入嵌入 = token嵌入 + 位置嵌入
input_embeddings = token_embeddings + pos_embeddings
# input_embeddings.shape = [8, 4, 256]

7. 为什么这很重要

文本预处理看起来是 LLM 中最「不性感」的部分,但它直接影响模型的能力边界:

  • Prompt 工程:理解 tokenization 有助于估算 token 数量、优化 prompt 长度、避免在 token 边界处截断关键信息。同一句话用不同 tokenizer 产生的 token 数可能差很多。
  • 成本优化:API 按 token 计费。中文由于 BPE 训练语料偏英文,同等含义的中文消耗的 token 数通常是英文的 2-3 倍。
  • 多语言能力:tokenizer 的训练语料决定了模型对不同语言的处理效率。这也是为什么 GPT 对中文的 token 效率低于英文。
  • 上下文窗口:滑动窗口的 max_length 决定了模型能「看到」多远。GPT-2 的上下文长度是 1024 个 token,现代模型已经扩展到 128K 甚至更长。
  • 位置编码方式:GPT-2 使用的绝对位置嵌入限制了推理时的最大长度。后来的 RoPE(旋转位置编码)等方案解决了这个问题,支持在更长序列上泛化。

ℹ️ 总结 — 第2章完整流程:原始文本 → 正则分词 (或 BPE) → Token ID 序列 → 滑动窗口组织为 (input, target) 对 → Token Embedding + Positional Embedding → 输入嵌入向量,准备送入 Transformer 的 Attention 层。

Complete pipeline summary

第2章完整的数据处理流程总结 (图源: LLMs from Scratch)

本文是 Build a Large Language Model From Scratch (Sebastian Raschka) 的学习笔记。所有配图版权归原作者所有。代码基于原书示例,有简化和中文注释。

Ch3: 注意力机制 →

Related Posts