Prhub

#47707 [Bugfix][Rust Frontend] Detokenizer: avoid leaking prompt on zero-generated-token completions

原始 PR 作者 xiaguan 合并时间 2026-07-16 17:11 文件变更 1 提交数 4 评论 4 代码增减 +34 / -5

执行摘要

修复 Rust 解码器在无生成 token 时泄露 prompt 文本的问题

当模型输出的第一个 token 就是 EOS 且被引擎抑制时,请求已完成但零个生成 token。此时 flush() 被调用,DecodeStreamids 中仍包含全部 prompt token id,而 prefix 为空(因为 seed_prefix() 仅在 push_token() 中被调用),导致整个 prompt 被解码输出。这与 Python V1 路径中 BaseIncrementalDetokenizer.update()new_token_ids 为空时的行为不一致(Python 端直接返回空)。

这是一个小而精的 bugfix,值得精读。缺陷定位准确,修复方案简洁,review 过程中的简化建议体现了良好的代码审美。新增的回归测试覆盖了正常 prompt 和不可种子化的 prompt 尾部两种场景。

讨论亮点

BugenZhao 指出:“Do we really need to seed the prefix here anyway? Can we simply do decode here only when if self.prefix_seeded && !self.ids.is_empty()?” 这一评论直接简化了修复方案——从在 flush() 中先执行 seed_prefix() 再跳过,变为仅依赖 prefix_seeded 标志。xiaguan 采纳该建议,在后续提交中移除了冗余的种子化操作。

实现拆解

  1. 问题定位:分析发现 DecodeStream::flush() 中无条件解码 self.ids(当非空时),但 idspush_token() 未调用时只包含 prompt context,prefix 为空,导致整个 prompt 被输出。

  2. 修复核心:在 flush() 中将解码条件从 !self.ids.is_empty() 改为 self.prefix_seeded && !self.ids.is_empty()。由于 prefix_seeded 只能在 push_token() 中设为 true,若无 token 被推送则条件不满足,跳过解码。同时将 idsprefixprefix_index 的清除操作移出条件块,确保状态始终被重置。

  3. 测试覆盖:新增两个回归测试:

    • flush_without_push_token_does_not_leak_prompt:使用 7001 个 token 的 prompt,验证 flush()full_text 为空字符串。
    • flush_without_push_token_does_not_leak_undecodable_prompt_tail:使用 [0xe4, 0xbd](不完整 UTF-8 序列),验证即使 seed_prefix() 无法建立有效 prefix,flush() 也不会泄漏 prompt 文本。
  4. 重构与简化:最初版本尝试在 flush() 中调用 seed_prefix() 再跳过,经 review 简化为直接检查 prefix_seeded 标志,避免不必要的种子化操作。

文件 模块 状态 重要度
rust/src/tokenizer/src/incremental.rs 分词器 modified 7.27

关键符号

flush

关键源码片段

rust/src/tokenizer/src/incremental.rs core-logic

包含 `DecodeStream::flush()` 方法的核心修复和两个新增的回归测试。

fn flush(&mut self, truncate_output_to: Option<usize>) -> Result<(Option<String>, String)> {
    // 如果 prefix 从未被种子化(即从未调用 push_token),
    // 则 ids 中只包含 prompt context —— 解码它会重复输出 prompt 文本。
    // 仅当 prefix_seeded 且 ids 非空时才执行解码;
    // 否则跳过解码,直接清理状态,返回空文本。
    if self.prefix_seeded && !self.ids.is_empty() {
        let string = self.tokenizer.decode(&self.ids, self.skip_special_tokens)?;
        let prefix_len = self.prefix.len();
        // 确保在 UTF-8 字符边界处切割。
        self.cumulative_output
            .push_str(&string[string.floor_char_boundary(prefix_len)..]);
    }
    self.ids.clear();
    self.prefix.clear();
    self.prefix_index = 0;
    self.prefix_seeded = true;
    if let Some(truncate_output_to) = truncate_output_to {
        self.cumulative_output.truncate(truncate_output_to);
    }
    let last_chunk = (self.output_index < self.cumulative_output.len())
        .then(|| self.cumulative_output[self.output_index..].to_string());
    self.output_index = 0;
    Ok((last_chunk, take(&mut self.cumulative_output)))
}// 回归测试:验证零生成 token 时 flush 不泄漏 prompt
#[test]
fn flush_without_push_token_does_not_leak_prompt() {
    let backend = Utf8Backend;
    let prompt: Vec<u32> = b"The quick brown fox jumps over the lazy dog. "
        .iter()
        .cycle()
        .take(7001)
        .map(|&b| b as u32)
        .collect();
    let mut decoder = backend.create_decode_stream(&prompt, false, 0);
    let (last_chunk, full_text) = decoder.flush(None).unwrap();
    assert_eq!(last_chunk, None);
    assert_eq!(full_text, "");
}// 回归测试:即使 prompt 尾部是不可解码的不完整 UTF-8,也不泄漏
#[test]
fn flush_without_push_token_does_not_leak_undecodable_prompt_tail() {
    let backend = Utf8Backend;
    let prompt = vec![0xe4, 0xbd];
    let mut decoder = backend.create_decode_stream(&prompt, false, 0);
    let (last_chunk, full_text) = decoder.flush(None).unwrap();
    assert_eq!(last_chunk, None);
    assert_eq!(full_text, "");
}

评论区精华

简化 flush 中的种子化逻辑 设计

BugenZhao 建议直接使用 `if self.prefix_seeded && !self.ids.is_empty()` 替代先 `seed_prefix()` 再跳过的方案。

结论:xiaguan 采纳建议,移除了多余的种子化调用,简化了修复。 · 已解决

风险与影响

风险较低。修复仅修改 flush() 方法的控制流:将 idsprefixprefix_index 的清除操作提前到了条件块之外,确保无论是否执行解码,这些字段都会被重置。这不会影响正常调用 push_token() 后的 flush() 行为,因为其调用路径中 prefix_seeded 已为 true,仍会进入解码分支。在零 token 生成的边缘情况下,修复确保只返回空文本,与 Python 端行为一致。

影响面窄,仅作用于 Rust 前端分词器在零生成 token 请求下的行为。对正常(有生成 token)的请求无任何影响。此修复确保了 Rust 前端与 Python V1 前端行为一致,可消除因 prompt 泄漏导致的用户困惑和潜在信息泄露。

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论