同一份 tokenizer.json,在 transformers 5.x 下,由于两个目录的 tokenizer_config.json 中 tokenizer_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)。
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])
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.json、vocab.json、merges.txt 是完全一致的(仅 27 字节差异,且经 diff 验证 pre_tokenizer 字段完全相同),但加载后行为天差地别——根本原因在 tokenizer_class。
tokenizer.json 对比使用 diff 对 pre_tokenizer 字段进行 JSON 反序列化对比,结果:完全一致。其中关键的数字处理规则为:
{
"type": "Split",
"pattern": { "Regex": "(?=(\\d{3})+(?!\\d))" },
"behavior": "Isolated"
}
这是 Qwen2 早期(2024 年初)的经典写法——使用正向 lookahead 实现"从右往左每 3 位一组"的强制切分。
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 位分组。
位于 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(...)
"Qwen2Tokenizer" → 能在新版本中查到一个同名类 → 走被重写过的 Qwen2Tokenizer(继承自 TokenizersBackend)。"TokenizersBackend" → 命中通用兜底类 → 忠实加载 tokenizer.json。位于 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),
])
tokenizer.json,只用 vocab.json + merges.txt 现场重建 BPE。PRETOKENIZE_REGEX 中数字部分是 \p{N}(单个数字字符),外层 Split + isolated 会把每位数字独立切出 → 完全覆盖了 tokenizer.json 里那条 3 位一组的规则。Qwen2Tokenizer→ slow tokenizer(纯 Python BPE)Qwen2TokenizerFast→ 直接读 tokenizer.json,不做重写use_fast=True,走 Fasttokenizer.json 里 (?=(\d{3})+(?!\d)) 切 → 千分位 3 位一组Qwen2Tokenizer→ 继承 TokenizersBackend,但 __init__ 中:不读 tokenizer.json、用 vocab.json + merges.txt 重建 BPE、用硬编码 PRETOKENIZE_REGEX 覆盖 → ❌ 单字符切分TokenizersBackend→ 新增通用类,原样加载 tokenizer.json → ✅ 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 |
tokenizer.json 中的 3 位分组规则切分。
这个不一致问题 只在 5.x 中出现,是 HF 引入新加载链路 + 重写 Qwen2Tokenizer 后才出现的"新坑"。
在 /apdcephfs/sg-m2/group_airesearch_1/models/ 目录下挑选 29 个有代表性的模型,
直接读取它们的 tokenizer.json 中 pre_tokenizer 规则,并用 tokenizers 库实测 encode 1234567890123。
按数字处理策略可分为 3 大派别:
每位数字独立成 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-120B | PreTrainedTokenizerFast |
| Baichuan-M2-32B | Qwen2Tokenizer |
占比:14 / 29 ≈ 48%。
正则 \p{N}{1,3} 限定每段最多 3 位,BPE 在窗口内贪心 merge。
| 模型 | encode 结果 | tokenizer_class |
|---|---|---|
| DeepSeek-V4-Pro / Flash | 123, 456, 789, 012, 3 | PreTrainedTokenizerFast |
| MiniMax-M2.5 | 123, 456, 789, 012, 3 | GPT2Tokenizer |
| Llama-3.1-8B-Instruct | 123, 456, 789, 012, 3 | PreTrainedTokenizerFast |
| Hy3-preview | 123, 456, 789, 012, 3 | PreTrainedTokenizerFast |
| GLM-4.7 / 4.5-Air / 5 | 123, 45, 6, 78, 9, 0, 12, 3(同样正则,BPE 词表不同 → 切法不同) | PreTrainedTokenizer / TokenizersBackend |
占比:7 / 29 ≈ 24%。
正则 (?=(\d{3})+(?!\d)),强制从右向左每 3 位切一刀。结果:1, 234, 567, 890, 123
| 模型 | tokenizer_class |
|---|---|
| WeLM-v4.5-80B-A3B-Instruct-0608 | Qwen2Tokenizer |
| WeLM-v4.5-…-0608-stacked-cache | TokenizersBackend |
| WeLM-v4.5-…-0608-VLM-OE-stacked-cache | TokenizersBackend |
占比:3 / 29 ≈ 10%,全部是自家 WeLM-v4.5。 整个目录下没有任何第三方模型还在用这个老正则。
(?=(\d{3})+(?!\d)) 这条正则源自 2024 年初 Qwen2 / Qwen2-Math 时期。
从 Qwen2.5(2024-09)开始,Qwen 团队自己都已经放弃了这种策略,全面切到单字符。
也就是说:派别 C 是 Qwen2 老式做法,连 Qwen 自己都不用了,目前业界几乎无人采用。
| 维度 | 派别 B:1~3 位贪心 BPE | 派别 C:3 位强制分组 |
|---|---|---|
| 正则 | \p{N}{1,3} |
(?=(\d{3})+(?!\d)) |
| 正则语义 | 最多 3 位为一段,可以是 1/2/3 位 | 强制按千分位从右往左切,左侧余数单独成段 |
| 切分决定者 | BPE merge 表(数据驱动,学出来的) | 正则本身(硬编码,固定规则) |
| 切分一致性 | 同一段数字在不同上下文可能切法不同 | 同一段数字永远切法相同 |
1234567890123正则先把字符串切死:
1234567890123 └─ 从右往左每 3 位一组 └─→ "1" | "234" | "567" | "890" | "123"
BPE 只能在段内 merge,跨段绝不可能合并。
结果固定:['1', '234', '567', '890', '123'] ✅ 实测一致
正则只规定"每段最多 3 位":
1234567890123 └─ 正则候选 (≤3 位) └─ BPE 按 merge 表贪心 └─→ 结果取决于词表
因此实测结果不唯一:
123,456,789,012,3123,45,6,78,9,0,12,3| 工程属性 | 派别 B(柔性) | 派别 C(刚性) |
|---|---|---|
| 数字 token 频次分布 | 高频数字(年份、年龄)合并成完整 token,低频数字被切碎,词表利用率高 | 右侧 3 位段均匀使用,左侧"余数段"频次极低(1 位/2 位段稀疏) |
| 算术任务对齐 | 不稳定。1234 可能切 123/4 或 12/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 保留) |
"把蛋糕切成最小薄片"
最稳但块数最多
"最大块不超过 3 寸,
具体怎么切看厨师手感(BPE)"
数据驱动,可解释性弱
"用尺子量好刻度切蛋糕"
规则驱动,结果固定
tokenizer.json 的 3 位分组规则,但若在 transformers 5.x 上以默认配置(tokenizer_class=Qwen2Tokenizer)推理,会被改成单字符切分,数字 token id 序列完全错位。
WeLM-v4.5-…-0608(Qwen2Tokenizer)和 …-stacked-cache 系列(TokenizersBackend)配置不统一,给下游使用者带来困惑,容易踩坑。
Qwen2Tokenizer = Qwen2.5+ 单字符行为,因此接入 WeLM-v4.5 必然踩同样的坑。
tokenizer_class 为 PreTrainedTokenizerFastPreTrainedTokenizerFast 是 HF 从 3.x 一直保留到 5.x 的稳定类名,语义为"把 tokenizer.json 整个原样吃进来,不做任何重写",4.x 和 5.x 行为完全一致。
操作:修改三个目录的 tokenizer_config.json:
// 修改前
{
...
"tokenizer_class": "Qwen2Tokenizer"
}
// 修改后
{
...
"tokenizer_class": "PreTrainedTokenizerFast"
}
需要修改的目录:
/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-stacked-cache/(顺手统一)/apdcephfs/sg-m2/group_airesearch_1/models/WeLM-v4.5-80B-A3B-Instruct-0608-VLM-OE-stacked-cache/(顺手统一)TokenizersBackendTokenizersBackend 类不存在,tokenizer_class_from_name 会返回 None,但因为有 model_type → TOKENIZER_MAPPING 兜底,4.x 也能回退到 Qwen2TokenizerFast,最终行为仍然正确。但不推荐作为长期方案,因为容易让维护者误以为这是个 5.x 专属配置。
| 派别 | 建议 |
|---|---|
| 单字符派(A) | 跟齐 Qwen2.5+ / Qwen3,对算术任务最稳,生态最兼容。推荐。 |
| 1~3 位贪心 BPE(B) | 跟齐 Llama-3 / GPT-4o / DeepSeek,token 序列短,但数字一致性弱。可选。 |
| 3 位强制分组(C) | 不推荐。 已被业界淘汰,且与 transformers 5.x 兼容性差。 |
建议在 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]}")
路径: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),
])
路径: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
)
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}")
| 用途 | 路径 |
|---|---|
| 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.py、tokenization_qwen2.py
) 阅读综合得出。所有结论均有可复现的验证脚本佐证。
建议下一步: 立即执行方案 B,统一三个 WeLM-v4.5 目录的 tokenizer_class 为 PreTrainedTokenizerFast,并在 CI 中增加 "encode 长串数字结果与基线一致" 的回归测试。