详解基于 Hugging Face 词汇文件用 C++ 实现 BPE 分词的完整流程,包括 Unicode 转换、合并规则解析和编码管道设计。
LLM 推理引擎的开发始于理解并实现分词器(tokenizer)。在模型执行任何计算之前,原始文本输入必须被转换为与模型词表(vocabulary)中的条目相对应的数字 token ID。
加载分词器配置
第一步是加载从 Hugging Face 下载的分词器文件。分词器配置包含几个重要组件:
在 C++ 中使用 nlohmann::json 库,将词表和合并字典解析为原生 C++ 数据结构,以实现高效查找。
std::unordered_map<std::string, int> vocabulary;
std::unordered_map<
std::pair<std::string, std::string>,
int,
PairHash
> merge_rank;
分词器类的设计用于管理完整的编码流水线:
Raw Text
|
v
Normalization
|
v
Regex Pre-tokenization
|
v
Byte / Unicode Conversion
|
v
BPE Merge Algorithm
|
v
Vocabulary Lookup
|
v
Token IDs
文本规范化与预分词
现代 LLM 分词器并不直接将单词映射到词表条目,而是采用多阶段转换。
存储在 tokenizer.json 中的正则表达式模式用于执行预分词(pre-tokenization)。
例如,该模式会将以下内容分开:
简化示例:
输入:
Hello world!
["Hello", " world", "!"]
每个片段随后由 BPE 算法独立处理。
现代 LLM 分词器(如 GPT-2、Qwen 和许多 Hugging Face 模型)操作的是字节(bytes)而非直接操作 Unicode 字符。
你好
首先被转换为 UTF-8 字节:
E4 BD A0
E5 A5 BD
每个字节通过字节到 Unicode 的映射表被映射为一种特殊的 Unicode 表示形式。
这一映射的目的是让每个可能的字节值(0-255)都能表示为一个有效的 Unicode token 候选。
Byte:
0xF0
Mapped Unicode:
ð
这创建了 BPE 所使用的中间表示。
实现过程中的关键发现是:分词并非简单的词表查找。分词器不会立即在词表中搜索:
"hello"
相反,它根据合并规则执行迭代合并。每个预分词片段首先被分解为单个字节/Unicode 单元:
示例:
hello
h e l l o
分词器随后检查相邻的字节对:
(h,e)
(e,l)
(l,l)
(l,o)
在合并字典中查找每一对。合并字典包含优先级排序:
("h","e") -> 10
("he","l") -> 5
("hel","l") -> 3
排名越低,合并优先级越高。
算法反复执行以下步骤:
找出所有可能的相邻字节对。 检查每一对是否存在于合并字典中。 选择合并排名最低的字节对。 将该对合并为单个 token。 重复直到没有有效的合并剩余。
BPE 合并完成后,产生的 token 字符串会在词表字典中查找。
示例:合并后:
["hello", "Ġworld"]
hello -> 15339
Ġworld -> 1917
最终分词器输出变为:
[
15339,
1917
]
这些整数 ID 随后被用作 Transformer 模型的输入嵌入。
Repo: https://github.com/NgKaiWen7/InferenceEngine/tree/tokenization