从“用不了新词”到“词表自由”:一次给 transformers 模型加词典和 embedding 的完整记录
做 NLP 这行,最烦的其实不是模型结构多复杂,而是你辛辛苦苦fine-tune一个 BERT、RoBERTa 或者 Qwen 系列,上线前同事甩给你一句:“咱们行业那些术语模型都不认识,比如‘碳配额’‘转置卷积’‘RAG’这些词,会被切得稀碎,你处理一下。”
这就是今天想聊的核心问题:在 Pytorch 生态里,用 Hugging Face 的 transformers 库时,怎么往 tokenizer 的词汇表里添加新的词语,同时正确地把模型的 embedding 层尺寸对齐,避免训练直接报错或者效果崩掉。
这篇文章不是贴一段add_tokens就完事,而是把背后的原理、会遇到什么坑、embedding 初始化怎么做、不同模型系列(Bert系列 / GPT系列 / T5系列)有什么区别,全部拆开揉碎讲清楚。适合两类人看:一是刚入门、被“词表大小不一致”报错折磨的新手;二是已经跑通但困惑“我加了新词,效果怎么反而变差了”的进阶玩家。全程基于 transformers 4.x 和 Pytorch 2.x 实践,可以直接抄作业。
1. 为什么要给词汇表加词,以及加词背后的核心机制
1.1 一切从一个“被拆碎的行业术语”说起
先别急着开代码,我们来复现一个真实场景。
假设你手上是一个中文 BERT 模型,用BertTokenizer加载。然后你输入一句话:“本文提出一种基于RAG架构的图神经网络方法,用于解决小样本条件下的语义对齐问题。”
标准分词器跑完之后,tokenizer.tokenize()的输出大概率是长这个样子的:
['本', '文', '提', '出', '一', '种', '基', '于', 'ra', '##g', '架', '构', '的', '图', '神', '经', '网', '络', '方', '法', ...]看到 “ra” 和 “##g” 没?好好的“RAG”被拆成了两个 token。这种拆法在通用语料里没什么问题,因为 BERT 的 WordPiece 词表只有 21128 个词(中文 BERT base 的 vocab_size),不可能把全世界所有词都收进去。但在你的垂直领域——比如做科研论文审稿、法律文书解析、医疗病历结构化、游戏NPC对话——就会出问题:
- 专有名词被切开,语义信息被打散;
- 每个 token 都要走一遍 attention,序列变长,训练变慢;
- 更关键的是,fine-tune 时模型根本没有针对“RAG”这个完整概念的 embedding 做优化,它只有 “ra” 和 “##g” 的 embedding。
所以你需要做的,就是让 tokenizer 认识这个新词,并且让模型对“新词”有一个独立的向量表示。前者查表,后者加 embedding,两者必须同时完成,只做一步都会报错或者白干。
1.2 tokenizer 加的到底是“词”还是“token”
很多教程把add_tokens叫做“加词”,实际上更准确的说法是“加入新的 token”。
- 对于 BERT 这种词表里存的是 WordPiece 子词单元,你加的“新词”可能是一个完整词语,也可能是你想固定住的短语;
- 对于 GPT-2 / LLaMA 这种 BPE 模型,
tokenizer.add_tokens()加入的是一个特殊的 byte-level token,它会强行让 BPE 合并逻辑不去继续拆解这个词; - 对于 T5 系列(SentencePiece),情况更特殊,因为它用的是
tokens_to_add这样的参数,而且如果你的模型是t5而不是mt5,加中文词要非常小心,因为原始 T5 词表根本没有中文字符。
但不管哪一类,tokenizer 层面的操作本质是修改两个映射表:
token_to_id或类似的词→ID 字典;id_to_token或类似的 ID→词 字典。
Hugging Face 的PreTrainedTokenizerFast和PreTrainedTokenizer都封装好了这两层结构,你不需要手工去改vocab.txt,只需要调用接口。
1.3 模型侧为什么必须同步改 embedding
这一步就是新手最容易漏的。
tokenizer 的词表大小变了(从 21128 变成 21130,假设加了两个词),但模型的embedding.weight还停留在原来的[21128, hidden_size]。如果你直接拿这个模型跑 forward,Pytorch 会直接抛出类似这样的错误:
RuntimeError: index out of range: 21128 is out of bounds for dimension 0 with size 21128更隐蔽的情况是:你用的是Trainer训练,它会在某个 step 才触发 embedding 的查表,届时直接崩溃,日志还不好排查。
所以必须调用model.resize_token_embeddings(new_vocab_size)。这个函数做了两件事:
- 在
nn.Embedding的weight末尾随机初始化新增的行; - 返回一个新的
Embedding对象,并且在你的模型里把对应的层替换掉。
大部分模型(BERT、GPT-2、LLaMA 架构的 transformers 实现)都支持这个操作。但也有例外,比如某些自定义模型没有实现resize_token_embeddings,那就需要手工改model.bert.embeddings.word_embeddings这类属性,后面详细说。
2. 动手实操:从加载模型到完成词表扩充的完整代码
2.1 环境说明和依赖版本
先交代一下我测试时的环境,避免你因为版本差异踩一些无关的坑:
python 3.10 pytorch 2.1.2 transformers 4.38.2 tokenizers 0.15.2实际上,在我的经验里,transformers 4.x 任意小版本(4.20 以上)基本都能跑通本文的代码,但有少数几个 API 是旧版本没有的(比如tokenizer.add_special_tokens返回值的差异),我会在对应位置标注。
模块导入:
import torch from transformers import ( AutoTokenizer, AutoModelForCausalLM, AutoModelForSequenceClassification, )这里有个很关键的细节:你加词之前,一定要先明确自己用的是“分词器”还是“快速分词器”。Hugging Face 现在默认很多模型会加载tokenizer_class对应的 fast 版本(一般类名带Fast后缀)。下面讲到的add_tokens在 fast 和 slow 版本上行为完全一致,所以你不用太纠结这一点。但如果你是自己训练的 tokenizer 并保存成了tokenizer.json,再手动加载,就要确认用的是PreTrainedTokenizerFast,否则某些方法会报错。
2.2 第一步:明确你的模型类型和加载方式
为了照顾更多场景,本文以三个主流模型家族为例:
| 模型家族 | 加载用类 | 典型词表大小 | 分词算法 |
|---|---|---|---|
| BERT (bert-base-chinese) | AutoTokenizer+BertModel | 21128 | WordPiece |
| GPT-2 (gpt2) | AutoTokenizer+GPT2LMHeadModel | 50257 | BPE |
| LLaMA (meta-llama/Llama-2-7b-hf) | AutoTokenizer+AutoModelForCausalLM | 32000 | SentencePiece / BPE |
model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)注意这里我用了AutoModelForSequenceClassification,因为分类任务在实际项目里需求最高。如果你用的是纯BertModel或者AutoModel,后续操作完全一致,只是最后输出层不同。
2.3 第二步:准备要加入的新词,并区分两类 token
要加入的词集合,通常来自你的领域语料,建议做一个词频统计,把高频且被错误切分的词语挑出来。举个例子:
new_words = ["碳配额", "碳交易", "碳足迹", "绿电", "CCER", "VER"]这时候要停下来想一个事情:这些词是普通 token 还是 special token?
- 普通 token:比如“碳配额”“绿电”这种,它们应该参与 attention 计算,在生成/分类时作为正常内容。
- special token:比如
[CLS]、[SEP]、[MASK]或者你要新加的[COMPANY]、[USER]这种控制符号,它们一般不参与正常文本表示,在代码里用add_special_tokens加入。
如果你把普通词语用add_special_tokens加进去,虽然也能用,但会污染整个特殊 token 的语义空间,尤其在生成任务里,模型会以为这些词是控制符,行为会变得非常诡异。
正确姿势:
num_added_normal = tokenizer.add_tokens(new_words) print(f"新增普通 token 数量: {num_added_normal}") # 新增特殊 token,比如自定义一个领域标记 num_added_special = tokenizer.add_special_tokens( {"additional_special_tokens": ["[TRADE]", "[ENV]"]} ) print(f"新增特殊 token 数量: {num_added_special}")两点提醒:
add_tokens的参数可以是List[str],也可以是Dict[str, str]。后者可以在加入 token 的同时给它们设定不同的属性,但一般用不上。- 如果某个词已经在词表里,
add_tokens会自动跳过,返回值里不会重复计数。所以放心地传入包含重复词的列表也没问题。
2.4 第三步:修改模型的 embedding 尺寸
这一步是核心,也是最容易出错的。
old_vocab_size = len(tokenizer) print(f"旧词表大小: {old_vocab_size}") # 调用 resize,一定要用新的 tokenizer 词表大小 model.resize_token_embeddings(old_vocab_size)等一下,这里的old_vocab_size其实已经是加入新词之后的长度了。len(tokenizer)是在加入新词之后调用的,所以它就是新词表大小。命名别被误导。
仔细看源码的话,resize_token_embeddings内部会做几步操作:
- 获取
get_input_embeddings()拿到当前模型的输入 embedding; - 比较当前 embedding 的
num_embeddings和传入的new_num_tokens; - 如果不一致,构造一个全新的
nn.Embedding(new_num_tokens, embedding_dim); - 把旧的 embedding 权重拷贝到新 embedding 的前
min(old_num, new_num)行; - 通过
set_input_embeddings()替换模型的输入 embedding。
如果模型有输出层(比如lm_head),它也会尝试同步 resize。但要注意:resize 的输出层并不总是对的。比如 GPT-2 的lm_head和 embedding 是权重共享的,resize_token_embeddings处理得很好。但某些模型(比如 T5)的lm_head不共享权重,或者名字不叫lm_head,这时候你要自己手动去处理model.lm_head的尺寸。
怎么判断模型是否需要手动调整输出层?最简单的办法是 debug 一下:
print(type(model.get_output_embeddings()))如果返回的不是None,就说明有一个输出 embedding 层。这个时候,你最好再手动检查一下它的out_features:
if model.get_output_embeddings() is not None: output_emb = model.get_output_embeddings() print("LM head shape:", output_emb.weight.shape)2.5 第四步:新词的 embedding 初始化策略(重要)
这一部分才是决定效果的分水岭。
resize_token_embeddings默认会对新增的行做随机初始化,并且初始化方法遵从nn.Embedding的默认策略,也就是标准正态分布乘以一个较小系数。这在大多数情况下是“能用但不够好”的。
为什么不够好?
因为你随机初始化出来的向量,跟词表里已有的其他词向量没有语义关系。假设“碳配额”被加入为新 token,随机向量意味着模型一开始完全不知道该把它放在语义空间的哪个位置,需要靠后续训练慢慢学。如果你的训练数据只有几万条,根本学不充分,模型可能退化成“记住了但不会用”。
针对这个问题,我实践下来比较好用的初始化方案有几种,按推荐程度排序:
方案一:平均已有子词向量
比如“碳配额”在加入前会被切成“碳”“配”“额”三个 token(或者“碳”“配额”)。把这三个 token 的 embedding 取平均,作为新 token 的初始向量。这个思路非常直观,且效果稳定。
def init_embedding_from_subwords(tokenizer, model, new_words): embed = model.get_input_embeddings() with torch.no_grad(): for new_word in new_words: # 用原来的 tokenizer 切分新词 sub_tokens = tokenizer.tokenize(new_word) if len(sub_tokens) == 1 and sub_tokens[0] == new_word: # 说明这个词本来就是一个 token,不需要初始化 continue sub_ids = tokenizer.convert_tokens_to_ids(sub_tokens) # 平均已有子词的 embedding avg_embedding = embed.weight[sub_ids].mean(dim=0) # 新词的 token id new_id = tokenizer.convert_tokens_to_ids(new_word) embed.weight[new_id] = avg_embedding有个细节:这里必须用加入新词之前的 tokenizer 来切分。如果你在add_tokens之后再调用tokenize("碳配额"),它会直接返回["碳配额"],因为新词已经进词表了,你反而拿不到子词信息了。所以严谨的做法是:先保存一份旧 tokenizer 的切分结果,再加新词。
# 保存旧切分结果的字典 sub_token_map = {} for new_word in new_words: sub_token_map[new_word] = tokenizer.tokenize(new_word) # add_tokens 之前调用方案二:使用相近词向量初始化
如果你的新词在领域词典里有解释,或者你能通过其他方式找到语义相近的已有 token,可以采用这种方案。比如你加了“绿电”,可以拿“风电”“光伏”“能源”这几个词的 embedding 加权平均作为初始值。这个方法效果好,但需要人工参与,不适合大规模批量加词。
方案三:直接用resize_token_embeddings的默认随机初始化
适合两种情况:一是你加的词数量很大(几百上千个),模型预训练本来就没见过,随机初始化等于给模型一个自由探索的空间;二是你后续会在大量领域语料上继续 pre-train,模型能自己学好。除此之外,不推荐。
2.6 第五步:验证一切是否对齐
加完词和 embedding 后,我习惯性做三件事来验证:
# 1. 新词能否被完整编码 test_text = "今年公司的碳配额交易量显著上升" encoded = tokenizer(test_text) print(tokenizer.convert_ids_to_tokens(encoded["input_ids"])) # 期望看到 "碳配额" 作为一个独立 token,而不是 "碳" "配" "额" # 2. 词表大小是否一致 assert len(tokenizer) == model.get_input_embeddings().num_embeddings # 3. 新 token 的 embedding 是否是有限值 new_ids = tokenizer.convert_tokens_to_ids(new_words) embedding_weights = model.get_input_embeddings().weight for wid in new_ids: assert torch.isfinite(embedding_weights[wid]).all()如果三个断言都通过,你就可以放心去 fine-tune 了。
2.7 第六步:保存和离线加载
训练完后,tokenizer 要保存,模型也要保存。注意两者要同步保存,并且优先使用save_pretrained而不是直接torch.save。
tokenizer.save_pretrained("./my_model_dir/") model.save_pretrained("./my_model_dir/")当别人(或者未来的你)需要加载这个模型时:
new_tokenizer = AutoTokenizer.from_pretrained("./my_model_dir/") new_model = AutoModelForSequenceClassification.from_pretrained("./my_model_dir/")这里有一个容易踩的坑:如果你在原来基础上加载预训练权重后又 add_tokens、又 resize,最后保存到同一个文件夹里,会把新增行是随机初始化这个状态也保存进去。如果你训练得不够充分,下次加载后模型表现可能会让你的老板怀疑人生。所以,加词后至少要做几十步的 warmup 训练,让新 embedding 融入整个表示空间,再保存。如果你只是做推理、不打算训练,那加新词几乎没有意义,因为新 embedding 是随机的,模型根本无法对它们给出合理预测。
3. 进阶:不同模型家族的特殊处理方式和避坑说明
3.1 中英文 BERT 系列:最稳,但要留意大小写和繁体
BERT 系的 tokenizer 通常有do_lower_case参数。中文模型一般不分大小写,但英文模型默认会把所有字母转小写。如果你要加入“iPhone”这种词,最好先确认tokenizer.do_lower_case是 True 还是 False,否则你保存的 token 列表里可能会出现两个完全不同但其实应当合并的词。
我在做英文金融领域模型时,尝试加入 “ESG”“ESG rating”“green bond” 这些词。最开始没注意do_lower_case=True,结果add_tokens(["ESG"])加了一个大写版本,但实际输入文本 “esg” 还是会被切成 “es” “##g”。改正方式很简单——先对候选词做大小写归一化,再根据分词器的处理方式决定加入哪个版本。
另外,中文词表还有一个“预分词器”的概念。BertTokenizer默认会对中文按字切分,而BertTokenizerFast的行为在某些版本上略有差异。为了避免这个差异,我一般会显式指定use_fast=False,或者干脆用PreTrainedTokenizerFast自己构建一个,统一行为。当然,如果你只是做 BERT 中文,不需要太担心。
3.2 GPT-2 和 LLaMA 系列:BPE 合并要小心,加完可能不生效
GPT-2 和 LLaMA 用的是 BPE 分词算法。BPE 的核心是“合并规则”,而不是单纯的词表。也就是说,tokenizer.add_tokens(["carboncredit"])虽然在词表里加了一个词条,但实际编码时,BPE 会先走一遍 merge rules,如果这个新词可以被已有的子词规则合并出来,那它根本不会单独被识别为新 token。
怎么验证?很简单:
tokenizer = AutoTokenizer.from_pretrained("gpt2") tokenizer.add_tokens(["carboncredit"]) # 如果不做额外操作,下面这个输出可能仍然是 ["carbon", "credit"] print(tokenizer.tokenize("carboncredit"))这是因为分词器的“新增 token”机制,在 fast tokenizer 实现里是把新词直接插到句子的“预分词”阶段,而不是 merge rules 阶段。tokenizers库会把你 add 进去的 token 当作一个整体,拼成一个“受保护”的词条,防止 BPE 继续拆分。所以理论上你加了就有效。
但是!如果你用的不是 fast tokenizer,而是 slow tokenizer(比如某些AutoTokenizer在缺少tokenizers库时fallback到 perl 实现),add_tokens的实现可能就没那么严谨。我遇到过一次在microsoft/DialoGPT-small上加词,tokenize结果完全没变,最后查代码发现是 slow tokenizer 的缓存问题。解决办法是强制加载 fast 版本:
tokenizer = AutoTokenizer.from_pretrained("gpt2", use_fast=True)如果在 LLaMA 系列上,还要注意:LLaMA 的 tokenizer 是用 SentencePiece 训练的,fairseq和transformers都加了特殊的控制符号。手动add_tokens时,要小心不要把bos_token、eos_token这些给覆盖了。另外,LLaMA 词表默认是 32000,你加词后变成 32005,之前保存的模型如果是用别的框架(比如 vLLM、TGI)加载,可能不支持动态词表,推理服务需要重新编译。这一点在部署时要特别留意。
3.3 T5 / mT5 系列:坑最多,建议慎重对待
T5 的 tokenizer 是 SentencePiece,它有一个很特殊的地方:词表大小由vocab_size参数决定,并且分词器里面有个sp_model对象,直接add_tokens只能加 special token,加普通词经常会报错或者被强制忽略。
如果你加的是英文字母、符号,SentencePiece 还算友好;如果你要加中文词,而原始模型是t5-base(不含中文语料),分词器可能根本不知道中文字符怎么映射,因为它的vocab.txt里全是英文字母和特殊符号。
我的建议是:如果你的任务是中文为主,直接用mt5或ByT5,它们的词表和 SentencePiece 模型天然支持多语言。如果你确实要在t5上加中文词,可以走“扩展 SentencePiece 词表 + 重训 sp_model”的路线,但这已经超出了tokenizer.add_tokens的范畴,需要动用sentencepiece库重新训练一个 sp model,再把vocab_size调整到新模型大小。这个流程极其容易出问题,不推荐在生产环境尝试,除非你有一整天的排查时间。
3.4 多任务模型和 LoRA 场景下的额外注意点
做 LoRA 微调时,很多人会问:加了新词,LoRA 能不能覆盖新 embedding?
答案是:能,但不能只靠 LoRA 的 adapter 实现。LoRA 默认只对q_proj、k_proj、v_proj、o_proj等线性层做低秩分解,它不修改 embedding 层。所以如果你加了新词,直接调用peft库加载 LoRA,model.get_input_embeddings().weight在推理时是冻结的(因为requires_grad=False),新词 embedding 依然是随机初始化状态。
解决办法有两条路:
- 在 LoRA 之外,单独设置 embedding 层可训练:
embedding_layer = model.get_input_embeddings() for param in embedding_layer.parameters(): param.requires_grad = True然后在设优化器时把 embedding 参数单独拎出来,给一个相对较大的学习率。
- 在加词后先做一小段“embedding warmup”训练,只更新 embedding 层,然后用
save_pretrained保存。之后再加载这份权重去做 LoRA。这是我最推荐的做法,相当于把加词和下游任务解耦。
如果你用的是transformers.Trainer,还可以通过optimizer_grouped_parameters分组,把 embedding 参数和 transformer 参数的学习率分开。
param_groups = [ {"params": [p for n, p in model.named_parameters() if "word_embeddings" in n], "lr": 5e-4}, {"params": [p for n, p in model.named_parameters() if "word_embeddings" not in n], "lr": 2e-5}, ] optimizer = torch.optim.AdamW(param_groups)4. 加词之后训练时容易踩的坑与排查实录
4.1 训练时第一个 batch 就崩,报CUDA error: device-side assert triggered
这个错误几乎 99% 是因为有 token id 超出了 embedding 表长度。但你可能会很困惑:明明我resize_token_embeddings了啊?
这时候要检查的不是 embedding 层,而是labels。
如果你在做序列标注或者生成任务,模型输出的logits维度等于词表大小。如果你的labels里有 token id 超过新的vocab_size,计算CrossEntropyLoss时就会触发设备侧断言错误。
这种错误最坑的地方在于,显存管理机制会让报错信息非常难查,甚至你 kill 进程之后显卡显存还被占着。我的排查流程:
- 把所有 label 全局扫一遍,看最大 id 是否小于
len(tokenizer); - 检查数据预处理时是否用了
tokenizer(..., padding=True)但没同步更新专门给 label 做的对齐; - 把
model.resize_token_embeddings(len(tokenizer))的返回值打印出来,确认输出层的out_features也变了。
如果是生成模型(GPT、LLaMA),还要检查model.lm_head.weight.shape是否和model.get_input_embeddings().weight.shape一致。如果一个是 32000 一个是 32005,loss 计算一定炸。
4.2 加了词,但tokenize后还是被拆开
这个问题在 fast tokenizer 里较少见,因为 fast tokenizer 的add_tokens是立即生效的。但如果你用了 slow tokenizer,而且是在同一个进程内反复加载旧权重,可能要清缓存:
tokenizer = AutoTokenizer.from_pretrained(model_name, use_fast=True)如果还是被拆开,检查你传的new_words里是不是包含了空格或标点。BPE 和 WordPiece 对空格很敏感,"carbon credit"和"carboncredit"是两个完全不同的 token。在中文场景下,空格不是问题,但英文场景里非常常见。
还有一个隐藏很深的坑:大小写。
BertTokenizer默认do_lower_case=True时,add_tokens("ESG")等于啥也没加,因为 tokenizer 内部会把输入先 normalize 成"esg"。但你打印tokenizer.vocab时,又确实能看到"ESG"这个 key。这就导致一种诡异情况:你检查词表里有 “ESG”,但真实文本里的 “esg” 还是被拆开。解决方法:用 tokenizer 的 normalize 逻辑先处理候选词,或者统一用小写。
4.3 resize 之后模型表现断崖式下降
这是正常的,因为你给 embedding 塞了一堆随机向量,相当于在模型里引入了噪声。如果只是加了一两个词,影响很微弱;如果加了上千个词,模型整体性能下降会非常明显。
解决办法是“加热”:
- 准备一段领域语料(几万条就够),在原有任务上继续预训练,或者至少做 Masked Language Modeling / Next Sentence Prediction 之类的自监督训练;
- 如果是生成模型,用原始语料做因果语言建模;
- 时间紧张的话,可以只训练 embedding 层,固定 transformer 其他层,训练若干个 epoch 后再解冻全部层做 fine-tune。
我在实际项目里,加 20 个金融专属词之后,如果不做 warmup,分类 F1 会从 0.91 掉到 0.87;做了 20 个 epoch 的 embedding-only warmup(学习率 1e-4,batch size 32,语料 3 万条),F1 反而涨到了 0.925。这说明新词表不一定是负担,只要初始化得当、训练充分,它会变成模型的专业知识入口。
4.4 多 GPU 分布式训练时的词表不同步
如果你用DistributedDataParallel或者deepseed,每个进程都会独立加载模型和 tokenizer。你必须在每个进程里都执行add_tokens和resize_token_embeddings,不能只在主进程做了再广播。
在transformers.Trainer中,resize_token_embeddings会在training_args设置正确时自动同步,但自定义训练循环中容易漏。漏掉的结果是:主进程词表 32005,从进程词表还是 32000,梯度 allreduce 时 shape mismatch,直接报错expected hidden_size 32000 but got 32005。
稳妥做法:在构造 DataLoader 之前,确保每个进程的 tokenizer 和模型都以同样的方式扩展。最保险的方案是:
# 每个进程都执行这段逻辑 new_words = load_my_new_words() tokenizer.add_tokens(new_words) model.resize_token_embeddings(len(tokenizer))不要试图把new_words拼进 config 文件里去动态读取,除非你写好了严格有序的加载逻辑。
4.5 在线推理服务的兼容性问题
很多工业场景不会直接用 transformers 做推理,而是导出 ONNX、TorchScript,或者用 TensorRT、vLLM、TGI 这些高性能推理引擎。这时候,原生的tokenizer.add_tokens和resize_token_embeddings只在训练阶段生效,导出时会有各种意外:
- ONNX 导出的词表大小是静态的,加词后必须重新导出;
- vLLM 对词表变动不友好,有些版本甚至会直接拒绝加载“非原始词表”的 checkpoint;
- 如果你用的是 Pytorch 的
torch.compile,embedding 层尺寸变化可能导致缓存 key 变化,重新编译。
所以在做工程方案时,我的建议是:要不要加词,应该在项目启动的第一天就决定。如果你觉得未来可能加某些专业词,宁可一开始就把它们加进去并做好初始化,也不要训练完再改。否则你还要承担换模型、重新量化、重新部署的一系列成本。
5. 综合案例:一个分类模型在 10 分钟内完成加词与微调
光讲理论不落地等于白讲。我再总结一个可以直接复制的最小闭环,帮你把前面的知识点串起来。任务是:中文 BERT 情感分类,需要在原词表基础上加入游戏领域黑话,然后做微调。
import torch from torch.utils.data import DataLoader from transformers import AutoTokenizer, AutoModelForSequenceClassification, AdamW # 1. 加载模型和 tokenizer model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2) # 2. 准备新词和旧切分映射 new_words = ["开黑", "上分", "连跪", "出装", "打野"] sub_token_map = {} for w in new_words: sub_token_map[w] = tokenizer.tokenize(w) # 3. 加入 tokenizer tokenizer.add_tokens(new_words) # 4. resize embedding model.resize_token_embeddings(len(tokenizer)) # 5. 用子词平均初始化新 embedding with torch.no_grad(): embeddings = model.get_input_embeddings().weight for w in new_words: sub_ids = tokenizer.convert_tokens_to_ids(sub_token_map[w]) new_id = tokenizer.convert_tokens_to_ids(w) embeddings[new_id] = embeddings[sub_ids].mean(dim=0) # 6. 验证 assert len(tokenizer) == model.get_input_embeddings().num_embeddings print(tokenizer.tokenize("今天和兄弟开黑三连跪,心态崩了")) # 7. 正常做微调 # 这里省略 dataset 构造细节,只给出 dtype 层面的建议 inputs = tokenizer("今天和兄弟开黑三连跪,心态崩了", return_tensors="pt") labels = torch.tensor([1]).unsqueeze(0) outputs = model(**inputs, labels=labels) loss = outputs.loss loss.backward()注意第 5 步里,先通过sub_token_map拿到了旧切分结果,再在add_tokens之后通过tokenizer.convert_tokens_to_ids(w)获取新词的 id。因为新词 id 在add_tokens之后才存在。顺序不能乱。
如果你一次性加几百个词,建议把初始化函数好好封装一下,并且加入日志监控。因为其中可能会有个别词在旧词表里找不到任何子词(比如特殊符号),这时候mean(dim=0)会得到空张量,需要跳过或者用随机初始化代替。
我第一次跑批量加词时,就因为这个空张量问题,凌晨两点在服务器上排查了一个多小时,最后发现有个词是纯 emoji,WordPiece 完全切不了。后来我在代码里加了过滤逻辑:如果sub_ids为空,就用默认的随机初始化并打日志。这样就不会因为一个脏数据导致整个流程中断。
6. 一些额外想跟你分享的经验
上面这些内容基本覆盖了“在 Pytorch + transformers 里给 tokenizer 加新词和 embedding”的全部关键点。最后分享几个我在多次实战后沉淀下来的个人习惯,也许能帮你少走弯路。
第一,加词之前先看你的语料。不要凭空想象哪些词需要加,最好跑一次词频统计,把频繁被切成 3 段以上的词拎出来。有时候你会发现,真正要加的不是那些很长的专业术语,而是像“氪金”“白嫖”“真香”这种网络黑话,它们切分后语义损失最严重。
第二,新词数量不要贪多。WordPiece / BPE 词表本身就是为了控制词表规模和低频词稀疏性而设计的,如果你一口气加几千个词,embedding 矩阵会显著变大,显存占用上升、训练速度下降,而且大量低频词占着 embedding 却学不好,得不偿失。我的经验是,单次增加不要超过词表原规模的 1%~2%。如果真的有大量领域词汇,建议用更底层的 tokenizer 重新训练词表,而不是简单地add_tokens。
第三,embedding 初始化不是越复杂越好。平均子词向量在大多数情况下已经够用。我试过用 Word2Vec 或 BGE 这类外部模型给新词构造 embedding,理论上更科学,但实际上因为分布不一致,效果反而不稳定。外部 embedding 是静态的,和 transformer 内部的上下文表示空间存在领域偏移,强行映射进去可能会干扰现有表示。倒不如老老实实用子词平均,或者干脆随机初始化让模型自己学。
第四,提交代码时,一定要把“加了哪些词”也提交进配置仓库。我见过太多项目,模型训练完了,代码里却没有记录词表扩充的逻辑,结果几个月后要复现实验,谁都不知道当初那 20 个新词是哪来的,整个实验没法回归。最简单的做法是在项目里放一个domain_vocab.txt,把人工挑选的词语全部写进去,训练脚本统一从文件读取。
第五,学会看 tokenizer 的底层输出。很多人看到tokenizer.encode返回的是一串 id,就不去关心中间过程了。但在加词这个场景下,我强烈建议你把convert_ids_to_tokens(encode(text))的结果打印出来,亲眼确认哪些词被整合了,哪些没有。这比看任何文档都有用。某些模型在特殊输入下还会出现 token 重叠的奇怪现象,只有把中间结果打出来才能发现。
最后再分享一个调试技巧:当你觉得一个词加了没效果时,把它放到 tokenizer 的 vocab 里用tokenizer.tokenize单独测是一个方法,但更让我觉得实用的是,在模型 forward 过程中把 embedding 查表结果 dump 出来,直接看这条文本经过 embedding 层之后的向量是不是稳定的、是不是和相似词向量的距离足够近。这样你可以非常明确地判断“词加成功了”和“词加得有用”是两码事。
加词和 embedding 扩充这件事,说难不算难,但想真正用出效果、不踩坑,还是要把底层机制和业务场景同时吃透。希望这篇长文能帮你省下几个通宵,祝模型调参顺利。