简介:这份源码资源面向AI智能体设计与开发的学习者,聚焦ReAct(推理-行动)与Plan-and-Execute(规划-执行)两种主流运行模式的对比分析。内容涵盖两种模式的核心思想、工作流程、优劣势及适用场景,并给出根据项目需求选择模式的建议,同时提出可结合使用两种模式以实现更灵活策略的思路,适合希望深入理解智能体架构、提升任务编排能力的中级开发者参考。资源包共3个文件,包含1个inscode工程配置、1个html页面和1个gitignore忽略规则文件,压缩包约10KB,结构精简,便于快速导入查看与二次修改。目前已有58人学习下载。通过这份源码,读者可直观对照两种模式的实现差异,理解动态任务环境下ReAct的即时调整优势,以及目标明确场景下Plan-and-Execute的全局规划价值,为智能体项目选型与代码实践提供参考。
1. Agent运行模式对比:为什么同样一个任务,换个循环方式就翻车
很多人第一次写 Agent,都是从一个while True开始的:把用户输入丢给模型,模型返回一段文本,解析出工具调用,执行,把结果塞回上下文,再循环。跑通 demo 那一刻很爽,但只要任务稍微长一点,你就会遇到三种典型症状:上下文越滚越大直到超限、同一个工具被反复调用、模型开始"忘记"最初的目标。这时候你才会意识到,Agent 的运行模式不是语法问题,而是架构问题。
所谓 Agent 运行模式,指的是"模型思考—工具执行—结果回灌"这个循环由谁驱动、状态存在哪里、什么时候终止。常见的有 ReAct 式单轮循环、Plan-and-Execute 两段式、以及带显式状态机的编排模式。源码层面它们的差别其实很小,无非是循环体里多几个分支,但行为差异巨大。这篇笔记面向已经能跑通单轮工具调用、想搞清楚"该选哪种循环"的开发者,我会把三种模式的源码骨架、参数取舍和踩坑点摊开讲,让你能照着改自己的项目。
2. 三种运行模式的源码骨架与选型依据
在动手改代码之前,先把三种模式的"循环体长什么样"讲清楚。选型不是看哪个高级,而是看你的任务有没有"中途需要重新规划"的需求。下面每一段都是可以直接抄进项目的最小骨架,我刻意去掉了框架依赖,只用最朴素的 Python 表达控制流,这样你能看清每种模式真正多出来的是什么。
2.1 ReAct 单轮循环:最小可用但最容易失控
ReAct 的核心是"想一步、做一步",模型每轮输出一段包含 Thought 和 Action 的文本,执行完把 Observation 拼回上下文,进入下一轮。它的源码骨架就是一个带最大轮数保护的 while:
def react_loop(task, tools, max_steps=8): # 上下文只维护一条消息列表,所有历史都往里塞 messages = [{"role": "user", "content": task}] for step in range(max_steps): # 每轮都把完整历史发给模型,让它决定下一步 reply = call_llm(messages, tool_specs=tools) action = parse_action(reply) # 解析出工具名和参数 if action is None: # 模型直接给最终答案 return reply # 执行工具,把结果作为 observation 回灌 observation = run_tool(action.name, action.args) messages.append({"role": "assistant", "content": reply}) messages.append({"role": "tool", "content": observation}) return "达到最大步数仍未完成" # 兜底,防止死循环这段代码里最关键的两个参数是max_steps和上下文裁剪策略。max_steps一般设 6 到 10,设太小复杂任务做不完,设太大一旦模型陷入循环就是烧钱。上下文裁剪我一般用"保留首条任务 + 最近 N 轮"的方式,因为中间那些失败的尝试对后续决策价值很低,留着只会干扰模型。ReAct 适合工具数量少、任务步骤在 5 步以内的场景,比如查资料、简单数据查询。它的软肋是:一旦某一步走错,模型很难跳出来重新规划,因为它没有"全局计划"这个概念,只能顺着错误往下滚。
2.2 Plan-and-Execute 两段式:先出计划再逐步执行
Plan-and-Execute 把一次循环拆成两个阶段:先让模型产出一个步骤列表,然后逐个执行,每执行完一步可以选择性地重新规划。源码上它比 ReAct 多了一个 plan 数据结构和一层外层循环:
def plan_execute(task, tools, max_replan=3): plan = call_llm(build_plan_prompt(task, tools)) # 阶段一:产出步骤列表 plan = parse_plan(plan) # 解析成 ["步骤1", "步骤2", ...] results = [] for i, step in enumerate(plan): # 阶段二:把当前步骤和已有结果一起发给模型执行 ctx = build_step_context(task, plan, results, step) output = call_llm(ctx, tool_specs=tools) results.append(run_step(output, tools)) # 每步之后判断是否需要重新规划 if need_replan(results) and max_replan > 0: plan = call_llm(build_replan_prompt(task, plan, results)) plan = parse_plan(plan) max_replan -= 1 return summarize(task, results)这里有两个必须调的参数:max_replan和need_replan的判定逻辑。max_replan我一般设 2 到 3,因为重新规划本身也要花一次模型调用,太频繁反而拖慢整体。need_replan的判定我常用一个轻量规则:如果当前步骤执行结果里包含"失败""未找到""错误"这类信号,就触发重规划,而不是每步都问模型一次。Plan-and-Execute 适合步骤明确、可以提前拆解的任务,比如"整理一份报告"这种有固定流程的活。它的代价是首轮规划如果错了,后面全歪,所以规划提示词里一定要把可用工具和约束写清楚。
2.3 状态机编排模式:把控制权从模型手里拿回来
前两种模式的控制流都由模型输出驱动,状态机模式则相反:开发者预先定义好状态和转移条件,模型只在特定节点被调用。源码上它表现为一个显式的状态字典加转移表:
# 状态定义:每个状态对应一个处理函数 STATES = { "parse": handle_parse, # 解析用户意图 "retrieve": handle_retrieve, # 检索资料 "act": handle_act, # 执行工具 "verify": handle_verify, # 校验结果 "done": handle_done, } def state_machine_run(task, max_transitions=12): state = "parse" ctx = {"task": task, "history": []} for _ in range(max_transitions): handler = STATES[state] ctx, next_state = handler(ctx) # 处理函数返回下一个状态 ctx["history"].append((state, next_state)) if next_state == "done": return ctx["result"] state = next_state return "状态转移超限"状态机模式的关键参数是max_transitions和每个状态的退出条件。它的好处是行为可预测、可测试,每个状态都能单独写单元测试,出问题能精确定位到哪个转移。代价是灵活性差,遇到没预设过的分支就卡住。我一般把它用在生产环境里那些"流程固定但需要模型做局部判断"的场景,比如客服工单处理。三种模式的选型可以对照下面这张表:
| 模式 | 控制权 | 适合任务 | 主要风险 | 典型步数上限 |
|---|---|---|---|---|
| ReAct | 模型 | 短链路、探索型 | 循环、上下文膨胀 | 6-10 |
| Plan-and-Execute | 模型+计划 | 可拆解、流程型 | 首轮规划错误 | 计划步数×2 |
| 状态机 | 开发者 | 固定流程、生产 | 分支覆盖不全 | 8-12 |
3. 把三种模式跑起来:最小可复现工程与参数调优
光看骨架还不够,得让它们真的跑起来才能对比。这一章我用一个统一的工具集和统一的测试任务,把三种模式各跑一遍,重点讲清楚每个参数改了之后行为怎么变。所有代码都基于同一个call_llm和run_tool抽象,这样对比才公平。
3.1 统一工具层与模型调用封装
先搭一个最小工具层,三个工具足够暴露问题:查天气、算数、查数据库。模型调用我用一个带重试和超时的封装,因为 Agent 循环里任何一次调用失败都会让整个流程崩掉:
import time, json def call_llm(messages, tool_specs=None, retries=2, timeout=30): # 统一入口:所有模式都走这里,方便对比 for attempt in range(retries + 1): try: resp = llm_client.chat( messages=messages, tools=tool_specs, timeout=timeout, ) return resp.content except TimeoutError: if attempt == retries: raise time.sleep(1.5 ** attempt) # 指数退避,避免雪崩 return "" def run_tool(name, args): # 工具执行统一加异常兜底,返回字符串而非抛异常 try: if name == "get_weather": return json.dumps(fetch_weather(args["city"])) if name == "calc": return str(eval(args["expr"])) # 仅演示,生产禁用 eval if name == "query_db": return json.dumps(query_db(args["sql"])) except Exception as e: return f"工具执行失败: {type(e).__name__}" return "未知工具"这里有两个容易被忽略的点。第一,run_tool一定要把异常转成字符串返回,而不是往上抛,因为模型需要看到"失败信息"才能决定下一步,抛异常会直接中断循环。第二,call_llm的重试要带退避,Agent 循环里模型调用密集,不加退避在限流时会把整个任务拖死。工具描述(tool_specs)里的参数说明要写得像给新人看的文档,模型解析参数的准确率跟描述质量强相关,这一点在三种模式里都一样。
3.2 用同一任务对比三种模式的实际行为
测试任务我选一个能体现差异的:"查一下某城市今天天气,如果温度高于 30 度就提醒带伞防晒,否则提醒正常出行。"这个任务有分支判断,能看出三种模式的处理差异。跑 ReAct 时,模型通常会在第一轮调get_weather,第二轮根据结果给建议,两到三步结束。跑 Plan-and-Execute 时,它会先规划出"查天气→判断温度→给建议"三步,然后逐步执行,步数更多但过程更清晰。跑状态机时,parse状态识别意图,retrieve状态调天气工具,verify状态判断温度分支,done输出,全程可控。
对比时重点看三个指标:总模型调用次数、总 token 消耗、以及失败后的恢复能力。我实测下来,简单任务 ReAct 调用次数最少,Plan-and-Execute 多出一次规划调用,状态机因为每个状态都要过一次模型,调用次数最多。但任务一旦变复杂,ReAct 的调用次数会指数上升,因为它每步都要重新理解全局,而 Plan-and-Execute 和状态机的增长是线性的。这就是选型的核心依据:任务复杂度决定模式。
3.3 关键参数怎么设:轮数、温度与上下文窗口
参数调优这块我踩过不少坑,直接给结论。max_steps和max_transitions这类轮数上限,经验值是"预期步数 × 1.5",留出重试空间但别太松。温度参数在 Agent 里要分场景:规划阶段用 0.2 到 0.4,让计划有一定多样性;执行阶段用 0 到 0.2,保证工具参数稳定。上下文窗口管理上,ReAct 模式必须做裁剪,我一般保留最近 6 轮加首条任务;Plan-and-Execute 可以把计划单独存,不占对话上下文;状态机模式每个状态独立上下文,天然不会膨胀。
还有一个参数是工具调用的并发度。ReAct 里如果模型一轮返回多个工具调用,可以并发执行,但要注意有依赖关系的工具不能并发。我一般先做依赖分析,无依赖的并发,有依赖的串行。这个逻辑在三种模式里都要写,但状态机模式最容易实现,因为依赖关系可以在状态转移图里提前定义好。
4. 避坑与排查:Agent 循环里最常见的五类翻车
这一章是我血泪经验最集中的地方。Agent 的 bug 不像普通程序那样报错,它往往是"看起来在跑,结果不对",排查起来很费劲。下面五条按出现频率排序,每条都按现象、原因、解决三步写。
4.1 模型反复调用同一个工具
现象是日志里同一个工具被连续调用五六次,参数几乎一样。原因通常是工具返回的结果格式模型没看懂,或者返回内容为空,模型以为没执行成功就重试。解决办法有两个:一是工具返回一定要带明确的状态字段,比如{"status": "ok", "data": ...},让模型能判断成功与否;二是在上下文里加一句"同一工具同一参数不要重复调用"的约束。我一般两个都做,双保险。
4.2 上下文超限导致任务中断
现象是跑到一半报 token 超限,或者模型开始胡言乱语。原因是 ReAct 模式把所有历史都堆在上下文里,轮数一多必然爆。解决办法是分层裁剪:首条任务永远保留,最近 N 轮保留,中间的历史压缩成一句摘要。摘要可以用一次便宜的模型调用生成,成本远低于超限重跑。Plan-and-Execute 和状态机模式这个问题轻很多,因为它们的状态不都在对话上下文里。
4.3 工具参数解析失败
现象是模型输出的工具调用参数缺字段或类型不对,执行时报错。原因是工具描述里参数说明太模糊,或者模型在长上下文里"忘记"了参数格式。解决办法是把工具描述写成带示例的 JSON Schema,参数名用下划线命名保持一致,必填字段明确标注。另外在解析层加一层校验,参数不对时把错误信息回灌给模型让它重试,而不是直接崩。
4.4 状态机卡在某个状态出不来
现象是状态机模式在某个状态反复转移,max_transitions耗尽。原因是转移条件写得太宽,或者某个状态的退出条件依赖外部数据但数据一直没到。解决办法是给每个状态加独立的超时和重试计数,超过阈值强制转移到兜底状态。状态机的好处就是这种问题容易定位,看转移日志就知道卡在哪,比 ReAct 的黑匣子好排查得多。
4.5 重规划触发过于频繁
现象是 Plan-and-Execute 模式里模型不停重新规划,任务迟迟不推进。原因是need_replan判定太敏感,把正常的中间结果也当成失败。解决办法是把判定规则收紧,只在明确的失败信号(工具报错、结果为空、校验不通过)时触发,并且给重规划加次数上限。我一般还会在重规划提示词里带上"已尝试过的方案",避免模型规划出重复的步骤。
5. 进阶技巧:用轨迹日志反推该换哪种模式
写到这,我想分享一个自己常用的判断方法:不要凭感觉选模式,而是先用最简单的 ReAct 跑一批真实任务,把每一步的轨迹完整记下来,然后看日志决定要不要升级。轨迹日志至少记四个字段:轮次、模型输出、工具调用、工具返回。跑完一批任务后,统计三个数字:平均轮数、重复工具调用率、失败恢复率。如果平均轮数超过 8 且重复调用率高,说明任务需要提前规划,该换 Plan-and-Execute;如果任务流程固定但模型判断点很多,该换状态机。
轨迹日志的落地很简单,在循环体里加一个 append 就行:
def log_trace(trace, step, reply, action, observation): # 每轮记录一条,方便事后分析模式是否合适 trace.append({ "step": step, "reply": reply[:200], # 截断,避免日志过大 "action": action.name if action else None, "args": action.args if action else None, "observation": observation[:200], })分析时重点看两类模式信号:一是"模型输出里反复出现相似的思考",这是 ReAct 缺全局计划的表现;二是"工具调用序列高度固定",这是可以固化成状态机的信号。我一般会拿 20 到 30 条真实任务跑一遍,统计完再决定架构,比拍脑袋靠谱得多。还有一个技巧是把轨迹日志喂给模型,让它自己分析"这个任务更适合哪种模式",虽然不能全信,但能提供一些你没注意到的视角。
最后说个我自己的习惯:任何 Agent 项目,我都会先写 ReAct 版本跑通,再根据轨迹日志决定要不要重构成另外两种。不要一上来就上状态机,那会让你在还没搞清楚任务真实形态时就过度设计。模式是手段,不是目的,能稳定完成任务的循环就是好循环。希望帮到你。
本文还有配套的精品资源,点击获取