前阵子有朋友问我:大模型是不是真的"看懂"文字了?我给的答案是:它其实只看懂了一种叫Token的东西。我最近在啃MiniMind这个开源极简大模型项目,目的就是想彻底搞明白一件事——一段普通的中文,经过大模型的前向传播之后,为什么会变成一段有逻辑的回答。学完一遍之后发现,整条链路其实是一条很清晰的流水线:文字先被切成Token,Token再变成ID,ID再变成向量,向量在几十层网络里被打磨成交织着上下文的语义,最后又变回文字。
这篇文章是MiniMind学习笔记的第02篇。我会从Token的视角,把这个过程完整拆开,顺便回答几个新手几乎必踩的困惑:为什么同一个中文句子在英文模型里消耗的Token数量不一样?为什么模型能理解"苹果好吃"和"苹果手机"里的同一个"苹果"?以及我们调用各种大模型API时偶尔看到的"Token失效"报错,和这里的Token到底是不是同一个东西。搞懂这些,大模型怎么理解和处理文字对你来说就不再是黑盒了。
1. 先别急着跑代码:把Token这个词彻底掰清楚
在动手看MiniMind源码之前,我建议你先花十分钟把"Token"这个概念彻底理清。很多人在配置API、写提示词、甚至看模型卡片的时候,都被这个词折磨过,但这里面的误区比想象中多。
1.1 模型不认识"你好",它只认识ID
人类读文字,看到的是一个连续的语义整体。但神经网络本质上是做数学运算的,它没办法直接"读"字符,只能吃下一堆浮点数。所以在大模型的世界里,任何文字进入网络之前,必须先被转换成一个离散的数字序列。
执行这个转换的组件叫分词器,也就是Tokenizer。它会按照一套规则,把连续文本切成一个个小块,每个小块就是一个Token。Token可以是一个完整的英文单词,也可以是单词的一半,还可以是一个汉字、一个标点,甚至一个字节。切完之后,分词器再给每个Token分配一个整数ID,于是"你好"就变成了类似[872, 997]这样一串数字。
MiniMind这类项目最让我喜欢的一点,就是它把整条流水线摊开给你看。你在它的训练和推理代码里,能清清楚楚地看到text -> token -> ID -> embedding -> transformer -> output -> text这条路径里的每一步。所谓"Token的三世之旅",其实就是把这条路径拆成三个大阶段:第一世是从文本到离散Token,第二世是从ID到稠密向量,第三世是向量在Transformer内部被反复重塑成真正的语义。
1.2 文本Token和API Token是两回事
这里必须先泼一盆冷水:大模型圈子里"Token"这个词,至少有两个完全不同的含义。
第一个含义就是我们这篇文章的主角——文本切分单元,也就是模型消化文字的最小颗粒度。你看到"这段提示词消耗了300个Token",指的就是这个。
第二个含义是API鉴权凭证,比如JWT里的access_token、各种接口返回的access token。你对接模型API时遇到"Token失效""token expired""token exchange failed"这类报错,绝大多数时候和文本切分没有半点关系,而是OAuth/JWT那套身份认证体系出了问题。很多初学者排查问题时,一看到"token"就去翻分词器、查词表,绕了一大圈才发现是权限过期了。
把这两个概念分清,能帮你省下大量调试时间。我自己的习惯是:看到报错里带token,先判断它是出现在调用接口的鉴权环节,还是出现在模型处理文本的环节。前者大概率是证书、权限、刷新策略的问题,后者才是词表和切分规则的问题。这两个领域虽然共用同一个英文单词,但技术栈完全不同。
1.3 为什么不能简简单单按词切
理解了Token的概念,你可能会问:为什么不用最简单的方案,按空格把英文句子切成一个个单词喂给模型?
这个问题背后藏着分词器的核心设计逻辑。按词切分看起来合理,但工程上会遇到几个麻烦:
- 词表爆炸。英文光常用词就几万个,加上各种时态变化、派生词、专业术语、人名地名,词表轻松几十万甚至上百万。你不可能把所有词都装进一张固定词表,而且新词每天都在产生。
- 未登录词问题。模型训练时没见过的新词、拼写错误、emoji、代码片段,直接无法表示,只能被迫扔进一个"未知"桶里,信息全部丢失。
- 序列太长。如果退回到按单字切,虽然不会遇到未登录词,但序列长度会暴涨。Transformer自注意力的复杂度是O(n²),序列越长,训练成本涨得越离谱。
所以业界主流选择了折中方案:子词切分。常见单词尽量保留成一个Token,罕见词拆成若干高频子词片段,再不行就拆到单字符甚至单字节。我习惯用一个物流比喻来解释:你要运输一批形状各异的货物,整箱运输太大,拆成散沙又太碎,而BPE这类算法就是把它们切成大小合适的标准物流单元。
2. 第一世:从文字碎片到Token ID,分词器究竟在做什么
Token的"第一世",是原始文本被分词器切碎并编号的过程。这一节我们重点看BPE(Byte Pair Encoding)这个最经典、也被用得最多的算法,然后落到MiniMind实际使用的分词器上。
2.1 BPE的合并逻辑并不复杂
BPE听起来像是某种高深数学,但核心逻辑非常简单:从单个字符出发,不断统计相邻单元对的出现频率,把最高频的那一对合并成一个新单元,反复迭代,直到词表达到我们想要的规模。
我拆一个具体例子。假设语料里出现了很多次"learning"、"learned"、"learner"。算法初始把所有单词拆成单个字符,比如"l""e""a""r""n""i""n""g"。第一轮统计时,它发现"l""e"经常紧挨着出现,可能先合并出"le";随着迭代,合并出来的"le"又和"a"频繁相邻,于是变成"lea";最后"learn"会作为一个完整的Token被收进词表。接下来算法发现"learn"后面经常跟着"ing",于是"learning"也可能成为一个独立Token。
这个过程的本质是:让高频出现的组合不断往"更大的块"合并。最终效果非常实用——常见词以一个整体存在,不常见的长单词则被拆成模型见过的高频片段。模型不需要为每一个新词单独学习,它能通过组合已知片段来理解没见过的新词。
2.2 英文和中文的Token切法差异很大
BPE算法本身没有语言偏好,但英文和中文在输入阶段存在一个天然差异:英文有空格,中文没有。
英文通常先按空格把句子切成单词,然后在单词内部做子词切分。比如"unbelievable"可能被切成"un"+"believable",也可能被切成"unbe"+"lievable",具体取决于训练语料的统计结果。切分结果不一定符合语言学直觉,但它统计上高效。
中文没有空格,切分逻辑就不同了。常见做法是把汉字作为原子单位,直接在汉字序列上跑BPE,让词表自己学习出"人工""智能""模型"这类高频组合。也会有一些项目用字节级BPE,把汉字按UTF-8字节拆开再做合并,这样对生僻字和特殊符号更鲁棒。
这就是为什么你会看到:同样是100个汉字和100个英文单词,消耗的Token数量差异巨大。中文一般接近一字一Token,而英文一个常见单词可能只占一个Token,但长单词会拆成两三个。理解这个差异,对估算上下文窗口和计费非常关键。
2.3 MiniMind用到的分词器与词表
MiniMind作为教学性质的项目,通常直接复用业界成熟的预训练分词器,走的是transformers标准流程:加载一个已训练好的分词器,然后对文本调encode得到Token ID序列,调decode还原成文字。词表大小可能落在几万到十几万这个区间,里面除了正常字词,必定会包含一批特殊Token,比如[CLS]、[SEP]、[PAD]、[UNK],生成式模型还会有 这类结束符。
下面是一段非常简化的示意代码,目的只是让你看到这个阶段在做什么。实际项目中分词器的具体路径和加载方式,以你克隆的MiniMind仓库最新源码为准。
from transformers import AutoTokenizer # 这里填入你的MiniMind实际加载的tokenizer路径 tokenizer = AutoTokenizer.from_pretrained("miniMind/tokenizer配置") text = "大模型通过Token理解文字" ids = tokenizer.encode(text, add_special_tokens=True) tokens = tokenizer.convert_ids_to_tokens(ids) print(ids) print(tokens)输出会是一串整数和对应的Token碎片列表。到这里,Token的"第一世"就结束了:一段自然语言变成了一串有固定顺序的数字ID,模型从此有了能下手的输入。
3. 第二世:从ID到向量,Embedding如何让模型第一次"看见"文字
Token拿到ID之后,就要进入模型的第一层:Embedding。这是Token从"离散编号"变成"稠密数字向量"的关键一跃,也是模型第一次真正拥有"可计算的文字表示"。
3.1 ID是门牌号,Embedding是住户画像
标准Transformer里,Embedding层本质上就是一张可训练的大矩阵,维度是[词表大小, hidden_dim]。词表大小决定它有多少行,hidden_dim决定每行多长。模型拿到Token ID后,做的操作极其朴实:查表。把ID当成行号,取出对应那一行的向量,拼起来得到一个[seq_len, hidden_dim]的张量。
我常用小区来类比:每个Token ID是一个门牌号,Embedding矩阵是整个小区的住户档案。门牌号本身只是编号,没有含义,但查表后拿到的向量,相当于这名住户的画像——训练越好,画像越能反映这个词的语义特征。
hidden_dim通常取512、768、1024或者更大。这个数字决定了一个Token用多少个浮点数描述自己。MiniMind这种规模的项目,hidden_dim一般不会特别大,但整个机制和大模型完全一致。
3.2 为什么是查表而不是one-hot
有人会问:Token ID本身就是数字,为什么不直接用它?或者干脆转成one-hot向量喂给模型?
先说直接用ID的问题。如果把"猫"编码成ID=100,"狗"编码成ID=101,那模型会以为这两个词只差1,试图学习它们之间的"数值距离",这毫无语义依据。ID只是编号,数字大小本身没有意义。
再看one-hot的问题。假设词表有10万个词,一个token的one-hot就是一个10万维、只有一位是1的向量。维度太高,计算量大,更麻烦的是向量两两正交,表达不了"猫和狗是相近的动物"这种相似性。每个词都像一座孤岛,模型学不到任何泛化关系。
Embedding的本质,是把高维稀疏的one-hot压缩成低维稠密向量,并在训练中不断调整向量数值,让语义相近的词在向量空间里靠得更近。经典案例king - man + woman约等于queen,虽然不能当精确数学用,但它确实反映了Embedding能编码语义方向。训练完成后的Embedding矩阵,就是模型词汇语义的"第一层地图"。
3.3 位置编码:让Token知道自己站在第几位
Embedding给每个Token赋予了语义,但还有一个严重问题没解决:Transformer的自注意力机制本身是同时看整个序列的,它天生不在乎谁先谁后。把"猫追老鼠"和"老鼠追猫"两句话的Token集合分别喂进去,如果不加位置信息,模型看到的两个序列没有任何区别。
所以Transformer必须给Token补充"位置感"。早期做法主要有两种:可学习位置编码,为每个绝对位置分配一个可训练的向量,直接加到Embedding上;以及固定三角函数位置编码,用正弦余弦函数生成位置向量。这两种方案在BERT和GPT-2时代都很常见。
MiniMind这类沿Llama3风格设计的模型,普遍偏爱RoPE旋转位置编码。RoPE的思路很有趣:不在Embedding上加位置向量,而是把位置信息变成旋转角度,注入到后续注意力的Query和Key向量里。两个Token做注意力计算时,它们的角度差会直接影响点积结果,模型就能天然感知相对距离。形象点说,RoPE相当于给每个Token戴了一块手表,互相交流前先对一下表,再决定给予多少信任。到这里,Token才真正变成一个"带顺序、带语义"的向量,可以送入Transformer主体了。
4. 第三世:Transformer内部的重塑,Token怎么从"词"变成"语义"
Token的"第三世"是整个旅程的高潮。从Embedding出来的向量,会在几十层Transformer块里被反复开会、表决、加工,最终从一个孤立的词向量,变成带着整个上下文烙印的语义向量。大模型"理解文字"这件事,本质发生在这里。
4.1 Self-Attention:Token之间的一场集体讨论
第一个关键组件是自注意力。每个Token进入注意力层后,会同时生成三份向量:Query、Key、Value。我用开会来做类比:
Query是你在会上提的问题:"我该听谁的?"Key是其他人举起的牌子:"我这边有这方面的信息。"Value是牌子上真正想分享的内容。每个Token拿自己的Query去和所有Token的Key做点积,得到注意力分数,softmax归一化成权重后,再去加权所有Value。输出向量等于"我从全场收集到的信息加权汇总"。
做完这一步,每个Token都不再是它自己了。它把自己关注到的上下文信息混入了向量里。最经典的例子是指代消解:在"那只猫吃了鱼,它很开心"这句话里,第二个"它"的Query会强烈匹配到"猫"的Key,于是"它"的向量里就携带了"猫"的信息。模型对"它指代谁"的判断,就是这么通过向量混合完成的。
多头注意力只是把上面的流程并行做多组,每组有独立的QKV权重。有的头可能专职关心相邻词,有的头喜欢长距离关系,有的头擅长识别语法结构。多个角度同时工作,Token才能在不同维度上吸收上下文。
4.2 FFN、残差连接与归一化:开完会后的独立消化
注意力层负责Token之间的信息交换,但一个标准的Transformer块还有下一个关键部分:前馈网络,也就是FFN。FFN对每个Token的位置单独做两次全连接变换,中间用一个非线性激活函数(Llama3里常见SwiGLU)做转折。
说直白点,注意力层是"开集体会议",FFN是"回到工位独立思考"。开完会,每个Token手里都握着一堆别人的观点,但它还得结合自己脑子里的知识储备,决定最终怎么理解自己、怎么更新自己。FFN中存储了模型相当大比例的参数,很多研究认为它像一个分布式的知识检索器,负责在语义空间中把Token推向更合适的位置。
为了让几十层网络稳定训练,每个子层后面还会接残差连接和LayerNorm/RMSNorm。残差连接的思想是"保留原始信息,只叠加本次学到的增量",打个比方,你可以在原句旁边不断写批注,但绝不删掉原句;归一化则负责把向量数值拉回稳定范围,防止深层累加后数值爆炸或消失。
4.3 多层叠加后:低层看词,高层看话
单层Transformer只能做一次"开会+思考",但模型里通常堆了十几到几十层。Token向量每过一层,就被重塑一次。大量探针实验表明,这种逐层处理有非常明显的层次规律:
底层向量还保留很浓的"词汇本身"色彩。此时"苹果"在"苹果好吃"和"苹果手机"两句话里的向量差别还不太大。中层开始捕捉句法组合和局部语义,"苹果"与"好吃"形成关联。高层向量越来越受整个上下文影响,模型已经能根据"手机"这个共现词,把"苹果"往品牌方向推,也能区分"店里的苹果卖得怎么样"这种模糊表达。
这就是为什么大模型能处理一词多义、长距离指代,靠的不是词典规则,而是向量在每一层被上下文逐步修正。所谓"理解文字",拆开来看就是:每个Token的向量,从静态词义出发,经过一层层上下文调制,最终变成"这个词在当前语境里应该有的含义"。
5. 转世归来:概率采样如何把Token吐回成文字
Token经历了第三世的语义重生之后,还要再走最后一步:变成输出文本。自回归模型的工作方式非常像文字接龙——每次只看已有的前文,预测下一个Token是什么。这一步做完,Token的整个生命周期才算闭环。
5.1 语言模型头:向量如何变成下一个词的概率
经过几十层Transformer之后,输入序列里的每个Token都拿到了一个富含上下文信息的向量。自回归模型在预测下一个Token时,关注的是序列最后一个位置的向量,因为它浓缩了全部已有上文。
这个向量会再过一个层,通常叫语言模型头(LM Head)。它做的事是一次线性映射,把hidden_dim维向量映射到vocab_size维,得到一组叫logits的原始分数。随后softmax把这些分数变成概率分布。这个分布里每个维度对应词表里的一个Token,数值越高,表示模型认为它是下一个Token的可能性越大。
注意,模型在这一步只会产生一个分布,不会直接"想好"整句话。它必须抽出一个具体的Token,拼回序列尾部,再重新跑一遍前向,预测下一个。所以生成阶段是反复执行"前向计算 + 采样 + 拼接"的过程。
5.2 采样策略:只选概率最大的Token会出问题
你可能会想:既然模型给出了概率分布,那每次都选概率最高的Token不就行了?这种贪婪策略确实最稳定,但实际生成效果很糟——文字会迅速变得重复、机械、像复读机,稍长长一点就陷入死循环。
所以实际推理时会引入随机采样,核心参数有三个:
- temperature(温度):先把logits除以一个温度系数再做softmax。温度低时分布更尖锐,输出保守;温度高时分布更平坦,输出更有创造性但也更容易跑偏。我一般调起来把它当成"胆量旋钮"。
- top-k:只从概率最高的k个Token里采样,把那些极端低概率的怪词直接关在门外。
- top-p(核采样):按概率从高到低累加,直到累计概率超过p,就在这个最小区间内采样。它比top-k更自适应,候选集合大小随分布的集中程度变化。
实践中我习惯组合使用temperature加top-p,必要时叠加top-k。把这三者理解成"选词时的胆量、候选范围和禁入名单",比背公式好记得多。
5.3 接龙游戏的代价:长文本为什么会越写越偏
自回归逐Token生成的本质,意味着生成质量和前文的每一个选择都有关。如果某一步采样抽到了一个不太合适的Token,这个错误会作为下一轮的输入继续传递,误差逐渐累积。这就是长对话和长文生成后期容易出现逻辑崩坏、胡言乱语的根本原因。
工程上为此设计了各种缓兵之计:重复惩罚、beam search、更长的上下文、以及各种采样参数调优。但最重要的停止机制是特殊Token:模型在训练时会学会在合适的地方输出 结束符。推理时一旦采到 ,生成就终止。所以你会发现, 这类看似不起眼的特殊Token,其实承担着"句子该收尾了"的关键职责。
6. 实操演示:让一句话走完MiniMind的Token全生命周期
前面全是原理,这一节我们动手。我以MiniMind项目为例,演示一个真实句子如何从文本出发,走完Token化、嵌入、前向、解码的完整流程。下面代码是简化演示逻辑,具体文件名和类名请以你克隆的仓库源码为准。
6.1 准备环境
在本地跑通这套流程,需要Python和PyTorch环境,并安装transformers。然后克隆MiniMind仓库,下载对应权重。MiniMind的代码结构很清爽,训练和推理入口都在根目录附近,适合逐文件阅读。
git clone https://github.com/jingyaogong/minimind.git cd minimind pip install -r requirements.txt如果你的网络环境不方便直接拉取,也可以把仓库下载为压缩包再解压。总之先把项目跑起来,后面每一步你都能亲手验证。
6.2 一句话的Token之旅
我们选一句中文:"大模型通过Token理解文字"。先在MiniMind配置好的分词器上走encode流程。
from transformers import AutoTokenizer # 改成你的MiniMind实际使用的tokenizer路径或名称 tokenizer = AutoTokenizer.from_pretrained("你的MiniMind分词器配置") text = "大模型通过Token理解文字" ids = tokenizer.encode(text, add_special_tokens=True) tokens = tokenizer.convert_ids_to_tokens(ids) print("Token IDs:", ids) print("Tokens:", tokens)假设你用的分词器是BERT风格,输出可能长这样:
Token IDs: [101, 17978, 6803, 6162, 6206, 6850, 1437, 2255, 3834, 704, 102] Tokens: ['[CLS]', '大', '模', '型', '通', '过', 'To', '##ken', '理', '解', '[SEP]']注意看"Token"这个词被切成了"To"和"##ken"两段,这就是BPE子词切分的真实效果。它优先保证高频片段复用,不照顾英文单词的完整边界。你把MiniMind的tokenizer换成BPE/BPE系的看一下,很可能发现"MiniMind"本身也会被切成"Mini"+"Mind",甚至切得更碎。
接着把ID喂给模型,观察各阶段张量形状:
import torch input_ids = torch.tensor([ids]) with torch.no_grad(): # 取出Embedding层输出 embed_out = model.get_input_embeddings()(input_ids) print("Embedding输出:", embed_out.shape) # [1, seq_len, hidden_dim] # 模型前向,得到logits logits = model(input_ids).logits print("LM Head输出:", logits.shape) # [1, seq_len, vocab_size] # 取最后一个位置,看模型预测的下一个Token next_id = logits[0, -1].argmax(dim=-1).item() print("模型预测的下一个Token:", tokenizer.decode([next_id]))到这里,一句话从文本到ID,再从ID到向量,最后到概率分布并decode回文字,闭环完成。
6.3 一张表看懂Token的状态变化
我把整个过程汇总成一张表格,方便你对照理解:
| 阶段 | 数据形式 | 形状/维度 | 说明 |
|---|---|---|---|
| 原始文本 | str | - | 人类可读内容 |
| 分词后 | List[Token] | seq_len | 词、子词、标点等碎片 |
| 编码后 | List[int] | seq_len | 每个Token对应的ID |
| 嵌入后 | Tensor | [1, seq_len, hidden_dim] | 带语义和位置的向量 |
| 多层Transformer后 | Tensor | [1, seq_len, hidden_dim] | 携带上下文的语义向量 |
| 输出概率 | Tensor | [1, seq_len, vocab_size] | 每个位置的下一Token概率分布 |
| 解码后 | str | - | 回到人类可读文本 |
看懂这张表,大模型"怎么理解和处理文字"的流程,就已经在你脑子里成形了。
6.4 三个和Token强相关的实战坑
最后分享三个我在实际操作中踩过、也看别人反复踩的坑。
第一个坑是拿字符数估算Token数。同样是100个汉字和100个英文单词,消耗的Token数量可能完全不同。中文常见接近一字一Token,英文常见单词可能一个词一个Token,长词会被拆开。如果你在配置MiniMind的上下文长度,或者对接任何按Token计费的服务,请直接用tokenizer的encode去数,千万别用字符数估算。
第二个坑是批量处理时忘记处理padding和attention mask。模型训练和批量推理时,一个batch内的句子长度往往不齐,需要pad到同一个长度。但Pad Token只是占位符,没有任何语义,必须通过attention mask告诉模型"这些位置是空气,不要注意"。漏了mask,轻则模型效果莫名其妙下降,重则训练loss直接异常。
第三个坑是训练和推理的tokenizer版本不一致。训练时用的词表和分词算法,推理时必须完全一致。只要换了词表,同一个文本得到的ID序列就会完全变掉,模型只认ID不认字,立刻输出乱码或者语义漂移。MiniMind不同版本如果切换过tokenizer,你在加载权重时一定要确认配套关系。顺便说一句,不要去手工增删special token,预训练模型对[CLS]、[SEP]、[PAD]这类Token的位置非常敏感,随便改等于给模型换了一套暗号。
最后说点自己的感受。刚开始学大模型的时候,我总盯着层数、参数量、训练算力这些数字看,越看越觉得玄乎。真到MiniMind里把一个中文句子的Token全过程打出来,才发现模型理解文字这件事没有魔法,每一步都是确定性的数学操作。搞透Token这一环之后,再去看上下文窗口、KV Cache、流式输出这些工程细节,你的恐惧感会少一大半,因为它们的度量单位都是Token。下一篇笔记我打算继续往后走,拆一拆MiniMind里注意力层的前向传播到底是怎么逐行算的,到时候你会发现,熟悉了Token之后,那部分也没有多可怕。