Prhub

#49992 [Rust Frontend] Add ordinary-text tokenizer encoding

原始 PR 作者 BugenZhao 合并时间 2026-07-28 15:46 文件变更 10 提交数 1 评论 0 代码增减 +448 / -2

执行摘要

新增普通文本 tokenizer 编码方法

Segment-aware prompt renderers 需要为可信的结构化标记和字面文本提供独立的编码路径。encode_ordinary 即使文本拼写与添加 token 相同,也将其保留在基础 tokenizer 管道上,不触发特殊 token 匹配。

建议深入理解此 PR 的设计思路:通过明确分离 '普通编码' 和 '含特殊 token 编码',为 prompt 结构化提供干净的 tokenization 抽象。值得关注的是对多后端的统一封装方式和测试策略。对于涉及 prompt 定制化的开发者,推荐精读 hf.rs 中 encode_hf_ordinaryencode_fastokens_ordinary 的实现。

讨论亮点

本 PR 未产生实质性的 Review 讨论,仅获得 claude[bot] 的自动评论和 njhill 的批准,表明实现方案共识明确。

实现拆解

  1. 在 Tokenizer trait (rust/src/tokenizer/src/lib.rs) 中声明 encode_ordinary 方法,语义为忽略所有添加、特殊和控制 token。
  2. 对 tiktoken 后端 (rust/src/tokenizer/src/tiktoken.rs) 直接调用底层 tiktoken-rs 或 riptoken 的 encode_ordinary,利用其内置的绕过机制。
  3. 对 HuggingFace tokenizers 后端 (rust/src/tokenizer/src/hf.rs) 实现 encode_hf_ordinary,手动构造 pretokenized 流程:使用空 AddedVocabulary 提取和归一化,然后执行预分词、模型 tokenize 和 post_process,全程不注入 added tokens。
  4. 对 fastokens 后端 (同 hf.rs) 实现 encode_fastokens_ordinary,构建不含 added tokens 的 PreTokenizedString,并针对 fused ByteLevel 场景进行优化分支,提高批量 tokenize 性能。
  5. 为 Tekken 后端 (rust/src/tokenizer/src/tekken.rs) 实现 encode_ordinary,直接调用 self.inner.encode(text, false, false),设置 special_tokens 为 false。同时添加测试工具和集成测试,覆盖 tiktoken、tekken 和 hf 后端的普通编码正确性。
文件 模块 状态 重要度
rust/src/tokenizer/src/hf.rs 分词器 modified 8.93
rust/src/tokenizer/src/tekken.rs 分词器 modified 8.24
rust/src/tokenizer/src/tiktoken.rs 分词器 modified 7.22
rust/src/tokenizer/src/incremental.rs 分词器 modified 6.05
rust/src/tokenizer/src/lib.rs 分词器 modified 6.05
rust/src/tokenizer/src/test_utils.rs 测试工具 modified 5.81
rust/src/chat/src/renderer/inkling/tests.rs 聊天 modified 5.49
rust/src/parser/benches/utils/adapter.rs 基准 modified 5.49
rust/src/text/src/backend/hf/mod.rs 文本后端 modified 5.49
rust/src/parser/src/unified/inkling.rs 解析器 modified 5.83

关键符号

encode_ordinary encode_hf_ordinary encode_fastokens_ordinary fastokens_fused_split fastokens_pre_tokenized_ordinary

关键源码片段

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

核心实现文件,新增 encode_hf_ordinary、fastokens_fused_split、fastokens_pre_tokenized_ordinary、encode_fastokens_ordinary 等函数,是复杂度最高的后端。

/// 使用 HuggingFace tokenizers 后端进行普通编码(绕过所有 added tokens)。
fn encode_hf_ordinary(tokenizer: &HfTokenizer, text: &str) -> tokenizers::Result<Vec<u32>> {
    // 使用空 AddedVocabulary 提取和归一化,不识别任何特殊 token
    let mut pretokenized =
        EMPTY_HF_ADDED_VOCABULARY.extract_and_normalize(tokenizer.get_normalizer(), text);    if let Some(pre_tokenizer) = tokenizer.get_pre_tokenizer() {
        pre_tokenizer.pre_tokenize(&mut pretokenized)?;
    }
    // 直接使用模型 tokenize,绕过 post_process 中的 added tokens 注入
    pretokenized.tokenize(|normalized| tokenizer.get_model().tokenize(normalized.get()))?;
    let encoding = pretokenized.into_encoding(None, 0, OffsetType::Byte)?;
    let encoding = tokenizer.post_process(encoding, None, false)?;
    Ok(encoding.get_ids().to_vec())
}/// 检测 fastokens 中是否包含 fused ByteLevel 的 Split,用于优化路径。
fn fastokens_fused_split(tokenizer: &FastokensTokenizer) -> Option<&FastokensSplitPreTokenizer> {
    let FastokensPreTokenizer::Sequence(steps) = tokenizer.pre_tokenizer()? else {
        return None;
    };
    let [
        FastokensPreTokenizer::Split(split),
        FastokensPreTokenizer::ByteLevel(byte_level),
    ] = steps.as_slice()
    else {
        return None;
    };
    byte_level.is_bulk_only().then_some(split)
}/// 构造不含 added tokens 的 PreTokenizedString。
fn fastokens_pre_tokenized_ordinary(
    tokenizer: &FastokensTokenizer,
    text: &str,
) -> FastokensPreTokenizedString {
    let normalized = tokenizer
        .normalizer()
        .map_or(Cow::Borrowed(text), |normalizer| normalizer.normalize(text));
    match normalized {
        Cow::Borrowed(_) => FastokensPreTokenizedString::from_text(text),
        Cow::Owned(text) => {
            let len = text.len();
            FastokensPreTokenizedString::new(
                text,
                vec![FastokensSplit {
                    range: 0..len,
                    token_id: None,
                }],
            )
        }
    }
}
rust/src/tokenizer/src/tekken.rs core-logic

Tekken 后端的 encode_ordinary 实现及单元测试,验证普通编码会绕过控制 token。

impl Tokenizer for TekkenTokenizer {
    fn encode_ordinary(&self, text: &str) -> Result<Vec<u32>> {
        // 调用底层 Tekken 编码,add_special_tokens = false,同时 bypass 特殊 token 匹配
        self.inner
            .encode(text, false, false)
            .map_err(|error| tokenizer_error!("encoding failed: {error}"))
    }
}#[cfg(test)]
mod tests {
    use base64::Engine as _;
    use tekken::config::TokenizerVersion;
    use tekken::{SpecialTokenInfo, TokenInfo};
    use super::*;    fn test_tokenizer() -> TekkenTokenizer {
        let vocab = (0_u8..=255)
            .map(|byte| TokenInfo {
                rank: byte as usize,
                token_bytes: base64::engine::general_purpose::STANDARD.encode([byte]),
                token_str: None,
            })
            .collect();
        let special_tokens = vec![SpecialTokenInfo {
            rank: 0,
            token_str: "<control>".to_string(),
            is_control: true,
        }];
        let inner = Tekkenizer::new(vocab, &special_tokens, r"(?s).", 257, 1, TokenizerVersion::V3, None)
            .expect("build Tekken tokenizer");
        TekkenTokenizer { inner }
    }    #[test]
    fn ordinary_matches_tekkens_empty_special_encoding() {
        let tokenizer = test_tokenizer();
        let text = "user <control> text";
        let control_id = tokenizer.token_to_id("<control>").unwrap();
        let ordinary_ids = tokenizer.encode_ordinary(text).unwrap();        assert_eq!(control_id, 0);
        assert_eq!(ordinary_ids, tokenizer.encode(text, false).unwrap());
        assert!(!ordinary_ids.contains(&control_id));
        assert_eq!(tokenizer.decode(&ordinary_ids, false).unwrap(), text);
    }
}
rust/src/tokenizer/src/tiktoken.rs core-logic

tiktoken 后端的 encode_ordinary 实现,直接利用底层库的原生方法,并附带测试验证绕过所有 registered added tokens。

impl Tokenizer for TiktokenTokenizer {
    fn encode_ordinary(&self, text: &str) -> Result<Vec<u32>> {
        // 直接委托给底层库的 encode_ordinary 方法,该原语会跳过所有注册的 added tokens 和特殊 tokens
        Ok(match &self.backend {
            Backend::Riptoken(backend) => backend.inner.encode_ordinary(text),
            Backend::TiktokenRs(backend) => backend.inner.encode_ordinary(text),
        })
    }
}#[cfg(test)]
mod tests {
    #[test]
    fn tiktoken_ordinary_bypasses_every_registered_added_token() {
        let dir = tempfile::tempdir().expect("create temp dir");
        let bpe_path = write_synthetic_bpe_file(dir.path());
        fs::write(dir.path().join("tokenizer_config.json"), r#"{
            "added_tokens_decoder": {
                "257": { "content": "<|im_end|>", "special": true },
                "258": { "content": "<|tool_call_begin|>", "special": false }
            }
        }"#).expect("write tokenizer_config.json");
        fs::write(dir.path().join("config.json"), r#"{"vocab_size": 260}"#).expect("write config.json");        let input = "<|im_end|><|tool_call_begin|><|reserved_token_259|>";
        let expected: Vec<u32> = input.as_bytes().iter().copied().map(u32::from).collect();
        for backend in explicit_backends(&bpe_path) {
            assert_eq!(backend.encode("<|im_end|>", false).unwrap(), vec![257]);
            assert_eq!(backend.encode("<|tool_call_begin|>", false).unwrap(), vec![258]);
            assert_eq!(backend.encode("<|reserved_token_259|>", false).unwrap(), vec![259]);
            assert_eq!(backend.encode_ordinary(input).unwrap(), expected);
        }
    }
}

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

主要风险在于 encode_ordinary 方法需要所有 Tokenizer 实现都正确覆盖。当前测试仅覆盖了 tiktoken 和 tekken 后端(通过合成词汇表),未在真实模型上进行端到端验证。HuggingFace 和 fastokens 后端的普通编码实现模拟了完整流程,但可能在某些边界情况(如自定义 normalizer、pre_tokenizer、post_processor)下行为与真实 encode 不一致。此外,部分 mock 实现(如 incremental.rs 中的测试后端)将 encode_ordinary 标记为 unreachable,如果未来代码路径意外调用,会导致 panic。

对系统影响范围限于 Rust 前端 tokenizer 模块。所有 Tokenizer 实现新增 encode_ordinary 方法,但不改变现有 API 行为。下游模块(如 parser、chat renderer)已通过添加存根适配。该功能主要用于 segment-aware prompt renderers,为后续添加结构化 prompt 支持奠定基础。

新增 trait 方法需所有实现适配 测试覆盖有限 HuggingFace 后端边界情况未充分验证 mock 实现使用 unreachable 存在潜在 panic 风险

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论