本文目录34 个章节
本章导读: 很多初学者在初次接触大模型时,往往急于跳进注意力机制(Attention)和深度网络,却把最基础的“分词器(Tokenizer)”当作一个可有可无的黑盒工具。 殊不知,分词器是大模型与人类世界交流的唯一咽喉要道。如果分词器设计糟糕,模型连最基本的数数、代码缩进都学不会,甚至会因为词表外词(OOV)而频频报出乱码。 现代最顶尖的大模型(GPT-4、LLaMA-3、Qwen-2.5)无一例外全部采用 Byte-Level BPE(字节级字节对编码)。 本章将像一位耐心的编程导师,带你从计算机最底层的 ASCII 码和 UTF-8 变长字节谈起,手把手带你推导算法,并一行代码一行代码地手写出一个生产级的纯 Python 分词器。
1.1 寻根溯源:计算机到底是怎么看文字的?
在写任何一行代码之前,我们必须先在脑海中建立起计算机世界的物理图景。
1.1.1 硅基芯片的物理局限
计算机的 CPU 和 GPU 芯片本质上是数十亿个微型晶体管组成的电路开关。
开关只有两种状态:开(1) 和 关(0)。
计算机连一个英文字母 'a' 都不认识,它只能存储和运算二进制数字。
为了在屏幕上显示人类语言,人类给每一个文字符号编造了一个唯一的数字代号:
| 编码演进代际 | 核心设计原理 | 典型字符编码示例 | 优势与历史局限性 |
|---|---|---|---|
| 第一代:ASCII (1960s) | 仅用 7 位二进制 () 记录字符 | 字符 'A' 65 (二进制 01000001) | 内存极其紧凑(仅 1 字节),但面对中文、日文、阿拉伯文等非拉丁语系彻底抓瞎。 |
| 第二代:Unicode 万国码 (1990s) | 为全球所有语言符号分配唯一数学编号(码点 Code Point) | 汉字 '中' U+4E2D (十进制 20013)<br>Emoji '🚀' U+1F680 (十进制 128640) | 统一了全球文字,但若固定用 4 字节存储,全英文文本体积将瞬间暴涨 4 倍,浪费极大。 |
| 第三代:UTF-8 变长编码 (工业标准) | 动态变长前缀码:根据字符码点动态分配 1 ~ 4 个字节 | 英文占 1 字节('A' [65])<br>汉字占 3 字节('中' [228, 184, 173])<br>Emoji 占 4 字节('🚀' [240, 159, 154, 128]) | 黄金平衡:向前完美兼容 ASCII,紧凑无浪费,成为现代互联网与现代大模型的通用基石! |
1.1.2 深入 UTF-8 的二进制底层:拿具体文字开刀
让我们在 Python 终端里输入几行代码,亲眼看看人类文字在计算机内存里到底长什么样:
# 1. 英文只占 1 个字节 (十进制 65)
print(list("A".encode("utf-8"))) # 输出: [65]
# 2. 汉字占 3 个字节!
print(list("中".encode("utf-8"))) # 输出: [228, 184, 173]
# 3. 火箭 Emoji 占 4 个字节!
print(list("🚀".encode("utf-8"))) # 输出: [240, 159, 154, 128]请停下来仔细观察这三个输出! 你会发现一个惊人的事实: 不论是英文、中文还是 Emoji,它们在计算机底层全部由 的整数(单个字节 Byte)构成!
- 字母
'A'是 1 个字节; - 汉字
'中'是由 228、184、173 这 3 个连续的字节拼成的; - 火箭 Emoji
'🚀'是由 240、159、154、128 这 4 个字节拼成的。
这就是大模型分词器最重要的地基!
1.2 为什么传统分词会走向死局?
在自然语言处理早期,科学家们尝试过两种最直观的分词方式,但都付出了惨痛的代价:
| 分词方案类型 | 切分机制举例 ("unbelievable") | 核心优势 | 致命缺陷 |
|---|---|---|---|
| 1. 词级分词 (Word-Level) | ["unbelievable"](字典直接收录整词) | 直观符合人类直觉 | 1. 词表无限膨胀(几百万词收不全)<br>2. 遇到新词/打错字输出 <UNK> 乱码 |
| 2. 字符级分词 (Char-Level) | ['u', 'n', 'b', 'e', 'l', ...](只认单字母或单字) | 基础字符集很小(几十到几千) | 1. 序列极度膨胀(一个单词占 12 个位置)<br>2. 极易撑爆上下文窗口,单字母语义贫瘠 |
| 3. 字节级 BPE (Byte-Level BPE) | ["un", "believ", "able"](256 原子 + 贪心合并分子) | 黄金平衡:高频词直接合并压缩,生僻词退化为单字节拼合 | 完美攻克前两者缺陷,现代大模型唯一工业级通用标配方案 |
💡 核心认知:化学世界的“原子与分子”
- 为什么永远不会报错未知词
<UNK>?- 在 Byte-Level BPE 中,我们直接把 256 个基础字节(数值 )作为词表的第 0 号到第 255 号基本零件。
- 既然世界上任何文字在计算机里都不过是这 256 个字节的组合,那么即使遇到火星文或者刚刚发明的网络新梗,分词器大不了退化成单字节输出,永远不可能出现“我不认识这个字”的崩溃!
- 怎么节约长度?
- 如果两个字节经常连在一起出现(比如
't'和'h'),算法就会把它们合成一个新分子'th'; - 汉字
'中'的 3 个字节经常手拉手出现,算法就会把这 3 个字节直接融合成一个单独的 Token 编号!
- 如果两个字节经常连在一起出现(比如
1.3 纸上推演:BPE 算法是怎么被发明出来的?
我们不看任何晦涩的论文公式,拿一张纸、一支笔,在纸上亲自推演一轮完整的 BPE 算法。
初始状态
假设我们的迷你语料库里只有 4 个单词,右侧是它们在文章里出现的次数:
"low":出现 5 次"lower":出现 2 次"newest":出现 6 次"widest":出现 3 次
第一步:全部拆散为单个字母原子
在每个字母之间加上空格,把它们还原为最基础的状态:
l o w(5 次)l o w e r(2 次)n e w e s t(6 次)w i d e s t(3 次)
第二步:拿放大镜扫描全场,统计相邻二元对 (Pair) 的总频次
让我们像侦探一样,统计所有紧挨在一起的两个符号一共出现了多少次:
(e, s):在"newest"里出现 6 次,在"widest"里出现 3 次 合计 9 次!(s, t):在"newest"里出现 6 次,在"widest"里出现 3 次 合计 9 次!(l, o):在"low"里出现 5 次,在"lower"里出现 2 次 合计 7 次。(o, w):在"low"里出现 5 次,在"lower"里出现 2 次 合计 7 次。
谁是全场出现频次最高的冠军?
很明显,是 (e, s)(9 次)!
第三步:颁发全新工牌,执行第一次合并!
我们把全场所有的 e 和 s 紧紧焊接在一起,创造一个全新 Token:"es"!
此时,语料库变成了:
l o wl o w e rn e w [es] t(6 次)w i d [es] t(3 次)
第四步:进入下一轮循环!
现在全场最高频的相邻对变成了谁?
是刚刚诞生的 ([es], t)!它在两个单词里各出现 6 次和 3 次,合计又是 9 次!
我们立刻把它再次合并,诞生了英文里最著名的最高级后缀:"est"!
看到其中的奥妙了吗? BPE 算法在完全不需要任何语言学语法词典的情况下,纯粹靠概率统计,自然而然地挖出了语言中高频的词根、词缀和常见单词!
1.4 预分词正则:为什么合并前必须先用“切菜刀”?
很多初学者自己动手写 BPE 时,经常会遇到一个莫名其妙的严重 Bug:
句号、逗号会和单词黏糊糊地连在一起,比如把 "apple." 或者 "cat," 合并成了一个单独的词!
===================================================================================
未加预分词 (No Pre-tokenization) 导致的灾难
===================================================================================
语料里有两句话:
"I eat an apple. You eat an apple."
BPE 统计全局文本时发现: "apple" 后面经常跟着 "."
结果: BPE 强行把它们焊死在了一起,生成了一个新 Token: "apple." (带句号的苹果)
灾难后果:
下次用户向模型提问: "Do you like apple?" (句末是问号)
此时模型查遍词表,只认识带句号的 "apple.",根本无法复用 "apple" 的语义!
===================================================================================💡 切菜刀原理:预分词正则 (Pre-tokenization)
为了解决这个问题,现代 GPT 和 LLaMA 都会在合并前使用正则表达式切菜刀: 先按语义把一整段长文本切成独立的食材块:
- 单词独立一块(如
"apple") - 标点符号独立一块(如
","、"."、"?") - 空格独立一块
- 数字独立一块
核心铁律:BPE 合并只允许在各自独立的食材块内部进行,严禁跨越标点和单词的边界!
经典预分词正则表达式解剖:
SPLIT_REGEX = re.compile(r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")'(?:[sdmt]|ll|ve|re):把英文紧缩词摘出来(如I've里的've,it's里的's);?\p{L}+:匹配可选前置空格 + 连续文字(中英文汉字与字母);?\p{N}+:匹配数字(保证100不会被乱黏连);?[^\s\p{L}\p{N}]+:匹配标点符号和各种 Emoji 符号。
1.5 一行代码一行代码手写分词器
现在,我们把上面的理论变成结结实实的 Python 代码。
请打开工作区中的 src/tokenizer.py。我们将分模块逐行剖析它是如何搭建起来的。
模块 1:类的初始化与基础地基搭建
class ByteBpeTokenizer:
SPLIT_REGEX = SPLIT_REGEX
def __init__(self):
# 1. 必须准备的 4 个特殊标记 (Special Tokens)
self.special_tokens = ["<pad>", "<s>", "</s>", "<unk>"]
self.pad_token_id = 0 # 占位补齐符:用于短文本补齐
self.bos_token_id = 1 # 句子开始符 (Beginning of Sequence)
self.eos_token_id = 2 # 句子结束符 (End of Sequence)
self.unk_token_id = 3 # 兜底未知符
# 2. 映射字典
self.vocab: Dict[int, bytes] = {} # 数字 ID -> 原始字节串
self.token_to_id: Dict[bytes, int] = {} # 原始字节串 -> 数字 ID
self.merges: Dict[Tuple[int, int], int] = {} # 记录合并规则: (id_a, id_b) -> 新合并ID
# 3. 初始化地基
self._init_base_vocab()导师提问:为什么需要特殊符号?
- 比如模型在自回归生成时,怎么知道一篇文章什么时候写完了?就是靠输出了
</s>(EOS)这个结束符号! - 当模型吐出
</s>时,外层的while循环就会立刻触发break停机。
接下来看地基函数 _init_base_vocab:
def _init_base_vocab(self):
"""装入 4 个特殊标记和 256 个基础字节原子"""
current_id = 0
# 先存特殊标记 (占用 ID 0, 1, 2, 3)
for st in self.special_tokens:
b_st = st.encode("utf-8")
self.vocab[current_id] = b_st
self.token_to_id[b_st] = current_id
current_id += 1
# 紧接着存入 0 ~ 255 的基础单字节 (占用 ID 4 ~ 259)
for b in range(256):
b_byte = bytes([b])
self.vocab[current_id] = b_byte
self.token_to_id[b_byte] = current_id
current_id += 1这段代码执行完毕后,分词器在起跑线上就已经拥有了 260 个基础词汇(4 个特殊标记 + 256 个字节原子)。
模块 2:训练核心——寻找最高频 Pair 并实施合并
训练分词器的过程,就是把语料库反复遍历、不断寻找最高频 Pair 的过程:
def train(self, texts: List[str], target_vocab_size: int = 4096, verbose: bool = True):
# 第一步:预分词。把所有句子切成块,并转为基础字节的数字列表
words = []
for text in texts:
for match in self.SPLIT_REGEX.finditer(text):
chunk = match.group()
# 将切块编码成字节,并查表转为初始 ID 列表
token_ids = [self.token_to_id[bytes([b])] for b in chunk.encode("utf-8")]
if token_ids:
words.append(token_ids)
# 第二步:迭代合并。需要合并的次数 = 目标词表大小 - 当前已有大小
num_merges = target_vocab_size - self.vocab_size
for step in range(num_merges):
pair_counts: Dict[Tuple[int, int], int] = {}
# 扫描所有单词,统计相邻 pair 出现次数
for word in words:
for i in range(len(word) - 1):
pair = (word[i], word[i + 1])
pair_counts[pair] = pair_counts.get(pair, 0) + 1
if not pair_counts:
break # 没有可合并的了,提前结束
# 找出出现次数最多的冠军 Pair
best_pair = max(pair_counts, key=pair_counts.get)
if pair_counts[best_pair] < 2:
break # 如果最高频的 pair 才出现 1 次,合并毫无价值,停机
# 产生新 Token 并注册到字典
new_id = self.vocab_size
self.merges[best_pair] = new_id
new_bytes = self.vocab[best_pair[0]] + self.vocab[best_pair[1]]
self.vocab[new_id] = new_bytes
self.token_to_id[new_bytes] = new_id
# 就地替换:在原数据集中把所有的 best_pair 替换为 new_id
new_words = []
for word in words:
i = 0
new_word = []
while i < len(word):
if i < len(word) - 1 and (word[i], word[i + 1]) == best_pair:
new_word.append(new_id)
i += 2 # 跨越两位
else:
new_word.append(word[i])
i += 1
new_words.append(new_word)
words = new_words模块 3:编码 (Encode) 与解码 (Decode)
编码:文本 数字
当我们输入一句话时,分词器怎么把学到的规则应用上去?
def encode(self, text: str, add_bos: bool = False, add_eos: bool = False) -> List[int]:
result = []
if add_bos:
result.append(self.bos_token_id) # 句子开头加上 <s>
for match in self.SPLIT_REGEX.finditer(text):
chunk = match.group()
# 1. 拆成单字节 ID
chunk_ids = [self.token_to_id[bytes([b])] for b in chunk.encode("utf-8")]
# 2. 按照学到的合并规则逐级合并
merged_ids = self._merge_token_sequence(chunk_ids)
result.extend(merged_ids)
if add_eos:
result.append(self.eos_token_id) # 句子末尾加上 </s>
return result解码:数字 文本
解码是编码的完美逆过程:将数字 ID 查表换回字节,然后一次性拼装解出 UTF-8 字符串:
def decode(self, token_ids: List[int], skip_special_tokens: bool = True) -> str:
byte_chunks = []
for tid in token_ids:
# 忽略掉 <s>, </s>, <pad> 等控制符号
if skip_special_tokens and tid in [self.pad_token_id, self.bos_token_id, self.eos_token_id, self.unk_token_id]:
continue
if tid in self.vocab:
byte_chunks.append(self.vocab[tid])
# 核心:将所有散落的字节拼成完整的 bytes 数组,再反解出汉字与 Emoji
all_bytes = b"".join(byte_chunks)
return all_bytes.decode("utf-8", errors="replace")1.5 工业级实战演进:预分词边界陷阱与标点吸附技术 (Fused Punctuation)
在实际大模型落地(尤其是古诗词、代码、数学等高密度符号场景)中,我们往往会遭遇一个极其隐蔽而致命的架构陷阱:标点符号概率偏置与单字孤立震荡。
1.5.1 隐蔽的 GPT-2 正则边界陷阱
经典 GPT-2 / GPT-4 的预分词正则表达式如下:
SPLIT_REGEX = re.compile(r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")?\p{L}+:将纯字母和纯汉字切分为词块;?[^\s\p{L}\p{N}]+:将标点符号强制切分为独立的词块!
灾难产生:
当输入古诗 "床前明月光,疑是地上霜。" 时,分词器在预分词阶段就会硬生生切分成:
["床前明月光", ",", "疑是地上霜", "。"]
因为 BPE 合并算法绝不允许跨越 Chunk 寻找字符对,这意味着:
无论你的语料规模多大、BPE 迭代多少万次,汉字和后面的逗号/句号都绝对不可能合并成一个 Token!
词表中永远只有孤立的汉字 霜 和孤立的句号 。。
在大模型自回归采样时,由于诗歌中逗号和句号占到了全语料的 12.5% 以上,模型一旦吐出单个汉字,下一步立刻被超高概率的孤立标点所吸引,诱发了“单字+标点”的病态震荡!
1.5.2 解决方案:标点吸附正则 (Fused Punctuation)
破解该物理隔离的最优雅手段,是微调预分词正则表达式,允许汉字在分块时贪婪吸附紧随其后的标点符号:
# 标点吸附正则:允许汉字词块末尾吸收一个中文标点(,。!?;)
SPLIT_REGEX = re.compile(
r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+(?:[,。!?;])?| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
)1.5.3 带来的三大降维打击收益
- 彻底根除标点诱惑(实测孤立标点暴降 93%):
在本项目 46 万首纯诗库实测中,标点吸附配合 4096 词表训练,使全语料的孤立标点(逗号/句号)占比从 13.11% 直降至 0.96%!
"光,"(Token 2929)、"霜。"(Token 2207)被自然合并为单个复合 Token。模型在生成句末最后一个汉字时,一步决策定音,不存在“写完字后发呆吐出孤立标点”的概率空档! - 训练与推理提速 15%~20%: 一首 28 字的七言绝句原本需要 32 个 Token,合并后整整缩减至 28 个 Token,不仅上下文窗口更加宽裕,算力吞吐量也得到直接飞跃!
- 完美匹配 0.04B 参数预算: 4096 词表仅占 Embedding 参数(仅占 0.04B 全模型的 5.5%),省下 2.1M 参数反哺深层 Transformer 隐藏层。
1.6 工业级实战进阶:结构化特殊 Token (Special Tokens) 与原子保护机制 (避坑指南 1)
在实际的大模型工业体系中,除了普通自然语言文字外,我们还需要引导模型理解系统结构边界(如文本起止、填充对齐、元数据提示等)。 这就是特殊标记(Special Tokens)的用武之地。
1.6.1 特殊 Token 的架构定义
在本项目中,我们为 0.04B Mini-LLaMA 定义了 8 个保留特殊标记:
DEFAULT_SPECIAL_TOKENS = [
"<pad>", # ID 0: 批次填充占位符
"<s>", # ID 1: 文本起始符 (BOS)
"</s>", # ID 2: 文本结束符 (EOS)
"<unk>", # ID 3: 未知字符标记 (Byte-Level 下作为冗余占位)
"<|title|>", # ID 4: 诗词题目引导标记
"<|author|>", # ID 5: 作者姓名引导标记
"<|dynasty|>", # ID 6: 朝代归属引导标记
"<|content|>" # ID 7: 诗词正文起始标记
]紧接着这 8 个特殊标记,是 256 个基础字节(ID 8 ~ 263),再往后(ID 264 ~ 4095)则是 BPE 贪心合并出的诗歌文学词块。
1.6.2 避坑指南 1:特殊标记击穿陷阱 (Special Token BPE Split)
很多初学者实现自定义分词器时,最容易犯的一个隐蔽错误是:
“在词表中声明了
<|title|>,但调用encode('<|title|>')时,分词器却吐出了一串子词 ID!”
致命根因:预分词正则击穿
回顾 1.5 节的预分词正则 SPLIT_REGEX:
SPLIT_REGEX 会把文本切分成字母、汉字、标点符号。
当你传入 <|title|> 时:
<|被切为非文字符号;title被切为英文单词;|>被切为非文字符号。
结果是:你的特殊标记还没进入 BPE 合并逻辑,就在第一步被正则表达式强行撕成了 3 段碎片!
最终,模型根本没看到你精心设计的 ID 4(<|title|>),而是收到了由 ASCII 字节拼凑的一堆散乱碎屑,导致条件预训练彻底失效。
工业级解法:原子级正则提取保护 (Atomic Split Protection)
解法非常巧妙:在文本被送入 SPLIT_REGEX 预切分之前,先通过一个由特殊标记组成的专用正则表达式,将文本切分为“特殊标记”与“普通文本段”:
def _update_special_pattern(self):
"""按长度降序贪婪编译特殊标记正则"""
if self.special_tokens:
sorted_tokens = sorted(self.special_tokens, key=len, reverse=True)
pattern_str = "(" + "|".join(re.escape(st) for st in sorted_tokens) + ")"
self.special_token_pattern = re.compile(pattern_str)在 encode() 中实行原子保护:
if allowed_special and self.special_token_pattern:
parts = self.special_token_pattern.split(text)
for part in parts:
if not part:
continue
# 命中特殊标记:直接转为对应单一原子 ID,严禁拆散!
if part in self.special_tokens:
result.append(self.token_to_id[part.encode("utf-8")])
else:
# 普通文本才送进 SPLIT_REGEX 走常规 BPE 切分
self._encode_chunk(part, result)通过这一道保护闸门,无论文本多么复杂,特殊标记永远以“不可分割的单一原子 Token”精准送入神经网络,奠定了工业级条件预训练(Prefix Conditioning)的底层基石!
1.7 动手试炼:验证你的分词器
现在,在你的电脑终端里运行这个已经写好的单元测试:
python tests/test_tokenizer.py屏幕输出实况:
初始词表大小(含特殊标记与基础 256 字节): 264
[Tokenizer] 开始训练 BPE,初始词表大小: 264,目标词表大小: 320
[Tokenizer] 训练完成!最终词表大小: 276
--- 编解码结果 ---
原始文本: 从零手搓 Transformer 🚀 与 Tokenizer!
Token IDs: [1, 236, 195, 150, ..., 247, 196, 137, 2]
解码还原: 从零手搓 Transformer 🚀 与 Tokenizer!
--- 特殊 Token 原子提取与结构化元数据测试 ---
元数据序列: <|title|>登鹳雀楼<|author|>王之涣<|dynasty|>唐<|content|>白日依山尽,黄河入海流。</s>
元数据 Token IDs: [4, 239, 161, 195, 241, 193, 187, 241, 163, 136]...
跳过特殊标记还原: 登鹳雀楼王之涣唐白日依山尽,黄河入海流。
✅ Tokenizer 纯原生无损编解码与特殊 Token 原子保护校验 100% 通过!看!不仅复杂的中文和英文单词被准确切分并还原,甚至连占用 4 个字节的航天火箭 Emoji 🚀 与多达 8 个专用结构化 Special Tokens 也毫发无损地被完整保护!
1.8 导师小结与课后练习
通过本章的学习,你不仅学会了如何使用分词器,更在底层吃透了它的每一个字节运转机理:
- UTF-8 变长字节:是我们实现“无死角编码”的物理基石;
- 256 个原子字节:是保证大模型永远不会报出
<UNK>的免死金牌; - 标点吸附正则:化解了古典诗词中高达 13% 的孤立标点诱惑,将标点孤立震荡降低 93%;
- 特殊标记原子保护:建立了结构化元数据与大模型之间的无损通道。
💡 课后动手小实验
在 Python 终端里尝试执行:
tok = ByteBpeTokenizer.load("data/tokenizer.json")
ids = tok.encode("<|title|>静夜思<|author|>李白<|content|>床前明月光")
print("第 0 个 ID:", ids[0]) # 看看是否等于 4 (<|title|>)?思考一下:如果去掉 _update_special_pattern,<|title|> 会被编码成几个数字?
当钥匙已经打造完毕,数字序列准备就绪,下一章,我们将正式迈入大模型的殿堂级中枢——第 02 章|从零手写现代 Transformer 架构!
REFERENCES
参考链接
所属系列
从零开始手搓大模型