最近在啃 MiniMind,一个把大模型训练和推理逻辑完整保留、但模型规模压到极小的学习项目。起因很简单:我不想只会调 API,想亲眼看看大模型内部到底是怎么干活的。第一次打开源码,第一个让我卡住的概念就是 Token。网上都说“Token 是大模型理解文字的基本单位”,可这个回答太笼统了——单位?为什么同一个 Token 一会儿是字符串,一会儿是整数,一会儿又变成一个高维向量?
我后来想明白一件事:Token 在模型里其实要经历三种完全不同的形态,可以理解成“前世、今生、来世”。这篇笔记就把这趟旅程从头到尾走一遍,从分词、编号、向量化,到 Transformer 里的注意力计算,再聊几个实际运行时最容易踩的坑。适合正在读 MiniMind 这类代码、或者刚开始系统学大模型原理的朋友参考,也欢迎在评论区拿你自己的报错来对比。
1. 先回答一个基础问题:Token 到底是什么
1.1 从一段中文文本说起
先别管那些抽象定义。打开 Python,随便用一个本地分词器把一句话拆开,看看会发生什么。
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("your_minimind_tokenizer_dir") text = "MiniMind 是怎么理解中文的?" tokens = tokenizer.tokenize(text) ids = tokenizer.encode(text) print(tokens) print(ids)我这边实际跑出来的结果大概是这样的:
['Mini', 'Mind', ' 是怎么', '理解', '中文', '的', '?'] [8352, 28466, 7441, 11412, 10392, 878, 10821]注意这个输出不唯一。不同模型的词表是在不同语料上用 BPE 之类的算法训练出来的,同一个句子在 GPT 系、Qwen 系、LLaMA 系的分词器里会被切成不同的片段,ID 也各不相同。但规律是一样的:模型不会一个汉字一个字去读,而是把这段文字切成若干“词元”,每个词元对应一个编号。
这就是 Token 的“前世”:它脱胎于原始字符串,是分词器从连续文本里切出来的一个个小片段。
1.2 模型为什么不能直接“读”文字
这里要回答一个很自然的疑问:神经网络不也是程序吗?为什么不直接把字符编码成 ASCII 或 UTF-8 塞进去?
原因有三层。第一,字符编码只代表字节值,不代表语义。比如“苹果”的 UTF-8 字节和“apple”没有任何数值上的相关性,模型如果直接用字节值做输入,学不出“这两种写法指的是同一个东西”。第二,文本长度不固定。一个句子有 5 个字,另一个有 500 个字,如果按字输入,模型要处理长度可变的序列,而神经网络计算往往需要固定维度,或者至少有一个明确的基本单位。第三,大模型训练和推理的复杂度跟序列长度强相关,如果直接按字节处理,序列会变得非常长,算力开销会爆炸。
Token 恰好解决了这三个问题:它把变长的文本切成粒度适中的片段,把每个片段映射成离散编号,再转成向量。大模型对外表现是“读文字”,本质上是在处理这些编号化之后的 Token。
1.3 三代身份,刚好构成“三世之旅”
按我自己的理解,一个 Token 在 MiniMind 里会经历三种形态:
- 字符串形态:分词器输出的字符片段,比如
['Mini', 'Mind']。 - 整数编号形态:每个 Token 在词表里的 ID,类似
[8352, 28466]。 - 高维向量形态:Embedding 层把 ID 变成稠密向量,再送进 Transformer 层参与注意力计算。
全文的“三世”就是按这个顺序展开的。第一世是“字符串切片”,第二世是“编号映射”,第三世是“向量感知与上下文交融”。等这三世走完,模型就能基于这个 Token 的所有信息去预测下一个 Token 的概率,进而生成完整句子。
2. 第一世:从字符串到 Token——BPE 分词
2.1 三种主流分词思路
分词算法决定了一个句子会被切成多碎。最常见的思路有三种:
- 按词切分:英文可以直接用空格和标点切,中文就得先做分词库,否则“武汉市长江大桥”这种句子会切出鬼故事。而且真实世界的新词、专有名词、乱码文本根本切不完。
- 按字/字节切分:绝对不会出现“未知词”,但序列长度成倍增加。一个短句变成几十个 token,模型要花更多计算量去学习“哪些字常一起出现”。
- 子词切分:界于上面两者之间,高频词整体保留,低频词拆成子词或字符。BPE、WordPiece、SentencePiece 都属于这类。
MiniMind 这类用 HuggingFace 生态的项目,分词器最常用的是字节级 BPE(Byte-Level BPE)。它把文本先转成 UTF-8 字节,然后不断合并出现频率最高的相邻字节对,最后生成一份字节级别的子词表。这样做的好处是:任何文本都能编码,不会遇到“这个词没在词表里”的尴尬。
2.2 BPE 到底怎么合并的
理解 BPE 不需要看复杂的论文,记住一件事就行:反复合并出现次数最多的相邻符号对。
用一个经典例子说明。假设语料是:
low low low low low lowest lowest初始时,每个字符都是一个 token:l,o,w,e,s,t。统计相邻对出现次数,l + o出现了 7 次,最高,于是合并成lo。接着lo + w也出现 7 次,合并成low。再看lowest内部,e + s出现 2 次,合并成es;es + t出现 2 次,合并成est;最后low + est出现 2 次,形成完整词lowest。
这个过程的伪代码大概长这样:
while 词表大小 < 目标大小: pair = 统计所有相邻 token 对的出现频率 best = 出现频率最高的那一对 把 best 合并成一个新 token 语料里所有 best 出现的地方都替换成新 token所以英文里常见的learn、learning、learner可能会被拆成learn加后缀,因为learn作为高频片段会被保留下来。中文里“怎么”“理解”“我们”这类常见词组如果频繁出现,也可能整体保留下。但如果语料里中文不够丰富,大部分双字组合的频率拼不过英文高频片段,中文就常常退化成单字切分。
2.3 实操:在 MiniMind 里看一次分词
我建议你把 MiniMind 仓库里的 tokenizer 单独提出来,做个最基础的自测:
from transformers import AutoTokenizer tk = AutoTokenizer.from_pretrained("./minimind_tokenizer") samples = [ "大模型是怎么理解文字的", "Transformer attention is all you need", "MiniMind 2024", ] for s in samples: print(s) print(tk.tokenize(s)) print(tk.encode(s)) print("token 数量:", len(tk.encode(s))) print("-" * 60)这个脚本能帮你直观看到英文单词是不是整体保留、中文是不是被切碎、数字和空格怎么处理。我实际跑的时候,中文句子被切成七八个 token,英文短句也被切成七八个 token,两者的 token 量差距并没有想象中那么大。原因是英文单词间的空格、标点、常见后缀本身也会占 token,中文每个字虽然单独算,但信息密度高。
2.4 中文经常被拆很碎,这不是 bug
很多新手第一次看到大模型是怎么理解文字的被拆成大模型是怎么理解文字的,会觉得分词器坏了。其实不是。
词表大小是固定的,比如 MiniMind 的词表可能只有 3 万到 5 万。英文里the、is、ing这种高频片段会抢走大量词表名额,中文双字词里只有出现频率极高的组合才可能留下。剩下的中文内容拆成单字,模型的注意力机制照样可以自行学习“哪几个字关系紧密”。拆细一点反而能处理更多未登录组合,只是计算量会涨。
所以我给大家一个建议:不要纠结分词结果“像不像正常人切词”。BPE 的目标不是语言学上正确的分词,而是让模型能以有限词表覆盖无限文本,同时让高频模式获得更稳定的表示。
3. 第二世:从 Token 到整数 ID——词表映射
3.1 词表就是一部“编码字典”
第一世结束后,句子变成了 token 字符串列表,比如['Mini', 'Mind', ...]。可神经网络算不了字符串,所以还得给每个 token 分配一个整数 ID。
这个映射关系存在vocab.json里。文件内容大概长这样:
{ "!": 0, "\"": 1, "#": 2, "$": 3, "Mini": 8352, "Mind": 28466, ... }键是 token 的字符串形式,值是它在词表中的唯一编号。MiniMind 加载这类文件后,tokenizer.encode("MiniMind")就会返回[8352, 28466]。
这一步不需要训练,是分词器训练完就定死的映射。关键点在于:**词表一旦固定,训练和推理必须使用同一份词表。**如果你中途换了词表,Embedding 层的维度就对不上了,模型就等于失忆了。
3.2 特殊 Token 是“系统保留位”
除了正常汉字英文,词表里还会有一批特殊 Token。最常见的有:
[UNK]/<unk>:遇到无法识别的字符时用它兜底。<s>/</s>或[BOS]/[EOS]:标记序列开始和结束。<pad>:把 batch 里长短不一的样本补齐到同一长度。- 对话类模型还有
<|im_start|>/<|im_end|>这样的角色分隔符。
MiniMind 使用对话模板时,<|im_start|>user和<|im_end|>这些 token 都占真实 ID,它们不是辅助信息,而是与普通文本一起参与模型计算的。所以调用tokenizer.encode时要注意add_special_tokens参数。默认True时会自动加 BOS/EOS,如果你在训练时默认加、推理时又手动加了一遍,就会凭空多出两个 token,找半天都发现不了。
3.3 实操:写一个 encode-inspect-decode 自检函数
我之前调试 MiniMind 时写了一个小工具,每次发现模型输出乱掉就先用它验证分词器有没有正常工作:
def inspect_tokenizer(text, tokenizer): enc = tokenizer(text, add_special_tokens=True, return_tensors="pt") ids = enc["input_ids"].tolist()[0] print("原文:", text) print("decode 回来:", tokenizer.decode(ids)) print("token 数:", len(ids)) print("逐 token 查看:") for pos, (tok, tid) in enumerate(zip( tokenizer.convert_ids_to_tokens(ids), ids )): print(f"{pos:>4} | {repr(tok):>15} | {tid}")正常时decode回来的文本应该和原文一致;若不一致,说明特殊 token 处理或字节解码出问题了。逐 token 查看也能帮你确认一个 token 到底对应几个字,很多长度问题就是在这里发现的。
3.4 上下文窗口说的其实是 Token 数
MiniMind 配置里的max_seq_len、block_size,指的是 Token 数,不是字数、不是字符数。很多项目说明写“上下文长度 2048”,意思是模型最多处理 2048 个 Token。
英文里平均一个单词大约 1.2 到 1.5 个 Token,所以 2048 个 Token 大概能装 1500 个英文单词。中文一个汉字经常就是一个 Token,所以 2048 个 Token 大概能装 1800 到 2000 个汉字。如果直接按“字符数”配置,很容易超长。
MiniMind 在数据预处理阶段也会按max_seq_len截断样本。比如你有一批长文本,先用 tokenizer 编码,然后只保留前 1024 个 Token,多余的直接丢掉。做微调时如果发现显存不够,第一件事就是看max_seq_len是不是设太大了,而不是急着换模型。
4. 第三世:从 ID 到高维向量——模型真正“理解”的地方
4.1 Embedding:给每个 Token 发一张“语义身份证”
第二世结束时,每个 Token 是一个整数 ID,但这个整数本身没有任何语义。你说苹果= 100,香蕉= 200,模型不会根据 100 和 200 的数值差猜出它们都是水果。数字大小在这里毫无意义。
于是要进入第三世的第一步:Embedding,也就是查表。PyTorch 里就是一行代码:
import torch import torch.nn as nn vocab_size = 32000 hidden_size = 768 embed = nn.Embedding(vocab_size, hidden_size) ids = torch.tensor([[8352, 28466, 7441]]) vectors = embed(ids) print(vectors.shape) # torch.Size([1, 3, 768])这里每个 Token ID 会被换成 768 维的浮点向量。初期这些向量是随机的,但随着模型训练,Embedding 矩阵会不断调整,让语义接近的 Token 在向量空间里距离更近。“国王”和“王后”、“喜欢”和“热爱”逐渐会落在相近位置。
可以把这个过程想成一本巨大的语义词典:查“MiniMind”,得到一串语义特征;查“Token”,得到另一串。模型后续的所有计算,都在这串特征上进行。
4.2 为什么不用 one-hot 编码
有人会问,直接用 one-hot 向量不是也能区分 Token 吗?用是能用,但非常不划算。
词表如果是 3 万,一个 one-hot 向量就是 3 万维,而且任何两个不同的 one-hot 内积都是 0,意味着 Token 之间完全没有相关性。“猫”和“狗”这两个 ID 在这个空间里是正交的,模型看不到它们之间的任何相似性。而密集的 Embedding 向量不同,它在训练中学会了相似 Token 的向量方向接近,这才是模型能“推理词义”的基础。
所以 Embedding 层不仅是查表,它是一场从离散符号到连续语义空间的投影。这一步做完,Token 才真正从“死编号”变成“活向量”。
4.3 在注意力机制里,Token 开始互相沟通
单个 Token 的向量再漂亮,也只是静态特征。真正让大模型理解一句话的,是 Token 与 Token 之间的交互,这个交互主要由自注意力完成。
我们可以用开会的场景来类比。假设会议室里有 5 个 Token,每个人都先写好三个小纸条:我要去哪找信息(Query)、我能提供什么信息(Key)、我实际想说的内容(Value)。接下来每个 Token 都拿自己的 Query 去跟所有人的 Key 做匹配。匹配度越高,注意力权重越大,最后把所有人的 Value 按这个权重加权求和,作为自己的新向量。
也就是说,模型在处理“它”这个 Token 时,会通过注意力机制去关注前文里的“苹果”,把“苹果”的信息融合到“它”的向量里。这个过程在每个 Transformer 层都会做一次,层次越深,Token 能“看到”的上下文范围越广。
注意力权重计算公式可以写成:
Attention(Q, K, V) = softmax(Q K^T / sqrt(d_k)) V其中d_k是 Key 向量的维度,除以sqrt(d_k)是为了防止点积过大把 softmax 梯度压没。对新手来说,记住前面那张“开会互相对答案”的图就够了,背后的矩阵乘法只是把这个过程规模化。
4.4 MiniMind 里 Token 张量形状的变化
我用的 MiniMind 版本默认配置大概是:n_layer=8、n_head=8、n_embd=512、block_size=512。不同分支配置有差异,但数据流转逻辑是一样的。
输入一批文本后,张量形状是这么变的:
输入 ids:[batch, seq_len] → Embedding:[batch, seq_len, hidden_size] → 8 层 TransformerBlock:[batch, seq_len, hidden_size] → LM Head:[batch, seq_len, vocab_size] → softmax:每个位置下一个 Token 的概率分布seq_len就是这一批样本里最长的 Token 数,所有样本会被 pad 到等长。hidden_size=512表示每个 Token 最终用 512 维向量表示。vocab_size是词表大小,LM Head 的输出维度必须和词表一致,因为我们要给每个候选 Token 打分,分数最高或按概率采样到的 ID,就是模型生成的下一 Token。
这也解释了一个现象:为什么生成时是一个 Token 一个 Token 往外蹦。因为模型每前向一次,只能根据当前上下文预测下一位。预测出来的 ID 再拼到输入末尾,再前向一次,才能预测再下一位。看起来模型在输出文字,其实它是把这些 Token 一个个“续写”出来的。
4.5 Token 的解码返场
第三世走完后,Token 还要变回能读的文字。这一步发生在 LM Head 输出之后:
next_token_id = int(logits[0, -1, :].argmax()) next_token_text = tokenizer.decode([next_token_id])decode并不只是查表,它需要把子词 token 拼接起来,还要处理字节级分词器留下的中间状态。比如某个 token 是半个汉字,另一个 token 是后半截,decode会先把它们拼成完整 UTF-8 字节,再转成字符。如果直接拼接字符串,往往会出现乱码。
所以“模型理解了文字”这个说法的真相是:文字先被切碎成 Token,再变成 ID,再变成向量,向量经过一系列注意力交互后,最终映射回另一个 Token 的概率分布,再解码回字符串。整个过程都是可计算的统计过程,不存在什么玄学理解。
5. 常见问题与排查实录
5.1 中文被拆得很碎,影响模型效果吗
我在 MiniMind 上用中文数据微调时,最直观的感受是:中文 Token 更碎,序列更长,同一条数据比英文多用大约 20% 到 50% 的 Token。这不一定影响语义理解,因为注意力机制可以让单字 Token 自由组合出词义,但会拖慢训练速度、增加显存占用。
如果你是在别的模型基础上做中文增强,不要轻易换词表。换词表后,原模型的 Embedding 权重全部失效,等于从零开始学中文,很可能越调越差。更稳妥的做法是把新语料在现有分词器上跑一遍,缺失的高频中文组合用额外 Embedding 增量训练做扩展,这属于进阶玩法,初学者慎碰。
5.2 出现 [UNK] 或乱码,先查分词器
[UNK]出现的原因通常是三个:词表里没有对应字符、加载的分词器文件不完整、特殊 Token 映射缺失。常见场景是拿一个只支持英文的 tokenizer 去怼中文,结果中文全变成[UNK]。解决方案是先跑我前面写的inspect_tokenizer函数,确认decode能不能还原原文。
至于解码乱码,问题往往出在字节级 BPE 上。字节级词表存的是 UTF-8 字节片段,直接用字符串拼接会拆坏多字节字符。正确做法是始终用官方提供的tokenizer.decode方法,不要自己拼接 token 文本。
5.3 序列超过 max_seq_len 怎么办
训练时如果样本太长,模型会报维度错误,或者悄无声息地截断。MiniMind 的数据加载里通常有max_seq_len参数,超长样本会被截尾。但这会带来一个副作用:样本的答案部分被切掉了,模型学了个寂寞。
我的习惯是在构造样本时就分层处理。普通段落超过限制就做滑动窗口切块,每块留一点重叠;如果是对话样本,保证最后一条回复完整,宁可把历史对话往前截,也不截掉当前回复。代码里可以这样:
def truncate_to_fit(input_ids, max_len, keep_last_ratio=0.6): if len(input_ids) <= max_len: return input_ids keep_last = int(max_len * keep_last_ratio) keep_first = max_len - keep_last return input_ids[:keep_first] + input_ids[-keep_last:]这个函数不是标准做法,是我自己实验下来效果比较稳的折中方案。核心思想是:开头上下文可以牺牲一点,结尾的答案尽量完整。
5.4 “Token 失效”“403”跟模型里的 Token 没关系
搜索 Token 相关问题时,会撞到大量报错:
token expired token exchange failed token endpoint returned status 403这些报错发生在登录第三方服务、调用 API、刷新授权时,说的是“访问令牌(Access Token)”失效了。它跟大模型里的 Token 完全是同名异物。
访问令牌是一种身份凭证,常见实现是 JWT。它的exp字段记录过期时间,过期后继续使用就会返回 401 或 403。排查思路很直接:检查系统时间是否准确、JWT 的密钥和签名算法是否一致、有没有用 refresh token 换新;如果是 OAuth 流程里 token endpoint 报 403,优先检查 client_id、client_secret、回调地址这些配置。而大模型里的 Token 报错,则应该看分词器加载、词表路径、序列长度。两者别混在一起查。
我见过不少人把“tokenizer 加载失败”和“登录 token 失效”当成一个问题搜半天,最后发现根本没有关联。这个坑值得专门提醒。
5.5 实用排查速查表
| 现象 | 属于哪类 Token | 常见原因 | 排查方向 |
|---|---|---|---|
打印出来全是[UNK] | 模型词元 | 分词器与文本语言不匹配 | 使用支持中文的词表 |
| decode 乱码 | 模型词元 | 字节级 BPE 拼接错误 | 必须用官方 decode 方法 |
| 显存爆掉 | 模型词元 | max_seq_len太大 | 缩短序列长度 |
| 训练效果差 | 模型词元 | 截断截掉了答案 | 保留尾部关键内容 |
| API 返回 token expired | 访问令牌 | JWT 过期 | 刷新凭证 |
| 登录时 403 | 访问令牌 | 回调地址或客户端配置错误 | 检查 OAuth 配置 |
6. 一点个人实操心得
学 MiniMind 最值的一步,是把 tokenizer 单独拆出来玩透。我强烈建议你找个半天时间,把仓库里的 tokenizer 加载起来,用它编码几段中文、英文、甚至带上特殊符号的文本,逐行打印 token 和 ID,再 decode 回去看看会不会还原。这个过程看起来简单,但能帮你把“Token 三世”串起来,之后再去看 Transformer 代码,心里就有底了。
我个人的一个小技巧:在理解注意力之前,先拿一个很短很短的输入,比如"我喜欢 MiniMind",通过在 MiniMind 的 forward 里临时加几个调试打印,观察它的张量是怎么从[1, 6]变成[1, 6, 512],又怎么变成[1, 6, vocab_size]的。每看一次,你都会对“模型如何处理文字”多一层体感。
如果把这一圈走完,再回头看开头的问题“大模型怎么理解和处理文字”,我的回答是:它先把文字切成 Token,把 Token 变成可计算的 ID 和向量,再靠注意力机制让这些向量在上下文里互相融合,最后从概率分布里采样下一个 Token。整个过程没有任何玄学,只是逐步把文字这个符号系统,翻译成了矩阵和浮点数的数学世界。