[LangChain智能体本质论-06]赋予Agent执行力的工具是个什么东西?

Python3.8

Python3.8

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

在 LangChain框架中,工具是连接大语言模型与外部世界的桥梁。它们赋予了模型“行动”的能力,使其能够超越文本生成的范畴,去执行搜索、计算、运行代码或调用API等实际任务。工具本质上是带有名称 、描述 和Shema的函数。LLM并不直接运行工具(服务端工具除外),而是根据用户的查询,参考工具的描述来决定是否需要调用某个工具,并输出该工具所需的结构化参数。Agent接收到模型生成的参数后,在本地或服务器端执行该工具,并将结果返回给LLM,供其进行后续推理或总结。

create_agent函数的定义可以看出,注册的工具具有三种形式:BaseToolCallable[..., Any]dict[str, Any]。提供的字典表示注册工具的声明,也就是以这种方式注册的工具只包含描述工具的JSON Schema,并不具有执行的能力。由于工具是由Agent负责执行的(服务端工具除外)的,所以当它接收到携带ToolCallAIMessage后,如果待执行的是这种类型的工具,可以利用中间件调用对应的工具,我们将在介绍中间件的时候演示这种实现方式。BaseTool是具体工具类型的基类,以Callable[..., Any]对象形式提供的可执行对象最终也会转换成BaseTool对象。

def create_agent(
    ...
    tools: Sequence[BaseTool | Callable[..., Any] | dict[str, Any]] | None = None,
    ...
) 

1. BaseTool

BaseTool是LangChain中所有工具的基石。它继承自RunnableSerializable,这意味着它不仅是一个可调用的函数,还可以成为LCEL链上的一环。作为一个Runnable对象,它的输入可以是一个ToolCall对象,我们知道模型生成的AIMessage中利用该对象描述工具调用,我们可以从之提取工具名称、参数以及唯一标识工具调用的ID。输入也可以是一个字符串或者字典,它们承载着调用工具的输入参数。

class BaseTool(RunnableSerializable[str | dict | ToolCall, Any]):
    name: str
    description: str
    args_schema: Annotated[ArgsSchema | None, SkipValidation()] = Field(
        default=None, description="The tool schema."
    )
    @property
    def args(self) -> dict
    @property
    def is_single_input(self) -> bool:
    @property
    def tool_call_schema(self) -> ArgsSchema:
ArgsSchema = TypeBaseModel | dict[str, Any]

工具的名称通过name字段表示,它的description字段提供了描述工具的文本。如果将描述理解成可有可无的补充性说明性文本,那就大错特错了。这是最重要的字段之一,因为它作为提示词的一部分传给LLM,指导模型在什么场景下使用该工具。标准的描述应该回答When/Why/How三个问题,即工具在何时被调用?调用工具可以达到什么目的?如何调用?并且最好提供少样本示例。BaseTool提供了几个与Schema相关的字段和属性,很难区分:

  • args_schema:描述参数结构的原始Schema形式,可以是一个Pydantic类型或者描述JSON Schema的字典,它是下面两个属性的源头。
  • args: 描述输出参数JSON Schema的字典。如果args_schema自身就是一个字典,那么两者一致;如果args_schema是一个Pydantic类型,会将每个成员解析为JSON Schema,并生成返回的字典。如果该字典的长度为1,is_single_input属性返回True。
  • tool_call_schema:它与args_schema的表现形式一致(同为表示JSON Schema的字典或者Pydantic类型),但是为LLM生成ToolCall服务的,所以会加上description成员。它是剔除自动注入的参数,因为ToolCall的参数列表只提供外部指定的参数。

如下的字段成员用于控制工具的执行。如果将return_direct字段设为True,工具执行后的结果将直接返回给用户,而不再发回给LLM进行后续的文本生成(常用于“跳转”或“终结”类工具)。handle_tool_error字段决定了如何处理工具执行抛出的ToolException。它可以是布尔值(表示是否再次抛出异常)、字符串(报错时返回给LLM的文本)或用于处理异常的回调函数。handle_validation_error字段的定义方式与之类似,但是它处理的是参数校验失败的情况。

class BaseTool(RunnableSerializable[str | dict | ToolCall, Any]):
    return_direct: bool = False
    handle_tool_error: bool | str | Callable[[ToolException], str] | None = False
    handle_validation_error: (
        bool | str | Callable[[ValidationError | ValidationErrorV1], str] | None
    ) = False
    response_format: Literal["content", "content_and_artifact"] = "content"

response_format决定了工具执行后返回给LLM的数据结构以及是否保留中间过程的原始数据。它目前支持两个选项:

  • content(default):工具执行完毕后,返回值会被转化为字符串,并封装在ToolMessagecontent字段中,并强制视为传给LLM的上下文。如果返回的是一张图片、一个复杂的DataFrame或一个数据库连接对象,这些内容都会被尝试字符串化。这会导致LLM 接收到大量无用(甚至乱码)的文本,下游的程序也会丢失了原始的对象引用。
  • content_and_artifact:这是为了解决“既要给LLM看简报,又要给程序留原始数据”而设计的。在这种模式下,执行工具必须返回一个包含两个元素的元组(content, artifact),分别存储与AIMessagecontentartifact节点。前者通常是字符串,后者可以是任何形式的内容,包括图像、Base64、复杂的 JSON、模型实例等。只有content部分会传递给LLM,所以这样可以降低Token的消费。

如下的字典成员涉及调试和跟踪。verbose用于控制是否打印详细的执行日志。我们可以利用callbacks提供工具在开始、完成和出错时会自动执行的回调。tagsmetadata字段提供的标签和元数据会出现在捕捉的跟踪信息中。

class BaseTool(RunnableSerializable[str | dict | ToolCall, Any]):
    verbose: bool = False
    callbacks: Callbacks = Field(default=None, exclude=True)
    tags: list[str] | None = None
    metadata: dict[str, Any] | None = None

我们最后来看看作为一个Runnable,它的invoke方法是如何实现的。如下面的代码片段所示,invoke方法会直接调用run方法,传入的参数利用私有方法_prep_run_args根据指定的输入和配置生成。BaseTool是一个基类,它将工具调用的实现利用抽象方法_run下放给子类。run方法最终会调用_run方法,并在此基础上完成一些回调执行和异常处理相关的操作。BaseTool也重写了ainvoke方法,并定义了arun_arun形成了一条异步调用链。

class BaseTool(RunnableSerializable[str | dict | ToolCall, Any]):
    @override
    def invoke(
        self,
        input: str | dict | ToolCall,
        config: RunnableConfig | None = None,
        **kwargs: Any,
    ) -> Any:
        tool_input, kwargs = _prep_run_args(input, config, **kwargs)
        return self.run(tool_input, **kwargs)
    
    def run(
        self,
        tool_input: str | dict[str, Any],
        verbose: bool | None = None,  # noqa: FBT001
        start_color: str | None = "green",
        color: str | None = "green",
        callbacks: Callbacks = None,
        *,
        tags: list[str] | None = None,
        metadata: dict[str, Any] | None = None,
        run_name: str | None = None,
        run_id: uuid.UUID | None = None,
        config: RunnableConfig | None = None,
        tool_call_id: str | None = None,
        **kwargs: Any,
    ) -> Any

    @abstractmethod
    def _run(self, *args: Any, **kwargs: Any) -> Any:

2. Tool

Tool类是对BaseTool最简单的实现。它利用funccoroutine字段分别提供了同步和异步可执行对象,实现的_run方法和重写的_arun方法会分别调用它们完成工具的调用。它还提供了类方法from_function作为创建Tool对象的工厂方法。

class Tool(BaseTool):
    description: str = ""
    func: Callable[..., str] | None
    coroutine: Callable[..., Awaitable[str]] | None = None

    @override
    async def ainvoke(
        self,
        input: str | dict | ToolCall,
        config: RunnableConfig | None = None,
        **kwargs: Any,
    ) -> Any:
        if not self.coroutine:
            return await run_in_executor(config, self.invoke, input, config, **kwargs)
        return await super().ainvoke(input, config, **kwargs)

    def _run(
        self,
        *args: Any,
        config: RunnableConfig,
        run_manager: CallbackManagerForToolRun | None = None,
        **kwargs: Any,
    ) -> Any:
        if self.func:
            if run_manager and signature(self.func).parameters.get("callbacks"):
                kwargs["callbacks"] = run_manager.get_child()
            if config_param := _get_runnable_config_param(self.func):
                kwargs[config_param] = config
            return self.func(*args, **kwargs)
        msg = "Tool does not support sync invocation."
        raise NotImplementedError(msg)

    async def _arun(
        self,
        *args: Any,
        config: RunnableConfig,
        run_manager: AsyncCallbackManagerForToolRun | None = None,
        **kwargs: Any,
    ) -> Any:
        if self.coroutine:
            if run_manager and signature(self.coroutine).parameters.get("callbacks"):
                kwargs["callbacks"] = run_manager.get_child()
            if config_param := _get_runnable_config_param(self.coroutine):
                kwargs[config_param] = config
            return await self.coroutine(*args, **kwargs)
        return await super()._arun(
            *args, config=config, run_manager=run_manager, **kwargs
        )

    @classmethod
    def from_function(
        cls,
        func: Callable | None,
        name: str,  # We keep these required to support backwards compatibility
        description: str,
        return_direct: bool = False,  # noqa: FBT001,FBT002
        args_schema: ArgsSchema | None = None,
        coroutine: Callable[..., Awaitable[Any]]
        | None = None,  # This is last for compatibility, but should be after func
        **kwargs: Any,
    ) -> Tool:
        if func is None and coroutine is None:
            msg = "Function and/or coroutine must be provided"
            raise ValueError(msg)
        return cls(
            name=name,
            func=func,
            coroutine=coroutine,
            description=description,
            return_direct=return_direct,
            args_schema=args_schema,
            **kwargs,
        )

在如下的演示程序中,我们根据函数greet创建了一个Tool对象,然后分别以字符串、字典和ToolCall对象作为输入对它进行调用。

from langchain_core.tools import Tool
from langchain_core.messages.tool import tool_call

def greet(name: str) -> str:
    return f"Hello, {name}!"

tool = Tool.from_function(
    func = greet,
    name = "greet",
    description = "Greet a person by name.")

result = tool.invoke("Alice")
assert result == "Hello, Alice!"

result = tool.invoke({"name": "Alice"}) 
assert result == "Hello, Alice!"

result = tool.invoke(tool_call(name="greet", args={"name": "Alice"}, id=None))
assert result == "Hello, Alice!"

Tool这种简单的实现只支持“单输入参数”的函数,一旦输入参数超过两个就会出错。如下这个演示程序试图根据一个Lambda表达式创建一个执行加法运算的Toolinvoke方法执行的时候会抛出ToolException,并提示“Too many arguments to single-input tool add. Consider using StructuredTool instead. Args: [1, 2]”

from langchain_core.tools import Tool
tool = Tool.from_function(
    func = lambda x, y: x + y,
    name = "add",
    description = "Add two numbers together.")
tool.invoke({"x": 1, "y": 2}) 

3. StructuredTool

当我们调用Tool的类方法from_function试图根据一个具有多个参数的函数创建对应Tool对象的时候,抛出的ToolException提示使用StructuredToolStructuredToolTool其实很类似,它们都是利用指定的同步函数和coroutine作为同步和异步“执行体”。除了提供针对多参数的支持外,StructuredTool还可以利用Pydantic模型类型定义输入结构,自动为LLM生成符合规范的参数描述(对应于args_schema字段),并对输入进行实时校验。它还可以直接从指定的函数创建,自动推断参数类型和文档说明。

class StructuredTool(BaseTool):
    description: str = ""
    args_schema: Annotated[ArgsSchema, SkipValidation()] = Field(
        ..., description="The tool schema."
    )
    func: Callable[..., Any] | None = None
    coroutine: Callable[..., Awaitable[Any]] | None = None

    @override
    async def ainvoke(
        self,
        input: str | dict | ToolCall,
        config: RunnableConfig | None = None,
        **kwargs: Any,
    ) -> Any:
        if not self.coroutine:
            return await run_in_executor(config, self.invoke, input, config, **kwargs)
        return await super().ainvoke(input, config, **kwargs)

    def _run(
        self,
        *args: Any,
        config: RunnableConfig,
        run_manager: CallbackManagerForToolRun | None = None,
        **kwargs: Any,
    ) -> Any:
        if self.func:
            if run_manager and signature(self.func).parameters.get("callbacks"):
                kwargs["callbacks"] = run_manager.get_child()
            if config_param := _get_runnable_config_param(self.func):
                kwargs[config_param] = config
            return self.func(*args, **kwargs)
        msg = "StructuredTool does not support sync invocation."
        raise NotImplementedError(msg)

    async def _arun(
        self,
        *args: Any,
        config: RunnableConfig,
        run_manager: AsyncCallbackManagerForToolRun | None = None,
        **kwargs: Any,
    ) -> Any:
        if self.coroutine:
            if run_manager and signature(self.coroutine).parameters.get("callbacks"):
                kwargs["callbacks"] = run_manager.get_child()
            if config_param := _get_runnable_config_param(self.coroutine):
                kwargs[config_param] = config
            return await self.coroutine(*args, **kwargs)
        return await super()._arun(
            *args, config=config, run_manager=run_manager, **kwargs
        )

    @classmethod
    def from_function(
        cls,
        func: Callable | None = None,
        coroutine: Callable[..., Awaitable[Any]] | None = None,
        name: str | None = None,
        description: str | None = None,
        return_direct: bool = False,  
        args_schema: ArgsSchema | None = None,
        infer_schema: bool = True,  
        *,
        response_format: Literal["content", "content_and_artifact"] = "content",
        parse_docstring: bool = False,
        error_on_invalid_docstring: bool = False,
        **kwargs: Any,
    ) -> StructuredTool

它也定义了类方法from_function作为创建StructuredTool对象的工厂方法。在调用此方法的时候,我们可以手工指定表示输入参数Schema的args_schema。如果没有指定,但是infer_schema参数被设置为True(默认为True),则会通过分析函数签名对输入参数Schema进行推断。如果args_schema没有指定、并且infer_schema也被设置为False,此时argstool_call_schema属性对应的Schema将有_run方法的签名进行推断。由于_run方法签名的固定的,推断出来的Schema根本不能体现输入参数的结构,它将使工具将变成一个“参数黑洞”。

对于前面执行失败的演示程序,如果将Tool类型替换成StructuredTool就没问题。

from langchain_core.tools import StructuredTool
tool = StructuredTool.from_function(
    func = lambda x, y: x + y,
    name = "add",
    description = "Add two numbers together.")
assert tool.invoke({"x": 1, "y": 2}) == 3

4. @tool装饰器

如果我们调用create_agent指定的工具是一个函数,它会利用@tool装饰器函数将其转换成一个BaseTool对象。由于BaseTooldescription是通过函数的docstring创建的,鉴于此字段的重要性,如果指定的函数没有定义docstring,转换过程将会失败。我们也可以将这个装饰器显式应用到自定义的函数上,并指定相应的参数对创建的BaseTool作相应的定制。LangChain为@tool装饰器函数定义了很多重载,最终调用的则是如下这个。

def tool(
    name_or_callable: str | Callable | None = None,
    runnable: Runnable | None = None,
    *args: Any,
    description: str | None = None,
    return_direct: bool = False,
    args_schema: ArgsSchema | None = None,
    infer_schema: bool = True,
    response_format: Literal["content", "content_and_artifact"] = "content",
    parse_docstring: bool = False,
    error_on_invalid_docstring: bool = True,
    extras: dict[str, Any] | None = None,
) -> BaseTool | Callable[[Callable | Runnable], BaseTool]:

tool函数的定义可以看出,被转换成BaseTool对象的原始对象不仅可以是一个函数(同步或者异步),还可以是一个Runnable对象。通过相应关键字参数,我们可以指定BaseTool绝大部分的字段。infer_schemaargs_schema这两个参数很重要,如果infer_schema为True或者显式指定了args_schema,最终创建的是一个StructuredTool对象。否则创建的就是一个Tool对象,由于Tool对象支持单参数函数,函数一旦违反这个约定就会报错,所以要么显式指定args_schema,要么保持infer_schema为True,永远使用StructuredTool来表示注册的工具。

您可能感兴趣的与本文相关的镜像

Python3.8

Python3.8

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值