使用 Rust 开发 AI Agent:ReAct 模式原理与组件设计
ReAct 把“推理”和“行动”结合成“思考—行动—观察”的自适应循环,让 Agent 依据当前已知信息不断调整策略,直到解决复杂问题;现代 tool calling 把推理内化到工具选择中,但 ReAct 的核心运行模式没有改变。
核心要点
- 前几期已经完成的基础:调用大语言模型(LLM)、让模型调用外部工具、包括 MCP,以及实现一个简单的 agent 循环。
- 接下来要向真正的 agent 靠拢:面对复杂问题时,agent 必须能自己反复思考、行动、观察,直到把问题解决。
- 本讲聚焦两部分:
- ReAct 的工作原理:它为什么好用,以及和早期实现有什么区别。
- 一个完整 agent 需要哪些组件,以及这些组件如何配合。
- 本讲不写代码,先把设计概念想清楚;下一集再动手写代码。
背景:从简单 Agent 循环到真正 Agent
前面已经搞定了“能调用模型”和“能让模型调用工具”这几件事,也实现了一个简单的 agent 循环。但这还不够,因为真正的 agent 需要处理复杂问题。复杂问题往往不能靠模型一次性回答,也不能靠固定脚本穷举所有步骤。
解决这类问题的自然方式,是不断循环:
- 先思考当前需要什么;
- 再采取行动去获取它;
- 然后观察行动结果;
- 有时还要再想一步、再查一次;
- 直到信息足够,才给出答案。
这个“思考—行动—观察”不断循环、直到能给出答案的过程,就是 ReAct 模式。
ReAct 的含义与定位
ReAct 是 reasoning 加 acting,即推理加行动。
它不是某个具体框架,而是一种设计 agent、设计智能体的思路。它模仿的是人类解决问题的自然方式:不会一上来就脱口说出答案,而是先拆解问题、确认缺少哪些信息、查找信息、计算或验证,最后才回答。
核心判断:
- 大语言模型负责“思考”的部分,也就是推理该做什么。
- 真正去执行、去获取外部信息的是工具。
- 两者配合起来,才能解决单靠大语言模型自己回答不了,或者单靠固定程序处理不了的问题。
示例:基普乔格跑到月球需要多久
原问题:如果马拉松世界纪录保持者基普乔格能一直保持他的配速跑下去,那么跑到月球需要多久?
如果由人类来回答,通常不会立刻说出一个数字,因为人并不知道答案。人类会先在脑子里拆解:
- 需要基普乔格跑马拉松的配速。
- 需要地球到月球的距离。
- 想清楚要查什么。
- 去搜索这两个数据。
- 拿到结果后做运算,例如除法,算出时间。
- 最后给出答案。
对应到 agent,就是先思考需要什么,再行动获取,最后观察结果;有时还要再想一步、再查一次,直到信息足够。
对应的 trace 执行轨迹
agent 真实运行的一步步日志,通常叫 trace,也就是执行轨迹。这个案例中的多轮循环可以整理为:
| 轮次 | thought 思考 | action 行动 | observation 观察 |
|---|---|---|---|
| 第一轮 | 需要基普乔格的配速 | 调用 search_web 工具,传入需要查询的词 | 查到:2 小时 1 分 9 秒跑完 42.195km |
| 第二轮 | 需要地球到月球的距离 | 调用 search_web 工具查询 | 原文只说返回了这个值,未给出具体数值 |
| 第三轮(原文说这一轮没有显示出来) | 现在可以计算时间了 | 调用计算器,做除法 | 得到计算结果,约 1 万 7000 小时 |
| 最后一轮 | 信息已经够了 | 不再调用任何工具 | 直接给出最后结果:大约 1 万 7000 小时 |
关键点:如果第一次搜索没有搜到有用信息,或者关键词不对,agent 完全可以在下一次思考时换一个说法重新搜索;也可以对前一次算出来的结果心里没底,然后回头再查一次进行验证。它不是照着一份写死的脚本机械执行,而是每一步都重新根据当前已知信息决定下一步该怎么做。这种能够随时调整的能力,就是 ReAct 模式最大的价值。
ReAct 的通用循环
把上面的 trace 抽象成通用图,就是:
- thought:思考、评估现状,决定下一步要做什么。
- action:调用一个工具,拿到所需信息。
- observation:观察工具返回的结果。
- 回到下一轮 thought。
- 这个循环一直重复,直到某一次 thought 判断信息已经足够。
- 此时不再走 action,而是直接给出 final answer,最终答案,整个流程结束。
这里再强调:
- 大语言模型负责思考,也就是推理该做什么。
- 工具负责真正执行,获取外部信息。
- 固定脚本只能按预定路线走,一旦遇到没有考虑过的情况,就会出错甚至崩溃。
- ReAct 循环里,每一步都基于当前已知信息重新判断,因此可以随时纠偏,甚至换个方向。
ReAct 为什么好用:适应性的三个体现
ReAct 好用的根本原因是适应性。具体体现在三个地方:
- 搜索失败不会卡死
如果某次搜索没有搜到有用信息,可能是关键词不够精确,也可能是搜索源里根本没有这个内容。agent 不会就此卡死,它可以在下一轮思考时换一个词再试一次,就像人搜不到东西会换个说法重新搜一样。
- 结果可疑可以回头验证
如果算出来的结果看起来不太对,或者需要确认,agent 可以主动回头再查一次资料来验证,而不是一条道走到黑。
- 没有预先写死的固定步骤表
传统脚本是先做 A,再做 B,再做 C。一旦中间遇到没设计过的情况,就会直接报错或者崩溃。ReAct agent 每完成一步都会重新看一下:当前已经知道了什么,还缺什么,然后再决定下一步该做什么。这种“边走边看”的模式,正是它能处理开放性、不确定性问题关键。
早期 ReAct 实现的问题
早期 ReAct 实现时遇到一个很现实的麻烦:怎么让大语言模型把“我要调用哪个工具、传什么参数”这件事表达出来。
早期做法很朴素:
- 在提示词里明确告诉模型要按某种格式输出。
- 例如这一行 action 要加上工具名,然后中括号里放各个参数;原文示例中只有一个参数。
- 程序这一端用正则表达式或字符串处理,把这段文本解析出工具名和参数。
问题就出在解析上。这里完全靠字符串匹配,只要模型输出时少打一个括号、多一个空格,或者把关键词写得完全不一样,解析就会直接失败,整个流程就会中断。
当时的开发者花了大量精力去写提示词,反复要求大语言模型一定要严格按格式来。但即便如此,输出格式依然经常出现错误,非常不稳定。这就是要解决的核心问题。
早期实现与 tool calling 的对比
现在所有主流模型都支持 tool calling,也就是工具调用。原文口播为“to calling”。两种做法可以对比为:
| 维度 | 早期 ReAct 做法 | 现代 tool calling ReAct |
|---|---|---|
| 提示词 | 写死格式要求 | 只需要告诉大模型:在需要的时候你可以使用工具 |
| 模型输出 | 一段自由文本,思考和 action 混在一起 | 直接生成结构化数据,明确写着调用哪个工具、参数是什么 |
| 程序处理 | 用正则表达式或其他方法解析工具名和参数 | 不需要自己再写解析逻辑 |
| 格式保证 | 靠模型遵守提示词,容易失败 | 结构由模型服务商的 API 本身保证格式正确 |
| 稳定性 | 少括号、多空格、关键词不同都可能导致解析失败、流程中断 | 不用再赌模型会不会按格式输出,它肯定会 |
| 推理可见性 | thought 以可读文字暴露出来 | 推理内化到工具选择过程里,不再以可读文字暴露 |
在 tool calling 模式下,模型在生成结构化调用之前,内部已经完成了和之前 thought 一样的推理判断。只不过这个思考过程不再以一段可读文字的形式暴露出来,而是直接体现在它选择调用哪个工具、传什么参数上面。
既然不再输出 thought,还能叫 ReAct 吗
答案是可以,而且原因值得讲清楚。
推理这件事并没有消失,它只是换了地方发生:
- 从原来模型输出一段我们能读到的文字;
- 变成在模型内部完成,直接体现在它选工具、填参数这个动作上。
现在主流的智能体框架,原文提到的包括 long graph、Google AD K、hugin face 的 small agent 等;这些名称来自原文口播,具体拼写未在原文中确认。即便它们底层用的都是这种原生工具调用能力,我们依然把这类智能体称为 ReAct agent。
原因是它们的核心运行模式完全没有改变:
- 评估当前情况;
- 决定要不要行动;
- 如果需要,就采取行动获取信息;
- 然后观察结果;
- 再重复这个过程,直到能给出答案。
这个所谓的自适应、一步步解决问题的思路,才是 ReAct 真正的精髓。跟它具体用文字还是用结构化数据来表达思考过程没有关系。
从使用接口倒推 Agent 组件
接下来进入第二部分:怎么把这套思路真正搭成一个可以复用的 agent。
这里不打算一上来就谈具体的类、具体的字段该怎么设计,而是先反过来想:这个 agent 造好之后,用起来应该是什么样子的?接口长什么样?调用一次它能帮我做什么?
想清楚结果长什么样之后,再一步一步倒推:要实现这样的效果,背后到底需要哪些组件,每个组件各自又负责什么。
这是一种很实用的设计思路:先定义好我要什么,再决定我需要造什么。
目标状态:造好的 Agent 用起来应该是什么样
创建 agent 时,需要给它三样东西:
- 用哪个模型。
- 能用哪些工具。
- 一段行为指令,也就是系统提示,告诉它大概扮演什么角色、怎么做事。
创建好之后,只需要把用户的问题丢给它,例如:
1234×5678 等于多少
然后它内部自己就会判断:
- 这个问题需不需要工具;
- 需要的话,用哪个工具,传什么参数;
- 拿到工具结果之后,还要不要再想一步;
- 直到它觉得可以给出答案了,就把最终答案返回给你。
这个接口表面上非常简单:三个输入,一个输出。但它背后藏着刚才讲的整个 ReAct 循环,包括:
- 用户输入怎么被记录下来;
- 要不要调用工具,这个决策是怎么做的;
- 工具执行完了,结果怎么喂回给大语言模型;
- 什么时候才算是完成了,可以停下来。
表面越简单,就说明背后要处理的信息流转越需要设计得很清楚。
Agent 运行时不只维护一份消息列表
可能有人会想:这个 agent 运行的时候,是不是维护一份消息列表就可以了?就像之前处理普通对话那样。
但仔细想一下,agent 运行实际发生的事情远远不止这些。它至少需要五类信息或状态:
- 完整历史记录
它不只是用户和模型之间的文字,还包括每一次工具调用的请求、工具返回的结果。这些全都得记录下来,方便回溯和调试。
- 步数计数器
原文口播为 current style,用来表示当前走到哪一步了。万一模型陷入某种循环,一直调用工具却拿不到满意的结果,必须有一个上限,避免它无限跑下去。这也是安全上必须考虑的一点。
- 唯一标识
可以叫 execution id,就是给这一次完整执行编个号。出问题的时候,方便定位是哪一次运行出的错。
- state
相当于一个临时的草稿本,用来临时存一些执行过程中产生的数据,比如任务进度、中间结果,或者某些工具之间需要共享的信息等等。
- final result
用来标记这一轮执行到底有没有完成。
如果把五样东西拆成五个相互独立的变量传来传去,每个函数的参数列表都会变得很长。而且如果以后想新加一个字段,就得把所有相关函数的函数签名都改一遍,非常容易出错,也非常难以维护。
所以更合理的做法是:把它们全装进一个统一的容器里,所有的方法只接收这一个容器作为参数。这个容器就叫 execution context,翻译过来就是执行上下文。
execution context 的四条关键信息流
把 execution context 放到整个 agent 里,可以看到四条关键信息流:
- 跟模型对话时,agent 从 execution context 里挑选、提取出这次调用真正需要的信息,把它整理成一份请求,即 LLM request。
注意这里是挑选,不是把所有历史一股脑儿都塞进去。这就是所谓的上下文工程,context engineering,原文口播为 contest engineering。它决定给模型看什么、不看什么。
- 这个请求要发给 LLM client,由它负责真正去调用模型的接口,拿回模型的回复,即 LLM response。
- response 拿到之后不是就地处理掉,而是要写回到 execution context 里,变成这一次执行历史的一部分,以供后续步骤使用。
- 如果模型回复里包含了调用某个工具的请求,agent 就会把 execution context 传给这个工具去执行。这样工具如果需要知道当前执行状态里的某些信息,也是可以拿到的。工具执行完以后,它的结果同样也要写回到 execution context 里。
这个设计会带来两个好处:
- 几乎所有内部方法的参数签名都被简化成只接收一个 execution context,不用再传一堆零散的参数。
- 因为 context 可以传递给工具,所以工具在执行的时候也能访问到当前执行过程中积累的状态,比如前面提到的 state 草稿本里的数据。
真正要搭的四个组件与顺序
把前面的东西整理一下,接下来真正要动手搭的主要是四个组件。顺序也有讲究:
- execution context
相当于一个中央存储,需要先把它搞定,因为后面的东西都依赖于它,都要接受它作为参数。
- Tool 的抽象
之前已经做了一定程度的抽象,但还不够。除了统一不同来源的工具接口之外,它还需要可选择地接收 context。
- 大语言模型 LLM 的通信层
这里又包含三个部分:
- LLM request 请求:负责从 context 里挑选要发送的内容。
- LLM client:负责真正发起调用。
- LLM response 响应:负责把不同模型服务商返回的结果统一成同一种格式。这样上层代码就不用关心具体在跟哪个厂商的接口打交道。
- agent 本身
它是整个系统的编排者,负责创建 execution context,并驱动前面讲的思考、行动循环,也就是 ReAct,一直跑下去,直到问题解决。
这个顺序背后的逻辑是:
- 先打地基;
- 再统一零件的接口;
- 再搭好和外部沟通的桥梁;
- 最后才是把所有东西串成一串,成为一个能跑起来的整体。
结论与下一讲
本讲主要讲了 ReAct 模式,并从“一个造好的 agent 用起来应该是什么样子”推导出它需要哪些组件:
- ReAct 的核心是思考、行动、观察的循环;
- 早期实现依赖提示词格式和字符串解析,非常不稳定;
- 现代 tool calling 用结构化数据保证格式正确,并把推理内化到工具选择中;
- 即便不再显式输出 thought,只要核心运行模式不变,它依然属于 ReAct agent;
- 一个可复用的 agent 需要 execution context、Tool 抽象、LLM 通信层和 agent 本体四个组件。
下一讲将实现代码。
限制与待确认问题
- 原文没有给出地球到月球距离的具体数值,因此无法还原完整除法和精确计算过程。
- 原文只给出最终结果“大约 1 万 7000 小时”,不是精确值。
- 原文说第二组循环结束后还有一轮没有显示出来,因此 trace 并非完整逐行展示。
- 原文提到的 long graph、Google AD K、hugin face 的 small agent 等框架名称来自口播,具体拼写未在原文中确认。
- current style、contest engineering、to calling 等术语来自原文口播或转写,具体拼写和标准写法需要后续确认。
- 本讲不写代码,只做概念和组件设计;具体 Rust 类、字段和方法签名留到下一集。