omni / docs /interview /tokenizer.md
chenbhao's picture
docs: move tokenizer.md into interview/
aefd3e0
|
Raw
History Blame Contribute Delete
13.5 kB

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 只需要三步:

# 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 的优势

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
视觉 `< vision_start
视觉 `< image_pad
视觉 `< vision_pad
音频 `< audio_start
音频 `< audio_pad
TTS <tts_pad> TTS 填充
TTS <tts_text_bos> / <tts_text_eod> TTS 文本开始/结束
TTS <tts_text_bos_single> TTS 单句开始
对象引用 `< object_ref_start
坐标 `< box_start
坐标 `< quad_start
视频 `< video_pad
结束 `< endoftext

第二层:额外特殊 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. 训练数据加载

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 结构详解

整体结构

{
  "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 关键配置

{
  "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
eos_token `< im_end
pad_token `< endoftext
unk_token `< endoftext
model_max_length 131072 最大序列长度

Q8. Chat Template 的工作原理

模板示例

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

代码调用

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

编解码一致性测试

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

Q10. 压缩率评估

本仓库的评测方法

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)。

本仓库的流式解码

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?

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

原因

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

什么时候需要重训?

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

Q13. tokenizer 与模型的交互

在模型前向传播中的位置

# 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 机制

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

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

VLM 中的使用

# 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 >`
`< vision_end >`
`< image_pad >`
`< audio_start >`
`< audio_pad >`

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