Prhub

#54884 [Rust Frontend] Use token-attributed text in reasoning and unified parsers

原始 PR 作者 BugenZhao 合并时间 2026-09-04 02:05 文件变更 22 提交数 2 评论 10 代码增减 +1012 / -350

执行摘要

将 token 归因信息贯穿 Rust 前端的 reasoning 与统一解析器管道,为准确统计 reasoning token 数量奠定基础。

为了支持未来精确统计 reasoning token 数量(即 usage.completion_tokens_details.reasoning_tokens),需要在解析器管道中传递 token 归因信息。本 PR 将 DecodedText(包含文本和每 token 的锚点)贯穿到 reasoning 和统一解析器,使得每个发出的 reasoning 片段都携带其由哪些 token 生成的归因信息,同时确保标记(如 ``)和分隔符对应的 token 归因被丢弃,从而不计入最终计数。

本 PR 是一个值得精读的基础设施变更。它精心设计并实现了一个干净、可扩展的管道,用于在解析层传递 token 归因。主要设计决策值得学习:

1) drain_prefix 的“首字节所有权”规则在分割点归属上给出了明确语义;
2) 在 CombinedParser 处划定归因边界是务实的解耦;
3) 新增的归因守恒测试验证了核心不变量。建议关注 attribution.rs 中 drain_prefix 的实现逻辑,以及各解析器迁移的一致性模式。对于 CodeRabbitai 提出的两个边界情况,建议在后续 PR 中补充针对性测试。

讨论亮点

Review 讨论集中在归因边界条件的正确性上,特别是零宽 token 的处理。

  • 核心争议点:CodeRabbitai 和 Codex 指出了两个可能丢失零宽 token 归因的边界情况:a) 在 MiniMaxM3ReasoningParser 中,当零宽 delta 到达后紧跟一个 leading closer(如 </mm:think>),该零宽 token 的归因可能因被缓冲并在后续处理中随标记 span 而被丢弃;b) 在 Step3p5ReasoningParser 中,零宽推理 token 可能被合并到用于处理换行的 pending_reasoning_newline 中,然后在转换时被丢弃。
  • 决策与结论:作者(BugenZhao)未在 PR 评论中直接回应这些具体点,但 PR 被合并,表明作者认为这些边界情况在当前实现中已得到正确处理,或者属于后续 PR 的范畴。这些评论主要关注归因完整性,而非功能回归。
  • 未解决疑虑:上述两个边界情况的测试覆盖是否充分?PR 为 Gemma4 和 Inkling 添加了归因测试,但为 step3p5 和 minimax_m3 添加的测试是否涵盖了这些极端场景?评论建议添加针对这些情况的回归测试,但 PR 中未明确添加。

实现拆解

  1. 为 DecodedText 添加缓冲区操作:在 rust/src/tokenizer/src/incremental/attribution.rs 中新增 is_empty()、take()、clear() 和关键函数 drain_prefix(len)。drain_prefix 实现了“首字节所有权”规则:Visible 锚点的 token 当其首字节在分割点前时归入前缀,ZeroWidth 锚点的 token 当其偏移 <= len 时归入前缀,并重新基准化后缀的锚点。这些方法是解析器缓冲区操作的基础。
  2. 更新解析器 trait 和核心类型:在 rust/src/parser/src/unified/mod.rs 中,将 UnifiedParserEvent::Reasoning 的类型从 String 改为 DecodedText。在 rust/src/parser/src/reasoning/mod.rs 中,将 ReasoningDelta 的 reasoning 和 content 字段类型从 Option<String> 改为 Option<DecodedText>。相应的,ReasoningParser::push 和 UnifiedParser::parse_into trait 方法的参数类型也从 &str 改为 DecodedText。
  3. 迁移所有解析器实现:对 DelimitedReasoningParser、Step3p5ReasoningParser、MiniMaxM3ReasoningParser、CohereCmdReasoningParser、DeepSeekR1ReasoningParser 等 reasoning 解析器,以及 Gemma4UnifiedParser、InklingUnifiedParser、KimiK3UnifiedParser 等统一解析器进行机械式迁移。主要操作包括:a) 将内部缓冲区字段类型从 String 改为 DecodedText;b) 在缓冲追加时使用 self.buffer.append(delta) 替代 push_str;c) 在解析循环中,用 let piece = self.buffer.drain_prefix(consumed_len) 替代 self.buffer.drain(..consumed_len),并将切片操作改为传递整个 piece;d) 更新事件处理函数(如 apply_event)以接收 DecodedText 并直接使用,不再进行切片。
  4. 在 CombinedParser 中划定归因边界:在 rust/src/parser/src/unified/combined.rs 中,CombinedParser 将 reasoning 解析器输出的 DecodedText 类型的 content 传递给工具解析器时,提取其 .text 字段(即丢弃归因),因为工具解析器 trait 仍使用纯文本。
  5. 更新测试:更新现有测试以适配新接口(如使用 DecodedText::unattributed),并新增针对归因守恒的测试。在 rust/src/tokenizer/src/incremental/attribution/tests.rs 中新增 10 个 drain_prefix 单元测试。在 rust/src/parser/src/reasoning/tests.rs 中,为 delimited 推理解析器家族(7 种构造函数)、CombinedParser、Gemma4 和 Inkling 新增归因守恒测试,验证标记 token 的归因被正确丢弃。
文件 模块 状态 重要度
rust/src/tokenizer/src/incremental/attribution.rs 分词器 modified 8.33
rust/src/parser/src/unified/mod.rs 解析器 modified 8.27
rust/src/parser/src/reasoning/tests.rs 推理解析器 modified 8.65

关键符号

drain_prefix push_reasoning parse_into apply_event append

关键源码片段

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

为 `DecodedText` 添加了 `drain_prefix` 等关键缓冲区操作方法,是归因传递的基础。

// DecodedText 缓冲区操作,支持带归因的分割。
impl DecodedText {
    // 当文本和归因都为空时返回 true。
    pub fn is_empty(&self) -> bool {
        self.text.is_empty() && self.attributions.is_empty()
    }
​
    // 取出所有内容,留下空结构。
    pub fn take(&mut self) -> Self {
        take(self)
    }
​
    // 清空文本和归因,保留分配。
    pub fn clear(&mut self) {
        self.text.clear();
        self.attributions.clear();
    }
​
    // 分割并返回前缀 [0, len);后缀保留在 self 中,
    // 其 token 锚点重新基准化为 -len。
    //
    // 锚点遵循首字节所有权规则:一个 `Visible` token 当其
    // 首字节位于前缀内时移至前缀,因此跨越分割点的 token
    // 属于持有其首字节的区间。一个 `ZeroWidth` token 当其
    // 偏移量 <= len 时移至前缀,这与解码器的 `take_ready`
    // 截断规则匹配,允许 `drain_prefix(text.len())` 取走
    // 尾部零宽 token。不同锚点种类的相对顺序得以保留。
    //
    // 如果 len 不是字符边界,则通过 `String::drain` panic。
    #[must_use]
    pub fn drain_prefix(&mut self, len: usize) -> DecodedText {
        let text: String = self.text.drain(..len).collect();
        let len = offset_as_u32(len);
​
        let mut prefix = DecodedText {
            text,
            attributions: SmallVec::new(),
        };
        for attribution in take(&mut self.attributions) {
            let in_prefix = match attribution.anchor {
                TokenAnchor::Visible { byte_offset } => byte_offset < len,
                TokenAnchor::ZeroWidth { byte_offset } => byte_offset <= len,
            };
            if in_prefix {
                prefix.attributions.push(attribution);
            } else {
                self.attributions.push(TokenAttribution {
                    token_id: attribution.token_id,
                    anchor: attribution.anchor.offset_by(-i64::from(len)),
                });
            }
        }
        prefix
    }
​
    // 追加另一个解码片段,重新基准化其 token 锚点。
    pub fn append(&mut self, other: Self) {
        if self.is_empty() {
            *self = other;
            return;
        }
​
        let byte_offset = offset_as_u32(self.text.len());
        let other_len = offset_as_u32(other.text.len());
        let _combined_len = byte_offset.checked_add(other_len).expect("decoded text exceeds 4 GiB");
​
        self.text.push_str(&other.text);
        self.attributions.extend(other.attributions.into_iter().map(|attribution| {
            TokenAttribution {
                token_id: attribution.token_id,
                anchor: attribution.anchor.offset_by(i64::from(byte_offset)),
            }
        }));
    }
}
rust/src/parser/src/unified/mod.rs data-contract

定义了 `UnifiedParserEvent::Reasoning(DecodedText)`,并更新了 `UnifiedParserOutput` 和 `UnifiedParser` trait,是归因传递的管道枢纽。

/// 一个由统一解析器发出的事件。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnifiedParserEvent {
    /// 常规助手可见文本。
    Text(String),
    /// 从正常内容流中隐藏的推理文本。
    ///
    /// 携带生成它的 token 的归因,以便下游消费者可以精确统计推理 token 数。
    /// 标记 span 由解析器丢弃,从而将这些标记 token 排除在任何计数之外。
    Reasoning(DecodedText),
    /// 从可见助手文本中提取的工具调用更新。
    ToolCall(ToolCallDelta),
}
​
// ... (UnifiedParserOutput 的其他方法不变 )
​
impl UnifiedParserOutput {
    // 如果 delta 非空,则附加一个推理文本事件。
    ///
    /// 一个仅包含零宽 token 的片段(空文本但非空归因)仍然被保留:
    /// 这些 token 计为推理 token。
    pub fn push_reasoning(&mut self, delta: DecodedText) {
        if delta.is_empty() {
            return;
        }
        if let Some(UnifiedParserEvent::Reasoning(last_text)) = self.events.last_mut() {
            last_text.append(delta);
            return;
        }
        self.events.push(UnifiedParserEvent::Reasoning(delta));
    }
}
​
// ... (UnifiedParser trait 的其他方法不变 )
​
/// Incremental parser that extracts reasoning and tool-call events from assistant output.
pub trait UnifiedParser: Send {
    // ... ( 其他方法签名不变 )
​
    /// 将一个解码文本增量输入解析器,将已提交的输出追加到 output。
    fn parse_into(&mut self, delta: DecodedText, output: &mut UnifiedParserOutput) -> Result<()>;
}

评论区精华

零宽 token 在前缀分割点的归属规则 正确性

CodeRabbitai 指出 `drain_prefix` 中 `ZeroWidth` 锚点使用 `<= len` 规则可能导致位于标记 span 边界(如 marker.len())的零宽 token 被错误归入标记 span 并被丢弃。这可能影响 completion token 归因的完整性。

结论:规则 `byte_offset <= len` 被明确设计为与解码器的 `take_ready` 截断规则匹配,以确保 `drain_prefix(text.len())` 能取走尾部零宽 token。作者在合并 PR 时未修改此规则,表明设计意图如此。潜在边界情况可能需要在后续 PR 中通过测试进一步验证。 · 已决定(设计选择)

MiniMaxM3 解析器中 leading closer 对零宽 delta 的处理 正确性

CodeRabbitai 指出在 `MiniMaxM3ReasoningParser::push_inner` 中,如果零宽 delta 到达后紧跟 `</mm:think>`,该零宽 token 可能被缓冲并在后续处理中随标记 span 而被丢弃,导致归因丢失。

结论:该边界情况的测试覆盖在本次 PR 中未明确添加。PR 已合并,但此疑虑点明了归因完整性需要在所有解析器路径中得到保障。 · 已识别但未解决

Step3p5 解析器中零宽推理 token 与换行符处理的交互 正确性

CodeRabbitai 指出在 `Step3p5ReasoningParser` 中,零宽推理 token 可能被合并到 `pending_reasoning_newline` 中,然后在转换时被丢弃。

结论:类似地,此边界情况的回归测试建议未被采纳。这凸显了在复杂解析器状态机中维护归因完整性的挑战。 · 已识别但未解决

风险与影响

  1. 正确性风险:DecodedText 的 drain_prefix 实现是新代码,其“首字节所有权”规则(特别是 ZeroWidth 的 <= len 规则)需要确保与解码器的 take_ready 截断规则一致。错误的归属规则可能导致 token 计数不准确。虽然已有单元测试,但复杂解析器路径中的集成测试覆盖是否足够是潜在风险。
  2. 回归风险:本次变更是大规模的接口重构,涉及所有推理和统一解析器。虽然 PR 声称“No emitted text changes”,但每个解析器的迁移都是机械式替换,错误(如切片偏移计算错误)可能导致解析行为改变。不过,现有测试通过以及新增的归因守恒测试降低了此风险。
  3. 性能风险:DecodedText 相比 String 增加了 attributions 向量,可能增加少量内存开销和复制成本。在热路径(如 parse_into 循环)中,频繁调用 drain_prefix 和 append 涉及向量操作,但考虑到文本 chunk 通常较小,影响应有限。未进行性能基准测试,但这是功能优先的管道搭建。
  4. 兼容性风险:这是一个内部接口变更,不影响公开 API。工具解析器(ToolParser)的接口未变,仍在 CombinedParser 边界处被转换为纯文本,确保向后兼容。

用户影响:直接用户不可见。这是为未来 reasoning_tokens 计数功能做的内部铺垫。一旦后续 PR 完成计数和上报,用户将能获得更精确的 token 使用统计,尤其是在使用推理功能时。
系统影响:变更范围广但深度浅,主要影响 Rust 前端的解析器模块。由于归因信息在解析器边界(CombinedParser)被丢弃,下游的工具解析器和聊天模块仅需进行最小化适配(提取 .text),未改变其行为。系统整体稳定性不受影响。
团队影响:展示了 Rust 前端解析器管道的可扩展性设计,通过传递归因结构体实现了可追溯性。为后续类似需求(如其他 token 级统计)提供了模式。

归因边界规则复杂性 缺少特定边界情况测试

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论