未分类

深入理解大模型训练推理过程(3):一个LLM请求的推理过程实现-tokenizer

2026年8月29日 阅读(3)

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

mistral_format=True

用户显式标志

2

tokenizer_type参数

用户显式指定

3

auto_map+ trust_remote_code

Hub 自定义代码

4

MODEL_IDS_TO_TOKENIZERS_BACKEND通配

已知需修正的模型 ID

5

Hub class vs 注册类冲突解决

tokenizer_config + model_type

6

tokenizer_config["tokenizer_class"]

保存的配置文件

7

config.tokenizer_class

Config 对象属性

8

TOKENIZER_MAPPING.get(type(config))

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 子类后,调用其继承自 PreTrainedTokenizerBasefrom_pretrained,位于 tokenization_utils_base.py 第 1489 行。该方法分两个阶段:

2.1 文件解析阶段(from_pretrained,第 1489-1748 行)

  1. 构建文件清单:合并类属性 vocab_files_names(如 {"vocab_file": "vocab.txt", "merges_file": "merges.txt"})和附加文件(tokenizer_config.jsontokenizer.jsonadded_tokens.jsonspecial_tokens_map.jsonchat_template.jinja
  2. 版本化 tokenizer 文件处理:检查 tokenizer_config.json 中是否有 fast_tokenizer_files 字段,选择正确版本的 tokenizer.json
  3. 聊天模板文件发现:扫描本地目录的 chat_templates/ 子目录
  4. 逐文件解析:通过 cached_file 下载/解析到本地路径,存入 resolved_vocab_files
  5. 委托 _from_pretrained 完成实例化

2.2 配置合并与实例化阶段(frompretrained,第 1751-1951 行)

  1. 加载 tokenizer_config.json 到 init_kwargs:读取保存的配置参数,移除 tokenizer_class 标识
  2. 聊天模板处理:优先从独立 .jinja 文件加载,覆盖内联模板
  3. 合并用户 kwargsinit_kwargs.update(kwargs)——用户显式传入的参数优先级最高
  4. V5 特殊 token 重命名additional_special_tokens extra_special_tokens
  5. 合并词表文件路径安全设计——只允许 repo 解析的路径或用户显式 kwargs 中的路径覆盖,防止来自不受信任的 tokenizer_config.json 的路径遍历攻击(CWE-22)
  6. 处理 Added Tokens(四种来源按优先级)
    • added_tokens_decoder(现代格式,直接在 tokenizer_config.json 中)
    • special_tokens_map.json(遗留格式)
    • added_tokens.json(遗留格式)
    • tokenizer.json added_tokens 数组(回退来源)

所有来源统一转换为 AddedToken 对象,通过 added_tokens_map 建立字符串到对象的映射

  1. convert_added_tokens:递归地将 dict 形式转换为 AddedToken 对象
  2. convert_to_native_format:基类中是空操作,但 TokenizersBackend 重写了它
  3. 实例化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 为 Rust tokenizers.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 关键设计决策

  1. 懒加载映射_LazyAutoMapping 通过 importlib.import_module 按需导入,避免数百个模型模块拖慢 import transformers,模块对象缓存避免重复导入开销
  2. V5 统一快速 Tokenizeruse_fast 参数被完全忽略,统一使用基于 Rust tokenizers 库的快速 tokenizer,消除了 V4 slow/fast 两套实现的行为不一致问题
  3. 多层级类型推断:8 条优先级路径确保向后兼容(老模型配置仍可加载)、安全性(MODELS_WITH_INCORRECT_HUB_TOKENIZER_CLASS 防止错误配置)和灵活性(远程代码支持)
  4. 安全设计:路径遍历防护(CWE-22)防止不受信任的 tokenizer_config.json 指向任意文件系统路径;远程代码注册保护防止覆盖原生模型映射;trust_remote_code 要求用户显式确认才执行 Hub 上的自定义代码
  5. Added Tokens 多来源兼容:四种来源(现代 added_tokens_decoder、遗留 special_tokens_map.json、遗留 added_tokens.jsontokenizer.json 内嵌)统一转换为 AddedToken 对象,确保不同版本保存的 tokenizer 都能正确加载
  6. PyO3 选择:类型安全的 Rust-Python 绑定,自动处理 GIL 和引用计数,abi3 确保跨 Python 版本兼容。tokenizers 库使用 PyO3 而非 cffi/ctypes,原因:
    1. PyO3 提供了类型安全的 Rust-Python 绑定,自动处理引用计数和 GIL
    2. 支持 #[pymodule] 宏自动生成模块初始化代码
    3. .so 中的 strings 输出可见,所有子模块(models、normalizers、decoders 等)都在同一个 .so 中,PyO3 的模块系统使其成为可能
    4. 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、早期 transformersBertTokenizer)。

痛点爆发: 当 OpenAI 发布 GPT-2,词表扩大到 50257,预训练数据集达到 40GB 文本时,纯 Python 分词器成了瓶颈。用 Python 正则切分 40GB 文本并跑 BPE 算法,需要耗费数天时间,而且受制于 Python 的 GIL(全局解释器锁),无法真正多线程并行。

破局: 2019 年,Hugging Face 推出了用 Rust 重写的 tokenizers 库。它带来了两个降维打击的优势:

  1. 极致性能:Rust 的原生速度 + 并行化(Rayon库),处理 GPT-2 级别的数据集从数天缩短到几分钟
  2. 绝对一致性:无论你在 Python、Node.js 还是 Rust 中调用,只要使用同一个 tokenizer.json,分词结果字节级完全一致,消除了跨语言部署的隐患。

关键特性:

  1. Rayon 数据并行: 传进来 1000 句话,Rust 端通过 Rayon 库瞬间拉起所有 CPU 核心并行处理,无需像 Python 那样等 GIL 解锁。
  2. 零拷贝与原地操作: 文本在内存中被转换为 UTF-8 字节切片(&[u8]),后续的 PreTokenizer 和 Model 尽量在这个切片上直接操作,避免反复申请和拷贝内存。
  3. 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:文本归一化

作用:将语义等价的文本映射到统一表示,减少词表稀疏性。

类型

功能

示例

NFC / NFD

Unicode 规范化(组合字符统一)

é vs

Lowercase

转小写

Hellohello

BertNormalizer

BERT 专用(小写+去重音+清洗)

Héllohello

Nmt

NMT 模型(空格处理)

多空格→单空格

Strip

去除首尾空白

" hi ""hi"

Sequence

组合多个归一化器

Lowercase + Strip

3.2.2 PreTokenizer:预分词

作用:在进入计算密集的子词模型之前,用快速规则切分文本,大幅缩小后续搜索空间。

常见 PreTokenizer:

类型

规则

适用模型

ByteLevel

按字节边界切分,对应 GPT-2/LLaMA 类

GPT-2, RoBERTa, LLaMA, Qwen

Metaspace

空格用 (U+2581)标记

SentencePiece, T5, Llama2

Whitespace

纯空格切分

早期 Word2Vec

BertPreTokenizer

标点和空格分别切分

BERT

CharDelimiter

自定义字符切分

特殊格式

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 使用)。这两种算法的词表数学结构完全不同:

  1. BPE 的词表是无分数的映射 {"高": 234, "兴": 567, "高兴": 890} -> 在 Python 中表现为 dict
  2. Unigram 的词表是带概率/分数的序列[("高", -0.12), ("兴", -0.45), ("高兴", -2.3)] -> 在 Python 中表现为 list[tuple]

SentencePiece

BPE (Byte-Pair Encoding):自底向上的“频率贪心”

  • 核心思想:从字符级别开始,不断合并出现频率最高的相邻 token 对,直到达到预设的词表大小。
  • 训练过程
    1. 基础词表:所有单字符(或字节)。
    2. 统计语料中所有相邻 token 对的频率。
    3. 找到频率最高的 pair(如 t h),合并为新 token th,加入词表。
    4. 在语料中替换所有 t h th
    5. 重复 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

ChatML

使用 <|im_start|>

/ <|im_end|>

包裹角色与内容

`<

Llama 3

Header ID

使用 `<

start_header_id

Mistral / Mixtral

INST

使用 [INST]

/ [/INST]

,无显式 role 标签

[INST] 内容 [/INST] 回答</s>

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_thinkingreasoning_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 %}

三种子情况:

  1. 用户有 system 消息且有内容 → 推理指令 + 用户内容合并输出
  2. 用户有 system 消息但内容为空且有推理指令 → 只输出推理指令
  3. 用户无 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 总结

  1. 可控推理深度enable_thinking + reasoning_effort):三级推理档位(xhigh/medium/low)通过注入不同的 system 指令影响模型思考深度,enable_thinking=false 时输出空 think 块跳过推理
  2. 推理内容选择性保留preserve_thinking + last_query_index):多轮对话中丢弃最后用户提问之前的推理内容以节省上下文窗口,但保留之后的推理供模型参考
  3. 工具调用 XML 格式
  4. 工具结果包装为 user 角色:tool 消息被合并到 user 角色块中,用 result = [] # 非空 content 时换行,否则直接输出
  1. 多模态支持:图片/视频用特殊 token 占位(<|vision_start|><|image_pad|><|vision_end|>),视觉数据由模型层处理,模板只负责位置标记
  2. namespace 计数器:绕过 Jinja2 作用域限制,使宏内递增的计数值在宏外部可见
  3. 安全沙箱配合:模板使用 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 关键设计决策

  1. add_special_tokens=False(tokenize=True 时):聊天模板已包含所有控制 token(BOS、EOS、角色标记),tokenizer 只负责纯文本分词,避免重复添加。单一职责设计。
  2. LRU 缓存编译模板:Jinja2 模板编译涉及词法/语法分析、AST 构建等昂贵操作,@lru_cache 确保同一模板只编译一次,多次调用 apply_chat_template 性能显著提升。
  3. ImmutableSandboxedEnvironment 安全沙箱:加载来自 HF Hub 的第三方模型模板时,模板是不受信任的内容,沙箱环境防止恶意模板执行任意 Python 代码。
  4. 自定义 tojson 过滤器:Jinja2 默认的 tojson 会转义 HTML 字符(< \u003c),导致 tools JSON Schema 内容损坏。覆盖为标准 json.dumps 保持原样。
  5. special_tokens_map 作为模板变量:将 bos_tokeneos_token 等特殊 token 的字符串值注入模板上下文,模板可直接引用 {{ bos_token }} 而非硬编码 token 字符串。
  6. 多模板自动选择:当 chat_template 是 dict 且用户未指定模板名时,系统根据 tools 参数智能路由——有 tools 选 "tool_use" 模板,无 tools 选 "default" 模板,用户无需手动切换。
  7. .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 重要字段说明

字段

含义

tokenizer_class

"Qwen2Tokenizer"

指定加载哪个 Python 类来处理分词。虽然模型是 Qwen3-VL,但分词算法与 Qwen2 完全一致(BPE + ByteLevel),无需新写类

backend

"tokenizers"

强制使用 Rust 后端(HuggingFace tokenizers

库),而非 Python 慢速实现。保证微秒级 encode/decode

processor_class

"Qwen3VLProcessor"

多模态专用。告诉 AutoProcessor

加载哪个处理器类,该类同时管理 tokenizer + image_processor + audio_processor

is_local

true

标记模型文件在本地磁盘,不从 Hub 下载

local_files_only

false

允许在本地文件缺失时回退到 Hub 下载(与 is_local互补)

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 的完整配置和状态。只要有了这个文件:

  1. 脱离 Python:你可以用 Node.js、Rust 甚至 C++ 直接加载它,分词行为与 Python 端 100% 一致
  2. 零依赖 Python 代码:像 vLLM 这样的高性能推理引擎,只要拿到 tokenizer.json,就可以完全绕过 Hugging Face 的 Python transformers 库,直接在 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 合并之前先将文本粗切为片段。拆解各分支:

正则分支

匹配内容

示例

(?i:'s|'t|'re|'ve|'m|'ll|'d)

英文缩写(不区分大小写)

don'tdon + 't

[^\r\n\p{L}\p{N}]?[\p{L}\p{M}]+

可选前导标点 + 连续字母/组合标记

hello, café

\p{N}

单个数字

1, 2, 3

?[^\s\p{L}\p{M}\p{N}]+[\r\n]*

可选空格 + 连续标点符号

..., !!!

\s*[\r\n]+

换行符(含前导空白)

\n, \r\n

\s+(?!\S)

行尾空白

行末空格

\s+

其他连续空白

多个空格

设计意图

  • 英文缩写('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_inputsencode_plusbatch_encode_plus 等),全部逻辑收敛到 __call___encode_plus → Rust encode_batchBatchEncoding 四个阶段。

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 后端:

输出类型

适用场景

"pt"

torch.Tensor

PyTorch 训练/推理

"tf"

tf.Tensor

TensorFlow

"np"

numpy.ndarray

NumPy / ONNX 导出

None/ 不传

List[int]

纯 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 在此基础上增加了:

额外能力

说明

.to(device)

将所有内部 tensor 一次性迁移到指定设备

.data

返回底层纯 dict(去掉 BatchEncoding 包装)

.__getattr__()

支持 inputs.input_ids点号访问(等价于 inputs["input_ids"]

序列化/反序列化

支持 save_pretrained() / from_pretrained()持久化

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]

属性

含义

数据类型

torch.int64(LongTensor)

Embedding 层的索引必须是整数

Shape

[batch_size, seq_len]

此处 batch=1, seq_len=2

内容

Token 在词表中的整数 ID

每个 ID 对应 vocab.json中的一行

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 可能出现的其他字段

字段

出现条件

语义

token_type_ids

BERT 类双句任务

0=句子A,1=句子B

position_ids

非 RoPE 模型 + 自定义位置

显式位置编码索引

offset_mapping

return_offsets_mapping=True

每个 token 对应原文的 (start_char, end_char)

overflow_to_sample_mapping

长文本截断/滑动窗口

标记哪些 chunk 属于同一条原始样本

pixel_values

多模态 Processor

图像张量

labels

训练时传入

目标 token ID(用于计算 loss)

5.5 关键设计决策

  1. return_tensors="pt" 时保留 batch 维度:batch 维度移除条件是 return_tensors is None。当指定 "pt" 时,即使单条输入也输出 [1, seq_len] 而非 [seq_len]。模型 forward 方法期望 batched 输入,保留维度避免用户手动 unsqueeze(0)
  2. Padding 由 Rust 后端完成:V5 中 set_truncation_and_padding 通过 enable_padding/enable_truncation 配置 Rust tokenizer,encode_batch 返回的 Encoding 已含 padding 结果。Rust 的 padding 比Python 循环快得多,且 attention_mask 直接由 Rust 生成,无需二次处理。
  3. convert_to_tensors 在构造函数中即时调用:确保 BatchEncoding 对象构造完成时数据已是张量格式,不会出现”半转换”状态。调用者无需额外调用转换方法,BatchEncoding(data, tensor_type="pt") 一步到位。
  4. all_kwargs 字典 + pop 设计:V5 使用统一的 all_kwargs 字典收集所有参数,pop 提取 padding/truncation/max_length 为策略枚举值。消除了 V4 中 _get_all_inputs 等辅助方法的复杂性,return_tensors 等参数原封不动通过 **all_kwargs 传递。
  5. ExplicitEnum 类型安全TensorType 使用 ExplicitEnumgeneric.py 第 590 行),V5 仅支持 "pt"(PyTorch)、"np"(NumPy)、"mlx" 三种,移除了 TensorFlow "tf" 和 JAX "jax" 支持。传入无效值时抛出包含所有合法选项的错误信息。
  6. as_tensor 内联函数的零拷贝优化:当值是 numpy 数组列表时,使用 torch.from_numpy(np.array(value)) 避免内存拷贝;空值默认 dtype=torch.int64(token id 标准类型);通用情况用 torch.tensor(value, dtype=dtype) 创建新张量。
  7. to(device) 只移设备不转类型:张量类型必须在构造时通过 return_tensors 指定,to 方法仅负责将已创建的张量移动到目标设备,职责分离。

You Might Also Like