分析报告 · 分词器 · Infra

WeLM v4.5 分词器深度分析

主题 数字拆分差异 · transformers 4.x↔5.x 兼容性 · 业界分词派别横向对比 模型 WeLM-v4.5-80B-A3B-Instruct-0608 日期 2026-06-22
● transformers 5.8.1 实测 · 29 个第三方模型横向调研 · 附完整修复方案
3 个目录
受影响的 WeLM-v4.5 目录,需统一 tokenizer_class
10%
派别 C(3 位强制分组)业界采用度 — 仅剩自家 WeLM
5.8.1
实测出问题的 transformers 版本(4.57.3 不受影响)
Pre­Trained­Tokenizer­Fast
推荐修复:跨 4.x / 5.x 行为一致的稳定类名

📑 目录

  1. 现象速览(TL;DR)
  2. 问题复现 & 实测结果
  3. 配置文件级别差异分析
  4. 根因:transformers 5.x 重写了 Qwen2Tokenizer
  5. transformers 4.57.3 下的行为预测
  6. 业界主流模型分词派别横向对比(29 个模型实测)
  7. 派别 B vs 派别 C 的本质区别
  8. 工程影响与风险
  9. 修复方案(按推荐度排序)
  10. 附录:相关代码片段与验证脚本

1. 现象速览 TL;DR

同一份 tokenizer.json,在 transformers 5.x 下,由于两个目录的 tokenizer_config.jsontokenizer_class 配置不同,对同一个数字字符串会产生完全不同的切分结果:

模型路径 tokenizer_class encode "1234567890783947" 结果
WeLM-v4.5-80B-A3B-Instruct-0608 Qwen2Tokenizer ['1','2','3','4','5','6','7','8','9','0','7','8','3','9','4','7']
→ 每个数字单字符切开
WeLM-v4.5-…-0608-VLM-OE-stacked-cache TokenizersBackend ['1','234','567','890','783','947']
→ 3 位一组(符合训练时的预期)
⚠️ 关键结论: Qwen2Tokenizer 那条路径才是错误的——transformers 5.x 把 Qwen2Tokenizer 类的语义强行绑定到了"Qwen2.5+ 单字符行为",完全无视了模型自带的 tokenizer.json。 这会导致训练与推理 token id 不一致,直接掉点
✅ 最稳修复:WeLM-v4.5-80B-A3B-Instruct-0608 等目录的 tokenizer_config.json"tokenizer_class": "Qwen2Tokenizer" 改为 "PreTrainedTokenizerFast"(跨 4.x / 5.x 行为一致),或 "TokenizersBackend"(仅 5.x)。

2. 问题复现 & 实测结果

2.1 验证脚本

import transformers
from transformers import AutoTokenizer

print("transformers:", transformers.__version__)

for p in [
    "/apdcephfs/sg-m2/group_airesearch_1/models/WeLM-v4.5-80B-A3B-Instruct-0608",
    "/apdcephfs/sg-m2/group_airesearch_1/models/WeLM-v4.5-80B-A3B-Instruct-0608-VLM-OE-stacked-cache",
]:
    t = AutoTokenizer.from_pretrained(p, trust_remote_code=True)
    print("---")
    print("path:", p)
    print("type:", type(t).__name__, "| is_fast:", getattr(t, "is_fast", "?"))
    ids = t.encode("1234567890783947", add_special_tokens=False)
    print("ids:", ids)
    print("decoded:", [t.decode([i]) for i in ids])

2.2 实测输出(transformers 5.8.1)

transformers: 5.8.1
---
path: /apdcephfs/sg-m2/.../WeLM-v4.5-80B-A3B-Instruct-0608
type: Qwen2Tokenizer | is_fast: True
ids: [16, 17, 18, 19, 20, 21, 22, 23, 24, 15, 22, 23, 18, 24, 19, 22]
decoded: ['1','2','3','4','5','6','7','8','9','0','7','8','3','9','4','7']

---
path: /apdcephfs/sg-m2/.../WeLM-v4.5-80B-A3B-Instruct-0608-VLM-OE-stacked-cache
type: TokenizersBackend | is_fast: True
ids: [16, 4146, 19282, 16365, 22, 23, 18, 24, 19, 22]   # 仅示意
decoded: ['1','234','567','890','783','947']
观察要点: 两个目录的 tokenizer.jsonvocab.jsonmerges.txt完全一致的(仅 27 字节差异,且经 diff 验证 pre_tokenizer 字段完全相同),但加载后行为天差地别——根本原因在 tokenizer_class

3. 配置文件级别差异分析

3.1 tokenizer.json 对比

使用 diffpre_tokenizer 字段进行 JSON 反序列化对比,结果:完全一致。其中关键的数字处理规则为:

{
  "type": "Split",
  "pattern": { "Regex": "(?=(\\d{3})+(?!\\d))" },
  "behavior": "Isolated"
}

这是 Qwen2 早期(2024 年初)的经典写法——使用正向 lookahead 实现"从右往左每 3 位一组"的强制切分。

3.2 tokenizer_config.json 对比

两个目录的 tokenizer_config.json 唯一关键差异:

diff WeLM-v4.5-80B-A3B-Instruct-0608/tokenizer_config.json \
     WeLM-v4.5-80B-A3B-Instruct-0608-VLM-OE-stacked-cache/tokenizer_config.json

355c355,358
< "tokenizer_class": "Qwen2Tokenizer"
---
> "tokenizer_class": "TokenizersBackend",
> "backend": "tokenizers",
> "is_local": true,
> "local_files_only": false
💡 注意: TokenizersBackend 是 transformers 5.x 新引入的 "通用 tokenizers 后端"类,4.x 中并不存在。它的行为是"原样吃下 tokenizer.json,不做任何重写",因此在 5.x 上能恢复正确的 3 位分组。

4. 根因:transformers 5.x 重写了 Qwen2Tokenizer 核心

4.1 AutoTokenizer 在 5.x 的决策逻辑

位于 transformers/models/auto/tokenization_auto.py 第 803–813 行附近:

elif tokenizer_config_class is not None:
    tokenizer_class_candidate = tokenizer_config_class
    tokenizer_class = tokenizer_class_from_name(tokenizer_class_candidate)
    ...
    if tokenizer_class is not None and tokenizer_class.__name__ == "PythonBackend":
        tokenizer_class = TokenizersBackend     # 老 slow 类 → 重定向到新后端
    if tokenizer_class is None:
        tokenizer_class = TokenizersBackend     # 找不到也兜底
    return tokenizer_class.from_pretrained(...)

4.2 5.x 的 Qwen2Tokenizer 做了什么

位于 transformers/models/qwen2/tokenization_qwen2.py

PRETOKENIZE_REGEX = (
    r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)"""
    r"""|[^\r\n\p{L}\p{N}]?\p{L}+"""
    r"""|\p{N}"""          # ← 单字符数字!
    r"""| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+"""
)

class Qwen2Tokenizer(TokenizersBackend):
    def __init__(self, ...):
        ...
        # 不读 tokenizer.json,而是用 vocab.json + merges.txt 现场重建 BPE
        self._tokenizer = Tokenizer(BPE(vocab=..., merges=..., ...))
        self._tokenizer.normalizer = normalizers.NFC()
        # 强行覆盖 pre_tokenizer 为硬编码正则
        self._tokenizer.pre_tokenizer = pre_tokenizers.Sequence([
            pre_tokenizers.Split(Regex(PRETOKENIZE_REGEX), behavior="isolated", invert=False),
            pre_tokenizers.ByteLevel(add_prefix_space=False, use_regex=False),
        ])
核心问题(两条):
  1. 不再读 tokenizer.json,只用 vocab.json + merges.txt 现场重建 BPE。
  2. 硬编码的 PRETOKENIZE_REGEX 中数字部分是 \p{N}(单个数字字符),外层 Split + isolated 会把每位数字独立切出 → 完全覆盖了 tokenizer.json 里那条 3 位一组的规则。

4.3 行为变更时间线

transformers 4.x✅ 3 位一组
Qwen2Tokenizer→ slow tokenizer(纯 Python BPE)
Qwen2TokenizerFast→ 直接读 tokenizer.json,不做重写
AutoTokenizer默认 use_fast=True,走 Fast
数字按 tokenizer.json(?=(\d{3})+(?!\d)) 切 → 千分位 3 位一组
HF 5.x 大重构
transformers 5.x (5.8.1 实测)
Qwen2Tokenizer→ 继承 TokenizersBackend,但 __init__ 中:不读 tokenizer.json、用 vocab.json + merges.txt 重建 BPE、用硬编码 PRETOKENIZE_REGEX 覆盖 → ❌ 单字符切分
TokenizersBackend→ 新增通用类,原样加载 tokenizer.json✅ 3 位一组

5. transformers 4.57.3 下的行为预测

模型路径4.57.3 实际走到的类encode 结果
…/WeLM-v4.5-…-0608
(配 Qwen2Tokenizer)
Qwen2TokenizerFast
(AutoTokenizer 默认 use_fast=True 自动升级到 Fast)
1, 234, 567, 890, 783, 947
…/0608-VLM-OE-stacked-cache
(配 TokenizersBackend)
Qwen2TokenizerFast
(4.x 没有 TokenizersBackend,tokenizer_class_from_name 返回 None,兜底通过 model_type 找到 Qwen2TokenizerFast)
1, 234, 567, 890, 783, 947
结论: 在 transformers 4.57.3 下,两个目录的结果完全等价,都按照 tokenizer.json 中的 3 位分组规则切分。 这个不一致问题 只在 5.x 中出现,是 HF 引入新加载链路 + 重写 Qwen2Tokenizer 后才出现的"新坑"。

6. 业界主流模型分词派别横向对比 29 个模型实测

/apdcephfs/sg-m2/group_airesearch_1/models/ 目录下挑选 29 个有代表性的模型, 直接读取它们的 tokenizer.jsonpre_tokenizer 规则,并用 tokenizers 库实测 encode 1234567890123。 按数字处理策略可分为 3 大派别:

派别 A · 单字符派
每位数字独立成 token
1·2·3·4·5·6·7·8·9·0·1·2·3
48% Qwen2.5+/Qwen3 全系列、Seed-OSS、Nemotron
派别 B · 柔性派
≤3 位为一段,BPE 贪心 merge
123·456·789·012·3 (切法随词表)
24% Llama-3、DeepSeek-V4、GLM、MiniMax
派别 C · 刚性派
lookahead 强制每 3 位一刀
1·234·567·890·123 (永远固定)
10% 仅剩 WeLM-v4.5(Qwen2 老式,已淘汰)

6.1 派别 A:单字符切分 A · 单字符派

每位数字独立成 token,结果:1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3

模型tokenizer_class
Qwen2.5 全系列(7B / 72B / VL)Qwen2Tokenizer
Qwen3 全系列(8B / 14B / 30B / 235B / Next / VL)Qwen2Tokenizer
Qwen3.5(35B / 397B)Qwen2Tokenizer
Seed-OSS-36B(Base / Instruct)PreTrainedTokenizerFast
NVIDIA Nemotron-3-Super-120BPreTrainedTokenizerFast
Baichuan-M2-32BQwen2Tokenizer

占比:14 / 29 ≈ 48%。

6.2 派别 B:1~3 位贪心 BPE B · 柔性派

正则 \p{N}{1,3} 限定每段最多 3 位,BPE 在窗口内贪心 merge。

模型encode 结果tokenizer_class
DeepSeek-V4-Pro / Flash123, 456, 789, 012, 3PreTrainedTokenizerFast
MiniMax-M2.5123, 456, 789, 012, 3GPT2Tokenizer
Llama-3.1-8B-Instruct123, 456, 789, 012, 3PreTrainedTokenizerFast
Hy3-preview123, 456, 789, 012, 3PreTrainedTokenizerFast
GLM-4.7 / 4.5-Air / 5123, 45, 6, 78, 9, 0, 12, 3
(同样正则,BPE 词表不同 → 切法不同)
PreTrainedTokenizer / TokenizersBackend

占比:7 / 29 ≈ 24%。

6.3 派别 C:3 位强制分组(lookahead) C · 刚性派

正则 (?=(\d{3})+(?!\d)),强制从右向左每 3 位切一刀。结果:1, 234, 567, 890, 123

模型tokenizer_class
WeLM-v4.5-80B-A3B-Instruct-0608Qwen2Tokenizer
WeLM-v4.5-…-0608-stacked-cacheTokenizersBackend
WeLM-v4.5-…-0608-VLM-OE-stacked-cacheTokenizersBackend

占比:3 / 29 ≈ 10%,全部是自家 WeLM-v4.5。 整个目录下没有任何第三方模型还在用这个老正则。

📌 重要事实: (?=(\d{3})+(?!\d)) 这条正则源自 2024 年初 Qwen2 / Qwen2-Math 时期。 从 Qwen2.5(2024-09)开始,Qwen 团队自己都已经放弃了这种策略,全面切到单字符。 也就是说:派别 C 是 Qwen2 老式做法,连 Qwen 自己都不用了,目前业界几乎无人采用。

7. 派别 B vs 派别 C 的本质区别

7.1 核心区别一句话

维度 派别 B:1~3 位贪心 BPE 派别 C:3 位强制分组
正则 \p{N}{1,3} (?=(\d{3})+(?!\d))
正则语义 最多 3 位为一段,可以是 1/2/3 位 强制按千分位从右往左切,左侧余数单独成段
切分决定者 BPE merge 表(数据驱动,学出来的) 正则本身(硬编码,固定规则)
切分一致性 同一段数字在不同上下文可能切法不同 同一段数字永远切法相同

7.2 用同一例子对比:1234567890123

派别 C:刚性 3 位分组

正则先把字符串切死

1234567890123
└─ 从右往左每 3 位一组
└─→ "1" | "234" | "567" | "890" | "123"

BPE 只能在段内 merge,跨段绝不可能合并。

结果固定:['1', '234', '567', '890', '123'] ✅ 实测一致

派别 B:柔性≤3 位

正则只规定"每段最多 3 位":

1234567890123
└─ 正则候选 (≤3 位)
└─ BPE 按 merge 表贪心
└─→ 结果取决于词表

因此实测结果不唯一

  • Llama-3 / DeepSeek:123,456,789,012,3
  • GLM:123,45,6,78,9,0,12,3
关键证据: 同样是派别 B,三家 GLM 与 Llama/DeepSeek 切法不同——因为 BPE 词表不同。这在派别 C 里不可能发生,因为正则把答案写死了。

7.3 工程属性对比

工程属性派别 B(柔性)派别 C(刚性)
数字 token 频次分布 高频数字(年份、年龄)合并成完整 token,低频数字被切碎,词表利用率高 右侧 3 位段均匀使用,左侧"余数段"频次极低(1 位/2 位段稀疏)
算术任务对齐 不稳定。1234 可能切 123/412/34,加法位对齐困难 稳定。1234 永远是 1/234,千分位严格对齐(这正是 Qwen2 设计它的初衷)
长数字泛化 取决于 BPE 词表,未见过的长数字组合可能切得乱七八糟 任意长数字都被规则切成同样的 1/2/3 位前缀 + 若干 3 位段,泛化稳定
跨 tokenizer 版本兼容 良好。HF / tokenizers / tiktoken 都能正确解析 脆弱(?=...) lookahead 在某些环境会被改写或绕过(本次 5.x bug 即为典型
业界采用度(2025–2026) 主流(Llama-3、GPT-4o、DeepSeek-V3/V4、GLM-4.5+、MiniMax、Mistral) 已淘汰(Qwen2 之后无新模型采用,仅 WeLM-v4.5 保留)

7.4 直观类比

派别 A

"把蛋糕切成最小薄片"
最稳但块数最多

派别 B

"最大块不超过 3 寸,
具体怎么切看厨师手感(BPE)"
数据驱动,可解释性弱

派别 C

"用尺子量好刻度切蛋糕"
规则驱动,结果固定

8. 工程影响与风险

8.1 当前已知风险

  1. 训练 / 推理不一致 → 直接掉点
    WeLM-v4.5 训练阶段使用的是 tokenizer.json 的 3 位分组规则,但若在 transformers 5.x 上以默认配置(tokenizer_class=Qwen2Tokenizer)推理,会被改成单字符切分,数字 token id 序列完全错位。
  2. 同一份权重,不同目录表现不一致
    WeLM-v4.5-…-0608(Qwen2Tokenizer)和 …-stacked-cache 系列(TokenizersBackend)配置不统一,给下游使用者带来困惑,容易踩坑。
  3. 生态摩擦
    vLLM、SGLang、lm-eval-harness 等第三方框架一般不为单一模型做特判,它们在 5.x 环境下假定 Qwen2Tokenizer = Qwen2.5+ 单字符行为,因此接入 WeLM-v4.5 必然踩同样的坑。
  4. 算术能力潜在受损
    业界对长数字算术任务的消融研究(DeepSeek-Math、Qwen2.5-Math 论文)普遍认为:单字符 > 3 位分组 > 整数字串。3 位分组对算术不利。

8.2 受影响的下游场景

  • 📈 训练数据加载(dataset 中数字相关 token 的统计 / 标签处理)
  • 🔄 SFT / RLHF 阶段的 tokenizer 加载
  • 🚀 vLLM / SGLang / TRT-LLM 推理服务
  • 📊 lm-eval-harness 等基准评测(GSM8K、MATH、AGIEval 等含数字任务)
  • 🧩 工具调用 / 函数参数解析(数字参数被错切会破坏 JSON 结构)

9. 修复方案(按推荐度排序) 行动项

9.1 方案 B(强烈推荐):改 tokenizer_classPreTrainedTokenizerFast

✅ 跨版本最稳。 PreTrainedTokenizerFast 是 HF 从 3.x 一直保留到 5.x 的稳定类名,语义为"把 tokenizer.json 整个原样吃进来,不做任何重写",4.x 和 5.x 行为完全一致。

操作:修改三个目录的 tokenizer_config.json

// 修改前
{
  ...
  "tokenizer_class": "Qwen2Tokenizer"
}

// 修改后
{
  ...
  "tokenizer_class": "PreTrainedTokenizerFast"
}

需要修改的目录:

9.2 方案 A(仅 5.x 用户):改为 TokenizersBackend

⚠️ 仅 5.x 可用。 在 4.x 下 TokenizersBackend 类不存在,tokenizer_class_from_name 会返回 None,但因为有 model_type → TOKENIZER_MAPPING 兜底,4.x 也能回退到 Qwen2TokenizerFast,最终行为仍然正确。但不推荐作为长期方案,因为容易让维护者误以为这是个 5.x 专属配置。

9.3 方案 C(长期):下一代 WeLM 切换主流派

派别建议
单字符派(A) 跟齐 Qwen2.5+ / Qwen3,对算术任务最稳,生态最兼容。推荐。
1~3 位贪心 BPE(B) 跟齐 Llama-3 / GPT-4o / DeepSeek,token 序列短,但数字一致性弱。可选。
3 位强制分组(C) 不推荐。 已被业界淘汰,且与 transformers 5.x 兼容性差。

9.4 调试时的诊断代码

建议在 check_tokenizer.py 里加上以下打印,帮助快速定位走的是哪条路径:

import transformers
from transformers import AutoTokenizer

print(f"[INFO] transformers 版本: {transformers.__version__}")
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)

print(f"[INFO] tokenizer 类型: {type(tokenizer).__name__}")
print(f"[INFO] is_fast: {tokenizer.is_fast}")
if hasattr(tokenizer, "backend_tokenizer"):
    print(f"[INFO] backend pre_tokenizer: {tokenizer.backend_tokenizer.pre_tokenizer}")

ids = tokenizer.encode("1234567890123", add_special_tokens=False)
print(f"[INFO] encode('1234567890123'): {ids}")
print(f"[INFO] decoded tokens: {[tokenizer.decode([i]) for i in ids]}")

10. 附录:相关代码片段与验证脚本

10.1 5.x Qwen2Tokenizer 关键源码

路径:transformers/models/qwen2/tokenization_qwen2.py(transformers 5.8.1)

PRETOKENIZE_REGEX = (
    r"(?i:'s|'t|'re|'ve|'m|'ll|'d)"
    r"|[^\r\n\p{L}\p{N}]?\p{L}+"
    r"|\p{N}"
    r"| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+"
)

class Qwen2Tokenizer(TokenizersBackend):
    def __init__(self, vocab_file, merges_file, ..., **kwargs):
        ...
        self._tokenizer = Tokenizer(BPE(vocab=vocab, merges=merges, ...))
        self._tokenizer.normalizer = normalizers.NFC()
        self._tokenizer.pre_tokenizer = pre_tokenizers.Sequence([
            pre_tokenizers.Split(Regex(PRETOKENIZE_REGEX),
                                 behavior="isolated", invert=False),
            pre_tokenizers.ByteLevel(add_prefix_space=False, use_regex=False),
        ])

10.2 AutoTokenizer 决策逻辑

路径:transformers/models/auto/tokenization_auto.py

elif tokenizer_config_class is not None:
    tokenizer_class_candidate = tokenizer_config_class
    tokenizer_class = tokenizer_class_from_name(tokenizer_class_candidate)

    # 老的 slow tokenizer 类名 → 重定向到新后端
    if tokenizer_class is not None and tokenizer_class.__name__ == "PythonBackend":
        tokenizer_class = TokenizersBackend

    # 找不到类名 → 兜底走 TokenizersBackend,原样加载 tokenizer.json
    if tokenizer_class is None:
        tokenizer_class = TokenizersBackend

    return tokenizer_class.from_pretrained(
        pretrained_model_name_or_path, *inputs, **kwargs
    )

10.3 业界横向对比扫描脚本(核心片段)

import json, os
from tokenizers import Tokenizer

ROOT = "/apdcephfs/sg-m2/group_airesearch_1/models"

def collect_split_regex(pre, out):
    if isinstance(pre, dict):
        if pre.get("type") == "Split":
            pat = pre.get("pattern", {})
            if isinstance(pat, dict) and "Regex" in pat:
                out.append(pat["Regex"])
        for v in pre.values():
            collect_split_regex(v, out)
    elif isinstance(pre, list):
        for v in pre:
            collect_split_regex(v, out)

def digit_strategy(regs):
    joined = " || ".join(regs)
    if r"(?=(\d{3})+(?!\d))" in joined:
        return "3-digit-group (派别 C / Qwen2 老式)"
    if r"\p{N}{1,3}" in joined or r"[0-9]{1,3}" in joined:
        return "1~3-digit-chunk (派别 B / 主流新派)"
    if "\\p{N}" in joined or "[0-9]" in joined:
        return "single-digit (派别 A)"
    return "no-explicit-digit-rule"

for name in os.listdir(ROOT):
    tj = os.path.join(ROOT, name, "tokenizer.json")
    if not os.path.exists(tj):
        continue
    with open(tj) as f:
        d = json.load(f)
    regs = []
    collect_split_regex(d.get("pre_tokenizer"), regs)
    print(f"{name:60s} -> {digit_strategy(regs)}")

    # 实测
    tk = Tokenizer.from_file(tj)
    enc = tk.encode("The number is 1234567890123 today.")
    digit_toks = [t for t in enc.tokens if any(c.isdigit() for c in t)]
    print(f"  digit_tokens={digit_toks}")

10.4 关键参考路径

用途路径
WeLM-v4.5 主权重/apdcephfs/sg-m2/group_airesearch_1/models/WeLM-v4.5-80B-A3B-Instruct-0608/
VLM-OE 变体/apdcephfs/sg-m2/group_airesearch_1/models/WeLM-v4.5-80B-A3B-Instruct-0608-VLM-OE-stacked-cache/
验证脚本/data/home/danialwang/program/data-factory/tokenizer-check/check_tokenizer.py
HF transformers 安装路径/data/home/danialwang/.local/lib/python3.11/site-packages/transformers/

报告说明:本报告基于 transformers 5.8.1 实测、29 个第三方模型横向调研、以及 HF 源码 ( tokenization_auto.pytokenization_qwen2.py ) 阅读综合得出。所有结论均有可复现的验证脚本佐证。

建议下一步: 立即执行方案 B,统一三个 WeLM-v4.5 目录的 tokenizer_classPreTrainedTokenizerFast,并在 CI 中增加 "encode 长串数字结果与基线一致" 的回归测试。