从零构建大语言模型分词器从零实现 — 从原始文本到 Token ID

本篇导读

在博客 #03 中,我们理解了为什么神经网络需要"把文字变成数字"------离散的文本必须先转为连续的向量。但向量化不是一步到位的,中间还要经过一个关键的桥梁:Token ID

学完本篇你将能够:

  1. 用 Python 正则表达式从零实现一个分词器
  2. 理解词汇表(vocabulary)的本质和构建过程
  3. 编写一个完整的 SimpleTokenizerV1 类,包含 encodedecode 方法
  4. 看清简单分词器的局限性------为什么它处理不了未知词

1.分词

分词在 LLM 数据流水线中的位置

分词(Tokenization 是把输入文本拆分成独立 token 的过程,这是为 LLM 创建嵌入的必要预处理步骤。这些 token 要么是单独的词,要么是特殊字符(包括标点符号)。

本节涵盖的文本处理步骤在 LLM 上下文中的视图。我们把输入文本拆分为独立的 token------它们要么是词、要么是标点等特殊字符。后续小节会把文本转换为 token ID 并创建 token 嵌入。

整个数据流水线是这样的:

复制代码
原始文本 ──[分词]──▶ Token 列表 ──[查词汇表]──▶ Token ID 列表 ──[嵌入层]──▶ 向量
   ↑               ↑                  ↑                       ↑
本节起点        本节终点           下一节内容             第 2.7 节内容

训练文本:Edith Wharton 的短篇小说

我们将使用 Edith Wharton 的短篇小说《The Verdict》作为分词的练习文本。这篇小说已进入公有领域,可以合法用于 LLM 训练任务。原文可从 Wikisource 获取,作者已将其保存为 the-verdict.txt 文件。

📌 原书 Listing 2.1:将短篇小说读入 Python

python 复制代码
with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()
print("Total number of character:", len(raw_text))
print(raw_text[:99])

输出:

复制代码
Total number of character: 20479
I HAD always thought Jack Gisburn rather a cheap genius--though a good fello

我们的目标是把这 20,479 个字符的短篇小说分词为独立的词和特殊字符,作为后续章节嵌入向量的输入。

💡 关于文本规模:实际 LLM 训练中通常处理数百万篇文章和数十万本书------数 GB 的文本。但出于教学目的,使用一本书这样的小样本就足以阐明文本处理的核心思想,并能在消费级硬件上合理时间内运行。

分词的三步演进

我们用 Python 的 re 正则表达式库逐步实现分词。注意:你不需要专门记忆任何正则语法------本章末尾会切换到现成的分词器(BPE)。

第一步:按空白字符拆分

python 复制代码
import re
text = "Hello, world. This, is a test."
result = re.split(r'(\s)', text)
print(result)

输出:

python 复制代码
['Hello,', ' ', 'world.', ' ', 'This,', ' ', 'is', ' ', 'a', ' ', 'test.']

问题 :词和标点仍粘在一起('Hello,''world.')。

💡 为什么不把所有文本转为小写? 大小写帮助 LLM 区分专有名词和普通名词理解句子结构生成正确大小写的文本。所以我们保留原始大小写。

第二步:把标点拆开 + 过滤空白

python 复制代码
result = re.split(r'([,.]|\s)', text)
result = [item for item in result if item.strip()]
print(result)

输出:

python 复制代码
['Hello', ',', 'world', '.', 'This', ',', 'is', 'a', 'test', '.']

💡 要不要保留空白? 移除空白能减少内存和计算开销。但如果训练对文本结构敏感的模型(例如 Python 代码,对缩进敏感),保留空白就有用。这里我们为简洁起见移除。

第三步:处理所有特殊字符(包括双破折号 --

python 复制代码
text = "Hello, world. Is this-- a test?"
result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
result = [item.strip() for item in result if item.strip()]
print(result)

输出:

python 复制代码
['Hello', ',', 'world', '.', 'Is', 'this', '--', 'a', 'test', '?']

完美------10 个独立 token。

图 1:分词的三步演进。从混杂着标点的原始文本,逐步过渡到干净的 Token 列表。

📌 我们目前实现的分词方案能把文本拆分为独立的词和标点字符。在此例中,示例文本被拆分为 10 个独立 token。

应用到全文

把这套规则用在《The Verdict》全文上:

python 复制代码
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(len(preprocessed))

输出 4649 ------ 全文拆分出 4,649 个 token(不含空白)。

打印前几个看看效果:

python 复制代码
print(preprocessed[:10])
# ['I', 'HAD', 'always', 'thought', 'Jack', 'Gisburn', 'rather', 'a', 'cheap', ...]

每个词和特殊字符都被整齐地分开了。

2.Token → ID 转换

为什么还要再转一次?

上一节我们得到了 token 列表(Python 字符串)。但神经网络处理的是数字,不是字符串------所以还需要一步:把每个 token 映射成一个唯一的整数 ID

这个映射关系存储在一个叫**词汇表(vocabulary)**的字典里。

词汇表构建过程

📌 原书 Figure 2.6 说明:我们通过对训练数据集的全部文本分词来构建词汇表。这些独立 token 按字母顺序排列、去除重复,然后聚合到一个词汇表中------它定义了从每个唯一 token 到唯一整数值的映射。

整个流程分三步:

图 2:词汇表的三步构建过程。完整训练文本经过分词、去重排序,最终每个唯一 token 获得一个整数 ID。

代码实现

第一步:得到所有唯一 token 并按字母排序

python 复制代码
all_words = sorted(list(set(preprocessed)))
vocab_size = len(all_words)
print(vocab_size)  # 1159

《The Verdict》的词汇表大小是 1,159 个唯一 token

第二步:用字典推导式构建词汇表

📌创建词汇表

python 复制代码
vocab = {token: integer for integer, token in enumerate(all_words)}

# 打印前 51 个条目看看
for i, item in enumerate(vocab.items()):
    print(item)
    if i > 50:
        break

输出:

复制代码
('!', 0)
('"', 1)
("'", 2)
...
('Has', 49)
('He', 50)

字典里每个 token 都关联着一个唯一的整数标签。下一步就是用这个词汇表把新文本转成 token ID。

📌 原书 Figure 2.7 说明:从一个新的文本样本开始,我们对它分词,再用词汇表把文本 token 转换为 token ID。词汇表从整个训练集构建,可以应用于训练集本身以及任何新的文本样本。

还需要反向映射

后面当我们想把 LLM 的输出(数字)转换回文本时,还需要一个反向词汇表------把 token ID 映射回对应的文本 token。

完整的 SimpleTokenizerV1 类

让我们实现一个完整的分词器类,包含:

  • encode 方法:文本 → token ID 列表
  • decode 方法:token ID 列表 → 文本

📌 实现简单文本分词器

python 复制代码
class SimpleTokenizerV1:
    def __init__(self, vocab):
        self.str_to_int = vocab                                # 正向:token → ID
        self.int_to_str = {i: s for s, i in vocab.items()}     # 反向:ID → token

    def encode(self, text):                                    # 文本 → ID
        preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
        preprocessed = [item.strip() for item in preprocessed if item.strip()]
        ids = [self.str_to_int[s] for s in preprocessed]
        return ids

    def decode(self, ids):                                     # ID → 文本
        text = " ".join([self.int_to_str[i] for i in ids])
        text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)        # 修复标点前的空格
        return text

逐行解读:

  • __init__:接收一个词汇表字典,同时建立正向和反向映射。
  • encode:复用第 2.2 节的分词逻辑,再查正向词汇表得到 ID 列表。
  • decode :查反向词汇表得到 token,用空格拼接,最后用正则修复标点前的多余空格(例如把 "Hello , world ." 修复为 "Hello, world.")。

图 3:分词器的两个核心方法。encode 将文本转为 ID 序列,decode 反过来还原文本。两者依赖同一个词汇表的正向和反向映射。

分词器实现共享两个通用方法------encode 接收文本、拆分 token、通过词汇表转换为 ID;decode 接收 ID、转换回 token、再连接成自然文本。

实测分词器

python 复制代码
tokenizer = SimpleTokenizerV1(vocab)

text = """"It's the last he painted, you know," 
           Mrs. Gisburn said with pardonable pride."""
ids = tokenizer.encode(text)
print(ids)

输出 ID 序列:

python 复制代码
[1, 58, 2, 872, 1013, 615, 541, 763, 5, 1155, 608, 5, 1, 69, 7, 39, 873, 1136, ...]

再用 decode 还原:

python 复制代码
print(tokenizer.decode(ids))
# '" It\' s the last he painted, you know," Mrs. Gisburn said with pardonable...'

完美还原------证明 encode 和 decode 是相互可逆的。

致命问题:未知词

到目前为止一切顺利。但试试一个不在训练集中的文本:

python 复制代码
text = "Hello, do you like tea?"
tokenizer.encode(text)

执行结果:

复制代码
KeyError: 'Hello'

问题在于 :单词 "Hello" 没有在《The Verdict》中出现过,所以不在词汇表里。这恰恰说明了------LLM 训练需要大规模、多样化的训练集来扩展词汇表

但即使训练集再大,永远会有从未见过的新词(人名、新造词、外语词等)。怎么彻底解决这个问题?这就是下一篇博客的主题。

3.本篇小结

概念 要点
分词(Tokenization) 把连续文本拆分成独立的 token(词 + 标点)
正则表达式分词 re.split 按空格、标点、双破折号拆分
词汇表(Vocabulary) 训练数据中所有唯一 token → 整数 ID 的字典映射
encode 方法 文本 → 分词 → 查词汇表 → 整数 ID 列表
decode 方法 整数 ID 列表 → 反向查词汇表 → 拼接还原文本
致命局限 词汇表外的词(OOV)会导致 KeyError
《The Verdict》数据 20,479 字符 → 4,649 token → 1,159 词汇表大小

4. 完整代码汇总

python 复制代码
import re

# 1. 读入原始文本
with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()

# 2. 分词(第 2.2 节)
preprocessed = re.split(r'([,.?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(f"共 {len(preprocessed)} 个 token")  # 4649

# 3. 构建词汇表(第 2.3 节)
all_words = sorted(list(set(preprocessed)))
vocab = {token: integer for integer, token in enumerate(all_words)}
print(f"词汇表大小: {len(vocab)}")  # 1159

# 4. SimpleTokenizerV1 类
class SimpleTokenizerV1:
    def __init__(self, vocab):
        self.str_to_int = vocab
        self.int_to_str = {i: s for s, i in vocab.items()}

    def encode(self, text):
        preprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)
        preprocessed = [item.strip() for item in preprocessed if item.strip()]
        return [self.str_to_int[s] for s in preprocessed]

    def decode(self, ids):
        text = " ".join([self.int_to_str[i] for i in ids])
        return re.sub(r'\s+([,.?!"()\'])', r'\1', text)

# 5. 测试
tokenizer = SimpleTokenizerV1(vocab)
sample = '"It\'s the last he painted, you know,"'
ids = tokenizer.encode(sample)
print(f"编码: {ids}")
print(f"解码: {tokenizer.decode(ids)}")

5.预习思考

  1. 为什么 decode 方法需要 re.sub(r'\s+([,.?!"()\'])', r'\1', text) 这一步?如果去掉会怎样?
  2. 如果训练文本里有"Hello",但有一个新词"Hi",词汇表无法处理。除了"扩大训练集",有没有更巧妙的方法?
  3. 为什么 GPT-2 的词汇表大小是 50,257,远大于我们这里的 1,159?这背后用到了什么不同的分词策略?
相关推荐
荪荪2 小时前
yolov8检测模型pt转rknn
人工智能·yolo·机器人·瑞芯微
mailangduoduo2 小时前
实战对比PyTorch VS PyTorch Lighting以MNIST为例
人工智能·pytorch·python·深度学习·图像分类·全连接网络
草青工作室2 小时前
AI大模型在软件研发的四个发展阶段
人工智能
Qy_cm2 小时前
pytorch+vit基础结构
人工智能·pytorch·python
nervermore9902 小时前
人工智能学习专栏
人工智能
人工智能AI技术2 小时前
预训练与微调:大模型基础工作模式解析
人工智能
字节跳动的猫2 小时前
2026 四款 AI:开发场景适配全面解析
前端·人工智能·开源
kisdiem2 小时前
DeepSeek-OCR 2:给人工智能更像人类的眼睛
人工智能·深度学习·计算机视觉
星马梦缘2 小时前
强化学习实战-2——Keras-DoubleDQN解决Predator【图像输入】
人工智能·python·jupyter·cnn·keras·强化学习·dqn