LangChain 自定义工具全解析:从 @tool 到 bind_tools 的完整调用链路

目录

[LangChain 自定义工具全解析:从 @tool 到 bind_tools 的完整调用链路](#LangChain 自定义工具全解析:从 @tool 到 bind_tools 的完整调用链路)

[一、为什么工具需要 "名称 + 描述 + 参数"?](#一、为什么工具需要 "名称 + 描述 + 参数"?)

[二、方式一:用 @tool 装饰器定义工具(最常用)](#二、方式一:用 @tool 装饰器定义工具(最常用))

[三、方式二:用 StructuredTool.from_function 手动包装](#三、方式二:用 StructuredTool.from_function 手动包装)

[3.1 先定义参数 Schema](#3.1 先定义参数 Schema)

[3.2 写普通函数(不加 @tool)](#3.2 写普通函数(不加 @tool))

[3.3 包装为 StructuredTool](#3.3 包装为 StructuredTool)

[3.4 手动模拟大模型调用来测试](#3.4 手动模拟大模型调用来测试)

四、把工具绑定给模型:bind_tools

五、工具调用的完整底层流程

[5.1 模型返回了什么?](#5.1 模型返回了什么?)

[5.2 "调不调用" 是 LLM 自己决定的](#5.2 "调不调用" 是 LLM 自己决定的)

[5.3 完整的两轮调用流程](#5.3 完整的两轮调用流程)

六、总结


前言:

本文记录 LangChain 中如何定义工具、包装工具、绑定给模型,以及模型实际调用工具时的底层流程。

一、为什么工具需要 "名称 + 描述 + 参数"?

在写代码之前,先想清楚一个问题:一个工具为什么需要 name、description、args 这三个属性?

答案很简单 ------这三个属性本质就是提示词(Prompt)。

大模型本身并不知道你写了什么函数,它只看到你喂给它的工具描述。模型通过这三行信息来做决策:

表格

属性 作用 类比
name 工具叫什么 函数名
description 工具干什么用的 函数的 docstring
args 需要传什么参数 函数签名

模型靠这三条信息决定两件事:① 要不要调用工具;② 调用时传什么参数。 所以自定义工具时,函数名、文档字符串(docstring)、类型提示一个都不能少 ------LangChain 的 schema 会做校验,缺了就报错。


二、方式一:用 @tool 装饰器定义工具(最常用)

@tool 装饰器会自动把普通 Python 函数包装成 LangChain 的 Tool 对象,函数签名和 docstring 就是工具的 schema。

python 复制代码
from langchain_core.tools import tool

@tool
def add(a: int, b: int) -> int:
    """两数相加

    Args:
        a: 第一个整数
        b: 第二个整数
    """
    return a + b

包装完成后,可以直接调用和查看工具的元信息:

python 复制代码
print(add.invoke({"a": 2, "b": 3}))   # 5
print(add.name)                        # add
print(add.description)                # 两数相加\n\n    Args:\n        a: 第一个整数\n ...
print(add.args)                        # {'a': {'title': 'A', 'type': 'integer'}, 'b': {'title': 'B', 'type': 'integer'}}

注意 :函数参数里的 : int 不是 Python 语法的运行时行为,而是类型提示。但在自定义工具场景中,它必须写明------LangChain 靠它生成 JSON Schema 做参数校验。docstring 同理,写得越清楚,模型选得越准。


三、方式二:用 StructuredTool.from_function 手动包装

如果不想用装饰器(比如函数已经存在、不想动它),可以用 StructuredTool.from_function 手动指定工具的各项属性。

3.1 先定义参数 Schema

用 Pydantic 的 BaseModel + Field 来声明参数结构:

python 复制代码
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field

class AddInput(BaseModel):
    a: int = Field(description="第一个整数")
    b: int = Field(description="第二个整数")

3.2 写普通函数(不加 @tool)

python 复制代码
def Add(a: int, b: int):
    return a + b

3.3 包装为 StructuredTool

python 复制代码
add_tool = StructuredTool.from_function(
    func=Add,               # 要执行的函数本体
    name="ADD",             # 工具名称
    description="两数相加",  # 工具描述,LLM 靠它判断何时调用
    args_schema=AddInput,   # 工具参数结构
)

四种核心参数一览:

表格

参数 作用
func 要执行的函数本体
name 工具名,如 "ADD"
description 工具描述,LLM 靠它判断何时调用
args_schema 参数结构(Pydantic Model),告诉模型需要传什么参数
response_format="content-and-artifact" 可选,把结果中的文本内容和结构化数据分开返回

3.4 手动模拟大模型调用来测试

包装好之后,我们可以手动构造一个 tool_call 字典来模拟大模型决定调用工具时的行为:

python 复制代码
add_tool.invoke({
    "name": "ADD",                    # 工具名
    "args": {"a": 3, "b": 4},         # 传入参数
    "type": "tool_call",              # 必填,类型标记
    "id": "123",                      # 必填,关联调用工具与调用方(处理并发用)
})

这一步的意义:脱离大模型,直接测试工具本身能不能跑通。type 和 id 是必填字段,其中 id 用来在并发场景下关联 "谁调用了哪个工具"。


四、把工具绑定给模型:bind_tools

定义好工具之后,需要把它 "告诉" 大模型,这一步用 bind_tools() 完成。

python 复制代码
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from typing import Annotated

model = ChatOpenAI(model="gpt-4o-mini")

@tool
def Add(
    a: Annotated[int, ..., "第一个整数"],
    b: Annotated[int, ..., "第二个整数"],
) -> int:
    """两数相加"""
    return a + b

@tool
def Muti(
    a: Annotated[int, ..., "第一个整数"],
    b: Annotated[int, ..., "第二个整数"],
) -> int:
    """两数相乘"""
    return a * b

# 绑定工具
tools = [Add, Muti]
model_calculate = model.bind_tools(tools=tools)

直接用自然语言提问试试:

python 复制代码
print(model_calculate.invoke("2乘3等于多少?").content)

注意 :这一步模型只做了 "工具选择"------ 判断该调哪个工具、传什么参数 ------还没有真正执行工具。


五、工具调用的完整底层流程

5.1 模型返回了什么?

当模型决定调用工具时,返回结果的 content 字段是空的 ,真正的信息在 tool_calls 字段里:

  • 函数名:要调用哪个工具
  • 参数值:传入什么参数

5.2 "调不调用" 是 LLM 自己决定的

即使你绑定了工具,模型也会根据问题与工具的相关性,自行判断要不要调用。

  • 问题和工具相关 → 模型返回 tool_calls
  • 问题和工具无关 → 模型直接在 content 里回答,不调工具

如果想强制模型必须调用工具 ,可以在 bind_tools() 时传:

python 复制代码
model_calculate = model.bind_tools(tools=tools, tool_choice="any")

5.3 完整的两轮调用流程

一次完整的工具调用,需要两次和模型交互:

python 复制代码
from langchain_core.messages import HumanMessage

# ① 定义消息列表
messages = [HumanMessage("2乘3等于多少?6加6等于多少?")]

# ② 第一次调用:模型返回 tool_calls(告诉你要调什么工具、传什么参)
ai_msg = model_with_tools.invoke(messages)

# ③ 把 AI 的回复(含 tool_calls)追加到消息列表
messages.append(ai_msg)

# ④ 你手动执行工具,拿到结果后包装成 ToolMessage,也追加进去
# tool_msg = ToolMessage(...)
# messages.append(tool_msg)

# ⑤ 第二次调用:模型拿到工具执行结果后,生成最终自然语言回答
print(model.invoke(messages).content)

六、总结

整个链路一句话概括:

定义工具 → 绑定给模型 → 模型根据问题选择工具并输出 tool_calls → 执行工具 → 把结果回传给模型 → 模型生成最终答案。

两个关键点记住就行:

  1. 工具的 name /description/args 本质就是提示词,写得越清楚,模型选得越准。
  2. 第一次 invoke 只做 "选择",第二次 invoke 才出 "答案"------ 中间隔了一步手动执行工具。
相关推荐
时间的情敌8 分钟前
Cesium 流动管线、管道动画、管线流向仿真,管网、油气、水利孪生高频需求
前端·交互·cesium
星恒随风1 小时前
MySQL 从零入门(六):复合查询详解——多表查询、自连接、子查询与 UNION
linux·服务器·数据库·mysql
wangxiaoxuan2172 小时前
SLAM 的基本结构:前端、后端、回环与地图优化
前端·机器人·科沃斯八界
无足鸟ICT2 小时前
【RHCA+】IO调度算法
linux·服务器
文丑颜不良啊2 小时前
LeetCode2821~2840题目解析
java·leetcode
wacsii2 小时前
【Linux进程(四)】命令行参数与环境变量
linux·服务器
无奈笑天下2 小时前
银河麒麟上lsblk命令教程详解
linux·运维·服务器·数据库
apcipot_rain3 小时前
C++转Java日记(10)
java·开发语言
简 洁 冬冬3 小时前
泛型方法写法全面辨析:从「能编译」到「能跑」到「能用」
java·泛型
LGL6030A4 小时前
Java学习历程31——初识Spring Boot
java·spring boot·学习