分词器才是真正的接口:BPE 从零实现,以及它如何决定你的账单、缓存命中率与算术能力
后端工程师看 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, 词表 400 | 161 | 0.6% | 0.722 | 0.989 |
| en1:zh1, 词表 400 | 161 | 16.8% | 0.728 | 0.947 |
| en8:zh1, 词表 1200 | 720 | 48.8% | 0.631 | 0.939 |
| en1:zh8, 词表 1200 | 724 | 49.0% | 0.631 | 0.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 切分 | 片数 | 三位分组(右对齐) |
|---|---|---|---|
| 128 | 128 | 1 | 128 |
| 1024 | 1024 | 1 | 1|024 |
| 1234 | 1234 | 1 | 1|234 |
| 12345 | 12345 | 1 | 12|345 |
| 3600 | 36/00 | 2 | 3|600 |
| 123456 | 123/4/56 | 3 | 123|456 |
| 81920 | 819/2/0 | 3 | 81|920 |
| 1234567 | 123/4/5/6/7 | 5 | 1|234|567 |
| 20261002 | 202/6/1/00/2 | 5 | 20|261|002 |
| 999999 | 9/9/9/9/9/9 | 6 | 999|999 |
平均每个数字 2.86 片,而三位分组只要 2.07 片。但片数不是重点,真正的病是不一致:
| 数字位数 | 观察到的切法(各位数长度) | 切法种类 |
|---|---|---|
| 4 位 | 1111 / 112 / 121 / 211 / 4 | 5 种 |
| 5 位 | 11111 / 1211 / 5 | 3 种 |
| 6 位 | 111111 / 1221 / 312 / 51 | 4 种 |
同样是 4 位数字,1234 是一个 token,3600 是 36/00 两个,100 是 1/00 两个,4919 是 4/9/19 三个。同一个数位在语料里出现得越频繁,它越可能被合并进一个 token;一旦出现位置靠前或靠后,切法就变。模型没有稳定的"个十百千"对齐信息可以学——它看到的是一串长度和边界都在漂移的符号。
这不是"模型太笨",是把一个位置敏感的任务交给了一个位置不敏感的表征。缓解手段有三条,按性价比排序:
- 输出侧用约束解码,不要用后置 JSON 修复。 数字字段在语法层固定成一到六位纯数字这种 token 级别的约束,让解码器在每一步只能选数字符号,比生成完再正则修补可靠得多,也省一次重试。
- 评测集里单独加一组"数字还原"用例。 大整数比较、金额对齐、版本号、时间戳,各来二十条。这类用例在通用评测里权重极低,却是业务事故的高发区。
- 不要在 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 加正则引擎把基线拉到了另一个量级,但"按词缓存"的思路是一致的。
六、生产踩坑清单
- 用错 tokenizer 估成本。 拿
tiktoken去估 DeepSeek 或 Claude 的账,误差可能到两位数百分比。跨模型做预算对比时,必须用各家的 tokenizer 或调用各家的 token 计数接口,不要用一个换算系数糊过去。 - 只报总 token 数。 按语言、按内容类型(代码/数字/文档)分桶上报,否则退化被平均值掩盖。第二节那个脚本就是最小实现。
- 缓存友好排序被当成"风格问题"。 把易变字段放前面在功能上完全正确,只在成本上错 41 倍。这类问题不会报错,只会体现在月底账单上。
- 约束解码被当成"可选的优化"。 结构化输出用 token 级约束是正确性手段,不是性能手段。用 Prompt 工程的下半场 里的评测回归框架把"格式错误率"单列一个指标盯住。
- 词表漂移没有进发布清单。 换模型就是换分词器,意味着你的 prompt 缓存全部失效、你测过的 token 预算全部作废、你按字符写的切块阈值全部失准。发布清单里必须有一行:pin 住 tokenizer 版本,并在切换后重跑 token 账单和缓存命中率。
- 切块按字符不按 token。 用
text[:4000]做切块,中文可能直接超限,英文又浪费一半额度。切块函数必须以 token 为单位、给 tokenizer 留出边界(不能把词切一半)。分块策略的取舍见 RAG 进阶实战。 - 长上下文里塞了不该塞的。 上下文越长,每个 token 的边际成本越高(KV 线性增长、注意力平方增长)。压缩系统提示词的收益是按倍数放的,见 Gisting 上下文压缩 的做法。
总结
分词器不显眼,但它是整条链路上唯一决定 token 数量的环节,而 token 数量同时是账单、KV 显存、decode 带宽、TTFT 和有效上下文窗口的共同乘数。它还是一个额度分配器:你的账单取决于训练语料的语言配比,不取决于词表大小这个漂亮的数字。
三件事今天就能做:用自家语料按语言分桶量一遍 token 数(第二节的十几行脚本);按稳定性降序重排一遍系统提示词,把会话 ID 和时间戳赶到末尾(第四节那份清单);把 tokenizer 版本写进发布 checklist,换模型时重跑账单和缓存命中率。三条加起来,投入不到一天,换来的是一个不会在月底给你惊喜的成本模型。
本文所有数字来自本地可复现实验,代码只用 Python 标准库。训练语料与评测语料严格隔离,实验脚本在 400 行以内,纯 CPU 数秒跑完。