
做 NLP 的几乎没人能绕开 BertTokenizer。我在实际项目里见过太多同学从网上抄了一段tokenizer BertTokenizer.from_pretrained(bert-base-uncased)然后inputs tokenizer(text, return_tensorspt)就往模型里塞跑通了就万事大吉。一旦换数据、换语言、调 batch就开始各种报错id 超出词表范围、padding 没对齐、中文全变成[UNK]、显存莫名其妙爆掉……这些问题十有八九不是你模型的问题而是没搞懂 tokenizer 内部到底做了什么。这篇我打算把 BertTokenizer 从原理到 API、从单句到批量、从英文到中文场景完完整整过一遍争取让看完的人能把它当工具书用。1. BertTokenizer 是什么为什么非用它不可先明确一个最基本的问题模型是数学机器它只认识数字不认识字符串。Transformer 类的模型接收的输入是三组张量input_ids、token_type_ids、attention_mask。这三组张量不是凭空来的而是由 tokenizer 把原始文本加工出来的。BertTokenizer 就是专门为 BERT 预训练模型设计的那把“加工刀具”它把分词、查词表、加特殊 token、生成 mask 这几件事全部封装好你不需要自己维护word2id和id2word两个字典。那为什么不直接用 Python 的str.split()或者 jieba 分词再自己建词表呢这里有一个关键点BERT 预训练时用的不是最简单的空格切分而是 WordPiece 分词。WordPiece 是一种介于“词”和“字符”之间的切分方式它会把一个生僻词拆成几个子词片段比如unaffable会被切成un、##aff、##able。这么做的好处是词表可以做得比较小同时又能覆盖绝大多数词汇。你自己用 jieba 切完再查 BERT 词表遇到词表里没有的词会被映射成[UNK]这会让模型完全丢失这个词的信息实际效果会很受影响。所以想用好 BERT 系列模型老老实实用它配套的 tokenizer 是最稳的选择。1.1 加载一个分词器后台到底发生了什么我们平时写的最多的一行代码就是BertTokenizer.from_pretrained(bert-base-uncased)。这行代码看似简单后台其实做了三件事第一从 Hugging Face 的模型仓库里下载整个 tokenizer 相关的配置文件包括vocab.txt、tokenizer_config.json、special_tokens_map.json。其中vocab.txt就是词表每一行是一个 token行号就是 token 对应的 idtokenizer_config.json保存了分词器的初始化参数比如是否做小写化、max_len 是多少special_tokens_map.json则记录了特殊 token 分别是什么。第二根据这些配置文件实例化一个分词器对象。bert-base-uncased这个 checkpoint 的分词器会在切词之前把所有英文字母转成小写然后做 WordPiece 切分bert-base-cased则不转小写。这个差异会直接影响你分词的结果后面我会再提。第三把词表加载进内存构建好token - id和id - token两张映射表。BERT 原始词表大小是 30522bert-base-uncased这意味着 tokenizer 在把文本切成子词后任何一个子词都必须能在 30522 个词条里找到找不到就用[UNK]兜底。这里有一个很多人不知道的细节from_pretrained也可以直接传一个本地目录路径比如你在国内网络环境下无法访问 Hugging Face就可以先把模型和 tokenizer 下载到本地然后从本地目录加载。只要目录里有vocab.txt和配置文件它就能正常实例化完全不依赖网络。1.2 WordPieceBERT 分词的核心逻辑WordPiece 的切分逻辑可以理解为“贪心最长匹配”。具体来说它先把句子按空格或者标点粗切一次得到一个个“词”然后对每个词尝试用词表里的词进行最长前缀匹配。如果整个词都在词表里就直接保留如果不在就把最长的前缀切出来作为一个 token剩下的部分前面加两个#标记后继续匹配。举个例子playing如果不在词表里它可能会被切成play和##ing。##前缀表示这个 token 是接在前一个 token 后面的不是一个完整词的开头。解码的时候tokenizer 看到##会把前后 token 直接拼接不需要加空格看到不带##的 token 之间则补一个空格。理解这个机制非常重要因为它解释了为什么tokenizer.tokenize()的结果看起来“缺胳膊少腿”那不是 bug是设计。再往深一层想WordPiece 这种切分方式让词表里的每个 token 都承载了比较稳定的语义片段。比如##ing总是在词尾出现模型看到它就能学会“这可能是进行时”的信号。这种子词级的建模能力是直接用 jieba 词级切分很难模仿的。所以即便你做中文任务也不建议用 jieba 切完再拿 BERT 的分词器去查 id因为中文 BERT 本身是字级的强行切成词只会制造大量[UNK]。2. 最常用的 API 拆解从 tokenize 到 encode_plusBertTokenizer 的可调用方法有很多但实际开发中真正高频用到的就那么几个。我把它们从底到顶拆开讲这样你不仅能会用出了问题也知道去哪排查。2.1 tokenize 与 convert_tokens_to_ids最底层的两条腿先看两个最基础的方法from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-uncased) text I love NLP! tokens tokenizer.tokenize(text) print(tokens) # 输出[i, love, nl, ##p, !] ids tokenizer.convert_tokens_to_ids(tokens) print(ids) # 输出[1045, 2293, 17953, 2362, 999]注意NLP被切成了nl和##p而且I变成了小写的i。这就是uncased模型的效果先转小写再做 WordPiece 切分。如果你用bert-base-cased结果会变成[I, love, NL, ##P, !]I的 id 也不一样。convert_tokens_to_ids是一个纯查表操作输入 token 列表输出对应的 id 列表。反过来convert_ids_to_tokens把 id 列表还原成 token 列表。这两个方法是最底层的工具适合你 debug 的时候用。比如你想看看某个 id 到底是什么 token直接调用它就行不用自己去翻vocab.txt。tokenize有一个容易被忽略的参数add_special_tokens。默认是 False所以上面切出来的结果里没有[CLS]和[SEP]。这意味着如果你直接用tokenize的结果去查 id然后丢给模型模型是无法区分句子边界的。所以实际项目中我很少单独用tokenize而是用下面要讲的encode或encode_plus。2.2 encode 与 encode_plus一步到位的捷径encode可以理解为tokenize convert_tokens_to_ids的合并版并且自动加上特殊 token。ids tokenizer.encode(I love NLP!) print(ids) # 输出[101, 1045, 2293, 17953, 2362, 999, 102]开头多出来的101是[CLS]结尾的102是[SEP]。BERT 在预训练时[CLS]的最终隐藏状态被用作整个句子的汇总向量[SEP]用来分隔句子对。所以你喂给模型的句子理论上都应该带上这两个 token。encode还有两个常用参数max_length和truncation。比如ids tokenizer.encode(I love NLP!, max_length5, truncationTrue) print(ids) # 输出[101, 1045, 2293, 17953, 102]你会看到它在截断的同时仍然保留了[CLS]和[SEP]。这是设计上的刻意为之因为这两个 token 对模型来说有特殊含义不能随便截掉。encode_plus则在encode的基础上把token_type_ids和attention_mask也一起返回out tokenizer.encode_plus( I love NLP!, max_length20, paddingmax_length, truncationTrue, ) print(out.keys()) # dict_keys([input_ids, token_type_ids, attention_mask]) print(out[input_ids]) print(out[token_type_ids]) print(out[attention_mask])token_type_ids用来区分两个句子单句输入时全是 0attention_mask用来标记哪些位置是真实 token、哪些位置是 padding模型在计算注意力时会把 padding 位置遮掉。这两个信息都是训练和推理时必要的所以别只拿input_ids就跑。2.3 回到文本decode 和 convert_ids_to_tokens模型输出的是概率分布你要把它变成人能读懂的文本就需要解码。decode是encode的逆过程但它内部会做一件很关键的事跳过特殊 token并且处理##拼接。ids [101, 1045, 2293, 17953, 2362, 999, 102] text tokenizer.decode(ids) print(text) # 输出i love nlp! print(tokenizer.decode(ids, skip_special_tokensFalse)) # 输出[CLS] i love nlp! [SEP]注意decode默认skip_special_tokensTrue所以[CLS]和[SEP]被去掉了。##拼接也在内部完成你不需要自己处理。这里要提醒一个坑decode之后原来大写的I变成小写的i了NLP也变成了nlp。这是因为uncased模型在预处理阶段就把大小写信息丢了如果你需要还原原始文本仅靠 tokenizer 是做不到的必须在外面自己保存原始文本。如果你只想看中间层级的 token不想直接出文本可以用convert_ids_to_tokenstokens tokenizer.convert_ids_to_tokens(ids) print(tokens) # 输出[[CLS], i, love, nl, ##p, !, [SEP]]这个结果展示了最真实的切分状态是我排查文本对齐问题时的首选工具。3. 模型输入三板斧padding、truncation、return_tensors前面讲的都是单句处理。实际训练和推理时你几乎不会一条一条地喂数据而是把一个 batch 的文本一起丢给 tokenizer让它输出对齐后的张量。这一节我把最常用的批量处理姿势讲清楚。3.1 一次性处理整个 batchBertTokenizer 对象本身是可以被直接调用的它内部封装了encode_plus的批量逻辑。用法如下texts [ I love NLP!, Transformers are awesome., This is a much longer sentence that might need truncation., ] encoded tokenizer( texts, paddingTrue, truncationTrue, max_length20, return_tensorspt, ) print(encoded[input_ids].shape) # torch.Size([3, 20])这里发生了三件事第一paddingTrue会把 batch 里所有序列补齐到当前 batch 中最长的长度或者max_length如果指定了paddingmax_length。补齐用的 token 是[PAD]它在bert-base-uncased中的 id 是 0。第二truncationTrue会把超过max_length的句子截断。默认截断方式是从尾部开始截也就是只保留前面max_length个 token。如果你在处理句子对任务可能希望只截断第二个句子这时候可以设置truncationonly_second。第三return_tensorspt表示返回 PyTorch 张量。如果你用 TensorFlow就传tf如果只想拿普通数组就传np或者不传。三个输出张量的形状都是[batch_size, max_length]可以直接喂给 BERT 模型。token_type_ids在单句任务里全是 0但批量返回时它仍然被保留因为双句任务要用到。3.2 参数组合到底怎么选Hugging Face 的 tokenizer 参数比较多很多初学者分不清padding和truncation的取值到底有几个含义。我整理了一张速查表参数取值含义paddingFalse不填充序列保持各自原始长度paddingTrue填充到当前 batch 的最大长度paddingmax_length填充到 max_length 指定长度truncationFalse不截断truncationTrue截断到 max_length默认从尾部截truncationonly_first只截断句子对中的第一个句子truncationonly_second只截断句子对中的第二个句子max_lengthint配合 padding 和 truncation 使用实际项目中我建议训练时用paddingmax_length配合固定的max_length这样整个 batch 的形状在训练过程中始终保持一致不容易出 bug推理时可以用paddingTrue减少不必要的计算。但要注意如果你用paddingTrue同一 batch 内所有样本必须走同一次 tokenizer 调用否则长度不一致collator 会报错。这也是很多人在 DataLoader 里遇到 shape mismatch 的原因。还有一个小技巧max_length不是拍脑袋设的而是根据你的数据分布定的。我先统计语料里 token 化之后的长度分布一般取 95 分位比如 90% 的样本 token 数都小于 180那max_length180就够用既不会丢太多信息又能省显存。这个思路在长文本分类、阅读理解任务里特别重要。3.3 与 PyTorch DataLoader 的衔接把 tokenizer 和 DataLoader 配合起来是工程里最容易出问题的一环。我见过不少同学在__getitem__里对每一条样本单独做tokenizer(text, paddingmax_length, max_length128)然后返回 dictDataLoader 默认的collate_fn遇到 dict of tensors 时会沿着 batch 维度去 stack。如果你的每个样本都已经是固定长度了这样没问题。但如果你在__getitem__里用paddingTrue每条样本的长度可能不一样DataLoader 强行 stack 就会报错。这时候你有两个选择一个是所有样本在__getitem__里都 pad 到同一个最大长度另一个是写一个自定义collate_fn在 batch 里动态 pad。第二种更高效因为不会浪费太多显存。我这里给一个通用的自定义 collatordef collate_fn(batch): texts [item[text] for item in batch] labels [item[label] for item in batch] encoded tokenizer( texts, paddingTrue, truncationTrue, max_length128, return_tensorspt, ) encoded[labels] torch.tensor(labels) return encoded用这个collate_fn你的Dataset.__getitem__只需要返回原始文本和 label所有 tokenizer 逻辑都集中到 collator 里。好处是分词只发生在一个 batch 内一次完成速度更快坏处是如果你在__getitem__里做了数据增强比如文本替换那你需要在返回前就把增强逻辑写好。这个方案我用了很久稳定性很高。4. 实际项目中的进阶操作与避坑工具讲完接下来聊聊真实项目里那些文档里不一定会写的坑和技巧。4.1 中文场景的细节中文 BERT 模型一般直接用bert-base-chinese它的分词器是字级别的也就是每个汉字、每个英文字母、每个中文字符都单独成一个 token。举个例子tokenizer BertTokenizer.from_pretrained(bert-base-chinese) tokens tokenizer.tokenize(我爱自然语言处理) print(tokens) # 输出[我, 爱, 自, 然, 语, 言, 处, 理]看到没中文分词被简化成了“切字”而不是按词切。这对中文任务来说效果并不差因为 BERT 预训练时就是这么训练的字级别的输入对它来说已经包含足够上下文信息。你不需要画蛇添足先跑一遍 jieba 再喂给它。我见过有人把所有词用空格隔开后传给bert-base-chinese结果词表里根本没有“自然语言”这种整词全部被切成单字白白增加了输入长度。如果你的任务同时包含中文和英文比如商品标题是“Apple iPhone 15 手机 128G”直接用bert-base-chinese也能处理英文部分会被切成字母级 token中文部分按字切。但这种场景我更推荐用专门的多语言模型比如bert-base-multilingual-cased它对中英混合的支持更均衡一点。具体用哪个取决于你的验证集效果不是拍脑袋定的。中文任务还有一个隐藏坑标点符号。中文全角标点比如。在bert-base-chinese的词表里是存在的但半角标点, . !在某些旧版本 tokenizer 里可能被处理成[UNK]。所以清洗数据时最好统一标点符号格式不要让全角和半角混着来。4.2 保存、加载与自定义词表你微调模型时如果往 tokenizer 里加过新 token比如某个领域专属词、或者你添加了[PROMPT]这样的特殊 token那么训练完之后一定要把 tokenizer 也保存下来。用法很简单tokenizer.save_pretrained(./my_tokenizer)下次加载时用tokenizer BertTokenizer.from_pretrained(./my_tokenizer)它会自动读取vocab.txt和所有配置文件。这里要特别注意如果你往词表里加了新 token但模型 embedding 层的大小没有同步调整会在加载模型时直接报错。因为 BERT 的 embedding 矩阵行数必须和词表大小一致。正确做法是先tokenizer.add_tokens(new_tokens)然后调整模型 embeddingmodel.resize_token_embeddings(len(tokenizer))resize_token_embeddings这个方法会保留原有 embedding 的权重只在末尾追加新的随机初始化向量。所以不用担心加了 token 之后预训练权重失效。自定义词表时还有一个操作tokenizer.add_special_tokens。注意它和add_tokens是有区别的。特殊 token 会被加入special_tokens_map解码时会自动跳过而且可以用tokenizer.sep_token、tokenizer.cls_token这样的属性访问。普通 custom token 则没有这些高级行为所以如果你要加的是类似[BOS]这种有特殊语义的 token用add_special_tokens更合适。4.3 常见报错与排查手册最后把几个高频问题统一整理出来都是我在群里、论坛里被问过无数次的。问题一所有中文都变成[UNK]排查思路先确认你加载的是不是中文模型。如果你用了bert-base-uncased去处理中文文本那几乎必然全是[UNK]因为这个模型的词表里根本没有中文字符。另外确认你的模型是不是bert-base-chinese或者一个包含中文的多语言模型。还有一个原因是文本编码格式问题比如文件本身是 GBK 编码读进来的字符串到 tokenizer 手里已经是乱码自然查不到词表。遇到这种情况读文件时指定encodingutf-8往往就好了。问题二模型输入长度不一致报错 shape mismatch排查思路检查 tokenizer 是否用了paddingTrue或paddingmax_length同时检查collate_fn是否正常工作。最稳妥的方式是在构造 batch 后用print(encoded[input_ids].shape)验证。如果 shape 是[1, seq_len]而不是[batch_size, seq_len]说明你传入 tokenizer 的是一个单独的字符串而不是字符串列表。这是一个非常经典的错误tokenizer(text)和tokenizer([text])返回的维度不一样。问题三token_type_ids全是零句子对任务效果异常排查思路检查你是否用了encode_plus(text_a, text_b)或tokenizer(text_a, text_b)这种双句输入形式。如果你只传了一个句子token_type_ids当然全是 0。另外注意token_type_ids在较新的 transformers 版本里已经默认不返回了除非你显式设置return_token_type_idsTrue。很多人的老代码升级库之后发现 key 不存在就是这个原因。问题四decode 之后文本和原始文本对不上排查思路这是正常的尤其用 uncased 模型时大小写信息会丢失\n等空白字符会被 tokenizer 特殊处理WordPiece##拼接可能导致单词边界变化。不要指望 decode 能完美还原原始文本它只是给你一条“模型视角”下的文本。问题五from_pretrained下载超时或网络不通排查思路使用本地路径加载 tokenizer。先把整个 checkpoint 目录下载下来然后传目录路径给from_pretrained。也可以设置环境变量HF_ENDPOINT或使用国内的镜像源但最稳妥的还是“下载到本地、从本地加载”这个思路部署上线时也建议这么做避免运行时依赖外网。问题六分词结果里出现大量[UNK]但是英文不是中文排查思路查看你的文本里是不是有 emoji、特殊符号、生僻 Unicode 字符。bert-base-uncased的词表只覆盖常见英文和少量符号碰到 emoji 或者特殊货币符号时很可能会落到[UNK]。如果这类样本占比高可以考虑加 token 或者换更大的词表模型。4.4 一个小众但很实用的技巧直接操作词表有时候你要做文本层面的过滤或者想检查一个 token 是否在词表里不需要走完整的 tokenize 流程。tokenizer.vocab是一个 dict保存了完整的 token 到 id 映射你可以直接查if 自然 in tokenizer.vocab: print(tokenizer.vocab[自然])反过来tokenizer.ids_to_tokens可以拿到 id 到 token 的映射。这两个属性在需要手写规则或者调试时非常有用。不过要提醒一句tokenizer.vocab在加了新 token 之后会自动更新吗答案是会前提是你用的是同一个 tokenizer 对象调用add_tokens或add_special_tokens。但如果你先调用了add_tokens然后又重新from_pretrained加载旧的vocab.txt新 token 就丢了。这也是为什么训练后必须save_pretrained的原因。5. 一套可以直接抄的完整流程前面讲得比较散最后我整合一个真实项目里最常见的流程加载预训练模型、预处理文本、构造 DataLoader、训练、保存 tokenizer全部串起来。from transformers import BertTokenizer from torch.utils.data import Dataset, DataLoader import torch tokenizer BertTokenizer.from_pretrained(bert-base-chinese) class MyDataset(Dataset): def __init__(self, texts, labels): self.texts texts self.labels labels def __len__(self): return len(self.texts) def __getitem__(self, idx): return {text: self.texts[idx], label: self.labels[idx]} def collate_fn(batch): texts [x[text] for x in batch] labels [x[label] for x in batch] encoded tokenizer( texts, paddingTrue, truncationTrue, max_length128, return_tensorspt, ) encoded[labels] torch.tensor(labels) return encoded texts [这个电影真好看, 剧情太拖沓了, 演员演技在线] labels [1, 0, 1] dataset MyDataset(texts, labels) dataloader DataLoader(dataset, batch_size2, shuffleTrue, collate_fncollate_fn) for batch in dataloader: print(batch[input_ids].shape) print(batch[attention_mask].shape) print(batch[token_type_ids].shape) print(batch[labels]) break这套流程里tokenizer 只在collate_fn里被调用一次足以保证 batch 内对齐。如果你想加快速度可以分词结果缓存到磁盘然后训练时直接加载 id 序列但那是另一个话题了。最后再分享一个小经验每次用新数据前我都会抽 10 条样本把tokenizer.tokenize()的结果打印出来看一眼文本被切成了什么样、有没有异常 token。这一步只要花两分钟却能避免太多后期的定位问题。记住BertTokenizer 不是“黑盒工具”你越了解它在内部做了什么用起来就越心里有底。