【langchain实战笔记】3、构建自定义多工具智能体

448 阅读9分钟

背景

大语言模型擅长自然语言的处理,我们很容易基于大语言模型搭建一个简单的对话应用,但是由于大模型背景知识为"静态"的,当面对诸如:"当前北京天气怎么样?","现在美国总统是不是奥巴马?"等这类依赖当前知识的问题,往往得不到令人满意的答案,除非我们给他更多的背景信息,或者赋予它进行知识检索的能力。

human:当前北京天气怎么样
AI:对不起,我无法提供当前的天气信息。你可以通过天气预报网站或应用程序查询北京的实时天气情况。

此外,当我们希望大模型与其他服务或者组件(例如:查询数据库、调用地图工具等)增强交互,或者 执行外部操作(例如:订机票、发送邮件通知等)时,就需要我们将工具集成整合到大模型应用中。

需要注意的是,工具集成的原理是让大语言模型通过决策进行工具选择,生成工具的参数,并执行工具函数的调用,而非大语言模型直接执行某些操作。工具调用的实际相应结果取决于如参和工具函数的定义。接下来让我们创建三个自定义的工具类。

工具创建

创建工具必须声明以下4个属性。

  • name: 工具名称,同一个工具列表中不允许出现同名的工具。
  • description: 描述该工具的功能。供LLM、代理用作上下文,用于决策选择哪个工具。
  • args_schema: 参数说明,可选但推荐使用,如果使用回调处理则必填。可用于提供更多信息和预期参数的验证。
  • return_direct: 是否直接返回,仅与Agent相关。当为 True 时,在调用给定工具后Agent直接返回工具调用结果。

Lanchain提供三种方式创建工具。

(1)、通过@tool装饰器定义工具

通过@tool装饰器定义工具,是最简单的一种实现方式。

from langchain.tools import tool
@tool
def multiply(a: int, b: int) -> int:
    # """用于计算两个数字的乘积"""
    return a * b
tools = [multiply]
# 由于工具实现了Runnable接口,可以直接通过invoke调用
result = multiply.invoke({"a": 2, "b": 3})
print(result)

注意: 装饰器将使用函数的注释作为工具的描述,因此方法必须提供注释说明,否则将报错:

ValueError: Function must have a docstring if description not provided.

我们也可以通过@tool装饰器显式的声明工具的其他属性,比如声明工具名和参数描述等。通过BaseModel来构建args_schema,或者通过嵌套模式构建args_schema,两者效果是一致的。

from langchain.tools import tool
from pydantic import BaseModel, Field

# 基于BaseModel来构建args_schema
class CalculatorInput(BaseModel):
    a: int = Field(description="first number")
    b: int = Field(description="second number")

@tool("multiplication-tool", args_schema=CalculatorInput, return_direct=True)
def multiply(a: int, b: int) -> int:
    """用于计算两个数字的乘积"""
    return a * b

# 嵌套模式构建args_schema
@tool("multiplication-tool", return_direct=True)
def multiply(
        a: Annotated[int, "first number"],
        b: Annotated[int, "second number"],
) -> int:
    """用于计算两个数字的乘积"""
    return a * b

# 打印参数说明
schema = multiply.args_schema.model_json_schema()
print(schema)
# 由于工具实现了Runnable接口,可以直接通过invoke调用
result = multiply.invoke({"a": 2, "b": 3})
print(result)

(2)、通过StructuredTool创建工具

通过StructuredTool.from_function创建工具,相比于@tool装饰器更加灵活,支持更多的参数配置。我们也可以通过Tool.from_function创建工具,区别在于StructuredTool.from_function 支持更多入参,而Tool.from_function 只支持单个入参。否则将报错:

ToolException: Too many arguments to single-input tool multiplication-tool.
                Consider using StructuredTool instead. Args: [2, 3]

因此建议直接通过StructuredTool.from_function创建工具,例如构建一个获取当前时间的工具

from langchain_core.tools import StructuredTool
from datetime import datetime

def get_datetime(
        query: Annotated[str, "任意输入"]
) -> str:
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

calculator = StructuredTool.from_function(
    func=get_datetime,
    name="datetime-tool",
    description="获取当前时间",
    return_direct=True,
)

print(calculator.invoke("今天几月几号"))

这里为了通过调用验证,我们为 get_datetime 增加了一个不需要的入参。当为LLM或者Agent绑定工具时,可以通过无入参的函数创建工具。

(3)、基于BaseTool子类创建工具

这是最灵活的工具定义方式,可以最大程度地控制工具细节,但需要更多代码实现。例如我们创建一个google_search的工具类。Google官方提供了开发者 API 查询接口,权限级资源需要自己申请:

一切准备就绪后,我们开始创建工具。

from typing import Optional, Type

from langchain_core.callbacks import (
    AsyncCallbackManagerForToolRun,
    CallbackManagerForToolRun,
)
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
import requests

class GoogleSearchInput(BaseModel):
    query: str = Field(description="需要检索信息的query")

class GoogleSearchTool(BaseTool):
    name: str = "google_search"
    description: str = "用于检索指定query的相关知识"
    args_schema: Type[BaseModel] = GoogleSearchInput
    return_direct: bool = True

    def _run(
            self, 
            query: str, 
            run_manager: Optional[CallbackManagerForToolRun] = None
    ) -> str:
        url = f"https://www.googleapis.com/customsearch/v1?key={YOU_KEY}&cx={YOU_CX}&q={query}".format(
            query=query)
        response = requests.get(url)
        # 只保留有用信息
        result = []
        for item in response.json()["items"]:
            result.append({"Title": item['title'],"Snippet": item['snippet'],"Link": item['link']})
        return result

    async def _arun(
            self,
            query: str,
            run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
    ) -> str:
        """Use the tool asynchronously."""
        # If the calculation is cheap, you can just delegate to the sync implementation
        # as shown below.
        # If the sync calculation is expensive, you should delete the entire _arun method.
        # LangChain will automatically provide a better implementation that will
        # kick off the task in a thread to make sure it doesn't block other async code.
        return self._run(query, run_manager=run_manager.get_sync())

search_tool = GoogleSearchTool()
search_tool.invoke({"query": "北京今天天气怎么样?"})

上面就基于BaseTool子类成功创建了一个名为google_search的自定义工具,同时实现了同步及异步调用的方法。该工具的调用方式如下:

# 同步调用
search_tool.invoke({"query": "北京今天天气怎么样?"})
# 异步调用
await search_tool.ainvoke({"query": "北京今天天气怎么样?"})

工具调用

LangChain 提供了一个标准化接口,用于将工具连接到模型。使用 bind_tools()方法可以将工具工具列表与大模型绑定。接下来让我们把上面创建的三个工具与大模型进行绑定。

from langchain_openai import ChatOpenAI

tools = [multiply,datetime_tool,search_tool]
model = ChatOpenAI(
   model="gpt-4o",
   temperature=0.0
)

model_with_tools = model.bind_tools(tools)

model_with_tools.invoke("今天几号了?")
model_with_tools.invoke("3*4=?")
model_with_tools.invoke("北京天气怎么样?")
# 此处我们也可以通过tool_calls 只输出调用的工具列表信息。
# model_with_tools.invoke("今天几号了?").tool_calls

第一个问题的结果如下,表明大模型选择调用datetime-tool工具,并生成的一个空入参。

AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_JroDiUGVgoXjbVOCz5uVmMe5', 'function': {'arguments': '{}', 'name': 'datetime-tool'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 100, 'total_tokens': 110, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-05-13', 'system_fingerprint': 'fp_f3927aa00d', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-46830cb9-d4a8-42c6-a868-739c55e039c8-0', tool_calls=[{'name': 'datetime-tool', 'args': {}, 'id': 'call_JroDiUGVgoXjbVOCz5uVmMe5', 'type': 'tool_call'}], usage_metadata={'input_tokens': 100, 'output_tokens': 10, 'total_tokens': 110, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})

第二个问题的结果如下,表明大模型选择调用multiply工具,并生成入参:{"a":3,"b":4}.

AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_2vgzlFLXZls5Qg1vuoCTqjOc', 'function': {'arguments': '{"a":3,"b":4}', 'name': 'multiply'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 17, 'prompt_tokens': 100, 'total_tokens': 117, 'completion_tokens_details': None, 'prompt_tokens_details': None}, 'model_name': 'gpt-4o-2024-05-13', 'system_fingerprint': 'fp_04751d0b65', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-75ca2923-0df5-45be-87e9-aa9e5da2f52a-0', tool_calls=[{'name': 'multiply', 'args': {'a': 3, 'b': 4}, 'id': 'call_2vgzlFLXZls5Qg1vuoCTqjOc', 'type': 'tool_call'}], usage_metadata={'input_tokens': 100, 'output_tokens': 17, 'total_tokens': 117, 'input_token_details': {}, 'output_token_details': {}})

第三个问题的结果如下,表明大模型选择调用google_search工具,并生成入参:北京天气

AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_V85nEXykvZrTajYt0M8aewcE', 'function': {'arguments': '{"query":"北京天气"}', 'name': 'google_search'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 15, 'prompt_tokens': 99, 'total_tokens': 114, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-05-13', 'system_fingerprint': 'fp_f3927aa00d', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-6d874773-7f67-4726-b208-38e4cf2a8183-0', tool_calls=[{'name': 'google_search', 'args': {'query': '北京天气'}, 'id': 'call_V85nEXykvZrTajYt0M8aewcE', 'type': 'tool_call'}], usage_metadata={'input_tokens': 99, 'output_tokens': 15, 'total_tokens': 114, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})

看到这里,细心的你一定会好奇,为什么大模型返回结果的 content 为空呢?

还记得我们前面提到的注意事项吗?

工具集成的原理是让大语言模型通过决策进行工具选择,生成工具的参数,并执行工具函数的调用,而非大语言模型直接执行某些操作

model_with_tools.invoke("北京天气怎么样?") 

上述命令只是让大模型根据问题选择使用哪个工具,并生成入参,真正执行还需要单独调用该工具方法。用法示例如下:

for tool_call in model_with_tools.invoke("今天几号了?").tool_calls:
    selected_tool = {"datetime-tool": datetime_tool, "multiply": multiply,"google_search":search_tool}[tool_call["name"].lower()]
    tool_msg = selected_tool.invoke(tool_call)
    print(tool_msg)

进一步的,我们可以将工具返回结果再送回大模型,生成最终的结果,具体实现会在后面Agent部分详细介绍,敬请期待!!!!

指定工具调用

在上一小节,我们介绍了让大模型决策工具的选择使用,同时langchain 也为我们提供了指定工具的方法。

  • 强制使用指定工具
model_with_tool = llm.bind_tools(tools, tool_choice="google_search")
model_with_tool.invoke("今天天气怎么样?")
  • 强制至少选择一个工具
model_with_tool = llm.bind_tools(tools, tool_choice="any")
model_with_tool.invoke("今天天气怎么样?")

总结

自定义工具的封装是后续搭建自定义Agent的基础,也是扩展大模型服务能力边界的主要途径。如果把大模型比做人的大脑,那工具就是人的手脚,在脑和手脚的配合下,大模型应用就可以自动化实现各种操作。

在开发实践过程中我们发现,要求模型从大量的工具列表中进行选择是有难度的,基于过往建议可以总结以下经验:

  • 功能单一的工具,更容易被模型正确使用。
  • 名称和描述简洁精确的工具,更容易被模型正确使用。

系列文章

【langchain实战笔记】1、构建简单LLM应用
【langchain实战笔记】2、LLM服务限速参数