miniLLM Tokenizer:8K ByteLevel-BPE 分词器
这是 miniLLM 项目从语料中训练得到的 8,192 词表 ByteLevel-BPE 分词器,以中文为主要设计方向,兼顾英文、代码和混合文本。分享本分词器,希望为学习“如何从原始语料构建自己的 Tokenizer”提供一个可运行、可检查的实例。
分词器负责把文本转换成 token ID,并将 ID 序列还原为文本。它可以独立加载和使用,无需语言模型权重或 GPU;生成回答、推理和工具调用能力则需要后续语言模型训练。
1. 基本信息
| 项目 | 本次产物 |
|---|---|
| 名称 | miniLLM-tokenizer |
| 算法 | ByteLevel-BPE(字节级字节对编码) |
| 词表大小 | 8,192,ID 范围为 0~8,191 |
| 词表组成 | 256 个基础字节符号 + 7,911 个合并项 + 25 个预置标记 |
| 实现 | Hugging Face Tokenizers,封装为 PreTrainedTokenizerFast |
| 主要语料 | 中文网页、中文百科、英文网页与代码 |
| 文本归一化 | 未配置 Normalizer,不进行大小写转换或 Unicode 归一化 |
| 预分词 | ByteLevel(add_prefix_space=False, use_regex=True) |
| 解码 | ByteLevel Decoder;关闭空白清理 |
| 对话格式 | <|im_start|>role\ncontent<|im_end|> |
| 训练元数据日期 | 2026-09-01 |
tokenizer_config.json 中的 model_max_length=32768 是接口配置值,实际模型的上下文范围取决于模型结构和训练方案。它也不会让所有编码调用自动截断到这个长度。
2. 原理与设计思路
从字节到子词
原始文本
↓ ByteLevel:按规则预切分,将 UTF-8 字节映射为字节符号
基础符号序列
↓ BPE:按训练得到的合并规则组合相邻符号
子词 token
↓ 查询词表
整数 ID 序列
训练时,BPE 反复统计并合并高频相邻符号对;使用时,则按已经保存的合并规则切分新文本。这样既能用较长 token 表示常见片段,也能把没见过的词拆成更小的片段。算法与组件说明可参阅 Hugging Face Tokenizers 官方文档。
本分词器保留了完整的 256 个基础字节符号,因此有效 UTF-8 文本中的生僻字、emoji 和其他符号都有基础表示。低频字符可能被拆成多个 token,可编码并不等于切分紧凑。这里的覆盖来自完整字节字母表,产物中的 byte_fallback=false 不代表缺少字节覆盖。
为什么使用 8,192 词表
对小型语言模型,词表大小会影响输入嵌入和输出层的规模,也会影响同一段文本占用的 token 数。以隐藏维度 768 为例,8192 × 768 的嵌入矩阵有 6,291,456 个参数。
本项目选择 8K,是为了在模型参数成本与中英文、代码的切分长度之间取得适合入门实验的平衡。它属于本次设计选择,尚未通过相同语料下的多词表对比证明其最优性。
为什么保留空白和原始字符
代码缩进、换行、Tab,以及英文大小写,都可能影响文本含义。本分词器不主动做大小写转换、Unicode 归一化或解码空白清理,并且不自动在输入开头补空格。对完整的有效文本进行编解码时,可以保留这些信息。
直接查看 vocab.json 或 convert_ids_to_tokens() 时,部分片段会显示为不直观的字节映射字符。可读文本应通过 decode() 获得;单独解码只包含某个汉字部分字节的 token,可能得到替换字符,应以完整序列的解码结果为准。
3. 训练语料与方法
语料设计
项目采样方案以约 3 GB JSONL 为目标,中文占主要部分,英文和代码补充不同的词形、标点与格式。下表是采样目标,来源依据项目采样脚本和训练元数据中的输入文件记录。
| 类别 | 数据来源 | 目标大小 | 目标比例 |
|---|---|---|---|
| 中文网页 | FineWeb2,cmn_Hani |
1.50 GB | 50% |
| 中文百科 | Wikipedia,20231101.zh |
600 MB | 20% |
| 中文高质量网页 | CCI3-HQ | 450 MB | 15% |
| 英文网页 | FineWeb,sample-10BT |
300 MB | 10% |
| 代码 | The Stack Smol | 150 MB | 5% |
这些比例按采样文件大小规划,不能直接理解为文档数、字符数或训练后 token 数的比例。分词器元数据没有逐来源实际字节数,实际进入训练的总量如下。
实际训练记录
| 项目 | 记录值 |
|---|---|
| 输入文件数 | 5 |
| 读取文档数 | 949,239 |
| 使用文档数 | 949,239 |
| 送入训练的文本块数 | 956,177 |
| 送入训练的字符数 | 1,375,851,167 |
| 分词器训练读取阶段跳过的短文本、无效 JSON 和不支持记录 | 均为 0 |
“字符数”按 Python 字符串长度累计,不是 UTF-8 字节数,也不是训练 token 数。这里的跳过数量只反映分词器训练时的读取阶段;上游采样脚本可以事先进行过滤。
训练流水线
- 按文件流式读取 JSONL/TXT,提取正文或对话中的文本。
- 过滤去除首尾空白后不足 20 字符的文档,将长文档按 20,000 字符切块。
- 使用完整字节字母表和预置标记初始化 BPE,学习合并规则,目标词表为 8,192,最低合并频次为 2。
- 保存词表、合并规则和完整分词流水线,再封装为 Transformers 可加载的分词器。
- 检查词表大小、核心 ID、结构标记原子性,以及文本编解码的一致性。
本次 max_documents=0,即不在训练读取阶段设置文档数量上限。Tokenizer 训练使用 CPU 和系统内存;流式读取减少了 Python 端保存全部语料的开销,BPE 的词频与相邻对统计仍会消耗内存。
4. 核心 ID 与结构标记
25 个预置标记已经包含在 8,192 词表内。其中 3 个为核心标记、6 个为思考/工具结构标记,另外预留了 16 个槽位。
| ID | 标记 | 用途 |
|---|---|---|
| 0 | <|endoftext|> |
pad_token 与 unk_token 的配置值 |
| 1 | <|im_start|> |
bos_token,消息开始 |
| 2 | <|im_end|> |
eos_token,消息结束 |
| 3、4 | <think>、</think> |
思考内容边界 |
| 5、6 | <tool_call>、</tool_call> |
工具调用内容边界 |
| 7、8 | <tool_response>、</tool_response> |
工具返回内容边界 |
| 9~24 | <|reserved_0|>~<|reserved_15|> |
为后续扩展预留的 ID |
思考与工具标记各自编码为一个 token,但在底层 tokenizer.json 中设为 special=false,因此 decode(..., skip_special_tokens=True) 会保留它们。核心和预留标记在底层设为特殊标记,使用同一选项会被跳过。
核心标记的名字与实际配置需要对应理解:本项目用 ID 2 的 <|im_end|> 作为 EOS;ID 0 的 <|endoftext|> 用于 PAD/UNK。直接把原文中的字面量 <|endoftext|> 编码为 0,也不意味着遇到了无法表示的普通字符。
开始训练语言模型后,需要保持 token 到 ID 的映射稳定。已有模型的嵌入矩阵逐行对应这些 ID,词表大小相同并不足以保证两个分词器可互换;模板、特殊 token 语义和合并规则也应与模型训练时匹配。
5. 快速使用
安装分词所需依赖
使用 Python 3.10 或更高版本,分词和模板渲染只需以下依赖:
python -m pip install "transformers>=4.51,<4.56" "tokenizers>=0.20,<1.0" "jinja2>=3.1,<4.0"
本次文档示例在 transformers==4.55.4、tokenizers==0.21.4 下验证。下载本目录后,将下面的 TOKENIZER_DIR 设置为实际保存位置;示例默认从 miniLLM 项目根目录执行。
编码与解码
from transformers import AutoTokenizer
TOKENIZER_DIR = "model/tokenizer"
tokenizer = AutoTokenizer.from_pretrained(
TOKENIZER_DIR,
use_fast=True,
local_files_only=True,
)
text = "你好,世界!miniLLM 从零开始训练。"
ids = tokenizer.encode(text, add_special_tokens=False)
decoded = tokenizer.decode(
ids,
skip_special_tokens=False,
clean_up_tokenization_spaces=False,
)
print("词表大小:", len(tokenizer)) # 8192
print("Token 数:", len(ids)) # 15
print("ID:", ids)
print("还原文本:", decoded)
assert decoded == text
assert tokenizer.encode("你好", add_special_tokens=False) == [588, 540]
当前流水线没有自动添加 BOS/EOS 的后处理规则,普通 encode() 即使指定 add_special_tokens=True,也不会自动包上消息边界。对话数据应使用配套模板构造。
渲染对话模板
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(
"model/tokenizer", use_fast=True, local_files_only=True
)
messages = [{"role": "user", "content": "你好"}]
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
print(prompt)
# 直接得到用于后续模型输入的 ID 列表。
input_ids = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
)
当前模板默认生成以下前缀,结束位置在空思考块之后:
<|im_start|>user
你好<|im_end|>
<|im_start|>assistant
<think>
</think>
模板还支持 reasoning_content、tool_calls、role="tool" 消息和 tools 定义。设置 open_thinking=True 时,生成前缀会停在未闭合的 <think> 后面。这些选项只控制输入文本的组织方式,使用时应与下游模型实际接受的训练格式一致。
普通消息的 content 应为字符串;当前模板不会将图片、音频或分段列表转换为多模态输入。若先渲染模板再单独编码,使用 add_special_tokens=False,明确由模板负责消息边界。
6. 已有验证结果
训练元数据记录的 6 条基础样例,在本次文档整理时重新验证,token 数保持一致,完整编码后解码均精确还原原文:
| 样例 | Token 数 | 完整往返一致 |
|---|---|---|
你好,世界!miniLLM 从零开始训练。 |
15 | 是 |
Byte-level BPE keeps English, numbers 12.5%, and punctuation. |
30 | 是 |
Python 示例:\ndef add(a, b):\n return a + b |
21 | 是 |
中英混合:Transformer 使用 attention 处理上下文。 |
17 | 是 |
生僻字与 emoji:𠮷野家 🙂🚀 |
25 | 是 |
空白测试:第一行\n 第二行\tTab |
13 | 是 |
表中的 \n、\t 表示原始样例中的真实换行和 Tab。
另外,本次运行项目 eval/eval_tokenizer.py 的 14 条内置样例,覆盖中文、英文、混合文本、代码和边界情况:
| 检查项 | 结果 |
|---|---|
| 词表大小、核心 ID | 通过 |
| 思考/工具标记单 token 编码及可见性 | 通过 |
| 对话模板 | 通过 |
| 14 条样例编解码不一致数 | 0 |
| 样例中 UNK token 数 | 0 |
| 样例总 token 数 | 313 |
| 样例平均字符数 / token | 1.897764 |
这些结果用于检查实现是否正常,不构成大规模语料上的压缩率或下游模型效果评测。完整字节覆盖可以保证基础可表示性,但不同语言、专业术语和代码的切分效率仍需用独立语料评估。
7. 学习与复现实验
使用完整 miniLLM 项目代码时,可以准备自己的 JSONL 语料,例如每行一条 {"text":"文档内容"},再按同一算法配置训练一个新分词器:
python trainer/train_tokenizer.py \
--input dataset/tokenizer_corpus \
--output model/tokenizer_experiment \
--vocab-size 8192 \
--min-frequency 2 \
--min-chars 20 \
--chunk-chars 20000 \
--max-documents 0
评估本次分享的分词器:
python eval/eval_tokenizer.py \
--tokenizer model/tokenizer \
--show-examples 5 \
--report eval/tokenizer_public_eval.json
训练命令演示的是同一方法和主要参数。精确重建同一词表还需要相同的语料快照及兼容的库版本;当前训练元数据未记录输入语料哈希和完整运行环境,不能据此保证逐字节复现。
适合进一步比较的问题包括:固定语料时,8K、16K、32K 词表对各语言 token 数的影响;增加代码占比后,代码与自然语言的切分如何变化;保持相同词表预算时,生僻字和领域术语会被拆成多少片段。
8. 文件与版本说明
| 文件 | 作用 |
|---|---|
tokenizer.json |
完整的分词流水线,包括词表、合并规则、标记和编解码配置 |
tokenizer_config.json |
Transformers 加载配置、PAD/BOS/EOS/UNK 设置等 |
chat_template.jinja |
当前聊天模板,含思考及工具调用格式 |
vocab.json |
token 字符串到 ID 的映射,便于检查词表 |
merges.txt |
按优先级保存的 BPE 合并规则,便于学习合并过程 |
tokenizer_metadata.json |
初始训练参数、输入文件记录、语料统计、基础检查和当时的文件哈希 |
README.md |
本分词器说明 |
使用 AutoTokenizer 时,至少保留 tokenizer.json、tokenizer_config.json 和 chat_template.jinja;公开分享时可完整保留本目录,方便学习与追溯。默认加载只读取本地文件,不要求原始训练语料仍位于元数据中的历史路径。
9. 来源与许可
本分词器由 miniLLM 项目的 trainer/train_tokenizer.py 训练,基于 Hugging Face Tokenizers 与 Transformers 实现。语料来源见上方链接,原始数据的使用条件分别以对应数据集及源内容说明为准。
当前目录尚未声明本分词器产物的独立许可证。本 README 记录其技术实现与实验结果,供学习交流和复现实验参考。