目录
- 前言
- [一、概念:Agent 是怎么「会调工具」的](#一、概念:Agent 是怎么「会调工具」的)
- [二、机制拆解:四个零件 + 一张循环图](#二、机制拆解:四个零件 + 一张循环图)
-
- [零件 1:`@tool` ------ 把函数变工具](#零件 1:
@tool—— 把函数变工具) - [零件 2:`bind_tools` ------ 让模型「会喊工具」](#零件 2:
bind_tools—— 让模型「会喊工具」) - [零件 3:`ToolNode` ------ 真正执行工具](#零件 3:
ToolNode—— 真正执行工具) - [零件 4:`tools_condition` ------ 决定循环还是结束](#零件 4:
tools_condition—— 决定循环还是结束) - 组装:一张最小循环图
- [零件 1:`@tool` ------ 把函数变工具](#零件 1:
- [三、案例:计算 + 查库 + 搜索的多工具 Agent](#三、案例:计算 + 查库 + 搜索的多工具 Agent)
- 四、工程化要点:防死循环、线程安全、超时、降级
-
- [① 防死循环:`recursion_limit`,以及 `remaining_steps` 的概念错位](#① 防死循环:
recursion_limit,以及remaining_steps的概念错位) - [② 工具的线程安全:共享资源别跨线程裸用](#② 工具的线程安全:共享资源别跨线程裸用)
- [③ 超时:模型调用和工具执行都得设](#③ 超时:模型调用和工具执行都得设)
- [④ 模型 fallback / 降级:主模型挂了不硬扛](#④ 模型 fallback / 降级:主模型挂了不硬扛)
- [① 防死循环:`recursion_limit`,以及 `remaining_steps` 的概念错位](#① 防死循环:
- [附:「Deep Agents 等价实现」贴士](#附:「Deep Agents 等价实现」贴士)
- 参考文献
前言
主线:Agent Runtime → Harness 。第 3 篇我们手写了
StateGraph的「图 / 节点 / 边 / 状态」,但它只会「说话」------模型节点读入消息、吐出一条回复,就结束了。一个真正的 Agent(智能体)要会「干活」:查数据、算数字、调接口。这一篇,我们给图装上「工具」,做出 Agent 最核心的动作------工具调用循环(tool calling loop),并把它跑通。

⚠️ 先把话放这 :本篇是
create_agent内部机制的教学解构 ------手写一遍,是为了让你看清高层那行create_agent背后发生了什么。生产代码请直接用create_agent/create_deep_agent,别手写(理由见第四节)。
一、概念:Agent 是怎么「会调工具」的
先回想第 3 篇那个「带记忆」的图:START → model → END,一条直线,模型节点返回消息就结束。它只会回答问题,不会调用任何外部能力。
要让 Agent「会干活」,图里要多两个东西:
- 一个 tools 节点:真正执行工具(查数据库、算数字、搜网页),把结果塞回状态。
- 一条 条件边(conditional edge) :判断模型这条回复是「想调工具」还是「已经答完了」,决定下一步走
tools还是直接END。
于是图就变成一个环:
yml
START
│
▼
┌───────────┐
│ model │ 带工具的模型:读消息,可能返回一条「要调工具」的消息
└─────┬─────┘
│ 最后一条消息里有 tool_calls 吗?
┌─────┴──────────┐
│ 有 │ 无
▼ ▼
┌────────┐ END
│ tools │ ToolNode 执行工具,结果作为 ToolMessage 塞回消息列表
└───┬────┘
│ 工具结果带回 model,让它「看结果、继续或收尾」
└──────► 回到 model
跑起来就是:模型喊工具 → 工具干活回结果 → 模型看结果 → 继续喊下一个工具,或给出最终回答 。这一「环」循环到模型不再喊工具为止------这就是 create_agent 内部那套 ReAct 循环的骨架。
三个关键角色各司其职(也是本篇的「四零件」前三个):
- 模型 只负责「决定」调哪个工具、传什么参数,它自己不执行工具;
- ToolNode 负责「执行」工具,把结果回填;
- 条件路由 (
tools_condition)负责「看」模型有没有喊工具,决定循环还是结束。
分层提醒(铁律不破):定义工具的
@tool、bind_tools是积木 (langchain-core)的能力;ToolNode、tools_condition是运行时 (LangGraph)的预置件。积木在 LangGraph 之下,工具循环跑在 LangGraph 之上。
二、机制拆解:四个零件 + 一张循环图
零件 1:@tool ------ 把函数变工具
@tool 装饰器(从 langchain_core.tools 导入)把普通函数包成工具,函数签名 + docstring 自动转成工具的 schema,供模型「看懂并调用」。
python
from langchain_core.tools import tool
@tool
def calculator(expression: str) -> str:
"""计算一个数学表达式,例如 "2 + 3 * 4"。支持 + - * / ** 和括号。"""
import re
if not re.fullmatch(r"[0-9+\-*/().\s]+", expression):
return "表达式含非法字符,只允许数字、小数点、括号和 + - * / ** 运算符。"
try:
# 禁掉 builtins,只对纯算术表达式求值(教学演示;生产请用沙箱,见讲沙箱执行的篇章)
return str(eval(expression, {"__builtins__": {}}, {}))
except Exception as e:
return f"计算失败:{e}"
两个要点:docstring 是写给模型看的「说明书」 (它靠这个判断什么时候用你、参数怎么填);类型注解 (expression: str) -> str 自动生成参数 schema。工具报错时「返回字符串」而不是抛异常,模型拿到错误信息能自己调整重试。
安全提醒:
calculator里用eval,做了两道防护------正则白名单只放行数字/运算符/括号,{"__builtins__": {}}禁掉内置函数。这是教学演示的权宜之计;生产环境的「跑任意代码」必须走真正的沙箱(本专栏讲沙箱执行的篇章会展开)。
零件 2:bind_tools ------ 让模型「会喊工具」
光有工具还不够,模型得「知道」有这么些工具、并且被允许调用它们。bind_tools 把工具的 schema 绑到模型上:
python
from langchain_deepseek import ChatDeepSeek
model = ChatDeepSeek(model="deepseek-chat")
model_with_tools = model.bind_tools([calculator]) # 绑上工具,模型从此能输出 tool_calls
绑完后的模型,遇到「算 (12+8)*3」这类问题,返回的不再是一段文字,而是一条带 tool_calls 的 AI 消息(里面是「调 calculator、参数 expression='(12+8)*3'」)。注意:此时工具还没执行,模型只是「下单」。
零件 3:ToolNode ------ 真正执行工具
ToolNode(从 langgraph.prebuilt 导入)是 LangGraph 给的一个预置节点:它读状态里最后一条 AI 消息的 tool_calls,挨个执行对应工具,把每个结果包成一条 ToolMessage 塞回消息列表。我们不用自己写「解析 tool_calls → 调函数 → 回填」这套代码。
python
from langgraph.prebuilt import ToolNode
tool_node = ToolNode([calculator]) # 把工具列表交给它
它默认会捕获参数校验错误 (ToolInvocationError)回填为 error ToolMessage,让模型自己改正参数;但工具执行期 抛出的异常默认会继续向上抛、中断本次执行。要兜住执行期异常,得显式配置 handle_tool_errors(True / str / 异常类型 / callable)。这也正是零件 1 里「工具报错返回字符串而不是抛异常」的原因------把可预期的失败变成正常返回值,交给模型处理。
零件 4:tools_condition ------ 决定循环还是结束
tools_condition(同样来自 langgraph.prebuilt)是一个条件路由函数:看状态里最后一条消息------
- 有
tool_calls→ 返回"tools"(走工具节点); - 没有 → 返回
END(结束)。
它配合 add_conditional_edges 用,整条循环的「什么时候停下来」就是它决定的。
组装:一张最小循环图
四个零件合起来,就是一张完整的工具循环图(完整真源见 code/04-工具调用循环/basics.py):
python
import sys
from typing import Annotated, TypedDict
from langchain_core.messages import HumanMessage
from langchain_core.tools import tool
from langchain_deepseek import ChatDeepSeek
from langgraph.graph import START, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
sys.stdout.reconfigure(encoding="utf-8") # Windows GBK 控制台防中文乱码
@tool
def calculator(expression: str) -> str:
"""计算一个数学表达式,例如 "2 + 3 * 4"。支持 + - * / ** 和括号。"""
import re
if not re.fullmatch(r"[0-9+\-*/().\s]+", expression):
return "表达式含非法字符,只允许数字、小数点、括号和 + - * / ** 运算符。"
try:
return str(eval(expression, {"__builtins__": {}}, {}))
except Exception as e:
return f"计算失败:{e}"
model = ChatDeepSeek(model="deepseek-chat")
model_with_tools = model.bind_tools([calculator])
class State(TypedDict):
messages: Annotated[list, add_messages] # 消息用 add_messages 追加(第 3 篇的 Reducer)
def call_model(state: State) -> dict:
return {"messages": [model_with_tools.invoke(state["messages"])]}
graph = StateGraph(State)
graph.add_node("model", call_model)
graph.add_node("tools", ToolNode([calculator]))
graph.add_edge(START, "model")
graph.add_conditional_edges("model", tools_condition) # 有工具调用→tools,否则→END
graph.add_edge("tools", "model") # 工具结果带回模型,形成循环
app = graph.compile()
def show_trace(result: dict) -> None:
"""把循环里走过的每一步按顺序打印出来,看清「模型 ↔ 工具」的往返。"""
for m in result["messages"]:
if m.type == "ai":
if m.tool_calls:
for tc in m.tool_calls:
print(f" 模型要调工具:{tc['name']}({tc['args']})")
elif m.content:
print(f" 模型回答:{m.content}")
elif m.type == "tool":
print(f" 工具返回:{m.content}")
result = app.invoke(
{"messages": [HumanMessage(content="帮我算一下 (12 + 8) * 3 等于多少?")]},
config={"recursion_limit": 25}, # 防死循环:最多跑 25 步(详见第四节)
)
print(f"你:帮我算一下 (12 + 8) * 3 等于多少?")
show_trace(result)
跑一遍(本文代码实测于 langgraph 1.2.14 / langchain-core 1.6.9 / langchain-deepseek 1.1.1 / tavily-python 0.8.5):
你:帮我算一下 (12 + 8) * 3 等于多少?
模型要调工具:calculator({'expression': '(12 + 8) * 3'})
工具返回:60
模型回答:(12 + 8) * 3 = 60
这三行就是循环的完整轨迹:模型喊工具 → 工具返回 60 → 模型看着 60 给出最终回答 。模型节点被走了两遍------第一遍「下单」,第二遍「收尾」。这张图就是 create_agent 在工具循环这件事上的全部秘密。
三、案例:计算 + 查库 + 搜索的多工具 Agent
一个工具是最小可跑,三个工具才是「多工具」------模型要在三个里自己挑对的那个 。这次不重复第 2 篇的「查天气」,换成三个不同领域:计算器、查数据库、联网搜索 (完整真源见 code/04-工具调用循环/multi_tool_agent.py)。
联网搜索用 Tavily(真联网),需要一把
TAVILY_API_KEY------Tavily 有免费额度,注册即得;pip install tavily-python后设环境变量:Windows 用set TAVILY_API_KEY=你的key,macOS/Linux 用export TAVILY_API_KEY=你的key(变量名参考code/.env.example)。
图的搭法和上面一模一样,只有三处变:tools 列表变三个、ToolNode(tools) 传三个、还要重新 model.bind_tools(tools) (否则模型不认得 query_db / web_search 两个新工具)。
下面这个代码块是依赖第二节的增量片段 (
calculator/State/call_model/model的定义在第二节,此处省略,只贴「新增两个工具 + 重新绑定 + 图」),不要单独粘贴运行 ;完整可运行文件见code/04-工具调用循环/multi_tool_agent.py。
python
import os
import sqlite3
from tavily import TavilyClient
# 工具 1:计算器(同前,略)
# 工具 2:查数据库(sqlite3 标准库,零依赖)
# 关键:LangGraph 会在「另一个线程」里执行工具,SQLite 连接默认绑线程,
# 跨线程用会报 "created in a thread can only be used in that same thread"。
# 这里用 check_same_thread=False 放开(教学演示);生产建议连接池或每次新建连接。
DB = sqlite3.connect(":memory:", check_same_thread=False)
DB.execute("CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, dept TEXT, salary INTEGER)")
DB.execute("INSERT INTO employees VALUES "
"(1, '小明', '研发部', 30000), (2, '小红', '产品部', 28000), (3, '小刚', '研发部', 32000)")
DB.commit()
@tool
def query_db(sql: str) -> str:
"""查询员工数据库。只允许 SELECT。表 employees(id, name, dept, salary),dept 如 '研发部'/'产品部'。"""
if not sql.strip().lower().startswith("select"):
return "只允许 SELECT 查询。"
try:
return str(DB.execute(sql).fetchall())
except Exception as e:
return f"查询失败:{e}"
# 工具 3:联网搜索(Tavily,真联网;需一把 TAVILY_API_KEY)
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY")
_tavily = TavilyClient(api_key=TAVILY_API_KEY) if TAVILY_API_KEY else None
@tool
def web_search(query: str) -> str:
"""联网搜索(Tavily),返回 top-3 结果的标题 + 链接 + 摘要。"""
if _tavily is None:
return "(未配置 TAVILY_API_KEY,无法联网搜索;请在环境变量里设置后重试)"
try:
resp = _tavily.search(query, max_results=3)
lines = []
for r in resp.get("results", []):
# 摘要截断到 200 字并压成一行,避免把整页内容塞进上下文、爆 token
content = r.get("content", "").strip().replace("\n", " ")[:200]
lines.append(f"- {r['title']}({r['url']})\n {content}")
return "\n".join(lines) if lines else "没有搜到结果。"
except Exception as e:
return f"搜索失败:{e}"
tools = [calculator, query_db, web_search]
model_with_tools = model.bind_tools(tools)
# 图:和最小循环一模一样,只是 ToolNode(tools)
graph = StateGraph(State)
graph.add_node("model", call_model)
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "model")
graph.add_conditional_edges("model", tools_condition)
graph.add_edge("tools", "model")
app = graph.compile()
跑一遍:模型自己挑工具,还能「连环调」
问三个新问题(第一个 calculator 问题第二节已跑过,这里跳过),看模型怎么路由。下面是某次真实运行的轨迹------模型每次生成的 SQL 与措辞可能不同,关键看「模型 → 工具 → 模型」的路由对不对:
txt
你:研发部工资最高的同事是谁?工资是多少?
模型要调工具:query_db({'sql': "SELECT * FROM employees WHERE dept = '研发部' ORDER BY salary DESC"})
工具返回:[(3, '小刚', '研发部', 32000), (1, '小明', '研发部', 30000)]
模型回答:研发部工资最高的同事是小刚,工资是 32000 元。
单独一个工具不够劲,来看一次运行里连环调两个工具------这才是「循环」真正的样子:
txt
你:查一下研发部工资最高的员工,再用计算器把他工资乘以 1.1。
模型要调工具:query_db({'sql': "SELECT * FROM employees WHERE dept = '研发部' ORDER BY salary DESC LIMIT 1"})
工具返回:[(3, '小刚', '研发部', 32000)]
模型要调工具:calculator({'expression': '32000 * 1.1'})
工具返回:35200.0
模型回答:研发部工资最高的员工是小刚(32000 元),乘以 1.1 后是 35200 元。
看清楚这个轨迹:query_db → 拿到 32000 → calculator → 拿到 35200 → 最终回答 。模型在这一轮里走了「模型 → 工具 → 模型 → 工具 → 模型」五步,全程无人干预。它之所以会连环调,是因为第一步 query_db 的结果告诉它「还差一步计算」,于是继续喊 calculator,直到信息够了才收尾------循环的停止条件,就是模型不再喊工具。
第三个工具「联网搜索」走的是真 Tavily,问一句「LangGraph 是什么」带回真实网页:
txt
模型要调工具:web_search({'query': 'LangGraph 是什么'})
工具返回:- 什么是LangGraph?(https://www.ibm.com/cn-zh/think/topics/langgraph)
LangGraph 由 LangChain 创建,是一个开源的 AI 智能体框架......
- langGraph的中文指引(https://github.com/jurnea/LangGraph-Chinese)
LangGraph() 是一个 python 库,用于构建有状态的、多操作的大模型应用......
- 一文搞懂LangChain 新利器(https://blog.csdn.net/musicml/article/details/136441895)
LangGraph 用于构建有状态和多角色的 Agents 应用......
模型回答:LangGraph 是由 LangChain 创建的开源 AI 智能体框架,基于图(Graph)架构编排有状态、可循环的 Agent 工作流。
每条摘要被截到 200 字、压成一行,否则 Tavily 会把整页正文塞进上下文、一次搜索烧掉几千 token------工具返回「够用就好」是成本控制第一课(讲成本与限流的部分会展开)。计算那个问题也各自路由正确,完整可运行文件见
code/04-工具调用循环/multi_tool_agent.py。
踩坑记录(真跑出来的) :第一版query_db的 SQLite 连接没加check_same_thread=False,结果模型一调工具就报SQLite objects created in a thread can only be used in that same thread------因为 LangGraph 会在另一个线程 执行工具节点,而 SQLite 连接默认绑线程。这暴露了一条工程常识:工具函数里持有的跨调用共享资源(数据库连接、HTTP 连接池、全局缓存),必须线程安全。第四节再展开。
四、工程化要点:防死循环、线程安全、超时、降级
① 防死循环:recursion_limit,以及 remaining_steps 的概念错位
循环图最怕「死循环」:模型一直喊工具、工具一直返回、模型又喊...... 跑不完。LangGraph 有两道闸:
-
recursion_limit(图级) :整张图最多执行的「超步数」上限,默认 25。超出就抛GraphRecursionError。上文里config={"recursion_limit": 25}就是这道闸。这是手写 StateGraph 时的正确防死循环手段。 -
remaining_steps(托管值) :这是 LangGraph 的 managed valueRemainingSteps(langgraph.managed),手写 StateGraph 也能用 ------在状态里声明remaining_steps: RemainingSteps后,运行时自动注入「还剩多少 super-step」。它只是一个只读快照,不负责「停」(「停」由路由 / 递归上限决定);create_agent的AgentState也声明了它,所以你在高层能读到这个字段。
别把两者混为一谈 :手写图用 recursion_limit 兜底防死循环;需要「最多调 N 次工具」这类业务级 限制,就自己在状态里加计数器、在节点里递减判断。remaining_steps 只告诉你「运行时还剩多少步」,不是业务计数器。
顺带纠正一个老坑:网上大量 ReAct 教程还在用
create_react_agent,它在 LangGraph 1.0 已弃用,统一走create_agent(完整的弃用 API 对照表集中在讲 create_agent 源码拆解的那篇)。
② 工具的线程安全:共享资源别跨线程裸用
第三节的 SQLite 坑就是这个主题的活例子。LangGraph 可能在不同线程执行工具,所以:
- 数据库连接:要么
check_same_thread=False,要么在工具内部每次新建连接,要么上连接池(生产推荐); - HTTP 客户端(
requests.Session/httpx.Client):连接池本身大多线程安全,但别在工具里共享一个带状态的对象而不加锁; - 全局计数器 / 缓存:多线程读写要么加锁,要么用线程安全的数据结构。
一句话:工具函数尽量写成「无状态」的------入参即全部输入,别依赖进程里的可变全局状态;非要有,就想清楚并发。
③ 超时:模型调用和工具执行都得设
Agent 一旦接上外部工具,就引入了外部不确定性:工具可能卡住(数据库慢、接口挂)。两个层面都要设超时:
- 模型调用超时 :
ChatDeepSeek(..., timeout=...)之类,避免模型侧长时间不返回; - 工具执行超时 :工具函数内部自己控(如
httpx的 timeout、子进程的timeout=),别让一个卡死的工具把整张图挂住。
超时之后让工具返回「超时」这个结果字符串给模型,而不是抛异常------模型能据此给用户一个交代或换条路,而不是整条链路崩掉。
④ 模型 fallback / 降级:主模型挂了不硬扛
生产环境模型服务有波动(限流、超时、故障)。工程化做法是给模型调用包一层 fallback:
python
model_with_fallback = model_with_tools.with_fallbacks([backup_model_with_tools])
主模型失败时自动切到备用模型(可以是同厂商的另一个 model,或换了厂商的等价模型)。切模型的成本前几篇讲过:同厂商改一行 model=,换厂商改 import + model 两行------所以 fallback 的写法是模型无关的,成本很低。
降级策略再进一步:不是所有问题都值得走工具循环。可以在入口按「意图」分流------简单闲聊直接裸调模型(不绑工具,省 token、省延迟),只有「要查要算」的才进工具循环。这是成本与体验的权衡,讲服务化与成本的那部分会展开。
附:「Deep Agents 等价实现」贴士
Deep Agents 等价实现
本篇手写的「model + tools 循环图」,在高层是一行
create_deep_agent------它内部就是create_agent(薄壳)帮你搭了这张循环图,再叠上 Deep Agents 套件的内置工具与中间件。例如:
pythonfrom deepagents import create_deep_agent agent = create_deep_agent(model=model, tools=[calculator, query_db, web_search]) result = agent.invoke({"messages": [{"role": "user", "content": "查一下研发部工资最高的员工,再算他加薪 10% 是多少"}]})你什么都不用搭,它内部照样是「模型喊工具 → 工具干活 → 模型看结果 → 继续或收尾」这套循环。需要自己掌控循环细节(自定义路由、并行工具、自定义防死循环逻辑)时,再回到本篇的底层写法。
参考文献
1 LangGraph 官方文档 · Overview --- https://docs.langchain.com/oss/python/langgraph/overview
2 LangGraph 官方文档 · Graph API(节点 / 边 / 条件边)--- https://docs.langchain.com/oss/python/langgraph/graph-api
3 LangGraph API 参考 · ToolNode / tools_condition(tool_node 模块)--- https://reference.langchain.com/python/langgraph.prebuilt/tool_node

🎯 更多专栏系列文章可以查看博客主页📑 👍 若文章对你有所触动,恳请点赞 ⭐ 关注 ⭐ 收藏