主题
10.8 工具调用循环与预构建组件
前七节一直在画轨道:状态怎么走、路口怎么选、人怎么签字。但所有 Agent 共用的那颗心脏,还没有单独拆开看——工具调用循环(Agent Loop)。16 节旅行助手里反复出现的 ToolNode、tools_condition、create_tool_node_with_fallback,全是围着它转的。
可以先把它想成一位顾问和一位办事员的搭档:顾问(模型)判断“下一步该查什么”,办事员(ToolNode)真去调接口,把结果递回来,顾问再决定继续查还是给出答案。这一来一回,就是工具调用循环。
1. 标准画法只有三步
在 LangGraph 里,这个循环几乎总是同一张小图:
python
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langgraph.graph import StateGraph, MessagesState, START
from langgraph.prebuilt import ToolNode, tools_condition
@tool
def search_flights(destination: str, date: str = "明天") -> str:
"""查询指定目的地、指定日期的航班列表。"""
catalog = {
"东京": ["CA-1801 08:40 ¥1280", "NH-955 13:15 ¥1560", "MU-523 19:05 ¥990"],
"大阪": ["CZ-385 09:20 ¥880", "HO-1337 16:40 ¥1120"],
}
flights = catalog.get(destination, ["暂无直飞,需中转"])
return f"{date}前往{destination}:{' / '.join(flights)}"
llm = init_chat_model("openai:gpt-4o-mini", temperature=0).bind_tools([search_flights])
def call_model(state: MessagesState):
"""模型节点收到的是整份 State,必须先取出 messages 再调用。"""
return {"messages": [llm.invoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("assistant", call_model)
builder.add_node("tools", ToolNode([search_flights]))
builder.add_edge(START, "assistant")
builder.add_conditional_edges("assistant", tools_condition)
builder.add_edge("tools", "assistant")
graph = builder.compile()运行时的闭环如下:
这就是常说的 ReAct(Reason + Act):想一步、做一步、看结果、再想下一步。LangGraph 允许循环图,价值在这里体现得最清楚——没有从 tools 连回 assistant 的那条边,模型永远看不到工具结果。
一次真实跑动的消息流水线通常长这样:
text
HumanMessage 用户:帮我查明天去东京的机票
AIMessage.tool_calls 模型:调用 search_flights(destination="东京", date="明天")
ToolMessage 工具:明天前往东京:CA-1801 08:40 ¥1280 / ...
AIMessage 模型:为您查到 3 个航班,最便宜的是 MU-523……审计时顺着这条流水线读即可,不必再猜“模型刚才想干什么”。
2. 三块积木各自干什么
| 积木 | 职责 | 记法 |
|---|---|---|
llm.bind_tools([...]) | 把工具的 JSON Schema 交给模型,允许它输出 tool_calls | 给顾问一张只能点的菜单 |
ToolNode(tools) | 按 tool_calls 执行 Python 函数,结果包成 ToolMessage | 办事员:对号入座、执行、回执 |
tools_condition | 看最后一条 AI 消息有没有 tool_calls:有去 tools,没有去 END | 路口判断 |
几个值得钉死的细节:
- 模型节点必须自己取字段。 节点输入是整份 State。把模型实例直接丢给
add_node,常见报错是“收到了字典而不是消息列表”。 tools_condition默认寻找名叫tools的节点。 想换名字,就给条件边加路径表,例如{ "tools": "flight_tools", END: END }。旅行助手里每个子助理都有自己的路由函数,本质是加强版tools_condition。MessagesState已经内置add_messages。 写小循环时不必再手搓 State;要加业务字段,继承它,如 02 节的TripState。- 一次可以点多个工具。 模型可能在同一条
AIMessage里发出两个tool_calls,ToolNode会按顺序(或并行,视版本与配置)执行,并配出两条ToolMessage。每条回执的tool_call_id必须和请求对上,否则会触发 06 节提过的INVALID_CHAT_HISTORY。
bind_tools 只是“让模型知道有这些工具”。它不会替你执行。执行永远发生在 ToolNode(或你自己写的等价节点)里。把这两步混在一个节点里,循环边就画不清楚,中断和重试也难挂。
3. 工具报错时,别让整张图崩掉
接口超时、城市名拼错、权限不足,都是日常。生产环境几乎总是打开容错,让模型看到错误信息后再想办法,而不是直接把线程打死。
python
# 方式一:ToolNode 自带 handle_tool_errors(默认往往是 True)
# 异常会被包成 ToolMessage 塞回对话,模型可以换参数重试或改口道歉
builder.add_node(
"tools",
ToolNode([search_flights], handle_tool_errors=True),
)
# 方式二:挂自定义兜底,统一回执格式(旅行助手 tools/tool_handler.py 就是这条路)
from langchain_core.runnables import RunnableLambda
def handle_tool_error(state):
last = state["messages"][-1]
return {
"messages": [
# 真实项目里按 last.tool_calls 逐条配对 tool_call_id
("assistant", f"工具执行失败,已记录:{last}")
]
}
builder.add_node(
"tools",
ToolNode([search_flights]).with_fallbacks(
[RunnableLambda(handle_tool_error)]
),
)两种方式的差别:默认容错省事;自定义兜底能统一错误码、打日志、避免把底层堆栈泄漏给模型。无论哪一种,都要在循环外再加保险丝——最大工具调用次数、超时、费用预算。模型连续用同一组参数敲同一扇门时,应停止并给出可解释的失败,而不是把 Token 烧完。
写操作还要在执行前检查权限,并用幂等键防止重复提交。ToolNode 负责调用和封装,不负责相信返回值。网页可能超时,数据库可能空,第三方字段也可能改名。业务层仍要校验类型、范围和错误状态。
4. 高层快车道:create_agent + middleware
如果不想手搓这张小图,LangChain 1.x 提供高层入口。老教程里的 langgraph.prebuilt.create_react_agent 在 LangGraph v1 已弃用,官方改推:
python
from langchain.agents import create_agent
agent = create_agent(
model="openai:gpt-4o-mini",
tools=[search_flights],
system_prompt="你是机票查询助理。先查再答,不要编造不存在的航班。",
)
agent.invoke({
"messages": [{"role": "user", "content": "帮我查明天去东京的航班"}]
})一行 create_agent,内部就是本节第 1 小节那张循环图。它还支持 middleware:在模型调用前后、工具执行前后插入逻辑——动态 Prompt、敏感词过滤、工具报错重试、调用次数限制。第九章 9.9 / 9.11 把中间件栈讲得很细,本章把它当作“已经封装好的 LangGraph 图”来用即可。
什么时候该下沉到裸图?
| 场景 | 更合适的路 |
|---|---|
| 标准单 Agent 工具循环,想最快跑通 | create_agent |
| 要精确控制每一步:敏感工具拦截、多助理路由、状态栈 | 手写 StateGraph |
| 先快车道起步,碰到控制需求再拆开改 | 混合(官方也认可这种演进) |
判断标准很具体:当你发现自己在 create_agent 外面层层包路由、包审批、包子状态时,就该下沉了。反过来,旅行助手那种多助理大图里,每个助理节点内部仍然是一个标准工具循环——高层和底层是同一件事的两个缩放级别。
create_agent 当前不支持用 Pydantic 模型当图状态。需要自定义字段时,用 TypedDict / MessagesState 子类,或直接手写 StateGraph。
工具结果仍是不可信输入
工具由程序执行,不代表内容正确。循环要有最大次数、超时和费用预算;写操作要有权限和幂等。选择预构建 Agent 还是手动画图,看你是否需要自定义状态、复杂路由和精细审批。通用问答用高层组件;业务步骤多、控制要求高,再回到 Graph API。
📁 本节示例代码:code/examples/08_tool_loop_demo.py —— 默认假模型按剧本先调工具再给答案,无需 API Key;加
--real就换成.env里配的真模型(先uv sync --extra real),图一行不改。两次对比着看:剧本模型的“调工具”是写死的,真模型是自己读了工具说明后决定调的。
扩展阅读
- LangChain Agents(
create_agent):docs.langchain.com/oss/python/langchain/agents - Agent Middleware:middleware/overview
- Workflows and agents(Agent 循环与 ToolNode):workflows-agents
- 工具调用与错误处理:tools
下一节: 手里有锤子之后,该先问“这颗钉子到底需不需要锤子”。官方总结的工作流设计模式,就是那张选型菜单。