词表不只决定切几刀:模型成本、多模态与分词边界
同一段文字,换一套分词器,Token 数可能变少。接下来很容易连着作出三个推断:词表越大越省计算;更省 Token 的分词器可以直接替换;模型既然能读图片,也就能生成图片。
这三个推断都跳过了中间的接口。词表决定哪些字节对应哪个 ID;模型已经把这些 ID 与参数行连在一起;进入 Transformer 的则是一串向量位置。 改词表、改位置数、改表示方式,影响的是不同的部分。
先记住三个量:V 是词表大小,T 是这次输入的序列长度,D 是每个位置的向量维度。文本模型的主干是:
文本 → Token ID → 查 V×D 的向量表 → T×D 的输入 → Transformer → 上下文向量 → 对 V 个候选打分 → 选择下一 ID从 Token ID 到下一 Token展开了这条主干的数值计算。这里先保留它的完整接口图,再看改变词表和边界会发生什么。
展开接口总览:Tokenizer 怎样接上 GPT
词表翻倍,先算参数,再算计算
一张 V×D 的表有多少参数
Token embedding 的每一行对应一个 ID,每一行有 D 个可学习数值,所以参数量是 VD。
| 教学设置 | V | D | Token embedding 参数 |
|---|---|---|---|
| A | 25,000 | 768 | 19,200,000 |
| B | 50,000 | 768 | 38,400,000 |
38,400,000 / 19,200,000 = 2,因此 B 是 A 的 200%,增加了 100%。这两句话的差别是分子用 B,还是用 B−A。
GPT-2 124M 的实际尺寸为 50,257×768,单张 Token embedding 有 38,597,376 个参数;这笔账不包含位置表和 Transformer Blocks。
输入、输出若共享权重,独立参数只算一张 V×D 表;若不共享,两端约为 2VD,输出偏置若存在还要加 V。nanoGPT 的 model.py 将 wte.weight 与 lm_head.weight 绑定,就是共享的情况。
共享省下一份参数,输出计算还在:输入只取当前 ID 对应的行,输出仍要让上下文向量与各候选权重做点积。新增 ΔV 个 Token 时,这部分参数增量分别约为 ΔVD 或 2ΔVD;位置表不会跟着自动变大。
T 变短,为什么有一部分反而更贵
设同一份文本的序列长度从 1,000 缩为 700,词表同时翻倍,D 不变。这个例子是明确的教学假设,不是两个真实模型的跑分。
看普通密集注意力的两次配对矩阵乘法:
Q: T×D,Kᵀ: D×T → QKᵀ: T×T每个位置对有一个 D 维点积,量级为 T²D
注意力权重: T×T,Value: T×D → 输出: T×D同样是 T²D 量级而全位置输出层是:
H: T×D,W_headᵀ: D×V → logits: T×V共有 T×V 个点积,每个长度为 D,量级为 TVD由此得到三笔不同的账:
| 部分 | B / A | 变化 |
|---|---|---|
| Token embedding 参数 VD | 2×1 = 2 | 增加 100% |
| 注意力配对的 T²D 部分 | 0.7²×1 = 0.49 | 减少 51% |
| 全位置输出的 TVD 部分 | 0.7×2×1 = 1.4 | 增加 40% |
这些比例描述训练或 prefill 中相应运算的量级。Attention 的投影、MLP、层数、批量、硬件与实现都还没合进来,不能据此直接预测总耗时。某些生成实现的 prefill 只计算最后位置的输出分数,此时也不能把全位置的 TVD 原样套上去。
带 KV cache 的逐 Token 解码又是另一笔账:新 query 与缓存的历史 key 配对,不再为所有历史位置重做整张 T×T 矩阵;单步输出层处理一个新位置,主要乘法量级是 VD。
更大的词表往往能覆盖更长片段,但切分还取决于训练语料、算法和规则,不能保证每条输入都更短。固定 Token 上限下,切分紧凑通常能装下更多文字;这也没有直接证明模型理解能力更强。
增加一个 Token,需要把哪些东西一起改
已有模型的 ID 是有含义的。假设分词器 A 把 apple 编为 100,B 把 banana 编为 100。只把 A 换成 B,模型看到 banana 仍会读出原来随 apple 训练的 E[100];输出 100 时又会被解码成 banana。两套词表大小相同,不代表编号兼容。
如果确实需要扩词表,流程要接完整:
- 在 tokenizer 中注册新词条,保留已有 ID 的含义。
- 扩大输入 embedding 和必要的输出层,正确处理权重共享与偏置。
- 初始化新增参数行。
- 用会实际出现这些词条的数据继续训练,并确认相关参数能够更新。
- 检查新词条的输入、输出行为及旧任务效果,同时保存 tokenizer、权重和配置。
Transformers 的扩表接口接收的是扩展后的总词表大小。以下是接口示意,尚不包含训练:Tokenizer 文档。
tokenizer.add_tokens(["<new_domain_term>"])model.resize_token_embeddings(len(tokenizer))# 接下来仍需训练、验证,并保存配套的 tokenizer 与模型。新增向量可以随机初始化,也可以从已有词片向量构造起点,具体由实现决定。初始化完成,只说明“现在有这一行”,不说明模型已学会怎样使用它。冻结大部分参数的微调方案也要检查新输入行和输出参数是否确实可训练。
这不是所有微调任务都要走的一步。很多任务直接沿用原 tokenizer。反过来,保留旧 ID 也不保证旧文本切分完全不变:新增可匹配词条后,原来的几个片段可能改用一个新 Token。
用一个维度示例说明扩表:设旧表 E 为 20×4,以旧行均值作为新行的初始值,追加后得到 E′。
旧表 E: 20×4新增一行: 1×4扩展表 E′: 21×4,前 20 行仍为 E
H:1×7×4 → H @ E′ᵀ:1×7×21输入表增加一行,共享输出也就增加一个候选。这只是参数与形状的关系;新词条的使用方式仍需通过训练学习。
图像进入模型,不一定先领一个文字 ID
文本用 ID 查 embedding,最后得到向量。图像也可以从向量序列这一层接入。这里要区分两条路线。
连续视觉特征:增加的是序列位置
图片 → 视觉编码器 → 视觉特征 → 投影到 LLM 维度 ↓文字 → Token ID → 文本向量 ────────→ 组合序列 → LLM用一组假设的维度理解这个接口:批量 B=1,文字词表 V=20,LLM 维度 D=4。
| 对象 | 形状 | 含义 |
|---|---|---|
| 三个文字 ID 查表 | [1,3,4] | 3 个位置,每个 4 维 |
| 四个视觉特征 | [1,4,3] | 4 个位置,每个 3 维 |
| 视觉投影矩阵 | [3,4] | 将特征维度接到 LLM |
| 投影后的视觉向量 | [1,4,4] | 4 个位置,每个 4 维 |
| 沿位置维拼接 | [1,7,4] | 3+4 个位置,每个 4 维 |
T 从 3 变为 7,文字词表仍是 20 项。此时“视觉 Token”指视觉向量的一个位置,不一定对应文字词表中的整数 ID。图像边界标记是否另加特殊 ID,是另一件事。
这组形状关系说明向量怎样接入模型;尺寸接得上只是接口条件,视觉和语言语义的对齐还需要训练。
离散视觉 ID:每个位置查一个码本
另一条路线先让编码器产生局部潜在向量,再用有限的码本量化。设四行码本是:
ID 0 → (0,0) ID 1 → (1,0)ID 2 → (0,1) ID 3 → (1,1)对 x=(0.9,0.2),选平方欧氏距离最小的那一行:
到 ID 0:(0.9−0)² + (0.2−0)² = 0.85到 ID 1:(0.9−1)² + (0.2−0)² = 0.05 ← 最小到 ID 2:(0.9−0)² + (0.2−1)² = 1.45到 ID 3:(0.9−1)² + (0.2−1)² = 0.65所以选 ID 1;另一个位置 (0.1,0.8) 选 ID 2。这个数值例子说明最近邻的选择规则;真实模型中编码器、码本和解码器还需要训练。
“有限的码怎么表示复杂图片”的关键在于一张图有很多位置。不是一行码本存一张整图,而是每个位置选一个码。K 个码、N 个位置最多有 Kᴺ 种排列;这是组合上限,不代表每种排列都是有效图片,也不代表无损。
量化会将不同向量映到同一个代表值,丢掉一些信息。连续表示也可能经过压缩,不能把“连续”等同于天然无损。
能读图,与能画图之间还差什么
离散生成的一种做法是自回归预测视觉 ID,再查码本、通过图像解码器得到像素。连续潜在表示的一种做法是由条件输入和噪声开始,经扩散或流模型生成视觉潜变量,再解码为像素。
因此,一个能接收图片的语言模型,仍需要适合图像生成的输出表示、模块和训练,才能画图。现有文本 lm_head 不会自动承担这个职责。
公开实例也说明两条路线可以在一个系统中并存:Janus §3.1将理解与生成的视觉编码分开,理解用连续视觉特征,生成用 VQ 离散 ID,并设独立的图像预测头。Qwen3-VL 技术报告则描述了视觉编码器、视觉语言连接模块和 LLM 的组合。这些是架构实例,不是不同路线的占比统计。
音频也可使用连续特征或离散 codec 码,视频还要组织时间上的帧与特征。这里先定位表示接口,视觉编码器、量化训练和生成模型的完整推导另需展开。
一个位置未必是一个字符、一个数位或一个空格
以下切分示例采用 tiktoken 0.14.0 的 GPT-2 与 cl100k_base 配置;␠ 只用于显示普通空格,输入并没有这个符号。
字母被装在词片里面
cl100k_base:strawberry → str | aw | berry → [496,675,15717]␠strawberry → ␠strawberry → [73700]一个 Token 可以压进多个字符,前缀空格还会改变分块。按 Token 个数直接数词内字母当然不行;这并不等于模型绝不可能学会拼写。
需要逐字符操作时,可以显式展开成 s t r a w b e r r y,让待处理单位更清楚。这里数出 3 个 r 是可检查的字符计算,没有据此评测 LLM 成功率。要求确定结果时,text.count("r") 或字符串反转程序更直接。
三位数字一组,未必对齐个位
| 输入 | GPT-2 | cl100k_base |
|---|---|---|
1234 | 12 · 34 | 123 · 4 |
123456 | 123 · 456 | 123 · 456 |
9123456 | 9 · 123 · 456 | 912 · 345 · 6 |
cl100k_base 的预切分限制数字块长度为 1~3 位,块内仍由 BPE 决定怎样合并。左侧加一个 9,后面的边界就可能一起移动;它不是从个位起固定分组的十进制表示。
逐位切分可以让数字单位更规则,但会增加一些序列长度,也没有自动教会进位、乘法或多步推导。对已经训练好的模型,不能单独换掉数字切分规则而不做适配。
四个空格要放回代码上下文看
孤立的四个普通空格,GPT-2 使用 4 个 Token,cl100k_base 使用 1 个。再看没有末尾换行的完整字符串:
def f(): return 1GPT-2 为 9 个,cl100k_base 为 7 个。后者在这里把前三个空格合为一项,第四个空格与 return 合并。孤立四空格的计数,不能机械地搬到所有代码中。
普通词片和特殊 Token 也要分清。名字奇怪不代表控制项;控制项需要在 tokenizer / 协议里注册,并按相应策略处理。具体机制见规则恢复与特殊 Token。
文本在边界处断开,ID 却不一定能直接拼接
两段分别编码再拼接,不一定等于整体编码:
encode('Question: ') = [14924,25,220]encode('hello') = [15339]两段 ID 拼接 = [14924,25,220,15339]encode('Question: hello') = [14924,25,24748]两条序列都解码成 Question: hello。区别在尾部:前者是 ␠ | hello,后者是一项 ␠hello。整体编码能考虑跨拼接边界的合并;普通自回归生成只追加新 ID,不会自动撤回末尾空格再合并。
类似地,encode('hel')=[50222],encode('lo')=[385],拼起来能解码为 hello,但 encode('hello')=[15339]。所谓“部分 Token”,指文本末尾是某个更长词条的字节前缀;50222 自身是合法、完整的 ID。
训练时若先编码完整文本,再在 ID 序列上切窗口,窗口不会把 hello 的单个 ID 自动拆为 hel 与 lo。当然,训练也可能包含本来就截断的文本或专门的补全数据,模型也可能泛化。这里解释的是一种边界分布差异,不是说未写完的输入必定无法推理。
用户消息是否完整、Token 边界是否稳定、应用是否要做自动补全,是三件不同的事。
Token Healing:退一个 ID,保留全部已输入字节
下面演示一种明确的版本:回退最后一个 Token,对重新选择的下一 Token 施加严格字节前缀约束。 原输入为 Say hel,cl100k_base 编码为 [46864,11591],即 Say | ␠hel。
动画中的 ID 与前缀候选来自实际词表;logits 全是手工设置,没有调用语言模型。
先找“允许谁”,再由分数决定“选谁”
回退末尾 ID 11591 后,模型上下文只剩 [46864],对应 Say。撤回词条的原始字节 20 68 65 6c,也就是 ␠hel,另存为 held。
合法集合是所有字节以 held 开头的普通词表项:
C = { id | token_bytes(id).startswith(held) }本词表中有 24 个,包括原来的 ␠hel。原词条仍可被选中,因此这条规则不强制一定延长。
| 词条 | ID | 属于 C | 手工 logit |
|---|---|---|---|
␠world | 1917 | 否 | 10 |
␠hello | 24748 | 是 | 6 |
␠help | 1520 | 是 | 4 |
␠helper | 13438 | 是 | 3 |
␠hel | 11591 | 是 | 1 |
其余普通词表项的手工分数统一为 −20。查词表回答合法性,不使用上下文偏好来选最终词条;模型前向才根据缩短后的上下文,给全词表候选打分。两步职责不同。
原始最高分不合法,就必须被遮住
设原分数为 z,遮罩后的分数为 z′:
id ∈ C:z′[id] = z[id]id ∉ C:z′[id] = −∞world=10 虽然最高,却违反前缀约束。遮罩后它的 softmax 概率为 0。合法项中 hello 的 6 最大,所以本例贪心选 ID 24748。
若改成概率采样,要在遮罩之后归一化。以 hello 的分数为稳定性基准,分母为:
1 + exp(−2) + exp(−3) + exp(−5) + 20×exp(−26)因此 hello 约占 83.90%,help 约 11.35%,helper 约 4.18%,hel 约 0.57%;其他 20 个合法词条还有极小的非零概率。采样和贪心可以给出不同结果,但严格遮罩下都只能选 C 中的 ID。
内部换了 ID,界面只多出 lo
回退后上下文:[46864]选择的新 ID:24748 → 字节 ' hello'新的内部历史:[46864,24748]完整解码: Say hello原有显示: Say hel新增显示: lo新词条字节先覆盖保存的 ␠hel 前缀,只把剩余的 lo 交给显示层。没有重复输出 hello,也没有改写用户已经输入的内容。一般 UTF-8 情况要用增量解码器缓存尚未组成完整字符的剩余字节;此例 lo 本身可以完整解码。
把规则写成伪代码,可以看到每一步传递的对象:
context = 原 ID 序列去掉最后一项held = 最后一个词条的原始字节C = 所有以 held 为字节前缀的词条 ID
z = 根据 context 得到的全词表分数z′[id] = z[id](id 属于 C);否则为 −∞selected = argmax(z′)
replacement = selected 对应的词条字节新增显示字节 = replacement 去掉 held 前缀新的内部历史 = context + [selected]上面用假设分数说明选择规则。真实系统中,logits 应来自回退后上下文的模型前向;若维护 KV cache,也要把被撤回的位置从有效缓存历史中去掉。
24 个候选,为什么另一个接口返回 182 条
tiktoken 0.14.0 的 encode_with_unstable('Say hel') 返回稳定部分 [46864] 和 182 条完成序列。24 数的是一个普通词条能否覆盖 held;182 数的是可能包含多个 Token 的完成序列,二者统计对象不同。它们都不是模型概率。
版本和实现也要写清:Transformers 4.57.6 generation/utils.py::heal_tokens 先对文本执行 strip(),再查询词条前缀,并用 sequence_bias 给相关候选加有限分数。有限加分不会让其他候选概率严格归零,不能等同于上面的 −∞ 遮罩。
一次回退一个 Token 也没有解决所有复杂边界;更一般的约束需要维护跨 Token 的前缀状态。Token Healing 是一种可选的续写边界处理,不保证内容正确,也不保证后续整个 ID 序列永远等于全文重新编码。
词表里有这一项,不代表模型会稳定地复述它
历史上的 SolidGoldMagikarp 研究报告记录过异常复述等行为。看这个词条的切分时,不能省略前缀空格:
| 文本 | GPT-2 ID | 切分 |
|---|---|---|
SolidGoldMagikarp | [46933,42202] | Solid · GoldMagikarp |
␠SolidGoldMagikarp | [43453] | 带空格的整项 |
同一个带空格字符串在 cl100k_base 中为 5 项:[22925,26509,34015,1609,8035]。这些都是普通词表项,不是特殊控制 Token。
decode(指定 ID) 是按表还原;让模型复述字符串,需要模型选出正确的 ID 序列。能确定性解码,不保证模型一定选对。词表与模型训练语料的覆盖差异是一种相关解释,不能在没有更多证据时当成唯一原因。历史报告本身也记录了行为变化,不能把当时的现象直接推广到其他版本或当前模型。
“没作为输入出现,所以那行没有梯度”也不成立
设共享表的第 j 行是 E[j],输出 z[j]=h·E[j],目标为 y。softmax 交叉熵可写为:
L = −z[y] + log Σₖ exp(z[k])∂L/∂z[j] = p[j] − 1[j=y]∂L/∂E[j](输出路径) = (p[j] − 1[j=y]) · h即使 j≠y,仍有 p[j]·h 这条梯度。输入输出共享时,它直接流回同一张 embedding 表。
设共享表有三行二维向量,输入只取 ID 0,目标是 ID 1:
E[0] = [ 0.2, 0.3]E[1] = [ 0.4, −0.1]E[2] = [−0.2, 0.5]
h = E[0],y = 1z[j] = h · E[j]这里 h=[0.2,0.3],三个 logits 为 [0.13,0.05,0.11],p[2]≈0.33761,所以 ID 2 的梯度约为 [0.06752,0.10128]。它没有作为输入或目标出现,输出路径仍给了非零梯度。
这只反驳“梯度必为零”,不证明该词语义已充分学好。排查异常更应该检查覆盖、训练分布和受控行为;直接删词重排 ID 会破坏参数对应关系。
中文效率:同一句话,为什么占用不同数量的 Token
下面的切分示例对应 GPT-2、cl100k_base 和 Qwen/Qwen3-0.6B 的公开分词配置。计数只针对原始字符串,不包含聊天模板、额外特殊 Token 或换行。
| 原始文本 | GPT-2 | cl100k_base | Qwen3-0.6B |
|---|---|---|---|
| 你好,世界! | 14 | 7 | 4 |
| 我正在学习大语言模型。 | 23 | 11 | 7 |
| 分词是大模型许多怪现象的根源。 | 32 | 19 | 11 |
| Hello, world! | 4 | 4 | 4 |
Qwen 将第一条切为 你好 / , / 世界 / !,ID 为 [108386,3837,99489,6313]。
GPT-2 的“你”是 [19526,254],两项原始字节分别为 e4bd、a0;拼成 e4bda0 才是完整的 UTF-8 字符。单独显示片段时出现替换符,不代表原始字节丢失。cl100k_base 在此使用单项 [57668]。
这四条示例只能说明上述配置在这些输入上的长度,不能推导整体语言能力。词表容量、语料与规则都可能影响结果,也不能凭更短就给已经训练好的 GPT-2 直接换成 Qwen 分词器。
该版本的 Qwen3-0.6B 公开配置包含 BPE、NFC 规范化和 ByteLevel 处理;不能把整个 Qwen / Llama 家族都笼统叫作“SentencePiece 分词器”。NFC 也意味着任意原始 Unicode 文本未必字节级无损。这是规范化带来的表示变化,与字节片段能否重组是不同的问题。
比较时应明确具体版本与配置,不能只按模型家族名称推断分词行为。
JSON 与 YAML:字符更少,Token 却可能更多
同一个数据对象可以写成不同的序列化形式。以下面的紧凑 JSON 为例:
{"name":"Alice","age":30,"skills":["Python","SQL"],"active":true}块式 YAML 为:
name: Aliceage: 30skills: - Python - SQLactive: true| 形式 | 字符数 | cl100k_base Token 数 |
|---|---|---|
JSON,indent=2 | 95 | 37 |
紧凑 JSON,separators=(",", ":") | 65 | 19 |
| 上面的块式 YAML | 59 | 22 |
计数不含 Markdown 围栏、聊天模板和末尾换行。YAML 字符最少,紧凑 JSON 的 Token 最少,因为 Token 数取决于训练好的切分与合并,不和字符数严格单调;本例 ":" 与 "," 这两段标点各是一项。
格式选择应对目标 tokenizer 测完整输入,并同时考虑解析、字段类型和任务效果。压缩 JSON 用序列化器的紧凑选项,不能直接删除字符串值里的空格。
选型时,先确定哪些东西已经训练过
| 场景 | 做法 | 原因 |
|---|---|---|
| 使用已有模型 | 加载该版本配套的 tokenizer 与配置 | ID 已与参数行对应 |
| 扩展已有词表 | 保留对应关系,扩表、训练、验证、成套保存 | 新行需要学会输入和输出用途 |
| 从零训练模型 | 可以复用现成分词器,也可以先训练自己的词表 | 模型从一开始适应所选 ID 体系 |
| 学习分词算法 | 用可读的小实现追踪训练、编码、解码 | 便于核对每一步的规则 |
BPE / Unigram 是算法,tiktoken / SentencePiece / Tokenizers 是工具,GPT-2 编码与 cl100k_base 则是具体配置。更换工具但保持编码、解码行为一致,与换掉 ID 体系,是两类改动。
到这里,分词器不再只是文本进入模型前的一道切分程序。它规定编号的含义,影响模型要处理的位置数,也规定生成在什么边界继续;每次调整都要沿着这些对应关系检查下去。