Token 的一生:从 BPE 分词到向量嵌入再到自回归生成
1. 这个标题到底在问什么
如果你正在啃 MiniMind 这个从零实现大模型的教学项目,应该已经被反复出现的 Token 这个词折磨过。它一会儿是分词结果,一会儿是数字下标,一会儿又变成向量,到了输出的时候又变回一串文字。很多人上手大模型时第一道坎,就是从各种教程里看到“把文本 token 化”这句话开始懵的。我当时也一样,花了整整两天才彻底搞明白 Token 到底经历了什么。
这篇笔记不是要念一遍官方文档,而是把我自己跟着 MiniMind 代码跑通之后的整个理解过程,用“三世”这个框架重新串一遍。第一世是文字变成 Token,第二世是 Token 在模型内部变身成向量并参与计算,第三世是 Token 再变回文字。看完你应该能回答这些最基础的灵魂拷问:为什么大模型不直接读文字?分词器到底在干嘛?为什么说大模型的记忆长度是按 Token 数算的?生成的时候为什么一个字一个字往外蹦?
这篇内容适合正在学 MiniMind、想搞懂大模型基础原理的人,也适合那些已经在用 API 但总被 Token 计费搞到头疼的开发者。搞清楚 Token 的完整生命周期,你再看大模型相关的代码、API 文档、调参指南,都会有豁然开朗的感觉。
2. Token 的第一世:从文字到数字编号
2.1 为什么大模型不直接读你的文字
很多人第一次接触 token 化的时候,第一反应是:把文字直接转成 Unicode 编码喂给模型不就行了?理论上有这个思路,但实际完全行不通。神经网络吃的是定长的浮点数向量,而且它需要从大量的文本中统计出规律。如果你把每个汉字直接映射成一个 Unicode 码点,比如“你”是 20320,这只是一个孤零零的整数,模型很难从这个数字本身学到“你”和“您”在语义上接近,更学不到“今天天气不错”和“明天天气很好”这种跨句子的结构关系。
还有一个更现实的问题:直接按字切分会导致序列极长。一个字一个 Token,一篇 1000 字的文章就是 1000 个 Token,而大多数模型的上下文窗口也就几千到几万 Token。如果按词切分,1000 字的文章大约只有 600 到 700 个词,能省下不少空间。但如果完全按词切分,又会出现无穷多的生僻词、组合词、网络新词,词表根本装不下。所以工业界的主流方案是做一个折中:把词切成更细的片段,让常见词是一个完整 Token,不常见的词拆成多个子词 Token。这套东西就是子词分词算法,其中最主流的就是 BPE。
2.2 BPE 是如何一层层“碎纸”的
BPE 全称是 Byte Pair Encoding,字节对编码。它的核心思想特别朴素:从一个字符级别的词表开始,反复统计语料里相邻字符对的出现频率,把最高频的那一对合并成一个新符号,加进词表,然后再统计、再合并,直到词表达到目标大小。
举个例子可能更直观。假设语料里反复出现“学习”这两个字,而且“学”和“习”总是挨在一起。第一次迭代,算法统计到(“学”, “习”)这对的频率最高,于是把“学习”合并为一个 Token。接着,“学习机”里的“学习机”三个字出现频率也高,那可能下一轮继续把“学习机”合并。这个过程不断重复,最后词表里既有“学”“习”这种单字,也有“学习”“学习机”这种词组,还有“的”“了”这种高频单字。
BPE 的优点在于它能平衡词表大小和序列长度。词表越大,每个词被完整覆盖的概率越高,序列越短,但模型参数量也会变大,训练更慢。词表太小,虽然模型变轻,但很多词会被拆得稀碎,序列边长,而且语义信息被分散到多个 Token 里。MiniMind 项目采用的是 GPT-2 风格的中文 BPE 词表,0.5B 版本用的词表大小大约在 16000 左右,蒸馏版本会更小。你可能好奇为什么中文模型用 BPE 而不是别的算法,这里有个关键点:BPE 不在乎语言类型,它只处理字节。MiniMind 把中文先按 UTF-8 编码转成字节,再做 BPE 合并,这样既能处理中文,又能无缝处理英文、表情符号、甚至乱码。
2.3 词表长什么样,以及特殊 Token 的角色
词表本质上就是一个字典,从 Token 字符串映射到整数 ID。MiniMind 的 tokenizer 加载之后,你可以直接看它的 vocab 文件,里面会有很多看起来不像词的符号,比如 “Ġ” 表示空格,“Ċ” 表示换行,这是 GPT 系列分词器的特色。中文语境下你可能看到 “我是一个” 这种完整短语直接占一个 ID,也可能看到 “苹果” 被拆成 “苹” 和 “果” 两个 Token,这都取决于语料里出现频率。
除了普通 Token,词表里还有几个特殊 Token。MiniMind 中你会看到<unk>表示未知 Token,<s>和</s>表示序列开始和结束,<pad>用于填充。在对话模型里还可能看到<endoftext>这种用来分隔不同段落的控制符号。这些特殊 Token 同样有 ID,而且在参与模型计算时和普通 Token 一视同仁——它们也是向量序列中的一员。理解这一点很重要,因为后面看注意力矩阵的时候,你会发现特殊 Token 也占着位置,也和其他 Token 做注意力计算。
3. Token 的第二世:在模型内部变成向量并参与理解
3.1 Embedding:给每个 Token 发一个“空间坐标”
Token 经过分词器变成了一个整数 ID,比如“学习”对应 1024。这个整数本身没有任何语义,1024 比 1023 大不代表“学习”比某个词更高级。模型要做的事,是把这个 ID 映射成一个向量。这一步叫 Token Embedding。
你可以这样理解:模型有一个巨大的查找表,这个表一共有词表大小行,每一行是一个固定维度的向量,比如 512 维或 768 维。MiniMind 的配置里,hidden_size 决定了这个向量的维度。这个查找表里的向量不是随机初始化的,而是在训练过程中不断被调整。经过充分训练后,语义相近的 Token 在向量空间里的位置会比较接近。比如“喜悦”“开心”“高兴”这三个词的 embedding 向量,它们的余弦相似度会明显高于“喜悦”和“悲伤”。这就像你给每个词发了一个空间坐标,意思相近的词住在坐标的同一个街区。
不过有一点容易被忽略:embedding 向量本身是静态的,也就是说无论“苹果”出现在“吃苹果”还是“苹果公司”,它初始拿到的 embedding 向量是完全一样的。模型真正理解上下文,靠的是后面 Transformer 层的层层加工,embedding 只是一块未经打磨的原料。
3.2 位置编码:给 Token 发座位号
如果你把一句“我喜欢你”送给模型,模型拿到的是三个 Token 的 embedding 向量拼成的矩阵。但 Transformer 结构本身没有顺序概念,如果把这三个 Token 打乱顺序,模型看到的输入是完全一样的。这显然不行,因为“我喜欢你”和“你喜欢我”意思完全不同。
所以模型需要把位置信息也塞进去。MiniMind 使用的是旋转位置编码 RoPE,它不是简单地把一个固定的位置向量加到 embedding 上,而是把位置信息通过旋转矩阵的方式作用于 Q 和 K 向量。这样做的好处是:模型能感知相对位置关系,而不只是绝对位置。比如“北京的天气”里,“北京”和“天气”之间隔了一个“的”,RoPE 能让注意力分数天然地带上这个相对距离信息。你不需要手动实现这个细节,但你应该理解它的存在——因为这就是为什么上下文窗口拉长以后,位置编码可能会失效,导致模型输出变乱的原因之一。
3.3 上下文窗口:Token 的座位是有限的
上下文窗口说的是模型一次最多能“看到”的 Token 数量。MiniMind 的默认上下文长度一般是 512 或 1024,也有一些版本支持调到 2048。这个数字决定了输入和输出的总 Token 上限。比如你给模型输入一段 800 Token 的文本,那它最多只能再生成 224 个 Token,超出的部分会被截断,或者直接报错。
这个限制和显存、计算量直接相关。注意力机制的计算复杂度是序列长度的平方,序列长度翻倍,计算量涨四倍。所以在有限显存下,上下文长度是被迫妥协的。MiniMind 作为一个教学项目,刻意保持较小的上下文窗口,为的是让你能在普通显卡甚至 CPU 上跑起来。实际生产环境里,动辄几万 Token 的窗口背后靠的是稀疏注意力、Ring Attention 这类优化手段,但核心原理依然是“给每个 Token 分配注意力”。
3.4 注意力机制:Token 之间的“互相打量”
当所有 Token 都变成向量并带上位置信息后,进入 Transformer 层。每一层里最重要的操作是自注意力。你可以把它想象成:序列里的每个 Token 都会向其他 Token 发出提问,同时也会回应别人的提问。具体来说,每个 Token 生成三个向量:Query(我关心什么)、Key(我有什么特征)、Value(我实际提供的信息)。然后用 Query 去点乘所有 Key,得到一个分数,这个分数再经过 Softmax 变成权重,最后把所有 Value 按权重加权求和,得到这个 Token 更新后的表示。
这么一说你可能觉得抽象,换个生活化的类比:班上开研讨会,每个同学(Token)要先讲自己知道什么(Key),同时说明自己想知道什么(Query),然后大家根据这些信息互相打分,最后每个人综合大家的发言(Value),更新自己的理解。这个过程在每一层重复,语序、指代、修辞都能通过这些交互慢慢被捕捉到。MiniMind 的代码里你可以看到 attention_mask 的存在,它保证模型在预测当前位置的时候只能看到左边的 Token,不能偷看右边,这是自回归模型的核心约束。
4. Token 的第三世:从概率分布变回文字
4.1 最后的 Softmax:预测下一个 Token
模型一层层处理完所有 Token 后,最后一个位置会输出一个隐藏状态向量。这个向量会经过一个映射层,把维度从 hidden_size 映射到词表大小。得到的每个位置上都有一个分数,叫 logit。然后对这些 logit 做 Softmax,转成概率分布:每一个词表里的 Token 都对应一个概率,所有概率加起来等于 1。模型实际干的事,就是“猜下一个 Token 是谁”。
MiniMind 的 forward 函数里,如果你传入 input_ids,它输出的是每个位置的 logits。对于训练阶段,你会拿这个 logits 和真实的下一个 Token 计算交叉熵损失。对于推理阶段,你会拿最后一个位置的 logits,根据某种策略挑一个 Token id,然后把它拼接到输入序列末尾,继续预测下一个。这就是自回归生成:它每次都只多生成一个 Token,然后把生成的 Token 当成下一次输入的一部分,反复循环,直到遇到结束标记或者达到最大长度。
4.2 采样策略:temperature、top-k、top-p
直接从概率分布里挑最大概率的那个 Token,得到的是确定但略显死板的输出。为了让输出更多样,实际推理时会用到采样策略。先说 temperature,它是在 Softmax 之前对 logits 做缩放:temperature 小于 1,概率分布变得更尖锐,高分 Token 的优势更明显;大于 1,分布变得更平滑,低概率 Token 也有机会被选中。你可以想象成把概率山峰加热:温度低了山峰更陡,翻来覆去就那么几条路;温度高了山峰变矮变平,到处都可能走两步。
top-k 是从概率最高的 k 个 Token 里重新归一化后采样,直接把长尾裁掉。top-p 则是按概率累积值裁切,从高到低累计概率超过 p 的那批 Token 里面重新采样。MiniMind 的 generate 函数里支持这些参数,你调一调马上能感觉到差异:temperature 开太低会复读,开太高会胡言乱语;top-p 设太小会丢失多样性,设太大等于没设。刚上手时我建议先固定 top-p 为 0.9,再慢慢调 temperature,会更容易找到手感。
4.3 为什么输出是一个字一个字蹦的
看到这里你应该能理解为什么大模型聊天时是一个字一个字往外冒的,而不是一次性把整篇回复吐出来。因为它每推进一步只产生一个 Token,然后把新 Token 并入上下文再做下一次预测。每一次推理都是一次完整的模型前向计算,只不过输入比上一次多了一个 Token。从代码角度看,就是在一个 for 循环里不断调用模型,把当前生成的 token id 追加到 input_ids 后面。这种方式虽然慢,但能保证每一步都基于最新的完整上下文,这是自回归模型的固有特性。
实际使用中你会发现,MiniMind 的 generate 函数里还做了 KV Cache 优化。它会缓存历史 Token 计算出来的 Key 和 Value 矩阵,这样新 Token 进来以后,只需要计算新位置的 Q、K、V,以及新位置对旧位置的注意力,不需要重新计算全部旧 Token 的 K 和 V。这就是为什么你在流式输出的时候,第一个 Token 可能慢一点,后面的 Token 一个比一个快。
5. 实操:在 MiniMind 里跑通一次完整的 Token 之旅
5.1 环境准备:拿到 MiniMind 并加载模型
MiniMind 项目推荐用 Python 3.9 以上版本,需要安装 PyTorch、transformers、tokenizers、numpy 这些基础库。如果你没有 GPU,用 CPU 也能跑通 MiniMind,就是速度慢不少。拿 MiniMind-0.5B 这个版本举例,加载模型只需要几行代码。我用的环境是 Python 3.10 加上 PyTorch 2.1,内存 16G,跑 0.5B 的 CPU 推理没什么压力。
from model.model import MiniMind from model.tokenizer import ChatTokenizer tokenizer = ChatTokenizer.from_pretrained('MiniMind-0.5B') model = MiniMind.from_pretrained('MiniMind-0.5B')第一次加载会自动下载权重文件。这里提个醒:MiniMind 项目的权重保存在模型仓库里,网络不好的时候可能下载失败,多试几次或者用镜像源通常能解决。如果你只是想先看分词效果,不加载模型也能跑 tokenizer,这样能更快上手。
5.2 编码:一段文字如何变成 ID 序列
模型拿到你的输入后,tokenizer 会先把文本切分成 Token,再把每个 Token 映射成 ID。下面的代码可以直观展示这个过程:
text = "MiniMind 是一个从零开始学习大模型的项目" ids = tokenizer.encode(text) print(ids) print(tokenizer.decode(ids))你可能会发现 encode 打印出来的 ID 列表里,有些 Token 对应的是完整单词“MiniMind”,有些是中文单字,有些是两个字的词。这正是 BPE 分词器按频率切分的结果。decode 方法可以把 ID 列表还原成文本,但还原出来的字符串可能会多出一些空格标记,这是分词器的正常行为,因为空格本身也是一个 Token 的一部分。
这里有一个新手常见的坑:不要试图把 tokenizer.encode() 得到的 ID 数量和文本的字数对应起来。中文字数和 Token 数之间没有固定比例关系。MiniMind 的词表经过训练后,常见的两字词、三字词会合并成一个 Token,但生僻的组合就会拆开。我自己实测过,一句 20 个字的话大概会变成 15 到 25 个 Token,波动非常大。你要是做 Token 计费,一定以实际 tokenizer 计算的结果为准,别按字数拍脑袋。
5.3 生成:一个 Token 一个 Token 往外蹦
加载模型并输入一句话,调用 generate 方法,你就能看到模型逐字吐回复了。MiniMind 的 generate 函数内部封装了前面提到的循环调用和采样策略。你可以试着调整参数,观察输出的变化:
response = model.generate( input_text="讲一个关于机器人的简短故事", max_new_tokens=200, temperature=0.7, top_p=0.9 ) print(response)当 max_new_tokens 设得越大,生成时间越长,因为每个 Token 都要做一次完整的前向计算。如果你开启流式输出,可以看到控制台里一个字一个字地蹦出来,第一次体验会非常有意思。这里有个特别值得留意的地方:你输入的中文提示词,在模型内部其实已经被切成了若干个 Token,而模型生成的第一个 Token 大概率不是“讲”或“我”这种字,而是一个空格或者语气词。这就是为什么有些模型的回复在一开始会有一个空格,不是模型的 bug,是分词器风格的正常表现。
6. 常见问题与避坑指南
6.1 Token 数量为什么总对不上你的估算
很多人用 API 时会发现,文档说中文大概一个汉字一个 Token,但实际账单总是超出预期。原因主要有两个:一是标点符号、空格、换行都会单独占用 Token;二是有些常用短语会合并成一个 Token,但有些非常用词会被拆成三四个 Token。更关键的是,对话历史里的 system prompt、历史轮次、生成结果本身,全部都要计入下一次请求的输入 Token。所以如果你在写一个聊天机器人,每一轮都要把之前的对话历史重新发送一遍,Token 消耗会成倍增长。MiniMind 因为是本地运行,不涉及计费,但这个计数逻辑和商业 API 完全一致。
6.2 中文分词的特殊陷阱
中文不像英文有天然空格,BPE 分词器在中文上往往会出现一些看起来很奇怪的切分。比如“你好”这种高频词一般是一个 Token,但“新能源汽车”可能被切成“新能源”“汽车”两个 Token,甚至“新”“能源”“汽车”三个。这不是错误,是频率统计的结果。如果你发现某个生僻词总是被拆得特别碎,导致模型理解不到位,一个有效的手段是在提示词里用常见词替换生僻同义词,降低拆分的概率。另外要注意,MiniMind 的词表是基于特定语料训练的,不是所有中文词都有覆盖,遇到 OOV(词表外词汇)时,Tokenizer 会用<unk>占位,模型对这类 Token 的理解基本为零。所以写提示词时尽量用大众化、常见的表达,效果往往比华丽辞藻更好。
6.3 上下文长度超限怎么办
当你给模型输入的文本太长,超过配置的上下文窗口时,MiniMind 的处理方式通常是截断。截断策略一般是保留开头和结尾,丢掉中间,因为对话或文档里,开头往往包含任务说明,结尾包含最新的指令。但这也意味着被丢弃的部分信息模型完全看不到。如果你真的需要处理超长文本,有几个方案:一是把文本切分成块,分段让模型处理,再用另一个模型或脚本汇总;二是做关键信息提取,先用一个 prompt 让模型把长文本压缩成摘要;三是升级到支持更长上下文的模型版本。MiniMind 作为教学项目,上下文窗口不大,所以这也是一个理解长文本处理为什么困难的好机会。
6.4 生成内容乱码和无限复读
如果你把 temperature 调到 1.5 以上,很可能会看到模型输出一堆不着边际的乱码,这其实不是模型坏了,是采样策略过于激进,把低概率 Token 也拉出来遛了。反过来,temperature 设为 0 时,模型每次都取最高概率 Token,很容易陷入重复循环,比如一直输出“好的好的好的好的”。碰到这种情况,我建议把 temperature 设在 0.7 到 0.9 之间,配合 top-p 为 0.9,同时把 repetition_penalty 调高一些,能有效抑制复读。MiniMind 的 generate 函数里如果支持 repetition_penalty,你会看到它对那些已经出现过的 Token 的 logit 做惩罚,让模型更愿意换个说法。
6.5 自己训练一个分词器要多久
MiniMind 项目最大的价值之一,就是你可以自己训练一个分词器。它提供了训练脚本,你只需要准备一个文本语料,运行训练代码,就能得到自己的 BPE 词表。这个过程在小型语料上只需几分钟到十几分钟。但你很快会发现,语料太小的话,词表里的 Token 非常碎片化,很多常用词都没合并起来,导致序列很长。我当时拿几 MB 的语料训练了一个 8000 词表的 tokenizer,编码同样的文本,Token 数量比官方预训练的多了将近 40%。这个对比能让你深刻理解:分词质量直接影响模型效率和效果,这也是为什么大厂都在卷语料清洗和分词器调优。
7. 我在这条 Token 学习路上的几个体会
跟着 MiniMind 把 Token 的三世走完一遍之后,我最深的感受是:大模型其实没有“理解”文字,它只是在做概率预测,而 Token 是它观察世界的基本颗粒。一开始我以为分词只是预处理里无关紧要的一步,踩了无数坑才发现,分词方式决定了模型学习的难度、推理的速度、甚至生成质量的上限。MiniMind 的教学设计最大的妙处,就是把这条链路完整暴露在代码里,让你随时能打印中间变量,看到每个 Token 的 ID、概率、采样结果。
有句话想送给正在学大模型的你:不要急着去看各种花哨的模型架构图,先把 Token 这个最基础的概念揉碎了吃透。你可以用一个晚上的时间,把 MiniMind 的 tokenizer 代码从头读一遍,再加载模型跑几轮带不同参数的生成本地实验。当你亲眼看懂一个 ID 是怎么变成概率分布,又是怎么被采样成下一个 ID,再拼回可读文本之后,大模型在你眼里就不再是黑魔法了。
最后分享一个我常用的调试小技巧:想确认模型是否理解了某个输入,不要只看最终输出,可以打印中间层的 embedding 相似度来检查关键 Token 的变化。MiniMind 里加一行 hooks 就有机会做到。这套方法在我后面学习注意力可视化时帮了大忙。如果你也想更进一步,下一站可以试试把注意力权重矩阵导出来画成图,看看“猫喜欢鱼”这种句子里,“猫”到底在关注哪些 Token。那会是你与 Token 真正成为朋友的时刻。