# 使用LangChain+LangGraph开发AI应用（4） - 工具定义与调用


![](https://static.xiongneng.me/langchain-langgraph-cover-20261017161835.png)

## 学习进度

工具让模型伸手干活。你给它的工具箱里放一个 `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，都会原样出现在模型眼前，工具好不好用，就定在这些细节上。

![](https://static.xiongneng.me/tool-loop-20261017161835.png)

调用链路值得单独讲。模型发起调用后，返回的 `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 让模型自我修正，工具内部抛的业务异常默认直接炸，需要你显式兜底。

![](https://static.xiongneng.me/error-levels-20261017161835.png)

## 核心用法

八个用法，示例在 `source/hello_tools.py`，不设 `AGENT_MODEL` 时用本地替身模型跑。下面替身和真实模型的输出都有，对照着看。

### 1. @tool 基础定义

一个普通函数，加上 `@tool` 和类型注解，就成了工具。

```python
from langchain.tools import tool

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

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

装饰器做了两层转换。类型注解变成入参 schema，docstring 变成描述，`Args:` 段落里的参数说明逐个进 schema。用两种方式调用它，能看到工具的两副面孔。

```python
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}))
```

```text
== @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`。

```python
@tool("web_search", description="搜索互联网获取公开信息，结果含标题与链接")
def search(query: str) -> str:
    """这段 docstring 会被 description 覆盖。"""
    return f"结果: {query}"
```

```text
== 自定义名称与描述 ==
name: web_search
description: '搜索互联网获取公开信息，结果含标题与链接'
```

工具名建议全小写加下划线，部分提供商对空格和特殊字符会直接拒绝。

### 3. args_schema 复杂入参

参数一多，类型注解就不够用了。枚举值、默认值、字段级说明，用 Pydantic 模型定义，挂到 `args_schema` 上。

```python
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
```

```text
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` 则干脆跳过模型，工具结果原样返回给用户。

```python
@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} 的直接答案"
```

```text
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` 才会短路，混着调时仍会回到模型。

### 5. bind_tools 与工具循环

手工循环是理解工具调用的基本功。绑定、调用、执行、回传、再调用，五步走完一圈。

```python
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)             # 模型继续生成
```

```text
== bind_tools 与工具循环 ==
模型发起调用: get_weather({'city': '北京'})
工具结果回传: '北京今天晴天，气温 26℃，适合出行。'
最终回答: 北京今天晴，气温 26℃。
```

真实模型跑同一段代码，效果一致，参数填得又快又准。循环的每一步都是显式的。消息列表由你维护，模型的原消息必须原样追加，第三篇踩过的坑，这里再强调一次。

### 6. 错误处理

错误分两类，处理方式完全不同。模型传错参数，属于校验错误，框架默认转成 error ToolMessage，模型读到后自己修正。工具内部抛的异常，属于业务错误，默认直接炸掉整个循环，需要显式兜底。

```python
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}")
```

```text
== 错误处理 ==
裸调用业务异常 -> ToolException: 连接超时
工具级兜底 -> status=error, content='接口暂不可用，请稍后再试'
```

校验错误的自我修正，用 create_agent 跑一遍最直观。脚本里的模型第一次调用 `add` 只传了 `a`，框架把校验错误转成 ToolMessage，模型看到后补上 `b` 重发，拿到结果才回答。

```text
工具消息 -> status=error, content="Error invoking tool 'add' with kwargs {'"
工具消息 -> status=success, content='7'
最终回答: 3 加 4 等于 7。
```

这条自我修正链是框架白送的，前提是你别把业务异常也交给默认行为。区分口径很简单。模型能改的错（参数填错），交给默认转换。模型改不了的（接口挂了），用 `handle_tool_error` 转成一句它读得懂的提示。

### 7. ToolRuntime 与注入

有些信息不该让模型填，比如当前对话历史、工具调用 id。LangChain 用参数注入解决，工具签名里声明对应类型的参数，框架在调用时自动填进去，这些参数不会出现在 schema 里。

```python
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}"
```

```text
== 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` 把绑定、执行、回传、路由整个包起来，你只管给工具。

```python
from langchain.agents import create_agent

agent = create_agent(
    model=model,
    tools=[get_weather],
    system_prompt="你是一个简洁的天气助手，回答不超过两句话。",
)
result = agent.invoke({"messages": [{"role": "user", "content": "北京天气怎么样？"}]})
```

```text
== create_agent 完整循环 ==
消息序列: ['human', 'ai', 'tool', 'ai']
最终回答: 北京今天晴，气温 26℃，适合出行。
```

四条消息，human 提问，ai 发起调用，tool 回传结果，ai 给最终回答。第三篇埋的伏笔在这里接上了，那个循环的内部就是这些消息在流转。

## 完整案例

工具一多就需要管理。收个尾，写一个工具箱 `toolbox.py`，统一注册、查看、直调、调度一组工具。

先看工具集怎么摆出来。`--list` 一条命令，名称、入参、描述各一列。

```bash
uv run python toolbox.py --list
```

```text
名称               入参                           描述
------------------------------------------------------------------------
get_weather      city                         查询指定城市的天气。
calculate        expression                   计算四则运算表达式，支持加减乘除、乘方和括号。
convert_unit     value, from_unit, to_unit    在常用单位间换算，支持长度 mm/cm/m/km 与温度 c/f。
now_time         （无入参）                        获取当前本地时间。
```

ToolBox 类管注册表，重名注册直接报错，不搞静默覆盖。里面的计算器值得单独说，它用 `ast` 模块做安全求值，只放行四则运算和乘方，模型要传个 `import os` 进来，会被兜底文案挡住。

```bash
uv run python toolbox.py --call calculate --args '{"expression": "(3 + 4) * 2"}'
uv run python toolbox.py --call calculate --args '{"expression": "import os"}'
```

```text
工具: calculate
入参: {'expression': '(3 + 4) * 2'}
结果: (3 + 4) * 2 = 14

工具: calculate
入参: {'expression': 'import os'}
结果: 表达式算不了，只支持数字与四则运算，请简化后重试
```

`--call` 是工具的单点测试入口，接新工具先在这里跑两个用例，确认行为再交给模型。错误转成一句话而不是抛异常，模型读到还能自己调整。

然后是完整的调度。问题里带两件事，模型自己决定调几个工具。

```bash
uv run python toolbox.py "北京天气怎么样？顺便算一下 128 乘以 4"
```

本地替身按脚本只调一个工具。

```text
模型: ScriptedChatModel（本地替身）
工具集: get_weather, calculate, convert_unit, now_time
问题: 北京天气怎么样？顺便算一下 128 乘以 4

-> 调用 get_weather({'city': '北京'})
回答: 北京今天晴，气温 26℃，适合出行。
```

接上真实模型再跑同一条命令，它一次挑了两个工具。

```text
模型: 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](https://github.com/yidao620c/xneng-langchain-langgraph)

## 参考

- [LangChain 官方文档 - 工具](https://docs.langchain.com/oss/python/langchain/tools)
- [LangChain 官方文档 - 工作流与 Agent](https://docs.langchain.com/oss/python/langgraph/workflows-agents)
- [LangChain 官方文档 - 消息](https://docs.langchain.com/oss/python/langchain/messages)


---

> 作者: XiongNeng  
> URL: https://xiongneng.me/posts/ai/langchain/langchain-langgraph-04-tools/  

