Prhub

#6189 [tool] feat: simpler function-based tool registration

原始 PR 作者 Begunner 合并时间 2026-05-08 11:43 文件变更 17 提交数 24 评论 22 代码增减 +1675 / -13

执行摘要

新增 @function_tool 装饰器实现基于函数的工具注册。

PR #6189 旨在提供一种更简洁的注册 verl 工具的方式,避免编写 BaseTool 子类和 YAML 配置文件。开发者只需编写纯 Python 函数并添加 @function_tool 装饰器,即可让 LLM 调用该工具。PR 描述中指出这是与现有 YAML 驱动流程正交的新路径,可独立落地。

值得精读,尤其关注新工具注册的设计模式(正交路径、自动 schema 推断、命名空间隔离)。对扩展 agent 框架的开发者有参考价值。建议结合测试文件理解使用方式。P.S. 作者计划后续将工具加载移至 AgentLoopWorker,可追踪新 PR。

讨论亮点

主要讨论:

  • wuxibin89 要求支持裸装饰器 @function_tool(无括号),已实现。
  • wuxibin89 要求添加 async 函数工具示例,已添加 fetch_url
  • gemini-code-assist[bot] 建议使用 transformers.utils.get_json_schema 替代手写 schema 推断,作者采纳。
  • gemini-code-assist[bot] 建议 Union[int, float] 应映射为 number 类型,已通过 schema 宽松校验解决。
  • wuxibin89 建议将工具加载移至 AgentLoopWorker 避免重复,作者答复会另开 PR 重构。

实现拆解

  1. 新增 verl/tools/utils/function_tool.py,定义 FunctionTool dataclass 和 function_tool 装饰器。装饰器支持裸用(无括号)和带名称,通过 transformers.utils.get_json_schema 自动推断参数 schema,并支持手动提供 schema。FunctionTool 封装了调用接口,同步调用通过 asyncio.to_thread 异步执行,避免阻塞事件循环。

  2. verl/experimental/agent_loop/agent_loop.py 中新增 FunctionToolListWrap dataclass,用于在 Ray Actor 间传递函数工具列表。同时修改 ToolAgentLoop.__init__,接收 function_tools 参数,将其与原生工具合并,并检查名称冲突。

  3. verl/workers/config/rollout.pymulti_turn 配置中新增 function_tool_path 字段,指向用户定义的函数工具文件。AgentLoopWorker 会加载该文件并将工具列表注入 ToolAgentLoop

  4. 配套提供三个测试文件:test_function_tool_on_cpu.py 测试装饰器注册、schema 推断、类型映射(如 int | float 输出 numberLiteral 输出 enum);test_mixed_tools_on_cpu.py 测试原生工具与函数工具的共存加载与名称冲突检测;function_tool_examples.py 提供示例工具集(含同步和异步函数)。

  5. 更新 docs/sglang_multiturn/multiturn.rst 文档,添加函数工具的使用说明和示例。

文件 模块 状态 重要度
verl/tools/utils/function_tool.py 工具模块 added 8.89
tests/tools/test_function_tool_on_cpu.py 单元测试 added 7.76
tests/tools/test_mixed_tools_on_cpu.py 集成测试 added 7.76
verl/experimental/agent_loop/tool_agent_loop.py Agent 循环 modified 7.16
verl/experimental/agent_loop/agent_loop.py Agent 循环 modified 6.71

关键符号

FunctionTool function_tool FunctionTool.call normalize_function_tool_return load_function_tools_from_path _build_schema_from_fn ToolAgentLoop.__init__ ToolAgentLoop._call_tool FunctionToolListWrap.__init__

关键源码片段

verl/tools/utils/function_tool.py core-logic

核心新增文件,定义了 FunctionTool dataclass 和 function_tool 装饰器,实现函数式工具注册、schema 推断和调用分发。

# 版权所有 2025 Bytedance Ltd.
"""轻量级函数式工具注册。"""
from __future__ import annotations
import asyncio, inspect, logging, os, sys
from dataclasses import dataclass
from typing import Any, Callable, Optional
from transformers.utils import get_json_schema
from verl.tools.schemas import OpenAIFunctionToolSchema, ToolResponselogger = logging.getLogger(__file__)
logger.setLevel(os.getenv("VERL_LOGGING_LEVEL", "WARN"))# 全局注册表
FUNCTION_TOOL_REGISTRY: dict[str, FunctionTool] = {}
_LOADED_FUNCTION_TOOL_PATHS: dict[str, list[FunctionTool]] = {}@dataclass
class FunctionTool:
    """Agent 循环依赖的载体对象。"""
    name: str
    fn: Callable[..., Any]
    tool_schema: OpenAIFunctionToolSchema
    is_async: bool = False
​
    async def call(self, parameters: dict[str, Any]) -> Any:
        """调用底层函数,同步函数通过线程池转换。"""
        if self.is_async:
            return await self.fn(**parameters)
        return await asyncio.to_thread(self.fn, **parameters)def function_tool(
    name: Optional[str | Callable] = None, *,
    schema: Optional[OpenAIFunctionToolSchema | dict] = None,
):
    """注册 Python 函数为 verl 工具。支持裸装饰器 @function_tool。"""
    def _make_decorator(tool_name_override: Optional[str]):
        def decorator(fn: Callable):
            tool_name = tool_name_override or fn.__name__
            if isinstance(schema, OpenAIFunctionToolSchema):
                built_schema = schema
            elif isinstance(schema, dict):
                built_schema = OpenAIFunctionToolSchema.model_validate(schema)
            else:
                # 使用 transformers 自动推断 Schema
                built_schema = _build_schema_from_fn(fn, tool_name)
            entry = FunctionTool(
                name=tool_name, fn=fn, tool_schema=built_schema,
                is_async=inspect.iscoroutinefunction(fn),
            )
            # 检查名称冲突
            existing = FUNCTION_TOOL_REGISTRY.get(tool_name)
            if existing is not None and existing.fn is not fn:
                raise ValueError(
                    f"函数工具 '{tool_name}' 已被注册到 "
                    f"{existing.fn.__module__}.{existing.fn.__qualname__}; "
                    f"拒绝覆盖 {fn.__module__}.{fn.__qualname__}."
                )
            FUNCTION_TOOL_REGISTRY[tool_name] = entry
            logger.info("注册函数工具 '%s' 来自 %s.%s", tool_name, fn.__module__, fn.__qualname__)
            return fn
        return decorator
​
    if callable(name) and schema is None: # 裸装饰器
        return _make_decorator(None)(name)
    return _make_decorator(name)# ... (其余辅助函数省略)

评论区精华

使用 transformers.utils.get_json_schema 替代手写 schema 推断 设计

gemini-code-assist[bot] 和 wuxibin89 建议利用 transformers 库提供的 get_json_schema 函数自动推断参数 schema,避免手写解析逻辑。wuxibin89 询问为何不使用该函数。

结论:作者采纳,用 get_json_schema 替换了手动构建 schema 的代码。 · 已解决

支持 @function_tool 裸装饰器(无括号) 设计

wuxibin89 要求支持 `@function_tool` 直接装饰函数,无需括号。

结论:添加了 `if callable(name) and schema is None` 分支,检测到 callable 时自动作为裸装饰器处理。 · 已解决

添加 async 函数工具示例 测试

wuxibin89 要求添加 async 函数工具以测试 `asyncio.iscoroutinefunction` 分支。

结论:在 function_tool_examples.py 中添加了 `fetch_url` 异步函数工具。 · 已解决

将工具加载移至 AgentLoopWorker 避免重复加载 设计

wuxibin89 建议将 load_function_tools_from_path 移入 AgentLoopWorker,确保每个 worker 只加载一次工具文件。作者回应将另开 PR 进行重构。

结论:暂时在 ToolAgentLoop 中加载,待后续 PR 重构。 · 待处理

风险与影响

  1. 命名冲突风险:函数工具与原生工具名称冲突时触发断言,但若用户未测试,可能导致运行时才发现。
  2. Schema 推断依赖:依赖 Google 风格 docstring 和类型注解,格式错误时 get_json_schema 抛出异常,影响启动。
  3. 性能开销:同步工具通过 asyncio.to_thread 调用,在高并发场景下可能有线程切换开销。
  4. 安全风险function_tool_path 动态加载用户 Python 文件并执行,存在代码注入风险,应只用于受信任来源。
  5. 配置复杂度:新增 function_tool_path 配置项,需用户理解与 tool_config_path 的区分,错误配置可能导致工具缺失。
  • 用户侧:工具开发者受益,无需编写 YAML 和 BaseTool 子类,但需遵循 Google 风格 docstring 保证 schema 推断正确。
  • 系统侧:不影响现有 YAML 工具流程,两者可共存。ToolAgentLoop 在初始化时合并两个来源的工具列表,并确保名称唯一。对性能无显著影响,工具加载仅在 worker 启动时执行一次。
  • 团队侧:新增代码约 1675 行,但主要集中在新文件和测试,维护成本可控。
配置复杂度 安全风险 : exec 用户代码 依赖 docstring 格式 命名冲突可能 线程切换开销

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论