【Transformer】Tokenization详解_BPE_WordPiece_SentencePiece

文章目录

摘要:大模型处理的不是"字"或"词",而是 token。Tokenization 决定一段文本会被切成多少 token,直接影响上下文长度、API 成本、训练效率、KV Cache、RAG 分块、代码能力和多语言效果。本文从"为什么不能直接按词切分"讲起,解释 token、token id、vocab、embedding 的关系,再用可运行的小实验讲清 BPE、WordPiece、SentencePiece 的直觉,最后讨论特殊 token、chat template、token 成本和 tokenizer 绑定模型的工程边界。

前置知识 : Embedding, Transformer, GPT 路线

阅读时间 :约 55-70 分钟

代码环境:Python 3.10+,示例只依赖标准库

入门导读:先抓住主线

如果你第一次读 Tokenization,把它理解成"大模型的输入编码协议"。文本必须先被切成 token,再转成 token id,最后查 embedding 表,模型才能处理。

Tokenization 看起来像预处理,实际上会影响很多工程问题:

text 复制代码
同样一篇文档,token 数越多,上下文越容易不够;
同样一个 API,token 数越多,费用和延迟越高;
同样一段代码,切分越差,模型越难学习变量和符号;
同样一个 chat 模型,模板和特殊 token 错了,行为就可能错。

读完本文,你应该能回答:token 是什么,BPE/WordPiece/SentencePiece 大概怎么构建词表,为什么不能随便替换 tokenizer。


一、token、token id、vocab 和 embedding 的关系

大模型不能直接处理字符串。输入文本要经过下面流程:

text 复制代码
文本 -> tokenizer -> token 列表 -> token ids -> embedding vectors -> Transformer

几个概念要分清:

概念 含义
token tokenizer 切出来的文本片段,可以是字、子词、词、符号或字节片段
token id token 在词表里的整数编号
vocab tokenizer 的词表,记录 token 到 id 的映射
embedding 模型中每个 token id 对应的向量

一个玩具例子:

python 复制代码
# Python 3.10+

vocab = {
    "<pad>": 0,
    "大": 1,
    "模型": 2,
    "很": 3,
    "强": 4,
}

tokens = ["大", "模型", "很", "强"]
ids = [vocab[t] for t in tokens]
print(tokens)
print(ids)

真实 tokenizer 的词表可能有几万到几十万个 token。模型的 embedding 表行数通常等于词表大小。token id 不是随便编号,它和模型参数绑定。


二、为什么不能直接按词切分

按词切分听起来直观,但很快会遇到问题:

text 复制代码
英文有 run/running/runner/runs 等词形变化;
中文没有天然空格;
代码有变量名、缩进、括号、路径;
新词、产品名、人名、术语不断出现;
emoji、URL、数字、公式很难按词处理;
如果每个词都进词表,词表会巨大。

如果词表太小,很多词会变成 unknown;如果词表太大,embedding 和输出层参数会变多,训练和推理成本上升。

子词切分是折中:常见词可以整体表示,罕见词拆成更小片段。例如:

text 复制代码
unbelievable -> un + believe + able
get_user_profile -> get + _ + user + _ + profile

子词方法既能处理新词,又能控制词表大小。


三、BPE:从字符开始,不断合并高频片段

BPE(Byte Pair Encoding)的大致思路是:先把文本拆成很小单位,再反复合并最常见的相邻片段。

流程:

text 复制代码
1. 初始词表:字符或字节;
2. 统计语料中相邻片段的频率;
3. 选择最常见的一对合并成新 token;
4. 更新语料表示;
5. 重复直到达到目标词表大小。

用一个极简 BPE 训练过程演示。下面不是生产级 tokenizer,只展示合并思想:

python 复制代码
# Python 3.10+
from collections import Counter


def get_pairs(words):
    pairs = Counter()
    for symbols, freq in words.items():
        for a, b in zip(symbols, symbols[1:]):
            pairs[(a, b)] += freq
    return pairs


def merge_pair(words, pair):
    merged_words = {}
    bigram = "".join(pair)
    for symbols, freq in words.items():
        new_symbols = []
        i = 0
        while i < len(symbols):
            if i < len(symbols) - 1 and (symbols[i], symbols[i + 1]) == pair:
                new_symbols.append(bigram)
                i += 2
            else:
                new_symbols.append(symbols[i])
                i += 1
        merged_words[tuple(new_symbols)] = freq
    return merged_words

words = {
    tuple("low") + ("</w>",): 5,
    tuple("lower") + ("</w>",): 2,
    tuple("newest") + ("</w>",): 6,
    tuple("widest") + ("</w>",): 3,
}

for step in range(5):
    pairs = get_pairs(words)
    best = pairs.most_common(1)[0][0]
    print("step", step, "merge", best)
    words = merge_pair(words, best)
    print([" ".join(w) for w in words])

观察输出,你会看到高频相邻片段逐步变成更长 token。BPE 的核心就是让常见片段短路径表示,罕见词仍可拆开。


四、WordPiece:不只看频次,还看合并价值

WordPiece 和 BPE 很像,也构建子词词表。但 WordPiece 在选择合并时更关注合并后对语言模型似然的提升,常见解释中会考虑片段之间的统计关联,而不是只看相邻频次。

BERT 使用 WordPiece。你经常会看到 ## 前缀:

text 复制代码
unwanted -> un ##want ##ed
playing -> play ##ing

## 表示这个子词不是词开头,而是接在前面片段后面。

可以用一个简单规则模拟 WordPiece 的输出风格:

python 复制代码
# Python 3.10+

vocab = {"un", "want", "ed", "play", "ing", "##want", "##ed", "##ing"}


def toy_wordpiece(word):
    pieces = []
    start = 0
    while start < len(word):
        found = None
        for end in range(len(word), start, -1):
            piece = word[start:end]
            candidate = piece if start == 0 else "##" + piece
            if candidate in vocab:
                found = candidate
                break
        if found is None:
            return ["[UNK]"]
        pieces.append(found)
        start = end
    return pieces

for w in ["unwanted", "playing", "unknown"]:
    print(w, "->", toy_wordpiece(w))

这个 toy tokenizer 很粗糙,但能说明 WordPiece 的两个特点:最长匹配和非开头子词标记。


五、SentencePiece:不依赖空格分词

SentencePiece 常用于多语言模型。它的一个重要特点是:可以直接从原始文本训练 tokenizer,不需要先按语言规则分词;空格也会作为普通符号处理。

这对中文、日文、多语言混合文本很有帮助,因为不是所有语言都有天然空格。

SentencePiece 常用特殊符号表示空格,例如 ▁。直觉上:

text 复制代码
Hello world -> ▁Hello ▁world
我喜欢AI -> ▁我 喜欢 AI

下面用一个简化函数模拟"把空格显式保留"的直觉:

python 复制代码
# Python 3.10+

texts = ["Hello world", "我 喜欢 AI", "北京weather不错"]
for text in texts:
    marked = "▁" + text.replace(" ", " ▁")
    print(text, "->", marked)

真实 SentencePiece 有 Unigram 或 BPE 等训练算法,这里不展开。入门阶段先抓住:它把原始文本统一看待,适合多语言和无空格语言。


六、token 数为什么影响成本和上下文

模型上下文长度按 token 计,不按字符计。如果上下文是 8K token,系统提示、用户输入、历史对话、检索文档、工具结果、模型输出都要共享这 8K 预算。

token 数会影响:

text 复制代码
1. API 费用;
2. prefill 延迟;
3. KV Cache 显存;
4. RAG 能放多少证据;
5. 训练样本长度;
6. batch size 和吞吐。

用一个粗略 tokenizer 对比不同文本:

python 复制代码
# Python 3.10+
import re

pattern = r"[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?|[\u4e00-\u9fff]|[^\s]"

samples = [
    "大模型正在改变软件开发。",
    "Large language models are changing software development.",
    "def get_user_profile(user_id): return db.query(user_id)",
    "退款金额为12,345.67元,状态为pending。",
]

for text in samples:
    tokens = re.findall(pattern, text)
    print(text)
    print(tokens)
    print("rough_count=", len(tokens), "\n")

这个例子不代表真实模型 token 数,但能提醒你:中文、英文、代码、数字、符号会被不同方式切分。正式工程必须使用目标模型 tokenizer 统计。


七、特殊 token:模型协议的一部分

特殊 token 用来表示结构,不只是普通文本。不同模型架构的特殊 token 约定并不通用,一定要以目标模型自己的 tokenizer 为准。下表按"常见于哪类模型"归类:

token 作用 常见于
BOS (<s>, `< begin_of_text >`)
EOS (</s>, `< endoftext >`)
PAD padding 填充 通用(部分模型直接复用 EOS 作为 PAD)
UNK 未知 token(无法切分时的兜底) 传统 tokenizer 更常见;BPE/字节级 BPE 通常不需要
[CLS] 序列开头,代表整段的池化表示 Encoder-Only(BERT/RoBERTa 等);Decoder-Only 里通常没有
[SEP] 句子分隔 / 段落分隔 Encoder-Only
[MASK] MLM 训练里被遮住需要预测的位置 Encoder-Only(专门为 MLM 任务设计)
system / user / assistant 对话角色(一般由 chat template 展开成若干普通 token) Chat 模型
tool / function 工具/函数调用边界 支持 function calling 的模型
image / audio 多模态输入占位 多模态模型

关键区分 :[CLS]、[SEP]、[MASK] 是 BERT 系 Encoder-Only 模型专用的(第 25 篇讲过),GPT / LLaMA / Qwen 等 Decoder-Only 模型的 tokenizer 里没有 这些 token,它们的输入约定是 BOS + 文本 + EOS,多轮对话通过 chat template(本质上仍是普通 token 序列)来编码角色。所以你不能拿 BERT 那套 [CLS]...[SEP]... 去喂 LLaMA,反过来也不行。

对聊天模型来说,特殊 token 和 chat template 是训练时学到的协议。如果你在推理或 SFT 时乱改,模型可能角色混乱、输出残留标记或不知道何时停止。

一个 toy chat template:

python 复制代码
# Python 3.10+

def apply_chat_template(messages):
    text = ""
    for m in messages:
        text += f"<|{m['role']}|>\n{m['content']}\n"
    text += "<|assistant|>\n"
    return text

messages = [
    {"role": "system", "content": "你是一个技术助手。"},
    {"role": "user", "content": "解释 KV Cache。"},
]
print(apply_chat_template(messages))

真实项目必须使用模型官方 tokenizer 提供的 chat template。模板不是美化格式,而是模型接口协议。


八、tokenizer 和模型为什么绑定

Embedding 表的行数通常等于词表大小。token id 直接决定查哪一行 embedding。

如果模型 A 的 token id 100 表示"模型",模型 B 的 token id 100 表示"apple",你把 A 的 tokenizer 配给 B,输入就会错位。模型看到的不是你以为的文本。

因此不能随便替换 tokenizer。除非你明确知道两个模型 tokenizer 兼容。

新增 token 也不是只改词表。你需要 resize embedding,并训练新 token 对应的向量。否则新 token 的 embedding 随机初始化,模型不知道它是什么意思。


九、tokenizer 对 RAG 和代码模型的影响

RAG 分块通常按 token 控制长度。如果你按字符切块,可能出现:

text 复制代码
1. 某些块 token 超长,被截断;
2. 中文和英文块成本差异大;
3. 代码块被切断,语法破坏;
4. 表格行被拆散;
5. 引用证据不完整。

代码模型也很依赖 tokenizer。变量名、缩进、括号、路径、下划线、驼峰命名,如果切分过碎,会增加学习和推理成本。

例如:

text 复制代码
get_user_profile_by_account_id

如果能合理拆成 get、user、profile、account、id,模型更容易理解;如果拆成很多不稳定片段,代码能力会受影响。


十、常见误区

误区一:token 等于中文一个字或英文一个词。 不一定。token 是 tokenizer 定义的子词、字节或符号单位。

误区二:字符数可以准确估算 token 数。 只能粗略估计。正式计费、上下文控制和 RAG 分块必须用目标 tokenizer。

误区三:chat template 只是提示词格式。 它是模型对话协议的一部分,影响训练和推理行为。

误区四:tokenizer 可以随便替换。 通常不行。tokenizer 和 embedding/LM head 强绑定。

误区五:词表越大越好。 词表大可能减少切分长度,但会增加 embedding 和输出层参数,也可能影响泛化。


总结

Tokenization 把文本切成模型可处理的 token ids。BPE 从字符/字节开始合并高频片段,WordPiece 更关注子词合并的统计价值,SentencePiece 直接处理原始文本并适合多语言场景。token 数影响成本、上下文长度、KV Cache、RAG 分块和训练效率;特殊 token 和 chat template 则影响模型是否正确理解输入结构。

第一遍记住一句话:大模型不是按字数工作,而是按 tokenizer 切出来的 token 工作;tokenizer 是模型接口的一部分,不是随手可换的预处理脚本。

大模型视角

后面讲上下文长度、API 成本、SFT 数据格式、RAG 分块、代码模型和多模态模型时,tokenization 都是基础。很多"为什么上下文不够用""为什么费用变高""为什么微调后格式乱"的问题,本质上都和 token 预算或模板协议有关。

下一篇

KV Cache 原理与实现:推理加速的核心技巧 ------ token 是模型生成的单位。下一篇看自回归生成时,为什么缓存历史 Key/Value 能大幅加速推理,同时又带来显存压力。

相关推荐
明志数科1 小时前
具身智能训练数据分布设计:为什么分布比数量更关键
人工智能·深度学习·机器学习
haerapi1 小时前
仓库拣货路线不只靠最短边:S 形启发式的反例记录
android·开发语言·javascript
啥都鼓捣的小yao2 小时前
自主机器人基础
人工智能·深度学习·机器人
其实防守也摸鱼11 小时前
Fastjson 反序列化漏洞(JNDI注入)
android·数据库·安全·oracle·自动化·fastjson·反序列化
狗凯之家源码网12 小时前
微博图床 PHP 系统搭建与实战应用指南
android·开发语言·php
消失的旧时光-194313 小时前
(第一篇)JNI 多线程到底难在哪:从 Linux Thread 到 JNIEnv
android·jni·ndk
一直在努力的小宁14 小时前
【阅读笔记】具身智能的真机数采,到了分水岭
人工智能·深度学习·机器学习·agent·具身智能·vlm·vln
AI人工智能+14 小时前
营业执照识别技术通过图像预处理、版面定位、深度学习OCR、NLP语义解析与智能校验五步流程,实现对倾斜、反光、遮挡等复杂照片的毫秒级精准识别
人工智能·深度学习·自然语言处理·营业执照识别
码农幻想梦15 小时前
深度学习与神经网络(二)
人工智能·深度学习·神经网络