从 Token ID 还原文本:Tokenizer、BPE 与转换表
链路上抓到一串整数时,它很可能就是 token id——大模型不认字,只认编号。要把这串编号变回人能读的文本,需要的只是一张 id 到字节 的对照表,本文称之为转换表;而这张表在不同模型之间并不通用。
本文的落点就是反查这一步。为了讲清它,前面会先把编码侧的形态走一遍(文字
→ UTF-8 字节 → token → id),中间说明 id 进 GPU
之后发生了什么——那部分只是背景,反查用不到模型内部的计算。文中数字全部来自本机真实跑出来的结果,词表取自
Qwen2.5-0.5B-Instruct 和 GPT-2 的公开 tokenizer.json。
📚 基本概念速读
| 名称 | 定义 | 记忆点 |
|---|---|---|
| token | 模型处理文本的最小单位 | 比字粗、比词细的碎片 |
| tokenizer | 负责文本与 token 互转的程序 | 编码器与解码器合体 |
| 词表(vocab) | token 与 id 的对照表 | 一本字典,行号即 id |
| BPE | Byte Pair Encoding,按频率合并相邻字节对的切分算法 | 主流词表的生成方式 |
| token id | token 在词表里的整数编号 | 模型真正看见的东西 |
| embedding | 把 id 查成向量的那张表 | 数字进入网络的入口 |
| logits | 最后一层输出的分数,长度等于词表大小 | 每个候选 token 的得分 |
| prefill / decode | 预填充(一次处理整段 prompt)与解码(逐 token 生成)两个阶段 | 一次大计算 + 多次小计算 |
| KV cache | 缓存的注意力 Key / Value 向量 | 避免每步重算整段历史 |
| 转换表 | id 到字节(或文本)的映射 | 反查文本的依据 |
🔑 全文的落点:id → 字节 → 文本
先给结论,后面都是为它铺垫:
1 | token id |
三点:
- 表是
id → bytes的映射,由词表与 bytes_to_unicode 两张表复合而成,不是 token 字符串到词。token 的边界不在字符边界上,单个 token 常常拼不出一个完整字符,只能先拼字节再统一解码。 - 反查只要这一张表,不需要 merges。merges 只在编码时决定怎么切分,解码方向用不上,表因此可以做得极小。
- 表不通用。同一个 id 在不同模型里是不同东西;手上只有一串整数时,只能拿备好的几张表逐张试,再按打分排序。
🧭 一次推理里,数据长什么样
先看全景。下面这条链路上每一层的输出形态都不一样,后面每一节都对应其中的一段:
1 | 用户输入的文字:PCIe 链路上抓到的 token id,能还原出原来的问题吗? |
能看出来整条链上的数量级变化:76 B 的编号 → 33 KiB 的向量 → 594 KiB 的分数 → 又是 4 B 的编号。抓包看到的往往是两头的小数据,中间的大块计算留在显存里。
往下按这条链路的顺序拆。前两段(文字到 id)完全在 CPU 上完成,跟模型权重无关,可以离线跑;第三段在 GPU 里;第四段又回到 CPU。
✂️ 文本怎么变成 token
为什么要切
模型的输入是一串整数,每个整数必须是词表里的合法编号,所以文本要先切成一堆片段再逐个编号。按字符切,字符集本身就有几万个,标点和大小写还得分开放;按空格切词,词表会跟着语言和领域无限膨胀,遇到没见过的词(OOV,out-of-vocabulary)就没法编码。
折中方案是子词(subword):高频片段保留成整块,低频内容退化成更小的碎片。常见做法是 BPE(Byte Pair Encoding,字节对编码)。
BPE 的直觉
BPE 原是一种数据压缩算法,思路很朴素:先从最小的单位开始,统计哪对相邻单位出现最多,就把它们合并成一个新单位,重复到词表规模达标为止。
真实词表里的 merges 列表记录了每一次合并。Qwen2.5 的
tokenizer.json 里 merges 有 151387
条,开头几条是:
1 | Ġ Ġ ← 两个单空格片段并成一个双空格片段 |
Ġ
不是乱码,它是空格(0x20)在词表里的写法,下一节解释。
BPE 生成的词表有一条清晰的账:
| 模型 | 初始字节 | merges 次数 | 词表条目 | 特殊 token | 最大 id |
|---|---|---|---|---|---|
| Qwen2.5 | 256 | 151387 | 151643 | 22(单独登记) | 151664 |
| GPT-2 | 256 | 50000 | 50257 | 1(本来就在词表里) | 50256 |
Qwen2.5 的 256 加 151387 正好等于词表条目 151643,再叠加 22 个特殊
token;GPT-2 的 50257 条里已经包含了
<|endoftext|>,所以 merges 加初始字节只有 50256。
初始单位是 256 个字节,不是字符。这是 byte-level BPE 的关键:任何文本(汉字、emoji、二进制残留)先转成 UTF-8 字节,都能被这 256 个基本单位表示,于是永远不会有 OOV,也永远能还原。
词表和 merges 是产物,编码每次都要做
词表和合并规则是一次性产物,编码是每次调用都要跑的步骤。
| 动作 | 输入 → 输出 | 什么时候做 |
|---|---|---|
| 建词表 + merges | 训练语料 → 词表与合并规则 | 一次性,训练时定死,随权重发布 |
| encode | 文本 → id | 每次新输入都跑,纯 CPU,毫秒级 |
| decode | id → 字节 → 文本 | 每次输出,可以增量做 |
编码不能只靠查表:同一个字节串在词表里可能有好几套合法切法(你好
既是一个整块 token,也能拆成几个碎片),用哪套由 merges
的合并顺序决定——BPE 是按优先级自底向上合并的,不是贪心取最长。
反过来,decode 是纯查表:id → 字节 → 拼接 → UTF-8,只需要一张 id 到字节的表。抓包反查走的正是这个方向,所以后面那张转换表不需要 merges。
从文字到字节,再到 token
这几个层次容易混在一起,拿 你好 两个字过一遍就清楚了:
1 | 你看到的文字:你好 |
同一段文字,不同模型切出来的结果差得很多:
| 模型 | token 数 | 切出来的片段 | 对应 id |
|---|---|---|---|
| Qwen2.5 | 1 | ä½łå¥½(整块 你好) |
108386 |
| GPT-2 | 4 | ä½ ł å¥ ½ |
19526 / 254 / 25001 / 121 |
Qwen2.5 词表里把 你好 当成一个整体,一个 token 装下两个字;GPT-2
词表里几乎没有中文,只能退回字节,6 个字节被切成 4 段——你 的三个字节
e4 bd a0 被拆成了 e4 bd 和 a0
两段。
三点值得记住:
- 字节层是永远有路可退的那一层。词表里没有的字符,BPE 就退回单字节表示,所以任何文本都能编码,也都能还原——后面关于可逆性的讨论都建立在这上面。
- token 数不等于字符数。同样两个汉字,Qwen2.5 用 1 个 token,GPT-2 要 4 个。
- 模型看到的只有 id。文字、字节、token 片段都是中间形态,真正进网络的是 108386 这个整数。
抓包时能看到的也是最后那一层:一串 id。要把它变回文字,靠的就是从 id 反查回字节的那张表。
Ġ 到底是什么
既然基本单位是字节,词表里就该有空格、换行这些不可打印字节。为了让词表能用纯文本保存(一个
tokenizer.json 或一份 merges 文件),GPT-2 定义了
bytes_to_unicode 映射:把 256 个字节一一对应到 256
个可见字符,空格(0x20)映射成
Ġ(U+0120),换行(0x0A)映射成
Ċ(U+010A),其余可打印字节保持原样。
这个映射是可逆的,反着查一遍就能拿回真实字节。到这里,本文说的转换表就凑齐了——它是两步复合出来的:
1 | id ──(词表)──> piece(如 Ġé)──(bytes_to_unicode 逆映射)──> 字节(b' \xe9') |
词表由模型提供,bytes_to_unicode 是固定 256 项的通用规则,所有 byte-level BPE 模型共用一份。逆映射的实现就是下面这个函数:
🔢 token 怎么变成 id
id 只是行号
词表是一份有序对照表,id
就是行号。它没有语义,也没有跨模型含义:Qwen2.5 的
53702 是 PCI,GPT-2 的 53702
是另一个完全无关的片段。同一句中文,两个模型的 token 数也差很多:
| 文本 | 字符数 | 汉字数 | Qwen2.5 token 数 | GPT-2 token 数 |
|---|---|---|---|---|
| PCIe 链路上抓到的 token id,能还原出原来的问题吗? | 32 | 16 | 19 | 44 |
| 大语言模型的推理过程分为预填充和解码两个阶段。 | 23 | 22 | 15 | 54 |
| Hello, how are you today? | 25 | 0 | 7 | 7 |
中文语料喂得多的 Qwen2.5 把常用汉字组合并进了词表(一个 token 常常覆盖两个汉字),GPT-2 词表里几乎没有中文,只能退回到字节,于是同一个汉字要 3 个 token 表示。同样一句中文,GPT-2 的 token 序列比 Qwen2.5 长出一倍以上,反查时要拼的片段也更多。
config.json 里的 vocab_size
和词表长度也不总相等:Qwen2.5-0.5B 的 config 写
151936,tokenizer.json 里实际 151665 条,多出来的行是预留
id。反查时用 id
当数组下标,预留区永远查不到东西,越界判断要用词表长度,而不是 config
的值。
特殊 token 不在 BPE 表里
Qwen2.5 有 22 个特殊 token,id 排在 151643 到 151664:
| id | 内容 | 用途 |
|---|---|---|
| 151643 | <|endoftext|> |
文本结束 |
| 151644 | <|im_start|> |
对话轮次开始 |
| 151645 | <|im_end|> |
对话轮次结束 |
它们不参与字节合并,是整串匹配的固定字符串,反向也直接映射成这串文字。做转换表时要单独处理,否则会当成
BPE token 硬拆成字节。GPT-2
只有一个(<|endoftext|>,id 50256)。
🧩 id 进 GPU 之后发生什么
查 embedding 表
GPU 拿到 id 序列后的第一件事是查表。Qwen2.5-0.5B 的 config 是
vocab_size=151936、hidden_size=896,也就是
151936 行、每行 896 个 bfloat16 数(bfloat16 是 16 位浮点数,占 2
字节):
1 | Token IDs [53702, 68, 18137, ...] |
整张表按 bfloat16 算是 259.7 MiB(151936 x 896 x 2 B),比链路上那 76
字节的 id 大了好几个数量级。这一步在实现上是
gather(按索引取数,out[i] = table[ids[i]])而不是矩阵乘法:id
就是行号,取出对应的那几行即可,不需要任何乘法。
从 id 到下一个 id
embedding 的输出是一张 n_tokens x 896 的矩阵,之后穿过 24 层 Transformer,最后一个位置的向量过 LM Head(输出层)映射到 151936 维,得到 logits——每个候选 token 一个分数。过一次 softmax 把它变成概率分布,采样器从中挑出一个 id:贪心取概率最大的那个;温度(temperature)在 softmax 之前把 logits 除以 T,T 越小分布越尖锐;top-p 只保留累计概率达到 p 的那几个候选再重新采样。这个 id 既追加回上下文继续生成,也是最终回传给主机的结果。
这些计算对本文的反查没有用:Transformer 内部不改变 id 的编码规则,那些中间状态只存在于显存里,链路上也看不到。这里提它,只是为了说明后面那些数据量级是怎么来的。
一处与词表相关的细节值得一提:Qwen2.5-0.5B 的 config 里
tie_word_embeddings = true,也就是输出层和输入 embedding
表共用同一份权重——模型读问题用的表和写答案用的表是同一张。
prefill 与 decode:两种流量形态
| 阶段 | 算什么 | 每轮搬多少 id | 显存里新增什么 |
|---|---|---|---|
| prefill(预填充) | 一次性处理整段 prompt | 一次搬 n 个(prompt 长度) | n 个位置的 KV cache |
| decode(解码) | 每次只算一个新 token | 每轮 1 个 | 1 个位置的 KV cache |
两阶段的差别根源在 KV cache:注意力算出来的 K 和 V 可以缓存下来,下一轮直接复用,不必每生成一个 token 就把整段历史重算一遍。代价是显存,Qwen2.5-0.5B 每 token 12.0 KiB(24 层 x 2 x 2 个 KV 头 x 64 维 x 2 字节),4k 上下文约 48 MiB。
1 | prefill |
从抓包角度看,这个区别意味着:
- 权重在模型加载阶段一次性搬完(0.5B 参数按 bfloat16 算约 1 GB),之后推理期不再搬。
- prompt 的 id 在 prefill 阶段一次性上行,长度就是 token 数乘以每个 id 的字节数。
- 生成的 id 在 decode 阶段一个个下行。每步回传的是采样后的 id,还是整整 151936 维的 logits(约 600 KB),取决于采样放在哪一侧——放在 GPU 上就只回传 4 字节。
- KV cache 通常只待在显存里;一旦出现 CPU offload、KV cache swapping 这类显存不够的兜底策略,链路上就会出现周期性的 KV cache 搬运流量。
这些数据在链路上长什么样
一次推理真正跨 PCIe 的部分只有两块:主机把 id 送上去,GPU 把结果送回来;权重在加载阶段搬一次(0.5B bf16 约 1 GB),KV cache 平时不出显存。
按开头那句
PCIe 链路上抓到的 token id,能还原出原来的问题吗?(19 个
token),尺寸变化汇总如下:
| 阶段 | 形态 | 大小 |
|---|---|---|
| token id | 19 个 int32 | 76 B |
| embedding | 19 x 896 bf16 | 33 KiB |
| 每层 hidden | 19 x 896 bf16 | 33 KiB |
| logits | 151936 个 fp32 | 594 KiB |
| 采样结果 | 1 个 int32 | 4 B |
抓包时可以对着这几行找:链路上出现几十字节的小传输、数值范围在几万以内,多半是 id 序列;出现五百多 KiB 的整块传输,可能是 logits 回传;上百 MB 到 1 GB 级别的块,则是模型权重或 KV cache 的搬运。
🔁 id 怎么回到文本
解码是编码的逆过程,只有三步,但顺序不能错:
1 | 1. 每个 id 查表得到一段字节 |
真实数据验证一遍:
1 | enc = tok.encode("PCIe 链路上抓到的 token id,能还原出原来的问题吗?") |
用 tok.decode()
一步到位当然方便,但抓包反查时必须自己实现这三步:手上有 id,没有那个
tokenizer 对象,只有一张表。
顺便记一条自测习惯:任何转换表到手,先拿已知文本走一遍往返(encode → id → decode),能复原才说明表是全的。真实词表的往返结果:
1 | 拼接字节 = b'PCIe \xe9\x93\xbe\xe8\xb7\xaf\xe4\xb8\x8a\xe6\x8a\x93\xe5\x88\xb0\xe7\x9a\x84 token id...' |
📋 三张主要的词表格式
三种流派
| 流派 | 代表模型 | 词表文件 | 空格表示 | 可逆性 |
|---|---|---|---|---|
| HF tokenizers | Qwen2.5、Llama 3 | tokenizer.json(JSON) |
Ġ(byte-level) |
完全可逆 |
| sentencepiece | T5、Llama 2 | tokenizer.model(protobuf) |
▁(U+2581) |
基本可逆 |
| tiktoken | GPT-3.5 / GPT-4 系 | .tiktoken(纯文本) |
Ġ(byte-level) |
完全可逆 |
| WordPiece | BERT | vocab.txt(纯文本) |
空格在切分时就被丢掉,## 前缀表续接 |
有损 |
前面三种都属于子词方案(BPE 或与之相近的 unigram 切分),差别主要在文件格式和空格记号。WordPiece 是另一支,它的 normalizer 常常顺手做小写化、去重音、按标点切分,原文的大小写和空白在编码时就丢了,这类表只能反查出规范化之后的近似文本。
真实文件长什么样
tiktoken 的 .tiktoken 一行一条,格式是 base64
加序号(rank)。cl100k_base 的前几行:
1 | IQ== 0 ← base64 解出来是 b'!' |
文件共 100256 行,前 256 行是全部单字节 token(0x00 到 0xFF 各出现一次),和前面 256 个基本单位的说法对上。
sentencepiece 的 tokenizer.model 是 protobuf
二进制,解析出来每条 piece 带三样东西:片段、分数(log
概率)、类型:
1 | piece='<pad>' score= 0.0000 type=CONTROL |
T5 的词表里有 21987 条 piece 含
▁。反查时把它换回空格即可,另外要留意 ▁
出现在片段开头还是结尾、以及有没有 byte fallback 的
<0xE9> 形式——后者表示这个片段就是单个字节 0xE9。
HF(HuggingFace)的 tokenizer.json 最省事,一份 JSON
把整条流水线都写全了。Qwen2.5 的顶层结构:
1 | normalizer : NFC ← 先做 Unicode 规范化 |
三种格式各写一个读取函数,出口统一是 {id: bytes}。HF
那支最简单:
1 | import json |
.tiktoken 多一步 base64 解码;sentencepiece 那支要解析
protobuf 的 pieces 字段,把 ▁ 换回空格,byte
fallback 类型(<0xE9>)的 id
直接映射成字节。做完这一步,三张表在代码里就完全一样了。
表的规模心里有个数:Qwen2.5 的 151665 条映射总共约 976 KB 字节内容,平均每个 token 6.44 字节;GPT-2 的 50257 条约 321 KB。挂到抓包工具里做多模型试解,内存开销可以忽略。
🎯 落到抓包场景:一串 id 反查文本
前置假设:链路(PCIe)上传的是整数 id 序列,不是已经渲染好的文本。这个假设待验证,但值得作为基线——如果成立,反查的全部工作量就是本文讲的转换表。
试解流程
1 | 1. 从 DMA 数据里切出整数数组(4 字节 int32 还是 8 字节 int64,两种都要试) |
打分器先只用最朴素的两条规则:越界 id 有几个、解出的文本里有多少替换字符。
1 | def try_decode(ids, table): |
真实试解结果
用两张表互相当作错误答案,跑出来是这样(待解的都是开头那句
PCIe 链路上抓到的 token id,能还原出原来的问题吗?
的编码结果,Qwen2.5 切出 19 个 id,GPT-2 切出 44 个):
| 抓到的 id 流 | 用 Qwen2.5 表解 | 用 GPT-2 表解 |
|---|---|---|
| Qwen2.5 编码的 19 个 id | ✅ 越界 0/19,替换字符 0 →
PCIe 链路上抓到的 token id,能还原出原 |
❌ 越界 7/19,替换字符 2 →
?euffle��??ivation PH real |
| GPT-2 编码的 44 个 id | ❌ 越界 0/44,替换字符 11 →
machineIe.div��路Dock抓 acc |
✅ 越界 0/44,替换字符 0 →
PCIe 链路上抓到的 token id,能还原出原 |
几点值得留意:
- 越界数是最硬的信号。Qwen2.5 的 id 拿去查 GPT-2 表,19 个里有 7 个超出 GPT-2 词表范围,直接排除。
- 替换字符数是第二硬的信号。GPT-2 的 44 个 id 拿去查
Qwen2.5 表,全部落在范围内,但解出的字节拼不成合法 UTF-8,出现 11 个
�。 - 两个信号都会失灵。GPT-2 id 流用 Qwen2.5
表解出来的片段里,
路和抓是真汉字——字节巧合拼成了合法字符。所以还需要第三层判断,比如统计可打印字符比例、中文字符比例,或者更进一步,把解出的文本丢给一个小语言模型算困惑度。
从 DMA 数据里切出 id 序列
如果链路传的是裸整数数组,切分很直接:
1 | import struct |
开头那句
PCIe 链路上抓到的 token id,能还原出原来的问题吗? 用
Qwen2.5 编码得到 19 个 id,序列化后是 76 字节,开头几个是
c6 d1 00 00 44 00 00 00 d9 46 00 00。但实际抓包更可能遇到:整数混在请求结构体里(带
batch 维度、长度字段、padding),或者一批 id 被拆成多个 DMA
包出现。切分逻辑要按框架的实际结构来,先拿已知文本做一次抓包对照,比盲试靠谱。
另外,链路如果有加密(PCIe IDE,或 ccAI 那类密文通道),抓到的就是高熵密文,id 结构完全看不出来,得先解决解密或走侧信道的路子——那是另一个话题了。
⚠️ 容易踩的坑
| 误区 | 正解 |
|---|---|
| 以为 token 就是词 | 一个汉字可能占 1~3 个 token,一个英文词可能被切成好几段 |
| 逐个 token 解码再拼字符串 | token 边界不在字符边界上,必须先拼字节再做一次 UTF-8 解码 |
| 以为所有模型共用一张表 | id 只是行号,Qwen2.5 的 53702 和 GPT-2 的 53702 毫无关系 |
把 config.json 的 vocab_size
当词表长度 |
Qwen2.5-0.5B 的 config 写 151936,tokenizer.json 实际
151665 条 |
| 认为 UTF-8 解得出就是解对了 | 拿错表也能拼出合法 UTF-8,需要越界数、替换字符、语言模型多层打分 |
| 以为 token 数约等于字符数 | 同一句中文(PCIe 链路上抓到的 token id,能还原出原来的问题吗?),Qwen2.5
切出 19 个 token,GPT-2 切出 44 个 |
| 忽略特殊 token | 它们不在 BPE 表里,<|im_start|>
这类要单独建映射 |
| 只看 id 数值判断模型 | 词表大小相近的模型会互相吻合一部分,要靠整串打分 |
📌 小结
| 概念 | 要点 |
|---|---|
| 切分与 id | byte-level BPE 从 256 个字节起步按频率合并;id 是词表行号,跨模型无语义 |
| GPU 侧 | id 查 embedding 变向量,前向出 logits,采样得下一个 id;prefill 一次吃整段 prompt,decode 每步一个 |
| 解码 | id → 字节 → 拼接 → UTF-8,三步顺序不能变 |
| 转换表与反查 | id → bytes 的映射(三种格式读进来统一成同一个函数),多表试解 + 打分排序 |
下一步可以顺着两个方向推进:一是把常见模型的词表都读成同一份
id -> bytes
表结构,用已知文本做一轮往返自测,把转换表这块地基打实;二是把打分器做实,让一串来路不明的整数能自动判出属于哪个模型。抓包侧的前置知识则在《PCIe TLP
解析入门》里。
Happy Hacking! 🎉