一个新技术出来的时候,往往概念一个接一个,让人目不暇接,但是所有解决方案都应该是为了解决某个问题而来,因此我试图用问题->解决方案->新的问题->新的解决方案的方式串起来各种Agent架构。本篇瞄准单Agent架构,尝试把ReAct、Plan-And-Execute、ReWOO、LLMCompiler、LATS等用一条思路串起来,推理一遍,既是对自己学习的一个总结,也希望能给同样在学习的兄弟们一点儿参考
01到底什么是Agent
大模型(LLM)的火从ChatGPT开始,表现为一个有智能的但是只能对话的机器人,智能是其相对于传统计算机系统的最大区别,那很自然的就可以想到,既然有了大脑(即智能),是不是可以配上手脚,形成一个完整的“人”,不光能说,还能干活,例如有一篇稿子需要润色,只有LLM对话框的时候,我只能把稿子文字复制到对话框,让LLM帮我改,改完后我自己再人肉贴到Word中,那么能不能直接把Word文档扔给它,它直接给我改好?
这就是Agent的定义,即Agent=LLM+工具。
定义出来了,自然就会引出来第一个问题,LLM运行在GPU上,GPU可能在云端,也可能在专用的服务器上,是一个多人共用的大脑,但是每个人想要使用的工具五花八门,一般都在各自的私有环境中,例如我的电脑上,例如我的业务服务器上,大模型是没有办法直接远程来使用我的工具的。
思考一下即可想到,所谓的工具调用,无非是三个步骤:1、选择合适的工具,这个大模型能做,只不过要把所有工具能干啥、当前上下文告诉它;2、确定调用工具的参数,大模型也能做,给它所有工具的参数定义+当前的上下文即可;3、真的调用工具,这个LLM干不了。第1步加第2步,就是所谓的Function Calling能力,LLM具有Function Calling能力,并不是说它可以直接使用工具,而是它具有根据当前的上下文准备好工具调用所需要的所有信息的能力。
从实现的角度,我们需要一个容器,把远端大模型的智能和本地工具的实操能力融合到一起,这就是Agent,显然Agent要做的事情包括:接收用户的输入、管理所有可用的工具、利用大模型的Function Calling能力确定要调用哪个工具、真实的调用工具,再利用大模型的智能决定是否要进一步迭代,直到用户的任务完成。这非常类似于一个“人”了,各个Agent架构**,其实就是在想办法让这个“人”能够更好的干活。
02 ReAct:想一步走一步看结果再继续
想象一个人在没有全图的情况下要在一个地图中找到宝藏,大概率按照这么个步骤来:1、根据当前已经有的信息,思考下一步怎么行动;2、行动,拿到行动结果;3、根据当前被更新的信息(因为每次行动都会反馈信息),继续思考下一步怎么行动。如此迭代,直到找到宝藏或者累坏放弃。
这就是ReAct**(Reasoning + Acting)了,其步骤为:1、思考Reasoning:分析当前情况,决定下一步该做什么;2、行动Acting:执行工具调用或者生成最终答案;3、观察Observation:接收第2步的结果;4、迭代:基于观察到的结果继续回到第1步。
实践发现,当迭代次数多到一定程度的时候,ReAct会出现两个问题:1、把前面步骤的假设或者错误当做事实;2、陷入死循环。这是因为LLM的能够使用的心智状态其实是一个扁平的无结构的token序列,token序列长到一定程度的时候跟人一样会心智混乱,典型的例子是lost-in-the-middle,这是虽然当前最顶级的大模型已经支持1M上下文,但是如果真的给1M上下文,大模型的表现会显著下降的原因;而LLM的训练过程尤其是早期的训练过程(Next-token机制的预训练+微调/强化阶段的忽略反思),使得它更倾向于自圆其说,而不是反思主动发现自己的错误(例如发现工具调用结果无效,自行判断再次调用是无意义的)。
模型不足,Agent来补,既然ReAct不会反思,那么最自然的想法就是给它打个补丁,强制它反思,即Reflection模式。事实上,Reflection模式是对所有基于大模型推理来行动的一个通用补丁,不仅适用于ReAct,也适用于其他Agent架构。
03 Reflection:需要的时候看看是不是搞错了
Reflection来增强ReAct的思路很简单,在ReAct的迭代的合适时机,强制插入一个额外的自我反思步骤,对已经执行的轨迹进行反思,并且把反思的结果作为ReAct后续迭代的输入,以实现纠偏的效果。
Reflection落地的时候的第一个关键点是:什么叫做合适时机?每步都加当然效果好,但是一来费token,二来用户响应变慢,实践中,考虑Reflection的目的是纠偏,所以合适的时机是ReAct可能有偏差或者要执行重大动作的时候,总结起来,一般包括以下几种方式(特别要注意的是,以下判断一般都是要Agent的代码监测LLM的思考过程来实现):
- 要调用昂贵工具或者直接修改外部数据时:工具加一个属性标注是否是需要反思的工具,侦测LLM的Function Calling输出即可;
- 出现了重复工具调用时:侦测LLM的工具调用记录;
- 调用工具出现了错误时:工具统一结构化返回明确调用结果错误码+侦测LLM工具调用记录的调用结果;
- ReAct要做选择时:一般情况下,检测LLM思考步骤的输出,判断是否有“考虑X和Y两种方式”类似的文本,这个并不能做到百分之百都搂住,一个可以改进的方法是让ReAct在做选择的时候,按照结构化方式输出,这里就可以利用这个结构化数据来判断了。
Reflection落地的第二个关键点在于如何让LLM好好反思:能控制LLM的只有提示词,所以:
- 需要明确LLM的反思定位
- 通过规范输出来使得其反思更有效:要求输出每个推理结论的合理性判断并附加事实溯源链
Reflection落地的第三个关键点在于反思结果如何塞进ReAct的上下文:反思结果不一定很小,尤其在步数比较多的时候,所以如果反思结果全对,完整的塞回去既费token又让扁平上下文更长,但是反思输出其实是一个总结性的梳理,对于大模型更好的推理是有价值的,所以一般对其进行总结精简(概括当前进展、确认路径有效)塞回去;如果反思结果发现前面有步骤不对,逻辑上只需要把这个错误部分塞回去即可,但是考虑到降低LLM的心智负担,最好使用agent层的逻辑,根据反思结果把错误的上下文修正(例如替换具体的错误步骤为“这一步干了什么,因为什么不应该这么干”)。
04 Plan-And-Execute:先规划再执行
任务比较复杂的时候,即便有Reflection,效果仍然不够好。类比人,ReAct+Reflection的方式类似于边做边看,定期反思,任务复杂了,这种思维模式就有毛病了,所以2023年的时候就出来了Plan-And-Execute,其思路并不复杂,人类面临复杂任务的时候,总是会先总体考虑一下步骤是什么样子的来做个计划,然后再一步步执行,Plan-And-Execute也是如此。
Plan-And-Execute中包含两个角色,Planner负责产生计划,Executor负责执行计划中的一个步骤,每个步骤执行完毕后,产生该步骤的执行结果,然后Executor重置,输入整体计划、当前执行步骤、上一个步骤的执行结果(一般不包括完整的内部执行trace),执行下一个步骤,如此循环,直到所有步骤全部执行完毕。
从这个流程可知,Plan-And-Execute其实同时改善了两个问题,第一个使得LLM做规划时只做规划,一方面更有整体观,另外一方面,不容易受到历史trace(即ReAct中Reasoning、Observation的完整细节序列)的干扰;第二个是简化了每个步骤的执行上下文,一定程度上屏蔽了前序步骤内部执行细节的干扰。
Plan-And-Execute的不足在于,想要基于用户的输入、已经提供的工具列表等上下文,在没有进行任何工具调用时,想要一次性做出完善的计划具有较高的难度。
05 ReWOO:只看该看的步骤产出
ReWOO的全称是Reasoning WithOut Observation,它与Plan-And-Execute一样是在2023年出现的针对ReAct来进行的改进。
从这个名字可以知道,它关注的是将工具执行的结果(即Observation)从推理中去掉,因为在实际Agent工作过程中,一个工具的执行结果对于完成当前步骤来说很可能存在很多冗余信息,例如ReAct中的一个步骤是查询用户最近看过哪些平台视频,大概率会有一个工具叫做查询用户播放历史,播放历史返回的信息会远远超出这个步骤所需要的内容名称(不能单独写一个工具叫做查询用户最近播放的内容名称,否则工具数量就爆炸了)。这些冗余信息,对于LLM会产生明显的干扰,所以ReWOO的出发点就是,只保留必须的信息给到LLM进行推理。
那么如何知道什么是必须的信息呢?显然最好的方式是对任务预先做一个整体计划,要标注出每个步骤需要什么输入,产生什么输出,这些输入即必须信息。然后逐步执行这些步骤,通过Agent层的逻辑从上一步的输出中抽取必要的数据,确保大模型执行步骤时,仅看到该步骤必需的输入。
虽然解决问题的出发点不同,但是ReWOO的流程跟Plan-And-Execute的流程非常像。
基于ReWOO的降噪思路,其实也可以直接对ReAct进行改造,即并不把完整的Observation作为上下文作为下一步的推理输入,而是先进行一步抽取,把下一步必须的数据抽取出来。这个的好处在于对于预先难以规划全局的任务也可以降低上下文数量提升Agent的最终表现,不足在于ReAct的走一步看一步方式,并不能确保可以预先确定下一步所需要的所有关键信息,更无法预知未来的某一步是否需要被丢弃的信息。这个思路叫做“前置声明式单步观测过滤 ReAct”。
06 LLMCompiler:用并行执行来加速
LLMCompiler**也是在ReAct的基础上改进,它出现在2023年年底,它瞄准的点是ReAct串行执行太慢,它提出的解决方案是能并行的就并行,所以也必须要先做计划,然后再执行。
LLMCompiler中有三个角色,Planner负责产生计划,这里的计划中的单元一般是Function Calling级别的,由多个这样的元素组成一个DAG**图;Task Fetching Unit,TFU负责从计划中提取出可以开始执行的任务单元给到Woker池执行。
LLMCompiler有两个重点:第一个是Worker是一个池子,来支撑并行执行。第二个是TFU采用尽快分派任务执行的策略,具体来说,在任务规划阶段,实时检测Planner输出,只要有可以执行的任务单元,就扔给Woker池执行;在任务执行阶段,只要DAG中的任意一个任务所有前置条件均已具备,就安排给Woker池执行。
07已有架构模式的融合落地
到此为止,我们发现,ReWOO、LLMCompiler、Plan-And-Execute虽然出发点不同,实现细节也有所不同,但是最后落下来的Agent整体流程都是先规划后执行的逻辑,又各自解决了不同的问题,所以在实际应用中,这三个需要也应该融合。
-
如果任务比较简单,直接上ReAct即可,顶多再增加一个Reflection,不应当为了架构而架构,只有当任务比较复杂了,ReAct出现了短板,才需要考虑引入规划-执行的架构。
-
Plan-And-Execute跟ReWOO天然应该融合,也即以Plan-And-Execute为骨架,在规划阶段,按照ReWOO的方式,要求每个步骤包括输入/输出契约;在每个步骤执行完毕后,将执行结果以统一的过滤器根据执行计划中的输入输出契约,提取出精简数据,作为下一步的输入。
-
如果任务有大量的并行空间(例如可并行执行的检索),且响应时间需要尽可能的短,则应当再融合LLMCompiler,分两层,第一层:Planner规划时,以DAG方式更进一步的标注出各个步骤的前置依赖关系,在执行时,以并行方式执行(参考TFU逻辑);第二层:Planner规划的第一层计划的每个环节是一个较粗的步骤,基于LLMComplier的思路,将其进一步规划为以工具调用为元素的第二层计划(仍然融合ReWOO的思路规范输入输出契约),并实现TFU的执行,TFU执行时,基于第二层计划中的输入输出契约对每个工具的执行结果进行提取。
-
第一层规划出来的较粗的步骤,如果存在需要自由探索的需求,应该应用ReAct模式,即每个Woker可以是一个ReAct+Reflection的智能体:因为其复杂度降低,ReAct出现死循环等问题的概率也大幅降低。
-
前面提到Plan-And-Execute模式的不足是一次性产出正确的规划存在难度,因此可以采用两种方式来优化:(1)规划完毕后,采用Reflection的思路,进行一次反思;(2)执行过程中,如果发现执行错误,则可进行有限次数的重规划RePlan,重规划时需把错误信息进行归纳后给到LLM。
-
Planner产生的计划,如果反复出现,可以考虑代码硬化为工作流WorkFlow,一方面规避Plan阶段降低消耗降低响应时长,另一方面提高任务执行成功率。
08 LATS:多路径探索来应对复杂问题
不论是ReAct的边看边计划下一步,还是Plan-And-Execute等的先规划完整计划,再逐步执行,最终都是选择了一个路径来解决问题,但是如果问题足够开放并且复杂,例如一个复杂bug的定位,就需要探索多条路径。重规划可以一定程度的解决这个问题,还有另外一个思路可以解决这个问题,即仍然基于ReAct的整体启发式框架,但是每一步Reasoning时,不再只推理一个下一步的计划,而是同时推理出几种可能的下一步,然后各个下一步并行执行拿到结果;评估这几种可能路径(历史路径+这几个不同的下一步生成了几条不同的最新版可能路径)的得分,选择一个最优的,作为下一步推理的基础,如此迭代。这就是LLM Agent Tree Search。
LATS的关键问题有以下几个:
-
各个并行执行的步骤的上下文trace,需要独立,而不能混入主上下文;
-
如何评估各个路径的结果,考虑问题的动态性,一般让LLM从进度增益(当前路径是否离任务目标更近)、动作有效性(工具调用是否贴合需求,无无效冗余操作)、逻辑一致性(前后步骤不自相矛盾)、死胡同风险(是否明显走向误解、重复循环、错误前提)四个角度打分,但是应当尽量前置一些LLM无关的硬性规则,例如是否陷入死循环、工具调用是否返回了错误等,以尽量降低LLM不确定性的影响。
09 CodeAct:充分利用代码的确定性能力
因为底层本质上仍然是概率模型,所以到今天为止,大模型仍然非常不擅长精确度要求高的计算类任务,例如基于用户的输入数字进行统计分析,基于用户的输入进行趋势图绘制等,而这种任务,恰恰是代码所擅长解决的,同时因为有编译等能力的存在,校验代码的准确性比校验计算结果的准确性来得容易,由此,CodeAct的思路为:接受到任务之后,大模型输出代码形式的解决方案,然后执行代码获得结果。
CodeAct的关键点包括:
-
如何验证代码的准确性:代码本身的语法准确性可以通过编译或执行报错来判断;比较麻烦的是判断代码是否可正确解决用户的任务,即是否存在逻辑错误,这个大概的方式有两种,事前增加基于LLM的代码审查环节(又是类似于Reflection的思路),多次生成代码多次执行相互验证。
-
代码执行必然要有沙箱。
-
LLM并不会编写业务相关的代码,因此CodeAct比较适用于实用编程语言通用库即可解决的计算类问题
10 总结
这么一路串下来,一方面可以得到以下选型建议:
| 任务特性 | 选型建议 |
|---|---|
| 较简单的动态任务 | ReAct,ReAct不行考虑增加Reflection |
| 存在明确的步骤 | 工作流WorkFlow,如果某一步需要一定的动态性,可将其设置为一个ReAct的智能体或者引入LLM的推理能力 |
| 较复杂的动态任务 | 07中的融合Plan-And-Execute、ReWOO、LLMCompiler(按需)、ReAct的混合模式 |
| 具有强不确定性需要动态探索的任务 | LATS |
| 精度要求高的计算类任务,且使用通用库即可解决 | CodeAct |
更重要的,所有的架构模式,其实主要都在考虑如何规避大模型智能的不足,大模型的不足来自于其底层架构,包括:
- 基于Next-Token的预训练机制:由此倾向于自圆其说,并且越强的模型自圆其说的能力越强,缺乏反思能力和机制;
- 概率模型:由此在精度要求高的计算任务上表现差;这也是为什么比较有创意的任务要设置较高的温度参数,温度参数本质上就是在控制如何进行概率选择。
- 扁平化token序列的状态表示:由此引起lost-in-middle、注意力倾斜等问题,大模型的状态本身是没有任何结构化的,完全依赖注意力机制提取。
一方面,能够控制大模型推理能力的只有提示词Prompt,所以Plan-And-Execute是让大模型基于干净的上下文先规划,ReWOO只把有必要的Observation给到大模型,Reflection强制加入反思步骤,让大模型反思时需要给明确的输出格式要求。
另一方面,把大模型不擅长的部分抽离出来,例如LLMCompiler的并行调度,CodeAct的使用代码执行计算,Refection中的反思时机判断的确定性规则的引入。
上面的各个模式,虽然顶着单Agent的架构,但是部分模式其实已经有分工了,例如Planner-And-Exeucte中的Planner和Worker的分工,将分工提高高度,自然导向了多Agent架构。这是下一篇的主题,敬请期待。