目录
[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 手动模拟大模型调用来测试)
[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→ 执行工具 → 把结果回传给模型 → 模型生成最终答案。
两个关键点记住就行:
- 工具的 name /description/args 本质就是提示词,写得越清楚,模型选得越准。
- 第一次 invoke 只做 "选择",第二次 invoke 才出 "答案"------ 中间隔了一步手动执行工具。