跳到主要内容

分词器才是真正的接口:BPE 从零实现,以及它如何决定你的账单、缓存命中率与算术能力

· 阅读需 16 分钟

后端工程师看 LLM 应用,最容易忽略的一层是分词器。它夹在你的文本和模型之间:你发出去的每一个请求,先被转换成一串整数编号;你按量付费的每一个 token,就是这串编号里的一位。这一层没有超时、没有重试、没有连接池,看起来不像"系统",但它是唯一决定 token 数量的环节——而 token 数量同时决定了账单、KV Cache 显存、有效上下文窗口和 decode 阶段的吞吐。

更要命的是,分词器不是中立的。它的行为由训练语料的语言配比决定:一个主要用英文语料训练出来的词表,会把绝大多数额度花在英文词素上,中文、代码和数字只能退化成更短、数量更多的符号。本文不讲"分词器很重要"这种废话,直接从零写一个 BPE,然后用它跑四组实验,把每一项都变成可以写在容量规划表里的数字。所有代码只用 Python 标准库,你可以直接复制运行。

一、BPE 在做什么:四十行可运行实现​

字节对编码(Byte Pair Encoding)的核心只有一个循环:统计出现频率最高的相邻符号对,把它合并成一个新符号,重复直到词表被填满。合并表就是全部产物——推理时不需要重新训练,只需要按合并顺序重放。

import re, collections

WORD_RE = re.compile(r"'(?:s|t|re|ve|m|ll|d)| ?[A-Za-z]+| ?[0-9]+| ?[^\sA-Za-z0-9]+|\s+")
pretok = lambda t: [w for w in WORD_RE.findall(t) if w.strip()]
symbols_of = lambda w: [c for c in w.strip()] + ["</w>"] # </w> 是词尾标记

def build_corpus(text):
c = collections.Counter()
for w in pretok(text):
c[" ".join(symbols_of(w))] += 1 # 用空格连接符号,整个词是一个 key
return c

def train_bpe(corpus, vocab_size):
words, vocab, merges = dict(corpus), set(), []
for w in words:
vocab.update(w.split(" "))
while len(vocab) < vocab_size:
pairs = collections.Counter()
for w, f in words.items():
s = w.split(" ")
for a, b in zip(s, s[1:]):
pairs[(a, b)] += f
if not pairs:
break
(a, b), cnt = max(pairs.items(), key=lambda kv: (kv[1], kv[0]))
if cnt < 2: # 没有出现两次以上的对,收敛
break
merges.append((a, b)); vocab.add(a + b)
words = {w.replace(a + " " + b, a + b): f for w, f in words.items()}
return merges

def encode_word(word, ranks):
s = symbols_of(word)
while len(s) > 1:
best, bi = None, -1
for i, (a, b) in enumerate(zip(s, s[1:])):
r = ranks.get((a, b))
if r is not None and (best is None or r < best):
best, bi = r, i # 永远合并合并顺序最靠前的对
if best is None:
break
s[bi:bi + 2] = [s[bi] + s[bi + 1]]
return s

def encode(text, ranks):
out = []
for w in pretok(text):
out.extend(encode_word(w, ranks))
return out

encode_word 里"永远合并合并顺序最靠前的对"这一条是正确性的关键:BPE 的编码必须和训练时的合并顺序一致,否则同一个词会有多种切法,模型看到的位置信息就不稳定。这一点在第四节会要你的命。

这个实现对工程有几个明显的问题,先说清楚,免得你直接抄上线:

  • 训练是 O(合并数 × 语料符号数)。每次迭代都重新统计全量 pair count。生产实现会维护 pair -> 出现次数 和 pair -> 受影响的词索引 两张增量表,一次合并只重算受影响的词,把复杂度打下来一个量级。
  • w.replace 是简化写法,遇到重叠出现(比如 a a a)会一次性吃掉两对。真实实现需要按索引精确合并。
  • 预分词决定上限。GPT 系列用上面那条正则把文本切成词,中文天然按字切,阿拉伯数字整块切。这条正则被很多人当成细节,其实它直接决定了后面所有数字的表现。

二、词表额度分配:同一段中文,token 数由训练语料决定​

第一组实验想回答一个问题:同一个中文段落,在不同的词表下要花多少 token? 我把训练语料(英文 2516 字符、中文 782 字符)和评测语料严格隔离,只改变两个变量:词表上限,以及训练语料里英文与中文的比例。

配置合并数中文相关合并占比英文 token/字符中文 token/字符
en8:zh1, 词表 4001610.6%0.7220.989
en1:zh1, 词表 40016116.8%0.7280.947
en8:zh1, 词表 120072048.8%0.6310.939
en1:zh8, 词表 120072449.0%0.6310.939

三行结论,每一行都能落到工程动作上:

第一,词表是个额度分配器,不是容量。 词表上限 400 时,160 次合并里只有 1 次(0.6%)落在含中文的符号上——英文还没吃饱,中文一次都轮不到。把上限抬到 1200,中文相关合并一下子占到 48.8%。中间那段 400 到 1200 的额度,几乎全被英文拿走了一半、中文拿了另一半,两边的每字符 token 才一起下降。

第二,英文吃额度的边际收益比中文高。 词表从 400 扩到 1200,英文从 0.722 降到 0.631 token/字符,降了 12.6%;中文从 0.989 降到 0.939,只降了 5.1%。同样是翻三倍词表,英文省下的 token 是中文的 2.5 倍。原因是结构性的:英文有大量高频词素可以合并成"一整词一个 token",而中文在完成常用双字词(比如"缓存""带宽")的合并之后,就没有那么多更高频的组合可吃了。

第三,别拿"词表 100k/200k"当选型依据。 我的玩具词表只有 1.2k,所以英文还停在 0.63 token/字符;生产 tokenizer 的词表是 10 万到 20 万,英文能压到大约 0.25 token/字符(OpenAI 对外的口径是 1 token 约等于 4 个英文字符)。中文同一时期降不到那个水平——因为它的额度已经被前面那些高频合并占掉了。最终的"中文贵还是英文贵",是这个额度分配过程的结果,而分配比例由训练语料的语言配比决定,不由词表大小决定。

工程动作很直接:不要引用别人的 token 数,用你自己的语料量。下面这个小脚本十几行,接在你自己的请求日志上跑一遍,就能拿到真实的每千字符 token 数。

def token_bill(texts, ranks):
"""按语言分别统计,避免一种语言的退化被整体平均值掩盖"""
import re
cjk = re.compile(r"[\u4e00-\u9fff]")
stat = {"zh": [0, 0], "en": [0, 0]} # [token 数, 去空白字符数]
for t in texts:
k = "zh" if cjk.search(t) else "en"
stat[k][0] += len(encode(t, ranks))
stat[k][1] += len(re.sub(r"\s", "", t))
for k, (tok, ch) in stat.items():
print(f"{k}: {tok} tokens, {tok/max(ch,1):.3f} token/char")

报告 token 数必须按语言分桶。 只报全局平均值是自欺欺人:中文退化 20% 会被英文的改善盖过去,你在账单上看到的总数只涨了几个点,等到某个中文业务线独立核算时才发现单价翻倍。

三、数字:为什么模型算术不稳​

数字是分词器最锋利的一刀。我用一份带真实频率分布的数字语料(年份、端口、常见尺寸高频,随机数字低频)单独训了一个词表,然后看它怎么切数字。

数字BPE 切分片数三位分组(右对齐)
1281281128
1024102411|024
1234123411|234
1234512345112|345
360036/0023|600
123456123/4/563123|456
81920819/2/0381|920
1234567123/4/5/6/751|234|567
20261002202/6/1/00/2520|261|002
9999999/9/9/9/9/96999|999

平均每个数字 2.86 片,而三位分组只要 2.07 片。但片数不是重点,真正的病是不一致:

数字位数观察到的切法(各位数长度)切法种类
4 位1111 / 112 / 121 / 211 / 45 种
5 位11111 / 1211 / 53 种
6 位111111 / 1221 / 312 / 514 种

同样是 4 位数字,1234 是一个 token,3600 是 36/00 两个,100 是 1/00 两个,4919 是 4/9/19 三个。同一个数位在语料里出现得越频繁,它越可能被合并进一个 token;一旦出现位置靠前或靠后,切法就变。模型没有稳定的"个十百千"对齐信息可以学——它看到的是一串长度和边界都在漂移的符号。

这不是"模型太笨",是把一个位置敏感的任务交给了一个位置不敏感的表征。缓解手段有三条,按性价比排序:

  1. 输出侧用约束解码,不要用后置 JSON 修复。 数字字段在语法层固定成一到六位纯数字这种 token 级别的约束,让解码器在每一步只能选数字符号,比生成完再正则修补可靠得多,也省一次重试。
  2. 评测集里单独加一组"数字还原"用例。 大整数比较、金额对齐、版本号、时间戳,各来二十条。这类用例在通用评测里权重极低,却是业务事故的高发区。
  3. 不要在 prompt 里做数字字符串拼接。 让模型"把 2026 和 1002 拼成 20261002"这种任务,等于故意踩在上面那张不一致表上。数字该由工具算,由代码拼。

四、前缀缓存:易变字段放错位置,成本翻四十倍​

这是本文最值钱的一组数字。提示词缓存(vLLM/SGLang 的前缀缓存、各家 API 的自动缓存)只复用逐 token 完全一致的前缀。我构造了一个 944 token 的系统提示词(11 条规则加 3 个工具 schema),然后用两种写法各跑两轮,把用户提问换掉:

写法前缀命中每轮需要重算的 token
易变内容放在末尾(只换用户消息)946 / 969 = 97.6%2.4%
会话 ID 放在最前面11 / 1035 = 1.1%98.9%

只差一个字段的位置,每轮重算的 token 从 2.4% 涨到 98.9%,相差约 41 倍。第一行那种写法,缓存几乎全覆盖;第二行那种写法,前缀在第 11 个 token 处就因为 session id 变了而断开,后面 1000 多个 token 全部作废。如果你的服务每轮都重算全量 prompt,而你却以为缓存生效了,你的成本模型就错了整整一个数量级。

正确的排布原则是按稳定性降序:越稳定的内容放越前面。

[1] 固定不变的人物设定与规则 <- 版本化,改了要通知
[2] 工具 schema / 输出格式定义 <- 注意 JSON 键序必须稳定
[3] 少量固定示例 <- few-shot 放这里
[4] 检索到的文档 <- 会变,但顺序要稳定(按 ID 排序)
[5] 会话级上下文、时间戳、请求 ID <- 每轮都变,越靠后越好
[6] 本轮用户输入 <- 永远在最后

配上一个好记的判断标准:任何在会话内会变的东西,都不允许出现在稳定块之前。按这个标准扫一遍你的 prompt,你会找到一批"顺手写在开头"的时间戳、租户 ID、实验分组标识、甚至 datetime.now()——每一个都在按轮次烧钱。

这里有个隐蔽变体:JSON 键序不稳定。同一个工具 schema,如果序列化时字典顺序随进程变化(多语言混合、从数据库读出后重新组装、或者 json.dumps 前后有人插了键),token 序列就不一致,缓存全部失效。所以工具定义要么冻结成常量字符串,要么序列化时显式 sort_keys=True。

五、KV Cache 与编码吞吐的账​

同一个 token 数量,在 decode 阶段的成本是实打实的显存和带宽。以 Qwen2.5-7B(28 层、4 个 KV head 的 GQA、head_dim 128、fp16)为例:

单 token KV 大小 = 2 (K 和 V) × 28 层 × 4 head × 128 dim × 2 字节 = 57,344 B ≈ 56 KiB
每 1k tokens ≈ 54.7 MiB
8k 上下文 ≈ 448 MiB
32k 上下文 ≈ 1.75 GiB

公式摊开写,你就能自己算任意模型:2 × 层数 × KV head 数 × head_dim × 精度字节数。注意这里用的是 KV head 数(GQA 共享后的数量),用成 attention head 数会算大 7 倍,这是容量规划里最常见的错。生产环境的数字通常还包含分页/对齐浪费,vLLM 的 PagedAttention 把内部碎片压到 4% 以内——细节可以看 KV Cache 深度解析。

把这张账和第二节连起来看:中文段落如果比等价英文多花 1.35 倍 token,那它就等比吃掉 1.35 倍的 KV 显存、1.35 倍的 decode 带宽、以及更长的 TTFT。token 数是这四件事的共同乘数,这也是为什么它值得被当成一个系统指标而不是文本处理的细节。

最后一项是编码本身的开销。纯 Python 的实现有多慢?13.2 万字符花了 7.6 秒,约 17.3 千字符/秒。加一层词级 LRU 缓存(同一批文本里只有 144 个唯一词)之后,同样的输入 0.255 秒跑完,约 518 千字符/秒,加速 29.9 倍:

import functools

def make_cached_encoder(ranks):
@functools.lru_cache(maxsize=100_000)
def f(word):
return tuple(encode_word(word, ranks))
return f

enc = make_cached_encoder(ranks)
def encode_fast(text):
out = []
for w in pretok(text):
out.extend(enc(w))
return out

这条优化在服务端特别值得做,因为线上文本里关键词和系统提示词的重复率极高。缓存键是词而不是整段文本,所以不同请求之间也能互相命中。生产 tokenizer 用 Rust 加正则引擎把基线拉到了另一个量级,但"按词缓存"的思路是一致的。

六、生产踩坑清单​

  1. 用错 tokenizer 估成本。 拿 tiktoken 去估 DeepSeek 或 Claude 的账,误差可能到两位数百分比。跨模型做预算对比时,必须用各家的 tokenizer 或调用各家的 token 计数接口,不要用一个换算系数糊过去。
  2. 只报总 token 数。 按语言、按内容类型(代码/数字/文档)分桶上报,否则退化被平均值掩盖。第二节那个脚本就是最小实现。
  3. 缓存友好排序被当成"风格问题"。 把易变字段放前面在功能上完全正确,只在成本上错 41 倍。这类问题不会报错,只会体现在月底账单上。
  4. 约束解码被当成"可选的优化"。 结构化输出用 token 级约束是正确性手段,不是性能手段。用 Prompt 工程的下半场 里的评测回归框架把"格式错误率"单列一个指标盯住。
  5. 词表漂移没有进发布清单。 换模型就是换分词器,意味着你的 prompt 缓存全部失效、你测过的 token 预算全部作废、你按字符写的切块阈值全部失准。发布清单里必须有一行:pin 住 tokenizer 版本,并在切换后重跑 token 账单和缓存命中率。
  6. 切块按字符不按 token。 用 text[:4000] 做切块,中文可能直接超限,英文又浪费一半额度。切块函数必须以 token 为单位、给 tokenizer 留出边界(不能把词切一半)。分块策略的取舍见 RAG 进阶实战。
  7. 长上下文里塞了不该塞的。 上下文越长,每个 token 的边际成本越高(KV 线性增长、注意力平方增长)。压缩系统提示词的收益是按倍数放的,见 Gisting 上下文压缩 的做法。

总结​

分词器不显眼,但它是整条链路上唯一决定 token 数量的环节,而 token 数量同时是账单、KV 显存、decode 带宽、TTFT 和有效上下文窗口的共同乘数。它还是一个额度分配器:你的账单取决于训练语料的语言配比,不取决于词表大小这个漂亮的数字。

三件事今天就能做:用自家语料按语言分桶量一遍 token 数(第二节的十几行脚本);按稳定性降序重排一遍系统提示词,把会话 ID 和时间戳赶到末尾(第四节那份清单);把 tokenizer 版本写进发布 checklist,换模型时重跑账单和缓存命中率。三条加起来,投入不到一天,换来的是一个不会在月底给你惊喜的成本模型。

本文所有数字来自本地可复现实验,代码只用 Python 标准库。训练语料与评测语料严格隔离,实验脚本在 400 行以内,纯 CPU 数秒跑完。