生成循环
主线位置:推理主线的「带 KV 缓存生成」一步:logits 已经对上,现在把「取下一个 token、喂回去」一直做到停下,并且边生成边把文字吐出来。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB
tpl["对话模板 prompt"] --> pre["prefill 整段"]
pre --> pick["argmax 取 token"]
pick --> eos{"是 eos 吗"}
eos -->|是| stop["停"]
eos -->|否| len{"到长度上限"}
len -->|是| stop
len -->|否| dec["decode 一个 token"]
dec --> pick
pick --> st["流式解码"]
st --> out["打印完整的字"]
实验代码:Python 侧 gen_chat.py、maxlen.py;candle 侧 crate gen-exp 的 bin chat。模型是本地的 SmolLM2-135M-Instruct 权重,两边都载成 f32,transformers 用 attn_implementation="eager"、torch.set_num_threads(8)。release 构建,平台 M2 Max、macOS 27、rustc 1.99.0。
两份程序
transformers 一侧,一个 generate 调用做完全部事情:套对话模板、prefill、逐个 decode、遇到 eos 停、流式输出。TextStreamer 的子类把每次要打印的文字收进列表,后面比较用:
import json
import sys
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, TextStreamer
model_dir, prompt, max_new = sys.argv[1], sys.argv[2], int(sys.argv[3]) # 本地的 SmolLM2-135M-Instruct 权重
torch.set_num_threads(8)
tok = AutoTokenizer.from_pretrained(model_dir)
model = AutoModelForCausalLM.from_pretrained(
model_dir, dtype=torch.float32, attn_implementation="eager"
).eval()
class Chunks(TextStreamer):
def __init__(self, tok):
super().__init__(tok, skip_prompt=True, skip_special_tokens=True)
self.chunks = []
def on_finalized_text(self, text, stream_end=False):
self.chunks.append(text)
enc = tok.apply_chat_template(
[{"role": "user", "content": prompt}],
add_generation_prompt=True, return_tensors="pt", return_dict=True,
)
streamer = Chunks(tok)
with torch.inference_mode():
out = model.generate(**enc, max_new_tokens=max_new, do_sample=False, streamer=streamer)
new = out[0, enc["input_ids"].shape[1]:].tolist()
json.dump({"ids": new, "text": tok.decode(new, skip_special_tokens=True),
"chunks": streamer.chunks}, sys.stdout, ensure_ascii=False)
candle 一侧,这些事情每一件都要自己写。Llama::forward 只返回最后一个位置的 logits(对齐 logits),第二个参数是这段输入在序列里的起始位置,prefill 时是 0,之后每步加上刚送进去的 token 数(KV 缓存)。对话模板是手拼的字符串,它和 apply_chat_template 逐字节相同,见分词与对话模板;TokenOutputStream 在下面「流式输出」一节:
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let dev = match args[1].as_str() {
"metal" => Device::new_metal(0)?,
_ => Device::Cpu,
};
let user = args.get(2).context("prompt")?;
let max_new: usize = args.get(3).context("max_new")?.parse()?;
let model_dir = "SmolLM2-135M-Instruct";
let tok = Tokenizer::from_file(format!("{model_dir}/tokenizer.json")).map_err(anyhow::Error::msg)?;
let cfg: LlamaConfig = serde_json::from_slice(&std::fs::read(format!("{model_dir}/config.json"))?)?;
let cfg = cfg.into_config(false);
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(&[format!("{model_dir}/model.safetensors")], DType::F32, &dev)?
};
let model = Llama::load(vb, &cfg)?;
let mut cache = Cache::new(true, DType::F32, &cfg, &dev)?;
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);
let prompt_ids = tok.encode(prompt.as_str(), false).map_err(anyhow::Error::msg)?.get_ids().to_vec();
let mut stream = TokenOutputStream::new(tok.clone());
let (mut ids, mut chunks) = (Vec::new(), Vec::new());
let mut input = prompt_ids.clone();
let mut pos = 0;
for _ in 0..max_new {
let x = Tensor::new(input.as_slice(), &dev)?.unsqueeze(0)?;
let logits = model.forward(&x, pos, &mut cache)?.squeeze(0)?;
pos += input.len();
let next = logits.argmax(0)?.to_scalar::<u32>()?;
ids.push(next);
if let Some(t) = stream.next_token(next)? {
chunks.push(t);
}
if next == eos {
break;
}
input = vec![next];
}
if let Some(t) = stream.decode_rest()? {
chunks.push(t);
}
let text = tok.decode(&ids, true).map_err(anyhow::Error::msg)?;
println!("{}", serde_json::json!({ "ids": ids, "text": text, "chunks": chunks }));
Ok(())
}
三个 prompt,两边逐个比较 prompt 的 id 和生成的 id(要求完全相同):
| prompt | 上限 | 停在 | CPU | Metal |
|---|---|---|---|---|
Translate to Chinese: Good morning. | 64 | eos,共 19 个 token | 19/19 | 19/19 |
How do you say thank you in Chinese? | 200 | eos,共 71 个 token | 71/71 | 71/71 |
中国的首都是哪里? | 80 | 长度上限 | 80/80 | 80/80 |
prompt 的 id(38、39、47 个)也全部相同。第二个 prompt 的回答(两边一样,模型只有 135M,内容不对):
In Chinese, you can say "六六" (Shiè) to express gratitude. Here's a translation:
"六六" (Shiè)
This phrase means "one hundred and six" in Chinese. It's a simple way to express your appreciation for the person's kindness and generosity.
71 个 token 的贪心生成,每一步都要两边的 argmax 相同,一步不同后面就全部分岔。logits 两边差 3e-5 量级(怎么读这一卷),这三段对话里没有哪一步的前两名近到会被这点差别翻转。这不是保证:换更长的生成、更平的分布,总会遇到前两名只差 1e-5 的一步,那时分岔是正常的,不说明哪一边错了。
停止条件
eos 从哪来
generate 停下来靠的是 generation_config.eos_token_id,它来自模型目录里的 generation_config.json(这里是 2,即 <|im_end|>)。transformers 把它包成一个 EosTokenCriteria,和 MaxLengthCriteria 一起放在停止条件列表里,每步检查一次。
candle 的 LlamaConfig 从 config.json 读 eos_token_id,类型是 LlamaEosToks:
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(untagged)]
pub enum LlamaEosToks {
Single(u32),
Multiple(Vec<u32>),
}
#[serde(untagged)] 让 "eos_token_id": 2 和 "eos_token_id": [128001, 128009] 都能读进来。读进来之后它只是一个字段:Llama::forward 不看它,停不停由你的循环决定。candle 的 llama 例子在取样之后比较,Multiple 就用 contains;config.json 里没有这个字段时,例子退回去从分词器里查 </s> 的 id。SmolLM2 的 config.json 和 generation_config.json 写的都是 2,两边一致。有的模型这两个文件写的不一样(generation_config.json 多列了几个 eos),这时 candle 侧要自己去读 generation_config.json。
两边都把 eos 本身算进输出:generate 返回的序列以 2 结尾,上面的手写循环也是先 push 再 break,所以两边的 id 列表长度相同(19、71)。解码时 skip_special_tokens=True(Rust 是 decode(&ids, true))把它去掉。
不停会怎样
把 eos 检查拿掉(chat 的 noeos 参数),同一个 prompt 生成 40 个 token,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'
<|im_end|> 之后模型没有「结束」的概念,它接着按训练数据的格式往下写:换行、又一个空的 system 轮、然后替用户编了一个新问题。用 skip_special_tokens 解码时特殊 token 消失,打印出来是 晨飞起来,你好?\nsystem\n\nuser\nI am planning a trip to Japan and I need help,看上去像模型「答非所问」,其实是循环没停。
长度上限
generate 不传 max_new_tokens 时(maxlen.py,39 个 token 的 prompt):
UserWarning: Using the model-agnostic default `max_length` (=59) to control the generation length. We recommend setting `max_new_tokens` to control the maximum length of the generation.
39 59 'In Chinese, you can say "六六" (Shiè) to express gratitude'
transformers 5.22.0 的默认是「再生成 20 个」:源码里那一支的注释写着 # by default let's always generate 20 new tokens,于是 max_length 成了 39 + 20 = 59,并给出警告。手写循环没有默认值,for _ in 0..max_new 就是上限;还要注意总长度不能超过 Cache 按 max_position_embeddings 预先算好的 RoPE 表(KV 缓存)。
流式输出
一个汉字拆在几个 token 里
SmolLM2 的分词器是字节级 BPE:先把文本变成 UTF-8 字节,再合并成 token。英文词大多一个 token;一个汉字是 3 个字节,词表里没有收录的字就被拆成 2 到 3 个 token。第一个 prompt 的回答「晨飞起来,你好?」8 个字符用了 18 个 token(再加一个 eos):
| id | 字节 | 单独 decode | 解码到这一个为止 |
|---|---|---|---|
| 177 | e6 | � | � |
| 243 | 99 | � | � |
| 118 | a8 | � | 晨 |
| 180 | e9 | � | 晨� |
| 113 | a3 | � | 晨� |
| 248 | 9e | � | 晨飞 |
| … | |||
| 12831 | ef bc 8c | , | 晨飞起来, |
| 18645 | e4 bd | � | 晨飞起来,� |
| 250 | a0 | � | 晨飞起来,你 |
| … | |||
| 8083 | ef bc | � | 晨飞起来,你好� |
| 249 | 9f | � | 晨飞起来,你好? |
「晨」是 e6 99 a8,三个 token 各一个字节。单独解码一个 token 得到的是半个字符,解码器(两边都是 tokenizers 的 ByteLevel 解码器)把不完整的 UTF-8 换成替换字符 �(U+FFFD)。19 个 token 逐个单独解码,只有「,」那一个是完整字符,其余全是 �:两边的逐个解码结果也逐项相同。
所以流式输出不能「来一个 token 打印一个 token」。两边的办法都是:把累积的 token 整段重新解码,只打印新多出来、并且已经确定不会再变的那一截。区别在「确定」怎么判断。
TextStreamer 和 TokenOutputStream
transformers 的 TextStreamer.put(5.22.0):
self.token_cache.extend(value.tolist())
text = cast(str, self.tokenizer.decode(self.token_cache, **self.decode_kwargs))
# After the symbol for a new line, we flush the cache.
if text.endswith("\n"):
printable_text = text[self.print_len :]
self.token_cache = []
self.print_len = 0
# If the last token is a CJK character, we print the characters.
elif len(text) > 0 and self._is_chinese_char(ord(text[-1])):
printable_text = text[self.print_len :]
self.print_len += len(printable_text)
# Otherwise, prints until the last space char (simple heuristic to avoid printing incomplete words,
# which may change with the subsequent token -- there are probably smarter ways to do this!)
else:
printable_text = text[self.print_len : text.rfind(" ") + 1]
self.print_len += len(printable_text)
candle 的对应物 TokenOutputStream 在仓库的 candle-examples 里,不在 candle-core、candle-nn、candle-transformers 这三个库里,要用就把这 80 来行拷进自己的 crate。next_token:
pub fn next_token(&mut self, token: u32) -> Result<Option<String>> {
let prev_text = if self.tokens.is_empty() {
String::new()
} else {
let tokens = &self.tokens[self.prev_index..self.current_index];
self.decode(tokens)?
};
self.tokens.push(token);
let text = self.decode(&self.tokens[self.prev_index..])?;
if text.len() > prev_text.len() && text.chars().last().unwrap().is_alphanumeric() {
let text = text.split_at(prev_text.len());
self.prev_index = self.current_index;
self.current_index = self.tokens.len();
Ok(Some(text.1.to_string()))
} else {
Ok(None)
}
}
两种判断:
TextStreamer:末尾是换行就全部打印;末尾是 CJK 汉字就打印到末尾;否则只打印到最后一个空格。�不是汉字,也没有空格,所以半个字一直等着。TokenOutputStream:末尾字符is_alphanumeric()才打印。汉字是字母类字符(Unicode 的 Lo 类),�、全角逗号、问号不是,于是半个字和标点都先等着。它只解码prev_index之后的一小段,不像TextStreamer那样每次从一行的开头解码。
is_alphanumeric 之前是 is_asciiTokenOutputStream 来自 #1007(「Streaming mode for reporting the generated tokens」),那时的条件是 text.chars().last().unwrap().is_ascii():末尾是 ASCII 才打印。汉字不是 ASCII,中文回答一路憋着,要等到末尾出现一个 ASCII 字符(英文字母、数字、空格、半角标点)才连同前面所有字一起吐出来。#1668(「Fix token generation in bilingual models (non-English outputs)」)把它改成 is_alphabetic(),同一个提交还在 yi 例子里把 <|im_end|> 换成换行。三周后 #1779(「Add the StarCoder2 model.」)顺手把 is_alphabetic() 改成现在的 is_alphanumeric(),数字结尾也能打印。两个修改提交时工作区版本是 0.4.x,本地仓库能查到的最早包含它们的 tag 是 0.5.1。
同一段回答,两边每次打印的内容(TextStreamer 的空串是「这一步什么也不打印」):
TextStreamer: ["", "", "晨", "", "", "飞", "", "", "起", "", "来", "", "", ",你", "", "好", "", "", "", "?"]
TokenOutputStream: ["晨", "飞", "起", "来", ",你", "好", "?"]
汉字两边都是凑齐一个打印一个;「,」两边都跟着下一个字「你」一起出来;最后的「?」两边都要等到结束时冲刷(end() 和 decode_rest())才出来。英文的那段回答差别就明显了:
TextStreamer: ["", "In ", "", "Chinese, ", "you ", "can ", "say ", "", "\"六", "", "六", "", "\" ", ...]
TokenOutputStream: ["In", " Chinese", ", you", " can", " say", " \"六", "六", "\" (Sh", "i", "è", ") to", ...]
TextStreamer 按空格切,一个词带着后面的空格整个出来;TokenOutputStream 只要末尾是字母就出,Shiè 被拆成 Sh、i、è 三次打印。两种切法拼起来都等于完整的 decode 结果(三段对话都核对过)。
中国的首都是哪里? 生成到 80 个 token 的上限停下,没有遇到 eos。tok.decode(new, skip_special_tokens=True) 的最后一个字符是什么?TextStreamer 和 TokenOutputStream 结束时会不会把它打印出来?动脑筋答案
transformers 的完整解码:
'中国的首都是,但是,中国的首都是,但是,中国的首都是,但是,中国的首都是,但是,中国的首都是,但是,中国的�'
两边结束时最后几次打印:
TextStreamer: [..., "国", "的", "", "�"]
TokenOutputStream: [..., ",中", "国", "的", "�"]
- 最后一个字符是
�。第 80 个 token 是某个汉字的前一两个字节,长度上限在字符中间截断,剩下的字节永远不会来了,解码器只能给出替换字符。candle 的decode(&ids, true)也以�结尾,80 个 id 两边相同。 - 两种流式输出平时都在等这半个字,结束时的冲刷(
TextStreamer.end()、decode_rest())不再判断,把剩下的原样交出来,所以�都会被打印。 - 按长度截断的输出要给用户看,就在截断后把末尾的
�去掉,或者截断时回退到最后一个完整字符。遇到 eos 停下的两段输出,eos 之前的字节都已经凑成完整字符,但这是模型学到的习惯,分词器并不保证。
小结
generate把模板、prefill、decode、停止条件、流式输出放在一个调用里;candle 里这些都是手写循环的几行:forward(&x, pos, &mut cache)、argmax、比 eos、pos += input.len()。- 三段 SmolLM2 对话,19、71、80 个贪心 token 在 CPU 和 Metal 上和 transformers 逐个相同。
- eos 在 transformers 里来自
generation_config.json,在 candle 里是LlamaConfig.eos_token_id(Single或Multiple),forward不看它,循环自己比;不比的话模型会接着写下一轮对话。 generate不传max_new_tokens默认再生成 20 个并给警告;手写循环没有默认值。- 字节级 BPE 把一个汉字拆成 2–3 个 token,逐个解码全是
�。TextStreamer按换行、汉字、空格判断,TokenOutputStream(在candle-examples里,要拷过来用)按末尾字符is_alphanumeric判断;两者拼起来都等于完整解码。 - 按长度截断时最后可能是半个字,两种流式输出都会把
�打印出来。