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)、编解码一致性、流式解码是否出现乱码。
|