LangGraph Tool Calling Agent 深度实战:从零构建 ReAct 循环与工具调用链
导读:Tool Calling Agent 是 LangGraph 最实用的能力之一------让 LLM 根据用户问题自主选择工具、执行工具、再根据结果生成回答。本文从 @tool 装饰器、bind_tools、AIMessage.tool_calls、ToolNode、tools_condition 五个核心组件讲起,深入 ToolMessage 与 tool_call_id 的关联机制,最后手写一个完整的 ReAct 循环。配合「智能订单查询 Agent」案例,一次性讲透工具调用链。
适合读者
- 需要让 LLM 调用外部 API 或数据库的开发者
- 想理解 Tool Calling Agent 底层机制而非只会用封装库的工程师
- 准备面试中回答「ReAct 是什么」的求职者
阅读收益
- 掌握 @tool 装饰器的底层原理与 docstring 规范
- 理解 bind_tools 只是「注册」,不执行工具
- 掌握 ToolNode 的执行流程与异常处理
- 理解 tool_call_id 的消息关联机制
- 获得一份从零手写的 ReAct 循环完整代码
目录
- [Tool Calling Agent 是什么](#Tool Calling Agent 是什么)
- 核心组件总览
- @tool:定义工具
- bind_tools:注册工具
- AIMessage.tool_calls:模型决策
- ToolNode:执行工具
- tools_condition:判断是否继续
- [ToolMessage 与 tool_call_id](#ToolMessage 与 tool_call_id)
- [完整案例:智能订单查询 Agent](#完整案例:智能订单查询 Agent)
- [手写 ReAct 循环](#手写 ReAct 循环)
- 踩坑清单
- 面试速答版
- 总结与延伸
- 思考题
1. Tool Calling Agent 是什么
普通模型调用只有一次输入和输出:
用户问题 -> LLM -> 回答
如果用户询问订单状态、实时天气或数据库数据,模型本身并不知道真实答案。此时需要让模型调用外部工具:
用户消息 -> LLM --没有 tool_calls--> 最终回答
|
|--生成 tool_calls-> 执行工具 -> ToolMessage -> LLM
工具执行后必须回到模型。工具返回的是原始数据,模型还需要根据这些数据组织最终回答,也可能判断是否需要继续调用其他工具。
这个循环可以概括为 ReAct(Reason + Act):
Reason:模型判断下一步做什么
Act:模型生成工具调用
Observe:模型读取工具执行结果
Reason:模型继续调用工具或生成最终回答
2. 核心组件总览
| 组件 | 作用 |
|---|---|
@tool |
把 Python 函数声明为工具 |
bind_tools |
把工具名称、描述和参数 schema 提供给模型 |
AIMessage.tool_calls |
保存模型生成的工具调用请求 |
ToolNode |
找到并执行模型请求的工具 |
ToolMessage |
保存工具执行结果,并关联原调用 ID |
tools_condition |
判断进入工具节点还是结束 |
MessagesState |
保存用户、模型和工具消息 |
核心消息链路:
HumanMessage
|
v
AIMessage(tool_calls=[...])
|
v
ToolMessage(tool_call_id=...)
|
v
AIMessage(content="最终回答")
3. @tool:定义工具
工具本质上是带有明确 schema 的 Python 函数。
python
from langchain_core.tools import tool
@tool
def query_order(order_id: str) -> str:
"""根据订单号查询订单状态。仅用于查询,不修改订单。"""
orders = {
"1001": "已发货",
"1002": "待付款",
}
return orders.get(order_id, "未找到该订单")
模型主要通过以下信息理解工具:
| 信息来源 | 作用 |
|---|---|
| 函数名 | 工具执行什么动作 |
| docstring | 什么情况下应该调用,以及有哪些限制 |
| 参数名 | 模型需要提供什么数据 |
| 类型标注 | 参数应采用什么格式 |
例如,模型看到的工具信息可以理解为:
工具名:query_order
用途:根据订单号查询订单状态
参数:order_id,字符串
3.1 工具设计原则
- 尽量小而明确:查询订单与取消订单应拆成两个工具
- 内部仍需校验:工具收到的参数来自模型,必须做数据格式、权限和业务规则校验
- 返回结构化数据:尽量返回 dict 而非自然语言,方便模型解析
4. bind_tools:注册工具
定义工具后,需要将工具绑定到模型:
python
tools = [query_order]
model_with_tools = model.bind_tools(tools)
bind_tools 会把工具的名称、说明和参数 schema 发送给模型,但不会执行工具。
调用绑定后的模型:
python
message = model_with_tools.invoke("查询订单 1001")
print(message.tool_calls)
如果模型决定调用工具,tool_calls 可能是:
json
[
{
"name": "query_order",
"args": {"order_id": "1001"},
"id": "call_123",
"type": "tool_call"
}
]
| 字段 | 含义 |
|---|---|
name |
要调用的工具名 |
args |
模型生成的工具参数 |
id |
本次工具调用的唯一标识 |
type |
消息类型 |
如果模型不需要工具,tool_calls 通常为空,并直接在 content 中生成回答。
5. AIMessage.tool_calls:模型决策
AIMessage 是 LangChain 的消息类型,代表模型的回复。当模型决定调用工具时:
python
from langchain_core.messages import AIMessage
# tool_calls 是 AIMessage 的属性
message = model_with_tools.invoke("查询订单 1001")
if message.tool_calls:
# 模型要求调用工具
for call in message.tool_calls:
print(f"调用工具: {call['name']}, 参数: {call['args']}")
else:
# 模型直接回答
print(message.content)
tool_calls 是一个列表,说明模型可能一次请求调用多个工具。
6. ToolNode:执行工具
python
from langgraph.prebuilt import ToolNode
tools = [query_order]
tool_node = ToolNode(tools)
ToolNode 会完成以下工作:
- 读取最后一条 AIMessage
- 遍历其中的 tool_calls
- 根据 name 找到对应工具
- 使用 args 调用工具
- 将结果包装成 ToolMessage
- 使用 tool_call_id 关联原始调用
如果一条 AI 消息中包含多个相互独立的工具调用,ToolNode 可以并行执行它们。
6.1 异常处理
python
# 自动处理工具异常
ToolNode(tools, handle_tool_errors=True)
# 自定义错误信息
ToolNode(tools, handle_tool_errors="工具执行失败,请检查参数。")
# 只处理特定异常类型
ToolNode(tools, handle_tool_errors=(ValueError, TypeError))
错误信息会以工具结果的形式返回给模型,模型可以据此修改参数或向用户补充提问。
7. tools_condition:判断是否继续
tools_condition 检查最后一条 AI 消息:
- 存在 tool_calls:进入名为 tools 的节点
- 不存在 tool_calls:说明模型已经给出最终回答,进入 END
标准写法:
python
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
graph = builder.compile()
注意 :tools_condition 返回的分支名字硬编码是两个值:"tools" 和 END。因此工具节点必须命名为 tools。
这条回边 tools -> agent 形成 Tool Calling Agent 的循环。模型读取 ToolMessage 后,再决定继续调用工具还是生成最终回答。
8. ToolMessage 与 tool_call_id
工具结果必须与原来的工具调用一一对应。
python
from langchain_core.messages import ToolMessage
tool_message = ToolMessage(
content="订单 1001 已发货",
name="query_order",
tool_call_id="call_123", # 必须与 AIMessage.tool_calls 中的 id 一致
)
tool_call_id 必须与 AIMessage.tool_calls 中的 id 一致。模型可能在一条消息中请求多个工具,如果缺少这个 ID,就无法判断结果属于哪个调用。
ToolNode 会自动维护这种对应关系。只有需要自定义工具执行过程时,才需要手动构造 ToolMessage。
9. 完整案例:智能订单查询 Agent
9.1 流程设计
START -> agent --无工具调用--> END
|
|--有工具调用-> tools --> agent
9.2 完整代码
python
import os
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langgraph.constants import START
from langgraph.graph import MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
# ========== 模拟订单数据库 ==========
ORDERS = {
"1001": {"status": "已发货", "carrier": "顺丰", "address": "北京市"},
"1002": {"status": "待付款", "carrier": None, "address": "上海市"},
"1003": {"status": "已签收", "carrier": "京东", "address": "广州市"},
}
# ========== 定义工具 ==========
@tool
def query_order(order_id: str) -> dict:
"""根据订单号查询订单状态、承运商和收货地址;找不到时返回错误信息。"""
order = ORDERS.get(order_id)
if order is None:
return {
"ok": False,
"order_id": order_id,
"message": "订单不存在",
}
return {"ok": True, "order_id": order_id, **order}
@tool
def list_orders() -> list:
"""列出所有订单号。"""
return list(ORDERS.keys())
# ========== 初始化模型 ==========
load_dotenv()
llm = init_chat_model(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-v4-flash"),
model_provider="openai",
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0,
)
# 绑定工具
tools = [query_order, list_orders]
llm_with_tools = llm.bind_tools(tools)
# ========== 定义节点 ==========
def call_model(state: MessagesState):
"""让模型根据消息历史决定回答,或者生成工具调用。"""
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
# ========== 构建图 ==========
builder = StateGraph(MessagesState)
builder.add_node("call_model", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", tools_condition)
builder.add_edge("tools", "call_model")
graph = builder.compile()
# ========== 运行 ==========
result = graph.invoke(
{
"messages": [
{
"role": "user",
"content": "请查询订单 1001 的状态和承运商。",
}
]
}
)
print(result["messages"][-1].content)
# 打印完整消息链路
for message in result["messages"]:
print(f"{type(message).__name__}: {message.content}")
9.3 执行过程
1. HumanMessage:请查询订单 1001 的状态和承运商
2. AIMessage:调用 query_order(order_id="1001")
3. ToolMessage:返回 status="已发货"、carrier="顺丰"
4. AIMessage:订单 1001 已发货,承运商是顺丰
9.4 多工具并行
如果用户问「我有几个订单?分别是什么状态?」,模型可能一次调用两个工具:
json
[
{"name": "list_orders", "args": {}, "id": "call_001"},
{"name": "query_order", "args": {"order_id": "1001"}, "id": "call_002"}
]
ToolNode 会并行执行这两个工具调用。
10. 手写 ReAct 循环
如果不用 ToolNode 和 tools_condition,完全手写工具调用循环:
python
from langchain_core.messages import ToolMessage
def custom_react_loop(user_input: str):
"""手写 ReAct 循环:理解底层机制"""
messages = [{"role": "user", "content": user_input}]
while True:
# Reason + Act:模型决定做什么
response = llm_with_tools.invoke(messages)
messages.append(response)
# 检查是否有工具调用
if not response.tool_calls:
# 没有工具调用,生成最终回答
break
# Observe:执行工具
for call in response.tool_calls:
tool_name = call["name"]
tool_args = call["args"]
tool_call_id = call["id"]
# 找到并执行工具
selected_tool = tools_by_name[tool_name]
result = selected_tool.invoke(tool_args)
# 构造 ToolMessage
tool_message = ToolMessage(
content=str(result),
name=tool_name,
tool_call_id=tool_call_id,
)
messages.append(tool_message)
return messages[-1].content
# 工具映射表
tools_by_name = {tool.name: tool for tool in tools}
这个手写循环展示了 ToolNode 和 tools_condition 的底层逻辑:
- 模型生成 tool_calls(Reason + Act)
- 遍历 tool_calls 执行工具(Observe)
- 构造 ToolMessage 并关联 tool_call_id
- 回到模型继续 Reason
- 直到没有 tool_calls,生成最终回答
思考题 2
上面的手写循环中,如果模型一次请求调用 5 个工具,是串行执行还是并行执行?如何改成并行执行?并行执行时如何处理异常?
11. 踩坑清单
| 序号 | 坑点 | 现象 | 正确做法 |
|---|---|---|---|
| 1 | 工具节点不叫 tools | ValueError: Node tools not found |
工具节点必须命名为 tools |
| 2 | 忘记 bind_tools | 模型从不生成 tool_calls | 调用 model.bind_tools(tools) |
| 3 | 工具名不一致 | KeyError: tool_name |
确保 tool_calls 中的 name 和工具函数名一致 |
| 4 | 缺少 tool_call_id | 模型无法关联工具结果 | ToolMessage 必须包含正确的 tool_call_id |
| 5 | 工具返回非字符串 | ToolMessage content 必须是字符串 | 用 str() 转换 |
| 6 | docstring 写得不清楚 | 模型调用错误的工具或参数 | docstring 要明确用途、参数含义和限制 |
| 7 | 一个工具做太多事 | 模型调用混乱 | 遵循单一职责,拆分成多个小工具 |
| 8 | 忽略工具异常处理 | 工具报错导致整个流程中断 | 配置 handle_tool_errors 或 try-except |
12. 面试速答版
Q1:ReAct 是什么?
A:Reason(推理)+ Act(行动)的循环。模型先推理下一步做什么,生成工具调用,观察工具结果,再推理,直到不需要工具直接回答。
Q2:bind_tools 做了什么?
A:把工具的名称、描述和参数 schema 提供给模型,让模型知道有哪些工具可以调用。不执行工具。
Q3:ToolNode 和手写工具执行的区别?
A:ToolNode 自动处理工具查找、参数传递、结果包装、异常处理和 tool_call_id 关联。手写需要自己实现这些逻辑。
Q4:tool_call_id 的作用?
A:关联工具调用请求和工具执行结果。模型可能在一条消息中请求多个工具,tool_call_id 让模型知道每个结果对应哪个调用。
Q5:tools_condition 返回什么?
A:硬编码返回 "tools" 或 END。因此工具节点必须命名为 "tools"。
13. 总结与延伸
本章核心要点
- Tool Calling Agent 是 "模型决策 -> 工具执行 -> 结果反馈 -> 再次决策" 的循环
@tool将 Python 函数转换为模型可理解的工具 schemabind_tools只提供工具信息,不负责执行工具- 模型通过
AIMessage.tool_calls请求调用工具 ToolNode执行工具并生成对应的ToolMessagetools_condition决定进入工具节点还是结束- 工具执行后必须回到模型,才能生成最终回答或继续调用工具
延伸思考
- 工具权限控制:不同用户应该看到不同的工具列表,可以在 bind_tools 前做权限过滤
- 工具调用审计:记录谁调用了什么工具、参数是什么、结果如何
- 工具返回格式:尽量返回结构化数据(dict),方便模型解析
- 多工具并行:ToolNode 自动并行执行独立工具调用
14. 思考题
-
工具权限:设计一个系统,让普通用户只能调用查询类工具,管理员才能调用修改类工具。应该在哪个环节做权限控制?
-
工具调用审计:如何在不影响性能的前提下,记录每次工具调用的调用者、参数、结果和耗时?
-
工具返回格式:如果工具返回的是复杂 JSON,模型可能无法正确解析。如何设计工具返回格式,既保留完整信息又方便模型理解?
-
工具异常降级:如果查询数据库的工具超时或报错,系统应该如何优雅降级?是直接报错,还是返回缓存数据,还是让模型告知用户?
-
多轮工具调用:如果用户问「比较订单 1001 和 1002 的状态」,模型需要先查 1001,再查 1002,最后比较。如何设计 State 让模型记住之前的查询结果?
如果本文对你有帮助,欢迎点赞 + 收藏 + 关注!有任何问题欢迎在评论区交流。
参考资料:LangGraph 官方文档 Tool Calling 章节