跳到主要内容

录制与重放:让一次成功的操作可复现

30 秒导读: 一个 AI agent 好不容易把"登录 → 搜索 → 下单"这套操作跑通了,下次能不能不再调用大模型、直接把上次的动作原样再放一遍?这就是「重放(replay)」。难点在于:上次记的是"点第 12 号元素",但今天页面 DOM 变了,第 12 号早已不是那个按钮。Nanobrowser 的答案是——别死记编号,记住那个元素长什么样,回放时按特征重新找到它、改写编号。本章讲透这套机制。

本章聚焦 history/replay 这一条线。它依赖但不重复:

如果你还不清楚"编号(highlightIndex)"和"动作(action)"是什么,建议先读那两章。


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

一句话定义

录制与重放 = 把一次成功任务的每一步(模型说了什么、点了哪个元素、页面当时什么状态)存下来,之后不经过大模型、按存档一步步再执行一遍。

解决什么问题 / 给谁用

假设你每天早上都要让 agent 做同一件事:打开某后台、点"导出报表"、下载。

  • 每次都跑完整的 Planner + Navigator 双 agent 循环(见 01-agent-loop.md),又慢又烧 token,而且模型有随机性,偶尔还会走错。
  • 但这套操作其实第一次就跑通了——既然跑通了,为什么不把它当成一个"宏(macro)"存起来,以后一键重放?

这正是 replay 要给的能力:把一次昂贵、不确定的 AI 探索,固化成一次廉价、确定的回放。

一句话直觉 / 类比

把它想成录制宏,但比 Excel 宏聪明:

  • Excel 宏记的是"点屏幕上 (300, 480) 这个坐标"——窗口一挪就废了。
  • Nanobrowser 记的是"那个 <button>、父路径是 body/div/form、属性 id=export"——哪怕它在页面里挪了位置、编号变了,也能重新认出来。

本节不涉及底层代码。记住一句话就行:重放的稳健,靠的是"记特征"而不是"记编号"。


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

重放分两个阶段:录制(任务正常执行时顺带存档)和回放(读档,逐步重演)。

怎么读下面这张图

从上到下是时间顺序。左半边是"录制"(一次真实任务),右半边是"回放"(读档重演)。中间的 chatHistoryStore 是把两者连起来的存档。

录制阶段(一次真实任务) 回放阶段(读档重演)
───────────────────── ─────────────────────
Navigator 每步 execute() Executor.replayHistory(sessionId)
│ │
│ finally: 记一条 AgentStepRecord │ 从 chatHistoryStore 读回历史
│ · 模型输出(next_goal + action) │
│ · 每个动作操作的元素特征 ▼
│ (interactedElement) 逐条 executeHistoryStep(带重试)
▼ │
context.history.history[] │ 对每个动作:
│ │ 按元素特征在【新 DOM】里重新定位
▼ │ → 改写 highlightIndex(编号)
┌──────────────────┐ 开启 replay 时存 ▼
│ chatHistoryStore │ ◀─────────────── 改写后的动作 → doMultiAction 落地
└──────────────────┘

部件一句话职责

部件干什么在哪个文件
AgentStepHistory / AgentStepRecord历史的数据结构:一次任务由多条"步记录"组成chrome-extension/src/background/agent/history.ts:4
DOMHistoryElement被操作元素的"特征快照"(标签/xpath/父路径/属性…)chrome-extension/src/background/browser/dom/history/view.ts:35
Executor.replayHistory回放总入口:读档 + 逐步驱动chrome-extension/src/background/agent/executor.ts:370
NavigatorAgent.executeHistoryStep单步回放:重试 / skipFailures / 定位改编号chrome-extension/src/background/agent/agents/navigator.ts:551
HistoryTreeProcessor把元素转成特征、在新树里按特征找回元素chrome-extension/src/background/browser/dom/history/service.ts:165
chatHistoryStore把历史序列化存进扩展存储、按 session 读回packages/storage/lib/chat/history.ts:228

主线走一遍(不进代码)

  1. 录制:正常任务里,Navigator 每执行完一步,就在 finally 里把"这一步"记成一条 AgentStepRecord,压进 context.history。任务结束时,若用户开了重放开关,整段历史被序列化存进 chatHistoryStore
  2. 回放:replayHistory 按 session 把历史读回来,for 循环逐条交给 executeHistoryStep
  3. 关键一步:回放每个动作前,拿录制时存的"元素特征"去当前页面的新 DOM里重新定位那个元素,把动作里的旧编号改成新编号,再真正执行。

3. 核心原理

3.1 录制:记的是"交互元素",不是"死编号"

它要解决的小问题: 一步操作里,模型说"click index 12"。如果我们只存下 {click: {index: 12}},回放时页面变了、12 号变成别的东西,就点错了。所以录制时必须额外存下"12 号当时到底是哪个元素、长什么样"。

思路: 每执行完一步,把三样东西打包存下来——(a) 模型的原始输出(含 next_goalaction 列表),(b) 每个动作的执行结果,其中带上被操作元素的特征快照 interactedElement,(c) 当时的浏览器状态。

特征快照存了什么?DOMHistoryElement(browser/dom/history/view.ts:35):

字段是什么回放定位时用不用
tagName标签名,如 button间接(在父路径里)
xpath该元素的 xpath✅ 参与哈希匹配
entireParentBranchPath从根到它的父标签链,如 ["html","body","form"]✅ 参与哈希匹配
attributes元素属性,如 {id:"export", class:"btn"}✅ 参与哈希匹配
highlightIndex录制当时的编号❌ 回放时会被重算
cssSelector / 坐标 / viewport增强选择器、坐标等存了但不参与下面的匹配

注意:highlightIndex(编号)也存了,但回放时它只是"参考旧值",真正靠的是前三个特征。 这就是"记特征而非记编号"的字面体现。

真实实现: 动作真正落地的地方(doMultiAction)在动作执行后补记交互元素:

// navigator.ts:417 —— 动作若带 index 参数,就把当时那个元素转成特征快照
if (indexArg !== null) {
const domElement = browserState.selectorMap.get(indexArg);
if (domElement) {
const interactedElement = HistoryTreeProcessor.convertDomElementToHistoryElement(domElement);
result.interactedElement = interactedElement; // 挂到这一步的结果上
}
}

这段:动作 {click:{index:12}} 执行后,从 selectorMap 取出 12 号对应的真实 DOM 元素,调 convertDomElementToHistoryElement(dom/history/service.ts:7)算出它的特征,存进 result.interactedElement

打包成一条步记录execute()finally 里(无论成功失败都记):

// navigator.ts:253-266 —— 每步收尾,复制结果(含 interactedElement)并压入历史
const actionResultsCopy = actionResults.map(result => new ActionResult({
/* …isDone, success, extractedContent, error, includeInMemory… */
interactedElement: result.interactedElement, // 关键:带上特征快照
}));
const history = new AgentStepRecord(modelOutputString, actionResultsCopy, browserStateHistory);
this.context.history.history.push(history);

一条 AgentStepRecord 的结构极简(history.ts:4):modelOutput(模型输出的 JSON 字符串)+ result(动作结果数组,每个可能带 interactedElement)+ state(浏览器状态历史)。整段任务就是 AgentStepHistory.history[] 这个数组。

存档发生在 Executor.execute()finally,且只有开了开关才存:

// executor.ts:222 —— 仅当用户启用了"重放历史任务"才落盘
if (this.generalSettings?.replayHistoricalTasks) {
const historyString = JSON.stringify(this.context.history);
await chatHistoryStore.storeAgentStepHistory(this.context.taskId, this.tasks[0], historyString);
}

3.2 为什么"记编号"必崩,"记特征"能活

它要解决的小问题: 编号(highlightIndex)是每次感知 DOM 时临时分配的序号(见 02-perception-dom.md)。它不是元素的身份,只是"这一帧里的排队号"。页面稍有变化,排队号全乱。

用一张对比讲清为什么必须换思路:

维度死记编号 index=12记特征 DOMHistoryElement
页面加载出新广告/横幅12 号整体后移,点错按父路径+属性+xpath 仍能认出
元素在 DOM 里挪了位置编号变,点错特征不变,重新定位后改编号
元素被删了静默点到别的元素匹配失败 → 明确报错(见 3.3)
身份稳定性每帧重排,极不稳定父链/属性/xpath 相对稳定

"特征"具体怎么比? 不是逐字段字符串比对,而是把三个特征各自 SHA-256 成哈希,再比哈希。见 hashDomHistoryElement(dom/history/service.ts:80):

// dom/history/service.ts:81 —— 一个历史元素被压成三段哈希
const [branchPathHash, attributesHash, xpathHash] = await Promise.all([
_parentBranchPathHash(domHistoryElement.entireParentBranchPath), // 父标签链 → 哈希
_attributesHash(domHistoryElement.attributes), // 属性 → 哈希
_xpathHash(domHistoryElement.xpath ?? ''), // xpath → 哈希
]);

三段哈希共同构成元素的"指纹"(HashedDomElement,view.ts:1)。回放时新 DOM 里的每个候选元素也算一遍指纹,三段全等才算同一个元素

3.3 回放:在新 DOM 里找回元素、改写编号

它要解决的小问题: 回放某一步时,页面是全新加载的,编号体系全变。得先把每个动作里的旧编号,换成"同一个元素在新页面里的新编号"。

思路(三步):

录制时的动作 当前新页面 DOM 树
{click:{index:12}} ───────────┐
带 interactedElement 特征 │
│ ▼
│ ① findHistoryElementInTree:遍历新树,
│ 找指纹三段全等的节点
▼ │
命中节点 highlightIndex=7 ◀──┘


② 若 7 ≠ 12,把动作改写成 {click:{index:7}}


③ 交给 doMultiAction 真正执行

① 按特征找回元素 —— findHistoryElementInTree(dom/history/service.ts:27)深度遍历当前元素树,对每个有编号的节点算指纹,与历史指纹三段比对,命中即返回:

// dom/history/service.ts:37 —— 三段哈希全等,才认定是同一个元素
if (
hashedNode.branchPathHash === hashedDomHistoryElement.branchPathHash &&
hashedNode.attributesHash === hashedDomHistoryElement.attributesHash &&
hashedNode.xpathHash === hashedDomHistoryElement.xpathHash
) {
return node; // 找到了!这个 node 带着它在新页面里的新 highlightIndex
}

② 改写编号 —— 这是全章的关键函数 updateActionIndices(navigator.ts:630)。它拿历史元素去新树里定位,再把动作里的 index 改成新元素的 highlightIndex:

// navigator.ts:630 起(节选)—— 定位 → 比对 → 改编号
const currentElement = await HistoryTreeProcessor.findHistoryElementInTree(historicalElement, currentState.elementTree);
if (!currentElement || currentElement.highlightIndex === null) {
return null; // 找不到 → 返回 null(触发失败,见下)
}
const oldIndex = actionInstance.getIndexArg(actionArgs);
if (oldIndex !== null && oldIndex !== currentElement.highlightIndex) {
const updatedAction = { [actionName]: { ...actionArgs } };
actionInstance.setIndexArg(updatedAction[actionName], currentElement.highlightIndex); // 写入新编号
logger.info(`Element moved in DOM, updated index from ${oldIndex} to ${currentElement.highlightIndex}`);
return updatedAction;
}
  • getIndexArg / setIndexArg(actions/builder.ts:101 / :117)是动作对象上读写 index 字段的小工具——只有声明了 hasIndex 的动作才有编号可改。
  • 返回 null 意味着"这个元素在新页面里找不到了"。上层 executeHistoryActions(navigator.ts:501)据此抛错:
// navigator.ts:501 内 —— 定位失败,明确报错而不是硬点某个编号
if (updatedAction === null) {
throw new Error(`Could not find matching element ${i} in current page`);
}

这就是 3.2 表格里"元素被删 → 明确报错"的落点:宁可清晰失败,也不误点。

③ 执行 —— 所有动作改好编号后,复用正常路径 doMultiAction(见 03-actions-execution.md)落地,回放不另造一套执行逻辑。


4. 深入实现:一次回放的完整调用链

从入口到落地,串起来看:

Executor.replayHistory(sessionId) executor.ts:370
├─ chatHistoryStore.loadAgentStepHistory(sessionId) 读回历史(空/未找到则抛错)
├─ JSON.parse → AgentStepHistory
└─ for 每条 historyItem:
└─ NavigatorAgent.executeHistoryStep(...) navigator.ts:551
├─ parseHistoryModelOutput(historyItem) navigator.ts:464 解析出 goal + actions
└─ while retryCount < maxRetries:
└─ executeHistoryActions(...) navigator.ts:501
├─ 对每个动作:updateActionIndices navigator.ts:630 定位+改编号
│ └─ HistoryTreeProcessor.findHistoryElementInTree service.ts:27
└─ doMultiAction(validActions) navigator.ts:366 真正执行

4.1 入口:读档与逐步驱动

replayHistory(executor.ts:370)先把历史读回并做防御性校验:

// executor.ts:385 起 —— 读不到 / 空历史,直接报错
const historyFromStorage = await chatHistoryStore.loadAgentStepHistory(sessionId);
if (!historyFromStorage) throw new Error(t('exec_replay_historyNotFound'));
const history = JSON.parse(historyFromStorage.history) as AgentStepHistory;
if (history.history.length === 0) throw new Error(t('exec_replay_historyEmpty'));

之后 for 循环逐条驱动,每条前都检查 context.stopped,支持用户中途停止(executor.ts:394 起)。回放的三个调参在方法签名上定默认值:maxRetries = 3skipFailures = truedelayBetweenActions = 2.0 秒(executor.ts:372-374)。

4.2 单步:解析、重试与容错

executeHistoryStep(navigator.ts:551)先解析这一步录的模型输出。parseHistoryModelOutput(navigator.ts:464)从 modelOutput JSON 里取出 next_goalaction 列表,并校验确有动作可放(空数组、[null] 都视为"无动作可放"并抛错)。

解析失败不炸整场回放,而是返回一条错误结果继续(navigator.ts:569 一带的 catch):

// navigator.ts 解析失败分支 —— 记一条错误、跳过本步,不中断回放
catch (error) {
return [ new ActionResult({ error: errorMsg, includeInMemory: false }) ];
}

真正执行包在重试循环里(navigator.ts:589 一带):最多 maxRetries 次,每次失败等 delay 再试;试满仍失败时,看 skipFailures——为 true 则记录错误继续下一步,为 false 则抛错终止整场回放:

// navigator.ts 重试收尾 —— 试满仍败:skipFailures 决定"跳过"还是"炸掉"
if (retryCount >= maxRetries) {
results.push(new ActionResult({ error: failMsg, includeInMemory: true }));
if (!skipFailures) throw new Error(failMsg); // 严格模式:一步失败即中止
}

4.3 逐动作改编号

executeHistoryActions(navigator.ts:501)先抓当前浏览器状态,再逐个处理动作。有几条刻意的分支值得记:

情况处理
这一步某动作没有对应结果break,不再往后放
动作是 null原样保留 null(占位),跳过
动作没有 interactedElement(如"输入文本""滚动"这类不针对具体编号元素的)原样使用,不改编号
动作 interactedElementupdateActionIndices(navigator.ts:532)定位改编号

最后把 null 过滤掉,交给 doMultiAction(navigator.ts:544)。这说明"改编号"只作用于真正指向某个可交互元素的动作,导航、输入等无编号动作原样重放。


5. 巧妙之处(可带走的技术)

  • 身份 vs 序号的分离。 编号(highlightIndex)只是"这一帧的临时序号",元素身份另用哈希指纹表达。回放时"先按身份找回、再重算序号",从根上绕开了序号漂移。这是整个 replay 稳健性的地基。navigator.ts:630 updateActionIndices

  • 三段哈希而非整体比对。 把父路径、属性、xpath 各哈希一段(service.ts:80),既能整体比对,也天然抗"文本内容变了但结构没变"的噪声——注意它故意没有把可见文本 textHash 纳入匹配(view.ts:9 注释掉了,service.ts:37 只比三段),因为文案常变、结构更稳。

  • 定位失败宁可报错也不硬点。 updateActionIndices 找不到元素时返回 nullexecuteHistoryActionsCould not find matching element。对自动化脚本来说,"清晰失败"远比"点错一个元素造成脏操作"安全。

  • 录制在 finally 里、失败步也录。 navigator.ts:245finally 无条件压入历史,连报错步也记——回放/复盘时能看到"当时到底发生了什么",而非只留成功片段。

  • 回放复用正常执行路径。 改完编号后仍走 doMultiAction(navigator.ts:544),没有第二套"回放专用执行器"。录制与回放共享落地逻辑,减少两条路径行为不一致的风险。

  • 重放是可选、可停、可容错的。 存档受 replayHistoricalTasks 开关控制(executor.ts:222);回放支持 stopped 中途停(executor.ts:394 一带)、每步重试、skipFailures 决定遇错跳过还是中止。


6. 边界与局限(诚实)

  • 不重新决策,只重演动作。 回放完全不调用大模型——它照搬录制时模型给的 action,只改编号。所以页面逻辑变了(比如多了一步验证码、按钮文案改成需要不同判断),回放不会"随机应变",只会按老剧本走,大概率在某步匹配失败。

  • 强依赖 DOM 结构稳定。 匹配靠父路径 + 属性 + xpath 哈希(service.ts:80)。若站点改版导致这三者变化(哪怕元素"看起来一样"),findHistoryElementInTree 就找不到,该步失败。

  • 文本变化不影响匹配,但也意味着"认错"风险。 因为 textHash 不参与匹配(view.ts:9service.ts:37),两个结构/属性/xpath 相同但文案不同的元素会被视为同一个——绝大多数场景没问题,但极端同构页面理论上可能误配。

  • 匹配是"首个命中即返回"。 findHistoryElementInTree 深度遍历遇到第一个三段全等的节点就返回,不校验唯一性;若页面存在多个指纹相同的元素,取到的是遍历顺序里的第一个。

  • 无编号动作不做定位。 输入文本、滚动等没有 interactedElement 的动作原样重放。若这类动作隐含依赖某个焦点/位置而页面已变,回放可能落到非预期目标——代码里对这类动作没有校验。

  • 存档随 session 走。 历史按 sessionId 存取(storage/lib/chat/history.ts:228/:244),空历史或空 task 读回为 null(:247);回放不存在跨 session 复用同一段宏的机制。


7. 横向对比

replay 是本 shelf 里 browser agents 较少见的工程细节。放到 04-memory-and-security.md 讲的"两条暗线"旁边看:

  • 记忆(memory)解决的是"同一次任务里,把过去的观察压进上下文喂给模型";
  • 重放(replay)解决的是"跨次任务,把过去的动作原样复现,且不喂模型"。

两者都在"复用历史",但一个复用给模型看、一个复用给执行器跑。它们共享同一套"元素特征化"(DOMHistoryElement)的底座——都来自 02-perception-dom.md 那套 DOM 感知。


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

主题文件关键符号
回放总入口:读档 + 逐步驱动chrome-extension/src/background/agent/executor.tsExecutor.replayHistory
录制开关 + 存档落盘chrome-extension/src/background/agent/executor.tsexecute(finally 内 replayHistoricalTasks 分支)
每步收尾:压入历史(带 interactedElement)chrome-extension/src/background/agent/agents/navigator.tsexecute(finally),AgentStepRecord
执行时补记交互元素chrome-extension/src/background/agent/agents/navigator.tsdoMultiAction(convertDomElementToHistoryElement 调用处)
解析历史里的模型输出chrome-extension/src/background/agent/agents/navigator.tsparseHistoryModelOutput
单步回放:重试 / skipFailureschrome-extension/src/background/agent/agents/navigator.tsexecuteHistoryStep
逐动作定位改编号 + 落地chrome-extension/src/background/agent/agents/navigator.tsexecuteHistoryActions
核心:按特征改写 highlightIndexchrome-extension/src/background/agent/agents/navigator.tsupdateActionIndices
元素→特征 / 新树按特征找回chrome-extension/src/background/browser/dom/history/service.tsconvertDomElementToHistoryElement,findHistoryElementInTree,hashDomHistoryElement,HistoryTreeProcessor
元素特征快照 + 指纹结构chrome-extension/src/background/browser/dom/history/view.tsDOMHistoryElement,HashedDomElement
历史数据结构chrome-extension/src/background/agent/history.tsAgentStepHistory,AgentStepRecord
动作 index 读写工具chrome-extension/src/background/agent/actions/builder.tsAction.getIndexArg,Action.setIndexArg
历史序列化存取packages/storage/lib/chat/history.tsstoreAgentStepHistory,loadAgentStepHistory