
学习进度
工具让模型伸手干活。你给它的工具箱里放一个 get_weather,它自己决定调不调、自己填参数,拿到结果再组织回答。这一篇把工具这条链路补齐,从 @tool 装饰器把一个普通函数变成工具讲起,到 bind_tools 绑定、模型发起调用、ToolMessage 回传,再到出错怎么办、工具多了怎么管。
示例在 source/hello_tools.py,八个 demo。完整案例是一个工具箱 source/toolbox.py,注册、查看、直调、交给模型调度,一条命令走通。
前言用途
模型光会说话成不了事。你问它北京天气,它只能说「我不知道,我没有实时数据」。给它的工具箱里放一个 get_weather,情况就变了,模型自己决定调工具、自己填参数,拿到结果再组织回答。这个从「查不到」到「查到了」的跨越,靠的就是工具调用。
写工具这件事,看着简单,实际有三道坎。第一道是定义,工具得让模型看懂,名称、描述、参数说明,一样都不能含糊,写得含糊模型就挑错工具或者乱填参数。第二道是执行,模型只能发起调用,干活的是你的代码,在哪一步执行、结果怎么回传,这条链路要清楚。第三道是出错,外部接口会超时,模型会传错参数,错误不当处理,整个循环直接炸。
LangChain 把这三道坎都铺好了路。@tool 装饰器负责把普通函数变成模型能看懂的工具,bind_tools 负责把工具递到模型眼前,create_agent 负责整个调用循环,错误处理在工具、节点、中间件三个层级各有一套。本篇配了一个工具箱案例,注册、查看、直调、交给模型调度,一条命令走通。
原理概念
一个工具,拆开看就三样东西,名称、描述、入参 schema。模型看不到你的函数体,它只看这三样。名称是调用的标识符,描述告诉模型这个工具干什么、什么时候该用,schema 告诉模型参数怎么填。模型据此决定调不调、怎么调。
@tool 装饰器做的就是这次转换。函数名变成工具名,docstring 变成描述,类型注解推断出入参 schema。你在函数上写的每个类型注解和每一行 docstring,都会原样出现在模型眼前,工具好不好用,就定在这些细节上。

调用链路值得单独讲。模型发起调用后,返回的 AIMessage 里带着 tool_calls,每个调用有名称、参数和一个 id。执行发生在你的进程里,把结果包成 ToolMessage 回传,tool_call_id 与调用对齐。模型拿到结果继续生成,可能又发起下一次调用,如此往复,直到它不再请求工具。控制流由模型在运行时决定,这正是 Agent 与普通函数调用的分界线。
执行循环谁来转,有两条路。一条是 create_agent,框架内部的 ToolNode 替你转,绑定、执行、回传、路由全自动。另一条是手工循环,bind_tools 之后自己维护消息列表,适合想完全掌控每一步的场合。两条路本篇都会走一遍。
错误处理是工具区别于普通函数的地方,它有三级防线。工具级,单个工具挂 handle_tool_error,异常就地转成一句模型读得懂的提示。节点级,ToolNode 的 handle_tool_errors 参数管一批工具。中间件级,wrap_tool_call 包住所有工具调用,适合统一兜底。默认行为值得记住,模型传错参数这类校验错误会自动转成 error ToolMessage 让模型自我修正,工具内部抛的业务异常默认直接炸,需要你显式兜底。

核心用法
八个用法,示例在 source/hello_tools.py,不设 AGENT_MODEL 时用本地替身模型跑。下面替身和真实模型的输出都有,对照着看。
一个普通函数,加上 @tool 和类型注解,就成了工具。
1
2
3
4
5
6
7
8
9
10
11
| from langchain.tools import tool
@tool
def add(a: int, b: int) -> int:
"""两数相加。
Args:
a: 第一个数
b: 第二个数字
"""
return a + b
|
装饰器做了两层转换。类型注解变成入参 schema,docstring 变成描述,Args: 段落里的参数说明逐个进 schema。用两种方式调用它,能看到工具的两副面孔。
1
2
3
4
| msg = add.invoke({"name": "add", "args": {"a": 2, "b": 3}, "id": "call_x", "type": "tool_call"})
print(type(msg).__name__, msg.content, msg.tool_call_id)
print(add.invoke({"a": 2, "b": 3}))
|
1
2
3
4
5
6
| == @tool 基础 ==
name: add
description: '两数相加。\n\nArgs:\n a: 第一个数\n b: 第二个数'
args schema: {'a': {'title': 'A', 'type': 'integer'}, 'b': {'title': 'B', 'type': 'integer'}}
tool_call 入参 -> ToolMessage 5 id=call_x
普通入参 -> 5
|
传 tool_call 字典,返回带 id 的 ToolMessage,这是回传给模型的形态。传普通参数字典,直接返回内容,这是你自己调用的形态。一个工具,两种用法。
2. 自定义名称与描述
函数名不合适就用装饰器参数覆盖,比如函数叫 search 但你想让模型看到 web_search。
1
2
3
4
| @tool("web_search", description="搜索互联网获取公开信息,结果含标题与链接")
def search(query: str) -> str:
"""这段 docstring 会被 description 覆盖。"""
return f"结果: {query}"
|
1
2
3
| == 自定义名称与描述 ==
name: web_search
description: '搜索互联网获取公开信息,结果含标题与链接'
|
工具名建议全小写加下划线,部分提供商对空格和特殊字符会直接拒绝。
3. args_schema 复杂入参
参数一多,类型注解就不够用了。枚举值、默认值、字段级说明,用 Pydantic 模型定义,挂到 args_schema 上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| from pydantic import BaseModel, Field
from typing import Literal
class WeatherInput(BaseModel):
"""天气查询的入参。"""
location: str = Field(description="城市名或坐标")
units: Literal["celsius", "fahrenheit"] = Field(
default="celsius", description="温度单位"
)
include_forecast: bool = Field(default=False, description="是否带五天预报")
@tool(args_schema=WeatherInput)
def get_weather_detail(location: str, units: str = "celsius", include_forecast: bool = False) -> str:
"""查询天气详情,可指定温度单位与预报。"""
temp = 22 if units == "celsius" else 72
result = f"{location}当前 {temp} {units}"
if include_forecast:
result += ",未来五天晴"
return result
|
1
2
| schema: {'location': {'description': '城市名或坐标', 'title': 'Location', 'type': 'string'}, 'units': {'default': 'celsius', 'description': '温度单位', 'enum': ['celsius', 'fahrenheit'], 'title': 'Units', 'type': 'string'}, 'include_forecast': {'default': False, 'description': '是否带五天预报', 'title': 'Include Forecast', 'type': 'boolean'}}
调用 -> '北京当前 22 celsius,未来五天晴'
|
枚举值让模型只能在两个温度单位里选,默认值让它可以不传。schema 越精确,模型填错参数的概率越低。
4. 三种回报
工具执行完,回报方式有三种选择。返回字符串最常见,模型直接读。response_format="content_and_artifact" 时返回二元组,第一项给模型看,第二项挂在消息对象上给程序用。return_direct=True 则干脆跳过模型,工具结果原样返回给用户。
1
2
3
4
5
6
7
8
9
| @tool(response_format="content_and_artifact")
def fetch_page(url: str) -> tuple:
"""抓取网页,返回正文与原始响应头。"""
return (f"网页正文,长度 1024 字节,来自 {url}", {"status": 200, "source": "本地替身"})
@tool(return_direct=True)
def direct_answer(question: str) -> str:
"""直接回答模式,结果原样返回给用户,不再过模型。"""
return f"这是 {question} 的直接答案"
|
1
2
3
| content_and_artifact -> content='网页正文,长度 1024 字节,来自 https://example.com'
artifact(不下发给模型)-> {'status': 200, 'source': '本地替身'}
return_direct: True
|
网页正文这种需要模型加工的内容放 content,HTTP 状态码这类模型用不上的原始数据放 artifact,第三篇讲过的约定在这里照用。return_direct 适合查询类工具,结果本身就是答案,没必要再让模型复述一遍,省一次调用。注意 create_agent 里只有整批工具都带 return_direct=True 才会短路,混着调时仍会回到模型。
手工循环是理解工具调用的基本功。绑定、调用、执行、回传、再调用,五步走完一圈。
1
2
3
4
5
6
7
8
9
10
11
12
13
| bound = model.bind_tools([get_weather])
messages = [HumanMessage("北京天气怎么样?")]
response = bound.invoke(messages) # 模型发起调用
messages.append(response)
call = response.tool_calls[0]
tool_msg = get_weather.invoke(
{"name": call["name"], "args": call["args"], "id": call["id"], "type": "tool_call"}
)
messages.append(tool_msg) # 结果回传
final = bound.invoke(messages) # 模型继续生成
|
1
2
3
4
| == bind_tools 与工具循环 ==
模型发起调用: get_weather({'city': '北京'})
工具结果回传: '北京今天晴天,气温 26℃,适合出行。'
最终回答: 北京今天晴,气温 26℃。
|
真实模型跑同一段代码,效果一致,参数填得又快又准。循环的每一步都是显式的。消息列表由你维护,模型的原消息必须原样追加,第三篇踩过的坑,这里再强调一次。
6. 错误处理
错误分两类,处理方式完全不同。模型传错参数,属于校验错误,框架默认转成 error ToolMessage,模型读到后自己修正。工具内部抛的异常,属于业务错误,默认直接炸掉整个循环,需要显式兜底。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| from langchain.tools import ToolException
from langchain_core.tools import StructuredTool
def flaky_query(city: str) -> str:
"""模拟一个不稳定的外部接口,总是失败。"""
raise ToolException("连接超时")
# 裸调用,异常直接抛
try:
StructuredTool.from_function(flaky_query).invoke({"city": "北京"})
except ToolException as e:
print(f"裸调用业务异常 -> {type(e).__name__}: {e}")
# 工具级兜底,异常就地转成 error ToolMessage
flaky = StructuredTool.from_function(flaky_query, handle_tool_error="接口暂不可用,请稍后再试")
flaky_msg = flaky.invoke(
{"name": "flaky_query", "args": {"city": "北京"}, "id": "call_5", "type": "tool_call"}
)
print(f"工具级兜底 -> status={flaky_msg.status}, content={flaky_msg.content!r}")
|
1
2
3
| == 错误处理 ==
裸调用业务异常 -> ToolException: 连接超时
工具级兜底 -> status=error, content='接口暂不可用,请稍后再试'
|
校验错误的自我修正,用 create_agent 跑一遍最直观。脚本里的模型第一次调用 add 只传了 a,框架把校验错误转成 ToolMessage,模型看到后补上 b 重发,拿到结果才回答。
1
2
3
| 工具消息 -> status=error, content="Error invoking tool 'add' with kwargs {'"
工具消息 -> status=success, content='7'
最终回答: 3 加 4 等于 7。
|
这条自我修正链是框架白送的,前提是你别把业务异常也交给默认行为。区分口径很简单。模型能改的错(参数填错),交给默认转换。模型改不了的(接口挂了),用 handle_tool_error 转成一句它读得懂的提示。
有些信息不该让模型填,比如当前对话历史、工具调用 id。LangChain 用参数注入解决,工具签名里声明对应类型的参数,框架在调用时自动填进去,这些参数不会出现在 schema 里。
1
2
3
4
5
6
7
8
9
10
11
12
13
| from langchain.tools import ToolRuntime, InjectedToolCallId
from typing_extensions import Annotated
@tool
def ask(question: str, runtime: ToolRuntime) -> str:
"""回答问题时带上上下文信息。"""
history = len(runtime.state.get("messages", []))
return f"问题「{question}」,当前历史 {history} 条,tool_call_id={runtime.tool_call_id}"
@tool
def echo(x: int, tool_call_id: Annotated[str, InjectedToolCallId]) -> str:
"""回显工具调用 id。"""
return f"x={x}, id={tool_call_id}"
|
1
2
3
| == ToolRuntime 与 InjectedToolCallId ==
问题「在吗」,当前历史 2 条,tool_call_id=c1
x=7, id=c2
|
ToolRuntime 是个大礼包,state、context、store、tool_call_id 都能从上面拿。只要 tool_call_id 一个字段,用 Annotated[str, InjectedToolCallId] 更轻。
8. create_agent 一把梭
手工循环写两遍你就烦了。create_agent 把绑定、执行、回传、路由整个包起来,你只管给工具。
1
2
3
4
5
6
7
8
| from langchain.agents import create_agent
agent = create_agent(
model=model,
tools=[get_weather],
system_prompt="你是一个简洁的天气助手,回答不超过两句话。",
)
result = agent.invoke({"messages": [{"role": "user", "content": "北京天气怎么样?"}]})
|
1
2
3
| == create_agent 完整循环 ==
消息序列: ['human', 'ai', 'tool', 'ai']
最终回答: 北京今天晴,气温 26℃,适合出行。
|
四条消息,human 提问,ai 发起调用,tool 回传结果,ai 给最终回答。第三篇埋的伏笔在这里接上了,那个循环的内部就是这些消息在流转。
完整案例
工具一多就需要管理。收个尾,写一个工具箱 toolbox.py,统一注册、查看、直调、调度一组工具。
先看工具集怎么摆出来。--list 一条命令,名称、入参、描述各一列。
1
| uv run python toolbox.py --list
|
1
2
3
4
5
6
| 名称 入参 描述
------------------------------------------------------------------------
get_weather city 查询指定城市的天气。
calculate expression 计算四则运算表达式,支持加减乘除、乘方和括号。
convert_unit value, from_unit, to_unit 在常用单位间换算,支持长度 mm/cm/m/km 与温度 c/f。
now_time (无入参) 获取当前本地时间。
|
ToolBox 类管注册表,重名注册直接报错,不搞静默覆盖。里面的计算器值得单独说,它用 ast 模块做安全求值,只放行四则运算和乘方,模型要传个 import os 进来,会被兜底文案挡住。
1
2
| uv run python toolbox.py --call calculate --args '{"expression": "(3 + 4) * 2"}'
uv run python toolbox.py --call calculate --args '{"expression": "import os"}'
|
1
2
3
4
5
6
7
| 工具: calculate
入参: {'expression': '(3 + 4) * 2'}
结果: (3 + 4) * 2 = 14
工具: calculate
入参: {'expression': 'import os'}
结果: 表达式算不了,只支持数字与四则运算,请简化后重试
|
--call 是工具的单点测试入口,接新工具先在这里跑两个用例,确认行为再交给模型。错误转成一句话而不是抛异常,模型读到还能自己调整。
然后是完整的调度。问题里带两件事,模型自己决定调几个工具。
1
| uv run python toolbox.py "北京天气怎么样?顺便算一下 128 乘以 4"
|
本地替身按脚本只调一个工具。
1
2
3
4
5
6
| 模型: ScriptedChatModel(本地替身)
工具集: get_weather, calculate, convert_unit, now_time
问题: 北京天气怎么样?顺便算一下 128 乘以 4
-> 调用 get_weather({'city': '北京'})
回答: 北京今天晴,气温 26℃,适合出行。
|
接上真实模型再跑同一条命令,它一次挑了两个工具。
1
2
3
4
5
6
7
| 模型: openai:deepseek-v4.1-flash
工具集: get_weather, calculate, convert_unit, now_time
问题: 北京天气怎么样?顺便算一下 128 乘以 4
-> 调用 get_weather({'city': '北京'})
-> 调用 calculate({'expression': '128 * 4'})
回答: 北京今天晴天,气温 26℃,适合出行;128 × 4 = 512。
|
两个工具并行执行,结果一起回传,模型合并成一段回答。工具集管理带来的另一个好处在 bind 方法上,给模型绑定时用 box.tools 一个属性搞定,加工具、下工具都不用动调用代码。
案例的测试在 tests/test_toolbox.py 七条,注册去重、直调报错、安全求值、单位换算、agent 端到端、模型构造两分支。加上 tests/test_hello_tools.py 的十三条,本篇新增二十条测试,全仓五十条,全绿。
源码
https://github.com/yidao620c/xneng-langchain-langgraph
参考