跳到主要内容

ReAct 反思循环与记忆流

30 秒导读: PageAgentCore 是整个 Page Agent 的编排大脑。它跑一个 while 循环:每一步先"看"页面、再让大模型"想"、然后"做"一个动作,直到任务 done 或撞上步数/取消上限。本章只讲这条主循环和它背后的三条信息流(哪些进模型的记忆、哪些只给 UI 看),不讲工具怎么打包成 schema(见 04),也不讲页面文本怎么脱水出来(见 02)。


1. 这是什么(零基础也能懂)

一句话定义: PageAgentCore.execute(task) 是一个"感知—思考—行动"不断重复的循环,让大模型像人一样一步步操作网页。

这种模式叫 ReAct(Reason + Act,边推理边行动):模型不是一次性吐出完整计划,而是每走一步都先复盘上一步、再决定下一步。好处是它能根据页面的真实反馈随时纠偏——点错了就换个按钮,页面没加载就等一等。

它解决什么问题: 你想让 AI 帮你"在这个网页上订张票 / 填个表 / 找条信息"。网页是活的、会变的,模型看不见屏幕,只能读到一份文字版的页面快照。于是需要一个"主持人"反复地:把当前页面喂给模型 → 收下模型要做的动作 → 真的去点/去填 → 把结果再喂回去。PageAgentCore 就是这个主持人。

一个最小的心智模型——ReAct 的一步长这样:

观察(看现在的页面) → 思考(模型说:先复盘,再定下一个动作) → 行动(真的去点那个按钮) → 回到观察

一句话直觉: 把它想成一个蒙着眼睛、只能听你念页面的助手。你每念一遍当前画面(observe),他就告诉你"上一步成没成、我记住了什么、这步我要点哪(think)",你替他点下去(act),再念一遍新画面。如此往复,直到他说"我做完了(done)"。

本节不出现底层代码。记住三个词就行:观察、思考、行动,循环。


2. 顶层全景(它大概怎么转)

主循环全在一个方法里:packages/core/src/PageAgentCore.ts:210execute()。它先做一次性重置,然后进 while(true),每一圈就是一步。

怎么读这张图: 从上往下是一步的四拍;第 ④ 拍决定"继续下一圈"还是"跳出循环收尾"。

┌──────────────── execute(task) 开跑 ────────────────┐
│ 重置 history / observations / states │
│ 新建 AbortController(取消开关)· 状态→running │
└───────────────────────┬────────────────────────────┘

┌─────────────────── while(true):每一步 ───────────────────┐
│ │
│ ① 观察 observe │
│ getBrowserState() ── 拿页面快照 │
│ #handleObservations() ── 生成系统观察(URL变/警告) │
│ ↓ │
│ ② 思考 think │
│ 拼 system+user prompt → llm.invoke(MacroTool) │
│ 模型回:reflection 三字段 + 一个 action │
│ ↓ │
│ ③ 行动 act │
│ 执行该 action 对应的 tool → 结果写回 history │
│ ↓ │
│ ④ 该停了吗? done / step>maxSteps / abort │
│ │否→ step++,回到 ① │是→ break │
└────────┴───────────────────────┴────────────────────────┘

退出 → 清理遮罩 → 统一 abort → 状态→ completed/error/stopped

主要部件一句话职责:

部件干什么在哪(PageAgentCore.ts)
execute()主循环,串起一步四拍 + 退出与收尾:210
#handleObservations()观察阶段:注入系统级提示(导航、警告):538
#assembleUserPrompt()思考阶段:把三段 XML 拼成给模型的 user prompt:579
#getSystemPrompt()思考阶段:取系统提示,按语言替换:475
#packMacroTool()思考阶段:把所有工具打成"必调"的单个大工具(见 04):386
history / #observations / activity三条信息流(下面 §3.3 详解):71/:92/:174
#abortController取消开关,贯穿 LLM 请求与工具执行:91

主线走一遍(高层): execute() 先把上一轮的记忆全部清零、开一个新的 AbortController(:219-223)→ 进 while → 每圈:观察拿到 browserState 并生成系统观察 → 拼两条消息(system + user)喂给 #llm.invoke → 模型返回的 actionMacroTool.execute 真正执行 → 把这一步的反思与结果压进 history → 检查是否 done/超步/被取消 → 收尾时统一 abort、恢复状态。


3. 核心原理(逐个机制,由浅入深)

3.1 一步四拍:observe → think → act → loop

它要解决的小问题: 怎么把"感知—思考—行动"稳定地重复下去,还能在中途安全地中断和收尾?

思路: 用一个 while(true),每圈严格走"观察 → 拼提示 → 调模型 → 执行动作 → 记历史 → 判退出"。关键在于内外两层 try:内层 try 把"agent 自己的错"(模型报错、工具抛异常)吞掉、转成一次失败结果并 break,不让它掀翻整个循环;外层的 finally 保证无论怎么退出,遮罩都会清、取消开关都会拨、状态都会落定。

四拍在源码里的位置(都在 execute 内):

做什么源码
① observebrowserState = await pageController.getBrowserState();再 #handleObservations(step)PageAgentCore.ts:268-269
② think[system, user] 两条消息 → #llm.invoke(messages, macroTool, signal, …):273-288
③ actinvoke 内部会调用 MacroTool.execute,由它真正跑中选工具(见 §3.4 的结果回灌):285 / 执行体 :386
④ loop从模型结果里取 action,判 done;否则 step++ 并检查 maxSteps:300-358

注意一个容易忽略的细节: stepDelay(默认 0.4 秒,:238)被算作下一步的开头——if (step > 0) await waitFor(stepDelay, signal)(:260),第 0 步不等。紧接着 signal.throwIfAborted()(:262)做取消检查,所以每一步开头都是一个"可被打断的短暂停顿"。

为什么"想"和"做"是一次 invoke 就完成的: 这里没有"先让模型返回、再由循环去执行"的分离。工具的执行被塞进了 MacroTool 的 execute 回调里(:406-468),#llm.invoke 内部选中 AgentOutput 工具、解析出 action、就地把动作执行掉,再把 {input, output} 交回来。也就是说②think 和 ③act 在一次 invoke 调用里连着发生;循环拿到的已经是"动作执行后的结果"。

3.2 退出条件:done / maxSteps / abort

它要解决的小问题: 循环什么时候停、以什么"身份"停(成功?出错?被人叫停?)。

三条出口,对应三种最终状态 AgentStatus(types.ts:263):

出口触发收尾动作finalStatus
done模型选了 done 动作action.input.success.text,组装 ExecutionResultbreakcompleted(:317-325)
maxSteps每步末 step++step > maxSteps记一条 error 历史,失败结果,breakerror(:349-358)
abortsignal 被拨(见 §3.7)内层 catch 认出 AbortError,breakstopped(:326-337)
(其他异常)工具/模型抛出的非取消错误内层 catch 记 error,失败结果,breakerror(:326-337)

done 是唯一"成功"的出口 ——只有它能把 success: true 带出来(:318)。系统提示也把这点写死:模型必须在完成/到达步数上限/卡住时调用 done,且 done 只能单独调用(prompts/system_prompt.md:97-111)。

收尾是"无论如何都执行"的: execute 最外层的 finally(:368-374)会清理高亮、隐藏遮罩、再 abort() 一次(兜底取消任何漏网的异步)、resolveRunning() 放行等待 stop() 的人、最后 #setStatus(finalStatus)。这保证了不管从哪个出口走,UI 状态和资源都不会悬着。

3.3 三条信息流:持久记忆 vs 系统注入 vs 瞬时 UI

它要解决的小问题: 一次运行里会冒出各种"事件"——模型的每步反思、系统的导航提示、"正在思考…"的动画、重试、报错。哪些该进模型的记忆?哪些只是给屏幕看的?混在一起会污染模型的推理。

类顶部 @remarks(PageAgentCore.ts:51-60)把它们分成两个世界,本章按任务口径拆成三条流来讲:

信息流是什么进不进模型上下文载体 / 源码
history(持久记忆)每一步的反思+动作结果,构成 agent 的长期记忆 —— 每步都被重放进 <agent_history>history: HistoricalEvent[](:71);#emitHistoryChange(:165)
observation(系统注入)系统自动生成的提示:导航了、等太久了、快没步数了 —— 作为 <sys> 混进 history#observations 暂存区(:92)→ pushObservation(:192)→ 冲入 history
activity(瞬时 UI)"thinking / executing / executed / retrying / error" 这类实时动效不进 —— 只派发给 UI 监听#emitActivity(:174);类型见 types.ts:274-279

关键区分一:observation 是"系统写给模型看"的记忆。 它和模型自己产出的 step 记录都住在 history 数组里,都会被喂回模型;区别只是作者不同——step 是模型写的,observation 是编排层写的(§3.5)。pushObservation 先把内容压进 #observations 缓冲,下一步开头由 #handleObservations 统一冲进 history(:569-576)。

关键区分二:activity 绝不进模型。 它是 EventTarget 派发的 'activity' 自定义事件,纯给 Panel 做"正在思考…""执行了 click,用时 120ms"这种即时反馈。类型注释说得很直白:没有 idle 这种 activity——没有 activity 事件本身就代表 idle(types.ts:266-279)。

一个微妙但重要的例外:error 事件在 history 里,却不进模型。 history 里会记 error/retry 事件供 Panel 渲染,但 #assembleUserPrompt 拼提示时特意跳过 error 事件(:625-628,注释:"避免用瞬时错误污染 agent 的推理")。所以"在 history 数组里"不等于"进模型上下文"——真正决定去留的是 §3.4 的拼装逻辑。

3.4 拼提示 & 反思回灌:#assembleUserPrompt

它要解决的小问题: 每一步要把"任务是什么、之前干了啥、现在页面长啥样"三件事,拼成一段模型读得懂的 user prompt。

#assembleUserPrompt(:579)按固定顺序拼三段 XML(可选再前置一段 <instructions>,来自 #getInstructions 的系统/页面/llms.txt 指令,:492):

内容源码
<agent_state><user_request> 原始任务 + <step_info>(第几步 / 时间):595-603
<agent_history>遍历 history,把每个 step 和每条 observation 重放出来:609-631
<browser_state>header + 脱水后的页面 content + footer(content 怎么来见 02):640-644

反思三字段如何"回灌"历史 —— 这是 ReAct 记忆的核心闭环:

  1. 模型每步产出三个反思字段 evaluation_previous_goal / memory / next_goal(定义见 AgentReflection,types.ts:171-175)。
  2. execute 从模型结果里把它们抽出来存进 step 事件(:295-315)。
  3. 下一步 #assembleUserPrompt 再把它们逐字写回 <agent_history>:
<step_2>
Evaluation of Previous Step: 已在出发地填入“北京” Verdict: Success
Memory: 已进入订票页,出发地=北京,待填到达地
Next Goal: 在到达地输入框 [15] 填“上海”
Action Results: ✅ 已在 [12] 输入“北京”
</step_2>
<sys>Page navigated to → https://.../order</sys>

上面 Evaluation / Memory / Next Goal 三行正是模型上一步写的反思(:616-618),Action Results 则来自动作的输出 event.action.output(:619)——注意是 output 不是 input,喂回模型的是"这个动作干完后的结果字符串"。<sys> 那行则是系统观察(§3.5)。这样模型下一步就能"读到自己上一步的复盘",形成 reflect-then-act 的闭环。

一个实现细节: <step_info> 里的"第几步"不是循环变量 step,而是数出 historytype==='step' 的事件个数再 +1(:593:600)。因为重试、观察也会往 history 里塞东西,用计数比用裸 step 更贴近"模型真正走过的步数"。

各类历史事件在拼装时的去向:

history 事件类型拼进 prompt 的样子源码
step<step_N> 四行(反思三字段 + Action Results):613-620
observation<sys>内容</sys>:621-622
user_takeover<sys>User took over control…</sys>:623-624
error / retry跳过(只给 Panel,不进模型):625-628

3.5 系统观察注入:#handleObservations

它要解决的小问题: 有些信息模型自己看不出来——比如"页面刚跳转了""你已经连着等了好几秒""只剩 2 步了"。这些得由编排层主动"塞话"给模型。

#handleObservations(step)(:538)在每步观察阶段跑,按条件往 #observations 缓冲里 pushObservation,最后统一冲进 history 成为 observation 事件(:569-576):

触发条件注入的系统观察(节选)源码
累计 wait ≥ 3 秒"You have waited N seconds… DO NOT wait any longer unless you have a good reason.":540-545
URL 变了"Page navigated to → {URL}",并 waitFor(0.5) 等页面稳定:548-553
剩余步数 == 5"⚠️ Only 5 steps remaining. Consider wrapping up or calling done…":557-561
剩余步数 == 2"⚠️ Critical: Only 2 steps left! You must finish… or call done immediately.":562-566

两个细节值得记住:

  • 累计等待#states.totalWaitTime(:100)统计:wait 工具每次执行都累加秒数,一旦调用了别的工具就清零(见 #packMacroTool:457-461)。这是一个"防止模型无脑空等"的软刹车。
  • URL 变化检测#states.lastURL(:102)对比:变了就注入导航提示、更新 lastURL、并短暂 waitFor(0.5) 让新页面稳一稳。

pushObservation 本身是 @experimental @internal 的公开方法(:192),所以外部宿主代码也能主动往记忆里塞一条系统观察。

3.6 系统提示的语言替换:#getSystemPrompt

它要解决的小问题: 同一份系统提示,要能让模型用中文或英文作答,而不用维护两份文件。

#getSystemPrompt(:475)的逻辑很轻:

  • 若配置了 customSystemPrompt,整份替换,直接返回(:476-478)。
  • 否则取内置的 system_prompt.md,用一个正则把其中的 Default working language: **…** 那一行,按 config.language 换成 中文English(:480-484)。

对应模板里的锚点就是 prompts/system_prompt.md:13- Default working language: **English**。系统提示是每步都重新取一次(:274),但内容是常量替换,开销可忽略。

3.7 取消一切:AbortController、stopdispose

它要解决的小问题: 用户点了"停止",或组件被销毁时,正在飞的 LLM 请求、正在跑的工具、正在等的延时,都得干净利落地断掉。

核心是一个贯穿全链路的 AbortController(字段 :91,每次 execute 新建 :222)。它的 signal` 被同时递给:

  • LLM 请求:#llm.invoke(messages, macroTool, signal, …)(:285)——能取消在途的网络请求。
  • 工具执行:MacroTool.execute 内把 signal 放进 ctx 传给工具(:407:440),执行完还会再 signal.throwIfAborted() 兜底(:442),防止工具忽略信号照常返回。
  • 步间延时:waitFor(stepDelay, signal)(:260)与每步开头的 signal.throwIfAborted()(:262)。

两个入口拨动这个开关:

  • stop()(:200-204):只在 running 时生效,abort()await this.#running——即等到整轮(含 finally 里的生命周期钩子)彻底结束才返回。#runningexecute 开头建的一个 Promise,收尾时 resolveRunning() 放行(:225-226:372)。
  • dispose()(:649-660):标记 disposed、销毁 pageControllerabort()、派发 'dispose' 事件供 UI 清理。之后再调 execute 会直接抛"已销毁"(:212)。

为什么取消永远是标准 AbortError: 字段注释特意说明(:85-91),abort() 从不带 reason,好让 signal.reason 保持为标准 AbortError。这样内层 catch 里用 error?.name === 'AbortError' 判定(:329)就能把"被主动取消"和"真出错"区分开——前者收尾状态是 stopped,后者才是 error


4. 巧妙之处(可借鉴的技术)

  • 一次 invoke 完成"想+做"。 把工具执行塞进 MacroTool 的 execute 回调(:406),让"选动作"和"执行动作"在同一次模型调用里闭合,循环拿到的直接是执行结果——省掉一轮往返,也天然保证"每步恰好一个动作"。
  • 内外两层 try 的错误分工。 内层 catch 绝不 rethrow(注释明说,:326-327),把 agent 自身的错转成一次失败结果并 break;外层 finally 负责"无论如何都收尾"。"agent 的错"和"宿主/钩子的错"因此被干净地分开(后者会穿透到外层 catch 重新抛出,:364-367)。
  • 记忆有"作者"之分。 同在 history 里,step 是模型写的、observation 是系统写的、error 只给 Panel——拼 prompt 时按类型分别对待(:613-628),既让模型读到有用的上下文,又不被瞬时错误干扰。
  • 取消不带 reason = 标准 AbortError。 一个约定(:85-91)让"被取消"能被可靠识别成 stopped 而非 error,可观测性更好。
  • 软刹车防死循环。 累计等待警告(:540)、剩余步数警告(:557/:562)都是"系统主动塞话"给模型,引导它别空等、该收尾时收尾——不是硬性拦截,而是提示。

5. 边界与局限(诚实)

  • 单页、单动作、串行。 系统提示明确只处理单页应用、不跳新标签页(prompts/system_prompt.md:87-89);每步只允许一个 action(MacroTool 结构决定,见 04)。要看跨标签页/扩展场景见 05
  • 成功与否由模型自证。 success 完全取自模型 done 时填的字段(:318),编排层不做客观校验——模型说成功就成功。可信度靠系统提示的自评规则约束(prompts/system_prompt.md:97-111)。
  • 步数上限是硬墙。 step > maxSteps(默认 40,:111)直接以 error 收尾;剩 5/剩 2 步只有警告,没有自动"压缩记忆"或续跑机制。
  • history 只增不减。 一次运行内 history 持续追加、不做裁剪或摘要;长任务下 <agent_history> 会线性变长(context 压力交给 LLM 层的缓存,见 04)。
  • #handleObservationswaitFor(0.5) 未传 signal(:552),这半秒稳定等待不受取消信号影响——一个已知的小瑕疵(源码也用 @todo 标注了 loop detection、console error 尚未实现,:536-537)。

6. 代码地图(导航索引)

用符号名 grep 定位比行号更抗漂移。

主题文件路径符号名
主循环 / 一步四拍 / 退出与收尾packages/core/src/PageAgentCore.tsPageAgentCore.execute
拼 user prompt(state/history/browser)packages/core/src/PageAgentCore.ts#assembleUserPrompt
系统观察注入(导航/等待/步数警告)packages/core/src/PageAgentCore.ts#handleObservations
系统提示语言替换packages/core/src/PageAgentCore.ts#getSystemPrompt
指令拼装(system/page/llms.txt)packages/core/src/PageAgentCore.ts#getInstructions
三条信息流的派发packages/core/src/PageAgentCore.ts#emitHistoryChange / #emitActivity
系统观察暂存 / 取消 / 销毁packages/core/src/PageAgentCore.tspushObservation / stop / dispose
取消开关(贯穿 LLM+工具)packages/core/src/PageAgentCore.ts#abortController
内置系统提示模板packages/core/src/prompts/system_prompt.md<language_settings> / <task_completion_rules>
反思三字段 / 事件类型 / 结果结构packages/core/src/types.tsAgentReflection / HistoricalEvent / AgentActivity / ExecutionResult

相邻章节: 页面文本怎么来 → 02 DOM 脱水;动作怎么落到真实点击 → 03 工具层与动作落地;MacroTool 打包与野模型容错、normalizeResponse04 LLM 客户端;遮罩与跨标签页 → 05 视觉反馈与跨标签页;总览 → index