Jean's Blog

一个专注软件测试开发技术的个人博客

0%

Token 与 Tokenizer:文字如何变成模型能够计算的数字?

Token 与 Tokenizer:文字如何变成模型能够计算的数字?

在上一篇文章中,我们完整走读了一次 LLM 请求:消息经过 Chat Template 和 Tokenizer 转换为 Token ID,再由模型执行 Prefill 和 Decode,逐步生成回答。

其中,Tokenizer 位于自然语言与神经网络之间:

人类输入文字,模型处理数字,而 Tokenizer 负责在两者之间进行转换。

Tokenizer 看似只是一个文本切分工具,实际上它会直接影响:

  • 模型如何观察文字;
  • 一段文本占用多少上下文;
  • API 按 Token 计费时的用量;
  • 文本是否会被截断;
  • 模型与输入格式是否兼容;
  • 多语言、代码和特殊字符的处理效率。

本文将从 Token、Vocabulary 和 Token ID 开始,逐步介绍 Tokenizer 的编码流程、常见子词算法、特殊 Token、Padding、Truncation 和 Chat Template,并通过本地 Qwen Tokenizer 查看真实的切分结果。

image-20261012102037915

Token 是什么?

Token 是模型处理序列时使用的基本离散单元,也可以翻译为“词元”。

Token 不一定是一个完整单词,也不一定是一个汉字。根据具体 Tokenizer 和输入文本,一个 Token 可能是:

  • 一个完整单词;
  • 一个子词;
  • 一个汉字或多个汉字;
  • 标点符号;
  • 空格与单词的组合;
  • 一个或多个字节;
  • 控制消息结构的特殊标记。

例如,下面只是用于理解概念的示意,并不是所有模型的真实切分结果:

原始文本 可能的 Token 切分示意
hello hello
unbelievable un、believ、able
人工智能 人工、智能
print("你好") print、(、"、你好、"、)
😊 一个 Token,或者多个字节相关 Token

同一段文字交给不同 Tokenizer,得到的切分结果和 Token 数量可能完全不同。因此不能脱离具体模型说“一句话固定等于多少个 Token”。

为什么不直接按字或单词切分?

如果把每个完整单词都作为 Token,会遇到几个问题:

  1. 自然语言中的单词数量非常庞大;
  2. 新词、人名、拼写变化和专业术语不断出现;
  3. 中文等语言并不依靠空格分隔所有词语;
  4. 代码、URL、Emoji 和混合语言很难用统一的“单词”定义处理;
  5. 词表过大会增大 Embedding 和输出层的参数量。

如果完全按字符或字节切分,虽然几乎可以覆盖所有输入,但序列会变得很长,增加上下文占用和推理成本。

子词 Tokenization 在两者之间折中:

1
2
常见内容 → 尽量使用较长 Token
少见内容 → 拆成更小的子词、字符或字节单元

这样既能控制词表大小,又能表示训练时没有完整出现过的新词。

Token、Vocabulary 与 Token ID

理解 Tokenizer,需要区分三个概念:

概念 含义 示例
Token 文本被切分后的离散单元 hello、ing、。
Vocabulary Tokenizer 可以使用的 Token 集合 模型对应的完整词表
Token ID Token 在词表中的整数编号 hello → 15339(仅为示意)

Tokenizer 会维护 Token 与 Token ID 的映射关系:

1
2
3
4
5
Token             Token ID
--------------------------
"hello" ↔ 15339
" world" ↔ 1917
"。" ↔ 1773

需要注意:Token ID 没有跨模型的统一含义。

在一个 Tokenizer 中,ID 1000 可能表示某个英文子词;在另一个 Tokenizer 中,它可能表示汉字、标点或特殊 Token。模型的 Embedding 矩阵按照自己的词表顺序训练,因此不能随意把一个 Tokenizer 生成的 ID 交给另一个模型。

模型权重与 Tokenizer 是配套的。更换 Tokenizer 不只是更换文本预处理方式,还会改变每个 ID 所代表的内容。

Tokenizer 的完整编码流程

一个 Tokenizer 的实现可以包含多个组件。典型流程如下:

1
2
3
4
5
6
7
8
9
10
11
原始文本
↓
Normalization(可选)
↓
Pre-Tokenization
↓
Subword Model
↓
Post-Processing(可选)
↓
Token 与 Token ID

不同模型会采用不同组件和规则,并不是每个 Tokenizer 都会执行完全相同的步骤。

1. Normalization:文本规范化

Normalization 会按照既定规则对输入文本进行规范化,例如:

  • Unicode 规范化;
  • 大小写转换;
  • 重音符号处理;
  • 空白字符处理;
  • 模型定义的字符替换。

例如,某些 Tokenizer 可能把:

1
HELLO → hello

但大小写是否保留取决于具体 Tokenizer。现代生成模型通常需要严格使用模型原有的规范化规则,不能在应用层想当然地先把文本全部转为小写或删除标点。

规范化还可能是有损的。如果两个不同输入经过规范化后变成相同形式,解码时就不一定能够恢复最初的原始文本。

2. Pre-Tokenization:预切分

预切分根据空格、标点、数字、字节等规则,把文本拆成较小的候选片段。

例如,一个简单的空格与标点预切分器可能把:

1
Hello, world!

拆成:

1
Hello | , | world | !

有些 Byte-Level Tokenizer 会先把文本映射到字节表示,因此可以覆盖任意 Unicode 输入,但内部 Token 字符串可能不再适合直接阅读。

预切分只是中间步骤,最终 Token 还要由后续子词模型决定。

3. Subword Model:子词切分

子词模型根据训练好的词表或统计规则决定最终如何切分片段。常见方法包括 BPE、WordPiece 和 Unigram。

4. Post-Processing:添加结构信息

后处理可以在序列中添加模型需要的特殊 Token,例如:

1
[CLS] sentence [SEP]

对于聊天模型,System、User、Assistant 等消息结构通常由 Chat Template 组织。Chat Template 可能插入角色边界和消息结束 Token,因此要避免随后再次错误添加重复的特殊 Token。

常见子词算法

BPE:Byte Pair Encoding

BPE 的基本思想是:从较小的基础单元开始,反复合并语料中高频出现的相邻单元。

假设训练语料经过初始拆分后包含:

1
2
3
l o w
l o w e r
n e w e s t

如果 l + o 经常一起出现,就可以学习合并为 lo;随后 lo + w 又可能合并为 low。

最终效果是:

  • 高频词或片段更可能形成较长 Token;
  • 低频词可以拆成多个较小 Token;
  • 词表大小可以控制在指定范围内。

实际模型可能使用字符级 BPE、Byte-Level BPE 或其他变体,不能仅凭“使用 BPE”推断所有预处理细节。

WordPiece

WordPiece 同样使用子词词表,但其训练目标与 BPE 的简单高频相邻合并并不相同。编码时通常使用从当前位置开始、词表中能够匹配的较长子词。

在一些 WordPiece 词表中,## 表示 Token 是一个单词的后续部分。例如:

1
playing → play + ##ing

## 是特定实现中的内部标记,不是原始文本的一部分。

Unigram

Unigram 从一个较大的候选子词集合开始,为每个子词建立概率模型,再逐步移除影响较小的候选。

同一段文本可能存在多种切分方式,Unigram 会根据训练得到的概率选择整体上更合适的 Token 序列。

SentencePiece 是什么?

SentencePiece 是一个 Tokenization 工具与训练框架,而不是与 BPE、Unigram 完全同层级的单一算法。它可以训练 BPE 或 Unigram 模型,并且通常可以直接在原始 Unicode 文本上工作。

部分 SentencePiece Tokenizer 使用类似 ▁ 的符号表示空格或词边界。这是内部表示,Decoder 会在解码时将其恢复为正常文本。

名称 核心特点 常见内部表现
BPE 逐步合并高频相邻单元 高频片段形成较长 Token
WordPiece 基于词表与目标函数建立子词 某些实现使用 ## 标记续接子词
Unigram 从多个候选切分中选择高概率组合 基于子词概率模型
SentencePiece Tokenizer 训练与执行框架 可支持 BPE、Unigram 等模型

Encode:从文本到 Token ID

编码过程通常包含两步:

1
文本 → Token 序列 → Token ID 序列

在 Transformers 中,可以使用:

1
2
3
4
token_ids = tokenizer.encode(
"Token 是模型处理文本的基本单位。",
add_special_tokens=False,
)

也可以直接调用 Tokenizer,获得更完整的输出:

1
2
3
4
5
6
7
8
encoding = tokenizer(
"Token 是模型处理文本的基本单位。",
add_special_tokens=False,
return_attention_mask=True,
)

print(encoding["input_ids"])
print(encoding["attention_mask"])

encode() 适合只需要 ID 列表的场景;直接调用 Tokenizer 则可以同时获得 Attention Mask、Offset Mapping 等信息。

Decode:从 Token ID 回到文本

解码是编码的反向过程:

1
2
3
4
5
text = tokenizer.decode(
token_ids,
skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)

其中:

  • skip_special_tokens=True:不在结果中显示特殊 Token;
  • clean_up_tokenization_spaces=False:尽量避免额外调整空格。

需要注意,Encode 与 Decode 不一定永远严格还原为逐字符完全相同的原始输入,原因可能包括:

  • Tokenizer 执行了有损规范化;
  • 解码时跳过了特殊 Token;
  • Decoder 进行了空格清理;
  • 输入中包含不同但规范化后等价的 Unicode 表示。

对于多数现代生成模型的普通文本,整体编码后再整体解码通常可以得到预期内容。但逐个 Token 分别解码再拼接,不一定等价于对整个 Token 序列统一解码,特别是使用字节级编码时。

特殊 Token

特殊 Token 不一定对应用户可见的普通文字,而是用于表示结构、控制边界或填充位置。

特殊 Token 常见作用 是否所有模型都有
BOS 表示序列开始 否
EOS 表示序列结束 否,但生成模型中很常见
PAD 将批次中的序列补齐到相同长度 否,有些模型会复用其他 Token
UNK 表示词表无法覆盖的内容 否,字节级方案可能不需要
MASK 掩码语言模型的训练标记 否,自回归模型通常不依赖它
角色 Token 表示 System、User、Assistant 等消息边界 取决于聊天模板

可以通过下面的代码查看具体 Tokenizer 的特殊 Token:

1
2
3
print(tokenizer.special_tokens_map)
print(tokenizer.all_special_tokens)
print(tokenizer.all_special_ids)

不要根据 Token 的名称猜测用途,应以模型配置、Tokenizer 配置和 Chat Template 为准。

Chat Template 为什么会增加 Token 数?

用户看到的可能只有一句话:

1
请介绍一下你自己。

但聊天模型实际接收的内容还可能包含:

  • System Message;
  • User 角色起始标记;
  • 消息结束标记;
  • Assistant 角色起始标记;
  • 历史对话中的全部角色和内容。

因此,下面两个结果通常不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
raw_ids = tokenizer.encode(
"请介绍一下你自己。",
add_special_tokens=False,
)

chat_ids = tokenizer.apply_chat_template(
[{"role": "user", "content": "请介绍一下你自己。"}],
tokenize=True,
add_generation_prompt=True,
)

print("裸文本 Token 数:", len(raw_ids))
print("聊天模板 Token 数:", len(chat_ids))

在估算上下文长度或 API 使用量时,应统计实际发送给模型的完整序列,而不是只统计用户可见的问题文本。

Padding 与 Truncation

Padding:补齐批次长度

批量推理时,不同文本包含的 Token 数可能不同。为了组成形状规则的张量,通常需要把较短序列补齐:

1
2
3
4
5
6
序列 A: [11, 25, 90, 36]
序列 B: [52, 18]

Padding 后:
序列 A: [11, 25, 90, 36]
序列 B: [52, 18, PAD, PAD]

Attention Mask 用来标识有效 Token 与 Padding:

1
2
序列 A: [1, 1, 1, 1]
序列 B: [1, 1, 0, 0]

Padding 在左侧还是右侧取决于 Tokenizer 配置和任务要求。对生成模型进行批量推理时,Padding Side 可能影响结果,应该遵循模型文档和推理框架的要求。

Truncation:截断过长输入

当输入超过指定长度时,可以启用截断:

1
2
3
4
5
encoding = tokenizer(
long_text,
truncation=True,
max_length=128,
)

截断不是无损操作。被删除的 Token 不会被模型看到,而且不一定总是从读者认为“不重要”的位置删除。

因此,真实应用需要明确:

  • 最大允许输入长度;
  • 输入和输出如何共享上下文预算;
  • 从左侧还是右侧截断;
  • System Prompt、用户问题和检索资料谁应该优先保留。

Token 数为什么重要?

1. 决定上下文占用

模型限制的是 Token 数量,不是字符数或字数。一篇字符数相同的中文、英文或代码文本,Token 数可能不同。

2. 影响推理延迟

输入 Token 越多,Prefill 需要处理的内容越多;输出 Token 越多,Decode 循环次数越多。

3. 影响内存占用

在自回归生成中,KV Cache 通常会随已缓存 Token 数增长。长上下文可能显著增加显存或内存占用。

4. 影响 API 费用

许多模型服务按照输入 Token 和输出 Token 分别计费。具体计费规则和 Tokenizer 由服务提供方决定,应以对应模型的最新文档为准。

5. 影响多语言效率

如果某种语言在 Tokenizer 的训练语料中覆盖不足,常见文字可能被切成更多、更小的 Token,导致相同信息需要更长的序列。

实战:走读 Qwen Tokenizer

下面继续使用前两篇文章中的本地模型目录:

1
./models/Qwen2.5-0.5B-Instruct

本章只加载 Tokenizer,不加载模型权重,因此运行速度更快,也不需要 GPU。

示例代码:将下面代码保存为 qwen_tokenizer_walkthrough.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
from transformers import AutoTokenizer

MODEL_PATH = "./models/Qwen2.5-0.5B-Instruct"

tokenizer = AutoTokenizer.from_pretrained(
MODEL_PATH,
local_files_only=True,
use_fast=True,
)


def inspect_text(text: str) -> None:
"""打印一段文本的 Token、Token ID、字符区间及解码结果。"""
kwargs = {
"add_special_tokens": False,
"return_attention_mask": False,
}
if tokenizer.is_fast:
kwargs["return_offsets_mapping"] = True

encoding = tokenizer(text, **kwargs)
token_ids = encoding["input_ids"]
tokens = tokenizer.convert_ids_to_tokens(token_ids)
offsets = encoding.get("offset_mapping")

print("\n" + "=" * 72)
print("原始文本:", repr(text))
print("字符数量:", len(text))
print("Token 数量:", len(token_ids))
print("Token ID:", token_ids)
print("Token 列表:", tokens)
print("-" * 72)

for index, (token_id, token) in enumerate(zip(token_ids, tokens)):
# 单个 Token 可能只是某个 UTF-8 字节片段,因此这里仅用于观察。
piece = tokenizer.decode(
[token_id],
skip_special_tokens=False,
clean_up_tokenization_spaces=False,
)

offset_text = ""
if offsets is not None:
start, end = offsets[index]
offset_text = f" offset=({start}, {end}) source={text[start:end]!r}"

print(
f"{index:>2}: "
f"id={token_id:<7} "
f"token={token!r:<18} "
f"decoded_piece={piece!r}"
f"{offset_text}"
)

decoded_text = tokenizer.decode(
token_ids,
skip_special_tokens=False,
clean_up_tokenization_spaces=False,
)
print("整体解码:", repr(decoded_text))
print("是否还原:", decoded_text == text)


print("=== Tokenizer 基本信息 ===")
print("Tokenizer 类型:", type(tokenizer).__name__)
print("是否为 Fast Tokenizer:", tokenizer.is_fast)
print("词表大小:", len(tokenizer))
print("Padding Side:", tokenizer.padding_side)
print("Truncation Side:", tokenizer.truncation_side)

print("\n=== 特殊 Token ===")
print("special_tokens_map:", tokenizer.special_tokens_map)
for token, token_id in zip(
tokenizer.all_special_tokens,
tokenizer.all_special_ids,
):
print(f"{token!r:<24} -> {token_id}")

# 比较中文、英文、代码、数字和 Emoji。
samples = [
"大语言模型正在改变软件开发。",
"Large language models are changing software development.",
'def greet(name):\n return f"你好,{name}"',
"订单编号是 20261012,价格为 ¥99.50。",
"今天天气真好 😊🚀",
]

for sample in samples:
inspect_text(sample)

print("\n=== 裸文本与 Chat Template 对比 ===")
question = "请介绍一下你自己。"
messages = [
{
"role": "system",
"content": "你是一名耐心的人工智能老师。",
},
{
"role": "user",
"content": question,
},
]

raw_token_ids = tokenizer.encode(
question,
add_special_tokens=False,
)
chat_token_ids = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
)
formatted_chat = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)

print("裸文本 Token 数:", len(raw_token_ids))
print("聊天模板 Token 数:", len(chat_token_ids))
print("\n模型实际接收的模板化文本:")
print(formatted_chat)

print("\n=== 批量 Padding ===")
batch = tokenizer(
["你好", "你好,请介绍一下大语言模型。"],
padding=True,
add_special_tokens=False,
)
print("input_ids:", batch["input_ids"])
print("attention_mask:", batch["attention_mask"])

print("\n=== Truncation ===")
long_text = "Token 是模型处理文本的基本单位。" * 30
full_token_ids = tokenizer.encode(
long_text,
add_special_tokens=False,
)
truncated = tokenizer(
long_text,
add_special_tokens=False,
truncation=True,
max_length=32,
)
print("截断前 Token 数:", len(full_token_ids))
print("截断后 Token 数:", len(truncated["input_ids"]))
print(
"截断后文本:",
tokenizer.decode(
truncated["input_ids"],
clean_up_tokenization_spaces=False,
),
)

执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
/Users/jean/.pyenv/shims/uv run /Users/jean/PycharmProjects/llm-base/.venv/bin/python /Users/jean/PycharmProjects/llm-base/qwen_tokenizer_walkthrough.py 
=== Tokenizer 基本信息 ===
Tokenizer 类型: Qwen2Tokenizer
是否为 Fast Tokenizer: True
词表大小: 151665
Padding Side: right
Truncation Side: right

=== 特殊 Token ===
special_tokens_map: {'eos_token': '<|im_end|>', 'pad_token': '<|endoftext|>'}
'<|im_end|>' -> 151645
'<|endoftext|>' -> 151643
'<|im_start|>' -> 151644
'<|object_ref_start|>' -> 151646
'<|object_ref_end|>' -> 151647
'<|box_start|>' -> 151648
'<|box_end|>' -> 151649
'<|quad_start|>' -> 151650
'<|quad_end|>' -> 151651
'<|vision_start|>' -> 151652
'<|vision_end|>' -> 151653
'<|vision_pad|>' -> 151654
'<|image_pad|>' -> 151655
'<|video_pad|>' -> 151656

========================================================================
原始文本: '大语言模型正在改变软件开发。'
字符数量: 14
Token 数量: 8
Token ID: [26288, 102064, 104949, 96555, 101933, 103951, 100013, 1773]
Token 列表: ['大', 'è¯Ńè¨Ģ', '模åŀĭ', 'æŃ£åľ¨', 'æĶ¹åıĺ', '软件', 'å¼Ģåıij', 'ãĢĤ']
------------------------------------------------------------------------
0: id=26288 token='大' decoded_piece='大' offset=(0, 1) source='大'
1: id=102064 token='è¯Ńè¨Ģ' decoded_piece='语言' offset=(1, 3) source='语言'
2: id=104949 token='模åŀĭ' decoded_piece='模型' offset=(3, 5) source='模型'
3: id=96555 token='æŃ£åľ¨' decoded_piece='正在' offset=(5, 7) source='正在'
4: id=101933 token='æĶ¹åıĺ' decoded_piece='改变' offset=(7, 9) source='改变'
5: id=103951 token='软件' decoded_piece='软件' offset=(9, 11) source='软件'
6: id=100013 token='å¼Ģåıij' decoded_piece='开发' offset=(11, 13) source='开发'
7: id=1773 token='ãĢĤ' decoded_piece='。' offset=(13, 14) source='。'
整体解码: '大语言模型正在改变软件开发。'
是否还原: True

========================================================================
原始文本: 'Large language models are changing software development.'
字符数量: 56
Token 数量: 8
Token ID: [34253, 4128, 4119, 525, 10018, 3162, 4401, 13]
Token 列表: ['Large', 'Ġlanguage', 'Ġmodels', 'Ġare', 'Ġchanging', 'Ġsoftware', 'Ġdevelopment', '.']
------------------------------------------------------------------------
0: id=34253 token='Large' decoded_piece='Large' offset=(0, 5) source='Large'
1: id=4128 token='Ġlanguage' decoded_piece=' language' offset=(5, 14) source=' language'
2: id=4119 token='Ġmodels' decoded_piece=' models' offset=(14, 21) source=' models'
3: id=525 token='Ġare' decoded_piece=' are' offset=(21, 25) source=' are'
4: id=10018 token='Ġchanging' decoded_piece=' changing' offset=(25, 34) source=' changing'
5: id=3162 token='Ġsoftware' decoded_piece=' software' offset=(34, 43) source=' software'
6: id=4401 token='Ġdevelopment' decoded_piece=' development' offset=(43, 55) source=' development'
7: id=13 token='.' decoded_piece='.' offset=(55, 56) source='.'
整体解码: 'Large language models are changing software development.'
是否还原: True

========================================================================
原始文本: 'def greet(name):\n return f"你好,{name}"'
字符数量: 40
Token 数量: 13
Token ID: [750, 40786, 3153, 982, 262, 470, 282, 1, 108386, 3837, 90, 606, 9863]
Token 列表: ['def', 'Ġgreet', '(name', '):Ċ', 'ĠĠĠ', 'Ġreturn', 'Ġf', '"', 'ä½łå¥½', 'ï¼Į', '{', 'name', '}"']
------------------------------------------------------------------------
0: id=750 token='def' decoded_piece='def' offset=(0, 3) source='def'
1: id=40786 token='Ġgreet' decoded_piece=' greet' offset=(3, 9) source=' greet'
2: id=3153 token='(name' decoded_piece='(name' offset=(9, 14) source='(name'
3: id=982 token='):Ċ' decoded_piece='):\n' offset=(14, 17) source='):\n'
4: id=262 token='ĠĠĠ' decoded_piece=' ' offset=(17, 20) source=' '
5: id=470 token='Ġreturn' decoded_piece=' return' offset=(20, 27) source=' return'
6: id=282 token='Ġf' decoded_piece=' f' offset=(27, 29) source=' f'
7: id=1 token='"' decoded_piece='"' offset=(29, 30) source='"'
8: id=108386 token='ä½łå¥½' decoded_piece='你好' offset=(30, 32) source='你好'
9: id=3837 token='ï¼Į' decoded_piece=',' offset=(32, 33) source=','
10: id=90 token='{' decoded_piece='{' offset=(33, 34) source='{'
11: id=606 token='name' decoded_piece='name' offset=(34, 38) source='name'
12: id=9863 token='}"' decoded_piece='}"' offset=(38, 40) source='}"'
整体解码: 'def greet(name):\n return f"你好,{name}"'
是否还原: True

========================================================================
原始文本: '订单编号是 20261012,价格为 ¥99.50。'
字符数量: 26
Token 数量: 22
Token ID: [50410, 72640, 20412, 220, 17, 15, 17, 21, 16, 15, 16, 17, 3837, 97480, 17714, 71488, 24, 24, 13, 20, 15, 1773]
Token 列表: ['订åįķ', 'ç¼ĸåı·', 'æĺ¯', 'Ġ', '2', '0', '2', '6', '1', '0', '1', '2', 'ï¼Į', 'ä»·æł¼', '为', 'ĠÂ¥', '9', '9', '.', '5', '0', 'ãĢĤ']
------------------------------------------------------------------------
0: id=50410 token='订åįķ' decoded_piece='订单' offset=(0, 2) source='订单'
1: id=72640 token='ç¼ĸåı·' decoded_piece='编号' offset=(2, 4) source='编号'
2: id=20412 token='æĺ¯' decoded_piece='是' offset=(4, 5) source='是'
3: id=220 token='Ġ' decoded_piece=' ' offset=(5, 6) source=' '
4: id=17 token='2' decoded_piece='2' offset=(6, 7) source='2'
5: id=15 token='0' decoded_piece='0' offset=(7, 8) source='0'
6: id=17 token='2' decoded_piece='2' offset=(8, 9) source='2'
7: id=21 token='6' decoded_piece='6' offset=(9, 10) source='6'
8: id=16 token='1' decoded_piece='1' offset=(10, 11) source='1'
9: id=15 token='0' decoded_piece='0' offset=(11, 12) source='0'
10: id=16 token='1' decoded_piece='1' offset=(12, 13) source='1'
11: id=17 token='2' decoded_piece='2' offset=(13, 14) source='2'
12: id=3837 token='ï¼Į' decoded_piece=',' offset=(14, 15) source=','
13: id=97480 token='ä»·æł¼' decoded_piece='价格' offset=(15, 17) source='价格'
14: id=17714 token='为' decoded_piece='为' offset=(17, 18) source='为'
15: id=71488 token='ĠÂ¥' decoded_piece=' ¥' offset=(18, 20) source=' ¥'
16: id=24 token='9' decoded_piece='9' offset=(20, 21) source='9'
17: id=24 token='9' decoded_piece='9' offset=(21, 22) source='9'
18: id=13 token='.' decoded_piece='.' offset=(22, 23) source='.'
19: id=20 token='5' decoded_piece='5' offset=(23, 24) source='5'
20: id=15 token='0' decoded_piece='0' offset=(24, 25) source='0'
21: id=1773 token='ãĢĤ' decoded_piece='。' offset=(25, 26) source='。'
整体解码: '订单编号是 20261012,价格为 ¥99.50。'
是否还原: True

========================================================================
原始文本: '今天天气真好 😊🚀'
字符数量: 9
Token 数量: 7
Token ID: [100644, 104307, 88051, 52801, 26525, 232, 145836]
Token 列表: ['ä»Ĭ天', '天æ°Ķ', '羣', '好', 'ĠðŁĺ', 'Ĭ', 'ðŁļĢ']
------------------------------------------------------------------------
0: id=100644 token='ä»Ĭ天' decoded_piece='今天' offset=(0, 2) source='今天'
1: id=104307 token='天æ°Ķ' decoded_piece='天气' offset=(2, 4) source='天气'
2: id=88051 token='羣' decoded_piece='真' offset=(4, 5) source='真'
3: id=52801 token='好' decoded_piece='好' offset=(5, 6) source='好'
4: id=26525 token='ĠðŁĺ' decoded_piece=' �' offset=(6, 8) source=' 😊'
5: id=232 token='Ĭ' decoded_piece='�' offset=(7, 8) source='😊'
6: id=145836 token='ðŁļĢ' decoded_piece='🚀' offset=(8, 9) source='🚀'
整体解码: '今天天气真好 😊🚀'
是否还原: True

=== 裸文本与 Chat Template 对比 ===
裸文本 Token 数: 4
聊天模板 Token 数: 2

模型实际接收的模板化文本:
<|im_start|>system
你是一名耐心的人工智能老师。<|im_end|>
<|im_start|>user
请介绍一下你自己。<|im_end|>
<|im_start|>assistant


=== 批量 Padding ===
input_ids: [[108386, 151643, 151643, 151643, 151643, 151643, 151643], [108386, 37945, 109432, 26288, 102064, 104949, 1773]]
attention_mask: [[1, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1]]

=== Truncation ===
截断前 Token 数: 240
截断后 Token 数: 32
截断后文本: Token 是模型处理文本的基本单位。Token 是模型处理文本的基本单位。Token 是模型处理文本的基本单位。Token 是模型处理文本的基本单位。

Process finished with exit code 0

如何阅读输出

convert_ids_to_tokens() 与 decode() 不同

convert_ids_to_tokens() 返回 Tokenizer 的内部 Token 字符串。对于 Byte-Level Tokenizer,结果可能包含看起来奇怪的字符。

decode() 会经过对应 Decoder,把 Token ID 序列恢复为人类可读文本。因此,判断最终输出时应该观察完整序列的 decode() 结果,而不是把内部 Token 字符串直接拼接起来。

Offset Mapping

Fast Tokenizer 可以返回每个 Token 对应原始文本中的字符区间:

1
offset=(start, end)

这对于以下任务很有用:

  • 命名实体识别;
  • 文本高亮;
  • 将模型预测位置映射回原文;
  • 检查 Token 切分边界。

特殊 Token 的 Offset 可能是 (0, 0),因为它们不是直接来自原始文本。

为什么单个 Token 解码可能出现异常字符?

如果一个 Unicode 字符的 UTF-8 字节被分布在多个 Token 中,单独解码其中一个 Token 可能出现替代字符或空内容;把相关 Token 放在一起整体解码后,才能恢复正确文字。

这也是为什么流式输出实现不能总是假设“生成一个 Token 就一定得到一个完整可显示字符”。

如何统计真实请求的 Token 数?

对于普通文本,可以直接统计:

1
2
3
4
5
6
7
def count_text_tokens(text: str) -> int:
return len(
tokenizer.encode(
text,
add_special_tokens=False,
)
)

对于聊天请求,应应用相同的 Chat Template:

1
2
3
4
5
6
7
def count_chat_tokens(messages: list[dict[str, str]]) -> int:
token_ids = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
)
return len(token_ids)

这种方式可以统计发送到本地模型的输入序列。对于远程 API,服务端可能使用不同版本的 Tokenizer、隐藏的系统消息或额外工具定义,因此客户端估算结果可能与最终账单存在差异,应以服务端返回的 Usage 数据为准。

常见误区

误区一:一个汉字等于一个 Token

有些常见汉字可能单独成为 Token,有些词语可能合并为一个 Token,少见字符也可能被拆成多个字节相关 Token。只有实际运行对应 Tokenizer 才能确定。

误区二:一个英文单词等于一个 Token

常见单词可能是一个 Token,少见单词、复合词和词形变化可能被拆成多个子词。单词前的空格也可能属于 Token 表示的一部分。

误区三:Tokenizer 在理解文本语义

Tokenizer 主要按照词表和算法规则编码文本。语境相关的表示和复杂模式计算发生在后续神经网络中。

误区四:不同模型可以共用 Token ID

Token ID 只在对应词表中有意义。即使两个模型都采用 BPE,也不代表它们具有相同词表、合并规则或特殊 Token。

误区五:Token 越长越好

较长 Token 可以缩短序列,但需要更大的词表,也可能降低对新词的组合能力。Tokenizer 设计是在词表大小、序列长度、多语言覆盖和模型能力之间进行权衡。

误区六:Decode 一定逐字符还原原文

规范化、特殊 Token 跳过和空格清理都可能改变结果。需要严格保留原文的系统不应只依赖“编码后再解码”保存文本。

误区七:字符数量可以代替 Token 数量

不同语言、代码、数字、空格和特殊符号的切分效率不同。字符数量只能用于非常粗略的估计,不能替代真实 Tokenizer 统计。

小结

Token 与 Tokenizer 的核心关系可以概括为:

  1. Token 是模型处理序列时使用的离散单元,不固定等于字、词或字符;
  2. Tokenizer 按照自己的规范化、预切分和子词规则生成 Token;
  3. Vocabulary 保存 Token 集合以及 Token 与 ID 的映射关系;
  4. Token ID 只对配套模型有意义,不能在不同模型之间随意复用;
  5. Encode 把文本转换为 Token ID,Decode 把 Token ID 序列恢复为文本;
  6. 特殊 Token 用于表示序列、消息角色、Padding 等结构;
  7. Token 数会影响上下文、推理延迟、内存占用、截断和 API 费用;
  8. 统计聊天请求时,需要计算 Chat Template 处理后的完整序列。

理解 Tokenizer 后,我们就能进一步回答一个重要问题:System、User、Assistant 和历史消息是如何共同组成模型上下文的。下一篇将继续介绍 Prompt、Context 与 Chat Template。

参考资料