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表示未知 Tokens和/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_tokens200, temperature0.7, top_p0.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 真正成为朋友的时刻。