数据截至 (上游 commit d02db1ee7c41)
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:210 的 execute()。它先做一次性重置,然后进 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 → 模型返回的 action 由 MacroTool.execute 真正执行 → 把这一步的反思与结果压进 history → 检查是否 done/超步/被取消 → 收尾时统一 abort、恢复状态。
3. 核心原理(逐个机制,由浅入深)
3.1 一步四拍:observe → think → act → loop
它要解决的小问题: 怎么把"感知—思考—行动"稳定地重复下去,还能在中途安全地中断和收尾?
思路: 用一个 while(true),每圈严格走"观察 → 拼提示 → 调模型 → 执行动作 → 记历史 → 判退出"。关键在于内外两层 try:内层 try 把"agent 自己的错"(模型报错、工具抛异常)吞掉、转成一次失败结果并 break,不让它掀翻整个循环;外层的 finally 保证无论怎么退出,遮罩都会清、取消开关都会拨、状态都会落定。
四拍在源码里的位置(都在 execute 内):
| 拍 | 做什么 | 源码 |
|---|---|---|
| ① observe | browserState = await pageController.getBrowserState();再 #handleObservations(step) | PageAgentCore.ts:268-269 |
| ② think | 拼 [system, user] 两条消息 → #llm.invoke(messages, macroTool, signal, …) | :273-288 |
| ③ act | invoke 内部会调用 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,组装 ExecutionResult 并 break | completed(:317-325) |
| maxSteps | 每步末 step++ 后 step > maxSteps | 记一条 error 历史,失败结果,break | error(:349-358) |
| abort | signal 被拨(见 §3.7) | 内层 catch 认出 AbortError,break | stopped(:326-337) |
| (其他异常) | 工具/模型抛出的非取消错误 | 内层 catch 记 error,失败结果,break | error(: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 记忆的核心闭环:
- 模型每步产出三个反思字段
evaluation_previous_goal/memory/next_goal(定义见AgentReflection,types.ts:171-175)。 execute从模型结果里把它们抽出来存进 step 事件(:295-315)。- 下一步
#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,而是数出 history 里 type==='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、stop 与 dispose
它要解决的小问题: 用户点了"停止",或组件被销毁时,正在飞的 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里的生命周期钩子)彻底结束才返回。#running是execute开头建的一个 Promise,收尾时resolveRunning()放行(:225-226、:372)。dispose()(:649-660):标记disposed、销毁pageController、abort()、派发'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 的错"和"宿主/钩子的错"