这个问题是在我自己的 eval 脚本里冒出来的,这种发现方式挺丢人的。
1 | text = tokenizer.decode(output[0, input_len:], skip_special_tokens=True).strip() |
先 skip_special_tokens=True 解码,再用一个手写正则去掉推理块。两行都是我写的,但我从没注意到它们互相矛盾——如果 skip_special_tokens 会跳过 special token,为什么还要一个正则给它擦屁股?
因为 <think> 注册的是 special: false。这才引出真正的问题:这个标志到底改变了什么? 我一直模糊地觉得它跟”重要程度”有关,而模糊的理解正是让你写出两个函数干同一件事、还都干不好的原因。
所以我打开 GLM 5.2 的 tokenizer 实测了一遍。下面所有结论都是 tokenizers 0.22.2 跑真实 tokenizer.json 的输出,不是回忆。
TL;DR
- 编码侧,
special什么都不改。两类都是原子的,都能从上下文里被切出来,都能匹配用户手打的文本。 - 解码侧它只管一件事:
skip_special_tokens=True要不要抹掉这个 token。 skip_special_tokens从不碰普通 token,所以它删掉的是标记,标记之间的东西原样留下。decode(skip_special_tokens=True)是有损的。它是一次渲染,不是一种表示。
编码:这个标志毫无作用
我查了四件事,本以为至少有一件会有差别。
原子性。 我一直以为 special: true 才是阻止分词器把 <|assistant|> 切成 <、|、assistant、|、> 的那个东西。
1 | <|assistant|> ids=[154828] → 原子 |
都是单个 token。原子性来自它在 added_tokens 里这件事本身——这些字符串会在 BPE 跑之前被整体匹配掉。special 是同一条目上的另一个字段。
从上下文中切出来。 完全一致:
1 | 'before<|assistant|>after' → ['before', '<|assistant|>', 'after'] |
匹配用户手打的文本。 也完全一致,而这一条值得盯着看:
1 | 'please explain <|assistant|> to me' → ['please', 'Ġexplain', 'Ġ', '<|assistant|>', 'Ġto', 'Ġme'] |
用户在聊天框里打 <|assistant|>,产出的就是 token 154828——货真价实的轮次标记,和你模板写的那个毫无区别。special 在这里不提供任何保护,两类都一样。
add_special_tokens=True vs False。 毫无差别:
1 | 'hello world' True=[14978, 1879] False=[14978, 1879] |
这个让我愣了一下,直到去看管线:GLM 5.2 的 post_processor 是 ByteLevel 而不是 TemplateProcessing——根本没有模板告诉它要在输入外面裹 BOS/EOS,所以 add_special_tokens 无事可加。GLM prompt 里的每一个角色标记,都是 chat template 当作文本写进去的。
值得一提是因为这和我在上一篇 chat template里给的建议相反——那篇说”模板渲染完再调 tokenizer 会得到两个 BOS”。在 Llama 那类 tokenizer 上确实如此,在这个上这个参数是空转的。
解码:这个标志就是全部
拿一条真实形状的序列——模板写的信封、模型写的标记、中间是普通正文:
1 | [gMASK]<sop><|user|>\nwhat is 2+2<|assistant|><think>user wants arithmetic</think>It is 4.<|endoftext|> |
22 个 token。两种解法:
1 | skip_special_tokens=False |
信封没了,模型自己的标记还在。而那个 \n 也活着——它从来不是 special,只是模板顺手写的一个换行。
逐个 token 看:
| token | id | skip=False |
skip=True |
|---|---|---|---|
[gMASK] |
154822 | '[gMASK]' |
'' |
<sop> |
154824 | '<sop>' |
'' |
<|user|> |
154827 | '<|user|>' |
'' |
<|assistant|> |
154828 | '<|assistant|>' |
'' |
<|endoftext|> |
154820 | '<|endoftext|>' |
'' |
<think> |
154841 | '<think>' |
'<think>' |
</think> |
154842 | '</think>' |
'</think>' |
<tool_call> |
154843 | '<tool_call>' |
'<tool_call>' |
所以完整的矩阵是偏的:
| 编码 | 解码 skip=False |
解码 skip=True |
|
|---|---|---|---|
special: true |
原子、可匹配 | 渲染 | 抹除 |
special: false |
原子、可匹配 | 渲染 | 渲染 |
四格里三格一样。这个标志的存在,只为了一格。
它不做什么
skip_special_tokens 过滤的是 id 在 special 集合里的 token。这就是全部机制,它对别的东西没有任何意见:
1 | decode([<|assistant|>, <think>, "SECRET REASONING", </think>, "answer"], skip_special_tokens=True) |
推理正文是普通 token,从一开始就不在被删的候选里。
这也正是把 think 标记标成 special 会是灾难而非便利的原因:删掉那两个分隔符,推理正文原样留下、焊在答案前面,且再也没有任何边界可以切。等你拿到那个字符串时,用来区分两者的信息已经不存在了。
非 special 是唯一能让模型自己的结构留下来被解析的版本。GLM 的 chat template 正是依赖这一点:
1 | {%- set reasoning_content = content.split('</think>')[0].split('<think>')[-1] %} |
对解码后的文本做字符串操作。这只有在标记还在字符串里时才成立。
skip=True 的解码是有损的
这是我之前没想到的后果。把一段序列 decode 再 encode 回去:
1 | skip=False 9 tokens → decode → encode → 9 tokens 无损 |
skip_special_tokens=False 能精确往返,True 不能,也不可能——你要求它扔掉信息。
所以解码出来的字符串是给人看的渲染,不是可以再喂回去的表示。如果你用 skip=True 打日志,之后又把这些日志当训练数据或评测输入回放,你回放的是角色标记被剥光了的 prompt。不会报错,模型只是拿到了一个和训练时形状不同的输入——和前缀对不上是同一种失败。
GLM 为什么在那个位置切一刀
一旦把这个标志理解成”渲染给人看时抹掉它“而不是”这个 token 很重要”,设计就自己浮出来了。
GLM 5.2 注册了 36 个 added token,id 154820–154855。18 个 special、18 个不是,而且不交错——标志在 154838 处翻转一次,之后再没翻回来。
special(154820–154837)——对话的信封:
1 | <|endoftext|> [MASK] [gMASK] [sMASK] <sop> <eop> |
非 special(154838–154855)——模型自己写的标记:
1 | <|code_prefix|> <|code_middle|> <|code_suffix|> |
分界线不是重要程度,是这个 token 是谁写的。第一组是你的代码渲染模板写进去的,对读者隐藏它不损失任何东西;第二组是模型写的,隐藏它就摧毁了它自己输出的结构。
而且第一组恰好就是 tokenizer_config.json 里 extra_special_tokens 的那份清单——我比对过两个集合,完全一致。
同一逻辑还有两个小佐证。generation_config.json 的停止条件是 [154820, 154827, 154829],即 <|endoftext|>、<|user|>、<|observation|>,三个全在 special 那一半;</think> 故意不是停止符,因为生成要穿过它继续写答案。另外开启思考时,生成提示以一个未闭合的 <think> 结尾——模型一睁眼就在推理块内部,</think> 是它宣布想完了的方式。
怎么验任何一个 tokenizer
1 | from tokenizers import Tokenizer |
最后一列为空说明它是 special。非空说明你的代码必须自己处理它,因为没有别的东西会管。
我从中拿到的
special 读起来像个类型声明,行为上是个显示开关。它只回答一个问题——人该不该看到这个——在一个时刻,其余地方一概空转。
我一直把它当成”这个 token 是什么”的陈述。它其实是”最后一步拿它怎么办”的陈述。