509 lines
14 KiB
Markdown
509 lines
14 KiB
Markdown
# 第4节:Tokenizer 与 Special Tokens
|
||
|
||
> 我的问题笔记 | 聚焦真正不懂的地方
|
||
|
||
---
|
||
|
||
## 📚 本节目录
|
||
|
||
1. [Q1: truncation=True 是什么意思?](#q1-truncationtrue-是什么意思)
|
||
2. [Q2: tokenizer 怎么把文字转成 token ids?](#q2-tokenizer-怎么把文字转成-token-ids)
|
||
3. [Q3: 词表里 token id 是怎么分配的?](#q3-词表里-token-id-是怎么分配的)
|
||
4. [Q4: special tokens 是什么?](#q4-special-tokens-是什么)
|
||
5. [Q5: `<|im_start|>` 这些标记怎么工作的?](#q5-im_start-这些标记怎么工作的)
|
||
6. [Q6: chat_template 是什么?](#q6-chat_template-是什么)
|
||
7. [Q7: `tokenizer.apply_chat_template` 做了什么?](#q7-tokenizerapply_chat_template-做了什么)
|
||
8. [Q8: 什么是预训练 tokenizer?](#q8-什么是预训练-tokenizer)
|
||
9. [Q9: `from pretrained()` 做了什么?](#q9-from-pretrained-做了什么)
|
||
10. [Q10: tokenizer 的训练语料从哪来?](#q10-tokenizer-的训练语料从哪来)
|
||
11. [训练流程](#训练流程)
|
||
12. [Encode / Decode](#encode--decode)
|
||
13. [一句话总结](#一句话总结)
|
||
|
||
---
|
||
|
||
## Q1: truncation=True 是什么意思?
|
||
|
||
**代码** `eval_llm.py:78`
|
||
```python
|
||
inputs = tokenizer(inputs, return_tensors="pt", truncation=True).to(args.device)
|
||
```
|
||
|
||
如果输入文本太长(超过 max_position_embeddings),**截断**多余部分。
|
||
|
||
**举例**:
|
||
```python
|
||
text = "很长很长的文本..." * 10000 # 假设有5万个token
|
||
|
||
# 报错:超出模型上限
|
||
inputs = tokenizer(text)
|
||
|
||
# truncation=True:自动截断到模型能处理的长度
|
||
inputs = tokenizer(text, truncation=True)
|
||
```
|
||
|
||
---
|
||
|
||
## Q2: attention_mask 里的"有效输入"是什么?
|
||
|
||
**代码** `eval_llm.py:83`
|
||
```python
|
||
attention_mask=inputs["attention_mask"]
|
||
```
|
||
|
||
GPU batch 处理要求固定 shape,会 padding。attention_mask 用 **1=有效,0=padding** 标记哪些是真 token。
|
||
|
||
**举例**:
|
||
```python
|
||
# 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`
|
||
```python
|
||
generated_ids = model.generate(inputs=inputs["input_ids"], ...)
|
||
```
|
||
|
||
**自回归** = 每一步的输出会成为下一步的输入。自己生成的东西又喂回去。
|
||
|
||
**生成过程**:
|
||
```
|
||
用户: "你好"
|
||
|
||
Step1 → 输入"你好" → 输出 ","
|
||
Step2 → 输入"你好," → 输出 "我"
|
||
Step3 → 输入"你好,我" → 输出 "是"
|
||
Step4 → 输入"你好,我是"→ 输出 "AI"
|
||
...一直重复直到 EOS 或 max_tokens
|
||
```
|
||
|
||
**对比**:非自回归是一次性输出所有 token,像"看一眼全说完"。
|
||
|
||
---
|
||
|
||
## Q4: repeat 是在干嘛?
|
||
|
||
**代码** `model/model_minimind.py`
|
||
```python
|
||
input_ids = kwargs.pop("input_ids", inputs).repeat(num_return_sequences, 1)
|
||
attention_mask = attention_mask.repeat(num_return_sequences, 1) if ...
|
||
```
|
||
|
||
复制 batch 维度,**一次生成多个不同回复**。
|
||
|
||
**举例**:
|
||
```python
|
||
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`
|
||
```python
|
||
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`
|
||
```python
|
||
tokenizer = AutoTokenizer.from_pretrained(args.load_from)
|
||
```
|
||
|
||
**配置** `minimind-3/tokenizer_config.json:330`
|
||
```json
|
||
{
|
||
"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步:准备语料
|
||
|
||
```python
|
||
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 对象
|
||
|
||
```python
|
||
tokenizer = Tokenizer(models.BPE())
|
||
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
|
||
```
|
||
|
||
- 用 **BPE 模型**
|
||
- 用 **ByteLevel** 做预切分(UTF-8 字节级)
|
||
|
||
### 第3步:定义 special tokens
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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步:保存
|
||
|
||
```python
|
||
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 |
|
||
|
||
**举例**:
|
||
```python
|
||
# 训练完成后
|
||
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 标记
|
||
|
||
```python
|
||
# 遍历 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 → **正常显示**
|
||
|
||
**举例**:
|
||
```python
|
||
# 假设:
|
||
# <|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
|
||
|
||
```python
|
||
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 ... |
|
||
|
||
```python
|
||
字节序列: [h, e, l, l, o, \xe4, \xbd, \xa0]
|
||
← ASCII → ←── 中文3字节 ──→
|
||
|
||
还原: "hello你"
|
||
```
|
||
|
||
---
|
||
|
||
## 一句话总结
|
||
|
||
**用 tokenizers 库 + BPE 算法,以 SFT 数据(1万行)为语料,训练出 6400 个 token 的词表**。
|
||
|
||
流程:ByteLevel 预切分 → BPE 统计字节对频率迭代合并 → 保存 tokenizer.json + tokenizer_config.json。
|