Rust in Everything
基于 candle 0.11.0

生成循环

主线位置:推理主线的「带 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上限停在CPUMetal
Translate to Chinese: Good morning.64eos,共 19 个 token19/1919/19
How do you say thank you in Chinese?200eos,共 71 个 token71/7171/71
中国的首都是哪里?80长度上限80/8080/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解码到这一个为止
177e6��
24399��
118a8�晨
180e9�晨�
113a3�晨�
2489e�晨飞
…
12831ef bc 8c,晨飞起来,
18645e4 bd�晨飞起来,�
250a0�晨飞起来,你
…
8083ef bc�晨飞起来,你好�
2499f�晨飞起来,你好?

「晨」是 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 那样每次从一行的开头解码。
📝NOTE
冷知识:is_alphanumeric 之前是 is_ascii

TokenOutputStream 来自 #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 结果(三段对话都核对过)。

❗IMPORTANT
动脑筋:第三个 prompt 中国的首都是哪里? 生成到 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 判断;两者拼起来都等于完整解码。
  • 按长度截断时最后可能是半个字,两种流式输出都会把 � 打印出来。

💬 关联讨论

发起讨论
加载中...