第一个 token 就是 <|im_end|>
主线位置:推理主线的「带 KV 缓存生成」一步,对一个 Instruct 模型手写生成循环:prompt 怎么拼,什么时候停。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate)
flowchart TB
q["用户问题"] --> t{"套了对话模板吗"}
t -->|否| e1["第一个就是 im_end"]
t -->|是| g["生成"]
g --> s{"比 eos 吗"}
s -->|否| e2["接着写新一轮"]
s -->|是| ok["停在 im_end"]
实验代码:Python 侧 chat_py.py,candle 侧 crate pitb-exp 的 bin p28_chat(release,raw / tpl / noeos 三种模式)。模型是本地的 SmolLM2-135M-Instruct 权重,f32,transformers 用 eager、torch.set_num_threads(8)。平台 M2 Max、macOS 27、rustc 1.99.0。对话模板怎么在 Rust 里逐字节拼出来,见分词与对话模板;完整的生成循环,见生成循环。
PyTorch 的写法
transformers 里这两件事各有一个函数替你做。先看漏了模板的样子:
q = "How do you say thank you in Chinese?"
enc = tok(q, return_tensors="pt") # 习惯写法:问题直接当 prompt
out = model.generate(**enc, max_new_tokens=30, do_sample=False)
print("generation_config.eos_token_id:", model.generation_config.eos_token_id)
enc = tok.apply_chat_template([{"role": "user", "content": "Translate to Chinese: Good morning."}],
add_generation_prompt=True, return_tensors="pt", return_dict=True)
out = model.generate(**enc, max_new_tokens=64, do_sample=False)
raw prompt ids: [2020, 536, 346, 1643, 9984, 346, 281, 4097, 47]
raw: [2] '<|im_end|>'
generation_config.eos_token_id: 2
template: 19 tokens, last 2 '晨飞起来,你好?<|im_end|>'
- 不套模板:生成一个 token 就停了,那个 token 是
<|im_end|>(id 2),用skip_special_tokens=True解码就是空字符串。 - 套模板:
generate在第 19 个 token(eos)停下。停是generate自己做的:它从模型目录的generation_config.json读到eos_token_id: 2,放进停止条件。
PyTorch 用户平时只写 apply_chat_template + generate,很少意识到这是两个独立的步骤。
搬到 candle 的现象
candle 里两件事都没有现成的函数。直接把问题编码送进去(p28_chat cpu "..." 30 raw):
raw [2] '' '<|im_end|>'
和 transformers 一样,第一个 token 就是 <|im_end|>,prompt 的 9 个 id 两边相同。
套了模板,但手写循环里没比 eos(noeos,生成 40 个,decode(&ids, false) 保留特殊 token):
'晨飞起来,你好?<|im_end|>\n<|im_end|><|im_start|>system\n<|im_end|>\n<|im_start|>user\nI am planning a trip to Japan and I need help'
用 decode(&ids, true) 时特殊 token 消失,打印出来是:
'晨飞起来,你好?\nsystem\n\nuser\nI am planning a trip to Japan and I need help'
模型回答完,接着写了一个空的 system 轮,又替用户编了一个新问题。只看去掉特殊 token 的输出,像是模型「答非所问」。
原因
模板。 SmolLM2-Instruct 是在 <|im_start|>system ... <|im_end|>、<|im_start|>user ... <|im_end|>、<|im_start|>assistant\n 这种格式上微调的。光秃秃的一句话在它看来是一段已经结束的文本,最可能的下一个 token 就是「这一轮结束」。模板写在 tokenizer_config.json 的 chat_template 字段里,是一段 jinja2;transformers 用 Python 的 jinja2 渲染,candle 和 Rust 的 tokenizers 都不读这个字段(分词与对话模板)。
eos。 candle 的 LlamaConfig 从 config.json 读 eos_token_id,类型是 LlamaEosToks(单个或列表)。它只是 Config 里的一个字段,Llama::forward 不看它,返回的只是最后一个位置的 logits。停不停由调用方的循环决定。<|im_end|> 对模型来说只是又一个 token,后面接什么它照训练数据的格式往下写。
改法
套模板:在 Rust 里照着模板手拼(render_chat,和 apply_chat_template 逐字节相同,见分词与对话模板);比 eos:从 config 取出 id,每步比较:
let eos = match cfg.eos_token_id {
Some(LlamaEosToks::Single(id)) => id,
_ => anyhow::bail!("unexpected eos config {:?}", cfg.eos_token_id),
};
let prompt = chat_prompt(user); // render_chat(&[user 消息], add_generation_prompt = true)
let prompt_ids = tok.encode(prompt.as_str(), false).map_err(|e| anyhow!(e))?.get_ids().to_vec();
// ... 循环里
let next = logits.argmax(0)?.to_scalar::<u32>()?;
ids.push(next);
if next == eos {
break;
}
morning [177, 243, 118, 180, 113, 248, 179, 130, 132, 30679, 115, 12831] '晨飞起来,你好?' '晨飞起来,你好?<|im_end|>'
19 个 token 停在 eos,和 transformers 的 template 一行相同(列表只打了前 12 个 id)。encode(.., false) 不额外加特殊 token:模板里的 <|im_start|> 已经在字符串里,编码时直接变成控制 token。
eos_token_id是列表的模型(Llama 3 之类)用Multiple(v)分支和v.contains(&next)。- 有的模型
generation_config.json列的 eos 比config.json多,transformers 用前者;candle 侧要自己读generation_config.json。SmolLM2 两个文件都是 2。
怎么检查
- 解码时保留特殊 token(
decode(&ids, false))看一遍原始输出:<|im_end|>后面还有东西,就是循环没停;第一个就是<|im_end|>,就是 prompt 没套模板。 - Python 里用
apply_chat_template(..., tokenize=False)渲染几段对话存下来,Rust 侧拼的字符串逐字节比。 - 对照测试比 prompt 的 id,不只比生成的文字。
小结
- 问题直接当 prompt:Instruct 模型的第一个 token 就是
<|im_end|>,两边都一样;transformers 用apply_chat_template解决,candle 要手拼模板。 generate从generation_config.json读 eos、自己停;candle 的Llama::forward不看eos_token_id,手写循环不比较,模型会接着写新的 system / user 轮,skip_special_tokens解码后看起来像答非所问。- 改法:手拼模板加每步比 eos,SmolLM2 在第 19 个 token 停下,和 transformers 相同。