Token的前世今生
本文适合: 刚开始研究 LLM、Tokenizer、BPE、
tokenizer.json,并且曾经被æĪij这种鬼东西吓到的人。阅读本文不需要数学基础。
需要一点心理承受能力。
本文基于Qwen3.8-27B模型词根分析因为你会发现:
你以为自己输入的是中文,实际上模型从头到尾都没见过中文。
一、事情是这样的
某天,我心血来潮,打开一个大模型的 tokenizer.json。
我想看看:
“我”这个字,在模型眼里到底是什么?
于是:
from tokenizers import Tokenizer
tokenizer = Tokenizer.from_file("tokenizer.json")
output = tokenizer.encode("我爱你")
print(f"'我爱你' 的 Token ID 是: {output.ids}")
print(f"'我爱你' 的 Token 形态是: {output.tokens}")结果:
'我爱你' 的 Token ID 是: [95815, ...]
'我爱你' 的 Token 形态是: ['æĪij', ...]我当时的第一反应:
?????
第二反应:
Tokenizer 坏了?
第三反应:
这模型是不是喝了假酒?
我输入的是:
我你告诉我模型看到的是:
æĪij这三个字母看起来就像一个程序员连续加班七天以后写出来的变量名。
但其实——
它没坏。
这恰恰是 Tokenizer 正常工作的结果。
二、先别急,我们从「我」的出生证明开始
人类看到:
我计算机并不是直接看到“我”。
首先,“我”是一个 Unicode 字符。
它对应的 Unicode Code Point 是:
U+6211Python 可以验证:
print(ord("我"))
print(hex(ord("我")))结果:
25105
0x6211这里有三个概念千万不要混:
我
│
├── Unicode Code Point
│ U+6211
│
├── 十进制
│ 25105
│
└── 十六进制
0x6211ord() 干的事情:
字符 → Unicode 数值而 chr() 正好反过来:
Unicode 数值 → 字符例如:
print(ord("我"))
print(chr(25105))输出:
25105
我所以:
ord()和chr()就像一对互相伤害的好兄弟。
三、但是 Unicode 还不是 Token
到这里我们只是知道:
我 = U+6211但 Tokenizer 还不能直接把:
U+6211塞进模型。
接下来需要编码。
最常见的编码之一就是:
UTF-8
于是:
text = "我"
print(text.encode("utf-8"))得到:
b'\xe6\x88\x91'也就是:
E6 88 91换成十进制:
230 136 145所以“我”的人生第一次出现了重大转折:
我
↓
Unicode
U+6211
↓
UTF-8
E6 88 91
↓
230 136 145注意:
E6 88 91 是什么?
它们是 Byte(字节)。
一个 byte 的范围是:
0 ~ 255所以这里:
E6 = 230
88 = 136
91 = 145四、问题来了:0~255 的 Byte,怎么拿去做 Token?
Tokenizer 的工程师们看着 256 种 Byte:
0
1
2
...
255陷入了沉思。
因为有些 Byte 对应的字符非常不适合直接拿来当 Token。
例如:
0
1
2
...
10
13
27这些东西很多属于控制字符。
你让 Tokenizer 直接处理它们,就像:
让一个 Web 前端工程师去维护 COBOL。
不是不能干。
主要是大家都不想干。
于是 GPT-2 风格的 Byte-Level BPE 做了一件非常漂亮的事情:
把 256 个 Byte 映射成 256 个“安全的 Unicode 字符”。
这就是你之前看到的:
{
33: '!',
34: '"',
...
65: 'A',
...
97: 'a',
...
230: 'æ',
...
136: 'Ī',
145: 'ij'
}五、这张神秘字典到底是什么?
我们来看这段经典代码:
def bytes_to_unicode():
bs = list(range(ord("!"), ord("~") + 1))
bs += list(range(ord("¡"), ord("¬") + 1))
bs += list(range(ord("®"), ord("ÿ") + 1))
cs = bs[:]
n = 0
for b in range(256):
if b not in bs:
bs.append(b)
cs.append(256 + n)
n += 1
cs = [chr(n) for n in cs]
return dict(zip(bs, cs))第一次看到:
256 + n以及:
chr(n)很多人会问:
“这到底在干嘛?”
其实它就是在干一件事:
给每一个 Byte 找一个可以安全表示它的 Unicode 字符。
六、为什么 0 会变成 Ā?
比如:
0 → Ā
1 → ā
2 → Ă
3 → ă
...这里千万不要理解成:
0 就是 Ā不是。
实际上是:
Byte 0
↓
人为规定
↓
Unicode 字符 Ā而:
ord("Ā")是:
256也就是说:
Byte Value Unicode Character
----------------------------------
0 Ā
1 ā
2 Ă
...这是一个映射关系。
不是说:
Ā = Byte 0七、现在轮到「我」登场了
前面已经知道:
我
↓ UTF-8
E6 88 91换成十进制:
230 136 145再查 Byte-Level Mapping:
230 → æ
136 → Ī
145 → ij于是:
我
↓
E6 88 91
↓
230 136 145
↓
æ Ī ij
↓
æĪij终于真相大白。
所以:
"æĪij"不是乱码。
不是模型疯了。
也不是 UTF-8 解码失败。
它是:
Byte-Level BPE 为 UTF-8 字节准备的内部字符表示。
八、所以 æĪij 是怎么来的?
把整个过程画出来:
人类世界
│
▼
"我"
│
│ Unicode
▼
U+6211
│
│ UTF-8
▼
E6 88 91
│
│ 十进制
▼
230 136 145
│
│ Byte-Level Mapping
▼
æ Ī ij
│
▼
"æĪij"于是你就理解了之前那个神秘现象:
'我' 的 Token 形态是: ['æĪij']九、但是到这里,还没有结束
如果你以为:
我 → æĪij然后就直接得到 Token ID:
95815那还差一步。
甚至是非常重要的一步:
BPE
全称:
Byte Pair Encoding
中文通常叫:
字节对编码
它最早是一种数据压缩算法,后来被 NLP 世界拿来做子词分词。
简单来说:
不断寻找相邻的两个符号,并按照训练出来的规则把它们合并。
例如极度简化:
h e l l o可能经历:
h e l l o
↓
he l l o
↓
hel l o
↓
hell o
↓
hello这就是 BPE 的核心思想。
十、所以 Tokenizer 并不是“查字典”
这是理解 LLM Tokenizer 最关键的一步。
很多人以为:
输入“我”
↓
查 vocab
↓
95815其实真实世界通常复杂得多。
大致是:
原始文本
│
▼
Normalizer
│
▼
PreTokenizer
│
▼
Byte-Level
│
▼
BPE Merge
│
▼
Token
│
▼
Vocab
│
▼
Token ID也就是说:
vocab负责“这个 Token 是谁”。
merges负责“这些东西怎么合并”。
这俩不是一回事。
十一、vocab.json 是什么?
假设:
{
"hello": 123,
"world": 456,
"æĪij": 95815
}这个文件非常朴素。
就是:
Token → Token ID例如:
"hello" → 123
"world" → 456
"æĪij" → 95815所以 vocab.json 就像一本通讯录:
Token ID
----------------------
hello 123
world 456
æĪij 95815十二、那 merges.txt 又是什么?
如果 vocab 是通讯录:
æĪij → 95815那么 merges 就像:
“你们俩先处,熟了以后再合并。”
例如简化:
a b
ab c表示:
a + b → ab
ab + c → abc因此:
vocab
=
“有哪些 Token?”
merges
=
“怎么把小东西合成大 Token?”十三、tokenizer.json 又是个什么鬼?
现代 Tokenizer 经常把一大堆东西打包进:
tokenizer.json里面可能有:
tokenizer.json
│
├── model
│ ├── vocab
│ └── merges
│
├── normalizer
│
├── pre_tokenizer
│
├── post_processor
│
├── decoder
│
└── added_tokens所以:
vocab.json更像:
词典
而:
tokenizer.json更像:
整个分词器的说明书 + 词典 + 算法配置。
十四、现在重新审视我们的代码
代码:
from tokenizers import Tokenizer
tokenizer = Tokenizer.from_file("tokenizer.json")
output = tokenizer.encode("我爱你")
print(f"'我爱你' 的 Token ID 是: {output.ids}")
print(f"'我爱你' 的 Token 形态是: {output.tokens}")实际上背后发生了很多事情。
你只写了一句:
tokenizer.encode("我爱你")Tokenizer 心里已经开始忙活:
你:
我爱你
Tokenizer:
收到。
↓
先处理文本
↓
转成 UTF-8
↓
做 Byte-Level 映射
↓
执行 BPE Merge
↓
得到 Token
↓
查 Vocab
↓
得到 Token ID你:
“这么复杂?”
Tokenizer:
“不然你以为我这 248044 个词表是拿来干嘛的?”
十五、为什么一个中文字符可能就是一个 Token?
比如你的 tokenizer 中:
我最终可能就是:
Token ID = 95815这意味着:
我
↓
æĪij
↓
95815这个 Token 已经存在于词表中。
所以它可以作为一个完整 Token。
但是:
解决不一定是一个 Token。
也可能:
解 + 决或者被进一步拆分。
同样:
鸳鸯也不一定天然就是:
鸳鸯 → 一个 TokenToken 是 tokenizer 训练出来的结果。
不是汉字词典。
十六、为什么叫“子词”?
因为 Token 往往处于:
字符
↓
子词
↓
单词之间。
例如英文:
unbelievable可能拆成:
un + believe + able中文可能:
人工智能变成:
人工 + 智能甚至可能:
人 + 工 + 智 + 能完全取决于 tokenizer 的训练结果。
所以:
Token 不是“字”。
Token 也不是“词”。
Token 是 Tokenizer 眼中的基本语言单位。
十七、最后,它终于进入了模型
经过千辛万苦:
我
↓
U+6211
↓
UTF-8
↓
E6 88 91
↓
Byte-Level
↓
æĪij
↓
BPE
↓
Token
↓
vocab
↓
95815模型最终看到的是:
[95815]而不是:
我这就是 LLM 一个非常重要的事实:
Transformer 并不直接吃字符串。
它吃的是:
Token IDs然后 Token ID 再进入 Embedding:
95815
↓
Embedding Table
↓
一个高维向量
↓
Transformer于是:
95815最终会变成类似:
[0.021, -0.183, 0.772, ...]这样的向量。
到了这里:
“我”这个字,已经彻底失去了它原来的模样。
它从一个汉字:
我一路变成:
U+6211再变成:
E6 88 91再变成:
æĪij再变成:
95815最后变成:
一串几千维的浮点数然后进入 Transformer。
十八、于是我们可以画出 Token 的“前世今生”
现在终于可以给这个 Token 办一张完整的身份证了:
┌──────────────────────────────────────┐
│ Token「我」的前世今生 │
└──────────────────────────────────────┘
出生:
我
↓ Unicode
U+6211
↓ UTF-8
E6 88 91
↓ Byte
230 136 145
↓ Byte-Level Mapping
æ Ī ij
↓ BPE
æĪij
↓ Vocabulary
Token ID = 95815
↓ Embedding
[0.021, -0.183, 0.772, ...]
↓ Transformer
“开始思考人生”到这里,一个 Token 的一生基本结束了。
十九、顺便说一句:decode 就是它的“投胎”
如果说:
encode()是:
把人类语言送进模型。
那么:
decode()就是:
把模型吐出来的 Token 再还原成人类语言。
比如:
ids = [95815]
print(tokenizer.decode(ids))最终得到:
我所以:
人类语言
↓
encode()
↓
Token IDs
↓
模型
↓
Token IDs
↓
decode()
↓
人类语言这就形成了一个闭环。
二十、最容易踩的坑:千万别直接 UTF-8 encode æĪij
之前很多人看到:
æĪij第一反应是:
“这乱码,我 UTF-8 decode 一下不就回来了吗?”
于是:
"æĪij".encode("utf-8").decode("utf-8")结果:
æĪij当场沉默。
因为:
æĪij已经是 Unicode 字符了。
你再 UTF-8 encode,它得到的是:
C3 A6 C4 AA C4 B3而真正的“我”需要的是:
E6 88 91完全不是一回事。
正确方向是:
æĪij
↓
Byte-Level Decoder
↓
230 136 145
↓
E6 88 91
↓
UTF-8 decode
↓
我而不是:
æĪij
↓
UTF-8 encode
↓
C3 A6 C4 AA C4 B3这就是为什么我们需要:
byte_decoder = {
v: k
for k, v in byte_encoder.items()
}二十一、把代码完整串起来
我们可以写一个小程序,看看 Tokenizer 的“前世”。
import json
def bytes_to_unicode():
bs = list(range(ord("!"), ord("~") + 1))
bs += list(range(ord("¡"), ord("¬") + 1))
bs += list(range(ord("®"), ord("ÿ") + 1))
cs = bs[:]
n = 0
for b in range(256):
if b not in bs:
bs.append(b)
cs.append(256 + n)
n += 1
cs = [chr(n) for n in cs]
return dict(zip(bs, cs))
# 读取 tokenizer.json
with open("tokenizer.json", "r", encoding="utf-8") as f:
data = json.load(f)
vocab = data["model"]["vocab"]
print(f"length: {len(vocab)}")
# 构造 Byte-Level BPE 反向映射
byte_encoder = bytes_to_unicode()
byte_decoder = {v: k for k, v in byte_encoder.items()}
# 把 tokenizer token 还原成真实文本
def decode_token(token):
try:
byte_values = [byte_decoder[c] for c in token]
return bytes(byte_values).decode("utf-8")
except (KeyError, UnicodeDecodeError):
return token
# 构建反向词表
real_vocab = {}
for token, token_id in vocab.items():
real_text = decode_token(token)
real_vocab[real_text] = token_id
with open('vocab.json','w',encoding='utf-8') as f:
json.dump(real_vocab,f,indent=2,ensure_ascii=False)这个程序做的事情可以概括成一句话:
把 Tokenizer 原本给机器看的词表,翻译成一份尽量给人看的词表。
于是原本:
æĪij → 95815我们可以得到:
我 → 95815程序员第一次看到这里,通常会露出欣慰的笑容:
“终于能看懂了。”
然后第二天打开 merges:
“???”
二十二、真正值得记住的,不是某一个 Token ID
今天我们看到:
我 → 95815但不要把:
95815当成某种宇宙真理。
换一个模型:
我可能是:
95815也可能是:
4217甚至:
[1234, 5678]因为:
Token ID 只对当前 tokenizer 有意义。
不同模型:
模型 A
我 → 95815
模型 B
我 → 4217
模型 C
我 → 1234 + 5678都完全正常。
二十三、所以 Tokenizer 到底是什么?
现在可以给 Tokenizer 下一个比较准确的定义:
Tokenizer 是一个把人类可读文本转换为模型可处理的离散 Token ID 序列,并负责将 Token ID 序列还原为文本的系统。
它可能包含:
Normalizer
PreTokenizer
Byte Encoder
BPE
Vocabulary
Post Processor
Decoder
Special Tokens它不是一个简单的:
字符串 → 数字而是一条完整的流水线。
二十四、最终总结:一个「我」到底经历了什么?
如果有人问:
“LLM 到底怎么看懂‘我爱你’?”
你可以非常装逼地回答:
“它并没有直接看到‘我爱你’。”真正的过程大致是:
人类
│
▼
我爱你
│
▼
Unicode
│
▼
UTF-8
│
▼
Byte Sequence
│
▼
Byte-Level Mapping
│
▼
BPE
│
▼
Tokens
│
▼
Token IDs
│
▼
Embedding
│
▼
Transformer
│
▼
Next Token
│
▼
Token IDs
│
▼
Decoder
│
▼
我爱你所以,当你再次看到:
æĪij不要慌。
它不是乱码。
它只是:
“我”穿上程序员工装以后的一副模样。
而:
95815也不是“我”。
它只是:
“我”在这个 tokenizer 词典里的工号。
最后再记住这四句话:
Unicode:给字符编号
UTF-8:把 Unicode 编码成 Byte
Byte-Level BPE:把 Byte 变成适合 tokenizer 处理的符号,并通过 BPE 合并
Vocab:给最终 Token 分配 Token ID至于:
95815为什么偏偏是 95815?
这就像公司工号一样:
不是因为他叫“我”,所以叫 95815。
而是入职的时候,HR 正好给他排到了 95815。
模型本人对此表示:
“工号无所谓,反正最后都得进 Transformer 加班。”
'我爱你' 哪里来 ?
'我爱你' 的 Token ID 是: [115734]
'我爱你' 的 Token 形态是: ['æĪijçĪ±ä½ł']
from tokenizers import Tokenizer
tokenizer = Tokenizer.from_file("tokenizer.json")
# 看看 "我爱你" 被切成了什么 Token ID
output = tokenizer.encode("我爱你")
print(f"'我爱你' 的 Token ID 是: {output.ids}")
print(f"'我爱你' 的 Token 形态是: {output.tokens}")