Prhub

#44552 [Rust Frontend] Add seed_oss and step3p5 reasoning parsers

原始 PR 作者 yzhan1 合并时间 2026-06-10 14:01 文件变更 9 提交数 6 评论 7 代码增减 +547 / -4

执行摘要

Rust 前端新增 seed_oss 和 step3p5 推理解析器

为实现 Rust 前端功能对等路线图 (issue #44280) 中的解析器对等项目,增加 seed_oss 和 step3p5 推理解析器,填补了 Rust 前端对 Seed-OSS 和 Step-3.5 模型推理输出解析的空白。

  • 值得精读:特别是 Step3p5ReasoningParser::process 的换行缓存机制和 DelimitedReasoningParserinitialize 设计(如何通过 prompt token 推断初始状态)。
  • 关注点:解析器工厂的模式注册顺序和 default_in_reasoning 语义确定策略,这对于以后添加类似解析器很重要。
讨论亮点
  • 测试组织:BugenZhao 建议将模型特定的解析器测试从通用 tests.rs 移到各自模块文件内(如 seed_oss.rsstep3p5.rs),以提高内聚性。已采纳并实施。
  • 显式开始标签处理方向:BugenZhao 指出初始实现中通过 DelimitedReasoningParser 在运行时跳过显式标记并非正确方向,而应通过 initialize 检测 prompt token 确定初始状态。已根据建议重构,现在 default_in_reasoning 解析器(DeepSeekR1、SeedOSS、Step3p5)的初始状态由 prompt 中是否包含起始/结束 token 决定,不需要在运行时跳过标记。

实现拆解

  1. 新增解析器文件:创建 rust/src/reasoning-parser/src/seed_oss.rsrust/src/reasoning-parser/src/step3p5.rs,分别实现 SeedOssReasoningParserStep3p5ReasoningParser,均基于共享的 DelimitedReasoningParser。SeedOSS 使用 <seed:think>/</seed:think> 标签,通过 initialize 检测 prompt 中的 token 设置初始状态。Step3p5 使用标准 <think>/</think> 标签,同时处理其特有的换行框架(在 </think> 前后去除)并维护 pending_reasoning_newlinejust_ended_reasoning 状态。
  2. 扩展共享解析器:在 rust/src/reasoning-parser/src/delimited.rs 中为 DelimitedReasoningParser 添加 in_reasoning() 公有方法,以便外层解析器获取当前推理状态,用于边界转换判断。
  3. 注册解析器和模式匹配:在 rust/src/chat/src/parser/reasoning/mod.rs 中注册新解析器的构造函数和模型名称模式。关键点在于 step3p5 模式必须排在 step3 之前注册,以避免像 step-3p5 被错误匹配到 step3。同样为 seed-ossseedoss 添加模式。
  4. 测试覆盖:(a) 单元测试:在每个解析器模块内添加初始化边界、显式开始标签、流式分片、部分分隔符、未终止推理等测试。(b) 工厂测试:在 rust/src/chat/src/parser/reasoning/tests.rs 中验证模式路由正确性。(c) 端到端 roundtrip 测试:在 rust/src/chat/tests/roundtrip.rs 中为两个模型添加配置并注册,使用真实 HuggingFace tokenizer 和 chat template 验证完整解析。
文件 模块 状态 重要度
rust/src/reasoning-parser/src/step3p5.rs 推理解析器 added 9.08
rust/src/reasoning-parser/src/seed_oss.rs 推理解析器 added 8.71
rust/src/chat/src/parser/reasoning/mod.rs 解析器工厂 modified 5.32
rust/src/chat/tests/roundtrip.rs 端到端测试 modified 5.21
rust/src/reasoning-parser/src/delimited.rs 推理解析器 modified 4.56
rust/src/chat/src/parser/reasoning/tests.rs 解析器测试 modified 5.94

关键符号

Step3p5ReasoningParser::new Step3p5ReasoningParser::process SeedOssReasoningParser::new DelimitedReasoningParser::in_reasoning ReasoningParserFactory::register_pattern

关键源码片段

rust/src/reasoning-parser/src/step3p5.rs core-logic

新增 Step3p5ReasoningParser,实现复杂的换行框架处理逻辑,是该 PR 的核心变更之一。

/// Step3p5 推理解析器核心处理逻辑:去除 `</think>` 前后的换行框架。
///
/// process 在每次 push 和 finish 时被调用,接收内部 DelimitedReasoningParser
/// 产生的 delta 以及转换前后的状态,负责处理换行缓存和丢弃。
fn process(
    &mut self,
    mut inner_delta: ReasoningDelta,
    was_in_reasoning: bool,
    now_in_reasoning: bool,
) -> ReasoningDelta {
    // 检测是否发生了推理→内容的转换(包括一次 push 内完成整个轮回的情况)
    let transitioned =
        !now_in_reasoning && (was_in_reasoning || inner_delta.reasoning.is_some());    // 如果之前持有拖尾换行,现在需回放或丢弃
    if self.pending_reasoning_newline {
        if let Some(reasoning) = inner_delta.reasoning.as_mut() {
            // 有新的 reasoning 文本:将之前缓存的换行加回到文本前面
            reasoning.insert(0, '\n');
            self.pending_reasoning_newline = false;
        } else if transitioned {
            // 触发转换且无新 reasoning 文本:缓存的换行是 `</think>` 前的那个,丢弃
            self.pending_reasoning_newline = false;
        }
    }    // 如果当前 reasoning 文本以换行结尾,则临时移除并缓存它,
    // 等待下一个 push 再决定是否保留(若之后是 `</think>` 则丢弃)
    if let Some(reasoning) = inner_delta.reasoning.as_mut()
        && reasoning.ends_with('\n')
    {
        reasoning.pop();
        if !transitioned {
            self.pending_reasoning_newline = true;
        }
    }    // 如果紧跟在 `</think>` 之后的内容以换行开头,则去掉前导换行
    if let Some(content) = inner_delta.content.as_mut()
        && (transitioned || self.just_ended_reasoning)
        && content.starts_with('\n')
    {
        content.remove(0);
    }    // 记录当前是否刚结束推理且未产生内容(以便下一个 push 处理前导换行)
    self.just_ended_reasoning = transitioned && inner_delta.content.is_none();    // 移除空字符串,避免外部收到无意义 delta
    if inner_delta.reasoning.as_deref() == Some("") {
        inner_delta.reasoning = None;
    }
    if inner_delta.content.as_deref() == Some("") {
        inner_delta.content = None;
    }    inner_delta
}
rust/src/reasoning-parser/src/seed_oss.rs core-logic

新增 SeedOssReasoningParser,处理 <seed:think>/</seed:think> 标签,是该 PR 的另一个核心新增。

/// SeedOSS 模型的推理解析器,使用 `<seed:think>`/`</seed:think>` 标签。
///
/// 该解析器直接委托给 `DelimitedReasoningParser`,并通过 `initialize`
/// 根据 prompt 中是否包含起始/结束 token 确定初始推理状态,无需运行时跳过标记。
pub struct SeedOssReasoningParser {
    inner: DelimitedReasoningParser,
}impl SeedOssReasoningParser {
    /// 创建内部解析器,指定自定义标签
    pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
        Ok(Self {
            inner: DelimitedReasoningParser::new(
                tokenizer,
                "<seed:think>",
                "</seed:think>",
                false, // default_in_reasoning: false,由 initialize 决定
            )?,
        })
    }
}impl ReasoningParser for SeedOssReasoningParser {
    fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
        self.inner.initialize(prompt_token_ids); // 解析 prompt 中是否包含标记
        Ok(())
    }    fn push(&mut self, delta: &str) -> Result<ReasoningDelta> {
        Ok(self.inner.push(delta)) // 直接委托
    }    fn finish(&mut self) -> Result<ReasoningDelta> {
        Ok(self.inner.finish())
    }
}// 单元测试:验证 initialize 从 prompt token 推断初始状态
#[cfg(test)]
mod tests {
    use std::sync::Arc;
    use super::SeedOssReasoningParser;
    use crate::{ReasoningParser, tests::FakeTokenizer};    #[test]
    fn picks_up_prompt_start_boundary() {
        let tokenizer = Arc::new(FakeTokenizer);
        let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap();
        // Prompt 中预填了 `<seed:think>` (token id 10),所以初始状态应为推理中
        parser.initialize(&[10]).unwrap();        let delta = parser.push("reason</seed:think>answer").unwrap();
        assert_eq!(delta.reasoning.as_deref(), Some("reason"));
        assert_eq!(delta.content.as_deref(), Some("answer"));
    }
}

评论区精华

将模型特定解析器测试移入对应模块 测试

在 review 中,BugenZhao 建议将各解析器的单元测试从 common tests.rs 移入各自的模块文件(如 seed_oss.rs 和 step3p5.rs),以保持模块内聚并便于维护。

结论:已采纳并实施,测试随解析器文件移动。现在每个解析器模块内部包含 #[cfg(test)] 模块。 · 已解决

default_in_reasoning 解析器处理显式开始标记的方式 设计

BugenZhao 指出初始实现中通过 DelimitedReasoningParser 在运行时跳过显式开始标记(如 <think>)并非正确方向,而应通过 initialize 检测 prompt 中是否包含指定 token 来确定初始状态,从而避免运行时跳过。该讨论影响 DeepSeekR1、SeedOSS 和 Step3p5(它们都有 default_in_reasoning = true)。

结论:采纳建议,改用 initialize 方法根据 prompt token IDs 设置初始推理状态,DelimitedReasoningParser 不再需要运行时跳过标记。 · 已解决

风险与影响

  • 模式匹配顺序step3p5 模式必须在 step3 之前注册,否则 step-3p5-instruct 会被误路由到 Step3ReasoningParser。当前通过代码顺序保证,但未来维护时需注意。
  • 影响现有解析器:修改了 DelimitedReasoningParser 的初始化逻辑,影响所有使用该共享解析器的解析器(特别是 DeepSeekR1)。单元测试已覆盖边界情况,但生产环境中的流式行为可能需要更多验证。
  • 新增路径:新解析器主要在流式推理路径中使用,涉及大量状态机逻辑(如 Step3p5 的换行缓存),边缘情况(如极端长的推理内容、特殊 Unicode 换行)需后续观测。
  • 用户影响:Rust 前端现在可以正确解析 Seed-OSS 和 Step-3.5 模型的推理输出,无需 falling back 到 Python 路径,提升响应速度和一致性。
  • 系统影响:仅涉及 Rust 前端推理解析路径,不影响 Python 后端。对现有解析器(DeepSeekR1)有轻微行为调整(初始化方式变化),但预期一致。
  • 团队影响:为 Rust 前端添加新模型推理解析提供了清晰的模式:继承 DelimitedReasoningParser,实现 ReasoningParser trait,注册名称和模式,添加单元测试和 roundtrip 测试。该模式可复用。
模式匹配顺序敏感 影响现有 DeepSeekR1 解析器 新逻辑缺乏生产环境验证

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论