BotOf TechAI / IoT / Full-Stack / 植物养护知识分享
W0401 / 夯实底层理论与架构

Tokenizer 到文本生成

拆开从字符串、token ids、logits 到逐 token 解码的完整推理链路,不再把 pipeline 当黑盒。

建议时长
7–9 小时
难度
工程基础
课程位置
4 / 12
LEARNING OBJECTIVES

学完这一课,你应该能:

  • 能解释 BPE 类分词器的训练与编码过程
  • 能识别 chat template、截断和特殊 token 错误
  • 能不用 generate() 编写自回归解码循环
  • 能比较 greedy、temperature、top-k 与 top-p

1. 模型从未直接看见文字

完整链路是:

text
→ Unicode normalization
→ pre-tokenization
→ subword segmentation
→ token ids
→ embeddings
→ Transformer
→ logits
→ decoding strategy
→ token ids
→ text

Tokenizer 决定序列长度、词边界、特殊符号与大量训练成本。比较 tokenizer 不能只看词表大小,还应测目标语料的平均 token/字符、P95 长度、截断率和往返解码。

2. 用一个小例子理解 BPE

BPE 从字符或字节单元开始,反复合并语料中最高频的相邻 pair。假设语料有:

low low lower newest widest

训练会统计相邻单元频率,逐步形成 lolower 等合并规则。编码新文本时按学习到的合并优先级应用。关键认识:

  • Token 不是语言学上的“词”。
  • 同一字符串在不同 tokenizer 中可能长度差异很大。
  • 罕见代码、数字、中文混合文本常出现意外切分。
  • 添加新 token 会改变 embedding 矩阵尺寸,必须同步 resize。

3. Chat template 是模型协议

指令模型通常在类似结构上训练:

<system>你是助手</system>
<user>问题</user>
<assistant>答案</assistant>

不同模型的 BOS、EOS、角色标记完全可能不同。错误模板会造成训练–推理分布偏移。使用 Hugging Face 时:

messages = [
    {"role": "system", "content": "你是严谨的技术助手。"},
    {"role": "user", "content": "解释 KV Cache。"},
]

encoded = tokenizer.apply_chat_template(
    messages,
    add_generation_prompt=True,
    tokenize=True,
    return_tensors="pt",
)

必须打印最终 token 和解码文本,确认:

  1. 是否重复添加 BOS/EOS。
  2. 是否包含 assistant generation prompt。
  3. 训练标签是否只监督 assistant 内容。
  4. 截断是否切掉了答案而只保留问题。

4. Logits 不是概率

模型输出 logits [B,T,V]。最后位置的 V 个数是未归一化分数:

next_logits = logits[:, -1, :]
probs = torch.softmax(next_logits, dim=-1)

Temperature:

p = softmax(logits / temperature)
  • 温度趋近 0:分布更尖锐,接近 greedy。
  • 温度大于 1:分布更平,随机性增加。
  • 温度不是“创造力开关”,它改变抽样分布,也可能放大低质量尾部 token。

Top-k 只保留最高 k 个 token;top-p 保留累计概率达到 p 的最小集合。二者解决的是限制采样候选,不保证事实正确。

5. 不用 generate() 写解码循环

import torch

@torch.inference_mode()
def sample(model, input_ids, max_new_tokens=64, temperature=0.8, top_k=40):
    model.eval()
    generated = input_ids

    for _ in range(max_new_tokens):
        outputs = model(generated, use_cache=False)
        logits = outputs.logits[:, -1, :] / max(temperature, 1e-5)

        if top_k is not None:
            values, _ = torch.topk(logits, k=min(top_k, logits.size(-1)))
            threshold = values[:, -1, None]
            logits = logits.masked_fill(logits < threshold, float("-inf"))

        probs = torch.softmax(logits, dim=-1)
        next_token = torch.multinomial(probs, num_samples=1)
        generated = torch.cat([generated, next_token], dim=-1)

        if torch.all(next_token == tokenizer.eos_token_id):
            break

    return generated

这版故意关闭 cache,让流程最清楚。下一阶段再加入 past_key_values:第一次 prefill 处理整个 prompt,之后每次只输入新 token,同时复用历史 K/V。

6. 为每一步生成保存 trace

不要只看最终文本。记录:

trace.append({
    "step": step,
    "token_id": next_token.item(),
    "token_text": tokenizer.decode(next_token[0]),
    "probability": probs[0, next_token.item()].item(),
    "entropy": -(probs * probs.clamp_min(1e-12).log()).sum().item(),
    "elapsed_ms": elapsed_ms,
    "sequence_length": generated.size(1),
})

Trace 可以回答:模型从哪一步开始不确定?重复前 entropy 是否下降?长序列 TPOT 是否上升?停止条件是否生效?

7. 常见生成故障

现象优先检查
无限生成EOS id、停止字符串、最大长度
回答前出现模板字符chat template 或 special token
输出重复训练数据、temperature、重复惩罚、上下文
同 seed 仍不同非确定算子、模型状态、采样 generator
批量输出异常padding side、attention mask、位置 id
答案被截掉truncation side 与 max length

左 padding 与右 padding 对 decoder-only 批量生成可能影响最后有效 token 的位置判断,必须遵循模型文档并实际核对。

8. 本课实验

选一个小型开放模型,生成同一 prompt 的 5 组对照:

  1. greedy
  2. temperature=0.7
  3. temperature=1.2
  4. top-k=20
  5. top-p=0.9

固定 seed,记录每步 token、概率、entropy、TTFT、TPOT。再把 chat template 故意替换成纯文本拼接,比较输出差异。

交付:

  • generation_trace.jsonl
  • 参数–质量–重复率对照表
  • 一张 token 概率时间线
  • 对两个失败样例的根因说明

9. 练习与答案提示

  1. 为什么新增 token 后要 resize embedding?**答:**新 id 必须有对应输入/输出参数行。
  2. Top-p 的候选数量固定吗?**答:**不固定,取决于当前概率分布。
  3. 为什么批量生成要传 attention mask?**答:**防止模型把 padding 当有效上下文。
  4. decode(encode(text)) 一定与原文逐字符相同吗?**答:**不一定,规范化和空白处理可能改变表示。
  5. 为什么 trace 比最终答案更利于调试?**答:**能定位错误开始的时间点和概率变化。

10. 延伸阅读