12: 为你的代理构建工具
在第四部分中,我们将构建一个代理(agent)——一种能够弄清楚如何处理用户请求然后去执行的 AI,而不是像聊天机器人那样仅仅对其作出回应。
来看看实际中的区别。假设用户问:“请取消订单 #12345。”聊天机器人会这样说:“前往“我的页面”>“订单历史”,然后点击该订单的‘取消’按钮。”从这里开始,就要靠用户自己去按照这些步骤操作了。而代理则会替你完成这项工作——它会查找订单,检查是否符合取消条件,然后取消它。它会采取行动。
而让这一切成为可能的是工具(tools):一个用于查找订单的工具,一个用于取消订单的工具,一个用于发送邮件的工具。有了工具,LLM 就不再局限于生成文本,而是开始真正地完成任务。
我们将在第四部分中逐步构建这个系统:首先是代理将使用的工具(本章),然后是将这些工具连接到 LLM(第 13 章),最后是循环执行决策 → 行动 → 观察的代理循环(第 14 章)。
本章涵盖第一部分——定义工具、将它们连接到真实数据,以及安全地处理错误。
12.1) 使用 @tool 装饰器定义工具
12.1.1) 工具是如何工作的?
我们刚刚看到一个代理使用工具查找并取消了一个订单。在继续之前,有一点值得澄清:当人们说“LLM 使用工具”时,听起来好像是 LLM 直接调用它。但事实并非如此。LLM 从不自己执行任何东西——它所做的只是请求用特定参数来调用某个工具。实际的执行发生在我们的代码中。
为了让这一切正常运作,LLM 必须知道存在哪些工具,以及每个工具在什么时候适用。因此每个工具都带有三项元数据:
name——一个简短的标识符,比如get_order,LLM 用它来指定想要哪个工具。description——一个描述该工具的功能以及何时使用它的句子。这是 LLM 用来为任务挑选合适工具所读取的内容。- 输入模式(input schema)——工具的参数有哪些:它们的名称、类型和含义。LLM 需要这些信息来正确地填入参数。
这些都不需要你做额外的工作。name 直接来自函数的名称,description 来自它的文档字符串(docstring),而输入模式来自参数的类型提示(type hints)。你所要做的就是附上 LangChain 的 @tool 装饰器。
12.1.2) 构建你的第一个工具
让我们把这付诸实践。编写一个带有类型提示和文档字符串的函数,然后用 @tool 装饰它。
from langchain.tools import tool
@tool
def get_weather(city: str) -> str:
"""获取给定城市的当前天气。"""
return f"It's always sunny in {city}!"我们来看看 @tool 为我们生成了什么。
print(get_weather.name)
# 输出: get_weather
print(get_weather.description)
# 输出: 获取给定城市的当前天气。
print(get_weather.args)
# 输出: {'city': {'title': 'City', 'type': 'string'}}函数名 get_weather 成为了它的 name,文档字符串成为了它的 description,而类型提示 city: str 成为了它的输入模式。上一节中提到的三项元数据全部都被自动生成了。这就是 LLM 用来挑选工具并填入参数的内容。
一旦附上 @tool,这个函数就变成了一个 LangChain 工具对象,这意味着你不能再像普通函数那样调用它了——get_weather("Paris") 是行不通的。你需要改用 .invoke() 来调用它,这与我们在第 6 章中用于链的标准执行方法是一样的。参数以字典的形式传入:
result = get_weather.invoke({"city": "Paris"})
print(result)
# 输出: It's always sunny in Paris!12.1.3) 自定义 name 和 description
默认情况下,name 来自函数名,description 来自文档字符串。你可以覆盖这两者。
将一个名称作为第一个参数传给 @tool:
@tool("web_search")
def search(query: str) -> str:
"""在网络上搜索信息。"""
return f"Results for: {query}"
print(search.name)
# 输出: web_search你也可以使用 description 参数来覆盖描述。当你想把文档字符串保留为给其他开发者看的说明,同时又给 LLM 提供更贴切的内容时,这会很有用:
@tool("calculator", description="执行算术运算。任何数学问题都使用此工具。")
def calc(expression: str) -> str:
"""对一个数学表达式字符串求值。"""
return str(eval(expression)) # 警告: eval() 是不安全的。切勿在生产环境中使用它。工具名称请坚持使用 snake_case——有些 LLM 提供商会拒绝带有空格或特殊字符的名称。
12.1.4) 使用 Pydantic 定义输入模式
当一个工具接受多个参数,或者你想单独描述每一个参数时,可以改用 Pydantic 模型来定义输入模式。这与我们在第 7 章中用于结构化输出的 BaseModel 和 Field 是一样的。
from pydantic import BaseModel, Field
from langchain.tools import tool
class WeatherInput(BaseModel):
"""天气查询的输入。"""
location: str = Field(description="城市名称 (例如, Seoul, Tokyo)")
units: str = Field(default="celsius", description="温度单位 (celsius 或 fahrenheit)")
@tool(args_schema=WeatherInput)
def get_weather_detailed(location: str, units: str = "celsius") -> str:
"""以选定的温度单位获取当前天气。"""
temp = 22 if units == "celsius" else 72
return f"Current weather in {location}: {temp} degrees {units[0].upper()}"你在 Field(description=...) 中写的内容都会成为 LLM 所读取的输入模式的一部分,这样它就能准确知道每个参数的含义。大多数情况下,类型提示和清晰的文档字符串就足够了——只有当你需要为每个参数提供那种额外层次的细节时,才使用 args_schema。
12.2) 处理工具错误
在现实世界中,工具可能会失败——数据库连接中断,或者出现了一个你没有预料到的输入。在本节中,我们将在工具内部处理这些错误,这样代理就能合理地作出响应,而不是直接中断。首先,让我们设置好工具将要依赖的函数。
12.2.1) 准备工作:产品查找函数
# product_service.py
PRODUCTS = {
1: {"name": "Wireless Mouse", "price": 29.99, "stock": 120},
2: {"name": "Mechanical Keyboard", "price": 89.99, "stock": 0},
3: {"name": "USB-C Hub", "price": 45.50, "stock": 35},
4: {"name": "Laptop Stand", "price": 39.00, "stock": 8},
}
def fetch_product(product_id: int) -> dict:
"""通过 ID 查找产品信息。"""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product
def fetch_stock(product_id: int) -> int:
"""返回某个产品的库存数量。"""
product = PRODUCTS.get(product_id)
if product is None:
raise ValueError(f"Product with ID {product_id} not found.")
return product["stock"]当给定一个不存在的产品 ID 时,这两个函数都会抛出 ValueError。
12.2.2) 在工具中处理错误
让我们把 fetch_product 封装到一个 get_product 工具中。
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""通过 ID 查找产品。返回它的名称、价格和库存水平。"""
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."使用有效的 ID 时,它会按预期工作。
print(get_product.invoke({"product_id": 1}))
# 输出: Product 1: Wireless Mouse — $29.99, 120 in stock.但如果传入一个不存在的 ID,fetch_product 就会抛出一个无人捕获的 ValueError——代理的执行会就此停止。
print(get_product.invoke({"product_id": 99}))
# ValueError: Product with ID 99 not found.修复方法很简单:在工具内部捕获异常,并返回一个 LLM 能够理解的字符串,而不是让异常向上传播。无论成功还是失败,工具始终返回一个字符串,而 LLM 会用这个字符串来决定下一步该做什么。
from langchain.tools import tool
from product_service import fetch_product
@tool
def get_product(product_id: int) -> str:
"""通过 ID 查找产品。返回它的名称、价格和库存水平。"""
try:
product = fetch_product(product_id)
return f"Product {product_id}: {product['name']} — ${product['price']:.2f}, {product['stock']} in stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error looking up product {product_id}: {e}"print(get_product.invoke({"product_id": 1}))
# 输出: Product 1: Wireless Mouse — $29.99, 120 in stock.
print(get_product.invoke({"product_id": 99}))
# 输出: Error: Product with ID 99 not found.一个不存在的 ID 不再抛出异常——它会返回一条 LLM 能够理解的错误消息。
让我们把同样的模式应用到 check_stock 上:
from product_service import fetch_stock
@tool
def check_stock(product_id: int) -> str:
"""检查某个产品当前是否有库存。"""
try:
stock = fetch_stock(product_id)
if stock > 0:
return f"{stock} units available."
return "Out of stock."
except ValueError as e:
return f"Error: {e}"
except Exception as e:
return f"Unexpected error checking stock for product {product_id}: {e}"以这种方式构建的工具可以直接用 .invoke() 进行测试。在把工具连接到 LLM 之前,要确保它能独立正确地工作。否则,当之后代理内部出现问题时,你将无法判断到底是工具坏了,还是模型只是做出了一个糟糕的调用。