最近后台经常收到一类私信:同样一个大模型,别人写出来的应用能自己查天气、能按照步骤调接口、能在出错后自己换一种方式重试,而自己写出来的只会一问一答,稍微绕一点就整个跑偏。这个差距其实不在模型本身上,而是差在“应用骨架”上。这篇文章要聊的 ReAct 模式,正是当前 Agent 应用最主流的一套“思考与行动”框架,而 LangGraph 则是我用来落地这套框架最顺手的基础设施。全文会从最基础的概念开始讲,不需要你预先掌握什么图论、状态机,只要能写几行 Python,就能跟着做出一个具备工具调用能力的 Agent。适用对象很清楚:刚接触大模型应用开发、被各种抽象术语劝退的小白,以及想快速搭一个稳定 Agent 骨架的老手。
1. ReAct 到底在解决什么问题
1.1 用一句话说清 ReAct 是什么
ReAct 是 Reasoning + Acting 的组合,直译过来就是“边思考、边行动”。它要解决的问题很朴素:大模型不能只坐在脑子里推理,它得能查资料、算数、调接口,然后根据结果继续推理,最后给出结论。
这套流程被拆成了三个反复循环的步骤:Thought(思考当前该做什么)、Action(做出一个具体动作,比如调用工具)、Observation(观察工具返回的结果)。模型会在这个循环里反复走,直到它认为自己有足够的信息回答用户,才输出最终答案。你平时看到的“让我先搜索一下”“我需要计算一下”这类过程,背后基本都是这个模式。
我用一个生活里的例子帮你建立直觉。假设你让一个实习生去办一件事:了解某家餐厅今晚还有没有位置。“纯推理”的实习生会坐在工位上告诉你“我觉得应该有位置”;“ReAct 风格”的实习生会先想“我需要查餐厅电话或线上预订页面”,然后真的去打电话或者翻页面,看到“今晚满座”后,再带着结论回来。你看,同样是干活,后者每一步都有依据,不会被自己的脑补带偏。
1.2 为什么纯推理撑不起复杂任务
很多初学者会困惑:模型不是知道很多知识吗,为什么一定要让它去“查”?这里有几个绕不开的硬限制。
第一是知识时效性。模型训练所使用的数据存在截止时间,新发生的事件、刚发布的价格、实时天气,它天然不知道。第二是计算精度。大模型本质上是预测下一个 token 的模型,让它做连续多位乘法或复杂逻辑运算,错误率非常高,远不如把它交给计算器。第三是私域数据。你自己公司的报表、某个系统的内部状态,模型不可能知道,必须通过工具接口实时获取。
这些问题不是靠换一个更强模型就能彻底解决的,而是需要在架构层给模型开一扇“通过工具接触外部世界”的门。ReAct 要做的,就是把这扇门变成一条不断往返的通道:推理得不到答案,就派工具去查;查完结果再喂给推理,如此循环。
1.3 ReAct 与普通提示词工程的本质差异
有些朋友可能会说,我写个 Prompt 让模型一步步做,不也能完成多步任务吗?这确实能处理一部分场景,但有一个致命区别:纯提示词式的链式调用,所有步骤都是预设死的,模型只能按剧本走,中途分支、跳转、根据工具结果调整策略都很难在同一个流程里自然发生。
| 对比维度 | 普通链式提示(CoT/预设工作流) | ReAct 模式 |
|---|---|---|
| 流程灵活性 | 提前写死,固定顺序 | 模型自主决定下一步动作 |
| 外部信息获取 | 基本没有 | 通过工具实时获取 |
| 错误恢复能力 | 错误后很难自我修正 | 观察到错误后可调整策略 |
| 实现复杂度 | 简单 | 稍复杂,但可控 |
ReAct 的内核是让模型“自己决定”调用哪些工具、调用的先后顺序、何时终止。LangGraph 这类框架的存在,就是把这个自主循环从手工 while 循环里解放出来,变成清晰、可恢复、可维护的工程结构。
2. 为什么选 LangGraph 做 ReAct 骨架
2.1 LangGraph 的核心抽象:节点、边、状态
第一次见 LangGraph 的项目文档,很多人会被“图”“状态机”这些词吓到。其实它的核心概念就三个:节点、边、状态。
节点就是一段 Python 函数,负责干一件事。比如一个节点负责让模型思考,一个节点负责执行工具。边就是节点与节点之间的连接关系,决定流程从哪里走到哪里。状态则是整个流程中共享的数据包,通常是一个字典,每次有节点执行完,状态就会更新一份。你完全可以把它当成一张生产流水线:每个工位是一个节点,工位之间的传送带是边,流水线上传递的工件是状态。
我把上面那套 ReAct 流程映射到 LangGraph 里,就是一张非常简单的有向图:模型思考节点开始工作,如果它决定调用工具,就走“调用工具”节点,工具执行完回到模型节点;如果它决定给出答案,就走一条到终点的边。这张图一旦搭好,就是一个不会“迷路”的 Agent 循环。
2.2 手写 while 循环的痛,用图解决
我最早实现 Agent 循环时,直接在 Python 里写一个 while True:调用模型、解析有没有工具调用、有就执行工具、把结果拼接回消息列表、再来一轮。看起来只有十几行,实际用起来问题一大堆。
最大的痛点是中断续跑。只要中间一步因为网络超时或进程意外退出,整个循环就断了,没法从断点恢复。其次是分支多了之后,维护条件逻辑特别痛苦,每加一种工具,if-else 就膨胀一层。还有调试困难,所有节点混在一段流程里,想单独看某一步输入输出很难下手。
LangGraph 把我这些痛点都解决了。节点是独立函数,天然可以单测;状态图就是流程的可视化文档;配合检查点机制,跑一半崩溃了还能在重启后接着跑。简单说,手写循环适合“写出来就跑一次”的玩具,而 LangGraph 是把 Agent 当真正的软件工程来做。
2.3 环境准备与版本选择
开始之前,先准备好环境。我建议建一个干净的虚拟环境,避免依赖冲突:
python -m venv react-demo source react-demo/bin/activate pip install langgraph langchain-core如果你的模型服务商提供了对应的 langchain 集成包,也一并装上;没有的话,用它的 HTTP 接口自行封装一个 ChatModel 类也可以。LangGraph 本身对模型接口没有绑定约束,只要你的模型对象具备两个能力:能接收消息列表、能输出工具调用指令。
版本方面,目前写代码建议直接用 0.2 以上版本的 LangGraph,API 更稳定,prebuilt.ToolNode这种神器让你少写大量样板代码。注意网上的教程版本很杂,看到StateGraph的写法时,优先参考新版文档风格。
3. 手把手:从零实现一个可运行的 ReAct Agent
3.1 先定义两个接地气的工具
工具是 Agent 的“手和脚”。我这次定义两个小工具:一个负责数学计算,一个负责获取指定时区的当前时间。为什么要带工具?因为模型自己算数不靠谱、对时间也一无所知,这两个场景最直观。
import ast import operator from datetime import datetime from zoneinfo import ZoneInfo from langchain_core.tools import tool _OPERATORS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, } def _safe_eval(node): if isinstance(node, ast.Expression): return _safe_eval(node.body) if isinstance(node, ast.BinOp): left = _safe_eval(node.left) right = _safe_eval(node.right) op_type = type(node.op) if op_type in _OPERATORS: return _OPERATORS[op_type](left, right) if isinstance(node, ast.Constant): return node.value raise ValueError("不支持的表达式") @tool def calculate_exp(expression: str) -> str: """计算一个数学表达式的值,例如 '(12+8)*3'。""" try: return str(_safe_eval(ast.parse(expression, mode="eval"))) except Exception as exc: return f"计算失败: {exc}" @tool def current_time_cn(timezone_name: str = "Asia/Shanghai") -> str: """获取某个时区的当前时间和日期。参数示例:'Asia/Shanghai'。""" try: now = datetime.now(ZoneInfo(timezone_name)) return now.strftime("%Y-%m-%d %H:%M:%S %Z") except Exception as exc: return f"获取时间失败: {exc}"我这里特意没有用eval函数,而是写了一个基于ast的简易安全求值器。别在正式项目里直接eval用户输入,这是很多人踩过的坑。工具函数的返回值一定要是字符串,因为模型只能理解文本,它要靠这段文本来做下一轮思考。
写工具函数时有个心得:函数名要像给人看的小标题,描述里要把“什么时候用这个工具”“参数怎么填”写清楚。模型是通过这些描述来决定调不调用它的,描述含糊,模型就乱来。
3.2 组装模型与 Agent 思考节点
模型这一步不绑定具体厂商,我写成一个示意结构。你实际使用时,只需要把YourModel替换成你选定的模型服务商对应的 ChatModel 类,并配置好base_url、api_key、model_name这三件套即可。
# 请替换成你使用的模型类,常见模型服务商都提供了 langchain 集成 # 例如:from your_provider import ChatModel # model = ChatModel(base_url="...", api_key="...", model_name="...") model = YourModel( base_url="你的模型接口地址", api_key="你的密钥", model_name="你的模型ID", ) model_with_tools = model.bind_tools([calculate_exp, current_time_cn])bind_tools这一步非常关键。它相当于在模型的“工具箱”里挂上了两个工具,模型收到用户问题后,会在内部判断是否需要调用工具,如果调用,它返回的消息里会带上结构化的tool_calls字段。
接下来写 Agent 节点。这个节点只做一件事:把当前所有的对话消息交给模型,拿到模型返回的新消息,放回状态里。
from langchain_core.messages import HumanMessage from langgraph.graph import MessagesState def agent_node(state: MessagesState): response = model_with_tools.invoke(state["messages"]) return {"messages": [response]}注意这里返回的是一个字典,"messages"对应的值是列表。LangGraph 会把这个列表追加到当前状态的messages里,而不是覆盖,这是 Agent 能记上下文的基础机制。
3.3 用一张图把节点串起来
现在要写一个路由函数,这是整个循环的“大脑”:
from typing import Literal def route_after_agent(state: MessagesState) -> Literal["tools", "end"]: last_message = state["messages"][-1] if getattr(last_message, "tool_calls", None): return "tools" return "end"规则很简单:看最后一条模型消息里有没有工具调用请求。如果有,就带它去执行工具;如果没有,说明模型认为可以直接回答了,就结束流程。
下面正式建图。LangGraph 的StateGraph负责把节点、边、状态串成一个可执行图:
from langgraph.graph import END, START, StateGraph from langgraph.prebuilt import ToolNode builder = StateGraph(MessagesState) builder.add_node("agent", agent_node) builder.add_node("tools", ToolNode([calculate_exp, current_time_cn])) builder.add_edge(START, "agent") builder.add_conditional_edges( "agent", route_after_agent, {"tools": "tools", "end": END}, ) builder.add_edge("tools", "agent") graph = builder.compile()这段代码就是整套 ReAct 的骨架。ToolNode是 LangGraph 预先封装好的工具执行节点,它会自动读取模型消息里的tool_calls,逐个执行对应工具,并把结果封装成ToolMessage写回状态。你需要留意,工具节点执行完一定要回到agent节点,让模型看到工具结果后继续思考,否则循环就断了。
3.4 跑起来看效果,以及背后的状态流转
编译完成后就可以运行了。我用的示例是一个需要“先查时间、再算时间”的复合问题:
inputs = { "messages": [ HumanMessage( content="现在是几点?如果吃完午饭3小时后出发,出发时间是几点?" ) ] } config = {"configurable": {"thread_id": "react-demo-1"}} for event in graph.stream(inputs, config, stream_mode="values"): msg = event["messages"][-1] msg.pretty_print()stream_mode="values"会让我看到每一轮状态更新后的最新消息。运行后你大概会看到这样的过程:模型先输出了一个思考消息,里面带了一条调用current_time_cn的工具指令;接着输出一条ToolMessage,内容是当前时间;模型看到这个时间后,又输出一条调用calculate_exp的指令;工具节点算出“午饭3小时后”的具体时刻;模型最后综合所有信息,给出完整回答。
这个过程中的每一步,都对应着图里一次“agent 节点 -> tools 节点 -> agent 节点”的循环。第一轮的信息会被完整保存在消息列表里,模型第二轮思考时已经能看到第一轮的工具返回值,这就是 Flow 能连续完成任务的关键。
这里提醒一个容易忽略的点:如果你的问题不需要工具,模型会直接返回纯文本答案,路由函数判断tool_calls为空,流程一次就走完。所以这个骨架天然支持“哪类问题就用工具、哪类问题直接答”的混合模式,不需要你额外做判断。
4. 给 Agent 加上记忆:多轮对话与状态持久化
4.1 为什么默认的 Agent 不记事
跑通上面的代码后,你会发现每次invoke都是独立的新会话。你问它“我叫小明”,它答得很好;再问“我叫什么”,它就愣住了。原因很简单:状态只在一次invoke调用内部有效,调用结束,内存里的消息列表就丢了。
日常对话场景显然不能这样。更好的做法是让 Agent 能记住某个会话的上下文,同时不同会话之间互不干扰。LangGraph 的解决办法是引入检查点机制,它会在每次节点执行后,把当前状态打一个快照保存下来。
4.2 用 Checkpointer 实现线程记忆
实现起来非常轻量。在编译图的时候传入一个MemorySaver,然后在调用时传入一个稳定的thread_id即可:
from langgraph.checkpoint.memory import MemorySaver graph = builder.compile(checkpointer=MemorySaver()) config = {"configurable": {"thread_id": "user-session-001"}} graph.invoke( {"messages": [HumanMessage(content="我叫小明")]}, config, ) graph.invoke( {"messages": [HumanMessage(content="我刚才说自己叫什么?")]}, config, )第二次提问时,LangGraph 会根据thread_id找到此前保存的状态,把历史消息重新加载到输入消息列表里,模型自然记得你是谁。你可以把thread_id理解成聊天窗口编号,同一个编号对应同一段连续记忆。
在生产环境里,我一般不会用默认的内存检查点,因为进程一重启记忆就全没了。LangGraph 提供了基于外部存储的检查点方案,比如接入 PostgreSQL,可以让记忆跨重启、跨多实例共享,这一步属于上线前基本操作。
4.3 多轮问题的正确玩法与线程隔离
有了记忆以后,线程隔离就变得很重要。你一定不希望用户 A 问了个问题,用户 B 的上下文里能看到 A 的聊天记录。解决办法就是把不同用户映射到不同的thread_id,保证他们的状态永不相交。
还有一个实操细节:同一个thread_id会无限追加历史消息,提问多了之后,消息列表越来越长,最终会超出模型的上下文窗口,也会增加每次调用的 token 成本。我通常会在消息列表超过一定阈值后做裁剪,或者对历史消息做摘要压缩,把早期对话浓缩成一段概要。
5. 实战中踩过的坑:常见问题与排查速查
5.1 工具总是调不到,或者乱调用
这是我在初学阶段遇到概率最高的问题,症状一般是:模型完全无视工具,直接口算;或者在一个不该用工具的场景里强行调用。
排查顺序有讲究。先确认模型本身是否支持工具调用,不支持工具调用的模型基本很难实现 ReAct。再看bind_tools是否真的把工具挂上了,打印model_with_tools.bind_tools后模型能拿到哪些工具信息。最后检查工具描述,如果描述写得太泛,模型就会对“什么时候该用”产生误判。
这里分享一个我后来养成的习惯:把工具描述写得像给同事发的协作消息一样直白。不要写“一个用于获取时区的函数”,而要写“当用户询问某地当前时间时使用此工具,参数为该地区的时区名,示例:Asia/Shanghai”。描述越具体,模型的选择越准确。
5.2 死循环与无条件边
如果一个 Agent 永远不结束,一直在工具和思考之间来回跳,通常是条件路由出了问题。最常见的情况是路由函数里没有任何终止条件,或者工具节点本身调用了模型但你又把它连回工具节点,形成了无条件强循环。
排查时可以给每个节点做标记。LangGraph 的stream_mode="updates"模式能按节点输出执行情况,你能直观看到流程卡在哪两个节点之间。另外给整个图加一个最大递归步数限制也是一种兜底手段,超时直接报错,省得被失控循环拖死。
还有一种隐蔽的死循环:工具每次都返回错误信息,模型看到错误后又调用同一个工具,循环往复。这种情况通常还是工具描述或参数定义有问题,让模型无法正确传参,优先回头修工具本身。
5.3 ToolMessage 的 tool_call_id 对不上
手动实现 ReAct 时,一个非常容易翻车的点是ToolMessage必须带着正确的tool_call_id,这个 ID 要精确匹配模型发出的那条工具调用指令,否则模型无法把这个执行结果和对应调用对上号。
自己拼接消息列表时,不少人会漏掉这个字段,结果模型拿到工具结果后一脸茫然。使用 LangGraph 自带的ToolNode就省心很多,它会自动处理 ID 匹配,不需要你手动构造ToolMessage。如果你确实需要自定义工具执行逻辑,把这个 ID 写正确是第一优先级。
5.4 状态“没更新”的真相
有时候你会觉得某个节点执行了,但结果没体现在后续节点里。问题的根源多半在于状态约减规则。LangGraph 的默认行为是“覆盖”:如果一个节点返回字典里某个 key,它可能直接覆盖旧值,而不是追加。
MessagesState之所以能不断累积消息,是因为它给messages定义了一个特殊约减规则,默认是追加。如果你自定义状态字段,一定要想清楚:这个字段是累积型还是覆盖型。如果不对,节点返回值会被后一个节点直接替换掉,看起来就像“状态丢了”。
5.5 快速排查看板
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 模型从不调用工具 | 模型不支持工具调用 | 换支持工具的模型,或检查 bind_tools |
| 循环不终止 | 路由缺终止分支 | 补条件边,加递归上限 |
| 工具结果模型看不懂 | ToolMessage 缺少 ID 匹配 | 用 ToolNode,勿手动拼接 |
| 多轮记忆不生效 | 没有配 checkpointer 或 thread_id 不固定 | 加 MemorySaver,固定会话标识 |
| 某个字段总是丢失 | state 约减规则错误 | 为字段绑定正确的 reducer |
| 上下文太长报错 | 消息无限累积 | 做裁剪或历史摘要 |
这套速查表看起来简单,每一条背后都是我至少踩过一遍的教训。尤其前两条,初学者最容易在这里消耗大量时间。
6. 把 Agent 做扎实的几个进阶技巧
6.1 工具设计是 Agent 的生命线
骨架搭好以后,Agent 的能力上限几乎完全取决于你的工具设计。工具不是“能跑就行”,它是模型与外部世界交互的协议,设计得好,模型上手快;设计得差,模型每步都在猜。
我总结了几条经验。工具数量要节制,一次挂给模型的工具太多,模型选择负担重,容易选错,控制在三到五个以内最好。参数要少且类型明确,能用一个字符串参数解决的事就不要拆成五个字段。函数返回不是给人看的报告,而是给模型看的“观测结果”,应该包含关键信息但不啰嗦。最后,工具要可以失败并给出可读的报错文本,模型会基于报错信息自省,尝试不同的参数。
6.2 用流式输出和可视化提升调试体验
调试 Agent 时最难受的是“黑盒”。我强烈建议你从一开始就用流式模式运行图,把每个节点的实时输出都打出来。前面用的stream_mode="values"适合看全局,stream_mode="updates"则更适合看某一步是哪个节点产生了什么更新。
如果流程再复杂一些,我还会给关键节点打印state["messages"][-1]的内容,看看模型在这一步到底说了什么。多几行打印,往往比对着文档猜原因高效得多。在实际项目里,我会把这些日志喂给一个可视化界面,把“思考-行动-观察”三个阶段的标题渲染出来。这既是给用户看的过程展示,也是给自己留的调试后门。
6.3 打断与人工确认:生产环境的必备能力
纯全自动的 Agent 看起来很酷,但在真实业务中,有些动作是不能让模型擅自执行的。比如转账、删除资源、发送通知,一旦误操作损失很大。LangGraph 支持在节点执行前打断流程,把控制权交回人类,确认后再继续。
用interrupt_before可以在进入某个节点前暂停。接到用户“请转账”的指令后,模型先生成转账参数,流程到工具节点前停下,前端展示参数并等待确认,用户点完“确认”再放行。配合检查点机制,被打断的图可以挂起很久,不受进程影响。
这种“人机协作”模式才是 Agent 靠谱的最终形态。不要追求每一步都无人值守,把风险高的动作留一道人工闸门,反而能让业务方更放心地让你上生产。
我个人的体会是,ReAct 加 LangGraph 这套组合,最大的价值不是“自动跑通一个 Demo”,而是让你把模型的行为变成可以设计、可以控制、可以恢复的工程流程。刚开始跑通第一个带工具的 Agent 时,你可能会觉得“就这么点东西”,但当你开始加记忆、加人工审批、加各种业务工具之后,会发现每一点复杂度都被这张清晰的图表消化得很好。最后再分享一个实用小技巧:把 Agent 核心代码里的工具列表、路由函数保持成独立模块,工具越来越多时,你会庆幸当初没把它们写进一个几十行的大函数里。