普通的大模型调用是一问一答,问完就结束了。但真实的 Agent 场景不是这样 -- 大模型需要自己判断:我现在信息够不够?不够的话该调用什么工具去补充?拿到结果之后怎么继续推理?
这种"思考 -> 行动 -> 再思考"的循环能力,就是 ReAct(Reasoning + Acting) 模式要解决的问题。
这篇文章从代码实现的角度拆解 ReAct 模块,主要回答四个问题:
1)ReAct 循环的本质结构和两个分支
2)上下文治理 -- 上下文越来越长怎么办
3)工具执行引擎的并发与串行模式
4)钩子机制 -- 如何在不改主流程的前提下扩展行为
关注“AI老马” —【获取资源】&【进群交流】
把所有概念剥掉之后,ReAct 的核心代码结构就是一个 for 循环。
每次循环做一件事:让大模型生成回复,然后检查回复里有没有工具调用请求。
如果有工具调用 -- 把参数提取出来,交给工具执行引擎,拿到结果后塞回上下文,然后 continue 进入下一轮循环。这就是"行动之后再思考"。
如果没有工具调用 -- 说明大模型认为已经可以给出最终答案了,直接输出结果,跳出循环。整个 ReAct 过程结束。
当然,不能让它无限循环下去。循环开始之前要设置一个最大迭代次数(比如 10 次或 20 次),到了上限强制终止,避免用户体验失控和 API 调用成本爆炸。
所以整个 ReAct 的骨架就这么点东西:
for i in range(max_iterations):
context = govern_context(context) # 上下文治理
response = llm.chat(context) # 大模型思考
if has_tool_call(response):
result = execute_tools(response) # 执行工具
context += result # 结果回填
continue # 继续循环
else:
return response # 输出最终答案两个分支,逻辑清晰。复杂的不在循环本身,而在循环内部的细节处理。
ReAct 循环每转一圈,上下文就会变长 -- 加入了大模型的推理结果、工具调用的请求、工具返回的结果。转个十圈八圈,上下文可能就超出了模型的窗口限制。
所以每轮循环的第一步,必须是上下文治理。这里有一套五步处理的标准化流程:
第一步:清理孤儿数据。 工具调用是成对出现的 -- 有请求就该有对应的结果。如果上下文里出现了只有结果没有请求、或只有请求没有结果的孤立数据,说明中间出了异常(比如工具执行超时)。这些孤儿数据一律清除,避免干扰后续推理。
第二步:压缩早期结果。 前几轮的工具调用结果对当前决策的价值已经不大了,但完全丢弃又怕丢失关键信息。折中方案是:保留最近 N 轮(比如最近 10 次)的原始结果,更早的结果压缩为一行摘要。既省空间,又不丢核心信息。
第三步:持久化关键数据。 将重要的中间结果(比如搜索到的文档、查询到的数据库记录)写入持久化存储。这样即使后续上下文被裁剪掉了,需要的时候还能找回来。相当于给大模型装了一个"外部记忆"。
第四步:结果格式化。 确保每个工具调用的输出都按照统一格式整理,方便大模型解析和使用。杂乱无章的工具返回只会增加理解成本。
第五步:历史裁剪。 当前四步做完之后,如果总长度仍然超过阈值,就从最早的消息开始裁剪,只保留系统提示词(System Prompt)和最近的消息。这是最后的保底措施。
这五步的目标只有一个:让上下文保持在模型窗口限制之内,同时尽可能保留有价值的信息。
大模型一次可能要求调用多个工具。这些工具之间有没有依赖关系?能不能并行执行?这决定了执行策略。
设计上支持两种模式:
串行模式:按顺序逐个执行工具,前一个的结果可以作为后一个的输入。适用于工具之间存在依赖关系的场景 -- 比如先用搜索工具找到文件路径,再用读取工具打开文件内容。
并发模式:将多个独立的工具调用打包成一个 Batch,同时发起执行,最后汇总所有结果一起返回。适用于工具之间互不相关的场景 -- 同时查天气、查股价、查日历,谁也不依赖谁。
具体用哪种模式,可以根据工具的属性配置来决定,不需要硬编码。执行引擎内部会自动判断和分发。
工具调用不可能次次成功。网络超时、API 报错、参数不合法 -- 异常情况随时可能发生。
错误恢复机制的核心原则是:局部失败不影响整体流程。
具体的策略包括:
这个设计体现了 ReAct 模式的一个重要理念:异常处理也是大模型推理的一部分。不需要在代码层面把所有异常路径都穷举出来,告诉大模型发生了什么,让它自己想办法。
ReAct 主循环的逻辑是固定的,但在实际使用中,不同场景有不同的附加需求:有的要在每轮迭代前打印日志,有的要实时推送流式结果给前端,有的要做调用埋点和监控。
如果在主流程里到处加 if-else,代码很快就会变成一团乱麻。
解决方案是钩子(Hook)模式。在 ReAct 执行类的关键节点预留出"挂钩点",用户可以通过继承 Hook 类并实现特定方法来注入自定义行为:
before_iteration)should_stream)on_stream)after_iteration)定义好 Hook 类之后,传给 ReAct 的数据包装类就行。主循环在执行到对应节点时,会自动检查并调用 Hook 中已实现的方法。主流程本身一行都不用改。
更进一步,如果需要同时生效多种 Hook(既要打日志又要推流式),可以用一个 CompositeHook 组合多个 Hook,按序依次执行。本质上又是一个"套壳"的设计 -- 一个壳子里装多个具体实现,对外还是一个 Hook。
回过头来看,ReAct 模块的实现并不神秘:一个 for 循环 + 两条分支 + 五步上下文治理 + 可插拔的 Hook 扩展。 真正值得关注的工程细节是上下文治理那部分 -- 怎么在有限的空间里保留最多的有效信息,这直接决定了 Agent 在多轮交互后的推理质量。
而对于想要自建 Agent 框架的同学来说,这套设计的可借鉴之处在于它的分层思路:主循环只管流转逻辑,上下文治理、工具执行、错误恢复、扩展钩子各自独立封装,互不耦合。加功能不用动主干,修 bug 不用牵连全身。