Rust in Everything
基于 candle 0.11.0

流式输出的中文全是 �

主线位置:推理主线的「带 KV 缓存生成」一步,边生成边把文字打印出来。

版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate),Rust tokenizers 0.22.2

flowchart TB
  t["新 token"] --> d1["单独 decode"]
  d1 --> bad["半个字 变成 �"]
  t --> acc["累积后整段 decode"]
  acc --> chk{"末尾完整吗"}
  chk -->|否| wait["先不打印"]
  chk -->|是| out["打印新增部分"]

实验代码:Python 侧 stream_py.py,candle 侧 crate pitb-exp 的 bin p28_chat(生成)和 p29_stream(三种解码方式,release)。模型是本地的 SmolLM2-135M-Instruct 权重,f32,贪心。平台 M2 Max、macOS 27、rustc 1.99.0。一个汉字怎么拆成几个 token、TextStreamer 和 TokenOutputStream 的判断规则,见生成循环的「流式输出」一节。

PyTorch 的写法

transformers 里流式输出是 generate(..., streamer=TextStreamer(tok))。自己写循环的人则常常这样打印:

# 习惯写法:手写循环里每拿到一个 token 就 decode 打印
print("naive:       ", repr("".join(tok.decode([t], skip_special_tokens=True) for t in new)))
print("TextStreamer:", [c for c in streamer.chunks if c])
print("full decode: ", repr(tok.decode(new, skip_special_tokens=True)))

prompt 是对话模板包着 Translate to Chinese: Good morning.:

naive:        '�����������,������'
TextStreamer: ['晨', '飞', '起', '来', ',你', '好', '?']
full decode:  '晨飞起来,你好?'

(TextStreamer 那一行去掉了「这一步不打印」的空串。)逐个 decode 拼起来,8 个字符只剩一个「,」;同样的 token 交给 TextStreamer 就是完整的字。

搬到 candle 的现象

candle 的手写循环(生成循环)里最顺手的打印方式也是一样的:

let mut naive = String::new();
for &t in &ids {
  naive.push_str(&tok.decode(&[t], true).map_err(|e| anyhow!(e))?);
}
tokens: 19
naive:             "�����������,������"
full decode:       "晨飞起来,你好?"

和 Python 逐字相同:两边是同一个 tokenizer.json、同一种解码器。英文测试时不会发现,英文词大多是一个完整的 token。

原因

SmolLM2 的分词器是字节级 BPE:文本先变成 UTF-8 字节再合并。一个汉字是 3 个字节,词表里没有整字的就被拆成 2 到 3 个 token,「晨」是 e6 99 a8 三个 token 各一个字节。单独解码一个 token 得到的是不完整的 UTF-8,ByteLevel 解码器把它换成替换字符 �(U+FFFD),这一步信息就丢了,后面再拼也拼不回来。逐 token 的字节表见生成循环。

所以流式输出要把累积的 token 整段解码,只打印新多出来、并且末尾完整的那一截。transformers 把这件事做成了 TextStreamer;candle 的对应物 TokenOutputStream 在仓库的 candle-examples crate 里,不在 candle-core、candle-nn、candle-transformers 这三个库里,cargo add 那三个之后用不到它。

改法

两个办法,都要累积 token 再解码。

一是把 TokenOutputStream 的 80 来行拷进自己的 crate(next_token 末尾字符 is_alphanumeric() 才打印)。二是 Rust tokenizers 自带的 DecodeStream:tok.decode_stream(skip_special_tokens),每个 token 调一次 step,末尾不是 � 才返回新增的文字:

let mut stream = TokenOutputStream::new(tok.clone());
for &t in &ids {
  if let Some(s) = stream.next_token(t)? {
    chunks.push(s);
  }
}
if let Some(s) = stream.decode_rest()? {
  chunks.push(s);
}

let mut ds = tok.decode_stream(true);
for &t in &ids {
  if let Some(s) = ds.step(t).map_err(|e| anyhow!(e))? {
    dchunks.push(s);
  }
}
TokenOutputStream: ["晨", "飞", "起", "来", ",你", "好", "?"]
DecodeStream:      ["晨", "飞", "起", "来", ",", "你", "好", "?"]
joined == full: TokenOutputStream true, DecodeStream true
  • TokenOutputStream 和 TextStreamer 的切法这一段正好相同:「,」不是字母类字符,跟着「你」一起出来。
  • DecodeStream 只看末尾是不是 �,「,」单独出来。两种拼起来都等于完整解码。
  • DecodeStream 在 tokenizers 里,不用拷代码。

到长度上限时截在半个字中间的情况(中国的首都是哪里?,80 个 token,没遇到 eos):

TokenOutputStream: [..., ",中", "国", "的", "�"]
DecodeStream:      [..., ",", "中", "国", "的"]
full decode:       "中国的首都是,但是,中国的首都是,但是,中国的首都是,但是,中国的首都是,但是,中国的首都是,但是,中国的�"
joined == full: TokenOutputStream true, DecodeStream false

TokenOutputStream 结束时 decode_rest 不再判断,把那半个字原样交出来,� 被打印;DecodeStream 没有冲刷的方法,最后那半个字一直留在里面不返回,拼起来比完整解码少一个 �。按长度截断的输出,两种都要在结束时自己处理末尾。

怎么检查

  • 流式输出的测试用中文、emoji 这类多字节字符的 prompt,英文测不出来。
  • 断言流式各段拼起来等于 decode(&ids, true)(截断时允许差一个末尾的 �)。
  • 输出里出现 � 先看是不是逐 token 解码;完整解码里也有 �,才是生成本身截断在了字符中间。

小结

  • 逐 token decode 再拼接,字节级 BPE 下中文几乎全是 �;Python 和 Rust 的 tokenizers 结果相同,不是 candle 的问题,是打印方式的问题。
  • transformers 用 TextStreamer;candle 的 TokenOutputStream 在 candle-examples 里,要拷过来;Rust tokenizers 0.22.2 自带 DecodeStream,可以直接用。
  • 两种都是「累积后整段解码、末尾完整才打印」,拼起来等于完整解码;截断在半个字上时 TokenOutputStream 会打出 �,DecodeStream 会吞掉它。

💬 关联讨论

发起讨论
加载中...