AI Agent 工程化开发实战(四):工具调用循环——解构 create_agent 背后的机制

目录

  • 前言
  • [一、概念:Agent 是怎么「会调工具」的](#一、概念:Agent 是怎么「会调工具」的)
  • [二、机制拆解:四个零件 + 一张循环图](#二、机制拆解:四个零件 + 一张循环图)
    • [零件 1:`@tool` ------ 把函数变工具](#零件 1:@tool —— 把函数变工具)
    • [零件 2:`bind_tools` ------ 让模型「会喊工具」](#零件 2:bind_tools —— 让模型「会喊工具」)
    • [零件 3:`ToolNode` ------ 真正执行工具](#零件 3:ToolNode —— 真正执行工具)
    • [零件 4:`tools_condition` ------ 决定循环还是结束](#零件 4:tools_condition —— 决定循环还是结束)
    • 组装:一张最小循环图
  • [三、案例:计算 + 查库 + 搜索的多工具 Agent](#三、案例:计算 + 查库 + 搜索的多工具 Agent)
  • 四、工程化要点:防死循环、线程安全、超时、降级
    • [① 防死循环:`recursion_limit`,以及 `remaining_steps` 的概念错位](#① 防死循环:recursion_limit,以及 remaining_steps 的概念错位)
    • [② 工具的线程安全:共享资源别跨线程裸用](#② 工具的线程安全:共享资源别跨线程裸用)
    • [③ 超时:模型调用和工具执行都得设](#③ 超时:模型调用和工具执行都得设)
    • [④ 模型 fallback / 降级:主模型挂了不硬扛](#④ 模型 fallback / 降级:主模型挂了不硬扛)
  • [附:「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「会干活」,图里要多两个东西:

  1. 一个 tools 节点:真正执行工具(查数据库、算数字、搜网页),把结果塞回状态。
  2. 一条 条件边(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 value RemainingSteps(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 套件的内置工具与中间件。例如:

python 复制代码
from 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


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

相关推荐
华为LTCITR专栏流程专家许浩明1 小时前
ITR经验经典案例|服务ITR咨询|诚易达咨询公司许浩明团队辅导杰克科技服务ITR变革
大数据·人工智能·科技
JJJennie7771 小时前
MAI Gateway实战部署:大模型网关部署方案?从0到1上手指南
数据库·人工智能·gateway·ai网关
涛思数据(TDengine)1 小时前
支撑药企全球化合规:TDengine 发布中美欧三地监管对照白皮书
大数据·数据库·人工智能·ai·tdengine
oioihoii1 小时前
Deepseek v5 来了
人工智能
zhongerzixunshi1 小时前
军工保密资质|筑牢涉密科研生产安全底线
人工智能·安全
绕灵儿1 小时前
火灾检测-应用场景-YOLO训练记录
人工智能·算法
超级架构师1 小时前
让 COBOL 长出 AI 大脑:原生 Agent 核心框架 praxo-core 深度剖析
c语言·开发语言·c++·人工智能·python·架构·ai编程
蓝悦无人机1 小时前
Ansys仿真常见错误及警告
人工智能·ansys仿真
skywalk81631 小时前
deepseek对skywalk163/hive 和hivecommons/hive 两个多机AI Agent任务调度项目的对比
人工智能·hive·agent