ReAct + LangGraph:从零搭建能调用工具的 Agent 应用骨架
最近后台经常收到一类私信同样一个大模型别人写出来的应用能自己查天气、能按照步骤调接口、能在出错后自己换一种方式重试而自己写出来的只会一问一答稍微绕一点就整个跑偏。这个差距其实不在模型本身上而是差在“应用骨架”上。这篇文章要聊的 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 以上版本的 LangGraphAPI 更稳定prebuilt.ToolNode这种神器让你少写大量样板代码。注意网上的教程版本很杂看到StateGraph的写法时优先参考新版文档风格。3. 手把手从零实现一个可运行的 ReAct Agent3.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: 计算一个数学表达式的值例如 (128)*3。 try: return str(_safe_eval(ast.parse(expression, modeeval))) 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_modevalues): msg event[messages][-1] msg.pretty_print()stream_modevalues会让我看到每一轮状态更新后的最新消息。运行后你大概会看到这样的过程模型先输出了一个思考消息里面带了一条调用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(checkpointerMemorySaver()) 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_modeupdates模式能按节点输出执行情况你能直观看到流程卡在哪两个节点之间。另外给整个图加一个最大递归步数限制也是一种兜底手段超时直接报错省得被失控循环拖死。还有一种隐蔽的死循环工具每次都返回错误信息模型看到错误后又调用同一个工具循环往复。这种情况通常还是工具描述或参数定义有问题让模型无法正确传参优先回头修工具本身。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_modevalues适合看全局stream_modeupdates则更适合看某一步是哪个节点产生了什么更新。如果流程再复杂一些我还会给关键节点打印state[messages][-1]的内容看看模型在这一步到底说了什么。多几行打印往往比对着文档猜原因高效得多。在实际项目里我会把这些日志喂给一个可视化界面把“思考-行动-观察”三个阶段的标题渲染出来。这既是给用户看的过程展示也是给自己留的调试后门。6.3 打断与人工确认生产环境的必备能力纯全自动的 Agent 看起来很酷但在真实业务中有些动作是不能让模型擅自执行的。比如转账、删除资源、发送通知一旦误操作损失很大。LangGraph 支持在节点执行前打断流程把控制权交回人类确认后再继续。用interrupt_before可以在进入某个节点前暂停。接到用户“请转账”的指令后模型先生成转账参数流程到工具节点前停下前端展示参数并等待确认用户点完“确认”再放行。配合检查点机制被打断的图可以挂起很久不受进程影响。这种“人机协作”模式才是 Agent 靠谱的最终形态。不要追求每一步都无人值守把风险高的动作留一道人工闸门反而能让业务方更放心地让你上生产。我个人的体会是ReAct 加 LangGraph 这套组合最大的价值不是“自动跑通一个 Demo”而是让你把模型的行为变成可以设计、可以控制、可以恢复的工程流程。刚开始跑通第一个带工具的 Agent 时你可能会觉得“就这么点东西”但当你开始加记忆、加人工审批、加各种业务工具之后会发现每一点复杂度都被这张清晰的图表消化得很好。最后再分享一个实用小技巧把 Agent 核心代码里的工具列表、路由函数保持成独立模块工具越来越多时你会庆幸当初没把它们写进一个几十行的大函数里。