Prhub

#40855 [Bugfix] Remove tokenizer encode/decode calls from Olmo3 reasoning parser

原始 PR 作者 yzong-rh 合并时间 2026-04-28 10:36 文件变更 2 提交数 2 评论 4 代码增减 +42 / -16

执行摘要

移除 Olmo3 推理解析器中的 tokenizer decode 调用

当 HuggingFace 的 Rust tokenizer 后端被请求处理循环中的 tool parser 和 reasoning parser 同时访问,且渲染器中的 AsyncMicrobatchTokenizer 也正在借用时,会触发 RuntimeError: Already borrowed。PR #40059 已移除 tool parser 中的 encode 调用,本 PR 移除了 reasoning parser 中剩余的 decode 调用。

建议合并。修复明确、测试覆盖完整,且与已合入的 PR #40059 形成一致方案。值得关注的设计决策是:通过预计算 token ID 来避免并发 tokenizer 访问,可作为类似并发问题的解决模式。

讨论亮点

gemini-code-assist[bot] 提出了两个高优先级建议:① self.vocab[token] 可能 KeyError,应优雅处理缺失的 token(作者 yzong-rh 回应 "We'll have to raise either way",认为缺失即错误,主动报错合理);② is_reasoning_end 实现复杂度 O(N^2),对长序列有性能风险(作者回应 "This mirrors existing behavior",原 decode 方式也是 O(N) 解码后子串查找,新方法未增加复杂度)。sfeng33 批准了 PR。

实现拆解

  1. 提取类属性为常量(vllm/reasoning/olmo3_reasoning_parser.py)think_startthink_endreasoning_regex__init__ 方法提升为类级别常量,并新增 think_end_first_splitthink_end_rest_split 两个静态列表,用于描述 </think> 在预分词器中的 2 种切分方式(首段可能包含前导空格 Ġ</</,剩余段为 think>)。

  2. 预计算结束 token ID(vllm/reasoning/olmo3_reasoning_parser.py)__init__ 中读取 tokenizer 的 vocab 字典,将上述静态字符串映射为 token ID 列表 think_end_first_token_idsthink_end_rest_token_ids,避免运行时重复解码。

  3. 重写 is_reasoning_end 方法(vllm/reasoning/olmo3_reasoning_parser.py) 删除原来的 text = self.model_tokenizer.decode(input_ids) 和子串检查,改为在 token ID 序列中反向扫描:查找 input_ids[i] 是否属于 think_end_first_token_ids,且后续连续 rest_len 个 token 是否匹配 think_end_rest_token_ids。此方法无需访问 tokenizer,完全避免并发竞争。

  4. 更新测试(tests/reasoning/test_olmo3_reasoning_parser.py) 将 tokenizer 从 allenai/dolma2-tokenizer 更换为实际的 allenai/Olmo-3-7B-Think,并新增 SIMPLE_REASONING_WITH_TRAILING_SPACE 测试用例及其流式测试参数,验证尾部空格场景。

文件 模块 状态 重要度
vllm/reasoning/olmo3_reasoning_parser.py 推理解析器 modified 6.98
tests/reasoning/test_olmo3_reasoning_parser.py 推理解析器 modified 4.35

关键符号

Olmo3ReasoningParser.__init__ Olmo3ReasoningParser.is_reasoning_end

关键源码片段

vllm/reasoning/olmo3_reasoning_parser.py core-logic

核心修复文件:移除 tokenizer.decode 调用,改为 token ID 匹配,消除并发竞争条件

class Olmo3ReasoningParser(ReasoningParser):
    # 类级别常量,无需每次初始化时重新编译正则
    think_start: str = r"<think>"
    think_end: str = r"</think>"
    # </think> 在 Olmo3 pre-tokenizer 中被切分为 3 段:
    # 首段可能是 "Ġ</"(带前导空格)或 "</",剩余段为 "think" 和 ">"
    think_end_first_split: list[str] = [r"Ġ</", r"</"]
    think_end_rest_split: list[str] = [r"think", r">"]
    reasoning_regex: re.Pattern = re.compile(
        rf"^(?:{think_start})?(?P<reasoning>.*?)"
        rf"{think_end}(?P<content>.*)$",
        re.DOTALL,
    )
​
    def __init__(self, tokenizer: "TokenizerLike", *args, **kwargs):
        super().__init__(tokenizer, *args, **kwargs)
        self.buffer = Olmo3ReasoningBuffer(
            think_start=self.think_start, think_end=self.think_end
        )
        # 预计算结束标记的 token ID,后续检测推理结束时无需调用 tokenizer.decode
        self.think_end_first_token_ids: list[int] = [
            self.vocab[token] for token in self.think_end_first_split
        ]
        self.think_end_rest_token_ids: list[int] = [
            self.vocab[token] for token in self.think_end_rest_split
        ]
​
    def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
        # 在 token ID 空间直接匹配 </think> 的拆分片段,避开 tokenizer.decode
        rest_ids = self.think_end_rest_token_ids
        rest_len = len(rest_ids)
        # 反向扫描,寻找首段 + 后续连续匹配
        for i in range(len(input_ids) - rest_len, -1, -1):
            if (
                list(input_ids[i + 1 : i + 1 + rest_len]) == rest_ids
                and input_ids[i] in self.think_end_first_token_ids
            ):
                return True
        return False
tests/reasoning/test_olmo3_reasoning_parser.py test-coverage

测试配套:更换真实 tokenizer 并新增尾部空格流式测试用例

# 新增测试用例:尾部空格场景,验证 is_reasoning_end 的 token 序列匹配能否正确处理
SIMPLE_REASONING_WITH_TRAILING_SPACE = {
    "output": f"{START_REASONING}\nLook!\nI'm thinking... {END_REASONING}\nThis is the rest",
    "reasoning": "\nLook!\nI'm thinking... ",
    "content": "\nThis is the rest",
}# 在流式参数列表中添加新用例
pytest.param(
    True, # enable streaming
    SIMPLE_REASONING_WITH_TRAILING_SPACE,
    id="simple_reasoning_with_trailing_space_streaming",
),# 将 tokenizer 从通用 dolma2 切换为实际模型 tokenizer,确保 vocab 映射一致
tokenizer = AutoTokenizer.from_pretrained("allenai/Olmo-3-7B-Think")

评论区精华

vocab 查找容错性 正确性

gemini-code-assist[bot] 建议对缺失 token 做 graceful handling 避免 KeyError;作者回应缺失即错误,直接抛异常更合理。

结论:维持原实现:token 缺失应直接报错,不静默忽略。 · 已解决

is_reasoning_end 性能复杂度 性能

gemini-code-assist[bot] 指出 O(N^2) 风险;作者回应与原方法复杂度一致。

结论:复杂度持平,接受当前实现。 · 已解决

风险与影响

  1. 回归风险(低):is_reasoning_end 行为从字符串匹配改为 token 序列匹配。若其他模型使用 Olmo3ReasoningParser,但其 tokenizer 的预拆分方式不同可能引入误差。测试已覆盖尾部空格场景。
  2. 性能风险(低):新实现在每次生成 token 时扫描整个输入序列,最坏 O(N^2),但 N 一般为 128-256,且与原方法的 O(N) decode + O(N) 子串查找相比,实际开销相近。
  3. 健壮性风险(低):硬编码的 token 列表若不在 vocab 中会抛出 KeyError,但作者认为这是正确的失败行为。

影响范围:仅针对 Olmo-3 系列模型的推理解析器(Olmo3ReasoningParser)。用户可见影响:修复了并发场景下的 RuntimeError,使 Olmo-3 模型在 DP/tensor-parallel + stream 场景下不再崩溃。对系统:不再与渲染器竞争 tokenizer 借用,提高了并发稳定性。

核心路径变更 并发安全性修复

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论