Python & AI Tutorials Logo
LangChain & LangGraph

13. 将工具连接到 LLM

在第 12 章中,我们构建了代理(agent)可以使用的工具(tool)。现在是时候将这些工具连接到 LLM,让代理能够真正使用它们了。

无论工具构建得多么完善,如果 LLM 不知道它的存在,就无法使用它。LLM 需要知道有哪些可用工具,并为每个请求挑选合适的工具。然而,正如我们在第 12 章中学到的,LLM 并不会直接执行工具。相反,它会请求"用这些参数调用这个工具",然后由我们的代理代码来调用工具。这整套机制称为工具调用(tool calling)

在本章中,我们将把工具绑定到 LLM、执行工具调用请求,并将结果传回 LLM——实现完整的循环。这个循环是我们将在第 14 章中构建的代理循环的基础。

13.1) 绑定工具并检查工具调用请求

要让 LLM 使用工具,它首先需要知道有哪些可用工具。当我们把每个工具的 namedescription 和输入模式(input schema)传给 LLM 时,它就能学会何时以及如何使用每个工具。这个过程称为工具绑定(tool binding)

工具绑定完成后,LLM 在收到问题时会做两件事中的一件。如果不需要工具,它会像往常一样用文本回应。如果需要工具,它会返回一个结构化的请求:"用这些参数调用这个工具。"然后我们的代理会检查这个请求,并调用指定的工具。

让我们从如何绑定工具开始。

13.1.1) 使用 bind_tools() 绑定工具

工具绑定由一个方法处理:bind_tools()。每个支持工具调用的聊天模型都提供这个方法。调用 bind_tools() 会返回一个绑定了指定工具的新模型对象。

让我们绑定在第 12 章中构建的 get_weather 工具。

python
from langchain_openai import ChatOpenAI
from langchain.tools import tool
 
llm = ChatOpenAI(model="gpt-5-mini")
 
@tool
def get_weather(city: str) -> str:
    """获取指定城市的当前天气。"""
    return f"It's always sunny in {city}!"
 
# 将工具绑定到模型——返回一个绑定了该工具的新模型对象
llm_with_tools = llm.bind_tools([get_weather])

就这么简单。返回的 llm_with_tools 是一个了解 get_weather 工具的模型。注意,原始的 llm 完全没有改变——它仍然对任何工具一无所知。

bind_tools() 接受一个工具列表作为参数,并返回一个绑定了这些工具的新模型对象。在内部,它会将每个工具的元数据(namedescription 和输入模式)转换为 LLM 提供商能够理解的格式,因此工具模式会随着每次模型调用一起发送。

13.1.2) 检查工具调用请求

当我们向绑定了天气工具的模型发送天气问题时,会发生什么?

python
response = llm_with_tools.invoke("What's the weather in Paris?")
 
print(f"type: {type(response)}\n")
print(f"tool_calls: {response.tool_calls}\n")
print(f"content: {repr(response.content)}\n")

输出:

type: <class 'langchain_core.messages.ai.AIMessage'>
 
tool_calls: [{'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': 'call_hgXrHGD', 'type': 'tool_call'}]
 
content: ''

由于 llm_with_tools 仍然是一个聊天模型,它返回一个 AIMessage。但这个响应有两点不同。

首先,response.tool_calls 包含一个工具调用请求列表。每个请求都是一个包含四个键的字典:

  • name——要调用的工具名称。它对应于工具元数据中的 name
  • args——调用工具时要传入的参数。模型查看了输入模式和用户的问题,构造出 {'city': 'Paris'}
  • id——此次调用的唯一标识符。LLM 稍后用它来将工具执行结果与工具调用请求进行匹配。
  • type——始终为 'tool_call'

其次,response.content 是一个空字符串。这个响应不是最终的文本答案——它是一个工具调用请求。

要判断响应是否为工具调用请求,应检查 tool_calls 而不是 content。如果 tool_calls 非空,它就是一个工具调用请求。如果为空,则 LLM 已直接提供了文本答案。

13.2) 执行工具并将结果传回

LLM 已请求调用 get_weather 工具。现在我们的代理代码需要执行该工具,并将结果传回 LLM。

处理一个工具调用请求涉及三个步骤:

  1. tool_calls 中提取工具名称和参数,并执行相应的工具。
  2. 将执行结果转换ToolMessage
  3. 将完整的对话(用户问题 + LLM 的工具调用请求 + 工具执行结果)发送回 LLM 以接收响应。

这是该过程的示意图:

LLMget_weather代理用户LLMget_weather代理用户"巴黎的天气怎么样?"HumanMessageAIMessage (tool_calls: get_weather)get_weather(city='Paris')"It's always sunny in Paris!"ToolMessageAIMessage ("巴黎的天气是晴天!")"巴黎的天气是晴天!"

13.2.1) 执行工具并创建 ToolMessage

首先,让我们从 tool_calls 中提取工具名称和参数,并运行相应的工具。为了通过名称查找工具,我们创建一个以工具名称为键的字典。

python
# 一个用于通过名称查找工具的字典
tool_map = {get_weather.name: get_weather}

接下来,我们从 tool_calls 中取出一个工具调用请求,在 tool_map 中找到匹配的工具,并执行它。

python
if response.tool_calls:
    tool_call = response.tool_calls[0]
    # {'name': 'get_weather', 'args': {'city': 'Paris'}, 'id': 'call_hgXrHGD', 'type': 'tool_call'}
 
    selected_tool = tool_map[tool_call["name"]]
    result = selected_tool.invoke(tool_call["args"])
    print(result)  # 输出: It's always sunny in Paris!

要将工具执行结果传回 LLM,我们需要将其包装在一个 ToolMessage 中。ToolMessage 有两个必需字段:

  • content——以字符串形式表示的工具执行结果。
  • tool_call_id——来自 tool_callsid。LLM 用它来识别结果属于哪个请求。
python
from langchain_core.messages import ToolMessage
 
if response.tool_calls:
    tool_call = response.tool_calls[0]
 
    selected_tool = tool_map[tool_call["name"]]
    result = selected_tool.invoke(tool_call["args"])
 
    tool_message = ToolMessage(
        content=result,
        tool_call_id=tool_call["id"],   # 必须与请求的 id 匹配
    )
    print(tool_message)

输出:

content="It's always sunny in Paris!" tool_call_id='call_hgXrHGD'

在上面的代码中,我们传入 args 来执行工具,然后将结果与 tool_call_id 组合起来构建 ToolMessage

有一种更简单的方法可以一步完成。不要只把 args 传给 .invoke(),而是传入整个 tool_call 字典。LangChain 会执行工具并自动返回一个 ToolMessage

python
if response.tool_calls:
    tool_call = response.tool_calls[0]
 
    selected_tool = tool_map[tool_call["name"]]
    tool_message = selected_tool.invoke(tool_call)
    print(tool_message)

输出:

content="It's always sunny in Paris!" tool_call_id='call_hgXrHGD'

结果相同,但代码简单得多,而且不会出现 tool_call_id 不匹配的情况。后续我们将使用这种方法。

13.2.2) 将工具结果发送给 LLM

现在让我们将工具执行结果传给 LLM。我们将完整的对话历史——用户问题、LLM 的工具调用请求和工具执行结果——发送回 LLM,它会基于工具结果生成答案。

让我们将从头到尾的整个过程整合到一段代码中。

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.tools import tool
 
# 定义工具并绑定
llm = ChatOpenAI(model="gpt-5-mini")
 
@tool
def get_weather(city: str) -> str:
    """获取指定城市的当前天气。"""
    return f"It's always sunny in {city}!"
 
llm_with_tools = llm.bind_tools([get_weather])
tool_map = {get_weather.name: get_weather}
 
# 步骤 1: 发送用户的问题并接收 LLM 的响应
messages = [HumanMessage("What's the weather in Paris?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
 
# 步骤 2: 如果请求了工具调用,则执行工具并添加结果
if ai_msg.tool_calls:
    tool_call = ai_msg.tool_calls[0]
    selected_tool = tool_map[tool_call["name"]]
    tool_message = selected_tool.invoke(tool_call)
    messages.append(tool_message)
 
    # 步骤 3: 将完整的对话历史发送回 LLM
    final_response = llm_with_tools.invoke(messages)
    print(final_response.content)

输出:

巴黎当前的天气是晴天。祝你今天愉快!

LLM 基于工具执行结果生成了最终的文本答案。回到 13.1,content 是一个空字符串——但在将工具结果传回后,我们收到了一个真正的答案。

让我们检查在步骤 3 时 messages 列表中有哪些消息。

python
for m in messages:
    print(f"{type(m).__name__}: {m.content!r}")

输出:

HumanMessage: "What's the weather in Paris?"
AIMessage: ''
ToolMessage: "It's always sunny in Paris!"

三条消息按顺序存储:用户的问题、LLM 的工具调用请求(内容为空)和工具执行结果。LLM 看到了用户的问题,判断它需要一个工具,于是请求调用 get_weather。代理代码执行了 get_weather 并将结果添加到消息列表中。一旦获得了所需的一切,LLM 就生成了最终响应。

13.3) 为 LLM 提供多个工具

现实世界中的代理通常拥有多个工具。我们目前介绍的所有内容在使用多个工具时完全一样。

让我们在 get_weather 之外加入 calculatesearch_web,绑定三个工具。

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.tools import tool
 
llm = ChatOpenAI(model="gpt-5-mini")
 
@tool
def get_weather(city: str) -> str:
    """获取指定城市的当前天气。"""
    return f"It's always sunny in {city}!"
 
@tool
def calculate(expression: str) -> str:
    """计算一个简单的算术表达式,例如 '123 * 456'。"""
    return str(eval(expression))  # 警告: eval() 存在安全风险。请勿在生产环境中使用。
 
@tool
def search_web(query: str) -> str:
    """在网络上搜索某个主题的最新信息。"""
    return f"Top result for '{query}': ..."
 
tools = [get_weather, calculate, search_web]
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}

让我们提一个计算问题。

python
response = llm_with_tools.invoke("What is 123 multiplied by 456?")
print(response.tool_calls)

输出:

[{'name': 'calculate', 'args': {'expression': '123 * 456'}, 'id': 'call_xyz789', 'type': 'tool_call'}]

LLM 请求调用 calculate。它将每个工具的 description 与用户的问题进行了比较,判断出 calculate 是正确的工具。

我们可以使用 13.2 中相同的模式来执行工具并获取最终答案。

python
messages = [HumanMessage("What is 123 multiplied by 456?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
 
if ai_msg.tool_calls:
    for tool_call in ai_msg.tool_calls:
        selected_tool = tool_map[tool_call["name"]]
        tool_message = selected_tool.invoke(tool_call)
        messages.append(tool_message)
 
    final_response = llm_with_tools.invoke(messages)
    print(final_response.content)  # 输出: 123 乘以 456 等于 56,088。

在 13.2 中我们只用 tool_calls[0] 处理了第一个请求,但这里我们用 for 循环遍历了所有请求。原因在于 LLM 可以一次请求多个工具调用。

同时发起多个工具请求

让我们看看当同时请求多个工具时会发生什么。代码是一样的——只是问题改变了。

python
messages = [HumanMessage("What's the weather in Tokyo, and what is 123 * 456?")]
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
 
if ai_msg.tool_calls:
    for tool_call in ai_msg.tool_calls:
        print(f"Tool: {tool_call['name']}")
        selected_tool = tool_map[tool_call["name"]]
        tool_message = selected_tool.invoke(tool_call)
        messages.append(tool_message)
 
    final_response = llm_with_tools.invoke(messages)
    print(final_response.content)

输出:

Tool: get_weather
Tool: calculate
东京当前的天气是晴天,而 123 乘以 456 等于 56,088。

LLM 同时请求了 get_weathercalculate,for 循环执行了 tool_calls 中的所有请求,并将它们的结果添加到消息列表中。然后 LLM 利用这些结果生成了最终答案。