14 KiB
第4节:Tokenizer 与 Special Tokens
我的问题笔记 | 聚焦真正不懂的地方
📚 本节目录
- Q1: truncation=True 是什么意思?
- Q2: tokenizer 怎么把文字转成 token ids?
- Q3: 词表里 token id 是怎么分配的?
- Q4: special tokens 是什么?
- Q5:
<|im_start|>这些标记怎么工作的? - Q6: chat_template 是什么?
- Q7:
tokenizer.apply_chat_template做了什么? - Q8: 什么是预训练 tokenizer?
- Q9:
from pretrained()做了什么? - Q10: tokenizer 的训练语料从哪来?
- 训练流程
- Encode / Decode
- 一句话总结
Q1: truncation=True 是什么意思?
代码 eval_llm.py:78
inputs = tokenizer(inputs, return_tensors="pt", truncation=True).to(args.device)
如果输入文本太长(超过 max_position_embeddings),截断多余部分。
举例:
text = "很长很长的文本..." * 10000 # 假设有5万个token
# 报错:超出模型上限
inputs = tokenizer(text)
# truncation=True:自动截断到模型能处理的长度
inputs = tokenizer(text, truncation=True)
Q2: attention_mask 里的"有效输入"是什么?
代码 eval_llm.py:83
attention_mask=inputs["attention_mask"]
GPU batch 处理要求固定 shape,会 padding。attention_mask 用 1=有效,0=padding 标记哪些是真 token。
举例:
# batch里2个句子,长度不同
sentence_a = [1, 2, 3] # 长度3
sentence_b = [4, 5, 6, 7, 8] # 长度5
# padding后
token_ids = [[1, 2, 3, 0, 0], [4, 5, 6, 7, 8]]
# attention_mask 标记
attention_mask = [[1, 1, 1, 0, 0], [1, 1, 1, 1, 1]]
# ← 前3有效,后2是padding → ← 全部有效
Attention 计算时,0的位置会被忽略。
Q3: generate 为什么叫"自回归"?
代码 eval_llm.py:82-87
generated_ids = model.generate(inputs=inputs["input_ids"], ...)
自回归 = 每一步的输出会成为下一步的输入。自己生成的东西又喂回去。
生成过程:
用户: "你好"
Step1 → 输入"你好" → 输出 ","
Step2 → 输入"你好," → 输出 "我"
Step3 → 输入"你好,我" → 输出 "是"
Step4 → 输入"你好,我是"→ 输出 "AI"
...一直重复直到 EOS 或 max_tokens
对比:非自回归是一次性输出所有 token,像"看一眼全说完"。
Q4: repeat 是在干嘛?
代码 model/model_minimind.py
input_ids = kwargs.pop("input_ids", inputs).repeat(num_return_sequences, 1)
attention_mask = attention_mask.repeat(num_return_sequences, 1) if ...
复制 batch 维度,一次生成多个不同回复。
举例:
input_ids = [[1, 2, 3]] # 1个样本
input_ids.repeat(3, 1)
# → [[1, 2, 3], # 样本1
# [1, 2, 3], # 样本2
# [1, 2, 3]] # 样本3
# 一次forward同时生成3个答案,比调3次generate省计算
Q5: past_key_values 是什么?
代码 model/model_minimind.py
past_key_values = kwargs.pop("past_key_values", None)
这就是 KV Cache。把之前算好的 K/V 存起来,下一步直接复用,不用重新算所有历史的 attention。
为什么需要:省计算。Attention O(n²) 成本高,缓存历史 K/V 可以避免重复计算。
举例:
生成第1个token后,past_key_values = [(k0,v0), (k1,v1), (k2,v2)] # 每层
生成第2个token时:
- 只算新token的K/V
- 历史K/V从缓存取,不用重算
Q6: ByteLevel 预切分是什么?
UTF-8 里任何字符都转成字节。不存在"这个字无法处理"的情况。
| 字符 | UTF-8 字节数 |
|---|---|
h |
1 byte |
你 |
3 bytes (\xe4\xbd\xa0) |
🎉 |
4 bytes |
举例:
"hello你"
→ [h, e, l, l, o, Ġ, 你] # 实际底层是字节
混合语言不怕,最终都是字节。
Q7: BPE 需要什么?不然切什么合什么?
ByteLevel 先把语料切成字节,然后 BPE 统计语料中哪些字节对最常出现,频繁出现的就合并成新 token。
训练过程举例:
语料中大量 "banana"
Step1: ByteLevel → [b, a, n, a, n, a]
Step2: 统计频率
"an" 出现4次 ← 最常见,先合
"na" 出现2次
"ba" 出现1次
Step3: 迭代合并
第1次: [b, a, n, a, n, a] → [b, an, an]
第2次: [b, an, an] → [ban, an]
最终 "banana" = 2个token [ban, an],而不是6个字节
好处:常见词合并成1个token(序列短),生僻字拆成字节(不会OOV)。
Q8: from_pretrained 和 tokenizer_config.json 什么关系?
调用 eval_llm.py:13
tokenizer = AutoTokenizer.from_pretrained(args.load_from)
配置 minimind-3/tokenizer_config.json:330
{
"tokenizer_class": "PreTrainedTokenizerFast"
}
关系:
AutoTokenizer.from_pretrained("./minimind-3")
↓ 读取 tokenizer_config.json
↓ 发现 "tokenizer_class": "PreTrainedTokenizerFast"
↓ 自动用这个类加载
tokenizer_config.json = 配方卡,from_pretrained = 自动点餐。
Q9: PreTrainedTokenizerFast 是什么?minimind 实现了吗?
PreTrainedTokenizerFast 是 HuggingFace transformers 库提供的类(Rust 编写,速度快)。
minimind 没有自己实现,只是定义了词表和配置,兼容这个接口。
| 类比 | 说明 |
|---|---|
| 公共汽车 | PreTrainedTokenizerFast(HuggingFace 提供) |
| 乘客名单 | tokenizer.json(minimind 定义) |
| 车辆配置 | tokenizer_config.json(minimind 定义) |
Q10: minimind 的 tokenizer 训练代码逻辑是什么?
文件 trainer/train_tokenizer.py
minimind 用 tokenizers 库 + BPE 算法训练 tokenizer,训练数据是 dataset/sft_t2t_mini.jsonl(取前1万行)。
第1步:准备语料
def get_texts(data_path):
for i, line in enumerate(f):
if i >= 10000: break # 取1万行
data = json.loads(line)
contents = [item.get('content') for item in ...]
yield "\n".join(contents)
从 jsonl 里提取 conversation 的 content,yield 出来供 BPE 训练用。
第2步:创建 tokenizer 对象
tokenizer = Tokenizer(models.BPE())
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
- 用 BPE 模型
- 用 ByteLevel 做预切分(UTF-8 字节级)
第3步:定义 special tokens
special_tokens_list = [
"<|endoftext|>", "<|im_start|>", "<|im_end|>",
"<|vision_start|>", "<|vision_end|>", "<|image_pad|>",
"<tool_call>", "<tool_response>", "<think>", "</think>",
...
]
additional_tokens_list = ["<tts_pad>", "<tts_text_bos>", ...]
buffer_tokens = [f"<|buffer{i}|>" for i in range(1, num_buffer+1)] # 预留扩展位
all_special_tokens = special_tokens_list + additional_tokens_list + buffer_tokens
为什么要有 buffer_tokens?预留空位,方便以后扩展新的 special token。
第4步:训练 BPE
trainer = trainers.BpeTrainer(
vocab_size=6400,
initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
special_tokens=all_special_tokens
)
texts = get_texts(data_path)
tokenizer.train_from_iterator(texts, trainer=trainer)
- vocab_size=6400:最终词表大小
- ByteLevel.alphabet():用字节级字母表
- special_tokens=all_special_tokens:固定这些 tokens,不让 BPE 拆分
special_tokens 的处理方式:
| 处理 | 说明 |
|---|---|
| 优先加入 vocab | 训练前就占好位置 |
| 不参与统计 | BPE 统计字节对频率时跳过它们 |
| 不被拆分 | 永远保持独立 token,不会被切成碎片 |
举例:
输入: "hello<|im_start|>你好"
ByteLevel: [h, e, l, l, o, Ġ, <|im_start|>, 你, 好]
BPE 训练后:
- "h", "e", "l", "o", "你", "好" 可能被合并
- "<|im_start|>" 永远不会被合并或拆分
- 它就是一个完整的 token,id 固定
这样设计是为了保证 <|im_start|>、<think> 这些结构标记在编码解码时保持完整。
训练过程
语料: "banana" 大量出现
ByteLevel 切: [b, a, n, a, n, a]
统计频率:
"an" 出现4次 ← 最常见,先合并
"na" 出现2次
"ba" 出现1次
迭代合并:
第1次: [b, an, an]
第2次: [ban, an]
最终: "banana" = 2个token [ban, an]
第5步:保存
tokenizer.decoder = decoders.ByteLevel()
tokenizer.add_special_tokens(special_tokens_list)
tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
tokenizer.model.save(tokenizer_dir) # 保存 vocab.json 和 merges.txt
tokenizer.decoder = decoders.ByteLevel():设置解码器。encode 用 ByteLevel 切成字节,decode 也要用 ByteLevel 还原回字节再转字符串。
tokenizer.add_special_tokens(special_tokens_list):训练后把 special tokens 强制加入词表。
| 步骤 | 代码 | 作用 |
|---|---|---|
| 训练时 | BpeTrainer(special_tokens=all_special_tokens) |
训练过程中不拆分这些 tokens |
| 训练后 | tokenizer.add_special_tokens(special_tokens_list) |
把这些 tokens 加入词表,确保能用 tokenizer.encode("<|im_start|>") 返回对应 id |
举例:
# 训练完成后
tokenizer.encode("<|im_start|>")
# 可能报错:这个 token 不在词表里!
# 执行 add_special_tokens 后
tokenizer.add_special_tokens(special_tokens_list)
tokenizer.encode("<|im_start|>")
# 正常返回: [5](假设 id=5)
tokenizer.save(...):保存 tokenizer.json(HuggingFace 兼容格式,可直接用 AutoTokenizer.from_pretrained() 加载)。
tokenizer.model.save(...):保存 BPE 核心文件。
| 文件 | 内容 |
|---|---|
vocab.json |
token → id 映射 |
merges.txt |
BPE 合并规则(按优先级排列) |
举例:
原始: [b, a, n, a, n, a]
按 merges.txt 合并:
1. 找 "an" → 合并 → [b, an, an]
2. 找 "ban" → 合并 → [ban, an]
最终: [ban, an]
第6步:修正 special 标记
# 遍历 tokenizer.json 里的 added_tokens
# special_tokens_list 里的 → special=True
# 其他(buffer 等)→ special=False
# 特别注意: <think> 是 special=False,所以 skip_special_tokens=True 时会保留
special vs skip_special_tokens 的区别:
| 概念 | 说明 |
|---|---|
special |
token 的固有属性(是或不是特殊标记),写在 tokenizer.json 里 |
skip_special_tokens |
decode() 的开关参数,控制是否跳过 special=True 的 token |
skip=True 时会发生什么:
- 解码时,遇到
special=True的 token → 不显示,直接忽略 - 遇到
special=False的 token → 正常显示
举例:
# 假设:
# <|im_start|> special=True
# <think> special=False
# <buffer1> special=False
input_ids = [<|im_start|>, 你, 好, ,, <think>, 我, 在, 想, <buffer1>]
# skip=True(跳过 special=True 的标记)
decode(input_ids, skip_special_tokens=True)
# 输出: "你好,我在想<buffer1>"
# ↑ <|im_start|> 被跳过了,<think> 还在
# skip=False(保留所有)
decode(input_ids, skip_special_tokens=False)
# 输出: "<|im_start|>你好,我在想<buffer1>"
第7步:生成 tokenizer_config.json
config = {
"added_tokens_decoder": {...},
"bos_token": "<|im_start|>",
"eos_token": "<|im_end|>",
"pad_token": "<|endoftext|>",
"chat_template": "{%- ... %}", # Jinja2 模板
"tokenizer_class": "PreTrainedTokenizerFast"
}
tokenizer_config.json 是 tokenizer 的配置文件,告诉 HuggingFace 怎么用这个 tokenizer。
| 配置 | 作用 |
|---|---|
tokenizer_class |
用 PreTrainedTokenizerFast 类加载 |
bos_token / eos_token |
句子开头/结尾标记 |
pad_token |
批量处理时的填充标记 |
added_tokens_decoder |
每个 token id 对应的详细信息 |
chat_template |
怎么把对话格式化成模型能理解的 prompt |
训练完怎么用(Encode / Decode)
Encode(文字 → token ids)
输入: "hello你"
Step 1: ByteLevel 预切分
[h, e, l, l, o, Ġ, 你] ← 字节序列(英文1字节,中文3字节)
Step 2: BPE merge 规则合并
[he, ll, o, Ġ, 你] ← 查 merges.txt,常见组合合并
Step 3: 查 vocab.json(token → id)
[32, 15, 15, 8, 200] ← 最终 token ids
Decode(token ids → 文字)
输入: [32, 15, 15, 8, 200]
Step 1: 查 vocab.json(id → token)
[he, ll, o, Ġ, 你]
Step 2: BPE 拆分(按 merges.txt 逆序)
[he, ll, o, Ġ, 你]
↓ "he" → 查 merges.txt → [h, e]
↓ "ll" → 查 merges.txt → [l, l]
↓ 最终: [h, e, l, l, o, Ġ, 你]
Step 3: ByteLevel decoder 还原
[h, e, l, l, o, \xe4, \xbd, \xa0]
↓ 按 UTF-8 规则组合
"hello你"
ByteLevel 怎么还原成文字?
按 UTF-8 编码规则把字节组合成字符:
| 字符类型 | UTF-8 字节数 | 字节特征 |
|---|---|---|
| ASCII | 1 字节 | 0xxxxxxx(英文、数字) |
| 中文 | 3 字节 | 1110xxxx 10xxxxxx 10xxxxxx |
| emoji | 4 字节 | 1110xxxx 10xxxxxx ... |
字节序列: [h, e, l, l, o, \xe4, \xbd, \xa0]
← ASCII → ←── 中文3字节 ──→
还原: "hello你"
一句话总结
用 tokenizers 库 + BPE 算法,以 SFT 数据(1万行)为语料,训练出 6400 个 token 的词表。
流程:ByteLevel 预切分 → BPE 统计字节对频率迭代合并 → 保存 tokenizer.json + tokenizer_config.json。