special: true 到底改变了什么

编码时它什么都不改。解码时它决定一切——但只在 skip_special_tokens 打开的那一种情况下。全部结论用 GLM 5.2 的 tokenizer 实测得出。

Posted by Jessie Jia on 2026-08-14

这个问题是在我自己的 eval 脚本里冒出来的,这种发现方式挺丢人的。

1
2
text = tokenizer.decode(output[0, input_len:], skip_special_tokens=True).strip()
text = re.sub(r"<think>.*?</think>\s*", "", text, flags=re.DOTALL).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
2
<|assistant|>    ids=[154828]  → 原子
<think> ids=[154841] → 原子

都是单个 token。原子性来自它在 added_tokens 里这件事本身——这些字符串会在 BPE 跑之前被整体匹配掉。special 是同一条目上的另一个字段。

从上下文中切出来。 完全一致:

1
2
'before<|assistant|>after'  → ['before', '<|assistant|>', 'after']
'before<think>after' → ['before', '<think>', 'after']

匹配用户手打的文本。 也完全一致,而这一条值得盯着看:

1
2
'please explain <|assistant|> to me'  → ['please', 'Ġexplain', 'Ġ', '<|assistant|>', 'Ġto', 'Ġme']
'please explain <think> to me' → ['please', 'Ġexplain', 'Ġ', '<think>', 'Ġto', 'Ġme']

用户在聊天框里打 <|assistant|>,产出的就是 token 154828——货真价实的轮次标记,和你模板写的那个毫无区别special 在这里不提供任何保护,两类都一样。

add_special_tokens=True vs False 毫无差别:

1
2
'hello world'    True=[14978, 1879]  False=[14978, 1879]
'<|assistant|>' True=[154828] False=[154828]

这个让我愣了一下,直到去看管线:GLM 5.2 的 post_processorByteLevel 而不是 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
2
3
4
5
skip_special_tokens=False
'[gMASK]<sop><|user|>\nwhat is 2+2<|assistant|><think>user wants arithmetic</think>It is 4.<|endoftext|>'

skip_special_tokens=True
'\nwhat is 2+2<think>user wants arithmetic</think>It is 4.'

信封没了,模型自己的标记还在。而那个 \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
2
decode([<|assistant|>, <think>, "SECRET REASONING", </think>, "answer"], skip_special_tokens=True)
'<think>SECRET REASONING</think>answer'

推理正文是普通 token,从一开始就不在被删的候选里。

这也正是把 think 标记标成 special 会是灾难而非便利的原因:删掉那两个分隔符,推理正文原样留下、焊在答案前面,且再也没有任何边界可以切。等你拿到那个字符串时,用来区分两者的信息已经不存在了。

非 special 是唯一能让模型自己的结构留下来被解析的版本。GLM 的 chat template 正是依赖这一点:

1
{%- set reasoning_content = content.split('</think>')[0].split('<think>')[-1] %}

对解码后的文本做字符串操作。这只有在标记还在字符串里时才成立。

skip=True 的解码是有损的

这是我之前没想到的后果。把一段序列 decode 再 encode 回去:

1
2
skip=False   9 tokens → decode → encode → 9 tokens   无损
skip=True 9 tokens → decode → encode → 6 tokens 丢了 3 个

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
2
3
4
<|endoftext|> [MASK] [gMASK] [sMASK] <sop> <eop>
<|system|> <|user|> <|assistant|> <|observation|>
<|begin_of_image|> <|end_of_image|> <|begin_of_video|> <|end_of_video|>
<|begin_of_audio|> <|end_of_audio|> <|begin_of_transcription|> <|end_of_transcription|>

非 special(154838–154855)——模型自己写的标记:

1
2
3
4
5
<|code_prefix|> <|code_middle|> <|code_suffix|>
<think> </think>
<tool_call> </tool_call> <tool_response> </tool_response>
<arg_key> </arg_key> <arg_value> </arg_value>
/nothink <|begin_of_box|> <|end_of_box|> <|image|> <|video|>

分界线不是重要程度,是这个 token 是谁写的。第一组是你的代码渲染模板写进去的,对读者隐藏它不损失任何东西;第二组是模型写的,隐藏它就摧毁了它自己输出的结构。

而且第一组恰好就是 tokenizer_config.jsonextra_special_tokens 的那份清单——我比对过两个集合,完全一致。

同一逻辑还有两个小佐证。generation_config.json 的停止条件是 [154820, 154827, 154829],即 <|endoftext|><|user|><|observation|>,三个全在 special 那一半;</think> 故意不是停止符,因为生成要穿过它继续写答案。另外开启思考时,生成提示以一个未闭合的 <think> 结尾——模型一睁眼就在推理块内部,</think> 是它宣布想完了的方式。

怎么验任何一个 tokenizer

1
2
3
4
5
6
7
8
from tokenizers import Tokenizer
tok = Tokenizer.from_file("tokenizer.json")

for t in ["<think>", "</think>", "<|assistant|>", "<|endoftext|>"]:
ids = tok.encode(t, add_special_tokens=False).ids
print(f"{t:16} ids={ids} "
f"原子={len(ids)==1} "
f"skip=True -> {tok.decode(ids, skip_special_tokens=True)!r}")

最后一列为空说明它是 special。非空说明你的代码必须自己处理它,因为没有别的东西会管。

我从中拿到的

special 读起来像个类型声明,行为上是个显示开关。它只回答一个问题——人该不该看到这个——在一个时刻,其余地方一概空转。

我一直把它当成”这个 token 是什么”的陈述。它其实是”最后一步拿它怎么办”的陈述。