Tokenizer 到文本生成
拆开从字符串、token ids、logits 到逐 token 解码的完整推理链路,不再把 pipeline 当黑盒。
- 建议时长
- 7–9 小时
- 难度
- 工程基础
- 课程位置
- 4 / 12
学完这一课,你应该能:
- 能解释 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
训练会统计相邻单元频率,逐步形成 lo、low、er 等合并规则。编码新文本时按学习到的合并优先级应用。关键认识:
- 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 和解码文本,确认:
- 是否重复添加 BOS/EOS。
- 是否包含 assistant generation prompt。
- 训练标签是否只监督 assistant 内容。
- 截断是否切掉了答案而只保留问题。
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 组对照:
- greedy
- temperature=0.7
- temperature=1.2
- top-k=20
- top-p=0.9
固定 seed,记录每步 token、概率、entropy、TTFT、TPOT。再把 chat template 故意替换成纯文本拼接,比较输出差异。
交付:
generation_trace.jsonl- 参数–质量–重复率对照表
- 一张 token 概率时间线
- 对两个失败样例的根因说明
9. 练习与答案提示
- 为什么新增 token 后要 resize embedding?**答:**新 id 必须有对应输入/输出参数行。
- Top-p 的候选数量固定吗?**答:**不固定,取决于当前概率分布。
- 为什么批量生成要传 attention mask?**答:**防止模型把 padding 当有效上下文。
decode(encode(text))一定与原文逐字符相同吗?**答:**不一定,规范化和空白处理可能改变表示。- 为什么 trace 比最终答案更利于调试?**答:**能定位错误开始的时间点和概率变化。