流式输出的中文全是 �
主线位置:推理主线的「带 KV 缓存生成」一步,边生成边把文字打印出来。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate),Rust
tokenizers0.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里,要拷过来;Rusttokenizers0.22.2 自带DecodeStream,可以直接用。 - 两种都是「累积后整段解码、末尾完整才打印」,拼起来等于完整解码;截断在半个字上时
TokenOutputStream会打出�,DecodeStream会吞掉它。