从 Workflow 到 Agent:理解 ReAct 和 Tool Calling
1. 从 Workflow 到 Agent:为什么需要新的架构
过去很长一段时间里,大模型的使用主要是围绕 Prompt 工程展开。随着 LangChain、LangGraph 等框架的发展,越来越多的应用开始采用工作流(Workflow)模式,将复杂任务拆解为多个步骤,再按照固定顺序执行。这种模式能够有效提升系统的稳定性和可维护性,但当任务复杂度进一步提高时,仅靠预定义流程可能无法满足需求。Agent 正是在这种背景下出现的一种新架构。
本文相关代码放在 reAct_tool_calling/ 目录下。依赖 LangGraph、Chroma、数据库或外部检索服务,沿用之前的 get_llm() 写法,通过 DashScope 的 OpenAI compatible 接口调用 deepseek-v4-pro:
1 | common.py |
1.1 Workflow 的本质是预定义流程
在前面的 Web Research Chain 中,整个研究报告生成流程是一个典型的工作流。
1 | 用户问题 → 搜索词生成 → 网页搜索 → 网页抓取 → 内容总结 → 报告生成 |
虽然中间可能存在条件判断或分支选择,但这些路径在系统设计阶段就已经确定。例如:
1 | if question_type == "weather": |
无论用户输入什么内容,系统最终都只能在预先设计好的路径中运行。这种方式有两个明显特点:
- 执行步骤固定
- 路由规则固定
因此 Workflow 更像是一条流水线。对于结构明确的问题,这种方式效果很好。例如网页研究、文档分析、RAG 问答等场景,本质上都属于确定性较强的任务,只要按照既定流程执行即可获得结果。
1.2 Workflow 的边界
但现实中的任务并不总是能够提前规划完整流程。例如:
1 | 苏州有哪些值得游玩的地方? |
这种问题可以直接走知识库检索流程。但如果问题变成:
1 | 苏州最近天气怎么样? |
系统需要调用天气服务。再进一步:
1 | 苏州哪里值得玩? |
此时既需要旅游知识库,又需要天气工具。如果继续采用传统工作流,代码很快会变成:
1 | if contains_weather: |
工具越多,条件分支越多。随着系统能力扩展,维护成本会呈指数级增长。更重要的是,很多复杂问题根本无法提前预测所有组合情况。例如:
1 | 上海到苏州怎么去? |
这类问题可能同时涉及:
1 | 地图工具 |
在设计阶段很难穷举所有路径,这也是 Workflow 的边界。
1.3 Agent 与 Workflow 的本质区别
Agent 的出现,本质上是改变了决策方式:
1 | # Workflow |
这是两种完全不同的思路。Workflow 的控制权掌握在代码。
1 | # Workflow 的控制权掌握在代码 |
例如面对:
1 | 我想看江南园林,应该去哪里? |
Agent 可能先调用旅游知识库。而面对:
1 | 苏州明天适合逛园林吗? |
Agent 可能先查询天气,再查询景点信息。面对:
1 | 推荐一个周末江南旅行行程 |
Agent 又可能连续调用多个工具,最后汇总生成结果。这些执行路径并不是开发阶段写死的,而是在运行过程中由模型动态决定。这也是 Agent 与 Workflow 最核心的区别。
1.4 Agent 不等于更智能
Agent 的能力来源于:
1 | 大模型 + 工具 + 动态决策机制 |
其中大模型负责推理和规划。工具负责获取真实世界信息。动态决策机制负责决定何时使用工具。因此 Agent 的优势不在于模型参数更多,而在于能够访问模型本身无法获得的信息。例如:
1 | 天气数据 |
这些内容原本并不在模型训练数据中。如果没有工具支持,大模型只能根据已有知识进行推测。生成的内容可能看起来合理,但看起来真实并不等于真实存在。而当 Agent 调用了外部工具之后,回答便建立在真实数据基础之上,这样可以获得更高的准确性和可验证性。
1.5 从工作流到智能体
回顾整个过程,可以看到一条路线:
1 | Prompt → Workflow → Tool Calling → ReAct → Tool Agent |
Prompt 解决的是模型表达问题,Workflow 解决的是流程组织问题。Tool Calling 解决的是外部能力接入问题。ReAct 解决的是动态决策问题。而 Agent,则是在这些能力基础上形成的一种新应用架构。接下来将进一步讨论 Tool Calling 的工作机制,以及大模型如何借助工具完成自主决策,从而构建真正意义上的 Agent。
2. Tool Calling:让大模型获得外部能力
前一章已经提到,Agent 与 Workflow 的最大区别在于流程控制权发生了变化。Workflow 由程序决定下一步做什么,而 Agent 则由大模型决定下一步应该执行什么操作。但这里会产生一个新的问题。大模型即使能够决定下一步要做什么,也不意味着它真的具备执行这些操作的能力。例如:
1 | 查询天气 |
这些都属于模型训练过程之外的能力。如果没有额外机制,大模型仍然只能生成文本。Tool Calling 的出现,就是为了让模型能够借助外部系统完成这些工作。
2.1 大模型为什么需要工具
假设直接向模型提问:
1 | 杭州明天的天气怎么样? |
模型可能会生成一段看起来合理的回答:
1 | 杭州明天多云,最高气温28℃。 |
问题是这个结果未必来自真实天气数据。模型只是根据训练过程中见过的大量天气描述,推测出一个概率最高的答案。从语言表达上看没有问题,但无法保证真实性。这也是大模型应用中最常见的问题之一。
1 | 看起来真实 ≠ 真实存在 |
模型擅长生成语言,却无法主动访问外部世界。因此很多实际业务系统都需要接入:
1 | 数据库 |
通过这些工具获取真实数据,再由模型负责理解和组织结果。此时系统结构就需要调整。
1 | 用户问题 → LLM → 工具 → 真实数据 → LLM → 最终答案 |
这就是 Tool Calling 的基础思路。
2.2 Tool 的本质是什么
对于程序来说,一个 Tool 就是一个普通函数。例如:
1 | def get_weather(city: str): |
这些函数原本只能由程序主动调用。而 Tool Calling 做的事情,是把这些函数描述给大模型,让模型知道系统里存在哪些能力可以使用。因此 Tool 更准确的定义应该是,可以被大模型主动调用的外部能力。能力的来源并不重要。可能来自:
1 | Python 函数 |
只要能够接收参数并返回结果,都可以被包装成 Tool。
2.3 从 Function Calling 到 Tool Calling
早先 OpenAI 提供的是 Function Calling。模型并不会直接执行函数,而是返回一个函数调用请求。例如:
1 | { |
程序收到这段结构化数据后:
1 | 找到函数 |
完成一次调用。后来随着能力扩展,调用对象已经不再局限于函数。可能是:
1 | 数据库 |
因此 OpenAI 将概念统一升级为 Tool Calling。对于模型来说,它并不关心底层实现。只需要知道:
1 | 工具名称 |
即可。
2.4 LangChain 如何定义 Tool
在 LangChain 中,最常见的写法是使用 @tool 装饰器。例如旅游知识库工具:
1 |
|
这里有一个容易忽略的细节。模型实际上看不到函数实现。模型能够看到的只有:
1 | 工具名称 |
因此 Tool 的描述质量会直接影响工具调用效果。很多 Tool 调用失败,并不是工具本身有问题,而是说明文档没有准确表达工具用途。
2.5 bind_tools 的实现机制
工具定义完成后,还需要注册给模型。例如:
1 | llm_with_tools = llm.bind_tools( |
内部发生了一次转换。LangChain 会自动读取:
1 | 函数名称 |
并生成对应的 Tool Schema。最终发送给模型的信息更接近:
1 | { |
从这一刻开始,模型便知道系统中存在这样一个能力。当用户提出问题时,模型会自行判断:
1 | 是否需要调用工具 |
因此:
1 |
完成了一次能力注册过程。
1 | Python 函数 → Tool Schema → 注册给模型 |
2.6 Tool Calling 不是执行工具
这里还有一个重要概念容易混淆。模型发起 Tool Call,并不意味着工具已经执行。例如模型返回:
1 | { |
这只是一个调用请求,真正执行工具的仍然是程序,执行流程实际上是:
1 | LLM → 生成 Tool Call → 程序执行 Tool → 获得结果 → 返回 LLM |
模型负责决策,程序负责执行,这是 Tool Calling 的核心工作机制。
2.7 Tool Calling:从对话模型走向智能体
当系统只有一个工具时,Tool Calling 看起来只是一次普通函数调用。但随着工具数量增加,情况开始发生变化。例如:
1 | 旅游知识库 |
面对同一个问题:
1 | 苏州哪里值得玩? |
模型可能需要:
1 | 先调用旅游知识库 |
此时 Tool Calling 已经不再是单纯的函数执行机制,而开始承担任务规划和能力调度的职责。这也是 Agent 架构能够成立的基础。下一章将进一步讨论 ReAct 模式,以及大模型如何通过“思考—行动—观察”的循环机制,将多个 Tool 串联起来完成复杂任务。
3. ReAct:现代 Agent 的运行基础
Tool Calling 解决了一个关键问题,让大模型拥有调用外部能力的入口。但仅有工具还不够。如果系统中同时存在天气查询、知识库检索、地图搜索等多个工具,大模型还需要知道:
1 | 什么时候调用工具 |
如果这些决策仍然提前编写规则,那么系统本质上还是 Workflow。真正让 Agent 具备自主决策能力的,是 ReAct(Reason + Act)模式。
3.1 ReAct 解决的问题
传统聊天模式非常简单。
1 | 用户问题 → LLM → 最终答案 |
整个过程只有一次模型调用。例如:
1 | 法国首都是哪里? |
模型直接回答:
1 | 巴黎 |
这种模式适合模型已经掌握答案的问题。但对于需要外部信息的问题,就会出现问题。例如:
1 | 杭州明天会下雨吗? |
模型无法直接知道真实天气。此时最合理的流程应该是:
1 | 分析问题 → 查询天气 → 获得结果 → 组织答案 |
也就是说,在回答之前需要先执行动作。ReAct 正是为了解决这种场景提出的。
3.2 Reason 与 Act
ReAct 这个名字来自两个单词,Reason(推理)、Act(行动),核心思想是先思考,再行动。获得结果后继续思考,再决定是否继续行动。整个过程形成循环。
1 | Question → Reason(负责规划) → Act(负责调用工具) → Observation(负责接收工具结果) → Reason(负责规划) → Act(负责调用工具) → Observation(负责接收工具结果) → Final Answer |
这很像人解决问题时的思考过程。
3.3 一个完整例子
假设问题是:
1 | 我想看江南园林,应该去哪里? |
第一次进入模型。此时模型没有任何资料。它可能产生如下推理:
1 | 需要查询江南园林相关的旅游信息 |
随后生成 Tool Call。
1 | search_travel_info( |
工具执行完成后返回结果。
1 | 苏州是江南园林最典型的目的地之一 |
模型再次收到信息。这时候会重新分析:
1 | 已经获得地点信息、可以生成答案 |
最终返回:
1 | 如果想看江南园林,首推苏州;如果还想结合湖景和茶文化,可以把杭州一起放进行程…… |
整个过程中发生了两次推理。第一次决定调用工具。第二次决定结束任务。这就是一个标准的 ReAct 循环。
3.4 ReAct 为什么比 Workflow 更灵活
Workflow 的特点是路径固定。例如:
1 | 搜索 → 总结 → 回答 |
所有问题都会经过同样流程。而 ReAct 的特点是动态决策。面对不同问题,可以产生完全不同的执行路径。例如:
1 | 杭州有哪些景点? |
可能只调用知识库。
1 | 知识库 → 回答 |
而:
1 | 苏州最近天气如何? |
则可能变成:
1 | 天气工具 → 回答 |
如果问题是:
1 | 苏州哪里值得玩? |
执行过程又会变成:
1 | 知识库 → 天气工具 → 汇总结果 → 回答 |
执行路径由模型实时决定。这也是 ReAct 相比传统 Workflow 最大的优势。
3.5 ReAct 并不是无限循环
Agent 并不会一直循环下去,每次进入模型时,都需要做出一个判断:
1 | 是否还需要工具 |
如果需要:
1 | 继续调用 Tool |
如果不需要:
1 | 生成最终答案 |
因此 Agent 的结束条件其实非常简单。
1 | 存在 Tool Call → 继续执行 → 不存在 Tool Call → 结束任务 |
后面 LangGraph 中的 tools_condition 节点,本质上就是在完成这个判断。
3.6 Query Rewrite:Agent 的隐藏能力
在实际运行过程中,可能出现这样一个现象。用户输入:
1 | 推荐适合看江南园林的城市 |
模型生成的检索请求往往不是原始问题,而是:
1 | 江南园林城市推荐 |
也就是说,模型会主动改写查询。这种能力本质上与 RAG 中的 Query Rewrite 十分相似。不同的是,以前需要额外设计查询改写模块。而在 Agent 中,这项工作往往由模型自行完成。这也是为什么很多 Agent 在检索场景下比简单 RAG 效果更好的原因之一。
3.7 ReAct 为什么会是 Agent 的标准模式
今天几乎所有主流 Agent 框架都建立在 ReAct 思想之上。包括:
1 | LangGraph |
虽然具体实现不同,但底层逻辑几乎一致。
1 | Reason → Act → Observation → Reason |
因为 ReAct 解决了 Agent 最核心的问题:让模型能够基于当前状态动态决定下一步行动,而不是按照预先写好的流程执行。 从某种意义上说,Tool Calling 提供了能力入口,而 ReAct 则提供了能力调度机制。有了这两部分之后,一个真正意义上的 Tool Agent 才开始形成。接下来将结合 reAct_tool_calling 里的可运行代码,拆解一个完整 ReAct Agent 的内部结构,看看 Tool 执行节点、Message History 和停止条件是如何协同工作的。
4. 用 Python 手写一个 ReAct Agent
理解了 ReAct 的运行逻辑之后,接下来看看这套循环到底如何落地成代码?02_manual_react_single_tool.py 并没有直接使用现成 Agent,而是先手动实现一个最小可运行的 ReAct Agent。这样做的目的是为了彻底理解 Agent 内部到底发生了什么。
本章对应的代码主要分布在两个文件中:
1 | common.py |
common.py 负责准备 Agent 运行所需的基础对象,包括 Message、ToolCall、Tool、旅游知识库、模型封装和工具执行函数。02_manual_react_single_tool.py 则负责把这些对象组合成一个最小 ReAct 循环。
这一章先按代码依赖顺序拆开看
1 | 本地知识库 → 普通查询函数 → Tool → 带工具的模型 → Message History → ReAct 循环 |
4.1 从旅游知识库开始
首先在 common.py 中准备了一个很小的中国城市旅行知识库。真实项目里,这里可以来自网页、企业文档、数据库或向量库。这个 demo 为了聚焦 Agent 机制,工具数据直接放在本地列表中;需要联网的是模型调用,而不是旅游资料查询。
代码结构类似这样:
1 | TRAVEL_KNOWLEDGE = [ |
这里每条资料都包含三个字段:
| 字段 | 含义 |
|---|---|
title |
城市名称 |
text |
可返回给模型的事实资料 |
keywords |
用于本地匹配的关键词集合 |
这一步的核心是准备一个可被工具查询的数据源。后面 Agent 并不会直接读取 TRAVEL_KNOWLEDGE,而是通过查询函数访问它。查询函数是:
1 | def search_travel_info(query: str) -> str: |
这个函数的输入是模型生成的查询词:
1 | "江南 园林 水乡 苏州 杭州" |
输出是一段可直接放回上下文的文本:
1 | - 苏州: 苏州以古典园林、平江路、山塘街和博物馆见长。如果想看园林、老城街巷和江南水乡气质,苏州比大城市更适合慢慢走。 |
1 | TRAVEL_KNOWLEDGE → normalize(query) → 关键词匹配 → 返回相关中国城市旅行资料 |
其中最重要的不是存储方式,而是统一查询接口。真实项目中,这个接口可能是:
1 | Chroma |
Agent 真正需要的只是:
1 | search_travel_info(query) |
这样的可调用能力。因此在 Agent 体系中,经常会看到这样一层抽象:
1 | 数据源 → 查询函数 → Tool → Agent |
知识库负责提供事实。查询函数负责检索。Tool 负责向 Agent 暴露能力。
4.2 把查询函数封装成 Tool
知识库准备完成后,需要把检索能力暴露给模型。最简单的方式就是封装成 Tool。例如:
1 | TOOLS = { |
这段代码在 common.py。它做的事情不是执行查询,而是声明一个工具。可以把它拆成四个字段看:
| 字段 | 作用 |
|---|---|
name |
模型调用工具时使用的名字 |
description |
告诉模型这个工具适合解决什么问题 |
parameters |
告诉模型调用时需要传哪些参数 |
fn |
真正被程序执行的 Python 函数 |
底层函数仍然很普通:
1 | def search_travel_info(query: str) -> str: |
但被 Tool(...) 包装之后,它就拥有了新的身份。此时模型能够通过工具名称、说明和参数定义感知到这个能力的存在。
这里有一个容易忽略的细节:对于模型来说,真正重要的并不是函数实现,而是工具描述。在 common.py 中,工具描述写在这里:
1 | Tool( |
模型无法读取函数内部代码。能够看到的只有:
1 | 工具名称 |
封装成 Tool 后,工具的调用方式也统一了。外部不再直接写:
1 | search_travel_info("江南 园林") |
而是写:
1 | TOOLS["search_travel_info"].invoke({ |
这一步看起来只是多包了一层,但它让所有工具都变成同一种形状:
1 | 工具名 + 参数dict → 执行结果 |
后面的 execute_tool_calls() 正是依靠这个统一接口执行工具。
4.3 Tool Schema 背后的工作机制
Tool 定义完成后,还需要注册给模型。在 LangChain 中,常见写法是:
1 | llm_with_tools = llm.bind_tools( |
在代码里,工具同时提供了两层表示。第一层是 Tool.schema(),用于在 01_tool_calling_schema.py 中直接打印工具描述,帮助观察模型能看到什么:
1 | def schema(self) -> dict[str, Any]: |
01_tool_calling_schema.py 会直接打印这个结构:
1 | tool = TOOLS["search_travel_info"] |
输出结构大致是:
1 | { |
这就是模型能够看到的工具说明。注意,这里没有函数体,也没有 TRAVEL_KNOWLEDGE。模型只知道自己可以调用一个叫 search_travel_info 的工具,并且调用时要传入 query。
第二层是 Tool.to_langchain_tool(),用于把本地工具转换成 LangChain 可以注册的 StructuredTool:
1 | def to_langchain_tool(self): |
然后 LangChainTravelModel 使用 get_llm() 创建模型,并通过 bind_tools() 注册工具:
1 | class LangChainTravelModel: |
这里的输入是工具名列表:
1 | enabled_tools=["search_travel_info"] |
构造函数先从全局 TOOLS 中取出对应工具:
1 | self.tool_map = {name: TOOLS[name] for name in enabled_tools} |
再把这些工具转换成 LangChain 工具:
1 | [tool.to_langchain_tool() for tool in self.tool_map.values()] |
最后绑定到模型上:
1 | self.llm = get_llm().bind_tools(...) |
所以 bind_tools()是一次 Tool Schema 转换和注册,LangChain 会自动读取函数名称、参数类型、Docstring,然后生成标准 Tool Schema。最终发送给模型的内容更接近:
1 | { |
然后模型便知道系统中存在一个可以查询旅游信息的能力。之后用户提问时,模型会自行判断:
1 | 是否需要调用 |
因此,真实框架里的:
1 |
和当前 demo 里的:
1 | Tool(...) + tool.to_langchain_tool() + get_llm().bind_tools(...) |
本质上都是在完成一次能力注册过程。
4.4 Messages 如何承载 Agent 的运行状态
接下来需要定义 Agent 状态。在 LangGraph 里,常见写法是:
1 | class AgentState(TypedDict): |
在 common.py 中,为了减少框架概念,直接用 Message 数据类表示消息:
1 |
|
这个类同时表示三种消息:
| role | 表示什么 | 关键字段 |
|---|---|---|
user |
用户输入 | content |
assistant |
模型输出 | content 或 tool_calls |
tool |
工具返回结果 | content、tool_call_id、tool_name |
第一次看到时容易产生疑问。为什么状态里只有一个字段?事实上,对于 ReAct Agent 来说,消息历史已经包含了全部上下文。例如:
1 | Human → AI → Tool → AI |
一次完整执行过程中产生的所有信息都会进入消息列表。例如:
1 | [ |
其中:
1 | 用户问题 |
全部都会保留。因此 Agent 的记忆并不是单独维护的变量,而是整条 Message History。这也是现代 Agent 框架普遍采用的设计方式。这也解释了为什么 02_manual_react_single_tool.py 里只需要初始化一条消息:
1 | messages = [Message(role="user", content=question)] |
后续所有中间结果都会追加到这个列表中,而不是分散保存在多个变量里。
4.5 ToolNode:Agent 执行动作的核心节点
很多人在学习 Tool Calling 时,会误以为模型负责执行工具。实际上并不是。模型只能生成调用请求。真正执行工具的是程序里的工具执行节点。在 LangGraph 中,这个角色通常叫 ToolNode。在 common.py 中,对应的是:
1 | def execute_tool_calls(ai_message: Message, tools: dict[str, Tool]) -> list[Message]: |
这个函数的输入是上一轮模型输出的 assistant message:
1 | Message( |
以及当前 Agent 允许使用的工具表:
1 | tools = { |
函数会遍历 tool_calls,根据 call.name 找到对应工具,再把 call.args 传进去:
1 | result = tools[call.name].invoke(call.args) |
执行完成后,输出不是普通字符串,而是一组 tool 消息:
1 | [ |
例如模型返回:
1 | AIMessage( |
这个函数会读取:
1 | tool_calls |
找到对应工具。
1 | tool.invoke(args) |
执行函数。获得结果后再封装成:
1 | ToolMessage(...) |
重新放回消息列表。整个过程如下:
1 | AIMessage → Tool Call → 工具执行节点 → 执行 Tool → ToolMessage |
因此 Agent 的运行过程实际上是一系列消息不断累积的过程。
4.6 LLM Node 是 Agent 的决策中心
与 ToolNode 相对应的是 LLM Node。它的职责只有一个,思考下一步做什么。每次进入 LLM Node 时,模型都会读取当前 Message History。然后做出判断。第一种情况,信息不足,生成 Tool Call。
1 | AIMessage( |
第二种情况是信息已经足够,直接生成答案。
1 | AIMessage( |
因此在 ReAct 体系中:
1 | LLM Node = Reason |
这正好对应 ReAct 中最核心的两个环节。在代码中,LLM Node 对应的是:
1 | ai_message = model.invoke(messages) |
LangChainTravelModel.invoke() 内部还做了一次消息格式转换。因为当前代码中使用自己的 Message 数据类,而 LangChain 模型需要的是 HumanMessage、AIMessage 和 ToolMessage:
1 | lc_messages = self._to_langchain_messages(messages) |
如果模型返回了工具调用,代码会把 LangChain 的 tool_calls 转回本地 ToolCall:
1 | tool_calls = [ |
因此 model.invoke(messages) 的输出始终是本地 Message。这样外层 ReAct 循环不需要关心 LangChain 的具体消息类型,只需要判断:
1 | if ai_message.tool_calls: |
4.7 tools_condition:驱动 Agent 决策流转的关键机制
到这里还缺少最后一个问题。Agent 如何知道什么时候结束?答案就在一个简单条件判断里:
1 | if ai_message.tool_calls: |
在 02_manual_react_single_tool.py 中,这个条件判断写在循环内部:
1 | while True: |
这段代码可以按输入输出拆成三步。
1 | # 1. 拿当前消息历史调用模型 |
如果没有工具调用,说明模型已经给出最终答案,Agent 结束。如果还有工具调用,就执行工具,并把工具结果继续追加回 messages:
1 | messages.extend(execute_tool_calls(ai_message, tools)) |
真实 LangGraph 中,这个判断通常封装为 tools_condition()。它的逻辑非常简单。
1 | if tool_calls: |
如果模型生成了 Tool Call。继续执行工具。如果没有生成 Tool Call。说明模型已经获得足够信息。流程结束。因此整个 Agent 图最终变成:
flowchart LR LLM1["LLM
Reason"] TC{"是否存在
Tool Call"} TOOL["ToolNode
Act"] LLM2["LLM
Reason"] END["END
Final Answer"] LLM1 --> TC TC -- 是 --> TOOL TOOL --> LLM2 LLM2 --> TC TC -- 否 --> END
这张图已经具备了完整 ReAct Agent 的全部能力。到这里一个真正意义上的 Tool Agent 已经构建完成。下一章将结合实际运行过程,逐步跟踪一次完整 Agent 调用,观察 Message、Tool Call、Tool Result 在系统内部是如何流转的。
5. 调试 Agent:看懂一次完整执行过程
前面已经完成了一个最小可运行的 ReAct Agent,它的结构并不复杂:
1 | LLM Node → ToolNode → LLM Node |
Agent 需要理解的核心是运行时消息是如何流转的,以及模型为什么有时调用工具、有时直接回答。02_manual_react_single_tool.py 接下来通过打印消息列表展示了一次完整执行过程。这一段很关键,因为它把抽象的 ReAct 循环变成了可以观察的实际状态变化。
5.1 Chat Loop 只是外层入口
为了运行 Agent,代码中实现了一个最小循环。完整函数在 02_manual_react_single_tool.py 中:
1 | def run_manual_react(question: str) -> list[Message]: |
输入调用:
1 | messages = run_manual_react("我想看江南园林,应该去哪里?") |
输出是完整消息历史:
1 | {message.role}: {message.content} |
也就是说,它不会只返回最终回答,而是把中间的模型决策、工具调用和工具结果全部保留下来。而不是只打印最后一条消息。这个循环本身不是 Agent 的核心。它只是完成三件事:
1 | 调用模型 |
真正的 Agent 逻辑就藏在这个循环里。也就是说,外层看起来只是一次普通问答:
1 | 输入问题 → 输出答案 |
但内部已经经历了多轮模型调用和工具调用。
5.2 Agent 的首次决策是如何产生
按照上面的输入问题:
1 | 我想看江南园林,应该去哪里? |
也就是让 Agent 推荐适合看江南园林的城市。第一次进入模型节点时,状态中只有一条消息。
1 | [ |
此时模型还没有外部资料。如果是普通 ChatBot,它可能会直接生成答案。但在 Tool Agent 中,模型会先判断是否需要调用工具。因为问题涉及具体旅游信息,模型很可能不会直接回答,而是生成 Tool Call。
5.3 LLM 如何决定调用工具
第一次模型输出通常不是最终答案,而是类似这样的结构:
1 | Message( |
这说明模型判断当前信息不足,需要查询旅游知识库。模型生成的查询不一定等于用户原始问题。在真实大模型里,它可能一次生成多个 Tool Call。例如:
1 | 江南园林推荐 苏州扬州 著名园林景点 |
在代码,这个查询改写由真实模型产生。LangChainTravelModel.invoke() 会把本地 Message 转成 LangChain 的 HumanMessage、AIMessage、ToolMessage,再调用:
1 | response = self.llm.invoke(lc_messages) |
如果模型返回 tool_calls,再把它们转换回本地 ToolCall。这体现出 Agent 的一个隐含能力:模型会主动改写查询,提高检索召回率。用户的问题是自然语言,检索工具需要的是更适合搜索的关键词。模型在两者之间做了一次转换。这和 RAG 中常见的 Query Rewrite、Multi Query Retrieval 思路非常接近,只是在 Agent 中,这件事由模型自动完成。
5.4 ToolNode 调用工具并生成 ToolMessage
当模型节点返回 Tool Call 后,循环会检查到:
1 | 存在 tool_calls |
于是流程进入工具执行节点。execute_tool_calls() 会读取上一条 assistant message 中的调用请求。
1 | tool_calls = last_message.tool_calls |
然后逐个执行对应工具。
1 | search_travel_info( |
工具内部会调用本地检索函数。
1 | query → normalize(query) → 匹配 TRAVEL_KNOWLEDGE → 返回相关资料 |
检索结果返回后,并不会直接跳到最终答案。它会被封装成 ToolMessage,继续加入消息历史。
1 | [ |
这里是理解 Agent 的关键点。工具结果也是消息。 它不是临时变量,也不是隐藏状态,而是作为上下文的一部分交回给模型。
5.5 Agent 如何利用观察结果继续推理
工具执行完成后,流程会再次回到模型节点。此时模型看到的上下文已经变成:
1 | 用户问题 |
模型终于拥有了回答问题所需的资料。于是这一次,它通常不会再生成 Tool Call,而是直接生成最终答案。例如:
1 | 看江南园林,首推苏州。苏州以古典园林、平江路、山塘街和博物馆见长,如果想看园林、老城街巷和江南水乡气质,苏州比大城市更适合慢慢走。 |
这时返回的是普通 assistant message。
1 | Message( |
没有 tool_calls。
5.6 Agent 为什么会自动停止
第二次模型节点执行完成后,循环再次检查 tool_calls。这一次判断结果不同。
1 | 没有 tool_calls |
于是图进入 END。
1 | LLM Node → 无 Tool Call → END |
这就是 ReAct Agent 的停止机制。并不需要额外写复杂逻辑。只需要判断模型这次输出中是否还有工具调用请求即可。
5.7 Agent 的运行本质是 Message 流转
通过这次调试,可以得到一个非常重要的结论:
1 | Agent 的运行过程 = Message 不断累积的过程 |
从外部看,只是一次问答。从内部看,实际经历了:
1 | HumanMessage → AIMessage(tool_calls) → ToolMessage → AIMessage(final answer) |
不同类型的消息共同组成上下文。模型每次决策,都不是凭空发生的,而是基于当前完整 Message History。这也是 Agent 状态通常只需要维护 messages 的原因。LangGraph 把这件事封装成状态类型。
5.8 调试 Agent
调试这类 Agent 时,重点在于观察三个点。第一个是模型节点。它决定是否调用工具。第二个是工具执行节点。它负责执行工具并返回结果。第三个是 Message History。它记录了整个决策链路。只要这三部分清晰,Agent 的运行过程就清晰。从工程角度看,这也能帮助定位问题。如果模型没有调用工具,通常要检查:
1 | Prompt 是否明确 |
如果工具调用了但结果不好,通常要检查:
1 | 参数是否合理 |
如果最终答案不准确,则需要检查:
1 | ToolMessage 是否包含真实有效信息 |
这也说明,在 Agent 系统中,最终答案不能只看语言是否流畅,更要看它是否真正来自工具结果。看起来真实不等于真实存在,只有可追踪到工具或数据源的信息,才更适合作为可靠答案。
6. 从单工具 Agent 到多工具 Agent
前面构建的 Agent 已经具备完整的 ReAct 循环能力。但它实际上只有一个工具。
1 | search_travel_info() |
因此无论用户提出什么问题,模型最终都只能调用旅游知识库。这种架构能够验证 ReAct 原理,却无法体现 Agent 真正的价值。因为 Agent 最重要的能力并不是调用工具,而是:在多个工具之间进行选择和协调。
本章对应的代码03_multi_tool_agent.py,它和 02_manual_react_single_tool.py 的结构几乎一样,变化的是工具集合:
1 | tools = { |
也就是说,多工具 Agent 并没有改 ReAct 循环,而是把可选能力从一个扩展到了三个。
6.1 单工具 Agent 的局限性
假设系统中只有旅游知识库。下面的问题都可以处理:
1 | 杭州有哪些景点? |
因为这些问题都属于知识库覆盖范围。但如果问题变成:
1 | 苏州明天天气怎么样? |
知识库无法回答。即使模型调用了工具,也只能获得旅游信息。这意味着:
1 | Agent ≠ 万能 |
Agent 的能力边界仍然取决于工具集合。模型再聪明,也无法凭空获取不存在的数据。
6.2 增加第二个 Tool
为了展示多工具协作,03_multi_tool_agent.py 增加了天气和住宿两个工具。示例代码类似:
1 | Tool( |
这个工具负责根据地点返回天气信息。为了避免依赖真实天气接口,代码中还是使用 common.py 里的本地模拟数据。返回内容大致如下:
1 | { |
重点在于 Agent 开始拥有多种能力。此时工具集合变成:
1 | tools = { |
系统结构也发生变化。
flowchart TB LLM["LLM"] Tool1["旅游知识库"] Tool2["天气服务"] Tool3["住宿查询"] LLM --> Tool1 LLM --> Tool2 LLM --> Tool3
从这一刻开始,模型需要做选择。完整函数如下:
1 | def run_multi_tool_agent(question: str) -> list[Message]: |
和单工具版本相比,循环部分完全没有变化:
1 | while True: |
变化只发生在初始化阶段:
1 | model = LangChainTravelModel(enabled_tools=list(tools)) |
这里 enabled_tools 会把三个工具都注册给模型。模型看到的就不再只有 search_travel_info,还包括 weather_forecast 和 check_bnb_availability。
6.3 Tool Routing 是如何发生的
很多传统系统会使用条件判断实现工具路由。例如:
1 | if "weather" in query: |
这种方式的问题非常明显。随着工具增加,例如地图、酒店、机票等,判断逻辑会越来越复杂。最终变成大量:
1 | if... |
而 Agent 的思路完全不同。程序不再决定工具路由。模型负责决定。例如:
1 | 杭州有哪些景点? |
模型可能选择:
1 | search_travel_info() |
而:
1 | 苏州天气怎么样? |
模型则会选择:
1 | weather_forecast() |
开发时不需要提前编写这些规则。模型会根据工具描述和用户问题自行判断。这也是 Tool Calling 的核心价值之一。
6.4 一个问题调用多个 Tool
真正体现 Agent 能力的场景,是一个问题同时涉及多个领域。例如:
1 | 推荐一个适合看江南园林的城市 |
此时模型可能先执行:
1 | search_travel_info() |
获得旅游信息。然后继续执行:
1 | weather_forecast() |
获得天气数据。然后还可以继续执行:
1 | check_bnb_availability() |
获得住宿数据。最后将这些内容综合。
1 | 景点推荐 + 天气情况 + 住宿选项 + 出行建议 |
生成最终回答。此时执行路径已经不再固定,这也是 ReAct 真正发挥作用的地方。在 03_multi_tool_agent.py 中,示例问题:
1 | question = "推荐一个适合看江南园林的城市,查一下苏州天气,并找一个民宿房间。" |
这句话同时包含三个需求:
| 需求 | 适合的工具 |
|---|---|
| 推荐江南园林城市 | search_travel_info |
| 查询苏州天气 | weather_forecast |
| 找民宿房间 | check_bnb_availability |
因此模型可能一次返回多个 Tool Call:
1 | Message( |
随后 execute_tool_calls() 会逐个执行这些调用,并生成三条 tool 消息:
1 | [ |
第二次进入模型节点时,模型看到的是用户问题加上三份工具观察结果,于是可以综合生成最终答案。
6.5 从单一能力到能力编排
只有一个 Tool 时,其实很难感受到 Agent 的优势。因为模型根本不存在选择空间。调用即可。但当工具数量不断增加时,情况开始发生变化。如果继续采用传统 Workflow。每增加一个能力,都要修改流程图和分支逻辑。而 Agent 架构下:
1 | 新增 Tool → 注册 Tool → 更新 Prompt |
即可。系统整体结构几乎不需要变化。因此 Agent 最大的优势并不是单个工具能力,而是:随着工具数量增长,复杂度增长速度远低于传统流程系统。
6.6 Tool 不决定答案,决定信息来源
很多人刚接触 Agent 时容易产生一种误解。认为 Tool 越多,模型就越聪明。实际上 Tool 并不会增强推理能力。Tool 只是提供信息来源。真正负责理解和组织这些内容的仍然是模型。因此可以把两者理解为:
1 | Tool |
如果 Tool 返回的数据有问题,模型很难纠正。如果 Tool 返回的是虚构数据,那么最终答案也会建立在错误基础上。因此在 Agent 系统中,工具质量往往比 Prompt 更重要。
6.7 从 Tool Agent 到 Multi-Agent
当工具数量继续增加时,又会出现新的问题。假设系统拥有:
1 | 20 个 Tool |
此时单个 Agent 的 Prompt 会越来越长。工具选择也会越来越困难。这时开始出现新的架构方向:
1 | Supervisor Agent + Specialized Agents |
即:
1 | 总控 Agent → 多个专业 Agent → 各自管理工具 |
例如:
1 | 旅游 Agent |
每个 Agent 只负责自己的领域。而总控 Agent 负责协调。这也是后续 Multi-Agent 架构产生的原因。从这里开始,讨论重点将不再是如何调用 Tool,而是如何组织越来越复杂的 Agent 系统。下一章将回到工程实践层面,看看为什么在实际开发中很少反复手写 ReAct 循环,而更多使用 create_react_agent() 这类封装快速构建标准 Agent。
7. create_react_agent:从原理验证走向实践
前面用了大量篇幅手动搭建 ReAct Agent。从 Tool 定义开始,到模型节点、工具执行节点、条件判断,再到完整循环,最终实现了一个能够自主调用工具的 Agent。这个过程对于理解原理非常重要。但如果把这些代码直接带到实际项目中,会发现有大量重复代码,因为绝大部分 ReAct Agent 的底层结构几乎完全一样。
本章对应的代码是04_create_react_agent_style.py,用来说明真实封装到底隐藏了什么。
7.1 为什么实际项目很少手写 ReAct 循环
回顾前面的实现过程。需要定义:
1 | Message |
整个流程加起来已经接近几十行代码。而且无论做什么 Agent,底层结构都高度相似。其核心运行逻辑都是:
1 | LLM → Tool → LLM → END |
区别仅仅在于:
1 | 使用什么工具 |
因此真实框架通常会把这种固定模式封装起来。
7.2 create_react_agent 封装了哪些能力
LangGraph 提供了一个预构建 Agent。
1 | from langgraph.prebuilt import ( |
创建方式非常简单。
1 | agent = create_react_agent( |
看起来只有一行代码。但内部已经自动完成:
1 | StateGraph |
全部构建过程。在 04_create_react_agent_style.py 中,也实现了一个简单封装:
1 | def create_react_agent(model: LangChainTravelModel, tools: list[Tool]) -> Callable[[str], list[Message]]: |
这段代码没有实现完整 LangGraph,只是把前面手写的固定 ReAct 循环包成函数。它的输入是两个对象:
| 输入 | 含义 |
|---|---|
model |
已经绑定好工具的模型 |
tools |
当前 Agent 允许调用的工具列表 |
输出不是一次执行结果,而是一个可复用的 agent 函数:
1 | agent = create_react_agent(model, tools) |
之后调用时只需要传问题:
1 | messages = agent("我想去一个有历史感的中国城市旅行,再查一下西安天气。") |
这样,使用者不需要每次都写:
1 | messages = [Message(role="user", content=question)] |
换句话说,前面亲手搭建的 Agent 最终可以浓缩成:
1 | create_react_agent(...) |
这里有一个关键点:create_react_agent() 封装的是“标准循环”,不是“业务逻辑”。业务差异仍然来自:
1 | 传入什么模型 |
7.3 create_react_agent 背后的运行结构
如果把内部结构展开,本质上仍然是:
flowchart LR
LLM["LLM"]
DECISION{"是否存在
Tool Call"}
TOOL["Tool 执行节点
ToolNode"]
END_NODE(["END"])
LLM_NEXT["LLM"]
LLM --> DECISION
DECISION -- 是 --> TOOL
DECISION -- 否 --> END_NODE
TOOL --> LLM_NEXT
也就是说:
1 | create_react_agent() |
并没有发明新的 Agent。只是把标准 ReAct Agent 预先封装好了。因此:
1 | create_react_agent = 标准 ReAct Agent 模板 |
理解这一点就可以知道,这只是把前面已经实现过的结构隐藏起来。这样就不用每次都创建工具表、模型和消息列表,运行时只剩下用户问题:
1 | messages = agent("我想去一个有历史感的中国城市旅行,再查一下西安天气。") |
这就是工程封装的意义:不是改变 Agent 原理,而是减少重复样板代码,让开发者把注意力放回工具设计和 Prompt 设计。
7.4 Prompt 如何影响 Agent 的行为决策
前面几章讨论的是 Graph 如何搭建、节点如何连接、状态如何传递,而在实际项目里,真正影响 Agent 效果的往往不再是 Graph,而是 Prompt。例如:
1 | SYSTEM_PROMPT = """ |
对于 Agent 来说。Prompt 决定:
1 | 职责范围 |
甚至会影响:
1 | 是否调用工具 |
随着 Agent 架构逐渐成熟,关注重点开始从 Graph 转向 Prompt。
1 | Tool 决定能力边界 |
工具负责提供外部能力。Prompt 负责引导模型如何使用这些能力。
7.5 Tool Selection 本质上是语义路由
拥有多个 Tool 后。Agent 的核心工作逐渐变成Tool Selection,即选择哪个工具,例如:
杭州有哪些景点?模型会倾向于调用:search_travel_info()苏州明天气温多少?则会调用:weather_forecast()
这里并不存在硬编码规则。模型依靠:
1 | 用户问题 |
三部分信息完成判断。从本质上看,Agent 实际上是在进行语义路由。
7.6 create_react_agent 的适用边界
虽然 create_react_agent() 十分方便。但它并不适合所有场景。标准 ReAct Agent 最适合:
1 | 单 Agent |
例如:
1 | 客服助手 |
这些应用通常直接使用即可。但当系统开始出现复杂状态、复杂路由、长流程任务、多 Agent 协作时,就需要重新回到 LangGraph。自己构建图结构。因为这些场景已经超出了标准 ReAct Agent 的能力范围。
7.7 从 Agent 到 Agent System
到了这里,本文其实已经完成了从 Workflow 到 Tool Agent 的演进,整个过程可以总结为:
1 | Workflow → Tool Calling → ReAct → Tool Agent → create_react_agent |
其中最大的变化不是增加了多少工具,而是流程控制权发生了转移。
1 | Workflow = 程序控制流程 |
当系统规模继续扩大时,新的问题来了:
1 | 一个 Agent 能管理多少工具? |
这些问题已经超出了单 Agent 范畴,也正是后续 Multi-Agent、Supervisor Agent 等架构要解决的问题。
8. 总结:Agent 本质上是一种动态工作流
从表面上看,Agent 像是在 Workflow 上增加了工具调用能力。但从架构角度看,两者最大的区别并不在工具,而在于决策机制。Workflow 中,开发者决定流程,而 Agent 中,模型决定流程。
Tool Calling 让模型能够访问外部能力。ReAct 让模型能够动态规划执行路径。LangGraph 则提供了组织这些能力的运行框架。最终形成:
1 | Reason → Act → Observation → Reason |
这一套标准循环,也是现代 Agent 系统几乎共同遵循的基础模式。无论是 LangGraph、OpenAI Agents、CrewAI 还是 AutoGen,其核心思想都没有脱离这一框架。理解了这一点,后续无论面对单 Agent、Multi-Agent 还是复杂 Agent Team,本质上都只是这套机制在不同规模下的扩展与组合。
9.备注
完整代码:https://github.com/keychankc/AI_agent_code/tree/main/reAct_tool_calling