问题与目标
模型不能直接处理字符串。Tokenizer 先把文本切成 Token,再通过词表把 Token 映射为整数 ID。分词粒度会影响词表大小、序列长度、未知词比例和最终计算成本。
本篇区分字符、词和子词三种粒度,说明 BPE 与 WordPiece 的直观思路,并实现包含特殊 Token、截断、补齐和 Attention Mask 的最小分词器。重点是看清每一步的数据,而不是把 Tokenizer 当成黑盒。

同一句文本会先变成 Token,再变成等长 ID 序列。Attention Mask 用 1 标记有效位置、用 0 标记补齐位置,避免模型把 <pad> 当作正文参与计算。
核心概念
三种分词粒度
- 字符级:词表小、几乎没有未知词,但序列通常更长,完整词义需要模型自行组合。
- 词级:结果接近阅读习惯,但中文词边界不天然明确,词表大且容易遇到未登录词。
- 子词级:在字符与完整词之间折中,高频片段保留为整体,低频词拆成更小单元,是预训练模型的常见选择。
英文字符串 unfriendly 可以拆为 un + friend + ly。中文中的专业名词也可能由多个字或片段组成。子词算法不需要预先枚举所有完整词,而是从语料统计中逐渐构建词表。
BPE 和 WordPiece 的直观区别
BPE 从更小单元出发,反复合并语料中高频相邻单元。WordPiece 同样构建子词词表,但选择合并项时更关注合并后对语言模型似然的改善。理解阶段应先抓住共同目标:用有限词表覆盖开放词汇,而不是死记算法细节。
特殊 Token
| Token | 常见作用 |
|---|---|
<pad> | 把批次中的序列补到相同长度 |
<unk> | 表示词表无法覆盖的内容 |
<bos> / <sos> | 标记序列开始 |
<eos> | 标记序列结束,也可作为生成停止条件 |
<cls> | 汇总整段输入,常用于分类 |
<sep> | 分隔句子或不同输入片段 |
<mask> | 掩码语言模型的遮盖位置 |
不同模型使用的名称和组合可能不同,不能把某个模型的 Token ID 硬编码到另一个模型中。模型权重、词表和 Tokenizer 必须配套。
可运行实现
下面实现一个用于说明流程的最长匹配分词器。它不是生产级 BPE,但完整展示 Token、ID、截断、补齐和 Mask 的关系。
from dataclasses import dataclass
VOCAB = [
"<pad>", "<unk>", "<cls>", "<sep>",
"巡检", "模型", "发现", "异常", "节点", "已", "恢复",
]
@dataclass
class EncodedText:
tokens: list[str]
input_ids: list[int]
attention_mask: list[int]
class GreedyTokenizer:
def __init__(self, vocab: list[str]):
self.token_to_id = {token: index for index, token in enumerate(vocab)}
self.words = sorted(
[token for token in vocab if not token.startswith("<")],
key=len,
reverse=True,
)
def tokenize(self, text: str) -> list[str]:
tokens = []
cursor = 0
while cursor < len(text):
if text[cursor].isspace():
cursor += 1
continue
matched = next(
(word for word in self.words if text.startswith(word, cursor)),
None,
)
if matched:
tokens.append(matched)
cursor += len(matched)
else:
tokens.append(text[cursor])
cursor += 1
return tokens
def encode(self, text: str, max_length: int = 10) -> EncodedText:
content = self.tokenize(text)[: max_length - 2]
tokens = ["<cls>", *content, "<sep>"]
ids = [self.token_to_id.get(token, self.token_to_id["<unk>"]) for token in tokens]
mask = [1] * len(ids)
pad_count = max_length - len(ids)
tokens.extend(["<pad>"] * pad_count)
ids.extend([self.token_to_id["<pad>"]] * pad_count)
mask.extend([0] * pad_count)
return EncodedText(tokens, ids, mask)
tokenizer = GreedyTokenizer(VOCAB)
encoded = tokenizer.encode("巡检模型发现 NX-3 异常", max_length=10)
print("tokens:", encoded.tokens)
print("ids: ", encoded.input_ids)
print("mask: ", encoded.attention_mask)
可能的输出如下:
tokens: ['<cls>', '巡检', '模型', '发现', 'N', 'X', '-', '3', '异常', '<sep>']
ids: [2, 4, 5, 6, 1, 1, 1, 1, 7, 3]
mask: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
NX-3 不在词表中,所以被拆成字符并映射为 <unk>。真实子词分词器通常能用更细的已知片段减少未知词。把 max_length 改大后,还能观察 <pad> 和 Attention Mask 如何出现。
常见问题与排查
Token 数量和看到的文字数量不一致
Token 不是字数或单词数。同一段文本在不同 Tokenizer 下可能得到不同长度。成本估算和上下文限制必须使用实际 Tokenizer 计算,不能直接按字符数猜测。
已经补齐,模型仍关注补齐位置
检查是否把 attention_mask 传给模型,以及掩码中有效位置和补齐位置是否写反。生成模型还要区分 Padding Mask 与 Causal Mask,它们解决的不是同一个问题。
手工修改词表后模型效果异常
新增 Token 会改变词表大小,模型嵌入矩阵也要同步扩展并训练。直接替换词表文件可能让原有 ID 与权重错位。
截断后关键内容消失
记录截断比例,检查信息通常出现在文本开头、结尾还是中间。长文档不能只依赖粗暴截断,后续可能需要滑动窗口、分块或检索策略。
小结
Tokenizer 决定了字符串怎样进入模型。排查文本模型时,至少应打印 Token、input_ids、attention_mask、实际长度和截断情况。只有看清这些中间结果,才能区分问题出在文本、分词器还是模型本身。
License: CC BY-NC 4.0
Updated 2 hours ago
Was this article helpful? Give it a like.
0 comments


