写过去这两年,我带着团队做了好几个生产级Agent项目,最大的一个感受是:决定一个Agent能不能从“demo能跑”变成“线上能用”,往往不是模型选得多强,而是外面那层决策与规划框架够不够稳。
市面上聊Agent,绕不开四个名字:ReAct、Plan-and-Execute、Reflexion、Tree of Thoughts(ToT)。这四套思路,几乎囊括了当前绝大多数Agent产品的思考方式。你去看LangChain的Agent实现、Dify的工作流节点、CrewAI的团队协作逻辑,底层都是这几套思路的变体。面试里问“Agent怎么设计”,本质考的就是你对这四套方案的取舍和认知。
这篇文章我打算用工程师的视角,把这四种范式掰开揉碎:它们各自的原理、适用场景、实现上的坑,以及同一个实际问题到底该选哪套。内容都是我实际调代码、跑实验踩出来的经验,不是网上的机翻文档。
1. 整体设计思路拆解:Agent为什么需要“决策与规划”这层大脑
1.1 从“单次问答”到“多步行动”的核心跨越
先搞清楚一个基础问题:大模型本身是“单轮推理器”,给它一段输入,它吐一段输出,内部没有循环,没有状态,也没有“下一步该干嘛”的自我感知。一旦涉及多步骤任务——比如“搜集竞品信息、整理成表格、生成摘要”或者“读用户需求、写代码、跑测试、修复错误”——模型必须把一个大问题拆成多个小步骤,每个步骤之间要有依赖关系、有中间结果、有异常处理。
这就是Agent出现的意义。Agent的本质是大模型外面包了一层“决策循环”:模型负责产出一个思考或动作,系统负责执行这个动作,再把结果反馈给模型。这层循环里的“怎么思考”“怎么规划”“怎么从失败中恢复”,就对应了前面那四种范式。
我见过很多新手把Agent当成“万能盒子”,以为随便调个框架就行,结果一跑真实任务就崩。原因几乎都出在同一个地方:没有人先想清楚这个任务需要哪种决策结构,就直接上了代码。
1.2 四种范式一张表看懂
与其先讲大道理,不如先放一张我经常拿来给团队做“选型热身”的对比表。
| 范式 | 核心思路 | 决策机制 | 规划时机 | 失败恢复能力 | 典型场景 | token开销 |
|---|---|---|---|---|---|---|
| ReAct | 思考+行动交替进行 | 逐步推理,边看边做 | 分散在每一步 | 中,靠观察修正 | 问答、工具调用、信息检索 | 中等 |
| Plan-and-Execute | 先制定计划,再按计划执行 | 一次性规划,执行时基本不重想 | 前置 | 低,计划失效时需重排 | 流程明确的多步任务、批量处理 | 较低 |
| Reflexion | 在执行循环外增加反思记忆 | 从历史失败中提取经验 | 执行后、重试前 | 高,靠经验积累 | 写代码、复杂推理、需要反复试错的任务 | 较高(反思消耗) |
| Tree of Thoughts | 同时探索多条推理路径 | 搜索式决策,评估多条路径 | 分支探索式 | 高,可回溯切换路径 | 难题、谜题、创造性推理 | 非常高 |
这张表只是粗略定位。真实工程里,它们不是互斥的。ReAct可以叠加Reflexion变成一个“会复盘”的ReAct;Plan-and-Execute也可以把执行步骤里的每一步都换成ReAct循环。我下面会逐个拆解,并说明组合方式。
1.3 所有范式共享的“底层假设”
在动手实现任何一种范式前,有两条隐藏的假设必须先接受:
假设一:LLM的输出不可百分百信任。无论你是用ReAct还是ToT,模型输出的格式都可能出错:忘了返回JSON、思考过程包含多余内容、工具名拼错。所以工程实现时,必须在模型输出外再加一层“解析+容错+重试”的壳。我最早吃过的亏就是在ReAct上直接正则匹配“Action:”前缀,结果模型一旦换一种说法,整个解析全断。后来统一改成“必须返回结构化JSON,解析失败则重试一次”,稳定性才上来。
假设二:外部工具的Observation不可控。工具返回的结果可能超长、格式错误、包含敏感信息、或者干脆超时。在设计决策循环时,必须考虑Observation的截断和清洗策略。比如一个网页爬虫返回5万字的HTML,你原封不动丢给模型,后续思考几乎必被干扰。我的做法是:工具层先做预处理,丢给模型之前提炼成摘要或结构化字段。
好,理解了这些底层假设,下面逐一拆解四种范式。
2. ReAct:最普适的“思考-行动”循环,先把骨架搭好
2.1 ReAct的核心机制:Thought-Action-Observation
ReAct(Reasoning + Acting)的核心思想很简单:每一步,模型先输出一个Thought(当前的判断和下一步计划),然后输出一个Action(要执行的操作),系统执行后返回Observation(观察结果),然后模型基于Observation继续下一轮 Thought。
这个循环的英文名是 T-A-O 循环。可以把它想象成一个“闭眼走路的人”:每走一步,先把脚探出去(Action),踩到地面后感受一下(Observation),再决定下一步往哪走(Thought)。
我用一个非常直白的例子说明。
问题:上海今天气温多少,适合穿什么衣服?
- Thought:我需要知道上海今天的天气。查询工具是 get_weather(city)。
- Action:
get_weather("上海") - Observation:
温度23℃,小雨,湿度85% - Thought:23℃有小雨,建议穿薄外套并带伞。可以回答了。
- Action:
Finish(建议穿薄外套,带伞)
这个循环看起来简单,但它解决了LLM单轮推理一个致命问题:没有外部反馈时,模型只能瞎猜。纯Prompting让模型直接回答,它会根据训练数据里的记忆编造天气;而ReAct强制模型以观察结果为准,每一步推理都建立在真实信息上,幻觉显著下降。
2.2 为什么它比“纯工具调用”更稳
有人会问:那我直接用Function Calling(函数调用)不也能实现这个效果吗?
确实,现在主流的LLM API都支持函数调用,少了一层“Thought”文本。但ReAct到现在还有存在价值,原因是多方面的。
第一,ReAct把推理过程暴露出来,可解释性更强。你可以看到模型每一步的思考逻辑和决策依据,方便排查。而Function Calling像一个黑盒,模型直接输出参数,你很难知道它“为什么调用这个函数”。
第二,ReAct不依赖特定的模型能力。Function Calling需要服务端支持,部分开源模型或本地部署模型对函数调用格式支持不好。而ReAct只需要模型能生成文本,哪怕是一个很弱的模型,你也能用“格式化输出”约束它走完循环。这就让ReAct在模型选型上拥有最大的自由度。
第三,Thought的存在让模型能“想清楚了再动手”。我做过对比实验,同一个检索问答任务,用Function Calling和用ReAct,复杂问题上的准确率反而不是Function Calling高。原因在于Thought空间给模型多了一步“自我提醒”,比如“注意:上一步查询的可能是旧数据,需要确认时效性”。这种隐性纠错能力在长链路任务里很值钱。
2.3 手写一个最小的ReAct循环
很多人以为ReAct必须用LangChain,其实核心逻辑只有十几行。我用Python给你写一个最简骨架,跑通了再往里面加工具和容错。
import json, re from your_llm_client import chat_completion def call_llm(messages): # 你的模型调用接口,返回文本 return chat_completion(messages) def parse_action(text: str) -> dict: # 约定模型输出格式为 JSON: {"thought": "...", "action": "tool_name", "action_input": "参数"} try: return json.loads(text) except Exception: # 容错:把模型输出里的 JSON 块提取出来 match = re.search(r"\{.*\}", text, re.S) if match: return json.loads(match.group()) raise ValueError(f"无法解析模型输出: {text}") def run_tool(name: str, arg: str): # 工具注册表 tools = { "get_weather": lambda city: f"温度23℃,小雨", "search": lambda q: f"关于{q}的搜索结果占位", # ... 更多工具 } if name not in tools: return f"错误:不存在的工具 {name}" return tools[name](arg) def react(question: str, max_steps: int = 5): messages = [ {"role": "system", "content": "你是一个智能Agent,每轮必须输出JSON格式:" "{\"thought\": \"你的推理\", \"action\": \"工具名|Finish\", " "\"action_input\": \"参数\"}"}, {"role": "user", "content": question} ] for step in range(max_steps): text = call_llm(messages) try: parsed = parse_action(text) except ValueError as e: # 解析失败:把错误信息反馈给模型,让它重来 messages.append({"role": "user", "content": f"输出格式错误,请重新输出。错误信息:{str(e)}"}) continue thought = parsed["thought"] action = parsed["action"] action_input = parsed["action_input"] if action == "Finish": return action_input observation = run_tool(action, action_input) print(f"Step {step}: {thought}") print(f"--> {action}({action_input})") print(f"--> Observation: {observation}") messages.append({"role": "assistant", "content": text}) messages.append({"role": "user", "content": f"Observation: {observation}. 请继续。"}) return "已达最大步骤数,任务未完成"这个骨架刻意用最少的代码说明ReAct的本质。实际操作中,你可能需要调整三处:
- 输出解析层:几乎所有稳定性都死在这里。我的经验是让模型输出一个
{"thought","action","action_input"}的JSON对象,而不是早期论文里的“Thought/Action/Observation”原始文本格式。JSON的好处是解析不容易被前缀干扰,LLM对这种格式的遵循度也高。 - 工具层的错误捕获:工具抛异常时,不要把异常直接抛到主循环,而是把异常信息当作Observation返回给模型,让模型自己决定下一步。比如
run_tool里如果工具报错,返回“工具执行失败,原因是xxx”,而不是中断进程。 - max_steps不是越大越好:很多任务3到5步就该完成,超过10步大部分是在兜圈子。设个上限之外,还可以加“重复动作检测”和“重复思考检测”,有效防止死循环。
2.4 实践中的关键参数与小坑
再分享几个我实测中总结的坑,这些是文档里不会写的。
坑一:Observation太长,模型“看不过来”。搜索工具返回几万字符,模型注意力被严重稀释,后续Thought质量明显下降。解决办法是:Observation进上下文前先做截断或摘要,只保留与当前任务最相关的部分。
坑二:Action名字容易编造。模型可能会脑补一个不存在的工具名,比如send_email(),而你的工具列表里只有send_email_with_attachment。所以在系统提示词里必须明确给出工具列表,最好附带一个示例调用格式。工具名底部做一个“非法工具名”的兜底处理,返回错误提示而不要直接崩溃。
坑三:多轮理解丢上下文。ReAct循环里,messages会不断累积,越到后面越容易超出上下文窗口。我的经验是:中间的历史记录可以压缩。把前几轮的Observation做一段摘要,替换掉原始的Observation长文本,只保留最近的完整轮次。
3. Plan-and-Execute:先画蓝图,再分头施工
3.1 和ReAct的本质区别:一次规划 vs 逐步规划
ReAct是“走一步看一步”,每次思考只推导出下一步,没有全局视图。Plan-and-Execute(P&E)的思路则完全不同:先用一个Planner把所有步骤列成一个计划清单,然后进入执行阶段,按清单逐步执行,执行完一个步骤就勾掉一个。
用生活化类比,ReAct像一个司机边开车边看导航,每个路口才决定怎么走;P&E像一个项目经理,开工前先写一份WBS(工作分解结构),然后交给施工队逐项执行。司机的好处是灵活,堵车能随机应变;项目经理的好处是高效、可控、可预估,但遇到突发改变就僵硬。
P&E的核心优点非常实际:token开销大幅降低。因为规划只做一次,后续执行阶段不需要重复“想接下来要做什么”,每步只需要聚焦“怎么执行当前任务”。在长任务上,这能省掉大量不必要的思考token。我记得有篇文章说P&E的token成本是ReAct的1/2到1/3,我实测在大规模网页信息采集任务上,确实省了将近一半。
3.2 设计一个带重规划的P&E实现
最简P&E的实现分三层:Planner(制定计划)、Executor(按计划执行)、Re-planner(计划失效时重新规划)。
PLANNER_PROMPT = """ 你是一个任务规划器。用户会给你一个任务,请你把它拆解成3-6个可执行步骤。 每个步骤必须是一个自然语言指令,能够让执行器独立完成。 输出格式为 JSON 列表: ["步骤1", "步骤2", "步骤3"] 不要输出其他内容。 """ EXECUTOR_SYSTEM = """ 你是一个执行器。用户会给你一个步骤指令,请你完成它。 如果有需要,你可以调用工具,工具列表如下:... 完成之后,输出一个简短的结果总结。 """ def plan(question: str) -> list[str]: resp = call_llm([ {"role": "system", "content": PLANNER_PROMPT}, {"role": "user", "content": question} ]) steps = parse_json(resp) return steps def execute_step(step: str) -> str: # 这里可以就是一个ReAct子循环,也可以用function calling,按需选择 resp = run_react_loop(step) return resp def replan(question: str, completed_steps: list[str], observations: list[str], failed_step: str): prompt = f""" 原始任务: {question} 已完成步骤: {completed_steps} 各步骤结果: {observations} 失败步骤: {failed_step} 请重新制定剩余计划,以JSON列表输出。 """ return call_llm([{"role": "user", "content": prompt}]) def plan_and_execute(question: str): steps = plan(question) completed = [] obs_list = [] for i, step in enumerate(steps): print(f"执行第{i+1}步:{step}") observation = execute_step(step) if is_failure(observation): # 触发重规划,而不是死磕当前步骤 steps = replan(question, completed, obs_list, step) # 重新从失败处继续执行 completed.append(step) obs_list.append(observation) return format_final_result(completed, obs_list)这个实现里,最关键的就是**“执行失败时触发重规划”**。很多P&E翻车的案例,都是执行到某一步发现计划跟实际不符,但系统还在硬跑后面的步骤,最后结果完全跑偏。加上重规划以后,等于给P&E装了一个“熔断”机制:发现不对劲,回到规划层重新来一份计划。
我自己的经验是,重规划不能每次失败都触发,否则会无限循环。要加一个“重规划次数上限”,比如最多重新规划2次,超过上限直接终止任务并返回当前部分结果。
3.3 什么时候该用P&E
以我自己做过的真实项目举例。我曾经做一个“定时抓取竞品官网,提取产品更新并生成周报”的Agent。最开始用ReAct,每一轮都让模型重新判断“下一步该做什么”,结果发现模型经常在“抓取哪个页面”和“提取哪些字段”之间反复犹豫,还偶尔自己开启新思路,抓了一堆不该抓的页面。
换成P&E之后,我把“抓取流程”固化为一个计划:“访问首页->抓取更新日志页->提取标题和日期->去重->生成摘要->生成周报”。每个执行步骤内部,其实还是一个小型ReAct循环,但外层大流程的规划稳定住了,整个Agent的行为一下子变得可控。
P&E适合的场景我很明确:
- 任务目标清晰,步骤可预知,比如批量文件处理、定时信息采集、标准流程审批。
- 步骤之间有明确的先后依赖,比如“先查库,再算指标,最后画图”。
- 需要在执行中保持“主计划”不被带偏,比如一个报告生成任务里,不希望模型因为某个中间发现而去做一堆无关的探索。
不适合的场景也很明显:如果任务本身需要根据实时反馈不断调整方向(比如“帮用户反复对比不同方案并适配新需求”),P&E会显得迟钝,这时回退到ReAct反而更顺手。
3.4 P&E的代价与风险
P&E不是银弹,有两条明显的代价值得留意。
一是计划本身的准确度。Planner也是一个LLM,它可能漏掉关键步骤或逻辑错误。一旦计划错了,后面执行方向全错,而且因为是“一次性规划”,中途很难发现方向性问题。对策是我上面说的“重规划”机制,但它增加了一层复杂度。
二是执行步骤之间的信息传递容易漏。在P&E流程里,步骤1的输出要作为步骤2的输入,这个中间状态怎么传递,需要你在工程上显式处理。很多框架里,每个Executor是独立调用的,前一步的结果如果不写进prompt传给下一步,模型根本不知道你已经拿到了哪些数据。所以我的P&E实践里,每一轮的Observation都会追加到一个“工作上下文”里,下个步骤执行时,自动带上“已完成信息摘要”。
4. Reflexion:让Agent学会“复盘”,从失败里长记性
4.1 核心思想:用语言反馈代替梯度下降
Reflexion的出发点是:Agent犯了一次错之后,下次能不能不犯同样的错?
这个问题在ReAct里没有答案,因为ReAct的每一步都是独立的,上一步失败留下的只是Observation,没有被抽象成“教训”。比如一个写代码的Agent,第一次运行代码报错“未定义变量x”,ReAct只是把报错丢给模型让它修;但如果换了类似的新任务,同样的错误可能再次发生。
Reflexion的思路,是在执行循环之外增加一个反思器(Reflexion Agent)。当一次完整的执行失败或者结果不理想时,反思器会读取“当前任务、执行轨迹、失败信息”,生成一段语言反思总结:“我这次失败的原因是XX,下次遇到类似情况应该先做YY。”这条反思被写进记忆区,在下一次尝试(或下个相似任务)开始时,作为前置经验注入Prompt。
你可以把它理解为“给Agent加上了一种用自然语言表示的‘模型微调’”,而且不需要动权重。模型不会忘掉刚学到的教训,因为反思被存在外部记忆中,每轮都会自动带上。这就是“用语言反馈代替梯度下降”的核心思想。
4.2 记忆结构怎么设计才能不“一锅乱炖”
Reflexion的记忆不是简单堆字符串。我按经验分三种记忆:
Situation记忆(当前任务描述):记录现在要解决什么问题,目标是什么。这部分相当于“当前任务的背景板”。
Failure记忆(短期失败的轨迹):记录本次尝试中,哪几步失败了、返回了什么错误、当时的思考是什么。有了这条记忆,模型可以沿着失败轨迹往前回溯,定位到真正导致错误的那一步。
Lessons记忆(长期教训):从失败中提炼出来的泛化经验,比如“使用requests前必须检查网络状态”或“PDF表格解析最好用pdfplumber而不是通用文本抽取”。Lessons才是Reflexion的核心价值——它已经从具体任务中抽离出来,成为可复用的常识。
实际工程里,我用一个Python列表维护这三类内容,并且在反思生成后做一个简单的“去冗余”判断:如果新反思和已有反思高度重叠(用向量相似度或关键词匹配),就不再插入,防止记忆区被同类教训刷屏。
4.3 一个完整反思循环的代码骨架
下面给一个“写代码Agent + Reflexion”的最简实现,重点看反思在哪里插入。
def generate_code(task): # 普通代码生成,返回代码和补全状态 ... return code, is_complete def run_code(code): # 执行代码,返回输出/错误 ... def reflect_on_failure(task, code, error, previous_lessons): prompt = f""" 任务: {task} 上一次生成的代码: {code} 执行错误: {error} 历史教训: {previous_lessons} 请反思本次失败的原因,并给出一条在未来任务中可复用的经验教训。 只输出经验教训,不要输出代码。 """ lesson = call_llm([{"role": "user", "content": prompt}]) return lesson.strip() def solve_with_reflexion(task, max_attempts=3): lessons = [] # 长期记忆 for attempt in range(max_attempts): code, is_complete = generate_code(task, lessons) if not is_complete: error = run_code(code) print(f"--- 尝试{attempt+1}失败,获取教训") lesson = reflect_on_failure(task, code, error, lessons) if lesson not in lessons: lessons.append(lesson) # 不结束,开始下一轮尝试,带入 lessons 到生成 prompt 中 else: return code raise Exception(f"无法解决,最终教训: {lessons}")这个模式我实际用在一个“自动修复Python脚本”的工具里,效果非常明显。第一次遇到“缺少依赖库”的错误,反思出“要先import”的教训前,模型可能会搜索API文档绕一大圈;有了反思之后,第二次面对类似任务,模型在生成阶段就直接做好依赖导入,一步到位。几次迭代后,整个Agent对那一类项目的成功率从50%左右提到了80%。
4.4 把Reflexion叠加到ReAct上,怎么叠不打架
Reflexion在工程里很少单独出现,更多是作为ReAct或P&E的外挂“反思壳”。我这里给一个大家容易理解的组合方式:
- 外层是“任务重试循环”:
- 用ReAct执行整个任务;
- 如果最终结果不达标(通过一个评估器判断,比如断言、测试用例、人工规则),触发Reflexion生成教训;
- 把教训写入记忆,重置执行状态,重新跑ReAct;
- 直到达标或达到重试上限。
这个组合要注意两个工程细节。一是重试时不要把上一轮的错误状态全部保留,否则模型会被旧的失败轨迹干扰。经验是:保留“教训列表”,但清空上一轮ReAct的对话历史,让模型带着教训从零开始,而不是沿着错路继续走。
二是评估器要有明确规则,不能全都让LLM自己判定。比如写代码任务,就用“代码能否执行通过+测试用例是否全绿”作为硬指标;数据提取任务,就用“关键字段是否都存在”。反思的触发条件越客观,整个系统的稳定性越高。
5. Tree of Thoughts:把单条思路升维成决策树
5.1 核心思想:让模型一次生成多条推理路径,而不是一条道走到黑
前面三种范式,本质都是“单条路径”:模型一次只生成一个Thought,顺着一条思路走下去,错了再重来。Tree of Thoughts(ToT)在思路上彻底改变:它让模型在每一个探索节点上,同时生成多个候选思维(Thought),并给每个候选打分评估,然后选择最有希望的路径继续探索,甚至可以回溯到之前的节点,换一条路线。
用类比最直观:解一道复杂的迷宫,新手是走一条路,撞墙了退回来再走另一条;老手是同时在地图上标记好几条可能的路线,每到一个岔路口都评估一下哪条最可能通向出口。ToT就是那个“老手”。
ToT最适合的场景是复杂推理,比如数学证明、逻辑谜题、创意写作里的情节展开、多步规划里的路径选择。普通ReAct在这种任务上很容易掉进局部最优,因为模型生成第一个Thought后,后续所有思考都建立在这条路径上,如果开头就偏了,后面很难拉回来。而ToT通过多条路径+评估+回溯,极大地提升了找到正确答案的概率。
5.2 状态定义、生成策略、评估器、搜索算法
实现一个ToT,你需要四样东西:
节点状态(State):当前推理到了哪一步。比如24点游戏里,“当前已经用了哪几个数字、还剩哪几个数字”;代码修复里,“当前代码版本是什么”。
候选项生成(Generator):从当前状态出发,让LLM生成k个可能的下一步思维候选。关键问题是“一次生成”还是“让LLM分开生成”更有效。我实测过,分开生成的质量明显更高。原因是一次性生成多个候选项时,LLM的输出会趋向同质性,几个候选往往只是措辞不同、思路雷同。分开生成、每次都清空上下文,反而能激发出不同的思考角度。
状态评估器(Evaluator):评估每个候选思维的价值。评估方式有两种:一种是让LLM打分(0-10分),叫“价值评分”;另一种是让LLM判断该状态是否可行,叫“可行性投票”。我建议处理创造性任务用“价值评分+理由”,处理逻辑严密的推理任务用“投票”更可靠。也可以在评估器里引入规则判断,比如24点游戏就检查“数字是否合法”,根本不需要LLM来评。
搜索算法(Search Algorithm):常见的三种——
- BFS(广度优先):同一层所有候选都探索,保留Top-k个最优状态,逐层推进。适合深度不算深但分支多的任务。
- DFS(深度优先)+ 回溯:沿着一条路走到底,不行再回溯到上一个节点换候选。适合深度较深、且你想尽快找到一个可行解的场合。
- Beam Search(束搜索):每层保留Top-k个状态,但我个人感觉在LLM场景里它和BFS差别不大,核心都是控制k值。
我给一个最简的BFS版ToT骨架,方便理解结构。
def generate_candidates(state, k=3): # 让LLM基于当前状态生成k个候选思维 # 每个候选是 "从当前状态出发的一步推理" candidates = [] for _ in range(k): resp = call_llm(prompt_for_generation(state)) candidates.append(resp) return candidates def evaluate_state(state): # 让LLM评估这个状态成事儿的可能性,返回0-10分 score = call_llm(prompt_for_evaluation(state)) return float(score) def tree_of_thoughts(start_state, depth=3, k=3): current_states = [start_state] for level in range(depth): new_states = [] for state in current_states: candidates = generate_candidates(state, k) for cand in candidates: cand_state = apply_candidate(state, cand) score = evaluate_state(cand_state) new_states.append((score, cand_state)) # 按分数排序,保留 Top-k new_states.sort(key=lambda x: x[0], reverse=True) current_states = [s for _, s in new_states[:k]] print(f"Level {level}: 保留状态数 {len(current_states)},最高分 {new_states[0][0]}") if is_solved(current_states[0]): return current_states[0] return current_states[0]这个骨架里的apply_candidate就是“把候选思维应用到当前状态,生成新状态”的操作,在代码修复场景里可能是“把这条修改建议合并到代码里”;在解谜场景里可能是“执行这一步操作后,剩余数字集合变化成什么”。
5.3 一个具体案例:用ToT解“24点”数字谜题
我在团队内做技术分享时,最常用“24点”游戏做ToT的演示。
任务:给定四个数字2、3、4、6,用加减乘除凑出24。
第一层状态是[2,3,4,6]。生成候选时,LLM输出“先计算3*4=12”,新状态变成[2,6,12];也可能输出“先计算6/3=2”,新状态是[2,4,2]。评估器对“[2,6,12]”打分较高,因为12和24倍数关系更近;对“[2,4,2]”打分一般。于是BFS保留高分的几个状态,进入下一层,继续生成候选。直到某个状态变成[24],任务成功。
我用ReAct跑过同一个24点题目,经常绕来绕去卡在某个局部(比如反复尝试2*6+...),而ToT几乎每道题都能靠广度搜索摸到正确答案。代价也很直观:ToT每层要并行调用多次LLM,一个深度3、每层3个候选的ToT,一次任务可能烧掉十几到几十次LLM调用,token开销大得惊人。
5.4 Token消耗与工程限制,别当“万金油”
ToT最大的克星是成本和延迟。我做一个24点题,纯ReAct大概几次调用就够;ToT如果深度设到4、候选数设到5,轻松烧掉三四十次调用。如果接的是商用的GPT/Claude API,一道题几十美分是常态。
所以我自己的选型原则很明确:ToT只用在“单次推理失败代价高、且没有现成算法可解”的任务上。比如金融报告里一段复杂因果推理、法律文书里的多步逻辑链条、或者深度代码Bug倒查。如果任务能通过工具或者规则解决,永远不要上ToT。
工程上还有一个细节:ToT的搜索宽度k和深度d需要调参。k=2和k=5的结果差异巨大,而token成本也几乎线性增长。我一般从k=2、d=3起步,跑一批验证集看准确率和成本曲线,再逐步加k,直到准确率不显著提升为止。盲目把k拉到很大,往往只是让计算成本翻倍,准确性并不会有太夸张的变化。
6. 四种范式对比与工程选型:你的场景到底该用哪一套
6.1 同场对比:决策机制、成本、适用边界
到这里,四种范式的底层逻辑已经清楚了。我想换一种更“面试题友好”的方式,用一张扩展对比表做二次总结。
| 对比维度 | ReAct | Plan-and-Execute | Reflexion | Tree of Thoughts |
|---|---|---|---|---|
| 决策粒度 | 单个动作级 | 整条任务级 | 任务级反思 | 节点状态级 |
| 原始规划方式 | 隐式,边走边想 | 显式,先完整列出 | 无规划,靠经验注入 | 分支并行探索 |
| 全局视野 | 弱,只看一步 | 强,有完整清单 | 中,有历史教训 | 强,探索多条路 |
| 失败恢复 | 靠Observation修正,恢复浅层 | 靠Re-planner重新计划 | 靠反思记忆重试 | 靠回溯切换路径 |
| 计算开销 | 中 | 低 | 中高 | 极高 |
| 调试可解释性 | 高 | 中 | 中 | 低 |
| 框架支持 | LangChain有原生Agent | LangChain有PlanAndExecute | 无内置,需要自研 | LangChain有TreeOfThought |
| 真实项目落地难度 | 低 | 中 | 中高 | 高 |
这张表背后有一个通用规律:决策灵活度越高的方案,成本也越高。ReAct灵活但每个动作都要推理,ToT成本爆炸但搜索空间覆盖最好。没有“全都要”,只有“按需选”。
6.2 框架层面:LangChain、Dify、CrewAI到底哪个适合做这些决策循环
结合热词里那句“agent框架如langchain、dify、crewai等,哪个好”,我简短说下我的真实体验。这属于面试和选型的高频问题。
LangChain是这四种范式表达力最完整的框架。它的AgentExecutor支持ReAct,近期版本对Tool Calling的封装也很好;PlanAndExecute有内置类;ToT也在LangChain社区里出现过,虽然支持比较浅。如果你要做的Agent需要精细控制决策逻辑、需要自定义工具调度,LangChain的灵活度无可替代。代价是学习曲线陡峭,版本更新频繁,底层抽象太多,调试时得撕开好几层包装。
Dify是低代码/可视化工作流平台,适合把Agent流程编排成画布。它能做的事情严格来说只有两类:对话流和工作流。对ReAct层面的控制很弱,几乎不能自定义Thought循环,但胜在快速搭建上线。如果你的需求是“不用写很多代码,把Agent串起来给业务用”,Dify是不错的选择;如果你想实现Reflexion或ToT这种定制化决策策略,Dify基本给不了你要的空间。
CrewAI主打多角色Agent协作,核心是“多个Agent组成一个团队,各有分工”。它背后每个Agent其实还是跑ReAct或Plan-and-Execute这类单Agent逻辑,但它更擅长的是“跨角色调度”。如果你的核心场景是“需要多个专业Agent协作,比如一个写方案、一个查资料、一个审校”,CrewAI的抽象很舒服;如果只是单Agent单任务的复杂推理,它的价值不大。
我的建议一句话总结:要深度控制决策循环用LangChain,要快速搭可视化流程用Dify,要多Agent协作分工用CrewAI。这三者不是替代关系,是需求分层。
6.3 一套可以照着抄的选型决策流程
我在项目里经常逼团队按这套流程走,能避免很多“想当然”:
- 任务是否可以分解成固定步骤?→ 如果能,直接考虑Plan-and-Execute。
- 任务是否需要实时环境反馈(查网页、查API、调整参数)?→ 是,考虑ReAct。
- 任务是否需要多轮试错才能完成?→ 是,考虑叠加Reflexion做记忆与复盘。
- 任务是否属于高难推理、单条路径容易死胡同?→ 是,考虑ToT。
- 任务成本敏感吗?→ 是,尽量避开ToT,优先P&E压缩token量。
- 需要多角色协作吗?→ 是,先把决策循环明确下来,再用CrewAI之类的框架包装。
这套流程在80%的项目里都能走通。剩下的20%,属于那种复合型任务,比如“既要实时反馈又要多步规划还要能复盘”,这种情况我的做法是“基础决策用ReAct或P&E,外面包一层Reflexion,只在最关键的一个决策点上局部使用ToT”。工程上永远不要追求“范式纯洁”,追求“任务被解决”才是正事。
7. 常见问题与排查实录:我踩过的那些坑
7.1 高频问题速查表
我把过去一年在Agent项目里遇到的高频问题整理成一张表,方便你快速对照排查。
| 现象 | 可能根因 | 解决方案 |
|---|---|---|
| Agent陷入无限循环,一直重复同一个动作 | ReAct缺重复检测;Observation没有促进状态变化 | 增加重复动作计数,连续N次相同动作强制终止;检查Observation是否被截断至无信息量 |
| Agent在P&E计划里跑偏,结果完全不对 | 计划本身质量差;执行步骤间信息丢失 | 提高Planner的prompt质量,加入领域规则;增加Re-planner熔断机制 |
| 反思功能加进来后效果反而变差 | 教训记忆注入过多,模型被历史干扰 | 反思上限设为1-3条;新任务开始时只注入与本任务相似的Lessons,不是全部塞入 |
| ToT搜索深度太深,结果卡死超时 | 候选数k或者深度d设得过大 | 从k=2/d=3起步,观察cost和准确率曲线;加全局超时控制(如总调用次数上限) |
| LLM输出格式不稳定,解析反复失败 | 没有明确格式约束;没有容错重试 | 改用JSON严格输出;解析失败把错误信息回塞给模型,让它自己纠正输出为合法格式 |
| 工具返回Observation过长,模型上下文崩溃 | 工具层缺少清洗与截断 | 工具层做摘要提取,把Observation压缩到300个token以内再交给模型 |
| Agent执行过程中产生敏感信息(如账号密码) | 工具返回包含敏感字段 | 在Observation清洗层对敏感字段过滤,不进入模型上下文 |
这张表不用背,关键是记住排查顺序:先看输入(context),再看工具(observation),最后看模型(output)。
7.2 一个典型的死循环排查实录
我举一个真实排查过程,你就明白这个顺序多重要了。
之前做一个“爬取竞品价格并生成日报”的Agent,用户反馈它有时跑3分钟不结束。我一看日志,模型每轮都在执行同一个动作:get_product_price(url),并且Observation里返回的都是“价格获取失败”。
第一反应是工具G了。检查工具,发现价格页面有反爬,返回的HTML里没有价格字段。工具层返回的结果是“解析不到价格”,这个Observation本身是干净的。
但我发现日志里有个细节:模型看到“获取失败”之后,并没有换工具或调整策略,而是机械地再次调用同一个工具,参数都不变。这说明ReAct循环里缺少“状态变化检测”——观察没变、动作没变,循环当然永远走不出来。
修复方法很简单:在循环外维护一个“最近3步的动作+观察指纹”,如果指纹完全相同,强制终止并返回“任务失败,建议更换策略或增加人工介入”。这个改动让那个Agent的偶发死循环下降了一多半。
后来我又发现一个更隐蔽的问题:某些失败场景下,工具的报错信息太友好(比如返回“重试即可”),模型就真的认为重试有用,于是疯狂重试。我的对策是:指南工具层在设计报错时,要把“是否为永久性失败”和“是否需要人工介入”显式写进Observation,尽量减少让模型“猜”的机会。
7.3 独家避坑技巧:这些经验很值钱
最后分享几条我踩过坑之后总结出的“独家避坑”经验,长期受益。
第一,永远给Agent加“熔断机制”。无论你用哪种范式,最终都要有一道物理层面的“总闸”:最大步骤数、最大调用次数、最大运行时间,三者至少设一个。模型被prompt绕晕的时候,它能用的不是Reasoning能力,而是你给它的“逃生舱”。
第二,Agent的Prompt要像飞机驾驶舱仪表盘一样“只显示关键信息”。很多人把任务细节、工具说明、历史对话一股脑塞给Agent,以为信息越多越聪明。实测告诉我,Agent的上下文是“越干净越可控”。我在ReAct系统提示词里只保留:任务目标、工具列表(名称+用途)、输出格式示例、一条“禁止做的事”清单。其他细节全部通过工具查询获得,而不是常驻上下文。
第三,评估器是Agent质量的灵魂。如果评估器写得模糊,整个Agent的判断就模糊。我建议每个Agent项目都花时间定义一个“成功标准”:什么算完成、什么算失败、什么算部分成功。最好用规则硬编码,不要全靠LLM自评。硬规则+软评估的组合,让Reflexion和ToT的“择优”才有依据。
第四,做好Agent行为的可观测性。每个thinking/action/observation都打点记录,甚至保存成JSON日志。这个习惯在项目初期看起来耽误时间,后期排查问题、复盘模型表现、做回归测试时,价值会成倍放大。有一次客户反馈Agent对某个用户的问题回答错误,我靠日志里的trace找到了是“工具返回了旧缓存”,三分钟定位问题。如果没有trace,这种问题就算调研一整天也未必有头绪。
我在实际项目里的体会是,Agent的“智能感”往往不是模型给的,而是工程结构给的。ReAct给了Agent行动的骨架,Plan-and-Execute给了它效率,Reflexion给了它从失败中站起来的能力,ToT给了它面对难题时的探索精神。把这四套东西真正理解透,看着模型在日志里一步步自己修正路线、完成任务,那种感觉比追着调Prompt阈值爽太多了。下次遇到Agent跑飞,别急着换模型,先回来检查这层“决策的外壳”。