Token 是什么?分词方式对成本和效果有什么影响?
一句话回答
Token 是模型处理文本的基本单位,由分词器切分得到,可能是一个词、词的一部分、一个或几个汉字,甚至只是一个字符的部分字节。主流模型用子词分词(如 BPE):常见的词整体作为一个 token,罕见的词拆成几段,既控制了词表大小,又基本不会遇到"不认识的词"。计费、上下文上限、生成速度都按 token 计算,同一段文字在不同模型里的 token 数不同,要用对应模型的分词器来算。
详细解析
为什么用子词
| 粒度 | 优点 | 缺点 |
|---|---|---|
| 按字符或字节 | 词表很小,任何文本都能表示 | 序列很长,计算量大;单个字符携带的语义少 |
| 按词 | 序列短,语义完整 | 词表巨大;新词、错别字、罕见词无法表示 |
| 子词 | 折中:常见词是一个 token,罕见词拆成几个片段 | 切分结果不一定符合人的语感 |
比如一个较长的英文单词,可能被切成 "un"、"believ"、"able" 三段(只是示意,实际怎么切取决于分词器)。
BPE 怎么构建词表
BPE(字节对编码)的思路是"把高频的组合合并成新 token":
- 从最小单位开始:单个字符,或者直接用 256 个字节值
- 统计语料中所有相邻单位组合出现的次数
- 把出现次数最多的一对合并成新单位,加入词表
- 重复第 2、3 步,直到词表达到预设大小
切分新文本时,按训练时学到的合并规则依次合并。以字节为基础单位的 BPE(Byte-level BPE)能表示任何语言和符号,不会出现未知字符:常见的汉字和词被合并成一个 token,生僻字可能被拆成好几个字节 token。其他常见算法还有 WordPiece、Unigram,做法不同,目的一样。
对成本、上下文和速度的影响
- 计费:输入和输出分别按 token 计费,见 一次调用的成本怎么估算
- 上下文上限:窗口大小按 token 计算,见 上下文窗口
- 速度:模型逐个 token 生成,同样的内容 token 越多,生成越慢
- 语言差异:词表里中文词汇覆盖得多,中文就省 token;词表以英文为主,一个汉字可能要占多个 token。不要背"一个汉字约等于几个 token"这类固定比例,换个模型就不成立
- 隐藏开销:对话格式里的角色标记、工具定义、JSON 的引号和括号都要占 token
估算 token 要用对应模型的分词器:例如 OpenAI 的 tiktoken 库,开源模型随权重一起发布的 tokenizer 文件;有的厂商还提供 token 计数接口。最准的是接口响应里的 usage 字段,它是实际计费的依据。
分词带来的弱点
模型看到的是 token,不是一个个字符,所以字符级的任务容易出错:
- 数字母:问 "strawberry 里有几个 r",模型看到的是几个子词片段,而不是 10 个字母
- 拼写和倒序:把单词倒着写、逐个字母拼读
- 数字计算:长数字会被切成几段,切法因分词器而异:有的长短不一,有的从左往右最多三位一组,有的逐位切开。不是逐位切开时,分段往往和个、十、百位对不上,多位数运算容易算错
- 精确控制长度:要求"恰好多少字"很难做到
应对方法:精确计算和字符处理交给代码或工具(见 Function Calling),长度要求在生成后用程序校验。
代码示例
用 JS 演示 BPE 的训练过程(真实实现还要处理字节、空格和特殊 token):
// 极简 BPE 训练:从单个字符出发,反复把出现次数最多的相邻一对合并成新 token
function trainBPE(words, numMerges) {
let corpus = words.map((w) => [...w]) // 每个词先拆成单个字符
for (let step = 1; step <= numMerges; step++) {
// 统计所有相邻 token 对的出现次数
const counts = new Map()
for (const tokens of corpus) {
for (let i = 0; i < tokens.length - 1; i++) {
const pair = `${tokens[i]}\u0000${tokens[i + 1]}`
counts.set(pair, (counts.get(pair) ?? 0) + 1)
}
}
if (counts.size === 0) break
const [best] = [...counts].sort((x, y) => y[1] - x[1])[0]
const [a, b] = best.split('\u0000')
console.log(`第 ${step} 次合并:${a} + ${b} → ${a + b}`)
// 在语料中执行这次合并
corpus = corpus.map((tokens) => {
const merged = []
for (let i = 0; i < tokens.length; i++) {
if (tokens[i] === a && tokens[i + 1] === b) {
merged.push(a + b)
i++ // 跳过已经合并进来的下一个 token
} else {
merged.push(tokens[i])
}
}
return merged
})
}
return corpus
}
const corpus = trainBPE(['大模型', '大模型应用', '模型训练', '大语言模型', '应用开发'], 3)
console.log(corpus.map((tokens) => tokens.join(' | ')))
// 第 1 次合并:模 + 型 → 模型
// 第 2 次合并:大 + 模型 → 大模型
// 第 3 次合并:应 + 用 → 应用
// [ '大模型', '大模型 | 应用', '模型 | 训 | 练', '大 | 语 | 言 | 模型', '应用 | 开 | 发' ]
面试官可能追问
词表是不是越大越好?
不是。词表大,同样的文本切出的 token 少,序列更短、更省上下文;但输入层和输出层的参数随词表变大,低频 token 的训练样本少,学得不充分。多语言模型通常会扩大词表,提高非英文文本的编码效率。
同样的内容,为什么 JSON 比纯文本更费 token?
JSON 的引号、括号、逗号和重复出现的键名都要占 token。把大批表格类数据传给模型时,可以考虑更紧凑的格式(如 CSV、Markdown 表格)或缩短键名,但要用评测确认模型理解得一样好。
让模型写"恰好 200 字",为什么总是做不到?
模型按 token 生成,一个 token 对应的字数不固定,它也没有可靠的计数能力。实际做法是给一个范围(如 150~200 字),生成后用代码校验长度,超出时截断或要求重写。
自己部署模型做流式输出,为什么偶尔会出现乱码?
用字节级分词时,一个汉字的几个字节可能分在相邻的两个 token 里。如果每生成一个 token 就单独解码成字符串,会得到不完整的 UTF-8 字节,显示成乱码。要把字节缓冲起来,凑成完整的字符再输出;常见推理框架的流式接口一般已经处理了这一点。
易错点
- 不要背固定的"汉字和 token 换算比例",分词器不同,结果就不同
- 用 A 模型的分词器估算 B 模型的 token 数会有偏差,同一厂商不同代的模型也可能换了分词器
- 估算成本时别漏了系统提示、工具定义、历史消息和输出
- token 数、字数、字节数是三个不同的量
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。