LLM 的 Tokenizer 本质是「将任意文本映射到有限词表的整数序列」。主流方法沿着 BPE(频率驱动的贪心合并)→ WordPiece(最大化语言模型概率的合并)→ Unigram(从大词表剪枝,概率最优)→ SentencePiece(语言无关的统一框架)→ BBPE/tiktoken(字节级杜绝 OOV) 这条路演进;选哪种取决于语言范围、数字/代码比例、服务延迟等工程约束。
语言模型的本质是:给定一段历史序列,预测下一个元素的概率。这个"元素"可以是:
- 字符级(Char-level):每个字母/汉字都是一个 token。序列太长,模型难以学习长程依赖。
- 单词级(Word-level):每个完整词语是一个 token。词表极大(英文 100 万+),出现生词(OOV)会崩溃。
- 子词级(Subword-level):介于两者之间,频率高的词保持完整,稀有词拆成子词。✅ 主流选择
文本:"Tokenization is important!"
- 字符级(27 token):
T o k e n i z a t i o n i s i m p o r t a n t ! - 单词级(3 token):
Tokenization | is | important! - BPE 子词级(5 token):
Token | ization | is | important | !
序列长度直接影响 Transformer 的计算量($O(n^2)$ attention),所以子词级方案在保持语义完整的同时,大幅减少序列长度。
Tokenizer 的两个阶段
训练阶段(构建词表)
在大型语料库上统计词频/共现,通过特定算法(BPE/WordPiece/Unigram)构建一个固定大小的词表(vocabulary),词表大小通常 32K–128K。这是一次性的离线过程。
推理阶段(编码/解码)
给定任意文本,按照词表规则将文本切分为 token id 序列(encode),或将模型输出的 id 序列还原为文本(decode)。推理时 tokenizer 是固定的,不再更新。
来源:1994 年提出用于数据压缩,2016 年被 Sennrich 等人引入 NMT(神经机器翻译),现为 GPT 系列(GPT-1/2/3/4-o)的基础方案。
核心思想
从字符级词表出发,反复合并语料库中最高频的相邻字节对,直到词表达到目标大小。这是一种贪心算法——每一步都选择当前最优的合并操作。
算法步骤(详细版)
每个字符=一个 token,加上特殊词尾符 ◁
将它作为新 token 加入词表
直到词表大小达到预设 K
语料(含词频):「low」×5,「lower」×2,「newest」×6,「widest」×3
初始词表(字符级,加词尾符):
📄 BPE 词表迭代过程Python
low</w>: 5 low e r</w>: 2 n e w e s t</w>: 6 w i d e s t</w>: 3
迭代 1:统计所有 bigram 频率:e s 出现 6+3=9 次 → 合并为 es
📄 BPE 词表迭代过程Python
low</w>: 5 low e r</w>: 2 n e w es t</w>: 6 w i d es t</w>: 3
迭代 2:es t 出现 6+3=9 次 → 合并为 est
📄 BPE 词表迭代过程Python
low</w>: 5 low e r</w>: 2 n e w est</w>: 6 w i d est</w>: 3
迭代 3:l o 出现 5+2=7 次 → 合并为 lo
以此类推,最终词表将包含:lo、low、est、new、newest 等子词。
关键点:遇到新词 lower,会先切成字符 l o w e r,然后按学到的合并规则从前往后合并:lo → low → low e r(若 er 也被合并过就继续合并)。
推理时编码(Encode)过程
训练阶段学到的是一个有序合并规则列表(merge rules),推理时按顺序应用这些规则:
📄 伪代码:BPE 编码(Encode)Python
# 伪代码:BPE 编码 def bpe_encode(word, merge_rules): tokens = list(word) + ['</w>'] # 字符级初始化 for pair in merge_rules: # 按训练顺序应用合并规则 tokens = apply_merge(tokens, pair) return tokens
✅ 简单直观,高频词不会被拆分;能处理稀有词(拆成子词);推理速度快(一次遍历合并规则)
❌ 贪心合并不保证全局最优;同一个词可能有多种合并方式(歧义);对数字处理不友好(如
12345 会被拆成 12 34 5,丢失数值大小关系)
代表性模型
GPT-1/2/3(原始 BPE)
词表大小 50,257(GPT-2)。操作在字符级别(Unicode 字符),空格被当作普通字符处理。│hello 和 hello 是不同 token(开头有空格与否不同)。
GPT-4 / LLaMA(BBPE / tiktoken)
升级为字节级 BPE(详见 §6),解决 Unicode 稀有字符的 OOV 问题。cl100k 词表 100,256 tokens,显著提升代码/数学/多语言能力。
来源:Google 在 2012 年用于语音识别(Schuster & Nakamura),后被 BERT(2018)广泛推广。
与 BPE 的核心区别
BPE 选择"频率最高的 pair"合并,WordPiece 选择"合并后使语言模型对训练数据的似然概率最大的 pair"合并。
判断是否合并 pair $(x, y)$ 的标准:
- $P(xy)$:片段 $xy$ 在语料中出现的频率(合并后)
- $P(x), P(y)$:片段 $x$ 和 $y$ 分别独立出现的频率
- 该分数本质是 互信息(Pointwise Mutual Information, PMI):越高说明 $x$ 和 $y$ 共同出现的频率远高于独立出现,值得合并为一个整体
假设语料中:
un出现 1000 次,happy出现 800 次,unhappy出现 50 次the出现 5000 次,cat出现 200 次,the cat出现 100 次
BPE 逻辑(纯频率):可能先合并 th + e → the(高频),这当然对,但 the cat 也因为出现 100 次而有合并倾向。
WordPiece PMI 逻辑:
score(the, cat) = P(the cat) / (P(the) × P(cat)) ≈ 很小(因为 the 和 cat 各自高频,联合出现只是巧合)
score(un, happy) = 明显更高(unhappy 的出现几乎都是 un+happy 组合,不是随机的)
PMI 准确捕捉了"这两个片段确实应该在一起"的情况,而不只看绝对频率。
WordPiece 的子词前缀规则
BERT 的 WordPiece 有一个重要约定:非开头的子词用 ## 前缀标记,表示这个子词紧跟在前面的 token 后面(没有空格)。
词表中没有 unaffordable 这个完整词,会被拆分为:
un + ##aff + ##ord + ##able
解码时看到 ## 前缀就知道拼接,重建原词:un + aff + ord + able = unaffordable
对比没有 ## 的 BPE:BPE 直接输出 un afford able,通过位置信息而非前缀来表示连续性。
✅ PMI 打分比纯频率更语义合理,更少产生"凑巧共现"的 token 合并
✅
## 前缀使得 decode 非常简单清晰❌ PMI 计算需要额外统计,训练速度比 BPE 慢;词表构建期间不能像 BPE 那样直接用二维频率表
代表性模型
BERT / BERT-multilingual / DistilBERT / ALBERT 均使用 WordPiece,词表大小通常 30,000。
来源:Kudo(2018)提出,被 T5、XLNet、mBART 等 Google/Facebook 模型采用。
核心思想:反向剪枝
BPE 和 WordPiece 都是从小词表出发向上合并,Unigram LM 反其道而行之:
- 从一个非常大的初始词表出发(包含所有常见字符串,可达 10 万+)
- 对每个 token,计算"如果删掉这个 token,语料库的似然损失多少"
- 删掉使损失最小的一部分 token(通常每轮删掉 10-20%)
- 重复直到词表达到目标大小
概率模型
假设语料中每个句子 $W$ 的最优分词方式为 $x^* = \arg\max_x P(x)$,其中:
- $x = (x_1, x_2, \ldots, x_n)$:句子的一种分词方案
- $p(x_i)$:子词 $x_i$ 在语言模型中的概率(unigram 概率,独立同分布假设)
- Unigram:字面意思是"一元语言模型",即每个子词的概率与上下文无关
- 训练目标:最大化整个语料的对数似然 $\sum_{s} \log P(s)$
训练时用 EM(Expectation-Maximization)算法迭代:
- E 步:给定当前词表,用 Viterbi/前向-后向算法找每个句子的最优分词
- M 步:用最优分词计算每个子词的频率,更新 $p(x_i)$
这个词有多种分词方式,Unigram 会给每种分配一个概率:
- 方案 A:
un+afford+able→ P = p(un) × p(afford) × p(able) - 方案 B:
u+naff+ordable→ P = p(u) × p(naff) × p(ordable) - 方案 C:
unafford+able→ P = p(unafford) × p(able)
如果 afford 和 un 在词表中都有较高概率,方案 A 胜出。如果 naff 和 ordable 从未见过,其概率极低,方案 B 被淘汰。
关键优势:Unigram 不像 BPE 那样强制执行固定合并顺序,而是给出所有可能分词的概率分布,可以用于子词正则化(Subword Regularization)——训练时随机按概率采样不同分词方案,增强模型鲁棒性。
来源:Google Kudo & Richardson(2018),开源工具库,支持 BPE 和 Unigram 两种算法。
核心创新:消除空格假设
BPE 和 WordPiece 都先用空格分词,再在词内部做子词分割——这对英语很自然,但对中文、日文等无空格语言失效,对泰语等粘着语也不友好。
SentencePiece 的做法:把空格作为特殊字符 ▁(U+2581),直接在原始字符流上做分词,不预先按空格切分。
输入:"Hello world"
传统 BPE(先分词再子词):先切成 ["Hello", "world"],再分别做 BPE → ["He", "##llo", "world"]
SentencePiece(原始字符流):将空格替换为 ▁ 后得到 "▁Hello▁world",直接在这个字符串上做分词 → ["▁Hello", "▁world"] 或 ["▁He", "llo", "▁world"]
中文处理:输入 "你好世界" 直接分词为 ["▁你好", "世界"] 或 ["▁你", "好", "世", "界"],不需要分词预处理。
SentencePiece 的四大特性
① 纯数据驱动
不依赖任何语言特定的预处理(不需要空格分词、形态分析、汉字分割等)。只需要原始文本,算法自己学习分词边界。
② 可逆编解码
通过 ▁ 标记,encode 和 decode 是完全可逆的,没有任何信息损失(包括空格位置)。
③ 两种算法可选
--model_type=bpe 或 unigram,框架统一,算法可插拔。T5 用 Unigram,LLaMA/Gemini 也用 SentencePiece+BPE。
④ 处理特殊字符
支持自定义特殊 token([PAD], [UNK], [BOS], [EOS]),支持 byte fallback 模式(对 OOV 字符用 <0xHH> 表示)。
代表性模型
| 模型 | 算法 | 词表大小 | 特殊处理 |
|---|---|---|---|
| T5 | SentencePiece Unigram | 32,000 | 无 byte fallback |
| XLNet | SentencePiece BPE | 32,000 | 同 T5 |
| LLaMA 1/2 | SentencePiece BPE | 32,000 | byte fallback ON |
| Gemini / PaLM | SentencePiece BPE | 256,000 | 多语言扩展词表 |
BBPE:Byte-level BPE
问题背景:原始 BPE 以 Unicode 字符为基本单位,遇到罕见 Unicode 字符(如生僻汉字、emoji、特殊符号)会触发 OOV(out-of-vocabulary)——模型完全不认识这个 token,只能用 [UNK] 替代,丢失信息。
BBPE 的解法:将基本单位从"Unicode 字符"换为"字节"(byte)。任何文本都可以表示为字节序列(UTF-8 编码),256 个字节足以表示所有 Unicode 字符,因此初始词表只有 256 个 token,绝无 OOV。
第一步:理解"字节"是什么
计算机存储文字时,最终都是一串 0-255 的整数(称为"字节")。 UTF-8 就是一套规则,规定"每个 Unicode 字符应该用哪几个字节来表示"。
常见例子:
- 字母
A→ 只需 1 个字节:65 - 汉字
你→ 需要 3 个字节:228, 189, 160 - 生僻字
𠀀→ 需要 4 个字节:240, 160, 128, 128 - emoji
🌍→ 需要 4 个字节:240, 159, 140, 141
第二步:那 0xF0 0xA0 0x80 0x80 和 [240, 160, 128, 128] 是什么关系?
完全是同一组数字,只是写法不同——一个用十六进制(0x 开头),一个用十进制:
| 十六进制(0x) | → | 十进制 |
|---|---|---|
0xF0 | → | 240 |
0xA0 | → | 160 |
0x80 | → | 128 |
0x80 | → | 128 |
所以 𠀀 这个字,就是 4 个字节:240, 160, 128, 128。
第三步:BBPE 怎么处理这 4 个字节?
BBPE 的词表里有 256 个基础 token,就是 0-255 这 256 个字节 id,相当于 256 块乐高积木。任何文字最终都能被拆成这 256 块积木的组合,永远不会 OOV。
"𠀀" → 词表里没有 → [UNK](信息丢失!)
# BBPE(字节级)
"𠀀" → UTF-8 字节 → [240] [160] [128] [128](4 个 token,保留全部信息)
# 如果 𠀀 在语料中频繁出现,BPE 会把这 4 个字节合并成 1 个 token
[240, 160, 128, 128] → 合并 → [token_99999](效率更高)
GPT-2 的处理:256 个字节基础 token + 常见字节序列合并 → 最终 50,257 词表。
tiktoken:OpenAI 的高性能实现
tiktoken 是 OpenAI 开源的 BPE tokenizer 实现(Rust 编写,Python bindings),采用 BBPE,是 GPT-3.5/GPT-4 系列的标准 tokenizer。
cl100k_base(GPT-4)
词表大小 100,256 tokens。改进了数字处理(每个数字最多 3 位一组,如 1234 → 12 34,减少数学任务的困难)。对代码和多语言表现更好。
p50k_base(GPT-3)
词表大小 50,281 tokens。与 GPT-2 的词表基本兼容但有扩展。代码处理能力介于 GPT-2 和 GPT-4 之间。
根本问题:模型做数学,本质是在做"token 序列操作"。token 切法不对,加减乘除就全乱了。
先看一个具体例子:计算 1234 + 5678。人类的做法是逐位对齐,从个位开始加:
+ 5 6 7 8
─────────
6 9 1 2
要做到这个,模型必须能"看到"每一位数字。如果 tokenizer 把 1234 当成一个 token,模型就看不到"千位是1、百位是2"这件事,自然就不会算了。
GPT-2 BPE 的问题:数字切法完全随机
GPT-2 的 BPE 词表是纯粹按语料频率训练的,数字没有任何特殊处理。结果:
| 数字文本 | GPT-2 实际切出的 token | 问题 |
|---|---|---|
100 | [100] → 1 个 token | 凑巧,词表里有这个词 |
101 | [101] → 1 个 token | 凑巧也有 |
1000 | [10][00] 或 [1000] | 随语料频率决定,无规律 |
1234567 | [1234][567] 或 [12][345][67] …… | 切法不固定,模型无法学到数位结构 |
3.14159 | [3][.][14][159] | 整数部分和小数部分分界也乱 |
模型在大量算术训练后,面对 1234 + 5678,它看到的是:[1234] [+] [5678],只有 3 个 token。它没有办法从 [1234] 这一个 token 里解析出"千位是1、百位是2、十位是3、个位是4"——就像你让人一眼看出"1234"的每一位、脑子里完全不经过分解一样,很难。
GPT-4 cl100k 的改进:强制 3 位一组
GPT-4 的 tiktoken 在构建词表时,用正则表达式对数字做了预切分规则:连续数字最多合并成 3 位一组,超过 3 位一定切开。
| 数字文本 | GPT-4 切出的 token | 效果 |
|---|---|---|
7 | [7] | 1位数,1个token |
42 | [42] | 2位数,1个token |
100 | [100] | 3位数,1个token |
1234 | [123][4] 或 [1][234] | 4位数,切成3+1 |
1234567 | [123][456][7] | 7位数,切成3+3+1,有规律 |
1,234,567 | [1][,][234][,][567] | 千分位逗号也被合理处理 |
现在模型计算 1234 + 5678,看到的是 [123][4] [+] [567][8]。虽然还不是每位一个 token,但结构已经规律很多,模型通过大量训练可以学会进位计算。
为什么不干脆每个数字一个 token(最理想方案)?
理论上最好的切法是:0–9 各一个 token,让模型真正逐位计算。但这有代价:
"1234567" → 3 个 token:[123][456][7]
# 每位一个(理想方案)
"1234567" → 7 个 token:[1][2][3][4][5][6][7]
# 代价:序列长度变为 2.3 倍,attention 计算量变为 5+ 倍
对于包含大量数字的任务(如代码、金融数据、数学证明),序列长度翻倍意味着推理速度和成本都显著变差。所以 3 位一组是一个工程上的妥协方案。
| 方法 | 合并策略 | 初始单位 | OOV | 多语言 | 代表模型 |
|---|---|---|---|---|---|
| BPE | 频率最高 bigram | Unicode 字符 | 有 | 差 | GPT-1/2, RoBERTa |
| WordPiece | PMI 最高 bigram | Unicode 字符 | 有 | 中 | BERT, DistilBERT |
| Unigram LM | 从大词表剪枝 | Unicode 字符 | 有(可缓解) | 中 | T5, XLNet, mBART |
| SentencePiece | BPE 或 Unigram(可选) | 原始字符流(含空格) | byte fallback 可选 | 强 | LLaMA, Gemini, PaLM |
| BBPE / tiktoken | 频率最高 bigram | 字节(256 个) | 无 | 最强 | GPT-3/4, GPT-4o |
主流大模型分词算法速查
OpenAI 系列
| 模型 | 算法 | 词表大小 | 词表名 | 备注 |
|---|---|---|---|---|
| GPT-2 | BPE(字符级) | 50,257 | gpt2 | 首个大规模 BPE 语言模型 |
| GPT-3 | BPE(字符级) | 50,281 | p50k_base | 与 GPT-2 基本兼容 |
| GPT-3.5 / GPT-4 | BBPE(字节级 BPE) | 100,256 | cl100k_base | 数字 3 位一组;多语言效率提升 |
| GPT-4o | BBPE | 200,019 | o200k_base | 词表扩到 20 万;中日韩效率大幅提升 |
Google 系列
| 模型 | 算法 | 词表大小 | 备注 |
|---|---|---|---|
| BERT | WordPiece | 30,522 | ## 前缀标记非首子词 |
| T5 | SentencePiece + Unigram LM | 32,000 | 支持子词正则化(训练时随机采样分词) |
| PaLM / Gemini / Gemma | SentencePiece + BPE | 256,000 | 超大词表;多语言覆盖最全 |
Meta LLaMA 系列
| 模型 | 算法 | 词表大小 | 备注 |
|---|---|---|---|
| LLaMA 1 / 2 / Code LLaMA | SentencePiece + BPE | 32,000 | byte fallback 模式;中文效率较低 |
| LLaMA 3 / 3.1 / 3.2 | BBPE(tiktoken 风格) | 128,256 | 从 SP 迁移到 tiktoken;词表扩 4×;中文效率大幅提升 |
国产及其他主流模型
| 模型 | 算法 | 词表大小 | 备注 |
|---|---|---|---|
| Claude 1/2/3(Anthropic) | BBPE(类 tiktoken) | ~100K | 架构未完全公开;与 cl100k 类似 |
| Mistral / Mixtral | SentencePiece + BPE | 32,000 | 同 LLaMA 2 词表 |
| BLOOM(BigScience) | BBPE | 250,680 | 250 种语言;字节级覆盖 |
| Qwen / Qwen2(阿里) | BBPE(tiktoken) | ~151,936 | 特别扩充中日韩字符;中文效率高 |
| DeepSeek v2/v3 | BBPE | 100,000+ | 中英双语优化 |
| Yi(零一万物) | SentencePiece + BPE | 64,000 | 中英双语扩展词表 |
| Baichuan 2 | SentencePiece + BPE | 125,696 | 中文优化;扩充汉字 token |
- BBPE 取代字符级 BPE — LLaMA 3、Qwen 都从 SentencePiece 迁移到了 tiktoken 风格的字节级 BPE,零 OOV。
- 词表持续扩大 — 32K → 100K → 256K。每翻一倍,同等文本的 token 数减少,推理成本下降,中文/代码/数学能力提升。
- WordPiece 退出主流 — 新模型几乎不再用,只有 BERT 系老模型还在用;PMI 打分的优势在 BBPE 面前不再显著。
词表大小选择指南
词表太小的问题
- 稀有词 / 专有名词被过度拆分 → 序列变长
- 长序列增加推理延迟和内存
- 模型更难学习词汇级语义
词表太大的问题
- Embedding table 参数量激增(32B 模型 128K 词表的 embedding 层 ≈ 10B 参数)
- 低频 token 训练不充分,embedding 学不好
- softmax 计算开销大
实践中:英语专用模型用 32K–50K;多语言模型用 100K–256K;代码/数学专用可能用 64K 但做特殊数字处理。
坑 1:特殊 token 污染
Tokenizer 通常有特殊 token(如 [CLS], [SEP], <eos>)。如果训练数据中的文本恰好包含这些字符串,它们会被当作特殊 token 处理而不是普通字符——需要在预处理时转义。
坑 2:不同 tokenizer 的不可互换性
BERT 的 WordPiece tokenizer 和 GPT-4 的 tiktoken 完全不兼容——同一段文字会产生不同的 token ID 序列、不同的长度。不能把 BERT 的 token id 直接喂给 GPT 模型。每个预训练模型必须配套使用其对应的 tokenizer。
坑 3:首尾空格导致 token 变化
📄 示例:空格位置影响 token idtiktoken
import tiktoken enc = tiktoken.get_encoding("cl100k_base") enc.encode("hello") # [15339] enc.encode(" hello") # [24748] ← 不同!开头空格使 token 变化
在构建 prompt 时,字符串拼接的空格位置会影响 tokenization 结果,进而影响模型对内容的理解。这也是为什么 chat template 要精心设计 system/user/assistant token 的格式。
坑 4:多语言 token 效率不均
英语语料训练的 tokenizer 对中文/日文/阿拉伯文效率极低——同样一段话,中文可能需要 4-5× 更多 token 才能表达(因为中文字符在英语 tokenizer 的词表中极少合并,每个汉字可能被拆成 2-3 个 byte token)。这导致多语言模型的"token 效率"(同等信息量的 token 数)极不均匀。
| 语言 | 文本 | tiktoken cl100k token 数 |
|---|---|---|
| English | "Hello, how are you today?" | 6 tokens |
| 中文 | "你好,你今天怎么样?" | 11 tokens(约 1.8× 英文) |
| 日文 | 「こんにちは、お元気ですか?」 | 18 tokens(约 3× 英文) |
| 阿拉伯语 | مرحبا، كيف حالك اليوم؟ | 约 20 tokens(约 3.3× 英文) |
这也是为什么 LLaMA 2 到 LLaMA 3 的词表从 32K 扩展到 128K,很大一个目标是提升多语言 token 效率。
坑 5:代码中连续空格/缩进问题
Python 缩进(4 个空格)在某些 tokenizer 里会被分成 4 个 space token,大幅增加代码序列长度。GPT-4 的 cl100k 对连续空格有专门的合并规则(如 4 个空格合并为一个 token),显著提升代码处理效率。
演化路径回顾
(序列太长)
(OOV 问题)
(频率驱动合并)
(PMI 更语义)
(概率最优,可随机采样)
(语言无关统一框架)
(字节级,零 OOV)
选型建议
✅ 英语 + 代码 + 数学
首选 BBPE + tiktoken(cl100k 或更大词表),配合数字 3 位一组处理。GPT-4 / CodeLLaMA 的选择。
✅ 多语言场景
首选 SentencePiece + BPE(词表 100K+),不依赖空格分词,中文/阿拉伯文/日文效率更均衡。LLaMA 3 / Gemini 的选择。
✅ 需要 subword regularization
选 SentencePiece + Unigram LM,训练时随机采样不同分词方案,显著提升低资源语言和拼写变体的鲁棒性。T5 的选择。
✅ 下游微调 / 接续预训练
必须使用与预训练模型完全相同的 tokenizer,不能替换。不同 tokenizer 的 token 空间完全不同,换了 tokenizer 相当于换了语言。
LLM 的 Tokenizer 本质是「将任意文本映射到有限词表的整数序列」。主流方法沿着 BPE(频率驱动的贪心合并)→ WordPiece(最大化语言模型概率的合并)→ Unigram(从大词表剪枝,概率最优)→ SentencePiece(语言无关的统一框架)→ BBPE/tiktoken(字节级杜绝 OOV) 这条路演进;选哪种取决于语言范围、数字/代码比例、服务延迟等工程约束。
BPE 完整实现(训练 + 编码 + 解码)
包括:① 从语料库训练 BPE 词表;② 用学到的 merge rules 对新文本编码;③ 将 token ids 解码回文本。
📄 BPE 完整实现:训练 + 编码 + 解码Python
from collections import Counter, defaultdict from typing import Dict, List, Tuple # ═══════════════════════════════════════════════════ # BPE 训练:从语料 → 词表 + 合并规则 # ═══════════════════════════════════════════════════ def get_vocab(corpus: List[str]) -> Dict[str, int]: """把语料转换为字符级词频词典(每个词加 </w> 结尾标记)""" vocab = Counter() for text in corpus: for word in text.split(): # 字符间加空格,末尾加 </w> vocab[' '.join(list(word)) + ' </w>'] += 1 return dict(vocab) def get_stats(vocab: Dict[str, int]) -> Dict[Tuple[str, str], int]: """统计词典中所有相邻 bigram 的频率""" pairs = Counter() for word, freq in vocab.items(): symbols = word.split() for i in range(len(symbols) - 1): pairs[(symbols[i], symbols[i + 1])] += freq return dict(pairs) def merge_vocab(pair: Tuple[str, str], vocab: Dict[str, int]) -> Dict[str, int]: """将词典中所有词里的 pair 合并为一个新 token""" bigram = ' '.join(pair) replacement = ''.join(pair) return { word.replace(bigram, replacement): freq for word, freq in vocab.items() } def train_bpe( corpus: List[str], target_vocab_size: int ) -> Tuple[Dict[str, int], List[Tuple[str, str]]]: """ BPE 训练主函数 Returns: token2id: 词表(token → id) merge_rules: 有序合并规则列表 """ vocab = get_vocab(corpus) # 初始词表:所有出现过的字符 + 特殊符号 all_chars = set() for word in vocab: all_chars.update(word.split()) token2id = {ch: i for i, ch in enumerate(sorted(all_chars))} merge_rules = [] while len(token2id) < target_vocab_size: pairs = get_stats(vocab) if not pairs: break # 选频率最高的 pair best_pair = max(pairs, key=pairs.get) # 合并并更新词典 vocab = merge_vocab(best_pair, vocab) # 记录合并规则 + 新 token new_token = ''.join(best_pair) merge_rules.append(best_pair) token2id[new_token] = len(token2id) return token2id, merge_rules # ═══════════════════════════════════════════════════ # BPE 编码:文本 → token id 序列 # ═══════════════════════════════════════════════════ def bpe_tokenize_word(word: str, merge_rules: List[Tuple[str, str]]) -> List[str]: """对单个词应用 BPE 合并规则,返回 token 字符串列表""" tokens = list(word) + ['</w>'] for pair in merge_rules: i = 0 new_tokens = [] while i < len(tokens): # 如果当前位置匹配目标 pair,则合并 if i < len(tokens) - 1 and (tokens[i], tokens[i+1]) == pair: new_tokens.append(tokens[i] + tokens[i+1]) i += 2 else: new_tokens.append(tokens[i]) i += 1 tokens = new_tokens return tokens def bpe_encode( text: str, token2id: Dict[str, int], merge_rules: List[Tuple[str, str]] ) -> List[int]: """文本 → token id 列表(未知 token 用 UNK id=0)""" unk_id = token2id.get('<unk>', 0) ids = [] for word in text.split(): tokens = bpe_tokenize_word(word, merge_rules) ids.extend(token2id.get(t, unk_id) for t in tokens) return ids # ═══════════════════════════════════════════════════ # BPE 解码:token id 序列 → 文本 # ═══════════════════════════════════════════════════ def bpe_decode( ids: List[int], id2token: Dict[int, str] ) -> str: """token id 列表 → 原始文本(去掉 </w> 标记,恢复空格)""" tokens = [id2token.get(i, '<unk>') for i in ids] # </w> 代表词尾,后面应该跟空格(除了最后一个 token) text = ''.join(tokens).replace('</w>', ' ').strip() return text # ═══════════════════════════════════════════════════ # 使用示例 # ═══════════════════════════════════════════════════ if __name__ == '__main__': corpus = [ "low low low low low", "lower lower", "newest newest newest newest newest newest", "widest widest widest", ] token2id, merge_rules = train_bpe(corpus, target_vocab_size=30) id2token = {v: k for k, v in token2id.items()} # 编码 text = "low newest" ids = bpe_encode(text, token2id, merge_rules) print(f"encode('{text}') = {ids}") # 例如: encode('low newest') = [14, 22, 23] # 解码 decoded = bpe_decode(ids, id2token) print(f"decode({ids}) = '{decoded}'") # 例如: decode([14, 22, 23]) = 'low newest' print(f"学到的合并规则(前10条): {merge_rules[:10]}") print(f"词表大小: {len(token2id)}")
WordPiece 实现(BERT 风格)
WordPiece 的推理阶段(给定词表如何分词)使用最长匹配优先(MaxMatch / Greedy Longest Match),训练阶段用 PMI 打分。这里实现最常用的推理侧 tokenizer:
📄 WordPiece 实现(BERT 风格)Python
from typing import Dict, List, Optional # ═══════════════════════════════════════════════════ # WordPiece 训练:从语料构建词表(PMI 打分版) # ═══════════════════════════════════════════════════ def train_wordpiece( corpus: List[str], target_vocab_size: int, special_tokens: List[str] = None ) -> List[str]: """ 简化版 WordPiece 训练 Returns: 词表列表(按 token 字符串排列) """ if special_tokens is None: special_tokens = ['[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]'] # Step 1:构建字符级初始词表 char_counts = Counter() word_counts = Counter() for text in corpus: for word in text.lower().split(): word_counts[word] += 1 for i, ch in enumerate(word): # 非首字符加 ## 前缀 symbol = ch if i == 0 else '##' + ch char_counts[symbol] += 1 # 词表从特殊 token + 字符开始 vocab = set(special_tokens) | set(char_counts.keys()) # Step 2:迭代合并(PMI 打分) # 将每个词表示为 symbol 序列 word_symbols = {} for word in word_counts: symbols = [word[0]] + ['##' + ch for ch in word[1:]] word_symbols[word] = symbols while len(vocab) < target_vocab_size: # 计算每个 symbol 的出现频率 sym_freq = Counter() pair_freq = Counter() for word, count in word_counts.items(): syms = word_symbols[word] for s in syms: sym_freq[s] += count for i in range(len(syms) - 1): pair_freq[(syms[i], syms[i+1])] += count if not pair_freq: break total = sum(sym_freq.values()) # PMI 打分:P(AB) / (P(A) * P(B)) best_pair, best_score = None, -1 for (a, b), ab_count in pair_freq.items(): score = (ab_count / total) / ((sym_freq[a] / total) * (sym_freq[b] / total)) if score > best_score: best_score, best_pair = score, (a, b) if best_pair is None: break # 合并 best_pair → 新 token a, b = best_pair # 去掉 b 开头的 ## 前缀(如果 b 是 ##x 则合并后是 ax) new_token = a + (b[2:] if b.startswith('##') else b) vocab.add(new_token) # 更新所有词的 symbol 序列 for word in word_symbols: syms = word_symbols[word] merged = [] i = 0 while i < len(syms): if i < len(syms) - 1 and syms[i] == a and syms[i+1] == b: merged.append(new_token) i += 2 else: merged.append(syms[i]) i += 1 word_symbols[word] = merged return sorted(vocab) # ═══════════════════════════════════════════════════ # WordPiece 推理:最长匹配(BERT 官方算法) # ═══════════════════════════════════════════════════ class WordPieceTokenizer: """BERT 风格的 WordPiece tokenizer""" def __init__(self, vocab: List[str], unk_token: str = '[UNK]'): self.vocab = set(vocab) self.token2id = {t: i for i, t in enumerate(vocab)} self.id2token = {i: t for i, t in enumerate(vocab)} self.unk_token = unk_token self.unk_id = self.token2id.get(unk_token, 0) def _tokenize_word(self, word: str) -> List[str]: """ 对单个词用最长匹配分词 - 首字符从头开始匹配 - 非首子串加 ## 前缀匹配 """ tokens = [] start = 0 word_len = len(word) is_bad = False while start < word_len: end = word_len found = None while start < end: substr = word[start:end] # 非开头子串加 ## 前缀 candidate = substr if start == 0 else '##' + substr if candidate in self.vocab: found = candidate break end -= 1 if found is None: # 没有任何匹配 → 整个词用 [UNK] is_bad = True break tokens.append(found) start = end return [self.unk_token] if is_bad else tokens def encode(self, text: str, add_special_tokens: bool = True) -> List[int]: """文本 → token id 列表(可选加 [CLS]/[SEP])""" tokens = [] if add_special_tokens: tokens.append('[CLS]') for word in text.lower().split(): tokens.extend(self._tokenize_word(word)) if add_special_tokens: tokens.append('[SEP]') return [self.token2id.get(t, self.unk_id) for t in tokens] def decode(self, ids: List[int], skip_special_tokens: bool = True) -> str: """token id 列表 → 文本(合并 ## 前缀)""" special = {'[CLS]', '[SEP]', '[PAD]', '[MASK]'} tokens = [self.id2token.get(i, '[UNK]') for i in ids] words = [] current = '' for token in tokens: if skip_special_tokens and token in special: if current: words.append(current) current = '' continue if token.startswith('##'): current += token[2:] # ## 前缀直接拼接,不加空格 else: if current: words.append(current) current = token if current: words.append(current) return ' '.join(words) def tokenize(self, text: str) -> List[str]: """文本 → token 字符串列表(调试用)""" result = [] for word in text.lower().split(): result.extend(self._tokenize_word(word)) return result # 使用示例 if __name__ == '__main__': # 用自定义词表创建 tokenizer vocab = [ '[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]', 'un', '##aff', '##ord', '##able', 'hello', 'world', '##ing', '##ed', 'a', 'b', 'c', 'd', 'e', 'f', 'g' ] tokenizer = WordPieceTokenizer(vocab) # tokenize tokens = tokenizer.tokenize("unaffordable hello") print(f"tokenize: {tokens}") # → ['un', '##aff', '##ord', '##able', 'hello'] # encode(含 [CLS]/[SEP]) ids = tokenizer.encode("hello world") print(f"encode: {ids}") # decode text = tokenizer.decode(ids) print(f"decode: '{text}'") # → 'hello world'
Unigram LM 实现(Viterbi 最优分词)
给定 Unigram 词表和每个 token 的对数概率,用 Viterbi 算法找最优分词,并支持多候选采样(子词正则化)。
📄 Unigram LM 实现(Viterbi 最优分词)Python
import math import random from typing import Dict, List, Tuple, Optional # ═══════════════════════════════════════════════════ # Unigram 词表格式:token → log_prob(对数概率) # ═══════════════════════════════════════════════════ class UnigramTokenizer: """ Unigram LM Tokenizer vocab: dict[token_str → log_prob](log_prob 均为负数或 0) """ def __init__(self, vocab: Dict[str, float], unk_token: str = '<unk>'): self.vocab = vocab self.unk_token = unk_token self.token2id = {t: i for i, t in enumerate(sorted(vocab.keys()))} self.id2token = {i: t for t, i in self.token2id.items()} if unk_token not in self.token2id: uid = len(self.token2id) self.token2id[unk_token] = uid self.id2token[uid] = unk_token def _viterbi_segment(self, text: str) -> List[str]: """ Viterbi 算法:找使 Unigram 概率最大的分词方案 时间复杂度 O(n²),n 为文本长度 """ n = len(text) NEG_INF = float('-inf') # best_score[i]:前 i 个字符的最优对数概率 best_score = [NEG_INF] * (n + 1) best_score[0] = 0.0 # back[i]:最优路径的回溯指针 back = [0] * (n + 1) for i in range(1, n + 1): for j in range(i): chunk = text[j:i] if chunk in self.vocab: score = best_score[j] + self.vocab[chunk] if score > best_score[i]: best_score[i] = score back[i] = j # 回溯路径 tokens = [] i = n while i > 0: j = back[i] tokens.append(text[j:i]) i = j return tokens[::-1] def _sample_segment(self, text: str, alpha: float = 0.1) -> List[str]: """ 子词正则化:按概率分布随机采样一种分词方案(用于训练数据增强) alpha:温度系数,越大越随机;alpha=0 等价于贪心最优 """ n = len(text) # 用前向算法计算每个位置的所有可能分词及其分数 candidates = [] def _segment_recursive(pos, path): if pos == n: candidates.append((path[:], sum(self.vocab.get(t, -20) for t in path))) return for end in range(pos + 1, n + 1): chunk = text[pos:end] if chunk in self.vocab: path.append(chunk) _segment_recursive(end, path) path.pop() # 只采样少量候选以控制复杂度(实际生产用 lattice sampling) _segment_recursive(0, []) if not candidates: return self._viterbi_segment(text) # fallback # softmax 采样 scores = [s * (1 / (alpha + 1e-9)) for _, s in candidates] max_s = max(scores) weights = [math.exp(s - max_s) for s in scores] total = sum(weights) probs = [w / total for w in weights] r = random.random() cumsum = 0.0 for (tokens, _), p in zip(candidates, probs): cumsum += p if r < cumsum: return tokens return candidates[-1][0] def tokenize(self, text: str, sample: bool = False) -> List[str]: """文本 → token 字符串列表;sample=True 启用子词正则化""" result = [] # 按空格预切分(实际 SentencePiece 版本不需要这步) for word in text.split(): if sample: result.extend(self._sample_segment(word)) else: result.extend(self._viterbi_segment(word)) return result def encode(self, text: str, sample: bool = False) -> List[int]: """文本 → token id 列表""" tokens = self.tokenize(text, sample=sample) unk_id = self.token2id[self.unk_token] return [self.token2id.get(t, unk_id) for t in tokens] def decode(self, ids: List[int]) -> str: """token id 列表 → 文本""" return ' '.join(self.id2token.get(i, self.unk_token) for i in ids) # 使用示例 if __name__ == '__main__': # 模拟词表(token → log_prob) vocab = { 'un': -2.0, 'afford': -3.5, 'able': -2.5, 'unafford': -6.0, 'unaffordable': -8.0, 'u': -5.0, 'n': -5.0, 'a': -4.0, 'hello': -2.0, 'world': -2.1, '<unk>': -20.0, } tokenizer = UnigramTokenizer(vocab) text = "unaffordable" # 最优分词(Viterbi) best = tokenizer.tokenize(text, sample=False) print(f"Viterbi 最优: {best}") # → ['un', 'afford', 'able'] # 子词正则化(随机采样,训练时用) samples = [tokenizer.tokenize(text, sample=True) for _ in range(5)] print(f"随机采样 5 次: {samples}") # 可能输出: [['un','afford','able'], ['u','n','afford','able'], ...] # encode / decode ids = tokenizer.encode("hello world") print(f"encode: {ids}") print(f"decode: {tokenizer.decode(ids)}")
核心:有词表后如何 Encode / Decode
这是实际工程中最常见的需求:已有一个训练好的词表文件,如何用它做编解码?以下给出一个通用的 VocabMatcher,支持加载词表文件、encode、decode、批量处理,并处理 OOV。
📄 VocabMatcher:通用词表匹配器Python
import json from typing import Dict, List, Union from pathlib import Path # ═══════════════════════════════════════════════════ # 通用词表匹配器 # ═══════════════════════════════════════════════════ class VocabMatcher: """ 通用 Token ↔ ID 双向映射器 支持从 dict / json 文件 / txt 文件加载词表 """ def __init__( self, vocab: Union[Dict[str, int], None] = None, unk_token: str = '[UNK]', pad_token: str = '[PAD]', bos_token: str = '[BOS]', eos_token: str = '[EOS]', ): self.unk_token = unk_token self.pad_token = pad_token self.bos_token = bos_token self.eos_token = eos_token if vocab is not None: self.token2id = vocab else: self.token2id = {} self.id2token = {v: k for k, v in self.token2id.items()} # 确保特殊 token 存在 for tok in [pad_token, unk_token, bos_token, eos_token]: if tok not in self.token2id: new_id = len(self.token2id) self.token2id[tok] = new_id self.id2token[new_id] = tok # ─── 加载词表 ─────────────────────────────────────── @classmethod def from_json(cls, path: str, **kwargs) -> 'VocabMatcher': """从 vocab.json 加载(格式:{"token": id, ...})""" with open(path, 'r', encoding='utf-8') as f: vocab = json.load(f) return cls(vocab=vocab, **kwargs) @classmethod def from_txt(cls, path: str, **kwargs) -> 'VocabMatcher': """ 从 vocab.txt 加载(格式:每行一个 token,行号即 id) BERT 词表就是这种格式 """ with open(path, 'r', encoding='utf-8') as f: tokens = [line.rstrip('\n') for line in f] vocab = {tok: i for i, tok in enumerate(tokens)} return cls(vocab=vocab, **kwargs) @classmethod def from_list(cls, tokens: List[str], **kwargs) -> 'VocabMatcher': """从 token 列表构建(顺序即 id)""" vocab = {tok: i for i, tok in enumerate(tokens)} return cls(vocab=vocab, **kwargs) # ─── 属性 ─────────────────────────────────────────── @property def vocab_size(self) -> int: return len(self.token2id) @property def unk_id(self) -> int: return self.token2id[self.unk_token] @property def pad_id(self) -> int: return self.token2id[self.pad_token] # ─── 单条编解码 ───────────────────────────────────── def token_to_id(self, token: str) -> int: """单个 token → id(OOV 返回 unk_id)""" return self.token2id.get(token, self.unk_id) def id_to_token(self, id: int) -> str: """单个 id → token(越界返回 unk_token)""" return self.id2token.get(id, self.unk_token) def encode( self, tokens: List[str], add_bos: bool = False, add_eos: bool = False, max_length: Optional[int] = None, padding: bool = False, ) -> List[int]: """ token 列表 → id 列表 - tokens: 已经由分词器切好的 token 字符串列表 - add_bos/add_eos: 是否在首尾加特殊 token - max_length: 截断长度(None 不截断) - padding: 是否补 PAD 到 max_length """ ids = [self.token_to_id(t) for t in tokens] if add_bos: ids = [self.token2id[self.bos_token]] + ids if add_eos: ids = ids + [self.token2id[self.eos_token]] if max_length is not None: ids = ids[:max_length] if padding and len(ids) < max_length: ids += [self.pad_id] * (max_length - len(ids)) return ids def decode( self, ids: List[int], skip_special_tokens: bool = True, join_str: str = ' ', ) -> str: """ id 列表 → token 字符串(空格连接) - skip_special_tokens: 跳过 PAD/BOS/EOS/UNK 等 - join_str: token 间分隔符(BPE 解码后通常不需要空格,根据算法决定) """ special = {self.pad_token, self.unk_token, self.bos_token, self.eos_token} tokens = [ self.id_to_token(i) for i in ids if not (skip_special_tokens and self.id_to_token(i) in special) ] return join_str.join(tokens) # ─── 批量编码 ─────────────────────────────────────── def batch_encode( self, batch_tokens: List[List[str]], max_length: int, padding: bool = True, add_eos: bool = True, ) -> Dict[str, List[List[int]]]: """ 批量编码(返回 input_ids + attention_mask) batch_tokens: 多条样本的 token 列表 """ input_ids = [] attention_masks = [] for tokens in batch_tokens: ids = self.encode( tokens, add_eos=add_eos, max_length=max_length, padding=padding, ) # attention_mask:非 PAD 位置为 1,PAD 位置为 0 mask = [0 if i == self.pad_id else 1 for i in ids] input_ids.append(ids) attention_masks.append(mask) return {'input_ids': input_ids, 'attention_mask': attention_masks} # ─── 工具方法 ─────────────────────────────────────── def save_vocab(self, path: str): """保存词表到 JSON 文件""" with open(path, 'w', encoding='utf-8') as f: json.dump(self.token2id, f, ensure_ascii=False, indent=2) def contains(self, token: str) -> bool: """检查 token 是否在词表中""" return token in self.token2id def oov_rate(self, tokens: List[str]) -> float: """计算 OOV 比例""" oov = sum(1 for t in tokens if t not in self.token2id) return oov / len(tokens) if tokens else 0.0 def __repr__(self): return f"VocabMatcher(vocab_size={self.vocab_size}, unk='{self.unk_token}')" # ═══════════════════════════════════════════════════ # 使用示例 # ═══════════════════════════════════════════════════ if __name__ == '__main__': # ── 方式 1:从 dict 构建 ────────────────────────── vocab_dict = { '[PAD]': 0, '[UNK]': 1, '[CLS]': 2, '[SEP]': 3, 'hello': 4, 'world': 5, 'un': 6, '##aff': 7, '##ord': 8, '##able': 9, 'token': 10, } matcher = VocabMatcher(vocab=vocab_dict) print(matcher) # VocabMatcher(vocab_size=13, unk='[UNK]') # ── 方式 2:从 vocab.txt 加载(BERT 格式)────────── # matcher = VocabMatcher.from_txt('/path/to/vocab.txt') # ── 方式 3:从 vocab.json 加载(HuggingFace 格式)── # matcher = VocabMatcher.from_json('/path/to/vocab.json') # ── 单条 encode ─────────────────────────────────── tokens = ['un', '##aff', '##ord', '##able'] ids = matcher.encode(tokens) print(f"encode: {ids}") # [6, 7, 8, 9] # 带截断和 padding ids_padded = matcher.encode(tokens, add_eos=True, max_length=8, padding=True) print(f"encode(padded): {ids_padded}") # [6, 7, 8, 9, <eos_id>, 0, 0, 0] # ── decode ──────────────────────────────────────── text = matcher.decode(ids) print(f"decode: '{text}'") # 'un ##aff ##ord ##able' # ── 批量 encode ────────────────────────────────── batch = [['hello', 'world'], ['un', '##aff', '##ord']] result = matcher.batch_encode(batch, max_length=6, padding=True) print("batch input_ids:", result['input_ids']) print("batch attention_mask:", result['attention_mask']) # input_ids: [[4, 5, 3, 0, 0, 0], [6, 7, 8, 3, 0, 0]] # attention: [[1, 1, 1, 0, 0, 0], [1, 1, 1, 1, 0, 0]] # ── OOV 率检查 ─────────────────────────────────── oov_tokens = ['hello', 'xyz123', 'world', 'foobar'] print(f"OOV 率: {matcher.oov_rate(oov_tokens):.1%}") # 50.0%
tiktoken 实际使用(GPT-4 / GPT-3.5)
如果你直接用 OpenAI 模型,tiktoken 是最实用的工具:
📄 tiktoken 完整使用指南Python
import tiktoken # ═══════════════════════════════════════════════════ # tiktoken 完整使用指南 # ═══════════════════════════════════════════════════ # 1. 加载编码器 enc = tiktoken.get_encoding("cl100k_base") # GPT-4 / GPT-3.5 # enc = tiktoken.get_encoding("p50k_base") # GPT-3 # enc = tiktoken.get_encoding("r50k_base") # GPT-2 # 2. 基础编解码 text = "Hello, 你好世界!🌍" ids = enc.encode(text) print(f"token ids : {ids}") # [9906, 11, 220, 57668, 53901, 11375, 6447, 127, 246, 9468, 242, 101] decoded = enc.decode(ids) print(f"decoded : {decoded}") # 完全还原 # 3. 查看每个 token 对应的文本 tokens_bytes = [enc.decode_single_token_bytes(t) for t in ids] print(f"token bytes: {tokens_bytes}") # [b'Hello', b',', b' ', b'\xe4\xbd\xa0', ...] # 4. 不切分特殊 token(用于 Chat 格式) ids_no_special = enc.encode(text, disallowed_special=()) # 阻止 <|endoftext|> 等被误识别为特殊 token # 5. 计算 token 数(不实际编码,更快) num_tokens = len(enc.encode(text)) print(f"token 数: {num_tokens}") # 6. 模型专属 encoding(自动对应正确词表) enc_gpt4 = tiktoken.encoding_for_model("gpt-4") enc_gpt35 = tiktoken.encoding_for_model("gpt-3.5-turbo") # ═══════════════════════════════════════════════════ # 实用工具:计算 Chat Messages 的 token 数 # 参考 OpenAI 官方文档 "How to count tokens with tiktoken" # ═══════════════════════════════════════════════════ def num_tokens_from_messages(messages: list, model: str = "gpt-4") -> int: """ 计算 messages 列表的 token 数(兼容 gpt-3.5-turbo / gpt-4) messages 格式:[{"role": "user", "content": "..."}] """ try: encoding = tiktoken.encoding_for_model(model) except KeyError: encoding = tiktoken.get_encoding("cl100k_base") # gpt-3.5-turbo / gpt-4 格式 tokens_per_message = 3 # <|im_start|> role \n content <|im_end|> tokens_per_name = 1 num_tokens = 0 for message in messages: num_tokens += tokens_per_message for key, value in message.items(): num_tokens += len(encoding.encode(str(value))) if key == "name": num_tokens += tokens_per_name num_tokens += 3 # 每次回复前有 <|im_start|>assistant return num_tokens # 使用示例 messages = [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Hello, how are you?"}, ] n = num_tokens_from_messages(messages) print(f"这段对话占 {n} tokens") # ~22 tokens # ═══════════════════════════════════════════════════ # 实用工具:截断文本到指定 token 数 # ═══════════════════════════════════════════════════ def truncate_text(text: str, max_tokens: int, model: str = "gpt-4") -> str: """截断文本到 max_tokens(完整 token 边界截断,不会截断 UTF-8 字节)""" enc = tiktoken.encoding_for_model(model) ids = enc.encode(text) if len(ids) <= max_tokens: return text return enc.decode(ids[:max_tokens]) # 超长文本截断到 100 tokens long_text = "这是一段很长的文本 " * 100 truncated = truncate_text(long_text, max_tokens=100) print(f"截断后 token 数: {len(tiktoken.encoding_for_model('gpt-4').encode(truncated))}")
上面是从零实现的版本,适合学习原理。实际项目推荐直接用:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased") ids = tokenizer.encode("hello world", add_special_tokens=True) tokens = tokenizer.convert_ids_to_tokens(ids) text = tokenizer.decode(ids, skip_special_tokens=True) batch = tokenizer(["hello", "world"], padding=True, return_tensors="pt")原理相同,接口更完善,但理解了本文的从零实现,你就知道 HuggingFace 内部在干什么了。