vocab size 不是分词器的内部参数,而是一笔跨越参数量、序列长度、训练稳定性与生态兼容性的预算分配。词表每翻一倍,embedding 就多吃掉一份固定开销,换来的是全语料约 10%–15% 的序列压缩——这笔账在 7B 上勉强划算,在 0.5B 上是灾难。
参数账: 的绝对规模
Embedding 与 lm_head 各占 个参数,两者若不共享权重则总开销 。关键在于这个量与层数无关:模型加深加宽时 Transformer 主体按 增长,词表开销只按 增长。于是同一个词表在大模型上微不足道,在小模型上吞掉四分之一。
def vocab_share(V, d, total_params, tied=False):
"""词表相关参数占总参数的比例。tied=True 表示 embedding 与 lm_head 共享权重。"""
p = V * d * (1 if tied else 2)
return p / 1e9, p / total_params
for name, V, d, total, tied in [
("Llama-2-7B", 32000, 4096, 6.74e9, False),
("Llama-3-8B", 128256, 4096, 8.03e9, False),
("Qwen2.5-7B", 151936, 3584, 7.62e9, False),
("Qwen2.5-0.5B", 151936, 896, 4.94e8, True), # 小模型共享权重仍然吃不消
("Gemma-2-2B", 256000, 2304, 2.61e9, True),
]:
b, r = vocab_share(V, d, total, tied)
print(f"{name:14s} V={V:6d} 词表参数={b:5.2f}B 占比={r:5.1%}")输出的量级差异是整篇的出发点:
| 模型 | V | 词表参数 | 占比 |
|---|---|---|---|
| Llama-2-7B | 32000 | 0.26B | 3.9% |
| Llama-3-8B | 128256 | 1.05B | 13.1% |
| Qwen2.5-7B | 151936 | 1.09B | 14.3% |
| Qwen2.5-0.5B(tied) | 151936 | 0.14B | 27.6% |
| Gemma-2-2B(tied) | 256000 | 0.59B | 22.6% |
Llama 2 到 Llama 3 把 V 从 32k 提到 128k,代价是近 8 亿新增参数全部用于查表——这些参数不参与任何跨 token 交互,表达力远低于同等规模的 FFN 权重。小模型因此普遍强制 tied embedding,Gemma 系列的 256k 词表更是让 2B 模型有五分之一的参数在做纯查表。
收益侧则是序列压缩:128k 词表相对 32k 在混合语料上通常带来 10%–20% 的 token 数下降。这是乘在全部训练与推理成本上的折扣——Attention 与 FFN 的 FLOPs 按序列长度线性(Attention 平方)下降,同一上下文窗口能装的信息也更多。多语言场景的压缩收益尤其陡峭,见 中文与多语言分词。
存在最优 vocab size
把两侧放进等 FLOPs 预算里做联合优化,会得到一个内点最优: 太小则序列冗长、压缩不足; 太大则参数浪费在长尾 token 上且 lm_head 的 矩阵乘开始主导单 token 计算。Tao 等人 2024 年的词表缩放律给出的经验结论是最优 随非词表参数量亚线性增长(指数约 0.6–0.7),并据此判断 Llama-2-70B 的 32k 词表严重偏小,同规模下最优值在 20 万量级。近两年主流模型集中在 128k–256k,方向上与此吻合。
实践判据比公式更有用:词表参数占比超过 20% 就该考虑缩表,低于 5% 且多语言/代码占比高就该考虑扩表。
大词表的训练病灶
梯度稀疏与长尾欠训练
Embedding 的梯度天然稀疏——一个 batch 只有出现过的 token 对应的行拿到非零梯度。词表分布服从 Zipf,尾部 token 在整个预训练中可能只被更新几十次,其 embedding 基本停留在初始化附近。这正是 glitch token 的成因(详见 Tokenizer 对推理的影响):词表里存在模型从未真正学会的行。
工程后果集中在优化器上:梯度稀疏但 Adam 的一阶/二阶矩是稠密的,对 整块维护状态。V=256k、d=2048 的 fp32 优化器状态就是 4GB,且其中大半在为几乎不更新的行做无用功。缓解手段包括稀疏梯度(nn.Embedding(sparse=True) 配 SparseAdam)、embedding 单独走 sharded optimizer、以及对 embedding 层用更大的学习率或独立的 weight decay(长尾行的 decay 会持续把未训练的 embedding 往零拉)。
loss 不可跨词表比较
交叉熵是按 token 平均的。词表变大 → 单 token 承载的信息更多 → 每个 token 的预测更难 → loss 数值上升;同时序列变短 → 总 loss 摊到更少的 token 上。两个效应方向相反且都与 相关,导致不同 tokenizer 训出的模型 loss / perplexity 完全不可比。
唯一可比的口径是归一化到与词表无关的单位:
任何声称”换了词表后 loss 降了”的结论,不换算成 bits-per-byte 或 bits-per-character 就是无效的。评测同理:固定 token 预算的基准在大词表下实际喂进了更多文本。
输出层的计算与显存
lm_head 的 logits 是 [batch, seq, V]。V=256k、seq=8192、bf16 时单序列 logits 就是 4GB,反向时再翻倍——大词表下 logits 而非 activation 成为峰值显存的主因。标准对策是分块计算 loss(chunked cross-entropy,只保留必要的中间量)或用融合 kernel(Liger、Cut Cross-Entropy 一类,把 logits 物化彻底省掉)。这也是训练框架里 V 一旦过 15 万就必须换 loss 实现的分水岭。
复用还是自训
| 维度 | 复用成熟 tokenizer | 自训 tokenizer |
|---|---|---|
| 压缩率 | 通用语料够好,垂域偏差大 | 可针对语料优化 10%–30% |
| 生态 | 直接吃现成 embedding、词表对齐的蒸馏与投机解码 | 全部要自建 |
| 已知缺陷 | 继承对方的 glitch token 与切分怪癖 | 缺陷未知,需自测 |
| 权重复用 | 可从同词表模型继续预训练 | 只能从零开始 |
决策边界相当清晰:
- 只要打算从既有开源权重继续预训练,词表必须锁死,换词表等于放弃全部预训练权重
- 投机解码要求 draft 与 target 词表严格一致,自训词表会封死这条最有效的加速路径
- 只有**从零预训练且语料分布明显偏离通用(纯代码、生物序列、单一小语种)**时,自训才可能收回成本;垂域上 20% 的压缩改善意味着同等语料少算 20% 的 token,这是真金白银
一个常被忽略的中间路线:复用词表但替换其分词实现,例如把 BPE 的正则预切分规则微调以适配缩进或数字,保持 merge 表与 id 映射不变。这样不破坏权重兼容性,却能改掉最难受的切分行为。
裁剪与扩表
裁剪
场景是单语言部署:一个 256k 的多语言词表在纯中文服务里可能只有三成 token 被用到,砍掉剩余部分能直接回收数亿参数。流程是统计目标语料的 token 频次、保留覆盖 99.9% 以上的集合、重映射 id、按新索引切 embedding 与 lm_head 的行。
两条硬约束:
- BPE 必须保持前缀闭包。若保留了某个 merge 产生的 token,它的两个构成部分也必须保留,否则贪心编码走不到该 token。安全做法是裁 merge 列表(截断而非任意删除)而非只裁 vocab 文件。Unigram 词表没有这个约束,删 piece 后重新归一化概率即可
- 特殊 token 与 chat template 用到的全部保留,漏掉一个
<|im_end|>会让对话格式整体失效,见 特殊 Token 与 Chat Template
裁剪后必须在目标语料上轻量续训——lm_head 的 softmax 归一化域变了,输出分布需要重新校准,通常几亿 token 足够。
扩表
反向场景是补充目标语言或垂域词汇。初始化策略决定了收敛速度,不要用随机初始化:
import torch
def init_new_embeddings(model, old_tok, new_tok, new_tokens):
"""新 token 的 embedding 初始化为其在旧 tokenizer 下子词 embedding 的均值。"""
model.resize_token_embeddings(len(new_tok), pad_to_multiple_of=64) # 对齐 tensor core
emb = model.get_input_embeddings().weight.data
for tok in new_tokens:
pieces = old_tok.encode(tok, add_special_tokens=False)
if pieces: # 均值初始化:语义上就在正确的邻域
emb[new_tok.convert_tokens_to_ids(tok)] = emb[pieces].mean(dim=0)
if model.config.tie_word_embeddings: # 共享权重时无需单独处理 lm_head
return
head = model.get_output_embeddings().weight.data
for tok in new_tokens:
pieces = old_tok.encode(tok, add_special_tokens=False)
if pieces:
head[new_tok.convert_tokens_to_ids(tok)] = head[pieces].mean(dim=0)均值初始化让新 token 起点就落在语义邻域内,相比随机初始化通常省下一个数量级的适配数据。三个易错点:resize_token_embeddings 会同时处理 tied 权重但不会帮你做智能初始化;pad_to_multiple_of=64 影响矩阵乘吞吐,别省;resize 之后 config.vocab_size 变了,所有下游 checkpoint 加载都要匹配。
续训遵循两阶段:先冻结 Transformer 主体、只训 embedding 与 lm_head 让新 token 对齐既有表示空间,再解冻全量做小学习率适配。跳过第一阶段会让新 token 的大梯度污染主体权重。
LoRA 场景
LoRA 的默认 target modules 只覆盖 attention/FFN 的线性层,embed_tokens 与 lm_head 不在其中。这引出微调中最隐蔽的一类失败:
- 加了新 token 但没训 embedding:新 token 的 embedding 停在初始化状态,模型永远学不会它,输出表现为在该 token 处崩坏
- 用
modules_to_save兜底的代价:把 embedding 与 lm_head 加进modules_to_save会保存两个完整副本,V=151936、d=3584 时 adapter 从几十 MB 膨胀到 4GB 以上,LoRA 的轻量优势尽失 - shape 不匹配:resize 后训出的 adapter 加载回原始 base model 会直接报维度错,必须同时分发 resize 过的 base 或在 adapter 配置里记录目标 vocab size
首选方案是根本不扩表。现代词表普遍预留了空位——Llama 3 的 128k 词表末尾有 250 个 <|reserved_special_token_N|>,Qwen 系列同样留有保留位。把它们重命名复用即可拿到”新” token,且这些位置在预训练中已被初始化到合理分布,无需 resize、无需新参数、adapter 与 base 完全兼容。
确实需要大批新 token(例如领域符号表)时,退而用 embedding 专用的低秩适配:只对新增的那部分行做可训练增量,或把 embedding 拆成”冻结的旧块 + 可训练的新块”,参数量与新 token 数成正比而非与 成正比。
词表决策之所以值得单独拿出来算,是因为它在整条管线里位置最靠前、改动成本最高——切分规则、参数预算、训练稳定性、推理行为都被它同时固定下来,全局视图见 Tokenizer 概述。