斯坦福大学 2026 年春季课程 CS336《Language Modeling from Scratch》在 Hacker News 上引发了热议——这门课要求学生从零开始实现一个完整的语言模型,而第一个作业就是手写一个 BPE Tokenizer。为什么理解 Tokenizer 如此重要?因为 Tokenizer 是 LLM 的「感官器官」——模型不直接读文字,它只读 Token。你对 Tokenizer 理解的深度,直接决定了你在 Prompt Engineering、成本控制、多语言处理等环节能走多远。
📌 记住: Tokenizer 不是 LLM 的附属品,而是它的前置约束。GPT-4 的 Tokenizer 决定了它「看到」什么,Claude 的 Tokenizer 决定了它「理解」什么。两个模型对同一段文字的 Token 切分方式完全不同,这直接影响了它们在不同语言和代码场景下的表现差异。
🔬 一、Tokenizer 底层原理:为什么 LLM 需要子词分词
1.1 从字符级到词级的两难困境
在理解 BPE 之前,先要理解为什么现有的分词方案都不够好。
字符级分词(Character-level):每个字符一个 Token。词汇表极小(英文约 128 个),但序列极长——一句 “Hello, World!” 需要 13 个 Token。模型需要更多层才能捕捉长距离依赖,训练和推理成本飙升。
词级分词(Word-level):按空格分词,每个词一个 Token。词汇表爆炸——英语有超过 100 万个单词,加上各种变形、专有名词、代码标识符,词汇表可能膨胀到数百万。更致命的是,任何 OOV(Out-of-Vocabulary)词都无法处理。
⚠️ 警告: 词级分词在处理代码时尤其糟糕。一个 JavaScript 变量名
fetchUserDataFromAPI在词级分词下如果是 OOV,模型就完全无法理解它。而 BPE 可以将其拆分为["fetch", "User", "Data", "From", "API"]这样的有意义子词。
子词分词(Subword Tokenization) 是 BPE 的核心思想——高频词保持完整,低频词拆分为更小的、有意义的子词片段。它在词汇表大小和序列长度之间找到了最优平衡点:
| 分词方式 | 词汇表大小 | 序列长度 | OOV 处理 | 适合场景 |
|---|---|---|---|---|
| 字符级 | ~200 | 极长 | 无 OOV | 理论研究 |
| 词级 | 100万+ | 短 | 严重 OOV | 小语种、受限场景 |
| 子词级(BPE) | 32K-100K | 适中 | 无 OOV | 生产级 LLM |
1.2 BPE 算法的核心直觉
BPE(Byte Pair Encoding,字节对编码)最初是一种数据压缩算法,由 Philip Gage 在 1994 年提出。2016 年,Sennrich 等人将其引入 NLP 领域,用于机器翻译的子词分割。
BPE 的核心直觉极其简单:反复合并语料中出现频率最高的相邻字符对,直到达到目标词汇表大小。 就像压缩算法找到重复模式并用更短的编码替换它们一样,BPE 找到语言中的重复子词模式,把它们变成独立的 Token。
🚀 二、用 JavaScript 从零实现 BPE Tokenizer
2.1 第一步:统计相邻字符对频率
BPE 训练的第一步是从训练语料中统计所有相邻 Token 对的出现频率。初始时,每个字符都是一个独立的 Token。
// BPE Tokenizer - 第一步:统计相邻 Token 对频率
function getPairs(ids) {
const pairs = new Map();
for (let i = 0; i < ids.length - 1; i++) {
const pair = `${ids[i]},${ids[i + 1]}`;
pairs.set(pair, (pairs.get(pair) || 0) + 1);
}
return pairs;
}
// 将文本转换为初始 Token 序列(每个字符一个 Token)
function textToInitialTokens(text) {
return Array.from(text).map(ch => ch.charCodeAt(0));
}
// 示例:统计 "aaabdaaabac" 中的字符对频率
const text = "aaabdaaabac";
const tokens = textToInitialTokens(text);
const pairs = getPairs(tokens);
// 输出:{ "97,97": 3, "97,98": 2, "98,100": 1, "100,97": 1, ... }
// 97='a', 98='b', 100='d', 99='c'
console.log(Object.fromEntries(pairs));
2.2 第二步:合并最高频 Token 对
找到频率最高的 Token 对后,用一个新 Token ID 替换所有出现的位置。
// BPE Tokenizer - 第二步:合并最高频 Token 对
function merge(ids, pair, newId) {
const result = [];
let i = 0;
while (i < ids.length) {
if (i < ids.length - 1 && `${ids[i]},${ids[i + 1]}` === pair) {
result.push(newId); // 合并为新 Token
i += 2; // 跳过已合并的两个 Token
} else {
result.push(ids[i]);
i += 1;
}
}
return result;
}
// 完整的 BPE 训练函数
function trainBPE(text, vocabSize) {
// 初始 Token 为所有字节值(0-255)
let tokens = Array.from(new TextEncoder().encode(text));
const merges = []; // 记录合并规则
let nextId = 256; // 新 Token 从 256 开始
while (nextId < vocabSize) {
const pairs = getPairs(tokens);
if (pairs.size === 0) break;
// 找到频率最高的 Token 对
let maxPair = null;
let maxCount = 0;
for (const [pair, count] of pairs) {
if (count > maxCount) {
maxCount = count;
maxPair = pair;
}
}
// 合并最高频对
merges.push({ pair: maxPair, newId: nextId });
tokens = merge(tokens, maxPair, nextId);
console.log(`合并 [${maxPair}] → ${nextId},频率: ${maxCount}`);
nextId++;
}
return { merges, vocabSize: nextId };
}
2.3 第三步:完整的训练与编解码
将训练和编解码整合为一个完整的、可运行的 Tokenizer 类:
// 完整的 BPE Tokenizer 实现
class BPETokenizer {
constructor() {
this.merges = []; // 合并规则列表
this.vocab = new Map(); // Token ID → 字节序列
this.inverseVocab = new Map(); // 字节序列 → Token ID
}
// 训练 Tokenizer
train(text, targetVocabSize = 300) {
// 1. 初始化词汇表:每个字节值 (0-255) 为一个 Token
for (let i = 0; i < 256; i++) {
this.vocab.set(i, [i]);
this.inverseVocab.set(String([i]), i);
}
let ids = Array.from(new TextEncoder().encode(text));
let nextId = 256;
while (nextId < targetVocabSize) {
const pairs = this._getPairFrequencies(ids);
if (pairs.size === 0) break;
// 找到最高频的 Token 对
let bestPair = null;
let bestCount = 0;
for (const [pair, count] of pairs) {
if (count > bestCount) {
bestCount = count;
bestPair = pair;
}
}
if (!bestPair || bestCount < 2) break; // 频率不足则停止
// 记录合并规则
const [left, right] = bestPair.split(",").map(Number);
this.merges.push([left, right]);
// 更新词汇表
const newToken = [...(this.vocab.get(left)), ...(this.vocab.get(right))];
this.vocab.set(nextId, newToken);
this.inverseVocab.set(String(newToken), nextId);
// 执行合并
ids = this._merge(ids, left, right, nextId);
console.log(`合并 Token ${left} + ${right} → ${nextId} (频率: ${bestCount})`);
nextId++;
}
console.log(`训练完成!词汇表大小: ${this.vocab.size},合并次数: ${this.merges.length}`);
return this;
}
// 编码:文本 → Token ID 序列
encode(text) {
let ids = Array.from(new TextEncoder().encode(text));
// 按合并优先级依次应用
for (const [left, right] of this.merges) {
ids = this._merge(ids, left, right,
this.inverseVocab.get(
String([...this.vocab.get(left), ...this.vocab.get(right)])
)
);
}
return ids;
}
// 解码:Token ID 序列 → 文本
decode(ids) {
const bytes = [];
for (const id of ids) {
bytes.push(...this.vocab.get(id));
}
return new TextDecoder().decode(new Uint8Array(bytes));
}
// 内部方法
_getPairFrequencies(ids) {
const pairs = new Map();
for (let i = 0; i < ids.length - 1; i++) {
const key = `${ids[i]},${ids[i + 1]}`;
pairs.set(key, (pairs.get(key) || 0) + 1);
}
return pairs;
}
_merge(ids, left, right, newId) {
const result = [];
let i = 0;
while (i < ids.length) {
if (i < ids.length - 1 && ids[i] === left && ids[i + 1] === right) {
result.push(newId);
i += 2;
} else {
result.push(ids[i]);
i += 1;
}
}
return result;
}
}
// 使用示例
const corpus = `
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const result = fibonacci(10);
console.log(result);
`.repeat(50); // 重复多次以产生足够的统计信息
const tokenizer = new BPETokenizer();
tokenizer.train(corpus, 400);
// 测试编码和解码
const testCode = "function hello() { return 42; }";
const encoded = tokenizer.encode(testCode);
const decoded = tokenizer.decode(encoded);
console.log("原文:", testCode);
console.log("Token IDs:", encoded);
console.log("Token 数量:", encoded.length);
console.log("解码还原:", decoded);
console.log("还原正确:", testCode === decoded);
💡 提示: 这个实现使用 UTF-8 字节作为基础 Token(0-255),这是 GPT-2 以来所有主流 LLM 的标准做法。字节级基础保证了任何 UTF-8 文本都能被编码,彻底消除了 OOV 问题。这也是为什么 GPT-4 能处理中文、日文、Emoji 甚至代码——因为一切最终都是字节。
📊 三、三大分词方案深度对比:BPE vs WordPiece vs SentencePiece
3.1 算法原理差异
现代 LLM 使用的分词方案远不止 BPE 一种。理解它们的差异,才能在不同场景下做出正确的选择。
BPE(Byte Pair Encoding)——GPT 系列、LLaMA 系列的核心选择。采用贪心合并策略:从字符/字节开始,反复合并最高频的相邻对。训练过程确定性高,结果可复现。
WordPiece——BERT、DistilBERT 的选择。与 BPE 类似,但合并标准不同:它选择的是使语言模型似然度最大化的合并,而非最高频的合并。实际效果是 WordPiece 更倾向于合并那些在语言学上有意义的片段。
Unigram——XLNet、T5 的选择(通过 SentencePiece 框架)。采用自顶向下的策略:从一个巨大的候选词汇表开始,反复删除对整体编码效率影响最小的 Token。它会为每个 Token 分配概率,编码时选择概率最高的分词方式。
| 特性 | BPE | WordPiece | Unigram |
|---|---|---|---|
| 合并策略 | 最高频对 | 最大似然对 | 自顶向下裁剪 |
| 代表模型 | GPT-4, LLaMA 3, Gemma | BERT, DistilBERT | T5, XLNet, mBART |
| 确定性 | 完全确定 | 完全确定 | 概率性(可能有多种分词) |
| 多语言支持 | 需要特殊处理 | 需要特殊处理 | 原生支持(SentencePiece) |
| 训练速度 | 快 | 较慢 | 中等 |
| 子词正则化 | ❌ 不支持 | ❌ 不支持 | ✅ 支持 |
⚠️ 警告: SentencePiece 不是一种独立的分词算法——它是一个框架,支持 BPE 和 Unigram 两种算法。很多开发者混淆了 SentencePiece 和 BPE 的概念。LLaMA 3 使用的是 SentencePiece 框架下的 BPE 算法,T5 使用的是 SentencePiece 框架下的 Unigram 算法。
3.2 为什么 GPT 系列选择 BPE?
GPT-2 引入的 Byte-level BPE 解决了一个关键问题:UTF-8 字符的处理。
传统的 BPE 在 Unicode 字符级别操作,需要预先定义一个基础字符集。如果训练语料中没有某个 Unicode 字符,Tokenizer 就无法处理它。而 Byte-level BPE 在字节级别操作——UTF-8 编码将任何 Unicode 字符拆分为 1-4 个字节,基础词汇表只有 256 个字节 Token,天然覆盖所有文本。
// Byte-level BPE 的优势:天然处理任何 Unicode
const tokenizer = new BPETokenizer(); // 上文实现的 Tokenizer
// 英文
console.log(tokenizer.encode("Hello World"));
// 中文(UTF-8 字节自动处理)
console.log(tokenizer.encode("你好世界"));
// Emoji(UTF-8 字节自动处理)
console.log(tokenizer.encode("🚀🔥💡"));
// 代码(特殊字符自动处理)
console.log(tokenizer.encode("const x = arr.map(i => i * 2);"));
3.3 不同模型的 Tokenizer 实际表现
不同模型的 Tokenizer 对同一段文字的切分方式差异巨大,这直接影响了使用成本和效果:
| 文本 | GPT-4o Token 数 | Claude Sonnet Token 数 | LLaMA 3 Token 数 |
|---|---|---|---|
| “Hello, World!” | 4 | 4 | 5 |
| “你好,世界!” | 6 | 4 | 8 |
| “function fibonacci(n) {” | 7 | 8 | 10 |
| “console.log(result)” | 6 | 6 | 7 |
| “const x = arr.map(i => i * 2)” | 14 | 13 | 16 |
| “📧 Email: user@example.com” | 10 | 8 | 12 |
⚡ 关键结论: 中文和代码场景下,不同模型的 Token 数差异可达 2 倍。Claude 对中文的编码效率明显高于 GPT-4o,而 GPT-4o 对代码的编码效率优于 LLaMA 3。选择模型时,如果你的主要使用场景是中文交互或代码生成,Token 效率差异带来的成本影响是显著的。
💡 四、实战:用 tiktoken 验证与优化
4.1 在 JavaScript 中使用 tiktoken
OpenAI 开源的 tiktoken 是目前最快的 Tokenizer 实现之一(Rust 编写,WASM 编译到浏览器)。以下是实际使用方式:
// 使用 tiktoken-wasm 在浏览器/Node.js 中计算 Token 数
// npm install tiktoken-wasm
import { encoding_for_model } from "tiktoken-wasm";
// 获取 GPT-4o 的 Tokenizer
const enc = encoding_for_model("gpt-4o");
// 编码
const tokens = enc.encode("Hello, World! 你好世界 🚀");
console.log("Token IDs:", tokens);
console.log("Token 数量:", tokens.length);
console.log("解码:", new TextDecoder().decode(enc.decode(tokens[0])));
// 计算成本
const pricePerMillionTokens = 2.5; // GPT-4o 输入价格
const tokenCount = enc.encode(longPrompt).length;
const cost = (tokenCount / 1_000_000) * pricePerMillionTokens;
console.log(`Token 数: ${tokenCount}, 预估成本: $${cost.toFixed(6)}`);
enc.free(); // 释放 WASM 内存
4.2 Token 优化的工程实践
理解 Tokenizer 的工作方式后,可以针对性地优化你的 Prompt:
// ❌ 冗余写法 - 消耗更多 Token
const badPrompt = `
请帮我分析以下 JSON 数据的结构,我需要你详细地、逐字段地告诉我
每个字段的类型是什么,以及它们之间的嵌套关系是什么样的。
请用表格的形式输出结果,以便于我阅读和理解。
JSON 数据如下:
${JSON.stringify(data)}
`;
// ✅ 精简写法 - 同样效果,节省 40% Token
const goodPrompt = `分析此 JSON 结构,输出字段名|类型|嵌套关系的表格:
${JSON.stringify(data)}`;
💡 提示: BPE Tokenizer 对自然语言有强烈的「高频词偏好」——常见的英文单词通常是单个 Token,而生僻词会被拆成多个子词。如果你的 System Prompt 中反复使用不常见的技术术语,每个术语可能消耗 3-5 个 Token,而用一个等价的常见短语替代可能只需要 2 个 Token。
🔧 五、Tokenizer 踩坑指南与最佳实践
5.1 常见踩坑
踩坑 1:中文 Token 效率陷阱
中文在 GPT 系列的 Tokenizer 中效率较低——一个常见汉字平均消耗 2-3 个 Token(UTF-8 编码为 3 字节,BPE 合并后通常变为 2 个 Token)。而英文单词 “hello” 只需 1 个 Token。这意味着同样的语义,中文 Prompt 的 Token 成本可能是英文的 2-3 倍。
踩坑 2:代码中的空白字符
缩进空格在代码 Tokenizer 中通常是独立的 Token。4 个空格的缩进 = 4 个 Token,而 Tab 缩进 = 1 个 Token。如果模型支持 Tab 缩进,可以节省大量 Token。
踩坑 3:特殊 Token 的隐藏消耗
很多开发者不知道,每个 API 请求都有固定的 Token 开销。GPT-4o 的每个请求额外消耗约 4-6 个 Token 用于特殊标记(BOS、EOS、消息分隔符等)。对于大量短请求的场景,这些固定开销可能占总 Token 的 10% 以上。
5.2 最佳实践总结
- ✅ 用英文写 System Prompt:如果你的应用场景允许,英文 Prompt 的 Token 效率比中文高 50-100%
- ✅ 压缩 JSON 数据:移除不必要的空格和换行(
JSON.stringify(data)而非JSON.stringify(data, null, 2)) - ✅ 利用 Prefix Caching:OpenAI 和 Anthropic 都支持前缀缓存,相同的前缀部分不重复计费
- ✅ 分批发送长文本:如果对话历史过长,定期摘要压缩而非原样发送
- ❌ 不要逐字拼写:避免 “请 你 帮 我…” 这种每个字后面加空格的写法,这会让每个字都变成独立 Token
- ❌ 不要发送冗余上下文:如果只需要 JSON 的某个字段,不要发送整个 JSON
⚡ 关键结论: 理解 Tokenizer 不仅是为了省钱——它让你能更精确地控制模型的「注意力范围」。当你知道哪些 Token 会被合并、哪些会保持独立时,你就能写出模型更容易理解、Token 消耗更少的 Prompt。这是从「能用 LLM」到「用好 LLM」的关键跃迁。
📚 总结与工具推荐
Tokenizer 是 LLM 生态中最被低估的组件。通过本文,你应该理解了:
- BPE 的核心算法:从字节级基础词汇表出发,通过统计频率合并构建子词词汇表
- 三大分词方案的差异:BPE(贪心合并)、WordPiece(最大似然合并)、Unigram(概率裁剪)
- Token 效率的实际影响:不同语言、不同模型的 Token 效率差异可达 2-3 倍
推荐工具和资源:
- 🔧 tiktoken:OpenAI 官方 Tokenizer(Rust + WASM),支持 GPT 系列模型
- 🔧 sentencepiece:Google 的 SentencePiece 实现,支持 BPE 和 Unigram
- 🔧 Hugging Face Tokenizers:高性能 Rust Tokenizer 库,支持所有主流分词算法
- 📖 CS336 斯坦福课程:Language Modeling from Scratch,从零实现完整 LLM
- 📖 minbpe:Andrej Karpathy 的极简 BPE 实现,仅 200 行 Python 代码,适合学习原理