从 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
2
3
4
5
6
7
token id
↓ 查表:一个 id 对应一段字节
字节
↓ 按顺序拼接
字节池
↓ 整段做一次 UTF-8 解码
文本

三点:

  • 表是 id → bytes 的映射,由词表与 bytes_to_unicode 两张表复合而成,不是 token 字符串到词。token 的边界不在字符边界上,单个 token 常常拼不出一个完整字符,只能先拼字节再统一解码。
  • 反查只要这一张表,不需要 merges。merges 只在编码时决定怎么切分,解码方向用不上,表因此可以做得极小。
  • 表不通用。同一个 id 在不同模型里是不同东西;手上只有一串整数时,只能拿备好的几张表逐张试,再按打分排序。

🧭 一次推理里,数据长什么样

先看全景。下面这条链路上每一层的输出形态都不一样,后面每一节都对应其中的一段:

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
用户输入的文字:PCIe 链路上抓到的 token id,能还原出原来的问题吗?
↓ UTF-8 编码(32 个字符 → 68 字节)
字节
↓ tokenizer.encode():BPE 合并 + 查表
┌──────────────┐
│ Token IDs │ ← 19 个整数,4 字节一个,共 76 B
└──────┬───────┘
↓ PCIe / DMA
┌──────────────┐
│ Embedding │ ← 查表得到 19 x 896 的向量,bf16 共 33 KiB
└──────┬───────┘
↓
┌──────────────┐
│ Transformer │ ← 24 层,注意力 + FFN 反复变换这些向量
└──────┬───────┘
↓
┌──────────────┐
│ LM Head │ ← 896 维映射到 151936 维
└──────┬───────┘
↓ softmax
┌──────────────┐
│ Logits │ ← 151936 个数,fp32 约 594 KiB
└──────┬───────┘
↓ 采样
┌──────────────┐
│ Next Token │ ← 1 个 id,4 B
└──────┬───────┘
↓ 追加进上下文,回到 Embedding 继续下一轮
...(循环到停止条件)
↓ tokenizer.decode():id → 字节 → 拼接 → UTF-8
回答的文字

能看出来整条链上的数量级变化: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
2
3
4
Ġ Ġ        ← 两个单空格片段并成一个双空格片段
ĠĠ ĠĠ ← 两个双空格片段并成一个四空格片段
i n ← i 和 n 并成 in
Ġ t ← 空格和 t 并成一个带前导空格的片段

Ġ 不是乱码,它是空格(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
2
3
4
5
6
7
8
9
你看到的文字:你好
↓ UTF-8 编码,一个汉字 3 个字节(统一标准)
字节:e4 bd a0 e5 a5 bd
↓ byte-level BPE 按频率合并字节对(取决于 Tokenizer)
token:ä½łå¥½ ← 词表里正好有这一整块
↓ 查词表
token id:108386
↓
模型真正看到的:108386

同一段文字,不同模型切出来的结果差得很多:

模型 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
2
3
4
5
6
7
8
9
10
11
12
Token IDs  [53702, 68, 18137, ...]
│
│ 每个 id 当行号,取出表里对应的一行
↓
┌────────────────────────────────┐
│ Embedding Table 151936 x 896 │ ← 权重的一部分,加载时一次性搬进显存
│ row 53702 -> vector 0 │
│ row 68 -> vector 1 │
│ row 18137 -> vector 2 │
└───────────────┬────────────────┘
↓
19 x 896 矩阵

整张表按 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
prefill
┌────────────────────────┐
│ prompt: A B C D E │ ← 5 个位置并行算完
└───────────┬────────────┘
↓
┌────────────────────────┐
│ KV cache: A B C D E │ ← 每层的 K/V 写进显存
└───────────┬────────────┘
↓ 取最后一个位置
logits → 采样 → F

decode
┌────────────────────────┐
│ KV cache: A B C D E F │ ← 读缓存,只算 F 这一个位置
└───────────┬────────────┘
↓
logits → 采样 → G

从抓包角度看,这个区别意味着:

  • 权重在模型加载阶段一次性搬完(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
2
3
1. 每个 id 查表得到一段字节
2. 把所有字节按顺序拼成一个 bytearray
3. 对整个 bytearray 做一次 UTF-8 解码

真实数据验证一遍:

1
2
3
4
5
6
7
8
9
enc = tok.encode("PCIe 链路上抓到的 token id,能还原出原来的问题吗?")

# 逐 token 解码:中间三个字节碎片会变成替换字符
[tok.decode([i]) for i in enc.ids[:5]]
# ['PCI', 'e', ' �', '�', '�']

# 拼接后统一解码:一个字都不缺
tok.decode(enc.ids)
# 'PCIe 链路上抓到的 token id,能还原出原来的问题吗?'

用 tok.decode() 一步到位当然方便,但抓包反查时必须自己实现这三步:手上有 id,没有那个 tokenizer 对象,只有一张表。

顺便记一条自测习惯:任何转换表到手,先拿已知文本走一遍往返(encode → id → decode),能复原才说明表是全的。真实词表的往返结果:

1
2
3
拼接字节 = b'PCIe \xe9\x93\xbe\xe8\xb7\xaf\xe4\xb8\x8a\xe6\x8a\x93\xe5\x88\xb0\xe7\x9a\x84 token id...'
UTF-8 解码 = 'PCIe 链路上抓到的 token id,能还原出原来的问题吗?'
往返一致 = True

📋 三张主要的词表格式

三种流派

流派 代表模型 词表文件 空格表示 可逆性
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
2
3
4
IQ== 0      ← base64 解出来是 b'!'
Ig== 1 ← b'"'
Iw== 2 ← b'#'
JA== 3 ← b'$'

文件共 100256 行,前 256 行是全部单字节 token(0x00 到 0xFF 各出现一次),和前面 256 个基本单位的说法对上。

sentencepiece 的 tokenizer.model 是 protobuf 二进制,解析出来每条 piece 带三样东西:片段、分数(log 概率)、类型:

1
2
3
4
piece='<pad>'   score= 0.0000  type=CONTROL
piece='</s>' score= 0.0000 type=CONTROL
piece='▁' score=-2.0123 type=NORMAL ← 空格本身也是一个 piece
piece='X' score=-2.4865 type=NORMAL

T5 的词表里有 21987 条 piece 含 ▁。反查时把它换回空格即可,另外要留意 ▁ 出现在片段开头还是结尾、以及有没有 byte fallback 的 <0xE9> 形式——后者表示这个片段就是单个字节 0xE9。

HF(HuggingFace)的 tokenizer.json 最省事,一份 JSON 把整条流水线都写全了。Qwen2.5 的顶层结构:

1
2
3
4
5
6
normalizer     : NFC                    ← 先做 Unicode 规范化
pre_tokenizer : Split(正则) + ByteLevel ← 按 GPT-2 正则预切分,再转字节
model : BPE(vocab 151643 + merges 151387)
post_processor : ByteLevel
decoder : ByteLevel
added_tokens : 22 个特殊 token

三种格式各写一个读取函数,出口统一是 {id: bytes}。HF 那支最简单:

1
2
3
4
5
6
import json

def load_hf_table(path):
"""tokenizer.json -> {id: bytes},特殊 token 天然不在 model.vocab 里。"""
raw = json.load(open(path))
return {tid: token_bytes(piece) for piece, tid in raw["model"]["vocab"].items()}

.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
2
3
4
1. 从 DMA 数据里切出整数数组(4 字节 int32 还是 8 字节 int64,两种都要试)
2. 对每张转换表调一次 decode
3. 给每次结果打分,排序
4. 分数最高的那张表就是最可能的模型

打分器先只用最朴素的两条规则:越界 id 有几个、解出的文本里有多少替换字符。

1
2
3
4
5
6
7
8
9
10
11
def try_decode(ids, table):
out, miss = bytearray(), 0
for i in ids:
b = table.get(i)
if b is None:
miss += 1
out += b"?"
else:
out += b
text = out.decode("utf-8", errors="replace")
return text, {"越界 id": miss, "替换字符": text.count("\ufffd")}

真实试解结果

用两张表互相当作错误答案,跑出来是这样(待解的都是开头那句 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
2
3
4
5
import struct

buf = struct.pack("<%dI" % len(ids), *ids) # 写:int32 小端
back = list(struct.unpack("<%dI" % (len(buf) // 4), buf))
assert back == ids

开头那句 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! 🎉