尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ReAct 循环 -- Agent 的心跳

ReAct 循环 -- Agent 的心跳 本文基于 AgentScope 2.0.4 源码示例项目为 myagentAgentScope FastAPI Postgres WebSocket。Reasoning-Acting 循环是 Agent 的心跳 – 像 for 循环之于编程像 request-response 之于 Web。在第一篇文章中我们展示了一次 Agent reply 的可视化Reasoning - Acting - Reasoning - 结束。但那是一个黑盒 – 你看到了 Agent 在做什么却不知道内部怎么控制这个流程。在第二篇文章中你学会了 asyncio – 理解了async for chunk in self._reply(inputsinputs)背后的异步机制。现在是时候打开_reply_impl()的黑盒了。这个函数是 AgentScope 最核心的代码 – 整个 ReAct 循环的逻辑都在这里。学什么理解 ReAct 循环的 4 步完整流程输入检查 - 消息处理 - 循环体 - 超限处理掌握 Reasoning 阶段LLM 流式调用 - 解析 tool_calls - 事件产出掌握 Acting 阶段批次调度sequential vs concurrent- 工具执行 - 结果事件理解循环的三个关键点进入条件、退出条件、迭代逻辑掌握被动中断CancelledError和主动暂停HITL两种中断模式前置依赖读过文章 1Agent 认知基础和文章 2asyncio 基础理解 async generator 和async for语法了解 Agent 类的基本结构name/model/toolkit/state一、ReAct 论文与思想Reason Act 的起源ReAct 不是 AgentScope 发明的概念。2022 年 Princeton 和 Google 的研究者发表了论文《ReAct: Synergizing Reasoning and Acting in Language Models》提出了一种让 LLM 交替进行推理Reasoning和行动Acting的范式。核心思想很简单不要让 LLM 只做思考或只做行动而是让它交替进行 – 先思考下一步该做什么然后执行行动看到行动结果后再思考下一步。Thought-Action-Observation 三元组ReAct 的基本单元是一个三元组Thought: 用户问天气我需要调用 get_weather 工具 Action: get_weather(city北京) Observation: 晴, 25°C Thought: 拿到天气数据了组织回复 Action: (生成最终回复无工具调用) Observation: 北京今天晴气温 25°C每一轮包含三个步骤Thought思考LLM 推理下一步该做什么Action行动执行工具调用或生成最终回复Observation观察获取行动结果加入上下文vs 传统 if-else 决策树传统 Web 开发中你写 if-else 决策树来编排流程# 传统编排: 你写流程控制defhandle_weather_query(query):cityextract_city(query)# 你决定先提取城市weathercall_weather_api(city)# 你决定调天气 APIreplyformat_reply(weather)# 你决定格式化回复returnreplyReAct 模式完全不同 – Agent 自己写这个 if-else# ReAct 模式: Agent 自己决定流程asyncforeventinagent.reply_stream(inputsmsg):# Agent 内部:# 轮 1 Thought: 需要调 get_weather# 轮 1 Action: get_weather(city北京)# 轮 1 Observation: 晴, 25°C# 轮 2 Thought: 拿到数据了生成回复# 轮 2 Action: (无工具调用 - 生成最终回复 - 循环结束)pass你的角色从流程编排者变成了约束配置者 – 你只配置max_iters最多循环几轮和toolkit能用哪些工具Agent 自己决定每一轮做什么。二、_reply_impl 4 步源码现在打开 AgentScope 源码。_reply_impl()是 Agent 的核心方法源码agentscope/agent/_agent.py:664整个 ReAct 循环的逻辑都在这个函数里。4 步流程全景┌─ _reply_impl() 4 步流程 ──────────────────────────────────────┐ │ │ │ Step 1: 检查输入类型 │ │ 输入是 Msg(新消息)? 还是 Event(HITL 续传)? │ │ │ │ Step 2: 处理输入 │ │ 新消息 - 发射 ReplyStartEvent, 初始化 reply_id/cur_iter │ │ HITL 续传 - 处理确认/中断事件 │ │ │ │ Step 3: ReAct 循环体 ← while cur_iter max_iters │ │ 3.1 检查是否该退出 (无工具调用 - 生成回复 - 退出) │ │ 3.2 Reasoning (调用 LLM, 产出事件) │ │ 3.3 Acting (执行工具, 产出事件) │ │ cur_iter 1 │ │ │ │ Step 4: 超限处理 │ │ ExceedMaxItersEvent ReplyEndEvent(EXCEED_MAX_ITERS) │ │ │ │ finally: 清理 ReplyEndEvent │ │ │ └──────────────────────────────────────────────────────────────┘Step 1: 检查输入类型# 源码: _agent.py:686-698ifisinstance(inputs,(UserConfirmResultEvent,UserInterruptEvent,ExternalExecutionResultEvent)):eventinputs# HITL 续传事件msgsNoneelse:eventNone# 没有续传事件msgsinputs# 新用户消息Agent 收到的输入有两种新消息Msg或续传事件UserConfirmResultEvent等。Step 1 做的就是区分这两种情况。这决定了后续走新回复路径还是续传路径。Step 2: 处理输入# 源码: _agent.py:719-739is_awaitingawaitself._check_incoming_event(event)ifis_awaiting:# HITL 续传: 处理确认/中断事件asyncforevtinself._handle_incoming_event(event):yieldevtelse:# 新消息: 初始化新回复awaitself._handle_incoming_messages(msgs)self.state.reply_id_generate_id()# 生成新 reply_idself.state.cur_iter0# 重置迭代计数器yieldReplyStartEvent(# 发射回复开始事件session_idself.state.session_id,reply_idself.state.reply_id,nameself.name,)新消息路径做三件事处理用户消息加入 context、生成新reply_id、重置cur_iter、发射ReplyStartEvent。这个事件告诉前端Agent 开始回复了。Step 3: ReAct 循环体这是全文的核心 –while循环# 源码: _agent.py:745-856whileself.state.cur_iterself.react_config.max_iters:# Step 3.1: 检查是否该退出action,dataself._check_next_action()ifactionexitandisinstance(data,Msg):yielddatareturn# 退出循环# Step 3.2: Reasoningifactionreasoning:asyncforevtinself._reasoning():ifisinstance(evt,Msg):# LLM 没产出 tool_calls - 最终回复 - 退出end_eventReplyEndEvent(finished_reasonReplyEndReason.COMPLETED)yieldevtreturnyieldevt# Step 3.3: Acting (批次执行工具)forbatchinawaitself._batch_tool_calls():# 执行 sequential 或 concurrent 批次asyncforevtinself._execute_sequential/concurrent_tool_calls(batch):yieldevtifisinstance(evt,RequireUserConfirmEvent):break_execution_for_hitlTrue# 迭代计数self.state.cur_iter1循环的三个关键点进入条件Step 2 发射ReplyStartEvent后直接进入循环。cur_iter从 0 开始。退出条件三种┌─ 退出条件 1: 正常完成 ─────────────────────────────┐ │ Reasoning 产出 Msg无 tool_calls │ │ - ReplyEndEvent(COMPLETED) │ │ - 最常见: LLM 认为可以直接回复了 │ └─────────────────────────────────────────────────────┘ ┌─ 退出条件 2: 超限 ──────────────────────────────────┐ │ cur_iter max_iters │ │ - ExceedMaxItersEvent │ │ - ReplyEndEvent(EXCEED_MAX_ITERS) │ │ - Agent 跑了太多次还没完成 │ └─────────────────────────────────────────────────────┘ ┌─ 退出条件 3: 中断 ──────────────────────────────────┐ │ CancelledError 或 HITL 中断 │ │ - ReplyEndEvent(INTERRUPTED) │ │ - 被外部取消或 Agent 主动暂停 │ └─────────────────────────────────────────────────────┘迭代逻辑每轮 Reasoning Acting 后cur_iter 1。下一轮检查cur_iter max_iters不满足则进入 Step 4。_check_next_action: “该做什么”# 源码: _agent.py:749action,dataself._check_next_action()这个方法检查 Agent 的 context决定当前应该做什么reasoning需要调 LLM 推理有未执行完的 tool_calls 就先执行没有就推理exit不需要再推理了可以返回最终消息附带Msg对象Step 4: 超限处理# 源码: _agent.py:861-886yieldExceedMaxItersEvent(reply_idself.state.reply_id,nameself.name,)end_eventReplyEndEvent(finished_reasonReplyEndReason.EXCEED_MAX_ITERS,)yieldAssistantMsg(contentExecuted maximum iterations of reasoning-acting loop without finishing the task.,)当 Agent 跑了max_iters轮还没完成时发射ExceedMaxItersEvent和一个 fallback 消息。这确保前端不会无限等待 – 循环一定会终止。finally: 清理与收尾# 源码: _agent.py:900-914finally:ifend_eventisnotNone:ifend_event.finished_reasonReplyEndReason.INTERRUPTED:# 关闭未完成的工具调用asyncfor_inself._close_unfinished_tool_calls():yield_# 发射 fallback 消息yieldAssistantMsg(contentself.react_config.interruption_message,)yieldend_event# 最终发射 ReplyEndEvent无论循环怎么退出finally块都会执行。如果是中断退出先关闭未完成的工具调用下一节详述再发射 fallback 消息和ReplyEndEvent。三、Reasoning 阶段_reasoning_impl()是 Reasoning 的核心源码agentscope/agent/_agent.py:973。它做三件事调 LLM、解析返回、产出事件。LLM 流式调用# 源码: _agent.py:996-1009yieldModelCallStartEvent(reply_idself.state.reply_id,model_nameself.model.model,)kwargsawaitself._prepare_model_input()# 准备 messages toolsresawaitself._call_model(tool_choicetool_choice,**kwargs)先发射ModelCallStartEvent告诉前端开始调 LLM 了然后准备输入对话历史 可用工具最后调用 LLM。解析流式返回# 源码: _agent.py:1020-1040ifinspect.isasyncgen(res):# 流式返回: 逐 chunk 处理asyncforchunkinres:ifchunk.is_last:completed_responsechunk# 最后一个 chunk 含完整响应else:asyncforevtinself._convert_chat_response_to_event(block_ids,chunk,):yieldevt# 转换为事件产出LLM 的返回是流式的 – 每个 chunk 可能是文本片段、思考片段或工具调用片段。_convert_chat_response_to_event()把每个 chunk 转换为对应的事件TextBlockDeltaEvent、ToolCallStartEvent等逐个yield给调用方。判断是否结束循环# 源码: _agent.py:1090-1113if(completed_response.finished_reason!FinishedReason.INTERRUPTEDandnotany(isinstance(_,ToolCallBlock)for_incompleted_response.content)):# LLM 没产出任何 tool_calls - 生成最终回复yieldAssistantMsg(idself.state.reply_id,nameself.name,contentlist(completed_response.content),)这是循环退出的关键判断如果 LLM 的完整响应里没有 ToolCallBlock说明 Agent 认为不需要再调工具了 – 可以直接生成最终回复。此时yield AssistantMsg回到_reply_impl的 Step 3.2被识别为Msg类型触发正常退出。如果有ToolCallBlock则不 yield Msg循环继续 – 进入 Acting 阶段执行这些工具调用。四、Acting 与批次调度_batch_tool_calls: 分组策略Agent 可能在一次 Reasoning 中产出多个工具调用。AgentScope 不是简单地逐个执行而是先分组源码agentscope/agent/_agent.py_batch_tool_calls# 源码: _batch_tool_calls()fortool_callintool_calls:toolawaitself.toolkit.get_tool(tool_call.name)iftoolisNoneortool.is_concurrency_safe:# 并发安全工具 - concurrent 批次batches.append(_ToolCallBatch(typeconcurrent,...))else:# 非并发安全工具 - sequential 批次batches.append(_ToolCallBatch(typesequential,...))分组规则基于工具的is_concurrency_safe属性┌─ 分组示例 ──────────────────────────────────────────┐ │ │ │ LLM 产出 3 个工具调用: │ │ get_weather(北京) -- 并发安全 │ │ get_weather(上海) -- 并发安全 │ │ write_file(result.txt) -- 非并发安全 │ │ │ │ 分组结果: │ │ Batch 1: concurrent [get_weather(北京), │ │ get_weather(上海)] │ │ - asyncio.gather() 并行执行 │ │ │ │ Batch 2: sequential [write_file(result.txt)] │ │ - 逐个执行虽然只有一个 │ │ │ │ 执行顺序: Batch 1 完成后 - Batch 2 │ │ │ └────────────────────────────────────────────────────┘与 JavaForkJoinPool的对比ForkJoinPool需要你手动fork()和join()AgentScope 自动根据工具属性分组 – 你只需要在定义工具时标记is_concurrency_safeTrue。_acting_impl: 工具执行# 源码: _agent.py:1870-1898asyncdef_acting_impl(self,tool_call:ToolCallBlock):Core tool execution logic.asyncforchunkinself.toolkit.call_tool(tool_call,self.state):yieldchunk实际的工具执行委托给toolkit.call_tool()。这个方法会检查权限PermissionEngine调用工具函数把返回值包装成ToolResponse/ToolChunk执行过程中产出的事件包括ToolResultStartEvent、ToolResultTextDeltaEvent、ToolResultEndEvent构成工具结果的完整生命周期。HITL 中断检测在 Acting 阶段执行工具时如果某个工具需要人类确认RequireUserConfirmEvent循环会立即中断# 源码: _agent.py:817-853asyncforevtinevt_generator:yieldevtifisinstance(evt,(RequireUserConfirmEvent,RequireExternalExecutionEvent)):break_execution_for_hitlTrue# 标记 HITL 中断ifbreak_execution_for_hitl:yieldAssistantMsg(contentWaiting for tool calls to be confirmed ...)return# 退出 _reply_impl等待外部续传Agent 不是崩溃退出而是主动暂停– 发射一条等待消息后return把控制权还给调用方。下一篇文章会详述这两种中断模式。五、中断与恢复被动中断CancelledError当外部调用task.cancel()时如用户关闭页面、服务关闭Agent 会在下一个await点收到CancelledError# 源码: _agent.py:888-898exceptasyncio.CancelledError:end_eventReplyEndEvent(finished_reasonReplyEndReason.INTERRUPTED,)ifself.react_config.interruption_raise_cancelled_error:raise# 重新抛出让取消传播# 否则吞掉进入 finally 清理interruption_raise_cancelled_error配置决定行为False默认吞掉 CancelledError执行清理后正常结束True重新抛出让上层调用者知道被取消了_close_unfinished_tool_calls: 清理未完成工具中断时可能有工具调用正在等待结果ASKING / SUBMITTED 状态。_close_unfinished_tool_calls()给它们补上已中断的结果源码agentscope/agent/_agent.py:605# 源码: _agent.py:634-662forindexinawaiting_tool_calls.values():# 更新状态为已完成last_msg.content[index].stateToolCallState.FINISHED# 发射完整的 tool_result 生命周期yieldToolResultStartEvent(...)yieldToolResultTextDeltaEvent(deltasystem-reminderThe tool call has been interrupted by the user./system-reminder)yieldToolResultEndEvent(stateToolResultState.INTERRUPTED,)# 补上 ToolResultBlock保持 context 完整last_msg.content.append(ToolResultBlock(outputinterruption_message,stateToolResultState.INTERRUPTED,),)为什么要补上因为 Agent 的 context 要求每条ToolCallBlock都有对应的ToolResultBlock。如果不补下次 Agent 加载这个 session 时会看到工具被调了但没结果导致上下文损坏。与 Thread.interrupt() 的对比维度asyncio.CancelledErrorThread.interrupt()触发方式task.cancel()thread.interrupt()接收时机下一个await点下一个安全检查点可捕获except CancelledErrorcatch InterruptedException清理机会finally 块finally 块默认行为吞掉可配置传播设置 interrupted flag六、HITL 中断HITLHuman-in-the-Loop与被动中断完全不同 – 它是 Agent主动暂停等待人类决策。触发场景用户: 帮我删除 /tmp/important.db 文件 Agent Reasoning: 需要调用 delete_file 工具 但这个操作有风险需要人类确认 Agent 产出: RequireUserConfirmEvent(tool_calls[delete_file(...)]) Agent 暂停: yield AssistantMsg(Waiting for confirmation...) Agent return: 退出 _reply_impl等人类响应 ┌─ 人类决策 ──────────────────────────┐ │ 确认: UserConfirmResultEvent(allow) │ │ 拒绝: UserConfirmResultEvent(deny) │ └─────────────────────────────────────┘ Agent 恢复: 收到 UserConfirmResultEvent - _reply_impl 重新被调用 - Step 1: 识别为续传事件 - Step 2: _handle_incoming_event 处理确认结果 - Step 3: 继续循环Case A vs Case BAgentScope 区分两种调用路径┌─ Case A: 新消息 ──────────────────────────────┐ │ 输入: Msg (用户发的新消息) │ │ 行为: 新建 reply_id, cur_iter0, 进循环 │ │ 场景: 正常对话 │ └────────────────────────────────────────────────┘ ┌─ Case B: 续传事件 ────────────────────────────┐ │ 输入: UserConfirmResultEvent │ │ 行为: 不新建 reply_id, 继续之前的 reply │ │ 场景: HITL 确认后恢复 │ └────────────────────────────────────────────────┘Case B 的关键在于_check_incoming_event()– 它检查 Agent 是否处于等待确认状态。如果是处理确认结果允许/拒绝然后继续之前的循环。如果不是没有等待中的工具调用抛出异常 – 因为不应该收到一个确认事件。RequireUserConfirmEvent vs RequireExternalExecutionEvent两种 HITL 事件事件谁处理场景RequireUserConfirmEvent人类用户工具权限确认允许/拒绝RequireExternalExecutionEvent外部系统工具需要外部执行如人工操作后回填结果两者都让 Agent 暂停但恢复方式不同 – 前者等用户点确认后者等外部系统回填结果。七、myagent 实战ReActConfig 配置myagent 的主 Agent 使用默认配置源码src/myagent/services/agent_config.py# myagent 主 Agent: 使用默认 ReActConfig# max_iters20 (默认), stop_on_rejectFalse (默认)# 来源: AgentScope Agent.__init__ 的默认值翻译子 Agent 使用更小的max_iters源码src/myagent/agents/translator.py# myagent translator: 翻译任务不需要多轮工具调用react_configReActConfig(max_iters5)配置选择策略┌─ max_iters 选择 ──────────────────────────────────┐ │ │ │ max_iters5: 简单任务翻译、摘要、格式转换 │ │ - 不需要多轮工具调用 │ │ - 5 轮足够 │ │ │ │ max_iters20: 通用任务默认值 │ │ - 可能需要多轮推理 工具调用 │ │ - 20 轮覆盖大部分场景 │ │ │ │ max_iters50: 复杂任务多步研究、代码生成 │ │ - 需要大量工具调用 │ │ - 但注意: 每轮都调 LLM, 成本和时间线性增长 │ │ │ └──────────────────────────────────────────────────┘max_iters是成本和能力的权衡 – 值越大Agent 能完成的任务越复杂但每轮都消耗 LLM token。对于不需要工具调用的任务如翻译5 轮足够对于需要多轮工具调用的任务如查天气 - 分析 - 生成报告20 轮更合适。八、常见陷阱陷阱 1: max_iters 过小症状Agent 任务没完成就结束了返回Executed maximum iterations。原因max_iters设得太小。翻译任务 5 轮够但查 3 个城市天气 对比 生成报告可能需要 8-10 轮。解决根据任务复杂度设置max_iters。如果不确定用默认值 20。陷阱 2: stop_on_reject 的语义症状工具被权限拒绝后Agent 停止推理等用户输入。原因stop_on_rejectTrue时工具被拒后 Agent 不继续推理而是等用户指示。stop_on_rejectFalse默认时Agent 会把拒绝结果加入 context继续推理可能换一种方式完成任务。选择需要严格控制的场景设True拒绝即停需要灵活的场景设False拒绝后换路走。陷阱 3: interruption_raise_cancelled_error 的传播症状Agent 被取消后上层调用者也收到了 CancelledError。原因interruption_raise_cancelled_errorTrue时CancelledError 会在清理后重新抛出传播给调用者。默认False时会被吞掉。选择如果你需要在上层知道 Agent 是否被取消如 ChatService 的 watchdog 监控设True。如果你希望 Agent 被取消后静默结束用默认False。总结ReAct 循环是 Agent 的心跳。while cur_iter max_iters这一行代码是整个 Agent 自主性的根源 – 它让 Agent 不再是调一次 API 返回一个结果的工具而是一个能自己决定下一步做什么的自主行动者。理解了_reply_impl()的 4 步流程你就理解了 Agent 的工作方式检查输入 - 初始化回复 - 推理-行动循环 - 清理收尾。每一轮循环里Agent 调 LLM 做推理有工具调用就执行工具没有就生成最终回复退出。三种退出条件保证了循环一定会终止正常完成无工具调用、超限max_iters、中断CancelledError 或 HITL。你不需要担心 Agent 无限循环 –max_iters是你的安全网。下一篇文章将深入工具系统 – Agent 怎么知道有哪些工具可用、工具怎么被调用、结果怎么返回。你已经理解了 ReAct 循环的骨架接下来是填充骨架的血肉。延伸思考如果max_iters设为无穷大Agent 会怎样什么场景下安全ReAct 的先推理再行动和 Tree of Thought 的先探索多条路径再选最优有什么区别如果一个工具调用耗时 60 秒ReAct 循环会怎么处理其他用户的请求会被阻塞吗
返回列表