Prhub

#44682 [Bugfix][Rust Frontend] Tolerate out-of-vocab prompt ids in detokenizer

原始 PR 作者 Sunt-ing 合并时间 2026-07-03 11:41 文件变更 1 提交数 8 评论 6 代码增减 +103 / -10

执行摘要

允许 Rust 前端 detokenizer 处理超词表 prompt id

预标记化的 prompt 可能携带超出 tokenizer 词表但仍在模型词表内的 token id(benchmark/infra 逃逸口)。模型可以正常嵌入这些 id,但 Rust 前端 detokenizer 在 streaming 初始化时使用 prompt ids 作为 left context,若 tokenizer 无法解码这些 id 会导致 DecodeStream prefix seeding 失败并抛出 server_error 异常。该修复确保两前端行为一致。

值得精读:该 PR 展示了一个典型的设计权衡:引擎 embedding 层可以接受任何模型词表内的 id,而 tokenizer 解码器却不能。修复方案选择在 detokenizer 层做容错而非放宽 tokenizer 校验,平衡了严格性与兼容性。decode_prompt_context 的双阶段重试模式值得在类似场景复用。

关注点:codex 建议的错误格式化未解决,可跟进统一。@BugenZhao 的 review 风格(明确要求限定影响范围)值得学习。

讨论亮点

核心设计讨论:作者最初在 Tokenizer::decode 层面放宽校验(commit 1),reviewer BugenZhao 指出应限制在 DecodeStream prompt-context seeding 路径,避免弱化 generated token 的校验(Issue 评论中明确“limits the tolerance to prompt-context seeding in DecodeStream”)。最终方案与此一致。

错误格式化建议:codex review 指出测试中直接使用 error.to_string() 违反 Rust 错误报告惯例(应使用 ToReportString),但该建议未在后续迭代中被采纳或回复。

实现拆解

  1. 新增 decode_prompt_context 方法(incremental.rs):先调用 tokenizer.decode() 尝试正常解码;若失败,则通过 id_to_token 过滤掉无法映射的 id 后重新解码,返回解码文本和过滤后的 id 列表。
  2. 改造 seed_prefix 方法:将原本直接调用 tokenizer.decode() 改为调用 decode_prompt_context;在循环中额外检查过滤后的 id 数量仍不少于 SAFE_SUFFIX_MIN,确保用于 prefix seeding 的上下文足够稳定;全量 fallback 同样使用过滤后的 id 更新 self.ids。
  3. 新增 prefix_seeded 标志:避免在 push_token 中重复调用 seed_prefix(原逻辑依赖 prefix.is_empty(),但过滤后 ids 变化可能导致状态不一致)。
  4. 调整 push_token 入口条件:从 self.prefix.is_empty() 改为 !self.prefix_seeded,与新增标志同步。
  5. 添加 Rust 单元测试:在模块内置测试中增加 prompt_context_filters_unknown_ids、prompt_context_seed_keeps_enough_suffix 等用例,使用 StrictByteBackend 和 TestTokenizer 覆盖过滤与严格路径。
文件 模块 状态 重要度
rust/src/tokenizer/src/incremental.rs 分词器 modified 8.15

关键符号

decode_prompt_context seed_prefix push_token new prompt_context_filters_unknown_ids prompt_context_seed_keeps_enough_suffix generated_unknown_ids_still_return_decode_error

关键源码片段

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

唯一变更文件,包含所有核心逻辑修改和单元测试。新增 `decode_prompt_context` 方法实现过滤重试,改造 `seed_prefix` 和 `push_token`,添加 `prefix_seeded` 状态标志。

/* 关键的 decode_prompt_context 方法 —— 前端容错的核心 */
/// Decode prompt-only context for prefix seeding.
///
/// Prompt ids may come from the model vocabulary rather than the local
/// tokenizer vocabulary. For this seeding path, ids that cannot be mapped
/// back to raw token text are dropped before retrying strict decode. This
/// tolerance is intentionally limited to prompt context; generated ids are
/// decoded later through the normal strict path.
fn decode_prompt_context(&self, ids: &[u32]) -> Result<(String, Vec<u32>)> {
    // 先尝试正常解码,如果成功就直接返回
    match self.tokenizer.decode(ids, self.skip_special_tokens) {
        Ok(decoded) => Ok((decoded, ids.to_vec())),
        Err(error) => {
            // 解码失败时,过滤掉 tokenizer 无法映射的 id
            let filtered = ids
                .iter()
                .copied()
                .filter(|&id| self.tokenizer.id_to_token(id).is_some())
                .collect::<Vec<_>>();
            // 如果过滤前后长度相同,说明所有 id 都无法映射,返回原始错误
            if filtered.len() == ids.len() {
                return Err(error);
            }
            // 用过滤后的 id 子集重新解码,并返回过滤后的列表
            self.tokenizer
                .decode(&filtered, self.skip_special_tokens)
                .map(|decoded| (decoded, filtered))
        }
    }
}
​
/* seed_prefix 中的关键改动:调用 decode_prompt_context 并检查过滤后长度 */
fn seed_prefix(&mut self) -> Result<()> {
    let prompt_len = self.ids.len();
    if prompt_len > SAFE_SUFFIX_MIN {
        let max_try = SAFE_SUFFIX_MAX.min(prompt_len - 1);
        for suffix_len in SAFE_SUFFIX_MIN..=max_try {
            let start = prompt_len - suffix_len;
            // 原版直接 decode,现改为通过 decode_prompt_context
            let (decoded, context_ids) = self.decode_prompt_context(&self.ids[start..])?;
            // 额外检查过滤后 id 数量不少于最小值,确保上下文稳定
            if !decoded.contains('\u{FFFD}') && context_ids.len() >= SAFE_SUFFIX_MIN {
                self.prefix = decoded;
                self.ids = context_ids;
                self.prefix_index = self.ids.len();
                return Ok(());
            }
        }
    }
    // 全量 fallback 同样使用过滤后的 ids
    let (decoded, context_ids) = self.decode_prompt_context(&self.ids)?;
    self.ids = context_ids;
    if !decoded.ends_with('\u{FFFD}') {
        self.prefix = decoded;
        self.prefix_index = self.ids.len();
    }
    Ok(())
}

评论区精华

修复范围界定:是否应放宽 Tokenizer::decode 本身 设计

作者最初在 Tokenizer::decode 层面放宽校验(commit 1),reviewer BugenZhao 建议限制在 DecodeStream 的 prompt-context seeding 路径,避免弱化 generated token 校验。作者随后重写方案。

结论:决定只修改 DecodeStream 的 seed_prefix 路径,保持 Tokenizer::decode 和 generated token 解码严格。 · 已解决

测试中错误格式化违反 Rust 惯例 style

codex review 指出测试中直接使用 error.to_string() 应改为 ToReportString 或 AsReport 以避免违反 Rust 错误报告惯例。

结论:未在后续迭代中回复或修改。可能因测试简单未被要求修复。 · unresolved

风险与影响

回归风险:低。变更局限于 DecodeStream 的 prefix seeding 路径,generated token 解码路径保持不变。过滤操作仅在 id_to_token 返回 None 时才触发,对正常 token ids 无影响。

前缀播种质量:过滤后 context ids 数量可能减少,可能降低前缀播种的准确性(导致更多字节被缓冲而不是立即输出)。但代码通过 context_ids.len() >= SAFE_SUFFIX_MIN 条件保证至少保留 4 个 id 用于后缀试探,且全量 fallback 路径仍会使用过滤后尽可能多的 id。

性能影响:引入一次额外的 id_to_token 迭代和潜在第二次 decode 调用,但仅在解码失败时发生,对正常请求无额外开销。

兼容性:与 Python 前端行为对齐(Python 端已容忍这些 id),无已知兼容问题。

用户影响:之前因超词表 prompt id 导致 500 错误的请求现在能正常得到 completion 结果。影响的是使用预标记化 prompt(如 benchmark 工具)的用户。

系统影响:仅 Rust 前端 detokenizer 模块变更,不涉及调度、模型推理或 schema。代码量小(+103/-10),内联测试覆盖关键路径。

团队影响:修复了一个已知的 frontend 行为不一致问题,降低了 Python/Rust 前端之间的维护摩擦。

单文件变更 核心路径变更 测试覆盖关键路径

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论