ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

ReAct 循环 -- Agent 的心跳

2026/8/9 5:17:55 拓冰建站 浏览量
ReAct 循环 -- Agent 的心跳

本文基于 AgentScope 2.0.4 源码,示例项目为 myagent(AgentScope + FastAPI + Postgres + WebSocket)。

Reasoning-Acting 循环是 Agent 的心跳 – 像 for 循环之于编程,像 request-response 之于 Web。

在第一篇文章中,我们展示了一次 Agent reply 的可视化:Reasoning -> Acting -> Reasoning -> 结束。但那是一个"黑盒" – 你看到了 Agent 在做什么,却不知道内部怎么控制这个流程。在第二篇文章中,你学会了 asyncio – 理解了async for chunk in self._reply(inputs=inputs)背后的异步机制。

现在,是时候打开_reply_impl()的黑盒了。这个函数是 AgentScope 最核心的代码 – 整个 ReAct 循环的逻辑都在这里。

学什么

  • 理解 ReAct 循环的 4 步完整流程(输入检查 -> 消息处理 -> 循环体 -> 超限处理)
  • 掌握 Reasoning 阶段:LLM 流式调用 -> 解析 tool_calls -> 事件产出
  • 掌握 Acting 阶段:批次调度(sequential vs concurrent)-> 工具执行 -> 结果事件
  • 理解循环的三个关键点:进入条件、退出条件、迭代逻辑
  • 掌握被动中断(CancelledError)和主动暂停(HITL)两种中断模式

前置依赖

  • 读过文章 1(Agent 认知基础)和文章 2(asyncio 基础)
  • 理解 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"

每一轮包含三个步骤:

  1. Thought(思考):LLM 推理下一步该做什么
  2. Action(行动):执行工具调用(或生成最终回复)
  3. Observation(观察):获取行动结果,加入上下文

vs 传统 if-else 决策树

传统 Web 开发中,你写 if-else 决策树来编排流程:

# 传统编排: 你写流程控制defhandle_weather_query(query):city=extract_city(query)# 你决定先提取城市weather=call_weather_api(city)# 你决定调天气 APIreply=format_reply(weather)# 你决定格式化回复returnreply

ReAct 模式完全不同 – Agent 自己写这个 if-else:

# ReAct 模式: Agent 自己决定流程asyncforeventinagent.reply_stream(inputs=msg):# 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)):event=inputs# HITL 续传事件msgs=Noneelse:event=None# 没有续传事件msgs=inputs# 新用户消息

Agent 收到的输入有两种:新消息(Msg)或续传事件(UserConfirmResultEvent等)。Step 1 做的就是区分这两种情况。这决定了后续走"新回复"路径还是"续传"路径。

Step 2: 处理输入

# 源码: _agent.py:719-739is_awaiting=awaitself._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_iter=0# 重置迭代计数器yieldReplyStartEvent(# 发射回复开始事件session_id=self.state.session_id,reply_id=self.state.reply_id,name=self.name,)

新消息路径做三件事:处理用户消息(加入 context)、生成新reply_id、重置cur_iter、发射ReplyStartEvent。这个事件告诉前端"Agent 开始回复了"。

Step 3: ReAct 循环体

这是全文的核心 –while循环:

# 源码: _agent.py:745-856whileself.state.cur_iter<self.react_config.max_iters:# Step 3.1: 检查是否该退出action,data=self._check_next_action()ifaction=="exit"andisinstance(data,Msg):yielddatareturn# 退出循环# Step 3.2: Reasoningifaction=="reasoning":asyncforevtinself._reasoning():ifisinstance(evt,Msg):# LLM 没产出 tool_calls -> 最终回复 -> 退出end_event=ReplyEndEvent(finished_reason=ReplyEndReason.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_hitl=True# 迭代计数self.state.cur_iter+=1
循环的三个关键点

进入条件: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,data=self._check_next_action()

这个方法检查 Agent 的 context,决定当前应该做什么:

  • "reasoning":需要调 LLM 推理(有未执行完的 tool_calls 就先执行,没有就推理)
  • "exit":不需要再推理了,可以返回最终消息(附带Msg对象)

Step 4: 超限处理

# 源码: _agent.py:861-886yieldExceedMaxItersEvent(reply_id=self.state.reply_id,name=self.name,)end_event=ReplyEndEvent(finished_reason=ReplyEndReason.EXCEED_MAX_ITERS,)yieldAssistantMsg(content="Executed 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_reason==ReplyEndReason.INTERRUPTED:# 关闭未完成的工具调用asyncfor_inself._close_unfinished_tool_calls():yield_# 发射 fallback 消息yieldAssistantMsg(content=self.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_id=self.state.reply_id,model_name=self.model.model,)kwargs=awaitself._prepare_model_input()# 准备 messages + toolsres=awaitself._call_model(tool_choice=tool_choice,**kwargs)

先发射ModelCallStartEvent(告诉前端"开始调 LLM 了"),然后准备输入(对话历史 + 可用工具),最后调用 LLM。

解析流式返回

# 源码: _agent.py:1020-1040ifinspect.isasyncgen(res):# 流式返回: 逐 chunk 处理asyncforchunkinres:ifchunk.is_last:completed_response=chunk# 最后一个 chunk 含完整响应else:asyncforevtinself._convert_chat_response_to_event(block_ids,chunk,):yieldevt# 转换为事件产出

LLM 的返回是流式的 – 每个 chunk 可能是文本片段、思考片段或工具调用片段。_convert_chat_response_to_event()把每个 chunk 转换为对应的事件(TextBlockDeltaEventToolCallStartEvent等),逐个yield给调用方。

判断是否结束循环

# 源码: _agent.py:1090-1113if(completed_response.finished_reason!=FinishedReason.INTERRUPTEDandnotany(isinstance(_,ToolCallBlock)for_incompleted_response.content)):# LLM 没产出任何 tool_calls -> 生成最终回复yieldAssistantMsg(id=self.state.reply_id,name=self.name,content=list(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:tool=awaitself.toolkit.get_tool(tool_call.name)iftoolisNoneortool.is_concurrency_safe:# 并发安全工具 -> concurrent 批次batches.append(_ToolCallBatch(type="concurrent",...))else:# 非并发安全工具 -> sequential 批次batches.append(_ToolCallBatch(type="sequential",...))

分组规则基于工具的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_safe=True

_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()。这个方法会:

  1. 检查权限(PermissionEngine)
  2. 调用工具函数
  3. 把返回值包装成ToolResponse/ToolChunk

执行过程中产出的事件包括ToolResultStartEventToolResultTextDeltaEventToolResultEndEvent,构成工具结果的完整生命周期。

HITL 中断检测

在 Acting 阶段执行工具时,如果某个工具需要人类确认(RequireUserConfirmEvent),循环会立即中断:

# 源码: _agent.py:817-853asyncforevtinevt_generator:yieldevtifisinstance(evt,(RequireUserConfirmEvent,RequireExternalExecutionEvent)):break_execution_for_hitl=True# 标记 HITL 中断ifbreak_execution_for_hitl:yieldAssistantMsg(content="Waiting for tool calls to be confirmed ...")return# 退出 _reply_impl,等待外部续传

Agent 不是"崩溃退出",而是主动暂停– 发射一条等待消息后return,把控制权还给调用方。下一篇文章会详述这两种中断模式。

五、中断与恢复

被动中断:CancelledError

当外部调用task.cancel()时(如用户关闭页面、服务关闭),Agent 会在下一个await点收到CancelledError

# 源码: _agent.py:888-898exceptasyncio.CancelledError:end_event=ReplyEndEvent(finished_reason=ReplyEndReason.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].state=ToolCallState.FINISHED# 发射完整的 tool_result 生命周期yieldToolResultStartEvent(...)yieldToolResultTextDeltaEvent(delta="<system-reminder>The tool call has been ""interrupted by the user.</system-reminder>")yieldToolResultEndEvent(state=ToolResultState.INTERRUPTED,)# 补上 ToolResultBlock,保持 context 完整last_msg.content.append(ToolResultBlock(output=interruption_message,state=ToolResultState.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 中断

HITL(Human-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 B

AgentScope 区分两种调用路径:

┌─ Case A: 新消息 ──────────────────────────────┐ │ 输入: Msg (用户发的新消息) │ │ 行为: 新建 reply_id, cur_iter=0, 进循环 │ │ 场景: 正常对话 │ └────────────────────────────────────────────────┘ ┌─ 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_iters=20 (默认), stop_on_reject=False (默认)# 来源: AgentScope Agent.__init__ 的默认值

翻译子 Agent 使用更小的max_iters(源码:src/myagent/agents/translator.py):

# myagent translator: 翻译任务不需要多轮工具调用react_config=ReActConfig(max_iters=5)

配置选择策略

┌─ max_iters 选择 ──────────────────────────────────┐ │ │ │ max_iters=5: 简单任务(翻译、摘要、格式转换) │ │ -> 不需要多轮工具调用 │ │ -> 5 轮足够 │ │ │ │ max_iters=20: 通用任务(默认值) │ │ -> 可能需要多轮推理 + 工具调用 │ │ -> 20 轮覆盖大部分场景 │ │ │ │ max_iters=50: 复杂任务(多步研究、代码生成) │ │ -> 需要大量工具调用 │ │ -> 但注意: 每轮都调 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_reject=True时,工具被拒后 Agent 不继续推理,而是等用户指示。stop_on_reject=False(默认)时,Agent 会把拒绝结果加入 context,继续推理(可能换一种方式完成任务)。

选择:需要严格控制的场景设True(拒绝即停),需要灵活的场景设False(拒绝后换路走)。

陷阱 3: interruption_raise_cancelled_error 的传播

症状:Agent 被取消后,上层调用者也收到了 CancelledError。

原因interruption_raise_cancelled_error=True时,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 循环会怎么处理?其他用户的请求会被阻塞吗?