大模型入门:手写一个 BPE 分词器,搞懂 Token 到底怎么来的

大模型入门:手写一个 BPE 分词器,搞懂 Token 到底怎么来的

摘要: 很多人学习大模型时,会直接从 Attention、Transformer、KV Cache 开始,但真正进入工程实践后,第一个绕不开的问题往往是 Token。为什么同一句话在不同模型里 token 数不一样?为什么中文、英文、代码的切分差异会影响上下文长度和费用?本文从 Byte Pair Encoding(BPE)的合并规则开始,手写一个最小 BPE 分词器,讲清 Tokenizer 的训练、编码、解码和常见面试追问。

在这里插入图片描述

一、大模型不是直接读“字”,而是读 Token

很多人第一次接触大模型 API,会先遇到一个很现实的问题:

为什么我明明只输入了一段文字,平台却按 token 计费?

再往后,你会遇到更多和 token 有关的问题:

  • 同一句中文,为什么不同模型统计出来的 token 数不一样?
  • 为什么代码、JSON、URL 经常特别费 token?
  • 为什么上下文窗口写着 128K,不代表你可以随便塞 128K 个汉字?
  • 为什么同一个词,有时会被切成完整单词,有时又会被拆成几个片段?
  • 为什么面试里问 Tokenizer,经常会追问 BPE、WordPiece、Unigram?

这些问题背后都指向同一层:

大模型看到的不是原始字符串,而是 Token ID 序列。

Tokenizer 的任务,就是把人类写的文本转成模型能处理的整数序列;模型输出整数序列后,再把它们解码回文本。

所以,BPE 不是一个边角知识点。它直接影响上下文长度、API 成本、多语言效果、代码和符号处理、OOV 问题,以及模型训练和推理的输入边界。

这篇文章不先堆公式,而是手写一个最小 BPE。

一句话理解:BPE 分词不是简单切字符串,而是从最小符号开始,反复合并语料里最常见的相邻符号对,最后得到一张“合并规则表”。
在这里插入图片描述

1. Tokenizer 在大模型里处在哪一层

一个最简化的大模型输入链路是:

原始文本 -> Tokenizer -> token ids -> Embedding -> Transformer

比如:

"hello world"

可能被切成:

["hello", " world"]

再映射成:

[15339, 1917]

模型真正吃进去的是后面的整数 ID,不是字符串本身。

在这里插入图片描述

常见组件包括:

组件作用
Normalizer统一大小写、Unicode 规范化、空白处理等
Pre-tokenizer先按空格、标点、正则或字节切一层
Model真正的分词模型,比如 BPE、WordPiece、Unigram
Post-processor加特殊 token,比如 BOS、EOS、CLS、SEP
Decoder把 token 还原成文本

Hugging Face Tokenizers 文档里也把 tokenizer 拆成这些组件。BPE 只是其中的 Model 部分,不是整个 Tokenizer 的全部。

2. BPE 到底在学什么

BPE 最早来自数据压缩。Philip Gage 1994 年提出的 Byte Pair Encoding,会反复找最常见的相邻 byte pair,并用一个新符号替换它。

后来 Sennrich、Haddow、Birch 在神经机器翻译里把 BPE 用到 subword 单元上,用来缓解稀有词和未登录词问题。

放到大模型里,可以先这样理解:

BPE 学到的不是“自然语言语法”,而是一张合并规则表。

假设语料里经常出现:

low
lower
lowest

最开始可以把词拆成字符:

l o w </w>
l o w e r </w>
l o w e s t </w>

如果 l o 出现次数最多,就合并成:

lo w </w>
lo w e r </w>
lo w e s t </w>

接着如果 lo w 出现次数最多,就合并成:

low </w>
low e r </w>
low e s t </w>

继续训练,模型可能学到:

("l", "o") -> "lo"
("lo", "w") -> "low"
("e", "r") -> "er"
("e", "s") -> "es"

这张表就是 BPE 的核心。

在这里插入图片描述

注意:真实大模型里常用的是 byte-level BPE 或更复杂的 tokenizer,会有正则预切分、byte 映射、特殊 token、Unicode 处理。本文的实现是教学版,用来讲清“合并规则如何训练和应用”。

3. 为什么不直接按字或按词切

如果所有文本都按字符切:

unbelievable -> u n b e l i e v a b l e

优点是简单,不容易遇到未知词。缺点是序列变长,模型要学更多长距离组合,英文、代码、URL 会被切得很碎。

如果按词切:

unbelievable -> unbelievable

优点是语义单元更完整。缺点是词表会膨胀,而且很容易遇到没见过的新词、拼写变化、代码变量名、数字组合。

比如:

getUserProfileByOrderId
refund_status_20260527

这些东西不可能都提前放进词表。

BPE 介于字符和词之间:

  • 高频片段可以合成大 token;
  • 低频词可以退回到更小片段;
  • 词表大小可控;
  • 对新词和组合词更友好。

所以 BPE 的价值不是“切得像人类分词”,而是在词表大小、序列长度和未知词之间做折中。

4. 手写 BPE:先从训练开始

下面写一个教学版 BPE。

输入是一组语料:

corpus = [
    "low lower lowest",
    "newer wider",
    "low low lower",
]

训练目标:

  1. 把每个词拆成字符序列;
  2. 统计所有相邻符号对的频次;
  3. 找出最高频 pair;
  4. 合并这个 pair;
  5. 重复多轮,得到 merge rules。

完整代码如下。

from collections import Counter

END = "</w>"


def build_word_vocab(corpus):
    vocab = Counter()
    for text in corpus:
        for word in text.strip().split():
            symbols = tuple(word) + (END,)
            vocab[symbols] += 1
    return vocab


def get_pair_stats(vocab):
    pairs = Counter()
    for symbols, freq in vocab.items():
        for pair in zip(symbols, symbols[1:]):
            pairs[pair] += freq
    return pairs


def merge_pair_in_word(symbols, pair):
    merged = []
    i = 0

    while i < len(symbols):
        if i < len(symbols) - 1 and (symbols[i], symbols[i + 1]) == pair:
            merged.append(symbols[i] + symbols[i + 1])
            i += 2
        else:
            merged.append(symbols[i])
            i += 1

    return tuple(merged)


def merge_vocab(vocab, pair):
    new_vocab = Counter()
    for symbols, freq in vocab.items():
        new_symbols = merge_pair_in_word(symbols, pair)
        new_vocab[new_symbols] += freq
    return new_vocab


def train_bpe(corpus, num_merges=10):
    vocab = build_word_vocab(corpus)
    merges = []

    for _ in range(num_merges):
        stats = get_pair_stats(vocab)
        if not stats:
            break

        best_pair, count = stats.most_common(1)[0]
        if count < 2:
            break

        vocab = merge_vocab(vocab, best_pair)
        merges.append(best_pair)

    return merges, vocab

试着跑一下:

corpus = [
    "low lower lowest",
    "newer wider",
    "low low lower",
]

merges, vocab = train_bpe(corpus, num_merges=8)

for i, pair in enumerate(merges, 1):
    print(i, pair, "->", "".join(pair))

可能得到类似结果:

1 ('l', 'o') -> lo
2 ('lo', 'w') -> low
3 ('e', 'r') -> er
4 ('er', '</w>') -> er</w>

每一轮都只做一件事:找最高频 pair,然后合并。

5. 编码:新词怎么切成 token

训练完之后,我们拿到的是一组有顺序的 merge rules。

编码一个新词时,不是重新统计频次,而是按训练时学到的规则去合并。

def encode_word(word, merges):
    symbols = tuple(word) + (END,)

    for pair in merges:
        symbols = merge_pair_in_word(symbols, pair)

    tokens = []
    for symbol in symbols:
        if symbol == END:
            continue
        tokens.append(symbol.replace(END, ""))

    return tokens


def encode(text, merges):
    output = []
    for word in text.strip().split():
        output.extend(encode_word(word, merges))
    return output

测试:

print(encode("lowest newer", merges))

可能输出:

['low', 'e', 's', 't', 'n', 'e', 'w', 'er']

输出不是唯一的,因为它取决于语料、训练轮数和合并规则。在这个小语料里,newernew 没有足够频次被合并,所以会退回成更小片段。

这也是一个关键点:

Tokenizer 不是普适标准答案,而是模型训练前固定下来的编码协议。

同一句话在不同模型里 token 数不同,通常就是因为 tokenizer 的词表、预切分规则、merge rules 或特殊 token 不同。

6. BPE 和大模型工程有什么关系

上下文窗口不是按字符算的

上下文窗口按 token 算。

如果某类文本被切得很碎,同样长度的字符会占用更多 token。

例如普通英文单词可能较容易合成大 token;罕见词可能被拆成多个 subword;代码变量、路径、URL、JSON 字段可能消耗更多 token。

所以做 RAG、Agent、长文档总结时,不能只按字符数估算成本。

词表大小会影响模型输入输出层

模型的 embedding 表通常和词表大小相关。

词表越大,单个 token 表达能力可能更强,但 embedding 和输出层也更重。

词表越小,OOV 压力小,但序列可能变长。

BPE 本质上是在这两者之间做折中。

Tokenizer 必须和模型匹配

不能随便拿 A 模型的 tokenizer 去喂 B 模型。

模型权重、词表、merge rules、special tokens 必须成套使用。

7. BPE、WordPiece、Unigram 简单区分

方法典型思路常见模型
BPE从小符号开始,反复合并高频相邻 pairGPT 系列相关 tokenizer、RoBERTa 等
WordPiece也做 subword,但选择合并时更强调似然/分数BERT 系列
Unigram从大词表开始,基于概率删除候选 tokenSentencePiece/部分 T5 系列

可以这样记:

  • BPE 像“从小到大合并”;
  • Unigram 像“从大到小裁剪”;
  • WordPiece 和 BPE 很像,但训练目标和打分方式不同。

8. 常见坑

在这里插入图片描述

  1. 把 BPE 理解成普通分词。BPE 按统计频率合并符号,不一定符合人类语义分词。
  2. 忽略 pre-tokenizer。真实 tokenizer 往往会先做正则切分、空白处理、byte 映射。
  3. 以为 token 数等于字数。token 数由 tokenizer 词表和规则决定。
  4. 训练和编码混在一起。训练学 merge rules;编码按已有 rules 切新文本。
  5. 随便换 tokenizer。Tokenizer 和模型权重是配套协议。
  6. 忽略特殊 token。BOS、EOS、PAD、系统消息分隔符都会占 token。

9. 面试怎么回答

如果面试官问“BPE 是什么”,可以这样答:

BPE 是一种 subword tokenizer。它从字符或 byte 这类小符号开始,在训练语料里反复统计最常见的相邻符号对,并把它们合并成新 token,最终得到一张词表和一组有顺序的 merge rules。编码新文本时,不再重新统计语料,而是按训练好的 merge rules 进行合并。它的价值是在词表大小、序列长度和未知词之间做折中。

如果继续问“大模型里为什么要用 BPE”,可以接:

如果按词切,词表会很大,而且容易遇到未登录词;如果按字符切,序列会变长,模型成本更高。BPE 能把高频片段合成较大的 token,把低频词退回到更小片段,所以既能控制词表,又能处理新词、代码变量名、数字和多语言文本。

10. 一张速记表

在这里插入图片描述

问题关键回答
BPE 学什么?学一组按频次得到的合并规则
从哪里开始?教学版从字符开始,生产版常见 byte-level
每轮做什么?统计相邻 pair,合并最高频 pair
编码怎么做?按训练好的 merge rules 合并新文本
为什么能处理新词?新词可以退回到更小 subword 或 byte
为什么 token 数不等于字数?token 是 tokenizer 词表和规则决定的
能随便换 tokenizer 吗?不能,tokenizer 和模型权重必须匹配
BPE 的工程价值?控制词表、降低 OOV、平衡序列长度

总结

BPE 分词器可以压缩成三句话:

  1. 训练时,从小符号开始,反复合并语料里最高频的相邻 pair。
  2. 编码时,按训练好的 merge rules 把新文本切成 token。
  3. 工程上,它是在词表大小、序列长度、未知词和多语言/代码适配之间做折中。

所以,Token 不是“字”,也不是“词”。

它是模型训练前约定好的一套文本编码协议。

参考资料

  • Philip Gage:A New Algorithm for Data Compression
    https://www.derczynski.com/papers/archive/BPE_Gage.pdf
  • Sennrich, Haddow, Birch:Neural Machine Translation of Rare Words with Subword Units
    https://aclanthology.org/P16-1162/
  • Hugging Face Transformers:Summary of the tokenizers
    https://huggingface.co/docs/transformers/v4.22.1/en/tokenizer_summary
  • Hugging Face Tokenizers:Components
    https://huggingface.co/docs/tokenizers/components
  • OpenAI tiktoken GitHub
    https://github.com/openai/tiktoken
  • OpenAI Cookbook:How to count tokens with tiktoken
    https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值