ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写ReAct循环:不靠框架,用while循环搭建Agent推理核心

手写ReAct循环:不靠框架,用while循环搭建Agent推理核心 还在纠结要不要给项目引入 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: fObservation: {observation}}, ] return ERROR: 达到最大步数仍未得出最终答案 if __name__ __main__: print(react_loop(现在是几点))这里有几处需要认真理解第一循环用的是for step in range(1, max_steps 1)这样就天然有了步数上限同时 step 变量可以直接用于打印日志。它本质上是 while 循环的一种确定次数变体但效果一样。第二assistant 输出一整段被放回上下文随后紧接着一个伪造的 user 消息内容为 Observation。为什么要伪装成 user 角色因为大部分模型不会自己输出 ObservationObservation 是环境产生的不属于模型所以我们要把它作为外部输入塞回去。这种角色安排在很多框架里被简写成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你的模型名, messagesmessages, max_tokensmax_tokens, temperaturetemperature, ) 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_asciiFalse, indent2))你会发现很多诡异行为都能归因于上下文里的某条历史消息。做一个无外部依赖的假工具调试环境也就是我在第三节代码里演示的两个简单工具。先用这些假工具把循环调顺再换成真实业务工具。环境越简单定位问题越快。最后所有工具调用的入参、出参、耗时都要打日志。Agent 循环是黑盒属性很强的东西没有日志你基本就是盲人摸象。我认为这一条比任何框架特性都重要。6. 从 ReAct 循环走出来的下一步你已具备手写 Agent 的基础把 ReAct 循环手写一遍之后你对 Agent 的理解会和只听说Agent 大模型 工具调用的人完全不同。你知道了每一步的数据流长什么样知道了模型输出如何被解析为动作知道了观察如何回灌上下文也知道了终止条件为什么需要双保险。这些认知无法通过安装框架获得只能靠你一行行写出来、一遍遍调试出来。我个人还会在这个基础上再加两层东西。第一层是记忆机制把经过压缩的历史摘要放入上下文让循环可以处理更长的问题。第二层是规划机制在进入 ReAct 循环之前先让模型生成一个整体计划然后循环按照计划执行。这两层都属于 ReAct 模式的外延核心骨架仍然是那个 while 循环。最后再分享一个我在项目里的实际用法不要全天候把所有请求都放进 ReAct 循环。可以先让普通大模型直接回答通过一个置信度判断只有低置信度时再进入 ReAct 模式。这样能大幅降低 token 成本和延迟。这个小技巧帮我省下了很多不必要的工具调用成本也希望你在自己写 ReAct 循环时能感受到这种掌控一切的踏实感。从你写出第一个能运行的 while 循环那一刻起Agent 对你就再也没有黑魔法了。
返回列表