跳到主要内容

双 agent 主循环:Planner 定策略、Navigator 执行

30 秒导读: Nanobrowser 让浏览器自己"看网页、点按钮"完成任务。真正的"大脑调度中枢"是一个 for 循环:每隔几步让 Planner(策略师) 想一想"下一步大方向",其余每一步让 Navigator(执行者) 干具体的活。任务什么时候算完,由 Planner 说了算。本章只讲这个调度循环——谁调度谁、怎么判完成、怎么容错;Navigator 内部怎么"看网页"和"点元素",留给 0203

还没建立整体心智模型?先读入口页 index — 这是什么 · 全景 · 主线 · 阅读地图 建立全局认知,再回到本章深入循环内部。


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

想象你雇了两个人合作在浏览器里做事:

  • 一个策略师:不动手,只负责看当前局面、判断"任务完成了没"、给出"接下来大方向"(比如"先搜索,再点第一条结果")。
  • 一个执行者:埋头干活,把"点这个按钮、往输入框打字"这类具体动作真正落到网页上。

Nanobrowser 就是让这两个"人"(其实是两次不同的大模型调用)交替上场,循环往复,直到策略师宣布"完事了"。这个负责排班、计数、喊停的调度者,就是 Executor 类。

一句话直觉: 把它想成一个**项目经理(Planner)+ 一个员工(Navigator)**在开短会——经理每几步开一次会定方向,员工其余时间照做,经理觉得目标达成了就散会。

用起来什么样: 用户在侧边栏输入一句"帮我在 GitHub 上搜 nanobrowser 并打开第一个结果",Executor 就启动这个循环,几轮之后 Planner 判定 done=true,把最终答复写进 final_answer 返回给用户。


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

整个调度都在 Executor.execute() 一个方法里,核心是一段 for 循环(agent/executor.ts:145)。

怎么读这张图: 从上往下是一次循环迭代(step),每步先问"要不要停",再决定"这步要不要开个策略会",最后一定让执行者干一次活。

┌─────────────────────────────────┐
│ execute(): for step in maxSteps │ executor.ts:145
└───────────────┬─────────────────┘
│ 每一步:
┌───────────▼───────────┐
│ shouldStop()? │ 停/暂停/失败太多 → break
└───────────┬───────────┘ executor.ts:152 / 316
│ 否
┌──────────────▼───────────────┐
│ 该开策略会吗? │
│ nSteps % planningInterval==0 │ executor.ts:157
│ 或 navigatorDone │
└──────┬────────────────┬──────┘
是 │ │ 否
┌────────▼─────────┐ │
│ runPlanner() │ │ executor.ts:235
│ → Planner 大模型 │ │
│ done? → break │ │ checkTaskCompletion:112
└────────┬─────────┘ │
└───────┬────────┘
┌───────▼────────┐
│ navigate() │ executor.ts:274
│ → Navigator 大模型│
│ 返回 navigatorDone│
└───────┬────────┘
│ 回到循环顶

部件一句话职责:

部件干什么在哪
Executor调度中枢:跑循环、计步、判完成、容错、响应暂停/停止agent/executor.ts
PlannerAgent策略师:看全局、判 done、给 next_steps;也判断这到底是不是网页任务agent/agents/planner.ts
NavigatorAgent执行者:每步产出并执行具体动作,返回"我这步是否声明完成"agent/agents/navigator.ts
AgentContext共享黑板:nStepsconsecutiveFailurespaused/stoppedfinalAnswer 等状态agent/types.ts:36

主线走一遍(高层): 用户任务 → Executor 构造两个 agent、初始化消息历史(executor.ts:89)→ 进入 for 循环 → 周期性 runPlanner 定策略 / 每步 navigate 执行 → Planner 报 done → 循环 break → 发出 TASK_OK 事件并带上 final_answer


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

3.1 交替节奏:每隔 planningInterval 步开一次策略会

要解决的小问题: 每一步都调一次 Planner 太贵(多一次大模型调用),但完全不调又会"只顾埋头干活、看不清大方向"。怎么平衡?

思路: 定一个节奏 planningInterval(默认 3,见 types.ts:33)。每 3 步跑一次 Planner;另外只要 Navigator 上一步声明"我干完了"(navigatorDone),就立刻加开一次 Planner 去核验。

判定条件就是这一行(executor.ts:157):

if (this.planner && (context.nSteps % context.options.planningInterval === 0 || navigatorDone)) {

这行的两个触发点:到点了(周期到)执行者举手说完了。二者满足其一就开会。

关键细节 —— "举手"必须由 Planner 复核。 Navigator 说完成,Executor 并不马上信。它把 navigatorDone 记下(executor.ts:168),下一轮循环因为 navigatorDone 为真而强制跑 Planner,由 Planner 来最终裁定(executor.ts:171-173 的注释就叫 "will be validated by next planner run")。这是一种**"执行者提议、策略师拍板"**的双保险。

3.2 谁说了算:完成由 Planner 的 done 决定

要解决的小问题: 一个多步任务,什么时候算真正做完?靠步数?靠 Navigator 自己说?都不可靠。

思路: 唯一的完成信号是 Planner 输出里的 done 字段Executor 每次跑完 Planner 就检查它:

private checkTaskCompletion(planOutput: AgentOutput<PlannerOutput> | null): boolean {
if (planOutput?.result?.done) {
logger.info('✅ Planner confirms task completion');
if (planOutput.result.final_answer) {
this.context.finalAnswer = planOutput.result.final_answer; // 顺手接住最终答复
}
return true;
}
return false;
}

出处 agent/executor.ts:112(checkTaskCompletion)。返回 true → 循环里 break(executor.ts:162-164),finalAnswer 被存进 context,循环结束后作为 TASK_OK 事件的内容发出去(executor.ts:177-185)。

Planner 怎么决定 done? 规则写在系统提示里(agent/prompts/templates/planner.ts)。摘几条要点:

规则出处
逐条核对任务要求,既不漏也不臆造需求planner.ts:40-42
需要登录/填凭据才能继续 → 直接判 done,在 final_answer 里请用户自己登录planner.ts:32planner.ts:44-48
done=true 时:必须填 final_answer,并把 next_steps 置空planner.ts:33-36
任务不清楚 → 也判 done,在 final_answer 里请用户澄清planner.ts:43

也就是说 "done" 不只是"成功了",还包括"卡住了、需要人接手"这类主动交还控制权的情形。

3.3 一个岔路:web_task=false 时直接当聊天助手

要解决的小问题: 用户输入不一定是"操作网页"的任务,也可能只是随口问一句"今天几号"。为这种问题去开浏览器、点元素纯属浪费。

思路: Planner 输出里有个 web_task 字段。Planner 的第一职责就是判断这到底要不要动浏览器(planner.ts:8)。若 web_task=false,Planner 不做任何规划,直接把答案写进 final_answer、把 done 设为 true、其余字段留空,当一个普通聊天助手回答(planner.ts:9-14)。

schema 里这两个字段都做了"字符串转布尔"的宽容处理(模型有时把布尔写成 "true" 字符串):

done: z.union([ z.boolean(), z.string().transform(val => { /* "true"→true */ }) ]),
...
web_task: z.union([ z.boolean(), z.string().transform(...) ]),

出处 agent/agents/planner.ts:22(plannerOutputSchema,含 observation / challenges / done / next_steps / final_answer / reasoning / web_task 七个字段)。

效果: 一个非网页问题走的路径是 —— 循环第 0 步(nSteps % planningInterval === 0 成立)跑 Planner → Planner 判 web_task=falsedone=truecheckTaskCompletion 返回 true → 循环立刻 break,Navigator 一步都没跑。等于同一个循环顺带兼了个聊天机器人

Planner 还会先做一步防注入清洗——observation / final_answer 等字段都过一遍 filterExternalContent(planner.ts:86-90),防止网页里塞的恶意指令混进策略。这条"暗线"在 04 展开。

3.4 容错:连续失败计数与 maxFailures

要解决的小问题: 大模型会偶发抽风、网页会偶发加载失败。不能一遇错就崩,但也不能无限重试。

思路: 用一个连续失败计数器 consecutiveFailures(types.ts:45)。核心是"连续"二字——成功一次就清零

navigate() 里的两处(agent/executor.ts:274):

context.consecutiveFailures = 0; // 成功执行一步 → 清零 executor.ts:291
if (navOutput.result?.done) return true;
...
} catch (error) {
...
context.consecutiveFailures++; // 出错 → +1 executor.ts:307
if (context.consecutiveFailures >= context.options.maxFailures) {
throw new MaxFailuresReachedError(...);// 攒够就掀桌 executor.ts:309
}
}

runPlanner() 里也是同一套逻辑(executor.ts:265-269):Planner 报错也让计数 +1,攒够就抛 MaxFailuresReachedError

maxFailures 默认 3(types.ts:26)。除了在 navigate/runPlanner 内部抛错,循环顶部的 shouldStop() 也会独立再查一遍这个阈值(executor.ts:329),等于双重保险

一个刻意的例外: 有几类错误(鉴权、非法请求、被禁、URL 不允许、用户取消、扩展冲突)不计入失败、直接向上抛(executor.ts:255-264 / 297-306)——这些是"重试也没用"的硬错误,没必要浪费失败额度,直接终止更诚实。

3.5 人机协同:pause / resume / stop

要解决的小问题: 用户想中途喊停,或者暂停看一眼再继续。循环得能被外部打断。

思路: 三个状态位挂在共享的 AgentContext 上,外部调 Executor 的方法翻转它们:

方法干什么出处
pause()context.paused=trueexecutor.ts:345
resume()context.paused=falseexecutor.ts:341
cancel()context.stopped=true,并延时 abort 掉底层请求executor.ts:337types.ts:96

循环怎么响应它们,全在 shouldStop()(agent/executor.ts:316):

if (this.context.stopped) return true; // 停了 → 直接退出循环
while (this.context.paused) { // 暂停 → 原地自旋等待
await new Promise(resolve => setTimeout(resolve, 200));
if (this.context.stopped) return true; // 暂停期间又被停 → 退出
}

暂停不是退出,而是每 200ms 轮询一次、原地打转,直到被 resume(paused 变 false)或被 stop。此外 navigate() 在调用 Navigator 前后都各查一次 paused || stopped(executor.ts:279284),确保暂停/停止能在一步中途也及时生效,不会白跑一次大模型调用。

3.6 追问:addFollowUpTask 让同一会话接着聊

要解决的小问题: 用户在一个任务结束后往往想接着追问("那再帮我点第二个结果")。不该每次都从零重建整个上下文。

思路: addFollowUpTask 把新任务追加进任务列表并塞进消息历史,让下一轮 execute() 复用已有上下文(agent/executor.ts:101):

addFollowUpTask(task: string): void {
this.tasks.push(task);
this.context.messageManager.addNewTask(task);
// 只保留标记了 includeInMemory 的旧动作结果,其余清掉
this.context.actionResults = this.context.actionResults.filter(result => result.includeInMemory);
}

关键细节: 最后那行过滤很重要——上一个任务留下的一次性动作结果(没标 includeInMemory 的)会被清掉,只保留需要长期记忆的部分,避免旧任务的临时噪声污染新任务。呼应 04 的记忆管理。

对应地,Planner 的系统提示也叮嘱:web_task 只在收到网页任务时才更新,否则沿用上次的值(planner.ts:37);收到新任务时要回读之前的消息拿全上下文(planner.ts:83)。


4. 一次完整循环的时序(把上面串起来)

以"在 GitHub 搜索并打开第一个结果"为例,把各机制串成一条真实路径:

step循环里发生了什么触发的代码
0nSteps=0,0 % 3 == 0 → 跑 Planner。判 web_task=truedone=false、给出 next_stepsexecutor.ts:157runPlanner:235
0Navigator 执行(打开 github.com),成功 → consecutiveFailures=0nSteps 变 1navigate:274
1,2nSteps 非 3 的倍数、Navigator 未声明完成 → 跳过 Planner,只执行 Navigatorexecutor.ts:157 判 false
33 % 3 == 0 → 再开策略会,Planner 评估进度、继续给方向runPlanner:235
Navigator 某步声明完成 → navigatorDone=truenavigate:292
下一步navigatorDone 强制跑 Planner 复核 → Planner 判 done=true,填 final_answerexecutor.ts:157 / checkTaskCompletion:112
break 退出循环 → 发 TASK_OK 事件带 final_answerexecutor.ts:162 / 177-185

注意 runPlanner 的一个细节(executor.ts:239-245): 跑 Planner 前,若已经不是第一步,会先调 navigator.addStateMessageToMemory()当前浏览器状态(当前网页长什么样)写进消息历史,这样 Planner 是"看着最新页面"做判断,而不是拍脑袋。这条状态从哪来、长什么样,见 02


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

  • "提议—复核"两段式完成判定。 Navigator 只能"提议完成"(返回 done),真正拍板权收归 Planner。避免执行者过度自信、提前收工。见 executor.ts:170-173 的注释。
  • 同一个循环兼做聊天机器人。web_task 一个布尔,把"网页任务"和"闲聊"两条路径合进同一套调度,零额外分支代码。见 planner.ts:8-14
  • "连续"失败而非"累计"失败。 成功即清零(executor.ts:291),容忍偶发抖动,又能在真卡死时快速止损。
  • 硬错误不吃失败额度。 鉴权/禁止类错误直接上抛(executor.ts:255-264),不浪费重试次数——区分"可重试"与"重试无意义"。
  • 暂停 = 原地自旋而非退出。 shouldStop 里 200ms 轮询(executor.ts:322-327)让 pause/resume 无需重建循环状态。

6. 边界与局限(诚实)

  • 完成判定完全依赖 Planner 的自评。 没有独立的第三方"验收"步骤介入主循环——本 execute() 循环的完成信号只认 Planner 的 done,Planner 误判即误停。
  • 只有 Planner + Navigator 两个 agent,没有独立的 Validator。 你若读过仓库自带的 CLAUDE.md,会看到 Navigator / Planner / Validator 三个 agent 的说法——那是过时的历史文档(属被研究的数据,非本文权威)。当前源码里 AgentNameEnum 只有 PlannerNavigator 两个成员(packages/storage/lib/settings/types.ts:2-5),装配时还会主动清掉遗留的 validator 配置(cleanupLegacyValidatorSettings,packages/storage/lib/settings/agentModels.ts:114-117)。原本 Validator 干的"验收任务是否完成",已折叠进 Planner 输出的 done 字段。这一澄清的完整证据见入口页 index §4;本文循环层面只需记住:验收由 Planner 承担,无独立 Validator。
  • 步数上限是硬顶。 循环最多跑 maxSteps(默认 100,types.ts:24);跑满仍未 done 就发 TASK_FAIL(executor.ts:186-188),不会自动加时。
  • Planner 节奏是固定周期。 planningInterval 是常量(默认 3),不会按任务难度自适应加密/稀疏开会。
  • 本章不覆盖: Navigator 内部如何感知页面(→ 02)、如何把动作落到真实元素(→ 03)、记忆与防注入细节(→ 04)、历史录制与重放(→ 05,对应 executor.ts:370replayHistory)。

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

主题文件符号
主循环入口chrome-extension/src/background/agent/executor.tsExecutor.execute(:128)
for 循环 + 节奏判定同上execute 内 for(:145)、planningInterval 判定(:157)
跑 Planner 并写状态同上runPlanner(:235)、addStateMessageToMemory 调用(:241)
跑 Navigator 一步同上navigate(:274)
完成判定 + 接住 final_answer同上checkTaskCompletion(:112)
停/暂停/失败阈值检查同上shouldStop(:316)
追问、清理旧结果同上addFollowUpTask(:101)
人机协同状态翻转同上pause(:345)、resume(:341)、cancel(:337)
容错计数字段chrome-extension/src/background/agent/types.tsAgentContext.consecutiveFailures(:45)、DEFAULT_AGENT_OPTIONS.maxFailures(:26)、planningInterval(:33)
Planner 逻辑与 web_task 岔路chrome-extension/src/background/agent/agents/planner.tsPlannerAgent.execute(:53)、plannerOutputSchema(:22)
Planner 职责与 done 规则chrome-extension/src/background/agent/prompts/templates/planner.tsplannerSystemPromptTemplate(:3),RESPONSIBILITIES(:7)、TASK COMPLETION VALIDATION(:39)
Navigator 完成信号来源chrome-extension/src/background/agent/agents/navigator.tsNavigatorAgent.executeaddStateMessageToMemory(:275)
只有两个 agent(无 Validator)packages/storage/lib/settings/types.tsAgentNameEnum(Planner / Navigator)
清理遗留 Validator 配置packages/storage/lib/settings/agentModels.tscleanupLegacyValidatorSettings(:114-117)