还在纠结要不要给项目引入 Agent 的时候,我第一个跳出来的念头就是:别给项目加一堆东西,先把你头脑里的推理过程翻译成一个循环。ReAct 这个名字一旦出现,网上搜出来的全是框架、库、Agent 中间件,搞得人以为这是什么重量级方案。实际上你要是把 ReAct 的论文扒开看,它做的事情就一句话:让模型在"思考"和"行动"之间反复横跳,直到得出答案。翻译成代码,不过是一个 while 循环加几个状态变量。我写这篇文章,就是想把这个推理模式拆到你能亲手写出来,不依赖任何 Agent 框架,不引入额外的抽象层,只需要一个循环、一次 LLM 调用、和一个能执行动作的函数。
我先说结论:ReAct 的价值根本不在工程实现,而在它强迫你回答两个问题——"模型应该看到什么"和"模型下一步能做什么"。想明白了,代码反而简单到让人怀疑是不是漏了东西。这篇文章会从 ReAct 的推理模式拆解开始,给出一个完全可运行的最小实现,再逐步加上 LLM 接入、解析容错、终止条件这些实操细节,最后把我自己踩过的坑列成一张排查表。适合那种不想被框架绑架、想真正理解 Agent 内部机制的开发者,也适合第一次接触 Agent 的初学者照着抄作业。
1. ReAct 不是框架,是"推理循环"的重命名
好多人对 ReAct 的第一印象是被 Agent 框架给带偏的。市面上太多实现了 ReAct 模式的库,装个包、配个 key、跑一个 demo,任务完成了,但脑子里留下的印象是"ReAct 很复杂"。其实 ReAct 甚至算不上一个新概念。它的全称是 Reasoning + Acting,出自 2022 年那篇《ReAct: Synergizing Reasoning and Acting in Language Models》,核心思想是在提示词里把模型的推理过程拆成 Thought、Action、Observation 三步,让模型交替输出思考内容、调用外部工具的动作、以及基于执行结果的新观察。这三步不断重复,直到模型输出最终答案。
你把它和普通 LLM 对话对比一下就明白了。普通对话里,你问模型一个问题,模型直接给答案,遇到没法从内部知识回答的问题就瞎编。ReAct 多了一个"行动"环节:模型可以说"我拿不准,我要查一下"或者"我要算一下",然后系统真的去执行这个动作,把结果喂回给它。模型再基于这个新信息继续推理。这不就是一个人处理陌生问题的流程吗?先想,再做,再看结果,再想。放到程序里,这就是循环结构。
为什么我用"重命名"这个词?因为循环这个概念早就存在,ReAct 只是把循环的每一步应该做什么规范化了。它定义了一种"模型与外部环境交互的模式",而不是定义了一套你要安装的软件。搜 ReAct 的时候出来的 react 框架、react native 教程、react 面试题,那是另一个生态圈的东西。真正的 ReAct 模式既不需要包管理器,也不需要组件树,你要做的只是写一个循环,然后设计好每一步的提示词。
1.1 为什么"框架感"会误导你
框架给我们的暗示是:你引入一个东西,然后按照它的生命周期去写代码。但 ReAct 的核心逻辑简单到可以被任何程序语言的三五行伪代码概括。如果你抱着"我要用 ReAct 框架"的心态去搜索,大概率会找到一个安装命令、一个配置文件、一套事件机制,然后陷入配置地狱。这种路径不是不能实现功能,而是把你和真实原理隔开了。
我自己强烈建议先脱离框架去手写一遍。原因有三个:
第一,框架把循环、解析、调用这些细节都封装了,你出了问题根本不知道从哪里查。手写版本只有几个变量和几十行代码,逻辑链路全在眼皮底下。
第二,框架通常自带提示词模板,但真实业务里你可能需要定制模型输出格式、调整最大步数、改终止条件。如果你不理解底层循环,改这些全凭感觉。
第三,面试和团队沟通的时候,你能说清楚"我们的 Agent 本质是一个带终止条件的 while 循环",这比"我用了某个框架的某个 Agent 类"要有说服力得多。这里我不是否定框架的价值,等你想清楚每一步的原理之后,找一个工程化框架来省时间是完全合理的。但这篇文章的目的就是让你先想清楚。
1.2 用生活化的方式理解 ReAct 循环
如果你喊家里的小朋友帮忙找个东西,会发生这个过程:你说"去客厅找一下遥控器"——这是初始任务;小朋友走进客厅,看了一圈没找到——这是执行一个动作并得到观察结果;他想了想,"遥控器可能在沙发缝里"——这是基于观察的推理;他翻开沙发垫——新动作;发现了遥控器——新观察;于是回答你"找到了"——终止条件满足。
这个场景里的每一步都可以对应到 ReAct 循环:思考生成下一动作的计划,动作被系统执行,系统把执行结果作为观察返回,模型基于新的观察继续思考。整个流程重复执行,直到模型认为问题已经解决。你可以把 while 循环看成"小朋友未找到遥控器之前,他不能停下来"的规则。循环体和终止条件,就是 ReAct 的全部秘密。
2. 解剖 ReAct 推理模式:Thought、Action、Observation 三段式
ReAct 循环的每次迭代,模型要输出一段结构化文本,通常包含三个字段:Thought(当前推理)、Action(决定做什么)、Action Input(动作参数)。系统执行完动作后,把结果以 Observation 字段的形式追加到上下文里。这四样东西拼在一起,构成模型下一轮推理的输入。
有些人会把 Action 和 Action Input 合并称成一步,但本质上这三个字段代表三种不同的信息:Thought 是模型的思维链,Action 是动作选择,Observation 是环境的反馈。可以理解为:Thought 是决策的理由,Action 是决策的结果,Observation 是决策的后果。
2.1 手写伪代码,把模式变成你能控制的逻辑
在写真实代码之前,先用伪代码搭建骨架:
初始化上下文 = 系统提示 + 用户问题 最大步数 = 10 步数计数器 = 0 最终答案 = None while 最终答案是 None 且 步数计数器 < 最大步数: 调用模型,输入上下文,得到模型输出 从模型输出中解析出 Thought, Action, Action Input 如果 Action 是 "Finish": 最终答案 = Action Input 跳出循环 否则: 执行 Action + Action Input,得到 Observation 把这一轮的所有输出追加到上下文 步数计数器 += 1 如果最终答案是 None: 返回"达到最大步数,未得到答案" 否则: 返回最终答案这就是 ReAct 的完整逻辑骨架,和是不是用了框架完全没有关系。你可以把这个骨架翻译成任何语言,甚至翻译成手写的状态机。循环条件有两个:一个是模型主动说完成,一个是步数上限兜底。这两个条件的顺序也值得注意:每次迭代都要先检查模型是否给出了最终答案,而步数上限是在每次循环之后判断的,确保至少能执行一次动作。
2.2 为什么终止条件这么关键
如果把 ReAct 循环比作一个员工,终止条件就是明确的"下班标准"。没有这个标准的循环会出两类问题:一类是模型反复执行相同动作,进入死循环;另一类是模型胡乱猜答案,在错误的观察上越走越远。
我建议把最大步数的默认值设成 8 到 10 步。设太短,多步骤工具调用没跑完就被截断;设太长,如果模型行为异常,你要付出好几倍的 token 成本和时间成本。实际项目中可以做成可配置项,并且每次循环记录一下已消耗的 token 数,一旦超预算就终止。这比只有步数限制更符合真实场景,因为有些动作返回的文本特别长,步数没超但 token 已经吃了很多。
Finch 动作是另一个关键。ReAct 提示词里必须告诉模型:当你认为问题已经解决时,输出 Action: Finish,并且把最终答案放在 Action Input 里。有些实现会输出 Final Answer 字段,本质上是一样的。你把"什么时候算完成"这个判定权交给模型,同时用步数上限做兜底,这就是工程上的双保险。
2.3 动作空间设计,ReAct 模式的核心决策
ReAct 循环里可以执行的动作,是你在系统提示词里预先声明的。比如你有 search 和 calculate 两个工具,你就要在提示词里写清楚每个工具的用途和参数格式。动作空间设计的好坏,直接决定了模型能不能高效解决问题。
我见过两个极端:一个动作空间全塞满,给了模型十几个工具,它每次都在做选择题;另一个动作空间狭窄到只有一个搜索工具,模型遇到计算问题也去搜,得出错误答案。好的设计是给模型提供最小但足够的工具集。工具太少,模型无法完成任务;工具太多,模型的选择成本上升,出错概率也上升。我的经验是一个 Agent 任务的工具数量控制在三到五个之间,每个工具的参数尽量简单,最好都支持 JSON 格式的参数描述。
工具描述文本也直接影响执行质量。描述应该包含"什么时候用"的场景说明,而不只是"做什么"。比如写 search 工具的描述时,不要只写"搜索互联网",要写"当需要查询实时信息、验证事实、或查找不熟悉的内容时,使用此工具,输入为搜索关键词"。这样模型在思考阶段更容易做出准确的工具选择。这一点很多人忽略,但它对 ReAct 效果的影响比模型选择的差别还要大。
3. 手写核心循环:一份能跑的 Python 实现
进入实操环节。我们用 Python 写一个不依赖任何 Agent 框架的 ReAct 循环。这段代码的目标不是完成一个炫酷的项目,而是把一个最小可运行的 ReAct 机制摆在你面前,每个变量都能看明白。
3.1 环境准备与项目文件结构
只需要 Python 3.9 以上环境,不需要安装任何框架库。如果你要接 OpenAI 兼容的 LLM 接口,需要安装 openai 或 requests,任选其一。我建议先用一个假的 LLM 来调试循环本身,因为这样能隔离问题:循环逻辑的 bug 和 LLM 接口的 bug 分开排查。
目录结构尽量简单:
react_loop/ |-- main.py # 主程序 |-- tools.py # 工具定义 |-- llm_client.py # LLM 调用封装先把工具函数写好。假设我们有两个工具:一个计算加法,一个获取"当前时间"。computed 的场景虽然简单,但已经足够演示 ReAct 的动作调用和观察返回。
3.2 核心 while 循环代码详解
main.py 的核心部分如下:
import json from tools import add_numbers, get_current_time from llm_client import call_llm SYSTEM_PROMPT = """ 你是一个能调用工具的智能体。你有以下工具可用: - add_numbers: 输入两个数字,返回它们的和。参数格式: {"a": 数字, "b": 数字} - get_current_time: 返回当前时间。参数格式: {} 你的输出必须严格按照以下格式: Thought: 你的推理过程 Action: 工具名称 或 Finish Action Input: 工具的 JSON 参数 或 最终答案 """.strip() def execute_action(action: str, action_input: str) -> str: if action == "add_numbers": args = json.loads(action_input) return str(add_numbers(args["a"], args["b"])) elif action == "get_current_time": return get_current_time() elif action == "Finish": return action_input else: raise ValueError(f"未知动作: {action}") def react_loop(question: str, max_steps: int = 10): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": question}, ] context = messages for step in range(1, max_steps + 1): model_output = call_llm(context) print(f"\n--- 第 {step} 轮 ---") print(model_output) parsed = parse_output(model_output) if parsed is None: print("解析失败,终止循环") return "ERROR: 模型输出格式解析失败" thought, action, action_input = parsed if action == "Finish": return action_input try: observation = execute_action(action, action_input) except Exception as e: observation = f"工具执行失败: {str(e)}" context = context + [ {"role": "assistant", "content": model_output}, {"role": "user", "content": f"Observation: {observation}"}, ] return "ERROR: 达到最大步数仍未得出最终答案" if __name__ == "__main__": print(react_loop("现在是几点?"))这里有几处需要认真理解:第一,循环用的是for step in range(1, max_steps + 1),这样就天然有了步数上限,同时 step 变量可以直接用于打印日志。它本质上是 while 循环的一种确定次数变体,但效果一样。第二,assistant 输出一整段被放回上下文,随后紧接着一个伪造的 user 消息,内容为 Observation。为什么要伪装成 user 角色?因为大部分模型不会自己输出 Observation,Observation 是环境产生的,不属于模型,所以我们要把它作为外部输入塞回去。这种角色安排在很多框架里被简写成"Observation 前缀消息",但我更倾向于明确使用 user 角色,兼容性更好。
3.3 输出解析函数:把模型的自由文本变成可执行指令
模型输出的文本需要被解析成结构化的 Thought、Action、Action Input。这个解析函数是整个循环能不能稳定的关键:
def parse_output(text: str): lines = text.strip().split("\n") thought = None action = None action_input = None for line in lines: if line.startswith("Thought:"): thought = line[len("Thought:"):].strip() elif line.startswith("Action:"): action = line[len("Action:"):].strip() elif line.startswith("Action Input:"): action_input = line[len("Action Input:"):].strip() if action is None: return None return thought, action, action_input这个解析方式比较天真,只适用于模型严格按说明输出的情况。实际项目中你会遇到模型不老实,比如把 Action Input 写成了多行、或者 Action 行写成了Action: add_numbers 参数是...这种多余描述。我们在第五节再谈怎么增强鲁棒性。现在用这个单纯版本,因为它的逻辑清晰,方便你调试。
3.4 手动模拟一轮 LLM 输出,打通整个流程
让我手动模拟一次调用,帮助你理解每次循环的数据流。假设用户问题是"请问 23 加 19 等于多少?",模型第一次的输出可能是:
Thought: 用户需要计算两个数字的和,我需要使用 add_numbers 工具。 Action: add_numbers Action Input: {"a": 23, "b": 19}解析后执行 execute_action,得到 observation 为 "42"。然后我们把这个模型输出和 observation 一起追加到上下文。回到循环顶部,模型现在看到的历史记录包括它上一轮的 Thought 和 Action,以及系统返回的 Observation,于是它会在下一轮输出:
Thought: 我已经得到了计算结果,可以回答用户了。 Action: Finish Action Input: 23 加 19 等于 42循环检测到 Finish,返回最终答案。这个过程你可以在 main.py 里打印每一步的上下文,确认数据结构变化。这是最直接的调试手段。
4. 接入真实 LLM:让循环真正工作起来
上面的循环是骨架,现在我们要把它接到真实的 LLM 接口上。llm_client.py 负责完成这一层封装。我以 OpenAI 兼容接口为例,因为目前绝大多数开源模型、国内大模型服务都提供这个协议的接口,迁移成本很低。
4.1 LLM 调用封装与上下文管理
from openai import OpenAI client = OpenAI( base_url="你的模型服务地址", api_key="你的密钥" ) def call_llm(messages: list, max_tokens: int = 500, temperature: float = 0): response = client.chat.completions.create( model="你的模型名", messages=messages, max_tokens=max_tokens, temperature=temperature, ) return response.choices[0].message.contenttemperature 设为 0 或者非常低,这一点极其重要。ReAct 循环里,我们希望模型输出的动作能够稳定复现,高温度会让模型在 Action 选择上飘忽不定,同一个问题可能得出完全不同的工具调用序列。我自己通常设成 0,只有在做头脑风暴类的 Agent 时才调到 0.3 以上。
max_tokens 也要注意。模型一次推理输出的文本通常不会太长,但 Action Input 如果是长文本(比如搜索术语),几百个 token 也有必要。500 是一个比较安全的默认值,可以避免因为输出截断导致 Action 不完整的问题。如果发现模型经常在中间截断,先检查 max_tokens 而不是换模型。
4.2 完整流程:从问题到答案的完整循环日志
真实运行一次,日志通常会长成这个样子:
--- 第 1 轮 --- Thought: 用户想知道当前时间,我应该用 get_current_time 工具获取。 Action: get_current_time Action Input: {} --- 第 2 轮 --- Thought: 我已经获得了当前时间,可以直接回答。 Action: Finish Action Input: 当前时间是 2024-06-15 14:30:00。如果第一个工具没有解决,还会继续。比如用户问一个需要两步的问题:
--- 第 1 轮 --- Thought: 我需要先获取商品价格,再计算总价。 Action: get_product_price Action Input: {"product": "apple"} --- 第 2 轮 --- Thought: 苹果单价是 5 元,用户买了 3 个,需要计算总价。 Action: multiply Action Input: {"a": 5, "b": 3} --- 第 3 轮 --- Thought: 总价是 15 元,回答用户。 Action: Finish Action Input: 总价是 15 元这种多步调用体现了 ReAct 的价值:第一步获取数据,第二步处理数据,第三步总结。每一步的观察都被保存在上下文里,模型可以在后续推理中引用。
4.3 上下文膨胀问题:循环的隐形成本
ReAct 循环有个不太好直接看见的问题:每一轮都会把模型的整段输出和观察追加到上下文,上下文长度会线性增长。如果工具返回的观察很长(比如搜索引擎返回一大段网页摘要),几轮之后就可能超出模型的上下文窗口。
处理这个问题的常规思路有三种。第一种是截断 Observation,只保留前 N 个字符。第二种是让工具本身返回摘要,比如搜索工具不要把全文都返回,而是返回标题加摘要。第三种是定期压缩历史消息,把前面的 Thought 和 Action 合并成一句话。我建议优先用第二种思路,因为它从源头减少数据量,效果最明显。比如一个网络搜索工具,完整网页内容可能是几万字,但你只需要返回给模型一个两三百字的摘要。这个摘要可以由工具内部完成,也可以在把结果塞给大模型之前做一次 LLM 摘要。
我踩过一次很深的坑:当时直接让搜索工具返回整个网页的正文,第三轮循环之后,输入长度就爆了,报错提示超出上下文限制。后来加了一层摘要模块,把网页正文压缩到 300 字以内,问题立竿见影。节省的不只是上下文空间,还有 token 成本,每一轮循环都在为你的上下文长度买单。
4.4 超时与错误处理机制
真实场景里,工具调用可能失败,LLM 接口可能超时。如果不处理,整个循环就会卡死或崩溃。我在 execute_action 外面包了一层 try-except,把异常信息转换为 Observation 文字:"工具执行失败: 网络错误,请重试"。这比让循环崩掉要好得多,因为模型看到了错误信息,可能会选择换一个工具或换一种参数重试。这种把错误作为观察反馈给模型的做法,是 ReAct 容错的核心技巧。
LLM 接口超时需要单独处理。我的做法是在 call_llm 里设置客户端超时时间,比如 30 秒。如果超时就抛出一个特定异常,在循环内捕获并重试一次,重试仍失败则提前返回错误信息。这里要注意的是,不要把超时报错直接当作 Observation 喂给模型,那样模型可能长期陷入"重试同一动作"的死胡同。要给它一个明确指令:超时后停止循环,告诉用户系统暂时不可用。
5. 常见问题与排查技巧实录
手写 ReAct 循环的过程中,你一定会遇到下面这些问题。我把它们整理成一张速查表,每个都附上我的排查思路和解决建议。
5.1 模型输出格式不稳定,Action 解析不出来
这是最高频的问题。模型没有按你要求的格式输出,可能把 Action Input 写成多行,或者在 Action 后面加了括号说明。我的经验是做一个多级解析策略。首先尝试按行前缀解析,解析失败就尝试正则表达式,再失败就把整个文本当作 Thought 并把无法识别的部分提示给模型,要求它重新输出标准格式。如果连续三次解析失败,不要无限重试,直接终止循环。
关键点在于提示词本身要写得非常严格:
你的输出必须严格遵循以下格式,不要输出任何额外内容: Thought: <你的推理> Action: <工具名或 Finish> Action Input: <JSON 格式参数或最终答案>我在提示词里会再加一句"Action Input 必须是合法 JSON,不得包含换行"。这句话能显著减少多行 JSON 带来的解析问题。
5.2 循环进入了死循环,模型反复执行同一个动作
模型可能因为观察结果不符合预期,一遍遍尝试同一个工具。我见过最离谱的情况是模型连续六次调用同一个搜索动作,参数一模一样,每次都返回同样的结果,然后它又接着搜索。排查思路有两个:第一,检查观察返回是否太冗长,模型可能没有注意到它已经看过这个结果了;第二,在系统提示词里明确告诉模型"如果观察结果与之前某一步完全相同,说明该路径无效,必须更换策略"。
代码层面也可以加一个动作去重机制:维护一个已经执行过的动作哈希集合,如果当前动作重复出现且参数一致,就自动返回一个提示观察,让模型换个方向。这算是工程上对模型推理能力不足的一个补丁,实践证明很有效。
5.3 Action Input 是"非法 JSON"占比很高
模型可能输出{"a": "23"}而不是{"a": 23},字符串和数字混淆;也可能输出带注释的 JSON,或者用单引号代替双引号。不要把 JSON 解析失败直接抛给用户。我的做法是写一个宽容解析函数,先尝试 json.loads,失败后用正则提取数字和键名,再拼回合法的 JSON。另一个更省心的方案是把参数格式设计成字符串:比如 add_numbers 的 Action Input 直接是 "23 19",工具内部再用 split 切分。牺牲一点结构换取稳定性,在动作很简单的时候是划算的。
5.4 模型提前 Finish,答案明显不对
这是最难排查的问题之一,因为循环本身没有报错,但你一看结果就知道不对。可能的原因包括:提示词里的例子引导了模型,让它误以为问题简单到可以直接回答;或者上下文里的历史轨迹让模型产生了幻觉。我建议在系统提示词里加一条硬性规则:"除非你已经执行了至少一个工具调用并且获得了观察结果,否则不得输出 Finish。"这样能强制模型至少尝试一轮工具调用,避免它纯靠内部知识作答。
如果已经完成了工具调用但答案还是错,那大概率是 Observation 被截断,模型没看到完整信息,或者工具本身返回的数据有问题。这时候需要在日志里检查工具返回的原始内容,确认数据源是否存在缺陷。很多情况下问题不在 ReAct 循环,而在工具的执行质量。
5.5 几个让你少走弯路的调试手段
把每一轮的完整消息数组打印出来,存成 JSON 文件。这样随时可以复盘模型到底看到了什么。像这样在循环内print(json.dumps(context, ensure_ascii=False, indent=2)),你会发现很多诡异行为都能归因于上下文里的某条历史消息。
做一个无外部依赖的"假工具"调试环境,也就是我在第三节代码里演示的两个简单工具。先用这些假工具把循环调顺,再换成真实业务工具。环境越简单,定位问题越快。
最后,所有工具调用的入参、出参、耗时都要打日志。Agent 循环是黑盒属性很强的东西,没有日志你基本就是盲人摸象。我认为这一条比任何框架特性都重要。
6. 从 ReAct 循环走出来的下一步:你已具备手写 Agent 的基础
把 ReAct 循环手写一遍之后,你对 Agent 的理解会和只听说"Agent = 大模型 + 工具调用"的人完全不同。你知道了每一步的数据流长什么样,知道了模型输出如何被解析为动作,知道了观察如何回灌上下文,也知道了终止条件为什么需要双保险。这些认知无法通过安装框架获得,只能靠你一行行写出来、一遍遍调试出来。
我个人还会在这个基础上再加两层东西。第一层是记忆机制:把经过压缩的历史摘要放入上下文,让循环可以处理更长的问题。第二层是规划机制:在进入 ReAct 循环之前,先让模型生成一个整体计划,然后循环按照计划执行。这两层都属于 ReAct 模式的外延,核心骨架仍然是那个 while 循环。
最后再分享一个我在项目里的实际用法:不要全天候把所有请求都放进 ReAct 循环。可以先让普通大模型直接回答,通过一个置信度判断,只有低置信度时再进入 ReAct 模式。这样能大幅降低 token 成本和延迟。这个小技巧帮我省下了很多不必要的工具调用成本,也希望你在自己写 ReAct 循环时,能感受到这种掌控一切的踏实感。从你写出第一个能运行的 while 循环那一刻起,Agent 对你就再也没有黑魔法了。