Prhub

#40059 [BUG]: fix HF tokenizer concurrent borrow in tool parsers

原始 PR 作者 yzong-rh 合并时间 2026-04-24 09:20 文件变更 3 提交数 5 评论 21 代码增减 +37 / -38

执行摘要

替换 tokenizer.encode/decode 为 vocab 查找修复并发借用

关联 Issue #34932 报告了在使用 Hermes tool parser 时,并发请求导致约 1% 请求返回 HTTP 500,错误为 RuntimeError: Already borrowed。根因是 tool parser 的 __init__ 中调用 tokenizer.encode()tokenizer.decode() 操作共享的 HuggingFace 快速 tokenizer,其 Rust 后端通过 PyO3 的 RefCell 实现,不支持并发可变借用。

值得精读。展示了如何通过消除共享可变状态而非加锁来解决并发问题,方法简洁高效。关注的重点:利用 tokenizer 内部已缓存的 vocabl(线程安全)替代 encode 调用,这是典型的“移走而非保护”策略。

讨论亮点

核心建议:Reviewer sfeng33 指出:"For the tool parsers that use vocab lookup, they are actually safe because get_vocab() is cached on the CachedTokenizer, there is no concurrency risk. The problematic parsers are the ones that use tokenizer.encode/decode - LlamaToolParser, FunctionGemmaToolParser, a minimal fix is to replace the encode/decode on these parsers to use vocab lookup as well."

设计取舍:作者 yzong-rh 回应 AI 审查关于加锁的建议:"specialize is not meant to be thread-safe. This PR only make already specialized ToolParser class thread-safe." 最终采用无锁方案,因为 vocabl lookup 本身线程安全。

最终批准:sfeng33 在第二次 review 时批准(APPROVED),未再提出异议。

实现拆解

  1. 去除 tokenizer.encode/decode 调用:在 vllm/tool_parsers/functiongemma_tool_parser.pyvllm/tool_parsers/llama_tool_parser.py 中,将原本在 __init__ 中动态计算 token ID 的逻辑(如 tokenizer.encode(bot_token))移除,将静态属性(token 字符串、正则表达式)提升为类变量。
  2. 使用 vocabl lookup:在 Llama3JsonToolParser__init__ 中,通过 self.vocab.get(self.bot_token) 获取 bot_token_id,该 vocab 是基类 ToolParser 的类变量(来自 tokenizer.get_vocab(),已由 CachedTokenizer 缓存且线程安全)。若找不到则抛异常。
  3. 清理冗余代码FunctionGemmaToolParser 原本还在 __init__ 中动态设置 tool_call_start_token_ids 等实例属性,现全部移除,因为这些 token 仅用于辅助解析,不再需要预计算。
  4. 更新测试tests/tool_parsers/test_llama3_json_tool_parser.py 从使用 MagicMock 改为使用真实的预训练 tokenizer(meta-llama/Llama-3.2-1B-Instruct),确保 vocab 可用,测试不再依赖 TokenizerLike 类型。
文件 模块 状态 重要度
vllm/tool_parsers/functiongemma_tool_parser.py 工具解析器 modified 6.57
vllm/tool_parsers/llama_tool_parser.py 工具解析器 modified 6.45
tests/tool_parsers/test_llama3_json_tool_parser.py 工具解析器测试 modified 5.63

关键符号

Llama3JsonToolParser.__init__ FunctionGemmaToolParser.__init__

关键源码片段

vllm/tool_parsers/functiongemma_tool_parser.py core-logic

核心变更文件之一,将 token 字符串和正则表达式提升为类变量,移除 __init__ 中 encode/decode 调用。

class FunctionGemmaToolParser(ToolParser):
    # 将原本在 __init__ 中动态计算的 token 和正则表达式提升为类变量
    # 这些值固定不变,不需要每次实例化时重新计算
    tool_call_start_token: str = "<start_function_call>"
    tool_call_end_token: str = "<end_function_call>"
    tool_call_regex: re.Pattern = re.compile(
        r"<start_function_call>call:(\w+)\{(.*?)\}<end_function_call>"
        r"|<start_function_call>call:(\w+)\{(.*)",
        re.DOTALL,
    )
    arg_regex: re.Pattern = re.compile(
        r"(\w+):<escape>(.*?)<escape>", re.DOTALL)
​
    def __init__(self, tokenizer, tools=None):
        super().__init__(tokenizer, tools)
        # 仅保留流式状态
        self.current_tool_name_sent = False
        self.prev_tool_call_arr = []
        self.current_tool_id = -1
        self.streamed_args_for_tool = []
        self.buffered_delta_text = ""
        # 不再调用 tokenizer.encode(),避免 Rust 后端竞争
vllm/tool_parsers/llama_tool_parser.py core-logic

核心变更文件之一,将 bot_token 等提升为类变量,使用 vocabl lookup 替代 encode 调用。

class Llama3JsonToolParser(ToolParser):
    bot_token: str = "<|python_tag|>" # 类变量,不再在 __init__ 中赋值
    tool_call_start_regex: re.Pattern = re.compile(r"\{")
    json_decoder: json.JSONDecoder = json.JSONDecoder()
​
    def __init__(self, tokenizer, tools=None):
        super().__init__(tokenizer, tools)
        # … 流式状态初始化 …
        # 替换原来的 tokenizer.encode(bot_token)[0]
        self.bot_token_id = self.vocab.get(self.bot_token)
        if self.bot_token_id is None:
            raise RuntimeError(
                f"Llama3JsonToolParser could not locate the bot token "
                f"'{self.bot_token}' in the tokenizer."
            )
        # 不再有 self.bot_token = "<|python_tag|>" 等实例属性

评论区精华

是否使用锁保护 vocabl 访问 设计

gemini-code-assist 建议在 maybe_specialize 和 __init__ 中加入 threading 锁以保护 specialize 调用,防止并发借用。作者 yzong-rh 回应 "specialize is not meant to be thread-safe." 认为该函数只用于单元测试,不用于生产并发路径。

结论:无需加锁。vocabl 访问本身线程安全(已缓存),specialize 仅由测试调用,不参与请求处理。 · 已解决

最小修复方案:用 vocabl 替换 encode/decode 设计

sfeng33 指出 "The problematic parsers are the ones that use tokenizer.encode/decode - LlamaToolParser, FunctionGemmaToolParser, a minimal fix is to replace the encode/decode on these parsers to use vocab lookup as well." 该建议被采纳,成为最终修复方案。

结论:采用 vocabl 查找替换 encode/decode 调用,避免 Rust 后端竞争。 · 已解决

风险与影响

低风险。vocab 查找只读且已缓存,不会触发 Rust 后端可变借用,完全规避并发竞争。但需确保所有 tool parser 的 encode/decode 调用均被替换。本 PR 只修改了 Llama 和 FunctionGemma 两个 parser,而其他 parser(如 Hermes、Mistral、Jamba)是否也存在类似调用?从 PR 上下文看,这些 parser 可能已在之前或通过其他方式修复(issue 评论区提到多个 PR 尝试),但本 PR 未覆盖,需后续确认。此外,测试改为使用真实模型 tokenizer,增加了对网络和模型文件的依赖,但测试 fixture 设为 scope='module',避免重复下载。

用户影响:消除并发场景下 tool-calling 请求的 HTTP 500 错误(~1% 概率),提升可靠性。系统影响:无性能回归,vocab 查找比 encode 更快。团队影响:为后续 tool parser 开发提供了安全范例——避免在请求路径中调用 tokenizer 的 encode/decode。

并发路径修复 测试依赖外部模型

关联 Issue

#34932 RuntimeError: Already borrowed in Hermes tool parser under concurrent load

完整报告

参与讨论