从零构建LLM 第2章:Tokenization与文本处理
1. 什么是词嵌入 (Word Embeddings)
LLM 不认识文字,只认识数字。但把「猫」编码为 1、「狗」编码为 2,这种简单映射无法表达语义关系 —— 模型不知道「猫」和「狗」都是动物,而「桌子」是家具。
嵌入 (Embedding) 解决了这个问题:它把每个词映射到高维空间中的一个向量。语义相近的词,在这个空间里距离也更近。下图用一个简化的二维空间来示意:
本章的核心流程就是:原始文本 → Token → Token ID → 嵌入向量。下面这张图展示了第2章在整体LLM构建中的位置:
2. 手搓一个分词器
分词 (Tokenization) 是把一段文本拆分成更小单元(Token)的过程。最直觉的做法是按空格拆分,但我们还需要处理标点符号。书中用正则表达式逐步完善分词逻辑:
2.1 正则表达式分词
核心思路:用正则表达式的捕获组把标点符号也当作独立 token 分出来。
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 列表 → 文本)两个核心方法:
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
ℹ️ 关键概念:分词器的本质就是一个双向映射表 — 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|> 代替 |
GPT-2 的设计哲学是极简:它只用了一个 <|endoftext|> 特殊 token,没有 [UNK](因为 BPE 能把任何词拆成子词),没有 [PAD](训练时用 attention mask 忽略填充位置)。
4. BPE:GPT-2 的分词方案
前面的 SimpleTokenizer 基于「整词匹配」— 词汇表里没有的词就没办法处理。而 Byte Pair Encoding (BPE) 彻底解决了这个问题:它把未知词分解为更小的子词 (subword) 甚至字符。
实际使用中,我们不需要从头实现 BPE — OpenAI 开源了 tiktoken 库(核心用 Rust 编写,速度很快):
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)。
书中用滑动窗口 (Sliding Window) 的方式从长文本中提取训练样本。两个关键参数:max_length(窗口大小 / 上下文长度)和 stride(每次滑动的步长)。
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,直接查出对应的向量。这个表是模型参数的一部分,在训练中不断更新。
6.2 位置嵌入 (Positional Embedding)
GPT-2 使用绝对位置嵌入:为序列中的每个位置(0, 1, 2, …)也学习一个向量。最终的输入嵌入 = Token 嵌入 + 位置嵌入:
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 层。
本文是 Build a Large Language Model From Scratch (Sebastian Raschka) 的学习笔记。所有配图版权归原作者所有。代码基于原书示例,有简化和中文注释。