File size: 13,533 Bytes
616c7df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
# Tokenizer 深度讲解

> 本仓库 `src/trainers/lm/train_tokenizer.py` 的 tokenizer 训练实现,覆盖 BPE 原理、特殊 token 体系、配置文件结构

## 0. Tokenizer 在 LLM 中的位置

```
原始文本
    |
    v
+-------------+
|  Tokenizer  |  文本 -> token ids
+------+------+
       |
       v
+-------------+
| Embedding   |  token ids -> 向量
+------+------+
       |
       v
    Transformer
```

Tokenizer 是 LLM 的"词典",决定了模型如何理解文本。

---

## Q1. 为什么用 BPE?

### 分词方案对比

| 方案 | 优点 | 缺点 |
|------|------|------|
| 字符级 | 词表小,无 OOV | 序列长,语义弱 |
| 词级 | 语义强 | 词表大,有 OOV |
| BPE | 平衡词表大小和语义 | 需要训练 |

### BPE 核心思想

```

初始:所有字符独立
"low" -> ['l', 'o', 'w']

第1步:统计最频繁的相邻对
"lo" 出现 100 次,"ow" 出现 80 次

第2步:合并最频繁的对
"lo w" -> ['lo', 'w']

重复直到达到词表大小
```

### BPE 训练三步

训练 BPE tokenizer 只需要三步:

```python
# 1. 创建模型
tokenizer = Tokenizer(models.BPE())

# 2. 配置预分词 + 训练器
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
trainer = trainers.BpeTrainer(vocab_size=6400, special_tokens=[...])

# 3. 训练
tokenizer.train_from_iterator(texts, trainer=trainer)
```

> 面试点:BPE 和 WordPiece 的区别?BPE 基于频率合并,WordPiece 基于似然合并

---

## Q2. 字节级 BPE(ByteLevel)为什么好?

### 传统 BPE 的问题

- 基础字母表是 Unicode 字符
- 不同语言需要不同处理
- 可能出现 OOV

### 字节级 BPE 的优势

```python
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
trainer = trainers.BpeTrainer(
    initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),  # 256 个字节值
)
```

**核心改进**

1. 基础字母表是 256 个字节值,覆盖所有可能的输入
2. 任何文本都能无损表示,永远不会 OOV
3. 中文/英文/emoji/代码都能统一处理
4. 加前缀空格是 GPT-2 风格,本仓库设为 False

> 面试点:为什么字节级 BPE 比字符级好?封闭 256 词表、无 OOV、多语言无损

---

## Q3. 本仓库的特殊 Token 体系

### 三层结构(共 36 个)

#### 第一层:核心特殊 token(21 个,special=True)

| 类别 | Token | 用途 |
|------|-------|------|
| 对话结构 | `<|im_start|>` / `<|im_end|>` | ChatML 角色标记边界 |
| 视觉 | `<|vision_start|>` / `<|vision_end|>` | 视觉内容边界 |
| 视觉 | `<|image_pad|>` | 图像占位 token |
| 视觉 | `<|vision_pad|>` | 视觉填充 |
| 音频 | `<|audio_start|>` / `<|audio_end|>` | 音频内容边界 |
| 音频 | `<|audio_pad|>` | 音频占位 token |
| TTS | `<tts_pad>` | TTS 填充 |
| TTS | `<tts_text_bos>` / `<tts_text_eod>` | TTS 文本开始/结束 |
| TTS | `<tts_text_bos_single>` | TTS 单句开始 |
| 对象引用 | `<|object_ref_start|>` / `<|object_ref_end|>` | 对象引用边界 |
| 坐标 | `<|box_start|>` / `<|box_end|>` | 边界框坐标 |
| 坐标 | `<|quad_start|>` / `<|quad_end|>` | 四边形坐标 |
| 视频 | `<|video_pad|>` | 视频占位 token |
| 结束 | `<|endoftext|>` | 文本结束 / pad / unk |

#### 第二层:额外特殊 token(6 个,special=True)

| Token | 用途 |
|-------|------|
| `<tool_call>` / `</tool_call>` | 工具调用边界 |
| `<tool_response>` / `</tool_response>` | 工具响应边界 |
| `<think>` / `</think>` | 思考链边界 |

#### 第三层:预留 buffer token(9 个,special=False)

```
<|buffer1|> ~ <|buffer9|>
用途:为未来扩展预留位置
```

### 为什么这样设计?

1. **ChatML 格式**:业界标准,兼容性好
2. **多模态支持**:视觉/音频 token 让模型区分不同模态
3. **工具调用**:支持 function calling
4. **思考能力**`<think>` / `</think>` 支持 chain-of-thought
5. **预留扩展**:buffer token 为未来功能留空间

---

## Q4. 训练数据加载

```python
def get_texts(data_path):
    with open(data_path, 'r') as f:
        for i, line in enumerate(f):
            if i >= 10000:
                break  # 只取 10000 行
            data = json.loads(line)
            # 提取所有对话中的 content 字段
            contents = [
                item['content']
                for item in data['conversations']
                if item.get('content')
            ]
            yield '\n'.join(contents)
```

数据来源:sft_t2t_mini.jsonl(SFT 对话数据)
格式:每行是 JSON,包含 conversations 数组
提取:将所有 content 字段拼成一段文本

---

## Q5. BPE 训练内部过程

### 步骤 1:初始化词表

```
基础词表 = 256 个字节值 + 36 个特殊 token = 292
目标词表 = 6400
还需合并次数 = 6400 - 292 = 6108
```

### 步骤 2:统计字节对频率

```
输入: "the cat sat on the mat"
统计相邻对频率:
  "t", "h" -> 3 次
  "h", "e" -> 2 次
  "a", "t" -> 3 次
  ...
```

### 步骤 3:合并最频繁对

```
合并 "th":
  "the" -> ["th", "e"]
继续合并 "he":
  "the" -> ["the"]
继续...直到词表达到 6400
```

### 最后一次合并

```
第 6108 次合并后,词表包含:
- 256 个字节 token
- 36 个特殊 token
- 6108 个合并后的子词 token
= 6400 个 token
```

---

## Q6. tokenizer.json 结构详解

### 整体结构

```json
{
  "version": "1.0",
  "model": {
    "type": "BPE",
    "vocab": {
      "!": 0,
      "\"": 1,
      ...
      "the": 256,
      "ing": 257,
      ...
    },
    "merges": [
      "t h",
      "h e",
      "t h e",
      ...
    ]
  },
  "added_tokens": [
    {
      "id": 6400,
      "content": "<|im_start|>",
      "single_word": false,
      "lstrip": false,
      "rstrip": false,
      "normalized": false,
      "special": true
    }
  ]
}
```

| 字段 | 说明 |
|------|------|
| vocab | token -> id 映射(6400 个条目) |
| merges | BPE 合并规则(按合并顺序排列) |
| added_tokens | 额外添加的 token(36 个特殊 token) |

---

## Q7. tokenizer_config.json 关键配置

```json
{
  "bos_token": "<|im_start|>",
  "eos_token": "<|im_end|>",
  "pad_token": "<|endoftext|>",
  "unk_token": "<|endoftext|>",
  "additional_special_tokens": [
    "<|object_ref_start|>",
    "<|vision_start|>",
    "<|vision_end|>",
    "<|audio_start|>",
    "<|audio_end|>",
    "<|image_pad|>",
    "<|audio_pad|>",
    ...
  ],
  "chat_template": "...",
  "image_token": "<|image_pad|>",
  "audio_token": "<|audio_pad|>",
  "video_token": "<|video_pad|>",
  "vision_bos_token": "<|vision_start|>",
  "vision_eos_token": "<|vision_end|>",
  "audio_bos_token": "<|audio_start|>",
  "audio_eos_token": "<|audio_end|>",
  "tokenizer_class": "PreTrainedTokenizerFast"
}
```

### 关键字段说明

| 字段 | 值 | 说明 |
|------|-----|------|
| bos_token | `<|im_start|>` | 开始 token |
| eos_token | `<|im_end|>` | 结束 token |
| pad_token | `<|endoftext|>` | 填充 token |
| unk_token | `<|endoftext|>` | 未知 token |
| model_max_length | 131072 | 最大序列长度 |

---

## Q8. Chat Template 的工作原理

### 模板示例

```
<|im_start|>system
你是一个助手<|im_end|>
<|im_start|>user
你好<|im_end|>
<|im_start|>assistant
你好!有什么可以帮助你的?<|im_end|>
```

### 代码调用

```python
messages = [
    {"role": "system", "content": "你是一个助手"},
    {"role": "user", "content": "你好"},
]
prompt = tokenizer.apply_chat_template(messages, tokenize=False)
# -> "<|im_start|>system\n你是一个助手<|im_end|>\n<|im_start|>user\n你好<|im_end|>\n<|im_start|>assistant\n"
```

### Chat Template 格式

```
{% for message in messages %}
  <|im_start|>{{ message.role }}
  {{ message.content }}<|im_end|>
{% endfor %}
<|im_start|>assistant
```

---

## Q9. 编码解码机制

### 编码过程(text -> token ids)

```
输入: "你好世界"

分词过程:
1. ByteLevel 预分词: 将文本转成字节序列
2. BPE 合并: 按 merges 规则合并字节对
3. 输出 token ids: [123, 456, 789]

底层原理:
"你好" 的 UTF-8 字节 -> [0xe4, 0xbd, 0xa0, 0xe5, 0xa5, 0xbd]
BPE 逐步合并字节对 -> 最终形成子词 token
```

### 解码过程(token ids -> text)

```
输入: [123, 456, 789]

解码过程:
1. 查 vocab 得到 token 字符串
2. ByteLevel 解码: 字节序列 -> UTF-8 文本
3. 输出: "你好世界"

注意: decode 时 skip_special_tokens 参数
- True: 跳过特殊 token(默认)
- False: 保留特殊 token
```

### 编解码一致性测试

```python
text = "你好世界"
ids = tokenizer.encode(text)
decoded = tokenizer.decode(ids)
assert decoded == text  # 字节级 BPE 保证无损
```

---

## Q10. 压缩率评估

### 本仓库的评测方法

```python
def eval_compression(tokenizer, texts):
    for text in texts:
        encoded = tokenizer.encode(text)
        char_count = len(text)
        token_count = len(encoded)
        ratio = char_count / token_count
        print(f"Chars: {char_count}, Tokens: {token_count}, 压缩率: {ratio:.2f}")
```

### 典型结果

| 语言 | 样本 | 压缩率 |
|------|------|--------|
| 中文 | 200 字 | 3.5 chars/token |
| 英文 | 200 词 | 4.0 chars/token |
| 混合 | 中英混合 | 3.8 chars/token |

> 中文压缩率偏低,因为中文字符多,字节表示更密集

---

## Q11. 流式解码

### 问题

逐 token 解码时,字节可能跨 token 分布,导致出现 Unicode 替换字符(\ufffd)。

### 本仓库的流式解码

```python
token_cache = []
for tid in input_ids:
    token_cache.append(tid)
    current_decode = tokenizer.decode(token_cache)
    # 只有当没有替换字符时才输出
    if current_decode and '\ufffd' not in current_decode:
        print(current_decode, end='')
        token_cache = []
```

### 为什么用缓存?

- UTF-8 编码中,一个中文字符可能被拆成多个 token
- 需要等待完整字节序列再解码
- `\ufffd` 检测确保输出完整的字符

---

## Q12. 为什么不建议重复训练 tokenizer?

```python
# 脚本开头的注释:
# Note: It is not recommended to re-train the tokenizer.
# MiniMind already includes one.
```

### 原因

1. **社区兼容性**:不同 tokenizer 导致模型不兼容
2. **数据敏感**:训练数据影响分词质量
3. **词表依赖**:下游任务依赖词表一致性
4. **已足够好**:6400 词表对小模型足够

### 什么时候需要重训?

- 新领域:代码、法律、医疗等专业领域
- 新语言:当前词表不支持的语言
- 优化需求:压缩率不满足要求

---

## Q13. tokenizer 与模型的交互

### 在模型前向传播中的位置

```python
# src/models/lm/model.py
class LMForCausalLM(PreTrainedModel):
    def forward(self, input_ids, ...):
        # input_ids = tokenizer.encode(text)
        hidden_states = self.model.embed_tokens(input_ids)
        # ... transformer ...
        return logits

    def generate(self, input_ids, ...):
        # 1. 编码: tokenizer.encode(prompt)
        # 2. 逐 token 生成
        for _ in range(max_new_tokens):
            logits = self(input_ids)
            next_token = sample(logits)
            input_ids = torch.cat([input_ids, next_token])
        # 3. 解码: tokenizer.decode(output_ids)
```

### 交互流程

```
用户输入文本
    |
    v
tokenizer.encode(text) -> [token ids]
    |
    v
model.forward(input_ids) -> logits
    |
    v
sample(logits) -> next token id
    |
    v
tokenizer.decode([next token]) -> 文本
```

---

## Q14. Tokenizer 与多模态

### 占位 token 机制

```python
# 视觉占位 token
"<|image_pad|>" -> id 6412

# 在输入序列中的位置
input_ids: [bos, ..., image_pad, image_pad, ..., eos]
                  |          |
                  1 个 token 对应 64 个视觉特征
                  image_token_len = 64
```

### VLM 中的使用

```python
# src/models/vlm/model.py
def count_vision_proj(self, input_ids, vision_proj_output):
    # 找到视觉占位 token 位置
    image_positions = (input_ids == self.config.image_ids[0]).nonzero()
    # 替换为视觉特征
    for i, pos in enumerate(image_positions):
        hidden_states[pos] = vision_proj_output[i]
```

### 多模态特殊 token 的作用

| Token | id | 在多模态中的角色 |
|-------|-----|-----------------|
| `<|vision_start|>` | 6408 | 视觉输入开始标记 |
| `<|vision_end|>` | 6409 | 视觉输入结束标记 |
| `<|image_pad|>` | 6412 | 图像特征占位符 |
| `<|audio_start|>` | 6414 | 音频输入开始标记 |
| `<|audio_pad|>` | 6416 | 音频特征占位符 |

---

## Q15. 常见面试问题

### Q: 词表大小怎么选?

A: 本仓库用 6400。词表太小 -> 序列长;词表太大 -> Embedding 参数多。通常根据模型规模选:小模型(<1B)用 8k-16k,大模型用 32k-128k。

### Q: ByteLevel 和 WordLevel 的区别?

A: ByteLevel 以字节为基本单元,WordLevel 以单词为基本单元。ByteLevel 更通用、无 OOV。

### Q: data_path 中的 jsonl 文件是什么格式?

A: 每行一个 JSON 对象,包含 conversations 数组,每个 conversation 包含 role 和 content 字段。

### Q: 为什么需要 special_tokens_num 参数?

A: 预留位置给特殊 token。特殊 token 在 BPE 训练时直接加入词表,不会被进一步拆分。

### Q: 怎样评估 tokenizer 的质量?

A: 看压缩率(chars/token)、编解码一致性、流式解码是否出现乱码。