我们将直接深入 BPE 的数据流转,并严格推导出为什么它会导致中英文和代码处理效率的巨大差异。
一、 BPE 的底层合并逻辑:从字节到词表的完整推演
假设我们现在要从零训练一个 LLM 的分词器。我们的训练语料库非常小,只有四个词和它们的出现频次:
{"low": 5, "lower": 2, "newest": 6, "widest": 3}
第一步:字节级/字符级初始化
我们把所有单词打散成最基础的字符(在现代 LLM 中是 UTF-8 字节,为了直观这里先用字符),并在词尾加上一个特殊的结束符 </w>(明确单词边界)。
此时我们的初始词表(Vocabulary)只包含基础字母:l, o, w, e, r, n, s, t, i, d, </w>。
语料库的内部状态变成了这样:
l o w </w>: 5 次l o w e r </w>: 2 次n e w e s t </w>: 6 次w i d e s t </w>: 3 次
第二步:统计相邻对的全局频次
算法扫描整个语料库,统计所有相邻两个 Token 的出现频率。
我们发现 e 和 s 这个组合在 n e w e s t 中出现了 6 次,在 w i d e s t 中出现了 3 次。
总频次:es = 9 次。这是全场最高频的组合。
第三步:执行合并与更新词表
算法将 e 和 s 合并为一个全新的 Token es,并加入词表。
此时词表新增了 es。
语料库被强制更新为:
l o w </w>: 5l o w e r </w>: 2n e w es t </w>: 6w i d es t </w>: 3
第四步:无限循环,自底向上构建
算法继续统计。现在最高频的相邻组合是 es 和 t,总共出现 9 次。
合并 es 和 t 为 est。加入词表。
语料库更新为:
n e w est </w>: 6w i d est </w>: 3
接着可能合并 est 和 </w>,变成 est</w>。接着合并 l 和 o 变成 lo...
这个 while 循环会一直执行,直到词表的大小达到我们预设的阈值(比如 OpenAI 的 100,277 个 Token)。
二、 为什么 Agent 处理代码比处理中文更省 Token?
理解了上面的合并过程,你就能从根本上明白不同语言之间的"Token 不平等条约"是怎么来的。这主要由物理编码层 和统计概率层两个因素决定。
1. 物理编码层:UTF-8 的字节差异
现代 LLM 的 BPE 是基于 Byte(字节)的。
- 英文和代码 (ASCII) :每个英文字母或标点符号(如
a,b,{,;)在 UTF-8 中只占 1 个字节。 - 中文 :每个标准汉字(如"龙")在 UTF-8 中通常占 3 个字节 (例如
\xe9 \xbe \x99)。
这意味着,即使 BPE 什么都不做,中文在起跑线上的物理长度就是英文的 3 倍。
2. 统计概率层:训练语料的偏差
LLM(如 GPT-4, LLaMA)的训练语料库中,英文和代码的占比远超中文。GitHub 上的代码库为模型提供了海量的、结构高度重复的数据。
-
对于代码 :像
public static void main、import java.util.、def __init__(self):这样的字符串组合,在语料库中出现了数千万次。在 BPE 的合并循环中,它们会极早地被识别为高频对,不断合并。最终,
public甚至static会直接变成1 个单独的 Token 。所以,一段 100 个字符的 Java 代码,可能只需要 20 个 Token 就能表示。
-
对于中文 :因为占比相对较小,且中文词汇组合极其丰富,除了"我们"、"的"、"是"这种超高频词能被合并成 1 个 Token 外,很多稍微生僻一点的词汇(比如某些专业术语或人名),BPE 算法在训练时并没有看到足够的频次去把它们的字节合并起来。
结果就是,一个中文字符可能被拆成 2 个甚至 3 个 Token(退化回字节状态)。
数学对比:
假设大模型的上下文窗口限制(Context Window)是 C=8192C = 8192C=8192 个 Token。
- 输入全英文/代码 :由于合并效率高,1 Token ≈\approx≈ 4 个字符。Agent 能够吃下 8192×4≈327688192 \times 4 \approx 327688192×4≈32768 个字符的代码量。
- 输入全中文 :合并效率低,1 Token ≈\approx≈ 0.5 到 1 个汉字。Agent 最多只能吃下 8192×0.8≈65538192 \times 0.8 \approx 65538192×0.8≈6553 个汉字。
这就解释了为什么 Agent 会发生截断 (Truncation) :当你把几篇中文长文档或者带大量中文注释的代码喂给 Agent 时,物理字符量看起来不大,但 Token 数量由于无法被有效合并而急剧膨胀,瞬间撞破了 8192 的上限。大模型的底层是一个固定大小的矩阵,一旦超过 CCC,模型就必须截断头部信息,导致 Agent "失忆"。
三、你的执行步骤
要想把这个机制内化为你的工程直觉,最好的方法不是继续看理论,而是手写出这个过程。打开你的 IDE(如 PyCharm、VS Code)或 Jupyter Notebook。你现在的任务是直接运行并彻底理解以下这段纯 Python 原生代码。
这段代码没有任何第三方库依赖(不需要 pip install 任何东西),它从最底层的 UTF-8 字节流开始,完整还原了 BPE 分词器"统计词频 -> 合并最高频对 -> 生成新 Token ID"的完整生命周期。
第一步:复制并运行以下完整的 BPE 核心引擎代码
python
# 1. 准备极简语料库
# 我们用一段包含重复规律的字符串来模拟海量语料
text = "low lower newest widest"
# 2. 物理层转换:将文本转换为 UTF-8 字节流,并转为整数列表 (0-255)
# 这就是大模型眼中的"初始 Token"
tokens = list(text.encode("utf-8"))
print(f"初始状态 (长度 {len(tokens)}): {tokens}\n")
# 3. 核心机制 1:统计相邻 Token 对的频次
def get_stats(ids):
counts = {}
# zip(ids, ids[1:]) 会生成所有相邻的元素对
for pair in zip(ids, ids[1:]):
counts[pair] = counts.get(pair, 0) + 1
return counts
# 4. 核心机制 2:将指定的最高频 Token 对合并为新的单一 ID
def merge(ids, pair, idx):
newids = []
i = 0
while i < len(ids):
# 如果找到了目标配对,并且没有越界
if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]:
newids.append(idx)
i += 2 # 跳过已经被合并的两个旧 Token
else:
newids.append(ids[i])
i += 1
return newids
# 5. 训练阶段:开始 BPE 循环
vocab_size = 256 # 初始基础字节词表大小
num_merges = 5 # 我们设定手动执行 5 次合并迭代
merges = {} # 记录合并规则:{(旧ID1, 旧ID2): 新ID}
ids = list(tokens) # 复制一份初始数据用于训练
for i in range(num_merges):
stats = get_stats(ids)
# 找到频次最高的那一对 Token
# max() 函数的 key 参数指定按字典的 value (频次) 来寻找最大值
best_pair = max(stats, key=stats.get)
# 分配新的 Token ID,从 256 开始递增 (256, 257, 258...)
new_idx = vocab_size + i
# 执行合并
ids = merge(ids, best_pair, new_idx)
# 记录规则,这正是 tiktoken 等库底层的 .tiktoken 文件里保存的核心数据
merges[best_pair] = new_idx
print(f"第 {i+1} 次迭代: 合并 {best_pair} -> 分配新 ID {new_idx}")
print(f"合并后序列 (长度 {len(ids)}): {ids}\n")
print("--- 训练完成 ---")
print(f"合并规则字典: {merges}\n")
# 6. 推理阶段 (Encoding):使用训练好的规则对全新字符串进行分词
def encode(text, merges):
# 将新文本转为基础字节
tokens = list(text.encode("utf-8"))
# 无限循环尝试合并,直到没有任何规则可以匹配
while len(tokens) >= 2:
stats = get_stats(tokens)
# 寻找当前序列中,最早被合并出来的那一对(即在 merges 字典中值最小的)
# 这里模拟了从长到短的贪婪匹配机制
pair = min(stats, key=lambda p: merges.get(p, float("inf")))
# 如果当前序列里的相邻对都不在我们的合并规则里,说明无法继续压缩,跳出循环
if pair not in merges:
break
# 否则,应用合并规则,替换为新 ID
idx = merges[pair]
tokens = merge(tokens, pair, idx)
return tokens
# 7. 验证我们的分词器
new_text = "lower newest"
encoded_ids = encode(new_text, merges)
print(f"输入新文本: '{new_text}'")
print(f"经过我们手写 BPE 编码后的 Token 列表: {encoded_ids}")
第二步:观察输出结果,死磕数据流转
当你运行这段代码后,你需要验证以下几个硬核逻辑,确保你没有任何理解死角:
输出结果:
初始状态 (长度 23): [108, 111, 119, 32, 108, 111, 119, 101, 114, 32, 110, 101, 119, 101, 115, 116, 32, 119, 105, 100, 101, 115, 116]
第 1 次迭代: 合并 (108, 111) -> 分配新 ID 256
合并后序列 (长度 21): [256, 119, 32, 256, 119, 101, 114, 32, 110, 101, 119, 101, 115, 116, 32, 119, 105, 100, 101, 115, 116]
第 2 次迭代: 合并 (256, 119) -> 分配新 ID 257
合并后序列 (长度 19): [257, 32, 257, 101, 114, 32, 110, 101, 119, 101, 115, 116, 32, 119, 105, 100, 101, 115, 116]
第 3 次迭代: 合并 (101, 115) -> 分配新 ID 258
合并后序列 (长度 17): [257, 32, 257, 101, 114, 32, 110, 101, 119, 258, 116, 32, 119, 105, 100, 258, 116]
第 4 次迭代: 合并 (258, 116) -> 分配新 ID 259
合并后序列 (长度 15): [257, 32, 257, 101, 114, 32, 110, 101, 119, 259, 32, 119, 105, 100, 259]
第 5 次迭代: 合并 (257, 32) -> 分配新 ID 260
合并后序列 (长度 14): [260, 257, 101, 114, 32, 110, 101, 119, 259, 32, 119, 105, 100, 259]
--- 训练完成 ---
合并规则字典: {(108, 111): 256, (256, 119): 257, (101, 115): 258, (258, 116): 259, (257, 32): 260}
输入新文本: 'lower newest'
经过我们手写 BPE 编码后的 Token 列表: [257, 101, 114, 32, 110, 101, 119, 259]
- 初始状态的数字从哪来 :你会在最开始看到
[108, 111, 119, 32, 108...]。对照 ASCII 码表,108就是小写字母l,111是o,32是空格。这就是计算机和 LLM 看到的物理真实。 - 迭代过程的降维打击 :观察
while循环中的打印。你会看到频率最高的字符对(比如代表e和s的(101, 115))被提取出来,并被赋予了新的 ID256。原本长度为 23 的数组,由于两个元素变成了一个,长度立刻缩短为 22。这就是 Token 压缩的数学本质。 - 规则字典(Vocabulary 的真面目) :训练结束时打印的
merges字典{(101, 115): 256, ...},就是各大模型发布的vocab.json或tokenizer.model文件的核心内容。 Agent 并不认识"词",它只是一台冷酷无情的查表替换机。 - 编码阶段的贪婪匹配(Greedy Matching) :在最后的
encode函数中,注意理解为什么我要用min(..., key=lambda p: merges.get(...))。因为在推理时,我们必须优先合并那些在训练阶段最早形成、频次最高的长组合。如果不按优先级合并,分词结果就会产生歧义。
你可能想问:合并后的新id是经过UTF-8编码得到的吗?
直接回答你:不是 。合并后产生的新 ID(比如我们在代码中看到的 256、257 及以后的数字),绝对不再是 UTF-8 编码 ,它们纯粹是大模型词典(Vocabulary)中的抽象索引(Index/ID)。
为了让你没有任何底层理解的断层,我们必须把这道"物理边界"划清楚。这里有一个严密的两层映射机制:
第一层:0 到 255 的物理层(真实的 UTF-8 字节)
在计算机底层,1 个字节(Byte)由 8 个比特(Bit)组成(00000000 到 11111111)。所以 1 个字节能表示的十进制数字范围严格被物理限制在 0 到 255 之间。
- 当我们在代码第一步执行
text.encode("utf-8")时,我们是把文本打碎成了最基础的物理形态。 - 比如小写字母
e的 UTF-8 字节值是 101,s是 115。 - 在 0 到 255 这个区间内,Token ID = 真实的 UTF-8 字节值。
第二层:256 及以上的逻辑层(抽象的 Token ID)
当我们统计发现 101 和 115 经常相邻出现,我们决定把它们合并,并人为分配一个新 ID:256。
- 注意,在单字节 UTF-8 中,不存在值为 256 的字节(因为超出了 8 Bit 的物理上限)。
- 从 256 开始(一直到 OpenAI 的 100,277),这些数字仅仅是字典里的"门牌号"。
- ID
256的真正含义是:"请去合并规则表里查一下,我是由哪两个旧 ID 拼起来的。"
逆向思考:大模型输出这些 ID 后,怎么变回人类可读的文字?(Decoding 过程)
这也是 Agent 在拿到大模型返回的一串数字后,必须在底层执行的最后一步:解码(Decoding)。
假设大模型经过计算,最后向你的程序输出了一个 Token ID 列表:[256]。
- 递归拆解(Un-merging) :
程序拿到256,发现它大于 255,知道它不是基础字节。于是去查规则字典(merges),发现256是由101和115合并来的。 - 还原为纯字节数组 :
程序将[256]替换为[101, 115]。因为 101 和 115 都小于等于 255,拆解结束。 - 物理层解码 :
程序把[101, 115]当作真实的 UTF-8 字节流(在 Python 中表现为bytes([101, 115])),然后执行标准的.decode("utf-8")。 - 最终输出 :
屏幕上打印出人类可读的字符串:"es"。
如果是中文(比如"龙"),在分词时被拆成了三个字节 [233, 190, 153]。如果在训练时这三个字节没能合并成一个新 ID,大模型在推理时就会依次输出 233、190、153。你的程序必须把这三个数字收集齐,拼成 bytes([233, 190, 153]),再 decode 才能看到"龙"。如果大模型只输出了前两个数字就被截断了,你强行 decode 就会报 UnicodeDecodeError------这就是为什么早期的模型在输出中文时偶尔会出现"乱码"的底层原因。
你可能还想问:那合并迭代的次数是怎么确定的呢?
在真实的 LLM 开发中,合并迭代的次数(num_merges)不是通过某个绝对的数学公式计算出来的,而是一个人为设定的"超参数"(Hyperparameter)。
这个数字的确定,本质上是在做一场极致的工程算力与显存的 Trade-off(权衡)。
在解释这个权衡过程之前,你必须先明确一个绝对的数学等式:
最终词表大小 (Vocabulary Size) = 基础字节数 (256) + 合并迭代次数 (num_merges) + 特殊 Token 数量 (如 <|endoftext|>)
所以,你问"合并迭代次数怎么确定",等价于在问大模型厂商:"你们是怎么决定最终词表大小的?"
以下是底层架构师确定这个数值的完整决策过程:
极端情况推演:为什么不能太大,也不能太小?
极端情况一:迭代次数极少(比如 0 次,词表大小 = 256)
- 表象:模型退化为纯字节模型。所有的单词都被打碎成单个字母。
- 致命后果(计算量爆炸) :一段 1000 个单词的英文,会被拆分成约 5000 个 Token。在 Transformer 核心的自注意力机制中,计算复杂度是 O(N2)O(N^2)O(N2) (NNN 是 Token 序列长度)。这意味着序列长度变为 5 倍,注意力的计算量和显存占用会飙升 25 倍。模型的上下文窗口会被瞬间撑爆,推理速度极慢。
极端情况二:迭代次数极大(比如 1000 万次,词表大小 = 1000 万)
- 表象:几乎所有常见的短语甚至整个句子(比如 "how are you doing today")都被合并成了一个单一的 Token。
- 致命后果(参数量与显存爆炸) :
- Embedding 矩阵撑爆内存 :在模型的最底层,需要维护一个维度为 (Vocab_Size,dmodel)(Vocab\Size, d{model})(Vocab_Size,dmodel) 的 Embedding 矩阵。如果词表有 1000 万,模型维度是 4096,单单这个词表矩阵就需要占用上百 GB 的显存,模型连加载都加载不起来。
- 最后的 Softmax 分类器崩溃:在解码阶段的最后一层,模型需要将隐藏状态映射回词表维度,进行概率分布计算。面对 1000 万分类的任务,计算开销极其恐怖。
- 数据稀疏(Data Sparsity):很多被合并出来的超长 Token,在训练语料中可能只出现过几次。模型根本没有足够的数据去学习这个 Token 背后准确的高维向量表示(Embedding)。
工业界的真实抉择过程
为了在这两个极端之间找到"甜点"(Sweet Spot),AI 工程师会执行以下详细的压测过程:
- 设定候选目标:通常选取 3万、5万、10万、15万这几个档位。
- 计算压缩率 (Compression Ratio) :用这几个不同大小的词表,去跑一个标准验证集。计算"平均每个 Token 能包含多少个字节"。
- 例如,OpenAI 在设计 GPT-4 时,发现将词表从 GPT-3 的 5万级别提升到 10万级别(即
cl100k_base),对于多语言和代码的压缩率有了显著提升,整体可以省下约 30% 的 Context Window 空间。
- 例如,OpenAI 在设计 GPT-4 时,发现将词表从 GPT-3 的 5万级别提升到 10万级别(即
- 评估参数占比:工程师会严格计算 Embedding 矩阵占整个模型总参数量的比例。对于百亿参数(10B)及以上的大模型,把词表扩大到 10万-15万,其增加的参数量是完全可以接受的;但如果是 1B 的小模型,10万的词表就会显得极其臃肿。
真实世界的工程数据
你可以看看目前最顶级的模型是如何设定这个参数的:
- GPT-2 / GPT-3 / LLaMA 1 :选择了约 5万 的词表大小(意味着大约执行了 50000 次合并)。偏向英文,中文表现很差。
- GPT-4 (
cl100k_base) :选择了 100,277。因为 GPT-4 要处理大量代码和多语言,工程师刻意增加了合并次数,把很多高频代码片段和多语言词汇合并了进去。 - Qwen (通义千问) / ChatGLM :词表大小通常在 13万 到 15万 之间。这是因为他们作为国产模型,必须在中英文双语上都取得极高的压缩率,所以增加了大量的中文专属合并迭代,确保中文不会退化为碎片的字节。
总结来说,合并次数是架构师拿着显存预算 、算力限制 和目标语料(代码/多语言占比),通过多次训练和压缩率测试,硬生生"试"出来的一个工程最优解。