主题
从文本到 Token 与向量
一句话:模型不会读字,它只做矩阵运算。分词器先把文本切成词表里的 token,再把每个 token 查表变成一个向量;上下文长度、费用和 KV 显存,全部从 token 数开始算。
为什么需要
模型的输入必须是一个固定维度的数值矩阵,而文本是变长的离散符号。中间要跨过两道坎:怎么把字符串切成离散单元,以及怎么把离散单元变成连续向量。
切分粒度只有三种选择,每种都有明显代价:
- 按词切:词表会爆炸,新词、拼写变体、专有名词都成了未登录词;中文还要先分词,分词错误会一路传下去。
- 按字符切:词表很小,但序列变得很长,一个汉字一个 token,同样一段话的注意力成本成倍增加;字符本身携带的语义也太少。
- 按子词切:把高频片段作为一个单元,低频词拆成更小的片段。这是现在所有主流模型的共同选择。
分词结果直接决定 token 数,而 token 数是后面所有账本的单位:上下文窗口按 token 算,API 按 token 计费,KV 缓存按 token 占显存,生成长度也按 token 限制。同一个句子换一个分词器,token 数可能差出两三成,成本和容量估算都会跟着偏。
核心机制
一、子词分词:从字符开始合并
以 BPE(Byte Pair Encoding)为例,训练过程只有三步:
- 把所有训练文本拆成最小单元。按字节切可以覆盖任意 UTF-8 字符,不会出现未登录词;按字符切则词表更贴近语言。
- 统计相邻单元对的出现次数,把频率最高的一对合并成一个新单元。
- 重复第 2 步,直到词表达到预定大小。
合并规则就是分词器。推理时用同一组规则,从左到右把文本切成词表里最长的片段。高频词会成为单个 token,低频词退回更小的片段——这就是“常见词短、生僻词长”的原因。
SentencePiece 在此之上把空格也当成普通符号处理,因此不依赖语言的分词边界,中文、日文这类没有空格的语言也能直接训练。GPT 系列常用 byte-level BPE,Llama 系列用 SentencePiece 的 BPE 变体,词表大小从 3 万到 20 万不等。
二、从 token ID 到向量
分词器输出的是整数 ID。模型里有一张嵌入矩阵 E,形状是 词表大小 × d_model;ID 就是行号,取出对应的一行,就得到这个 token 的向量。
text
token id → E[id] → d_model 维向量这张矩阵是训练出来的,不是人工标注的。训练开始时随机初始化,反向传播只更新被用到的行;出现次数少的 token,向量也练得不准。很多模型让输入嵌入和输出投影共享同一张矩阵(weight tying),既省参数,也让“输入某个词”和“预测某个词”使用同一套坐标。
得到的是上下文无关的向量:同一个词在任何句子里,查表结果都一样。让“苹果”在“吃苹果”和“苹果发布会”里表示不同意思的,是后面的注意力层,不是嵌入层。
这张图把“文本进模型”拆成四步:字节化、按规则切分、映射成 ID、查表成向量。前两步由分词器完成,后两步由模型完成;分词器版本和嵌入矩阵必须匹配,换分词器而不换嵌入,ID 的含义就全变了。
三、token 不等于词
这是实际估算时最容易错的地方:
- 一个英文单词可能是一个 token,也可能被拆成三四个;越生僻、越长,越容易被拆。
- 一个汉字通常是一到两个 token,取决于词表和该字在训练语料里的频率。
- 数字常被拆成多位一组,
2026可能是202+6,模型对数字逐位运算的能力因此受影响。 - 开头的空格、标点、大小写都可能是独立 token;
" hello"和"hello"的 ID 不同。 - 代码里的缩进、括号、常见变量名会被合并;罕见标识符会被拆得很碎。
所以“1000 个汉字大约多少 token”没有统一答案,必须用目标模型的分词器实测。不同模型的 token 数不可直接比较,它们的词表、合并规则和特殊 token 都不一样。
四、词表大小的取舍
词表越大,同样的文本切出的 token 越少,上下文更省、推理更快;代价是嵌入矩阵和输出层更大,低频 token 的训练样本更少,尾部 token 的向量质量更差。词表越小,序列越长,注意力的平方级成本越高。
常见词表在 3 万到 20 万之间。Llama 3 的词表是 128,256,GPT-4o 的 o200k_base 约 20 万——大词表是为了更好地压缩多语言和代码,不是单纯“越大越好”。词表在预训练前就固定,之后新增 token 必须扩展嵌入矩阵并继续训练,否则新 token 的向量是随机初始化的。
五、特殊 token 与对话模板
除了文本 token,分词器还定义了一批特殊 token:句子开始/结束、填充、角色标记(system/user/assistant)、工具调用边界。对话模型靠这些标记区分“谁在说话”和“哪里是工具参数”。
这也是为什么不能把消息手工拼成字符串再发给模型:角色、边界和结束位置都要用模型规定的模板。模板错了,模型看到的就不是它训练时见过的结构,轻则答非所问,重则把工具参数当成普通文本生成。
最小上手
下面用一个 20 行的 BPE 训练器,观察“高频片段被合并”的过程:
python
from collections import Counter
def train_bpe(words, merges=6):
vocab = [list(w) + ["</w>"] for w in words]
rules = []
for _ in range(merges):
pairs = Counter()
for chars in vocab:
pairs.update(zip(chars, chars[1:]))
if not pairs:
break
pair = pairs.most_common(1)[0][0]
rules.append(pair)
for i, chars in enumerate(vocab):
out, j = [], 0
while j < len(chars):
if j + 1 < len(chars) and (chars[j], chars[j + 1]) == pair:
out.append("".join(pair)); j += 2
else:
out.append(chars[j]); j += 1
vocab[i] = out
return rules, vocab
words = ["low"] * 5 + ["lower"] * 2 + ["newest"] * 6 + ["widest"] * 3
rules, vocab = train_bpe(words)
print(rules)
print(sorted({"/".join(v) for v in vocab}))输出(合并规则和最终切分):
text
[('e', 's'), ('es', 't'), ('est', '</w>'), ('l', 'o'), ('lo', 'w'), ('n', 'e')]
['low/</w>', 'low/e/r/</w>', 'ne/w/est</w>', 'w/i/d/est</w>']可以看到 est 先被合并成整体,随后 low 也成为一个 token;newest 和 widest 共享了 est,但前缀不同,切分结果也不同。真实分词器只是把这个过程放大到几十万词表,并对字节而不是字符操作。
生产动作
- 按 token 做预算,不按字符。上下文预算 = 系统提示 + 工具 Schema + 历史消息 + 检索内容 + 输出预留,每一项都用目标模型的分词器计数。
- 输出要留余量。
max_tokens不是“最多写多少”,而是“必须从窗口里扣掉多少”;不预留会触发截断,JSON 或工具调用会在中途断掉。 - 截断按优先级,不按位置。先保留系统约束和最近轮次,再压缩检索材料;不要从中间硬切,切掉的可能正是任务目标。
- 分词器版本跟模型绑定。升级模型时同步升级分词器和对话模板;自己扩词表要重训嵌入,并重新评估旧任务。
- 对中文、英文、代码分别统计。同一套业务在中英文混合场景下的 token 成本可能相差一倍,按单一比例估算会失真。
边界
- token 不是语义单元。一个 token 可能是一个词、半个词或几个字符,token 数少不代表句子简单。
- 嵌入向量不是“词义”。它是训练得到的坐标,只在当前模型的坐标系里有意义,不能跨模型比较或直接做语义运算。
- 分词器不会理解文本。它只做字符串到整数的映射;分词错误会放大,但分词正确也不保证模型理解正确。
- 词表不是越大越好。大词表省 token,但尾部 token 训练不足,嵌入和输出层也更占显存。
- 特殊 token 是协议的一部分。手工拼接消息、忽略结束标记、错用角色标记,都会让模型处在训练分布之外。
讲清楚
模型只接受数值矩阵,所以文本要先过分词器:按 BPE 这类子词规则把字符串切成词表里的 token,再用 token ID 去嵌入矩阵里查表,得到向量。token 是上下文、费用和 KV 显存的统一单位,但它不等于词——同一个句子换分词器,token 数会变,不同模型之间也不能直接比。词表大小是压缩率与尾部质量的取舍;特殊 token 和对话模板则是模型协议的一部分,不能手工拼字符串替代。
实际做预算时,用目标模型的分词器数 token,给输出留余量,按优先级截断,并把分词器版本和模型版本绑定。
适用:所有基于子词分词的大模型;词表与模板以具体模型为准 | 初稿 2026-10