1.transformers代码
from transformers import AutoTokenizer
path = "./models/Qwen2.5-0.5B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(path)
messages = [{"role": "user", "content": "你好,介绍一下你自己"}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
print(text)
inputs = tokenizer(text, return_tensors="pt")
print("inputs.input_ids:\n", inputs.input_ids)
print("inputs.input_ids.shape", inputs.input_ids.shape)
print("inputs.attention_mask:\n", inputs.attention_mask)
print("inputs.attention_mask.shape", inputs.attention_mask.shape)
(AI) peile.duan@U-7K6V5HCV-0153 AI % python debug_tokenizer.py
<|im_start|>system
You are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>
<|im_start|>user
你好,介绍一下你自己<|im_end|>
<|im_start|>assistant
inputs.input_ids:
tensor([[151644, 8948, 198, 2610, 525, 1207, 16948, 11, 3465,
553, 54364, 14817, 13, 1446, 525, 264, 10950, 17847,
13, 151645, 198, 151644, 872, 198, 108386, 3837, 109432,
107828, 151645, 198, 151644, 77091, 198]])
inputs.input_ids.shape torch.Size([1, 33])
inputs.attention_mask:
tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1]])
inputs.attention_mask.shape torch.Size([1, 33])
2.tokenizer初始化
tokenizer = AutoTokenizer.from_pretrained(path)
该函数对应的完整调用链如下所示:
AutoTokenizer.from_pretrained("bert-base-uncased")
│
├── 1. 提取参数 (use_fast丢弃, tokenizer_type, trust_remote_code...)
├── 2. 快捷路径检查 (mistral / tokenizer_type) → 未命中
├── 3. AutoConfig.from_pretrained(path) → 读 config.json → model_type="bert"
├── 4. get_tokenizer_config(path) → 读 tokenizer_config.json
├── 5. 检查 auto_map (远程代码) → 无
├── 6. MODEL_IDS_TO_TOKENIZERS_BACKEND 通配 → 无
├── 7. Hub class vs 注册类冲突解决
└── 8. TOKENIZER_MAPPING.get(BertConfig)
│
▼ _LazyAutoMapping.__getitem__(BertConfig)
│ 反查: BertConfig → "bert" → "BertTokenizer"
│ importlib.import_module("transformers.models.bert")
│ getattr → BertTokenizer 类
▼
BertTokenizer.from_pretrained(path)
│
▼ PreTrainedTokenizerBase.from_pretrained [base:1489]
│
├── A. 构建文件清单 (vocab.txt, tokenizer.json, tokenizer_config.json...)
├── B. cached_file() 逐文件解析 → 本地路径
└── C. 委托 _from_pretrained
│
▼ PreTrainedTokenizerBase._from_pretrained [base:1751]
│
├── D. 加载 tokenizer_config.json → init_kwargs
├── E. 加载 chat_template.jinja → init_kwargs
├── F. init_kwargs.update(kwargs) ← 用户参数优先
├── G. V5: additional_special_tokens → extra_special_tokens
├── H. 合并词表文件路径 (防路径遍历)
├── I. 处理 Added Tokens (四来源统一为 AddedToken 对象)
├── J. convert_added_tokens() → 递归转 dict→AddedToken
├── K. 映射特殊 token
├── L. convert_to_native_format() → 从 tokenizer.json 提取原生参数
└── M. cls(**init_kwargs) ← 实例化 (构建 Rust Tokenizer)
│
▼
返回 tokenizer 实例
2.1 AutoTokenizer 类定义
文件: tokenization_auto.py(第 634-646 行)models/auto/tokenization_auto.py
AutoTokenizer 是一个不可直接实例化的工厂类,其 __init__ 方法主动抛出异常:
class AutoTokenizer:
def __init__(self):
raise OSError(
"AutoTokenizer is designed to be instantiated "
"using the `AutoTokenizer.from_pretrained(pretrained_model_name_or_path)` method."
)
设计意图:AutoTokenizer 本身不持有任何 tokenizer 逻辑,它仅作为分发入口。用户必须通过 from_pretrained 类方法调用,该方法负责根据模型配置推断出具体的 tokenizer 子类并返回其实例。
2.2 config加载
if gguf_file:
# GGUF 路径:从 GGUF 文件中提取 config
config_dict = load_gguf_checkpoint(gguf_path, return_tensors=False)["config"]
config = AutoConfig.for_model(**config_dict)
elif config is None:
try:
# 主路径:通过 AutoConfig 加载
config = AutoConfig.from_pretrained(
pretrained_model_name_or_path, trust_remote_code=trust_remote_code, **kwargs
)
except (ValueError, OSError):
# 回退:使用基础 PreTrainedConfig
config = PreTrainedConfig.from_pretrained(pretrained_model_name_or_path, **kwargs)
加载config.json文件,以models/Qwen3.8-27B-4bit/config.json为例
根据里面的:”model_type”: “qwen3_5”,字段推断出具体的 Config 子类,从models/auto/auto_mappings.py中可以看到(“qwen3_5”, “Qwen3_5Config”)。config.model_type 随后成为 tokenizer 类型推断的关键依据。
加载tokenizer_config.json,读取tokenizer_class字段
(AI) peile.duan@U-7K6V5HCV-0153 AI % cat models/Qwen3.8-27B-4bit/tokenizer_config.json|grep tokenizer_class
"tokenizer_class": "Qwen2Tokenizer",
(AI) peile.duan@U-7K6V5HCV-0153 AI % cat models/Qwen3.8-27B-4bit/config.json|grep model_type|head -n 1
"model_type": "qwen3_5",
2.3 加载TokenizersBackend/SentencePieceBackend类
系统比较两个来源的 tokenizer 类名:
- Hub 类:来自
tokenizer_config.json的tokenizer_class或config.tokenizer_class - 注册类:
TOKENIZER_MAPPING_NAMES[config.model_type]查表结果
当两者不一致时(去除 Fast 后缀比较),系统判断该 model_type 是否在 MODELS_WITH_INCORRECT_HUB_TOKENIZER_CLASS 集合中:
- 在集合中:信任注册类(强制使用库中定义的正确 tokenizer)
- 不在集合中:信任 Hub 上用户/模型作者指定的类
tokenizer 类的完整优先级链路如下:
|
优先级 |
路径 |
依据来源 |
|
1 |
|
用户显式标志 |
|
2 |
|
用户显式指定 |
|
3 |
|
Hub 自定义代码 |
|
4 |
|
已知需修正的模型 ID |
|
5 |
Hub class vs 注册类冲突解决 |
tokenizer_config + model_type |
|
6 |
|
保存的配置文件 |
|
7 |
|
Config 对象属性 |
|
8 |
|
model_type 查表(最终兜底) |
以上面的models/Qwen3.8-27B-4bit为例,tokenizer_class为Qwen2Tokenizer,而根据model_type查找TOKENIZER_MAPPING_NAMES得到的结果为Qwen3_5Tokenizer。最终返回了transformers.models.qwen2.tokenization_qwen2.Qwen2Tokenizer。
这体现了微调或二创模型生态中的一个常见问题:模型的骨架来自新一代架构,但分词器仍在沿用老架构的配置。model_type字段告诉 Hugging Face 的 AutoModelForCausalLM:“该用什么代码逻辑来搭建模型的神经网络骨架?” 在 Qwen3.8-27B里,"model_type": "qwen3_5","qwen3_5" 代表的是模型架构(Transformer 层数、注意力机制、激活函数等),值的变化意味着这个模型引入了新的底层改动(可能是新的注意力机制、MoE 路由、或新的归一化方式),旧的 qwen2 代码已经无法正确加载它。因此,加载模型的 Python 代码必须升级。
tokenizer_class代表的是文本到 Token ID 的映射规则(词表大小、BPE 合并规则、特殊 Token 定义),告诉 AutoTokenizer:“该用什么代码逻辑来把文本转成数字ID?” “tokenizer_class”: “Qwen2Tokenizer” 意味着:虽然模型架构升级到了 Qwen3.5,但分词算法、词表、特殊 token 的切分规则和 Qwen2 完全一模一样。Qwen2 的 Qwen2Tokenizer 是一个极其成熟的 Fast Tokenizer(基于 Rust),底层处理多语言(尤其是中文)极其稳健。通过把分词器声明为 Qwen2Tokenizer,让所有已经支持 Qwen2 的工具链,都能无痛加载这个新模型的分词部分。这能省去大量排查 Tokenizer 报错的麻烦。
在大模型研发中,这两者的更新频率完全不同:
- 模型架构:几乎每个大版本都会微调(比如从 Qwen2 到 Qwen2.5 再到 Qwen3,可能改了 RoPE 基数、加了 GQA/MLA、调整了 FFN 结构),所以
model_type必须变,以便AutoModelForCausalLM能路由到正确的网络结构代码。 - 分词器:一旦确定,通常在多个世代间保持稳定。更换词表意味着之前所有的预训练数据都要重新 tokenize,成本极高且会破坏兼容性。因此,Qwen2、Qwen2.5、Qwen3 共享同一套分词器是业界常态(类似地,Llama-2 和 Llama-3 虽然架构不同,但 Llama-3 也只是扩展了词表,核心分词类依然兼容)。
2.3.1 懒加载映射机制——LazyAutoMapping
transformers 库包含数百个模型实现,如果在 import transformers 时全部加载所有 tokenizer 子模块,会导致导入耗时数十秒。TOKENIZER_MAPPING(第 418 行)是 _LazyAutoMapping 的实例,定义在 auto_factory.py 第 575-696 行。它继承自 OrderedDict,核心机制:
__getitem__(第 596 行):给定一个 Config 类(如BertConfig),通过__name__属性反查到 model_type(如"bert"),再从_model_mapping获取 tokenizer 类名(如"BertTokenizer"),最后调用_load_attr_from_module动态导入_load_attr_from_module(第 612 行):将 model_type 转为模块名(连字符替换为下划线),用importlib.import_module按需导入transformers.models.{module_name}子模块,模块对象缓存在self._modules中避免重复导入register(第 665 行):运行时注册自定义 tokenizer 类,但安全检查——如果 key 的__module__以"transformers."开头则跳过注册,防止远程代码覆盖原生模型映射
2.3.2 具体 Tokenizer 类的 from_pretrained——文件解析与实例化
选定具体 tokenizer 子类后,调用其继承自 PreTrainedTokenizerBase 的 from_pretrained,位于 tokenization_utils_base.py 第 1489 行。该方法分两个阶段:
2.1 文件解析阶段(from_pretrained,第 1489-1748 行)
- 构建文件清单:合并类属性
vocab_files_names(如{"vocab_file": "vocab.txt", "merges_file": "merges.txt"})和附加文件(tokenizer_config.json、tokenizer.json、added_tokens.json、special_tokens_map.json、chat_template.jinja) - 版本化 tokenizer 文件处理:检查
tokenizer_config.json中是否有fast_tokenizer_files字段,选择正确版本的tokenizer.json - 聊天模板文件发现:扫描本地目录的
chat_templates/子目录 - 逐文件解析:通过
cached_file下载/解析到本地路径,存入resolved_vocab_files - 委托
_from_pretrained完成实例化
2.2 配置合并与实例化阶段(frompretrained,第 1751-1951 行)
- 加载 tokenizer_config.json 到 init_kwargs:读取保存的配置参数,移除
tokenizer_class标识 - 聊天模板处理:优先从独立
.jinja文件加载,覆盖内联模板 - 合并用户 kwargs:
init_kwargs.update(kwargs)——用户显式传入的参数优先级最高 - V5 特殊 token 重命名:
additional_special_tokens→extra_special_tokens - 合并词表文件路径:安全设计——只允许 repo 解析的路径或用户显式 kwargs 中的路径覆盖,防止来自不受信任的
tokenizer_config.json的路径遍历攻击(CWE-22) - 处理 Added Tokens(四种来源按优先级):
-
added_tokens_decoder(现代格式,直接在 tokenizer_config.json 中)special_tokens_map.json(遗留格式)added_tokens.json(遗留格式)tokenizer.json的added_tokens数组(回退来源)
所有来源统一转换为 AddedToken 对象,通过 added_tokens_map 建立字符串到对象的映射
convert_added_tokens:递归地将 dict 形式转换为 AddedToken 对象convert_to_native_format:基类中是空操作,但TokenizersBackend重写了它- 实例化:
tokenizer = cls(*init_inputs, **init_kwargs)
2.3 TokenizersBackend 的关键重写
位于 tokenization_utils_tokenizers.py 第 85 行。TokenizersBackend 是 V5 的默认 tokenizer 后端:
convert_to_native_format(第 103 行):将tokenizer.json拆解为 vocab/merges/post_processor/padding/truncation 等原生参数,传给子类的__init____init__(第 328 行):构建 Rust 后端 tokenizer,优先级为tokenizer_object>tokenizer_file(直接 from_file)>gguf_file(转换)>vocab + merges(从原始词表构建),最终self._tokenizer为 Rusttokenizers.Tokenizer对象
2.3.3 Rust tokenizer 构建机制
V5 架构中,TokenizersBackend 是所有快速 tokenizer 的基类(PreTrainedTokenizerFast 只是它的别名,见第 1477 行)。它通过 self._tokenizer(指向 C++/Rust 动态链接库的指针)属性持有 Rust tokenizers.Tokenizer 对象,所有核心操作(编码、解码、词表管理)都委托给这个 Rust 对象执行。
Python 层 (TokenizersBackend)
│ 做输入验证、批次检测、结果格式化
▼
PyO3 FFI 边界 (tokenizers.abi3.so, 8MB, arm64)
│ 零拷贝传参,GIL 安全管理
▼
Rust 层 (tokenizers::Tokenizer)
│ 执行 normalize → pre_tokenize → model → post_process 管道
▼
返回 Encoding 对象 → Python 层转换为 BatchEncoding
选定具体 tokenizer 类后,调用其继承自基类的 from_pretrained:
BertTokenizer.from_pretrained(path)
= PreTrainedTokenizerBase.from_pretrained(path)
│ [tokenization_utils_base.py:1489-1748]
│
├─[A] 参数提取
│ pop proxies, subfolder, _from_pipeline, _from_auto, _commit_hash [:1576]
│
├─[B] 单文件 vs 目录判断
│ path 是文件?
│ ├─ vocab_files_names 只有 1 项 → 允许单文件加载 [:1596]
│ └─ path 是目录或模型 ID → 构建完整文件清单 [:1615]
│
├─[C] 文件清单构建
│ vocab_files_names = {"vocab_file": "vocab.txt"} [:1621]
│ additional_files_names = {
│ "added_tokens_file": "added_tokens.json",
│ "special_tokens_map_file": "special_tokens_map.json",
│ "tokenizer_config_file": "tokenizer_config.json",
│ "tokenizer_file": "tokenizer.json",
│ "chat_template_file": "chat_template.jinja",
│ } [:1627]
│
├─[D] 版本化 tokenizer.json 处理
│ config 中有 fast_tokenizer_files?
│ └─ get_fast_tokenizer_file() 选择正确版本 [:1633]
│
├─[E] 聊天模板发现
│ 扫描 chat_templates/ 子目录的 .jinja 文件 [:1657]
│
├─[F] 远程文件列表探测
│ hf_api().list_repo_files() → 检测 tokenizer.json 是否存在 [:1675]
│ 不存在 → 尝试匹配 SentencePiece/tiktoken 替代文件名 [:1690]
│
├─[G] 逐文件解析
│ for each file in 文件清单:
│ └─ cached_file(path, filename)
│ (处理 Hub 下载/缓存/本地路径解析)
│ → resolved_vocab_files[file_key] = 本地路径 [:1696]
│
└─[H] 委托实例化
return cls._from_pretrained(
resolved_vocab_files, path, init_configuration,
*init_inputs, token=token, cache_dir=cache_dir,
trust_remote_code=trust_remote_code, **kwargs
) [:1736]
_from_pretrained具体调用链:
PreTrainedTokenizerBase._from_pretrained(resolved_vocab_files, path, ...)
│ [tokenization_utils_base.py:1751-1951]
│
├─[1] 加载 tokenizer_config.json → init_kwargs
│ json.load(tokenizer_config_handle) [:1767]
│ init_kwargs.pop("tokenizer_class") # 移除类名标识 [:1770]
│ init_kwargs.pop("init_inputs", ()) [:1771]
│
├─[2] 聊天模板处理
│ .jinja 文件存在?
│ ├─ 单个 → init_kwargs["chat_template"] = 字符串 [:1784]
│ └─ 多个 → init_kwargs["chat_template"] = dict [:1792]
│ (覆盖 tokenizer_config 中的内联模板)
│
├─[3] 合并用户 kwargs
│ init_kwargs.update(kwargs) ← 用户参数优先级最高 [:1809]
│
├─[4] V5 特殊 token 重命名
│ additional_special_tokens → extra_special_tokens [:1812]
│ 收集非标准 *_token 键 → model_specific_special_tokens [:1816]
│
├─[5] 合并词表文件路径到 init_kwargs
│ resolved_vocab_files → init_kwargs[vocab_file_key] = path
│ 安全设计: 只允许 repo 解析路径或用户 kwargs 路径 [:1833]
│ (防止 tokenizer_config.json 路径遍历攻击 CWE-22) [:1838]
│
├─[6] 处理 Added Tokens (四种来源按优先级)
│ │
│ ├─ 来源 A: added_tokens_decoder (现代格式) [:1850]
│ │ 每个 token → AddedToken 对象
│ │ 存入 added_tokens_decoder (id→AddedToken)
│ │ 存入 added_tokens_map (str→AddedToken)
│ │
│ ├─ 来源 B: special_tokens_map.json (遗留) [:1862]
│ │ 读取 → 合并到 init_kwargs (用户 kwargs 优先)
│ │ dict 形式 → AddedToken(special=True)
│ │
│ ├─ 来源 C: added_tokens.json (遗留) [:1889]
│ │ 读取 {str_token: index} 映射
│ │ 判断是否特殊 token → 创建 AddedToken
│ │
│ └─ 来源 D: tokenizer.json 的 added_tokens (回退) [:1905]
│ 直接从 tokenizer.json 的 added_tokens 数组提取
│
├─[7] AddedToken 转换与特殊 token 映射
│ init_kwargs["added_tokens_decoder"] = added_tokens_decoder [:1918]
│ convert_added_tokens(init_kwargs, save=False) [:1919]
│ │ 递归转 {"__type":"AddedToken",...} → AddedToken 实例
│ for key in SPECIAL_TOKENS_ATTRIBUTES: [:1920]
│ └─ init_kwargs[key] = added_tokens_map.get(str(val), val) [:1921]
│
├─[8] 原生格式转换
│ init_kwargs = cls.convert_to_native_format(**init_kwargs) [:1929]
│ │ (基类: 空操作 return kwargs)
│ │ (TokenizersBackend: 见第五层 ↓)
│
└─[9] 实例化
tokenizer = cls(*init_inputs, **init_kwargs) [:1932]
│ → BertTokenizer.__init__ → super().__init__()
│ → TokenizersBackend.__init__() (见第六层 ↓)
│
└─ 返回 tokenizer 实例
TokenizersBackend.init(Rust Tokenizer 构建)
TokenizersBackend.__init__(**init_kwargs)
│ [tokenization_utils_tokenizers.py:328-489]
│
├─[1] 参数预处理 (从 kwargs 中弹出 convert_to_native_format 预提取的值)
│ _json_truncation, _json_padding, _spm_precompiled_charsmap [:328-348]
│ tokenizer_object, gguf_file, tokenizer_file [:340-345]
│ vocab, merges [:346-347]
│
├─[2] 构建 Rust Tokenizer (四分支决策树)
│ │ [:349-382]
│ ├─[1] tokenizer_object is not None?
│ │ fast_tokenizer = copy.deepcopy(tokenizer_object)
│ │ [复用内存对象,深拷贝避免共享内部 Rust 状态]
│ │
│ ├─[2] fast_tokenizer_file 是文件?
│ │ fast_tokenizer = TokenizerFast.from_file(path)
│ │ [加载模型目录中存在 tokenizer.json,PyO3 FFI → Rust Tokenizer::from_file → JSON 反序列化]
│ │
│ ├─[3] gguf_file is not None? (GGUF 是自包含的二进制容器,把权重和tokenizer元数据打包在一起)
│ │ gguf_path = cached_file(name_or_path, gguf_file)
│ │ gguf_param = load_gguf_checkpoint(gguf_path)
│ │ architecture = gguf_param["config"]["model_type"]
│ │ tokenizer_dict = gguf_param["tokenizer"]
│ │ fast_tokenizer, add_kwargs = convert_gguf_tokenizer(arch, tokenizer_dict)
│ │ kwargs.update(tokenizer_config, add_kwargs)
│ │
│ └─[4] vocab is not None? (从原始词表构建,为了支持 2018-2019 年的legacy 模型)
│ ├─[4a] merges is not None?
│ │ vocab_dict = vocab if isinstance(vocab, dict) else {w:i for i,(w,_) in enumerate(vocab)}
│ │ fast_tokenizer = TokenizerFast(BPE(vocab=vocab_dict, merges=merges, fuse_unk=True, dropout=None))
│ │ [PyO3 FFI → Rust BPE::new → Tokenizer::new]
│ │
│ ├─[4b] isinstance(vocab, dict)?
│ │ fast_tokenizer = TokenizerFast(BPE(vocab=vocab, merges=[], fuse_unk=True, dropout=None))
│ │
│ └─[4c] isinstance(vocab, list) and isinstance(vocab[0], (tuple,list))?
│ fast_tokenizer = TokenizerFast(Unigram(vocab=vocab, unk_id=kwargs.get("unk_id", 0)))
│ [PyO3 FFI → Rust Unigram::new → Tokenizer::new]
│
├─[3] 赋值 self._tokenizer
│ self._tokenizer = fast_tokenizer [:388-392]
│ (此时 self._tokenizer 是 Rust tokenizers.Tokenizer 对象)
│
├─[4] 截断配置 (三级优先级)
│ tokenizer_truncation (显式传入) > self._tokenizer.truncation (Rust 自带) > _json_truncation (JSON 备份)
│ self._tokenizer.enable_truncation(**_truncation) [:394-402]
│ [PyO3 FFI → Rust Tokenizer::enable_truncation]
│
├─[5] 填充配置
│ tokenizer_padding > self._tokenizer.padding > _json_padding
│ self._tokenizer.enable_padding(**_padding) [:404-411]
│ [PyO3 FFI → Rust Tokenizer::enable_padding]
│
├─[6] 后端标识
│ kwargs["backend"] = "tokenizers" [:414]
│
├─[7] Post-processor 设置
│ if post_processor := kwargs.pop("post_processor", None):
│ self._tokenizer.post_processor = post_processor [:420-422]
│ [直接赋值 Rust PostProcessor 对象给 Rust Tokenizer 属性]
│
├─[8] 调用基类初始化
│ super().__init__(**kwargs) [:424]
│ → PreTrainedTokenizerBase.__init__()
│ → 处理 special_tokens_map, added_tokens_decoder 等
│
├─[9] 特殊 token 添加
│ for token in added_tokens_decoder + _special_tokens_map:
│ token = AddedToken(token, special=True)
│ self.add_tokens(tokens) [:432-467]
│ [PyO3 FFI → Rust Tokenizer::add_special_tokens]
│
├─[10] Mistral regex 补丁 (条件性)
│ vocab_size > 100000 且检测到 Mistral 配置?
│ └─ _patch_mistral_regex() [:474-483]
│ 替换错误的 pre-tokenizer Split pattern
│ [PyO3 FFI → Rust Tokenizer::pre_tokenizer = ...]
│
└─[11] Post-processor 更新 (条件性)
if _should_update_post_processor:
update_post_processor() [:485-489]
→ 重建 TemplateProcessing
self._tokenizer.post_processor = processors.TemplateProcessing(...)
[PyO3 FFI → Rust PostProcessor 赋值]
2.4 关键设计决策
- 懒加载映射:
_LazyAutoMapping通过importlib.import_module按需导入,避免数百个模型模块拖慢import transformers,模块对象缓存避免重复导入开销 - V5 统一快速 Tokenizer:
use_fast参数被完全忽略,统一使用基于 Rusttokenizers库的快速 tokenizer,消除了 V4 slow/fast 两套实现的行为不一致问题 - 多层级类型推断:8 条优先级路径确保向后兼容(老模型配置仍可加载)、安全性(
MODELS_WITH_INCORRECT_HUB_TOKENIZER_CLASS防止错误配置)和灵活性(远程代码支持) - 安全设计:路径遍历防护(CWE-22)防止不受信任的
tokenizer_config.json指向任意文件系统路径;远程代码注册保护防止覆盖原生模型映射;trust_remote_code要求用户显式确认才执行 Hub 上的自定义代码 - Added Tokens 多来源兼容:四种来源(现代
added_tokens_decoder、遗留special_tokens_map.json、遗留added_tokens.json、tokenizer.json内嵌)统一转换为AddedToken对象,确保不同版本保存的 tokenizer 都能正确加载 - PyO3 选择:类型安全的 Rust-Python 绑定,自动处理 GIL 和引用计数,
abi3确保跨 Python 版本兼容。tokenizers库使用 PyO3 而非 cffi/ctypes,原因:
-
- PyO3 提供了类型安全的 Rust-Python 绑定,自动处理引用计数和 GIL
- 支持
#[pymodule]宏自动生成模块初始化代码 - 从
.so中的 strings 输出可见,所有子模块(models、normalizers、decoders 等)都在同一个.so中,PyO3 的模块系统使其成为可能 abi3标签确保编译产物兼容多个 Python 版本,无需按版本分发
3.tokenizers库
Rust Tokenizer的实现位于tokenizers库,与transfromers库平级。
项目源码:https://github.com/huggingface/tokenizers
(AI) peile.duan@U-7K6V5HCV-0153 tokenizers % ls
__init__.py decoders normalizers tokenizers.abi3.so trainers
__init__.pyi implementations pre_tokenizers tokenizers.pyi
__pycache__ models processors tools
3.1 背景
在 2018 年之前,早期的 NLP 模型(如 BERT、GPT-1)的分词器全是用纯 Python 写的(如 gensim、早期 transformers 的 BertTokenizer)。
痛点爆发: 当 OpenAI 发布 GPT-2,词表扩大到 50257,预训练数据集达到 40GB 文本时,纯 Python 分词器成了瓶颈。用 Python 正则切分 40GB 文本并跑 BPE 算法,需要耗费数天时间,而且受制于 Python 的 GIL(全局解释器锁),无法真正多线程并行。
破局: 2019 年,Hugging Face 推出了用 Rust 重写的 tokenizers 库。它带来了两个降维打击的优势:
- 极致性能:Rust 的原生速度 + 并行化(Rayon库),处理 GPT-2 级别的数据集从数天缩短到几分钟。
- 绝对一致性:无论你在 Python、Node.js 还是 Rust 中调用,只要使用同一个
tokenizer.json,分词结果字节级完全一致,消除了跨语言部署的隐患。
关键特性:
- Rayon 数据并行: 传进来 1000 句话,Rust 端通过 Rayon 库瞬间拉起所有 CPU 核心并行处理,无需像 Python 那样等 GIL 解锁。
- 零拷贝与原地操作: 文本在内存中被转换为 UTF-8 字节切片(
&[u8]),后续的 PreTokenizer 和 Model 尽量在这个切片上直接操作,避免反复申请和拷贝内存。 - Trie 树与 Aho-Corasick 自动机: 在匹配特殊 Token 或进行 WordPiece 匹配时,Rust 端使用高度优化的双数组 Trie 树,查找时间复杂度接近 O(1)。
在 LLM 时代,分词器要处理几十万词表、复杂的正则切分和 BPE 合并。如果用纯 Python 写,处理 1 万条文本可能需要几秒;而用 Rust 写,只需几十毫秒。tokenizers 用 Rust 把分词从一个”慢且易错”的工程难题,变成了”快且可靠”的标准化组件,让高层框架能专注于模型架构与推理优化,无需再为文本预处理性能操心。
|
维度 |
说明 |
|
上游依赖 |
Hugging Face 十亿级模型的一致性分词保证 |
|
核心价值 |
把分词从”训练瓶颈”变成”零成本操作” |
|
性能标杆 |
LLM 推理 Prefill 阶段的分词耗时 < 5ms |
|
生态覆盖 |
几乎所有主流 LLM(LLaMA/Qwen/Mistral/DeepSeek/Gemma) |
|
扩展性 |
自定义 Normalizer/PreTokenizer 只需几行 Rust |
|
与 llama.cpp 关系 |
GGUF 格式中的 tokenizer 信息需转换为此库格式 |
3.2 核心架构
tokenizers 库的核心设计思想是流水线。一段文本变成 ID,必须经过四个独立且可插拔的 Rust 组件。具体如下:
Raw Text (原始文本)
│
▼
┌─────────────────┐
│ 1. Normalizer │ (规范化:清理乱码、小写化、NFC统一等)
└────────┬────────┘
▼
┌─────────────────┐
│ 2. PreTokenizer │ (预分词:按空格/标点切分成粗粒度词,防越界)
└────────┬────────┘
▼
┌─────────────────┐
│ 3. Model │ (核心算法:BPE / WordPiece / Unigram,在预切分结果上做子词合并/切分)
└────────┬────────┘
▼
┌─────────────────┐
│ 4. PostProcessor│ (后处理:加特殊Token,如 [CLS] <s> <|im_start|>)
└────────┬────────┘
▼
Token IDs + Offsets (整数序列 + 原文偏移量)
3.2.1 Normalizer:文本归一化
作用:将语义等价的文本映射到统一表示,减少词表稀疏性。
|
类型 |
功能 |
示例 |
|
|
Unicode 规范化(组合字符统一) |
|
|
|
转小写 |
|
|
|
BERT 专用(小写+去重音+清洗) |
|
|
|
NMT 模型(空格处理) |
多空格→单空格 |
|
|
去除首尾空白 |
|
|
|
组合多个归一化器 |
Lowercase + Strip |
3.2.2 PreTokenizer:预分词
作用:在进入计算密集的子词模型之前,用快速规则切分文本,大幅缩小后续搜索空间。
常见 PreTokenizer:
|
类型 |
规则 |
适用模型 |
|
|
按字节边界切分,对应 GPT-2/LLaMA 类 |
GPT-2, RoBERTa, LLaMA, Qwen |
|
|
空格用 |
SentencePiece, T5, Llama2 |
|
|
纯空格切分 |
早期 Word2Vec |
|
|
标点和空格分别切分 |
BERT |
|
|
自定义字符切分 |
特殊格式 |
3.2.3 Model:核心分词算法
这是整个 Pipeline 的计算核心,决定了 token 的质量和词表结构,真正决定子词切分算法的组件。三大主流模型全在 Rust 端实现:
- BPE (Byte-Pair Encoding):GPT、LLaMA、Qwen 使用。贪心合并最高频的相邻字符对。
- WordPiece:BERT 使用。类似 BPE,但选择合并对的标准是似然概率增益最大化(用
##表示后缀)。 - Unigram:T5、ALBERT 使用。自顶向下,从大词表开始,逐步剔除对整体似然概率贡献最小的 Token,保留最优子词。
3.2.4 PostProcessor(后处理器)
为了适配不同模型的特殊输入格式要求。
- BertProcessing:在句首加
[CLS],句尾加[SEP]。 - TemplateProcessing(现代 LLM 必备):根据 Chat Template 填充特殊 Token。
-
- 例如:
single = "<|im_start|>user $A <|im_end|> <|im_start|>assistant",自动把用户输入$A塞进去,并正确计算特殊 Token 的 ID 和 Type ID。
- 例如:
3.3 核心分词算法对比
观察当前主流开源和闭源 LLM 的选型,会发现一个极其明显的趋势:BPE(特别是 Byte-level BPE)一统天下,WordPiece 几乎被淘汰,Unigram 退守特定生态位。
2024-2025 年主流 LLM 分词方案:
┌─────────────────────────────────────────────────────────┐
│ GPT-4o → ByteLevel BPE (tiktoken, ~100K) │
│ LLaMA 3/4 → ByteLevel BPE (tiktoken, 128K) │
│ Qwen 2.5/3 → ByteLevel BPE (151K) │
│ Mistral 7B → ByteLevel BPE (32K) │
│ DeepSeek V3 → ByteLevel BPE (128K) │
│ Gemma 2 → ByteLevel BPE (256K) │
│ Phi-3/4 → ByteLevel BPE (32K) │
│ Command R+ → ByteLevel BPE (256K) │
├─────────────────────────────────────────────────────────┤
│ BERT 系列 → WordPiece (30K) ← 仅存于旧模型 │
│ T5 / mT5 → Unigram (32K) ← 仅存于 Encoder │
└─────────────────────────────────────────────────────────┘
BPE/WordPiece/Unigram的核心分歧在于:“什么算好的子词?”
- BPE 认为:出现频率高的子词好
- WordPiece 认为:让语料概率最大的子词好
- Unigram 认为:在概率模型下信息量最大的子词好
3.3.1 为什么 BPE (Byte-level) 成为 LLM 的绝对霸主?
- 降维打击的 OOV 解决能力: 在 LLM 时代,模型需要处理代码、数学公式、多语言混合、甚至乱码。WordPiece 和传统的 Unigram 遇到没见过的符号会输出
<UNK>,导致信息丢失。而 Byte-level BPE 将一切映射为 256 个基础字节,物理上消灭了<UNK>。模型永远能处理任何输入,只是可能拆得比较碎。 - 训练成本极低: BPE 的训练只需要统计 n-gram 频率,时间复杂度低,不需要像 WordPiece/Unigram 那样去拟合一个语言模型。对于动辄 10TB 的预训练语料,BPE 可以在几小时内用多核 CPU 跑完,而 WordPiece 可能需要数天。
- 推理速度极快: BPE 的推理是简单的贪心匹配(在 Rust 实现中通常构建为 Trie 树或 DAWG),速度极快。而 Unigram 的 Viterbi 解码需要动态规划,计算开销更大。在 LLM 推理的 Prefill 阶段,分词速度必须让位于模型计算,BPE 的轻量级特性完美契合。
- 与自回归架构的完美契合: LLM 是 Next-token prediction。BPE 从左到右的贪心切分逻辑,与自回归生成的逻辑在直觉上更一致。
|
原因 |
说明 |
|
简单高效 |
训练和分词都是确定性算法,易于并行化 |
|
与 ByteLevel 天然配合 |
任何语言/符号都能处理,无需语言假设 |
|
推理确定性 |
同一输入永远同一输出,适合生产环境 |
|
工程生态成熟 |
tiktoken / sentencepiece / HF tokenizers 全支持 |
|
词表扩展灵活 |
可以在基础词表上追加领域词汇 |
|
训练速度快 |
比 Unigram 快 10 倍以上 |
3.3.2 为什么 WordPiece 在 LLM 时代没落了?
- 历史局限性:WordPiece 是为 BERT(双向编码器)设计的。
##后缀的设计在 Masked Language Modeling (MLM) 中表现很好,但在自回归生成(如 GPT/LLaMA)中,##并没有带来比 Byte-level 更好的压缩率。 - 无法处理非自然语言数据:WordPiece 强依赖自然语言的词边界和概率。当输入包含大量 Python 代码、JSON 格式或特殊控制符时,WordPiece 的分词效果会急剧下降,而 Byte-level BPE 依然稳健。
- 注:RoBERTa 虽然继承了 BERT 的衣钵,但已经抛弃 WordPiece 改用了 BPE。
3.3.3 Unigram 为什么还能在 T5/Gemma/多语言模型中占有一席之地?
虽然 Unigram 训练慢、推理稍复杂,但它在以下场景具有不可替代的优势:
- 多语言场景的“公平性”: 在构建多语言模型(如 mBART, T5)时,英语词频极高,小语种词频极低。如果用 BPE,英语会占据词表的大部分,小语种被切得极碎。Unigram 的“自顶向下剔除”机制,能够更好地评估每个 token 对全局 Loss 的贡献,从而在词表分配上对不同语言更加公平,提高低资源语言的压缩率。
- 支持 Subword Regularization(子词正则化): Unigram 的 Viterbi 算法可以很容易地输出多种概率相近的分词结果(n-best)。在训练时,随机采样不同的分词结果输入模型,相当于一种强大的数据增强(Data Augmentation),能显著提升模型的鲁棒性(Dropout 效果)。BPE 很难做到这一点。
- SentencePiece 的绑定: Google 推出的 SentencePiece 工具包默认使用 Unigram(也支持 BPE)。由于 T5、Gemma 等 Google 系模型深度绑定 SentencePiece,Unigram 得以在这些架构中延续。
总结:BPE (Byte-level) 凭借其消灭 OOV、训练极快、推理轻量的三大优势,成为了当前自回归 LLM 的绝对标准配置。WordPiece 完成了其在 BERT 时代的使命,因无法适应多模态/代码/自回归场景而退出历史舞台。Unigram 凭借其在多语言公平性和子词正则化上的独特数学性质,在 Seq2Seq 架构(T5)和特定多语言模型中保留了高价值的生态位。
3.4 BPE分词机制
Hugging Face 需要支持两种主流分词算法:BPE(如 GPT, LLaMA, Qwen 使用)和 Unigram(如 T5, Albert 使用)。这两种算法的词表数学结构完全不同:
- BPE 的词表是无分数的映射:
{"高": 234, "兴": 567, "高兴": 890}-> 在 Python 中表现为dict - Unigram 的词表是带概率/分数的序列:
[("高", -0.12), ("兴", -0.45), ("高兴", -2.3)]-> 在 Python 中表现为list[tuple]
SentencePiece
BPE (Byte-Pair Encoding):自底向上的“频率贪心”
- 核心思想:从字符级别开始,不断合并出现频率最高的相邻 token 对,直到达到预设的词表大小。
- 训练过程:
-
- 基础词表:所有单字符(或字节)。
- 统计语料中所有相邻 token 对的频率。
- 找到频率最高的 pair(如
t和h),合并为新 tokenth,加入词表。 - 在语料中替换所有
t h为th。 - 重复 2-4,直到词表达到目标大小(如 50,000)。
初始状态:每个词拆成字符序列(+ 词尾标记 </w>)
语料: "low lower lowest"
初始: l o w </w> l o w e r </w> l o w e s t </w>
迭代 1:统计所有相邻对频率
(l, o) → 3
(o, w) → 3
(w, </w>) → 1
(w, e) → 2
(e, r) → 1
(e, s) → 1
(s, t) → 1
迭代 2:合并最高频对 (l, o) → "lo"
lo w </w> lo w e r </w> lo w e s t </w>
迭代 3:合并 (lo, w) → "low"
low </w> low e r </w> low e s t </w>
... 重复,直到词表达到目标大小(如 50000)
- 推理/分词过程:使用贪心策略或 Trie 树,从左到右匹配最长的已知 token。
分词过程:
1. PreTokenizer 切分 → 每个片段拆成字符
2. 按合并规则(merges)从高到低合并
3. 无法继续合并时输出
- LLM 时代的进化(Byte-level BPE):GPT-2 引入了字节级 BPE。将 Unicode 字符映射到 256 个基础字节上。这彻底消灭了
<UNK>(未知词)token,因为任何字符、emoji、甚至乱码,都可以被拆解为字节。
from tokenizers import Tokenizer, models
from tokenizers.models import BPE
tokenizer = Tokenizer(models.BPE(
vocab={"<s>": 0, "<pad>": 1, "</s>": 2, "h": 3, "e": 4, "l": 5, "o": 6},
merges=["e l", "l l", "l o"], # 合并规则
))
# "hello" → ["h", "el", "lo"](取决于 merges 顺序)
3.5 分词与LLM
在实际的 LLM 工程中,算法本身只是基础,词表设计(Vocab Design)和工程优化往往更决定最终效果:
3.5.1 词表大小(Vocab Size)的博弈
- BPE 词表越大越好吗? 不一定。词表越大,文本被压缩得越短,Transformer 的序列长度(Sequence Length)越短,推理越快。但是,Embedding 层和 LM Head 层的参数量与词表大小成正比。
- 实际选型:
-
- LLaMA-2/3:32K(平衡了英文压缩率和显存占用)。
- Qwen-2.5:151,936(超大词表!为了极大提高中文的压缩率,避免中文被切成过多的碎 token,同时兼容多语言)。
- Mistral:32K。
|
词表大小 |
优点 |
缺点 |
|
32K(LLaMA 1) |
模型小,推理快 |
中文/代码效率极低(一个汉字 = 3+ token) |
|
50K(GPT-2) |
英文够用 |
中文仍然低效 |
|
100K(Qwen2) |
中文效率高(一个汉字 ≈ 1-2 token) |
模型参数增加(Embedding + LM Head) |
|
128K(Gemma 2) |
多语言均衡 |
词表稀疏,训练需要更多数据 |
|
151K(Qwen2.5) |
中英代码全覆盖 |
Embedding 层参数量大 |
词表大小对模型参数的影响:
Embedding 层参数量 = vocab_size × hidden_dim
LM Head 参数量 = vocab_size × hidden_dim(通常与 Embedding 共享)
以 Qwen2.5-72B 为例:
vocab = 151,936
hidden_dim = 8,192
Embedding 参数 = 151,936 × 8,192 ≈ 1.24B(占总参数 1.7%)
以 LLaMA-3-70B 为例:
vocab = 128,256
hidden_dim = 8,192
Embedding 参数 = 128,256 × 8,192 ≈ 1.05B(占总参数 1.5%)
结论:词表从 32K 扩到 150K,额外增加约 1B 参数。对 70B 模型来说占比 < 2%,完全可接受
3.5.2 中文压缩率(Compression Ratio)痛点
在 Byte-level BPE 中,由于 UTF-8 编码的特性,一个中文字符通常占 3 个字节。如果词表中没有足够的中文高频词/字,一个中文字可能会被拆成 3 个 token,而英文单词 the 只有 1 个 token。这导致同样的语义,中文消耗的 token 数量远大于英文,推理成本翻倍。
- 解决方案:Qwen、ChatGLM 等国产模型在训练 BPE 时,刻意在语料中增加中文权重,或者人为扩充中文字词表,这就是为什么 Qwen 的词表高达 150K+ 的原因。
输入: "人工智能正在改变世界"
GPT-2 (50K 词表, ByteLevel BPE):
→ 约 18~22 个 token(每个汉字 2-3 token)
LLaMA 2 (32K 词表):
→ 约 25~30 个 token(更差)
Qwen2.5 (151K 词表, 中文优化):
→ 约 8~10 个 token(一个汉字 ≈ 1 token)
影响:
同样一句话,Qwen 消耗的 token 数是 GPT-2 的 1/3
→ 推理速度快 3 倍
→ 上下文窗口利用率高 3 倍
→ 训练数据"信息密度"高 3 倍
3.5.3 特殊 Token 与 Chat Template 的融合
现代 LLM 的分词器不仅仅是“切词工具”,它还承担了控制流的作用。
- 分词器词表中会硬编码大量特殊 token,如
<|im_start|>,<|im_end|>,<|system|>,<|user|>。 - 这些 token 在词表中的 ID 是固定的,不参与 BPE 合并。
- 配合
apply_chat_template,分词器负责将复杂的 JSON 对话结构,精确地转换为模型能理解的 Token ID 序列。这是当前 LLM 对齐(Alignment)工程中最容易出 Bug 的环节。
在实际工程中,当你看到一个 LLM 时,不需要再去纠结它用了哪种底层算法(大概率是 Byte-level BPE),而应该关注它的词表大小、对目标语言(如中文)的压缩率、以及特殊 Token 的设计,这些才是决定模型实际表现和推理成本的关键。
3.6 训练分词器
tokenizers原生支持从零训练分词器。
from tokenizers import Tokenizer, models, trainers, pre_tokenizers
# 1. 初始化 BPE 模型
tokenizer = Tokenizer(models.BPE())
# 2. 设置预分词器
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel()
# 3. 配置训练器
trainer = trainers.BpeTrainer(
vocab_size=50000,
min_frequency=2, # token 出现 < 2 次丢弃
special_tokens=["<unk>", "<s>", "</s>"],
show_progress=True,
)
# 4. 训练(支持流式文件,不会一次性读入内存)
tokenizer.train(files=["text1.txt", "text2.txt"], trainer=trainer)
# 5. 保存
tokenizer.save("my_tokenizer.json")
4.对话模版渲染
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
该函数对应的完整调用链如下所示:
tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
│
├─[1] 参数前处理 [base.py:3076]
│ tokenize=False → return_dict 强制为 False
│ return_assistant_tokens_mask 约束检查
│
├─[2] 获取聊天模板 [base.py:3085]
│ self.get_chat_template(chat_template=None, tools=None)
│ │ [base.py:3225-3277]
│ ├─ self.chat_template 是 dict? → 多模板模式
│ │ ├─ 有 tools 且有 "tool_use" → 选 tool_use
│ │ └─ 无 tools → 选 "default"
│ └─ self.chat_template 是 str? → 直接返回
│ = 模板字符串 (Jinja2 语法)
│
├─[3] 空对话校验 + 批次检测 + 互斥校验 [base.py:3087-3106]
│ conversation 非空? ✓
│ is_batched = False (单条对话)
│ continue_final_message 互斥? ✓ (默认 False)
│
├─[4] 构建模板上下文 [base.py:3107]
│ template_kwargs = {**self.special_tokens_map, **kwargs}
│ (bos_token, eos_token, pad_token, ... → 模板变量)
│
├─[5] Jinja2 模板渲染 [base.py:3111]
│ render_jinja_template(conversations, chat_template, add_generation_prompt=True, ...)
│ │ [chat_template_utils.py:498-608]
│ │
│ ├─[5a] 编译模板 (LRU 缓存) [chat_template_utils.py:419]
│ │ _compile_jinja_template(chat_template)
│ │ └─ _cached_compile_jinja_template() [chat_template_utils.py:425]
│ │ ├─ 检查 jinja2 >= 3.1.0
│ │ ├─ 创建 ImmutableSandboxedEnvironment (安全沙箱)
│ │ │ ├─ trim_blocks=True, lstrip_blocks=True
│ │ │ ├─ 自定义 tojson 过滤器 (不转义 HTML)
│ │ │ ├─ 注册 raise_exception, strftime_now
│ │ │ └─ 加载 AssistantTracker + loopcontrols 扩展
│ │ └─ jinja_env.from_string(chat_template) → 编译后模板对象
│ │
│ ├─[5b] Tools 预处理 (本次 None, 跳过) [chat_template_utils.py:516]
│ │ dict → 直接使用; function → get_json_schema()
│ │
│ ├─[5c] 逐对话渲染 [chat_template_utils.py:537]
│ │ for chat in conversations:
│ │ ├─ Conversation 对象? → 提取 .messages
│ │ ├─ continue_final_message? (本次 False, 跳过)
│ │ ├─ return_assistant_tokens_mask? (本次 False)
│ │ │ └─ False → compiled_template.render() [普通渲染路径]
│ │ │
│ │ │ 传入模板上下文变量:
│ │ │ ├─ messages = [{role, content}, ...]
│ │ │ ├─ tools = None
│ │ │ ├─ documents = None
│ │ │ ├─ add_generation_prompt = True ← 关键变量
│ │ │ └─ **kwargs (bos_token, eos_token, ...)
│ │ │
│ │ │ 模板内部执行 (Jinja2 渲染):
│ │ │ ...
│ │ │ {% for message in messages %}
│ │ │ <|im_start|>{{ message.role }}
│ │ │ {{ message.content }}<|im_end|>
│ │ │ {% endfor %}
│ │ │ {% if add_generation_prompt %}
│ │ │ <|im_start|>assistant
│ │ │ {% endif %}
│ │ │ ...
│ │ │
│ │ └─ 返回 rendered_chat (字符串)
│ │
│ └─ 返回 (rendered_chat_list, generation_indices)
│
├─[6] 非批量解包 [base.py:3119]
│ rendered_chat = rendered_chat[0] (从列表取出单条)
│
└─[7] tokenize 分支 [base.py:3122]
│
├─ tokenize=True (本次不走):
│ self.__call__(rendered_chat, add_special_tokens=False, ...)
│ │ [base.py:2418] → _encode_plus() [tokenizers.py:925]
│ │ → self._tokenizer.encode_batch() ← Rust FFI
│ │ → BatchEncoding (input_ids, attention_mask, ...)
│
└─ tokenize=False ← 本例走此路径 [base.py:3162]
return rendered_chat → 直接返回文本字符串
4.1 chat_template.jinja
该文件负责将人类/程序易于理解的结构化对话数据(JSON 格式的 messages),转换为模型在训练时“见过”的特定纯文本格式的“协议说明书”。
LLM 底层是 Next-Token Prediction(下一个词预测) 模型。它:
- ❌ 不懂 JSON:
{"role": "user", "content": "你好"} - ❌ 不懂 Python 字典或 API 结构
- ✅ 只懂纯文本序列:
<|im_start|>user\n你好<|im_end|>\n<|im_start|>assistant
chat_template 的作用就是“翻译官”:将上层应用传入的结构化多轮对话(List/JSON),按模型预训练/微调时所使用的特定文本格式,渲染成一段纯字符串。这段字符串随后交给 tokenizer 转为 Token IDs,送入模型。不同模型家族在 SFT 阶段使用了不同的对话格式,模板必须严格匹配:
|
模型家族 |
模板风格 |
核心特征 |
渲染示例片段 |
|
Qwen / DeepSeek / OpenAI |
|
使用 / 包裹角色与内容 |
`< |
|
Llama 3 |
|
使用 `< |
start_header_id |
|
Mistral / Mixtral |
|
使用 / ,无显式 role 标签 |
|
chat_template.jinja 是 LLM 的“对话语法词典”。模型权重决定“有多聪明”,而模板决定“能不能听懂人话”。格式错配时,再强的模型也会变成复读机或乱码生成器。在现代 LLM 工程中,它已从“可选元数据”升级为一等公民(First-Class Citizen)。无论是推理引擎(vLLM/SGLang)、微调框架(LLaMA-Factory/Axolotl),还是端侧部署(llama.cpp/Ollama),都依赖它实现安全、一致、可扩展的对话交互。
以Qwen3.8-27B-4bit/chat_template.jinja为例,整个模版分为如下几块内容:
┌──────────────────────────────────────────────────────────┐
│ 区块 1: 图片/视频计数器初始化 (L1-2) │
│ 区块 2: render_content 宏定义 (L3-41) │
│ 区块 3: 消息非空校验 (L42-44) │
│ 区块 4: 推理模式配置 (L45-56) │
│ 区块 5: System 消息 + 工具声明 (L57-87) │
│ 区块 6: 末尾用户提问检测 (L88-101) │
│ 区块 7: 主消息渲染循环 (L102-162) │
│ 区块 8: 生成提示 (add_generation_prompt) (L163-170) │
└──────────────────────────────────────────────────────────┘
4.1.1 render_conent宏
content 是什么?
│
├─[1] 字符串 → 直接输出 {{ content }} [L4-5]
│
├─[2] 可迭代且非 mapping (内容块列表) [L6-35]
│ 遍历每个 item:
│ ├─ 图片? ('image' in item or 'image_url' in item or item.type == 'image')
│ │ ├─ is_system_content? → raise_exception [L9-11]
│ │ ├─ do_vision_count? → image_count.value += 1 [L12-14]
│ │ ├─ add_vision_id? → 输出 "Picture 1: " [L15-17]
│ │ └─ 输出 '<|vision_start|><|image_pad|><|vision_end|>' [L18]
│ │
│ ├─ 视频? ('video' in item or item.type == 'video')
│ │ ├─ 同图片逻辑,但用 video_count 和 'Video N: ' [L19-28]
│ │ └─ 输出 '<|vision_start|><|video_pad|><|vision_end|>'
│ │
│ ├─ 文本? ('text' in item) → 输出 item.text [L30-31]
│ │
│ └─ 其他 → raise_exception('Unexpected item type') [L32-33]
│
├─[3] None/undefined → 输出空字符串 [L36-37]
│
└─[4] 其他 → raise_exception('Unexpected content type') [L38-39]
4.1.2 推理模式配置
enable_thinking和reasoning_effort都是外部传入的模板变量,可通过apply_chat_template(messages, enable_thinking=True, reasoning_effort='low')控制- 三级推理深度影响注入 system 消息的指令文本,引导模型调整思考深度
medium档位不注入额外指令(保持默认行为)
{%- set reasoning_instructions = '' %}
{%- if enable_thinking is undefined or enable_thinking is true %}
{%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}
{%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %}
{{- raise_exception('Unexpected reasoning effort ...') }}
{%- endif %}
{%- if resolved_reasoning_effort == 'xhigh' %}
{%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully ...' %}
{%- elif resolved_reasoning_effort == 'low' %}
{%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief ...' }}
{%- endif %}
{%- endif %}
enable_thinking 的值?
│
├─ undefined 或 true → 推理模式开启
│ ├─ reasoning_effort 参数 → 默认 'xhigh'
│ ├─ 校验: 必须是 'xhigh' | 'medium' | 'low'
│ ├─ 'xhigh' → 深度推理指令:
│ │ "Reasoning effort is set to xhigh. Please think carefully through the task,
│ │ validate key assumptions, consider plausible alternatives,
│ │ and prioritize correctness, consistency, and clarity..."
│ │
│ ├─ 'low' → 简略推理指令:
│ │ "Reasoning effort is set to low. Keep your thinking brief and focused,
│ │ moving directly to the conclusion without unnecessary elaboration."
│ │
│ └─ 'medium' → 无额外指令 (只有默认行为)
│
└─ false → 推理模式关闭
reasoning_instructions 保持空字符串
4.1.3 System 消息 + 工具声明
即使用户未提供 system 消息,只要推理模式开启,模板会自动注入一个包含推理指令的 system 消息。
有tools的情况:
{%- if tools and tools is iterable and tools is not mapping %}
{{- '<|im_start|>system\n' }}
{%- if reasoning_instructions %}
{{- reasoning_instructions + '\n\n' }}
{%- endif %}
{{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
{%- for tool in tools %}
{{- "\n" }}
{{- tool | tojson }}
{%- endfor %}
{{- "\n</tools>" }}
{{- '<函数调用格式说明>' }} {# 详细的 XML 格式规范 #}
{%- if messages[0].role == 'system' %}
{%- set content = render_content(messages[0].content, false, true)|trim %}
{%- if content %}
{{- '\n\n' + content }}
{%- endif %}
{%- endif %}
{{- '<|im_end|>\n' }}
<|im_start|>system
[推理指令 (如有)]
# Tools
You have access to the following functions:
<tools>
{tool1 JSON Schema}
{tool2 JSON Schema}
</tools>
If you choose to call a function ONLY reply in the following format...
<function=example_function_name>
<parameter=example_parameter_1>
value_1
</parameter>
</function>
...
<IMPORTANT>
Reminder:
- Function calls MUST follow the specified format...
- Required parameters MUST be specified
- You may provide optional reasoning BEFORE the function call, but NOT after
- If there is no function call available, answer normally
</IMPORTANT>
[用户 system 消息内容 (如有)]
<|im_end|>
无tools的情况:
{%- else %}
{%- if messages[0].role == 'system' %}
{%- set content = render_content(messages[0].content, false, true)|trim %}
{%- if content %}
{{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '') + content + '<|im_end|>\n' }}
{%- elif reasoning_instructions %}
{{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
{%- endif %}
{%- elif reasoning_instructions %}
{{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
三种子情况:
- 用户有 system 消息且有内容 → 推理指令 + 用户内容合并输出
- 用户有 system 消息但内容为空且有推理指令 → 只输出推理指令
- 用户无 system 消息但有推理指令 → 创建 system 消息只含推理指令
4.1.4 末尾用户提问检测
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
{%- for message in messages[::-1] %}
{%- set index = (messages|length - 1) - loop.index0 %}
{%- if ns.multi_step_tool and message.role == "user" %}
{%- set content = render_content(message.content, false)|trim %}
{%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
{%- set ns.multi_step_tool = false %}
{%- set ns.last_query_index = index %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- if ns.multi_step_tool %}
{{- raise_exception('No user query found in messages.') }}
{%- endif %}
反向遍历消息列表,找到最后一个真正的用户提问(而非工具返回结果的 user 消息)。
messages[::-1] 反向遍历
│
├─ 遇到 user 消息?
│ ├─ content 以 <tool_response> 开头且以 </tool_response> 结尾?
│ │ ├─ 是 → 这是工具返回结果,跳过,继续找 [多步工具调用场景]
│ │ └─ 否 → 这是真正的用户提问
│ │ └─ last_query_index = index, multi_step_tool = false
│
└─ 全部遍历完,multi_step_tool 仍为 true?
└─ raise_exception('No user query found in messages.')
为什么需要这个:在多轮工具调用场景中,工具返回结果也可能以 user 角色出现(包裹在 <tool_response> 标签中)。模板需要区分”真正的人类提问”和”工具返回结果”,因为 last_query_index 影响后续 assistant 消息中推理内容的保留策略(见下一区块)。
4.1.5 主消息渲染循环
102 {%- for message in messages %}
103 {%- set content = render_content(message.content, true)|trim %}
104 {%- if message.role == "system" %}
105 {%- if not loop.first %}
106 {{- raise_exception('System message must be at the beginning.') }}
107 {%- endif %}
108 {%- elif message.role == "user" %}
109 {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
110 {%- elif message.role == "assistant" %}
111 {%- set reasoning_content = '' %}
112 {%- if message.reasoning_content is string %}
113 {%- set reasoning_content = message.reasoning_content %}
114 {%- endif %}
115 {%- set reasoning_content = reasoning_content|trim %}
116 {%- if preserve_thinking is undefined or preserve_thinking is true or loop.index0 > ns.last_query_index %}
117 {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
118 {%- else %}
119 {{- '<|im_start|>' + message.role + '\n' + content }}
120 {%- endif %}
121 {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
122 {%- for tool_call in message.tool_calls %}
123 {%- if tool_call.function is defined %}
124 {%- set tool_call = tool_call.function %}
推理内容保留策略:
preserve_thinking 的值?
│
├─ undefined 或 true → 保留所有 thinking 内容
│ 输出: <|im_start|>assistant
│ <think>
│ {reasoning_content}
│ </think>
│
│ {content}<|im_end|>
│
├─ false → 还要看位置:
│ ├─ loop.index0 > last_query_index (在最后用户提问之后)
│ │ → 保留 thinking (后续轮次可能需要参考)
│ │ 输出: <|im_start|>assistant
│ <think>{reasoning_content}</think>
│ {content}<|im_end|>
│ │
│ └─ loop.index0 <= last_query_index (在最后用户提问之前)
│ → 丢弃 thinking (节省上下文窗口)
│ 输出: <|im_start|>assistant
│ {content}<|im_end|>
关键设计:preserve_thinking=false 时,只丢弃最后用户提问之前的 assistant 推理内容,之后的保留——因为模型生成后续回复时需要参考最近的推理过程。这是一个上下文窗口管理策略。
工具调用封装:tool 角色消息被包装在 <tool_response> 标签中,并放在 user 角色下。连续的多个 tool 消息被合并到同一个 user 块中。这与区块 6 中检测 <tool_response> 标签的逻辑对应——模板自己生成的 tool 消息用 <tool_response> 标记,反向遍历时跳过这些”假” user 消息。
<|im_start|>user ← 仅在第一个 tool 消息前输出
<tool_response>
{tool_result_1}
</tool_response>
<tool_response>
{tool_result_2}
</tool_response>
<|im_end|> ← 仅在最后一个 tool 消息后输出
4.1.6 生成提示
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- if enable_thinking is defined and enable_thinking is false %}
{{- '<think>\n\n</think>\n\n' }}
{%- else %}
{{- '<think>\n' }}
{%- endif %}
{%- endif %}
当 add_generation_prompt=True 时:
enable_thinking 的值?
│
├─ false (明确关闭推理)
│ 输出: <|im_start|>assistant
│ <think>
│
│ </think>
│
│ (空 think 块,告诉模型跳过推理直接回答)
│
└─ undefined 或 true (推理开启)
输出: <|im_start|>assistant
<think>
(只输出起始标签,模型从 <think> 开始生成推理内容)
关键设计:
- 推理开启时只输出
<think>\n开头,模型自行生成推理内容直到</think>,然后输出正式回复 - 推理关闭时输出一个空的
<think>\n\n</think>\n\n,模型看到空 think 块后跳过推理直接输出正文——这是一种”占位跳过”策略
4.1.7 总结
- 可控推理深度(
enable_thinking+reasoning_effort):三级推理档位(xhigh/medium/low)通过注入不同的 system 指令影响模型思考深度,enable_thinking=false时输出空 think 块跳过推理 - 推理内容选择性保留(
preserve_thinking+last_query_index):多轮对话中丢弃最后用户提问之前的推理内容以节省上下文窗口,但保留之后的推理供模型参考 - 工具调用 XML 格式
- 工具结果包装为 user 角色:tool 消息被合并到
user角色块中,用result = [] # 非空 content 时换行,否则直接输出
- 多模态支持:图片/视频用特殊 token 占位(
<|vision_start|><|image_pad|><|vision_end|>),视觉数据由模型层处理,模板只负责位置标记 - namespace 计数器:绕过 Jinja2 作用域限制,使宏内递增的计数值在宏外部可见
- 安全沙箱配合:模板使用
raise_exception全局函数(transformers 在ImmutableSandboxedEnvironment中注册),在运行时抛出有意义的错误信息
4.2 运行示例
from transformers import AutoTokenizer, AutoModelForCausalLM
path = "./models/Qwen3.8-27B-4bit"
tokenizer = AutoTokenizer.from_pretrained(path)
messages = [{"role": "user", "content": "你好,介绍一下你自己"}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
print(text)
<|im_start|>system
Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.<|im_end|>
<|im_start|>user
你好,介绍一下你自己<|im_end|>
<|im_start|>assistant
<think>
可以看到默认的推理配置为xhigh。
4.3 关键设计决策
add_special_tokens=False(tokenize=True 时):聊天模板已包含所有控制 token(BOS、EOS、角色标记),tokenizer 只负责纯文本分词,避免重复添加。单一职责设计。- LRU 缓存编译模板:Jinja2 模板编译涉及词法/语法分析、AST 构建等昂贵操作,
@lru_cache确保同一模板只编译一次,多次调用apply_chat_template性能显著提升。 ImmutableSandboxedEnvironment安全沙箱:加载来自 HF Hub 的第三方模型模板时,模板是不受信任的内容,沙箱环境防止恶意模板执行任意 Python 代码。- 自定义
tojson过滤器:Jinja2 默认的tojson会转义 HTML 字符(<→\u003c),导致 tools JSON Schema 内容损坏。覆盖为标准json.dumps保持原样。 special_tokens_map作为模板变量:将bos_token、eos_token等特殊 token 的字符串值注入模板上下文,模板可直接引用{{ bos_token }}而非硬编码 token 字符串。- 多模板自动选择:当
chat_template是 dict 且用户未指定模板名时,系统根据tools参数智能路由——有 tools 选"tool_use"模板,无 tools 选"default"模板,用户无需手动切换。 .jinja文件优先于tokenizer_config.json:V5 将模板从 JSON 配置中抽离为独立.jinja文件,更易于版本管理和编辑,加载时覆盖 config 中的内联模板。
5.tokenizer编码
inputs = tokenizer(text, return_tensors="pt")
该函数对应的完整调用链如下所示:
tokenizer("Hello world")
│
├─ TokenizersBackend.__call__ (继承自基类)
│ [tokenization_utils_base.py:2418-2541]
│ ├─ 合并 all_kwargs (显式参数 + tokenizer_kwargs + **kwargs)
│ ├─ _get_padding_truncation_strategies()
│ │ → padding_strategy, truncation_strategy
│ └─ self._encode_plus(text, text_pair, ...) [:2520]
│
├─ TokenizersBackend._encode_plus()
│ [tokenization_utils_tokenizers.py:925-1077]
│ ├─ _is_valid_text_input() 输入验证 [:949]
│ ├─ 批次检测 is_batched? [:982]
│ │ 单条 → batch = [text]
│ │ 批量 → batch = zip(text, text_pair) or text
│ │
│ ├─ self.set_truncation_and_padding() [:1010]
│ │ ├─ self._tokenizer.enable_truncation(...) → Rust
│ │ └─ self._tokenizer.enable_padding(...) → Rust
│ │
│ ├─ self._tokenizer.encode_special_tokens = split [:1023]
│ │ [PyO3 FFI → Rust Tokenizer::encode_special_tokens setter]
│ │
│ ├─ encodings = self._tokenizer.encode_batch( [:1027] ★核心Rust调用★
│ │ batch_text_or_text_pairs,
│ │ add_special_tokens=add_special_tokens,
│ │ is_pretokenized=is_split_into_words
│ │ )
│ │ [PyO3 FFI → Rust Tokenizer::encode_batch]
│ │ │
│ │ │ ┌─────── Rust 层执行管道 ──────────────────┐
│ │ │ │ │
│ │ │ │ "Hello world" │
│ │ │ │ │ │
│ │ │ │ ▼ Normalizer │
│ │ │ │ (NFC/NFD/Lowercase/Replace/...) │
│ │ │ │ │ │
│ │ │ │ ▼ PreTokenizer │
│ │ │ │ (ByteLevel/Whitespace/Bert/...) │
│ │ │ │ │ │
│ │ │ │ ▼ Model │
│ │ │ │ (BPE: 合并规则查找 / Unigram: Viterbi / WordLevel: dict查找)
│ │ │ │ │ │
│ │ │ │ ▼ PostProcessor │
│ │ │ │ (TemplateProcessing: 加 [CLS] [SEP]) │
│ │ │ │ │ │
│ │ │ │ ▼ Truncation + Padding │
│ │ │ │ │ │
│ │ │ │ ▼ Encoding (Rust 对象) │
│ │ │ │ .ids .type_ids .attention_mask │
│ │ │ │ .offsets .special_tokens_mask │
│ │ │ │ .tokens │
│ │ │ │ .word_ids .sequence_ids │
│ │ │ │ │
│ │ │ └──────────────────────────────────────────┘
│ │ │
│ │ └─ 返回 List[Encoding] (Rust 对象)
│ │
│ ├─ _convert_encoding(encodings) [:1034]
│ │ for e in encodings:
│ │ 从 Rust Encoding 提取属性 → Python dict:
│ │ input_ids = e.ids
│ │ token_type_ids = e.type_ids
│ │ attention_mask = e.attention_mask
│ │ offset_mapping = e.offsets
│ │ special_tokens_mask = e.special_tokens_mask
│ │ tokens = e.tokens
│ │
│ └─ return BatchEncoding(sanitized_tokens, ...) [:1049]
│ (dict-like 对象,支持 tensor 转换)
│
└─ 返回给用户:
{
'input_ids': [101, 7592, 2088, 102, ...],
'token_type_ids': [0, 0, 0, 0, ...],
'attention_mask': [1, 1, 1, 1, ...],
...
}
5.1 tokenizer_config.json
它规定了文本如何切词(BPE + 正则预分词)、对话如何分轮(<|im_end|>)、图像/视频/音频如何嵌入序列(<|image_pad|> + <|vision_start|>/<|vision_end|>)、以及推理引擎如何正确加载和执行分词(Rust 后端 + 特殊 token 不拆分)。任何一个字段的错配,都会导致模型从”多模态智能体”退化为”乱码生成器”。
{
"add_prefix_space": false,
"audio_bos_token": "<|audio_start|>",
"audio_eos_token": "<|audio_end|>",
"audio_token": "<|audio_pad|>",
"backend": "tokenizers",
"bos_token": null,
"clean_up_tokenization_spaces": false,
"eos_token": "<|im_end|>",
"errors": "replace",
"image_token": "<|image_pad|>",
"is_local": true,
"local_files_only": false,
"model_max_length": 262144,
"model_specific_special_tokens": {
"audio_bos_token": "<|audio_start|>",
"audio_eos_token": "<|audio_end|>",
"audio_token": "<|audio_pad|>",
"image_token": "<|image_pad|>",
"video_token": "<|video_pad|>",
"vision_bos_token": "<|vision_start|>",
"vision_eos_token": "<|vision_end|>"
},
"pad_token": "<|endoftext|>",
"pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
"processor_class": "Qwen3VLProcessor",
"split_special_tokens": false,
"tokenizer_class": "Qwen2Tokenizer",
"unk_token": null,
"video_token": "<|video_pad|>",
"vision_bos_token": "<|vision_start|>",
"vision_eos_token": "<|vision_end|>"
}
5.1.1 重要字段说明
|
字段 |
值 |
含义 |
|
|
|
指定加载哪个 Python 类来处理分词。虽然模型是 Qwen3-VL,但分词算法与 Qwen2 完全一致(BPE + ByteLevel),无需新写类 |
|
|
|
强制使用 Rust 后端(HuggingFace 库),而非 Python 慢速实现。保证微秒级 encode/decode |
|
|
|
多模态专用。告诉 加载哪个处理器类,该类同时管理 tokenizer + image_processor + audio_processor |
|
|
|
标记模型文件在本地磁盘,不从 Hub 下载 |
|
|
|
允许在本地文件缺失时回退到 Hub 下载(与 |
5.1.2 多模态支持
"model_specific_special_tokens": {
"audio_bos_token": "<|audio_start|>",
"audio_eos_token": "<|audio_end|>",
"audio_token": "<|audio_pad|>",
"image_token": "<|image_pad|>",
"video_token": "<|video_pad|>",
"vision_bos_token": "<|vision_start|>",
"vision_eos_token": "<|vision_end|>"
}
用户发送一张图片 + 文字”描述这张图”,Qwen3VLProcessor 渲染后的文本序列:
<|im_start|>user
<|vision_start|><|image_pad|><|image_pad|>...<|image_pad|><|vision_end|>
描述这张图<|im_end|>
<|im_start|>assistant
其中 <|image_pad|> 重复 N 次(N = 图像 patch 数量,如 256/576/1024),每个位置的 embedding 在模型前向时被替换为 Vision Encoder 的输出向量。
用户输入: {"role":"user", "content":"<image>描述这张图", "image": img}
│
▼
Qwen3VLProcessor(由 processor_class 指定)
│
├──→ ImageProcessor: 图像 → patches → pixel_values 张量
│
└──→ Tokenizer(由 tokenizer_class + backend 指定)
│
├── apply_chat_template(): 渲染对话格式
│ 使用 eos_token, vision_bos/eos_token, image_token
│ split_special_tokens=false 保证特殊 token 完整
│
├── pretokenize_regex: 粗切文本
│
├── BPE Model: 子词合并
│
└── 输出: input_ids + 图像 token 位置掩码
│
▼
模型前向: 在 image_token 位置
用 Vision Encoder 输出替换 embedding
5.2 tokenizer.json
这个文件不仅仅是词表,它是整个 Rust 流水线的序列化快照。 一个 tokenizer.json 包含了 Normalizer、PreTokenizer、Model、PostProcessor 的完整配置和状态。只要有了这个文件:
- 脱离 Python:你可以用 Node.js、Rust 甚至 C++ 直接加载它,分词行为与 Python 端 100% 一致。
- 零依赖 Python 代码:像 vLLM 这样的高性能推理引擎,只要拿到
tokenizer.json,就可以完全绕过 Hugging Face 的 Pythontransformers库,直接在 C++/Rust 层面完成文本处理,避免 Python GIL 拖慢推理并发。
{
"version": "1.0",
"truncation": null,
"padding": null,
"added_tokens": [
{
"id": 248044,
"content": "<|endoftext|>",
"single_word": false,
"lstrip": false,
"rstrip": false,
"normalized": false,
"special": true
},
{
"id": 248045,
"content": "<|im_start|>",
"single_word": false,
"lstrip": false,
"rstrip": false,
"normalized": false,
"special": true
},
{
"id": 248046,
"content": "<|im_end|>",
"single_word": false,
"lstrip": false,
"rstrip": false,
"normalized": false,
"special": true
} ...],
"normalizer": {
"type": "NFC"
},
"pre_tokenizer": {
"type": "Sequence",
"pretokenizers": [
{
"type": "Split",
"pattern": {
"Regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+"
},
"behavior": "Isolated",
"invert": false
},
{
"type": "ByteLevel",
"add_prefix_space": false,
"trim_offsets": true,
"use_regex": false
}
]
},
"post_processor": {
"type": "ByteLevel",
"add_prefix_space": false,
"trim_offsets": false,
"use_regex": false
},
"decoder": {
"type": "ByteLevel",
"add_prefix_space": true,
"trim_offsets": true,
"use_regex": true
},
"model": {
"type": "BPE",
"dropout": null,
"unk_token": null,
"continuing_subword_prefix": "",
"end_of_word_suffix": "",
"fuse_unk": false,
"byte_fallback": false,
"ignore_merges": false,
"vocab": {"<s>": 0, "▁the": 1, ...},
"merges": ["▁ t", "▁th", "e r", ...]
}
}
5.2.1 关键说明
- Normalizer:仅 NFC(Unicode 规范化形式 C),不做大小写转换或其他处理
- Pre-tokenizer:两段式 Sequence——先用 GPT-2 风格正则 Split(处理缩写、单词、数字、标点、空白),再接 ByteLevel 转换(将非 ASCII 字符映射为字节表示,如空格→
Ġ) - Post-processor / Decoder:均为 ByteLevel,负责字节级偏移和前缀空格处理
- Model:标准 BPE,无
unk_token(词表覆盖所有字节),continuing_subword_prefix和end_of_word_suffix为空字符串,byte_fallback=false(不需要回退到字节,因为词表本身基于字节构建) - Added Tokens 分两类:
special=true的控制 token(如<|im_start|>、<|vision_start|>)和special=false的功能性 token(如<think>/</think>、
5.2.2 预分词正则(PreTokenizer)
{
"type": "Split",
"pattern": {
"Regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+"
},
"behavior": "Isolated",
"invert": false
}
这是 ByteLevel PreTokenizer 的正则表达式,在进入 BPE 合并之前先将文本粗切为片段。拆解各分支:
|
正则分支 |
匹配内容 |
示例 |
|
|
英文缩写(不区分大小写) |
|
|
|
可选前导标点 + 连续字母/组合标记 |
|
|
|
单个数字 |
|
|
|
可选空格 + 连续标点符号 |
|
|
|
换行符(含前导空白) |
|
|
|
行尾空白 |
行末空格 |
|
|
其他连续空白 |
多个空格 |
设计意图:
- 英文缩写(
's,'re)作为独立 token,避免don't被切成don+'+t。 - 数字逐位切分(
123→1,2,3),让模型学会逐位处理数值。 - Unicode 属性(
\p{L}字母、\p{N}数字、\p{M}组合标记)保证多语言兼容。
5.3 代码实现
V5 架构对调用链做了重大简化,消除了 V4 的多个中间方法(_call_one、_get_all_inputs、encode_plus、batch_encode_plus 等),全部逻辑收敛到 __call__ → _encode_plus → Rust encode_batch → BatchEncoding 四个阶段。
tokenizer("hello", return_tensors="pt")
│
│ ┌──── 第一层: __call__ ────────────────────────────────────┐
│ │ [tokenization_utils_base.py:2418] │
│ │ │
│ │ 1. 构建 all_kwargs 字典 (含 return_tensors="pt") │
│ │ 2. 合并 tokenizer_kwargs + **kwargs │
│ │ 3. _get_padding_truncation_strategies() │
│ │ [base.py:2336] │
│ │ ├─ padding=False → DO_NOT_PAD │
│ │ ├─ truncation=None → DO_NOT_TRUNCATE │
│ │ ├─ max_length=None → None │
│ │ └─ pop padding/truncation/max_length 出 all_kwargs │
│ │ 4. text 不为 None → 委托 _encode_plus │
│ └────────────────────────────────────────────────────────────┘
│
│ ┌──── 第二层: _encode_plus ────────────────────────────────┐
│ │ [tokenization_utils_tokenizers.py:925] │
│ │ │
│ │ A. _is_valid_text_input("hello") → True │
│ │ B. is_batched = isinstance("hello", list) → False │
│ │ C. 包装: batch_text_or_text_pairs = ["hello"] │
│ │ D. set_truncation_and_padding(DO_NOT_PAD, DO_NOT_TRUNCATE)│
│ │ [tokenizers.py:850] │
│ │ ├─ self._tokenizer.no_truncation() → Rust FFI │
│ │ └─ self._tokenizer.no_padding() → Rust FFI │
│ │ E. self._tokenizer.encode_special_tokens = ... │
│ │ F. encodings = self._tokenizer.encode_batch(["hello"]) ★│
│ │ [PyO3 FFI → Rust 管道: │
│ │ normalize → pre_tokenize → model → post_process │
│ │ → Encoding (含 ids, attention_mask, type_ids...) │
│ │ ] │
│ │ G. _convert_encoding(encodings, ...) │
│ │ [tokenizers.py:738] │
│ │ ├─ return_token_type_ids=None → 查 model_input_names │
│ │ ├─ return_attention_mask=None → 查 model_input_names │
│ │ ├─ encoding_dict["input_ids"] = [e.ids] │
│ │ ├─ encoding_dict["attention_mask"] = [e.attention_mask]│
│ │ └─ 返回 ({input_ids:[[...]], attention_mask:[[...]]}) │
│ │ H. 重构: sanitized_tokens = {input_ids:[[...]], ...} │
│ │ I. BatchEncoding(sanitized_tokens, ..., tensor_type="pt") │
│ │ → 触发 convert_to_tensors("pt") │
│ │ J. is_batched=False, return_tensors="pt"≠None │
│ │ → 不移除 batch 维度 │
│ └────────────────────────────────────────────────────────────┘
│
│ ┌──── 第三层: BatchEncoding + 张量转换 ─────────────────────┐
│ │ [tokenization_utils_base.py:195] │
│ │ │
│ │ 1. super().__init__(data) → UserDict 初始化 │
│ │ 2. self._encodings = [Encoding, ...] (Rust 对象) │
│ │ 3. convert_to_tensors(tensor_type="pt") │
│ │ [base.py:665] │
│ │ ├─ TensorType("pt") → TensorType.PYTORCH │
│ │ ├─ is_torch_available() → True │
│ │ ├─ 定义 as_tensor(value, dtype): │
│ │ │ list[numpy] → torch.from_numpy() │
│ │ │ 空值 → dtype=torch.int64 │
│ │ │ 通用 → torch.tensor(value, dtype=dtype) │
│ │ │ │
│ │ └─ for key, value in self.items(): │
│ │ ├─ "input_ids": [[1,2,3,...]] │
│ │ │ → torch.tensor([[1,2,3,...]]) │
│ │ │ → Tensor(shape=[1, seq_len], dtype=int64) │
│ │ │ → self["input_ids"] = tensor │
│ │ │ │
│ │ └─ "attention_mask": [[1,1,1,...]] │
│ │ → torch.tensor([[1,1,1,...]]) │
│ │ → Tensor(shape=[1, seq_len], dtype=int64) │
│ │ → self["attention_mask"] = tensor │
│ │ │
│ │ 错误处理: 转换失败 → 提示开启 padding+truncation │
│ └────────────────────────────────────────────────────────────┘
│
= BatchEncoding({
'input_ids': tensor([[ 101, 7592, 102, ...]]), # shape=[1, seq_len]
'attention_mask': tensor([[1, 1, 1, ...]]), # shape=[1, seq_len]
})
5.3.1return_tensors="pt" 参数
这个参数决定了输出的 tensor 后端:
|
值 |
输出类型 |
适用场景 |
|
|
|
PyTorch 训练/推理 |
|
|
|
TensorFlow |
|
|
|
NumPy / ONNX 导出 |
|
|
|
纯 Python,调试用 |
5.3.2 encodeplus——输入验证、Rust 编码、结果格式化
定义位于 tokenization_utils_tokenizers.py 第 925-1077 行。分为 7 个阶段。
encodings = self._tokenizer.encode_batch(
batch_text_or_text_pairs,
add_special_tokens=add_special_tokens,
is_pretokenized=is_split_into_words,
)
关键设计:V5 中 padding 和 truncation 由 Rust 后端在 encode_batch 内部完成,而不是由 Python 的 _pad 方法完成。set_truncation_and_padding 在调用前已通过 enable_padding/enable_truncation 配置了 Rust tokenizer,因此 encode_batch 返回的 Encoding 对象已经包含 padding 后的结果。Rust 管道执行:
"hello" → Normalizer → PreTokenizer → Model (BPE/Unigram)
→ PostProcessor (加 [CLS] [SEP]) → Truncation + Padding → Encoding
返回 List[Encoding](Rust 对象),每个 Encoding 含 .ids、.attention_mask、.type_ids、.offsets 等属性。
5.4 最终输出
inputs = tokenizer("hello word", return_tensors="pt")
# inputs 是 BatchEncoding (UserDict 子类)
# inputs.input_ids.shape → torch.Size([1, 2])
# inputs["input_ids"] → tensor([[14556, 3299]]) shape=[1, 2]
# inputs["attention_mask"] → tensor([[1, 1]]) shape=[1, 2]
# inputs.to("cuda") → 将所有 tensor 移到 GPU
inputs 是 Hugging Face transformers 库中一个极其精巧的设计:BatchEncoding。它既是一个标准的 Python 字典(方便取值),又封装了 Tensor 操作能力(方便设备迁移)。
BatchEncoding
└── UserDict (collections.UserDict)
└── dict-like interface (__getitem__, __setitem__, keys, values...)
UserDict 是 Python 标准库提供的”字典包装器”,允许子类在保持完整字典接口的同时,添加自定义行为。BatchEncoding 在此基础上增加了:
|
额外能力 |
说明 |
|
|
将所有内部 tensor 一次性迁移到指定设备 |
|
|
返回底层纯 dict(去掉 BatchEncoding 包装) |
|
|
支持 |
|
序列化/反序列化 |
支持 |
|
offset mapping |
存储 token 到原始文本的字符级偏移(用于 NER 等任务) |
BatchEncoding 是 “带设备感知能力的结构化字典”:它以字典接口暴露 input_ids(token ID 序列)和 attention_mask(有效位置掩码)等字段,同时通过 .to() 方法将所有内部 tensor 一键迁移到目标设备,使 tokenizer 输出能无缝嵌入 PyTorch 训练/推理流水线。**
5.4.1 input_ids说明
inputs["input_ids"] # tensor([[14556, 3299]]) shape=[1, 2]
|
属性 |
值 |
含义 |
|
数据类型 |
|
Embedding 层的索引必须是整数 |
|
Shape |
|
此处 batch=1, seq_len=2 |
|
内容 |
Token 在词表中的整数 ID |
每个 ID 对应 |
5.4.2attention_mask:有效位置掩码
inputs["attention_mask"] → tensor([[1, 1]]) shape=[1, 2]
为何需要它?当 batch 中多条文本长度不等时,短文本会被 pad 到相同长度:
# batch_size=2, 一条3 token,一条5 token
input_ids = [[101, 7592, 102, 0, 0],
[101, 2023, 2003, 102, 0]]
attention_mask = [[ 1, 1, 1, 0, 0], ← 后两个是 padding
[ 1, 1, 1, 1, 0]] ← 最后一个是 padding
单条无 padding 时(如本例 "hello"):全为 1,等价于不做任何 masking。但字段仍然存在,保证接口一致性。
5.4.3.to("cuda") 的内部机制
inputs.to("cuda")
# BatchEncoding.to() 伪代码
def to(self, device):
new_data = {}
for key, value in self.data.items():
if isinstance(value, torch.Tensor):
new_data[key] = value.to(device) # tensor 迁移
elif isinstance(value, BatchEncoding):
new_data[key] = value.to(device) # 递归处理嵌套
else:
new_data[key] = value # 非 tensor 保持不变
return BatchEncoding(new_data, encoding=self._encodings)
- ✅ 所有
torch.Tensor值自动迁移 - ✅ 非 tensor 值(如字符串元数据)原样保留
- ✅ 返回新的
BatchEncoding(不修改原对象,与tensor.to()行为一致) - ✅ 支持
"cuda","cuda:0","cpu","mps"等所有 PyTorch device 字符串
5.4.4 可能出现的其他字段
|
字段 |
出现条件 |
语义 |
|
|
BERT 类双句任务 |
0=句子A,1=句子B |
|
|
非 RoPE 模型 + 自定义位置 |
显式位置编码索引 |
|
|
|
每个 token 对应原文的 |
|
|
长文本截断/滑动窗口 |
标记哪些 chunk 属于同一条原始样本 |
|
|
多模态 Processor |
图像张量 |
|
|
训练时传入 |
目标 token ID(用于计算 loss) |
5.5 关键设计决策
return_tensors="pt"时保留 batch 维度:batch 维度移除条件是return_tensors is None。当指定"pt"时,即使单条输入也输出[1, seq_len]而非[seq_len]。模型forward方法期望 batched 输入,保留维度避免用户手动unsqueeze(0)。- Padding 由 Rust 后端完成:V5 中
set_truncation_and_padding通过enable_padding/enable_truncation配置 Rust tokenizer,encode_batch返回的 Encoding 已含 padding 结果。Rust 的 padding 比Python 循环快得多,且attention_mask直接由 Rust 生成,无需二次处理。 convert_to_tensors在构造函数中即时调用:确保BatchEncoding对象构造完成时数据已是张量格式,不会出现”半转换”状态。调用者无需额外调用转换方法,BatchEncoding(data, tensor_type="pt")一步到位。all_kwargs字典 +pop设计:V5 使用统一的all_kwargs字典收集所有参数,pop提取 padding/truncation/max_length 为策略枚举值。消除了 V4 中_get_all_inputs等辅助方法的复杂性,return_tensors等参数原封不动通过**all_kwargs传递。ExplicitEnum类型安全:TensorType使用ExplicitEnum(generic.py 第 590 行),V5 仅支持"pt"(PyTorch)、"np"(NumPy)、"mlx"三种,移除了 TensorFlow"tf"和 JAX"jax"支持。传入无效值时抛出包含所有合法选项的错误信息。as_tensor内联函数的零拷贝优化:当值是 numpy 数组列表时,使用torch.from_numpy(np.array(value))避免内存拷贝;空值默认dtype=torch.int64(token id 标准类型);通用情况用torch.tensor(value, dtype=dtype)创建新张量。to(device)只移设备不转类型:张量类型必须在构造时通过return_tensors指定,to方法仅负责将已创建的张量移动到目标设备,职责分离。