一、为什么要学 Tools?

先想一个问题:大模型本质上只能"生成文字"。它上知天文下知地理,但它不能真正做事情——不能查实时天气、不能查数据库、不能发邮件、不能下单。

工具(Tools)就是给大模型装上"手脚",让它从认识世界走向改变世界。

在 LangChain 中,工具是一个非常明确的概念:

工具 = 一个定义了输入和输出的可调用函数。

所以"工具调用(Tool Calling)"也常被叫做"函数调用(Function Calling)"。大模型会根据你和它的对话,自己决定"要不要调用某个函数、传什么参数"。

一句话总结:工具是构建智能体(Agent)的核心要素之一。


二、工具的两种调用方式

方式 1:直接调用(适合测试)

我们可以把一个普通函数用 @tool 装饰,然后像普通函数那样 .invoke() 它。

from langchain_core.tools import tool
​
@tool
def get_weather(city: str) -> str:
    """
    获取指定城市的天气信息
​
    参数:
        city: 城市名称,如"北京"、"上海"
​
    返回:
        天气信息字符串
    """
    # 你的实现
    return city + "晴天,温度 15°C"

直接调用:

result = get_weather.invoke({"city": "北京"})
print(result)

输出:

北京晴天,温度 15°C

这种方式就是手动喂参数、手动拿结果,适合测试工具本身是否正常。

方式 2:绑定到模型(主流,开发中用)

把工具"交给"模型,让 AI 来决定什么时候调用它。

from langchain.chat_models import init_chat_model
from dotenv import load_dotenv
import os
​
load_dotenv(override=True)
​
CLOSEAI_API_KEY = os.getenv("CLOSEAI_API_KEY")
CLOSEAI_BASE_URL = os.getenv("CLOSEAI_BASE_URL")
​
model = init_chat_model(
    model="gpt-5.4-mini",
    model_provider="openai",
    api_key=CLOSEAI_API_KEY,
    base_url=CLOSEAI_BASE_URL
)

绑定工具并让 AI 判断:

from langchain_core.tools import tool
​
@tool
def get_weather(city: str) -> str:
    """获取指定城市的天气"""
    return "晴天,温度 15°C"
​
# 把工具"绑"到模型上
model_with_tools = model.bind_tools([get_weather])
​
# AI 可以自己决定是否调用工具
response = model_with_tools.invoke("北京天气如何?")
​
# 检查 AI 是否想调用工具
if response.tool_calls:
    print("AI 想调用工具:", response.tool_calls)
else:
    print("AI 直接回答:", response.content)

输出:

AI 想调用工具: [{'name': 'get_weather', 'args': {'city': '北京'}, 'id': 'call_fR3LE8Wjqh9lnDosQ61Y892E', 'type': 'tool_call'}]

看到了吗?AI 并没有直接查天气(它也没这能力),而是返回了一个"调用请求":告诉你要调用 get_weather,参数 city 是 "北京"。真正执行函数的是我们自己。


三、一次完整工具调用的底层流程

大模型调用工具,本质上是单次推理(一次请求、一次响应),完整的"让大模型根据工具结果回复"需要四个步骤:

  1. 步骤 1:模型绑定工具 — 通过 model.bind_tools([...]) 绑定一个或多个工具。

  2. 步骤 2:模型生成工具调用请求 — 用户提问,模型返回一个 AIMessage,里面带着工具名和参数。

  3. 步骤 3:开发者手动执行工具 — 从响应里取出工具调用信息,自己调用对应函数(比如 工具.invoke())。

  4. 步骤 4:把结果喂回模型 — 把工具执行结果(ToolMessage)连同历史一起再发给模型,模型生成最终回复。

特别注意:大模型调用工具是单次推理,需要开发者手动执行工具和管理循环,适合简单、确定的任务。


四、方式一:不用 @tool 装饰器

不写 @tool,直接用一个普通 Python 函数也能当工具。这能帮你理解 LangChain 底层到底做了什么。

4.1 模型绑定工具并发送请求

from rich import print as rprint
​
# 定义一个普通函数(没有 @tool)
def get_weather(city: str):
    return f"{city}天气晴朗"
​
model_with_tools = model.bind_tools([get_weather])
​
response = model_with_tools.invoke("今天北京天气如何")
rprint(response)

输出会是一个 AIMessage,关键字段是 tool_calls:

tool_calls=[
    {
        'name': 'get_weather',
        'args': {'city': '北京'},
        'id': 'call_ECvZNV7RLTWpKQSjhvdGzKBd',
        'type': 'tool_call'
    }
]

4.2 工具描述的底层秘密:convert_to_openai_tool

model.bind_tools([...]) 底层最终会调用 convert_to_openai_tool,把函数"翻译"成模型能看懂的 JSON Schema 描述。我们可以直接调用它来观察:

from langchain_core.utils.function_calling import convert_to_openai_tool
​
def get_weather(city: str):
    return f"{city}天气晴朗"
​
rprint(convert_to_openai_tool(get_weather))

输出:

{
    'type': 'function',
    'function': {
        'name': 'get_weather',
        'description': '',
        'parameters': {
            'properties': {
                'city': {'type': 'string'}
            },
            'required': ['city'],
            'type': 'object'
        }
    }
}

关键字段说明:

  • type:数据类型,常见有 string、number、integer、boolean、object、array、null。object 就是 JSON 对象。

  • properties:定义 JSON 对象里有哪些"键",以及每个键的值类型和说明。

  • required:当 type 为 object 时使用,是一个数组,列出"必须存在的字段名"。

为什么不用 @tool 函数也能当工具?看底层源码:加了 @tool 的函数走 BaseTool 分支,没加的就走 callable 分支——后者会根据函数定义和 docstring 生成 Pydantic 模式,再转成规范的 tool_schema。所以普通函数也能被解析。

4.3 description 从哪来?

convert_to_openai_tool 会从函数的 docstring(文档字符串) 里加载描述。上面例子 docstring 为空,所以 description 为 ''。

加上 docstring:

def get_weather(city: str):
    """
    天气查询工具
    """
    return f"{city}天气晴朗"

这次输出里 description 就变成 '天气查询工具' 了。

4.4 参数说明:用 Google 风格 docstring

参数说明也来自 docstring,但必须遵循 Google 风格(Args:、Returns:、Raises: 这些关键字):

def get_weather(city: str):
    """
    天气查询工具
​
    Args:
        city: 城市名称
    """
    return f"{city}天气晴朗"

输出中 city 参数就带上了 description: '城市名称'。

AI 依赖 docstring 来理解工具! 描述越清晰,AI 就越能"用对"工具。

  • ❌ 不好:"""做一些事情"""

  • ✅ 好:"""在产品数据库中搜索产品\n\nArgs:\n query: 搜索关键词,如"笔记本电脑"、"手机"\n\nReturns:\n 产品列表的 JSON 字符串"""

4.5 参数类型说明

参数类型来自函数的类型注解。删除 city: str 的类型注解,工具描述里 city 就变成空 {},不再包含类型信息。

⚠️ 注意:如果 docstring 里写了某参数说明,那这个参数必须有类型注解,否则报错: ValueError: Arg city in docstring not found in function signature.

4.6 参数默认值说明

  • 参数没有默认值 → 会出现在 required 列表里。

  • 参数有默认值 → 描述里带 default 字段,且不会出现在 required 列表。

例如 def get_weather(city: str="北京"):city 带 default: '北京',而 required 字段被移除。


五、方式二:使用 @tool 装饰器(推荐)

用 @tool 装饰器,能自动把普通 Python 函数转成智能体可调用的工具。代码量最少、最直接,适合快速验证想法或参数简单的工具。

5.1 自定义工具描述:description

情况 1:只提供 docstring

from langchain_core.utils.function_calling import convert_to_openai_tool
from langchain.tools import tool
​
@tool
def get_weather(city: str):
    """
    天气查询工具
    """
    return f"{city}天气晴朗"
​
print(convert_to_openai_tool(get_weather))

⚠️ 用 @tool 时必须有 docstring,否则报错: ValueError: Function must have a docstring if description not provided.

情况 2:用 description 参数覆盖

@tool(description=...) 优先级高于 docstring:

@tool(description="根据城市名称查询当日天气的工具")
def get_weather(city: str):
    """天气查询工具"""
    return f"{city}天气晴朗"

情况 3:parse_docstring 解析 docstring

不加 parse_docstring=True 时,@tool 会把整个 docstring 当成 description。加上后,会把 docstring 里的 Args: 等字段分别解析进对应参数的 description:

@tool(parse_docstring=True)
def get_weather(city: str, units: str = "celsius", include_forecast: bool = False) -> str:
    """
    获取当日天气,可选择是否同时查询未来五日天气预报
​
    Args:
        city: 城市
        units: 气温单位,可选:celsius-摄氏度,fahrenheit-华氏度
        include_forecast: 是否包含未来五日的天气预报
    """
    ...

这样每个参数的描述都能精确落到 JSON Schema 里。

⚠️ 区别:不用 @tool 时 docstring 不合法会被当普通文本;但用 @tool 时 docstring 不合法会直接抛异常(ValueError: Found invalid Google-Style docstring.)。

5.2 更改工具名称:name_or_callable

默认用函数名当工具名。可以传参改名:

@tool(name_or_callable="getWeather")   # 或简写 @tool("getWeather")
def get_weather(city: str):
    """天气查询工具"""
    return f"{city}天气晴朗"

说明:不要用 config 或 runtime 作为参数名,这是 LangChain 内部保留的。开发中习惯用函数名作为工具名,不推荐自定义改名。


六、自定义参数 schema

当工具参数变复杂(需要枚举值、范围限制、业务校验)时,可以用 Pydantic 模型或 JSON Schema 精确定义参数。

6.1 用 Pydantic 定义 args_schema

Pydantic 能精确控制参数格式和校验规则,让大模型更准确地理解如何调用工具。

核心三件套:

① BaseModel 基类 —— 声明字段结构、类型、默认值、校验。

from pydantic import BaseModel
​
class WeatherInput(BaseModel):
    city: str
​
print(WeatherInput(city="北京"))
# city='北京'

⚠️ BaseModel 子类初始化时不接收位置参数,必须用关键字参数: WeatherInput("北京") 会报 TypeError。

② Field() 定制字段 —— 设置默认值、描述等。

from pydantic import BaseModel, Field
​
class WeatherInput(BaseModel):
    city: str = Field(default="北京", description="城市")
    include_forecast: bool = Field(default=False, description="是否包含未来五日天气预报")

每个字段的 description 至关重要,直接影响大模型理解参数含义的能力。

③ Literal 限定枚举值 —— 字段只能是几个固定字面量之一。

from typing import Literal
​
class WeatherInput(BaseModel):
    city: str
    unit: Literal["celsius", "fahrenheit"]

传入 unit="kelvin" 会报 ValidationError,提示只能选 celsius 或 fahrenheit。

把 Pydantic 模型关联到工具:

from pydantic import BaseModel, Field
from typing import Literal
from langchain.tools import tool
​
class WeatherInput(BaseModel):
    city: str = Field(default="北京", description="城市")
    unit: Literal["celsius", "fahrenheit"] = Field(default="celsius", description="气温单位")
    include_forecast: bool = Field(default=False, description="是否包含未来五日天气预报")
​
@tool(args_schema=WeatherInput)
def get_weather(city: str, unit: str = "celsius", include_forecast: bool = False) -> str:
    """获取当日天气,可选未来五日天气预报"""
    temp = 22 if unit == "celsius" else 72
    result = f'{city}当天气温: {temp} {"摄氏度" if unit == "celsius" else "华氏度"}'
    if include_forecast:
        result += "\n未来五天都是晴天"
    return result

解析出来的 schema 里,unit 会带上 enum: ['celsius', 'fahrenheit'],这正是 Literal 的功劳。

6.2 用 JSON Schema 定义 args_schema

适合参数结构需要运行时动态生成的场景(比如基于数据库配置或用户输入动态构造)。直接传 JSON Schema 字典:

weather_schema = {
    "type": "object",
    "properties": {
        "location": {"type": "string"},
        "units": {"type": "string"},
        "include_forecast": {"type": "boolean"}
    },
    "required": ["location", "units", "include_forecast"]
}
​
@tool(args_schema=weather_schema)
def get_weather(city: str, unit: str = "celsius", include_forecast: bool = False) -> str:
    """获取当日天气,可选未来五日天气预报"""
    ...

注意:传给 args_schema 的只是 parameters 对应的那块 JSON,不是整个 function 描述。


七、实战:多工具调用

大模型每次运行(一次推理)可能同时请求调用多个工具,也可能需要多轮。下面看两个实战例子。

案例:股票查询 + 新闻搜索(多工具 + 循环)

from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
​
# 1. 定义两个工具
@tool(parse_docstring=True)
def get_stock_price(company: str, timeframe: str = "today") -> str:
    """获取指定公司的股票价格信息
​
    Args:
        company: 公司名称(如:苹果公司, 微软公司, 谷歌公司)
        timeframe: 时间范围(today-今日, week-本周, month-本月)
    """
    mock_data = {
        "苹果公司": {"today": 185.20, "week": 183.50, "month": 180.75},
        "微软公司": {"today": 415.86, "week": 412.30, "month": 405.42},
        "谷歌公司": {"today": 15.42, "week": 15.20, "month": 14.85}
    }
    if company in mock_data:
        price = mock_data[company].get(timeframe, "未知时间范围")
        return f"{company} {timeframe}价格: {price}美元"
    return f"未找到股票代码 {company} 的数据"
​
@tool(parse_docstring=True)
def search_news(company: str) -> str:
    """搜索指定公司的财经新闻
​
    Args:
        company: 公司名称
​
    Returns:
        公司的财经新闻,每个新闻占一行
    """
    mock_news = {
        "苹果公司": ["苹果发布新款iPhone,股价上涨3%", "苹果与欧盟达成反垄断和解协议", "苹果将在印度扩大生产规模"],
        "微软公司": ["微软Azure云业务季度增长超预期", "微软完成对Nuance的收购", "微软推出新一代AI助手Copilot"],
        "谷歌公司": ["谷歌发布新AI模型,性能提升20%", "谷歌与OpenAI合作", "谷歌在欧洲展开AI研究项目"]
    }
    return "\n".join(mock_news.get(company, [f"未找到{company}的相关新闻"]))
​
# 2. 绑定工具
tools = [get_stock_price, search_news]
model_with_tools = model.bind_tools(tools)
​
message_list = [HumanMessage(content="苹果公司今天的股价是多少?最近有什么新闻?")]
​
# 3. 工具调用循环(手动管理多轮)
while True:
    response = model_with_tools.invoke(message_list)
    message_list.append(response)
​
    # 模型不再需要调用工具 → 退出
    if not response.tool_calls:
        print("没有工具调用,直接返回答案")
        break
​
    # 逐个执行模型请求调用的工具
    for tool_call in response.tool_calls:
        if tool_call["name"] == "get_stock_price":
            message_list.append(get_stock_price.invoke(tool_call))
        if tool_call["name"] == "search_news":
            message_list.append(search_news.invoke(tool_call))
​
for msg in message_list:
    msg.pretty_print()

模型会依次给出:股票查询结果、新闻搜索结果,最后生成总结性回复。

关键点:这里用一个 while True 循环手动管理"调用→执行→再问→...",因为大模型每次只用一次推理决定要不要调工具。


八、强制使用工具:tool_choice

bind_tools 可以传 tool_choice 参数,控制是否强制调用工具。它会作为 tool_choice 字段传给模型(OpenAI 和 DeepSeek 官方 API 对这个字段的取值规定一致)。

取值

行为

none

模型不会调用任何工具

auto

默认值,模型自主决定是否调用、调用几个

required

模型必须调用工具,数量不限

any

等价于 required

8.1 none:禁止调用

model_with_tools = model.bind_tools([get_weather], tool_choice="none")

即使你问"今天北京天气如何",模型也不会调用工具,而是直接说"我无法获取实时天气数据,建议你去查天气 App"。

8.2 auto:模型自己决定

model_with_tools = model.bind_tools([get_weather], tool_choice="auto")
  • 问天气 → 模型调用 get_weather。

  • 说"你好啊" → 模型直接回答,不调用工具。

8.3 required:强制调用

model_with_tools = model.bind_tools([get_weather], tool_choice="required")

即使你说"你好啊",模型依然会调用工具(这个例子它会强行去调 get_weather,默认用一个城市)。

8.4 强制调用指定的某个工具

tool_choice 还能传具体工具名,强制只用那个工具:

model_with_tools = model.bind_tools([get_weather1, get_weather2], tool_choice="get_weather2")

这样即使有多个工具,模型也只会调用 get_weather2。


九、实践经验总结(避坑指南)

1. 描述要清晰

@tool(parse_docstring=True)
def search_flights(origin: str, destination: str, date: str) -> str:
    """
    搜索航班信息
​
    Args:
        origin: 出发城市,如"北京"
        destination: 目的地城市,如"上海"
        date: 出发日期,格式 YYYY-MM-DD
​
    Returns:
        可用航班的 JSON 列表
    """

2. 功能要单一

一个工具只做一件事:

# ❌ 不好:一个工具做太多事
@tool
def do_everything(action: str, data: str) -> str: ...
​
# ✅ 好:每个工具做一件事
@tool
def get_weather(city: str) -> str: ...
@tool
def calculator(operation: str, a: float, b: float) -> str: ...

3. 工具失败的三层防护

第 1 层:工具内部处理(在工具函数里 try/except,返回错误字符串而不是抛异常)。

@tool
def divide(a: float, b: float) -> str:
    """除法计算
​
    Args:
        a: 被除数
        b: 除数
    """
    try:
        if b == 0:
            return "错误:除数不能为零"
        result = a / b
        return f"{a} / {b} = {result}"
    except Exception as e:
        return f"计算错误:{e}"

第 2 层:Agent 级重试(用 prompt 引导)。

agent = create_agent(
    model=model,
    tools=[...],
    prompt="如果工具失败,尝试使用其他方法解决问题。"
)

第 3 层:调用级重试(用 tenacity 的 @retry 做容错保险)。

from tenacity import retry, stop_after_attempt
​
@retry(stop=stop_after_attempt(3))   # 失败最多尝试 3 次
def call_agent(question):
    return agent.invoke({"messages": [{"role": "user", "content": question}]})

4. 工具务必返回字符串(str)

import json
​
# ✅ 好:返回字符串
@tool
def get_user_info(user_id: str) -> str:
    """获取用户信息"""
    user = {"id": user_id, "name": "张三"}
    return json.dumps(user, ensure_ascii=False)   # 转成 JSON 字符串
​
# ❌ 不好:返回字典(某些情况可能有问题)
@tool
def get_user_info(user_id: str) -> dict:
    return {"id": user_id, "name": "张三"}

为什么?

  1. 大模型本质只吃"文本"。

  2. 避免乱码:如果直接返回含中文的字典,LangChain 强制转字符串时可能用 Unicode 编码变成 {"name": "\u5f20\u4e09"},干扰模型判断。手动 json.dumps(..., ensure_ascii=False) 能让中文正常显示,喂给模型的是最干净的纯文本。

小知识:ensure_ascii=False 让中文、表情等字符在 JSON 字符串里正常显示,而不是一堆 \uXXXX 转义。

5. 同步 vs 异步

  • 同步工具:简单场景、CPU 密集型任务。

  • 异步工具:IO 密集型(API 调用、数据库、文件操作)。

# 同步
@tool
def sync_tool(x: str) -> str:
    return process(x)
​
# 异步
@tool
async def async_tool(x: str) -> str:
    return await async_process(x)

十、小结

到这里,你已经掌握了 LangChain 工具调用的完整知识体系:

主题

核心要点

工具本质

定义了输入输出的可调用函数(函数调用 Function Calling)

两种方式

直接 .invoke() 测试;bind_tools 交给模型

完整流程

绑定→模型提请求→手动执行→结果回喂模型

定义方式

普通函数 / @tool 装饰器(推荐)

描述来源

docstring(Google 风格)、description、parse_docstring

复杂参数

Pydantic 的 BaseModel/Field/Literal,或 JSON Schema

强制控制

tool_choice:none / auto / required / any / 指定工具名

最佳实践

描述清晰、功能单一、三层容错、返回字符串、合理选择同步异步

下一步建议: 学完工具后,就可以进入智能体(Agent)的世界了