跳到主要内容

文本快照与 uid 定位模型:agent 如何"看见"并"点中"页面

30 秒导读: 模型看不到像素,也拿不到真实 DOM 指针。这一章讲整个货架最核心的机制—— chrome-devtools-mcp 把当前页面压成一棵带编号(uid)的可读文本树发给模型;模型回一句 "点 uid=3_12",服务器再把这个编号反查回真实 DOM 元素去操作。看见(快照)与点中(定位) 是同一套 uid 的两半。

本章聚焦"看见 → 编号 → 点中"这条主线。真正的输入动作(点击/输入怎么可靠落地)见 04-autowait-input;产出 extra handle 的第三方工具全流程见 05-response-collectors,本章只讲它插进快照树的那个接口点。


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

问题:模型怎么"指"一个按钮?

想象你在电话里让别人帮你操作一个你看不见的网页。你只能用描述:"点那个写着 '登录'的蓝按钮"。对方(浏览器)得把你这句话对应到屏幕上某个真实的按钮上。

AI agent 面对的正是这个困境。它是纯文本模型,拿不到浏览器里那个活的 DOM 节点对象。两条路:

方案agent 看到的怎么"指"问题
截图 + 坐标一张图片"点 (x=412, y=380)"要视觉模型算坐标,像素一动就错,贵且脆
文本快照 + uid一棵文字树"点 uid=3_12"稳、便宜、纯文本模型就能用

chrome-devtools-mcp 选后者。它的 take_snapshot 工具描述里写死了这条原则: "Prefer taking a snapshot over taking a screenshot"(src/tools/snapshot.ts:16)。

一眼看懂:快照长什么样

服务器发给模型的不是图片,而是这样一棵缩进文本树(节点来自浏览器的无障碍树 / a11y 树):

uid=3_0 RootWebArea "登录页"
uid=3_1 heading "欢迎回来"
uid=3_2 textbox "用户名"
uid=3_3 textbox "密码"
uid=3_4 button "登录"

模型读完只要回 click(uid="3_4"),服务器就知道那是"登录"按钮,并能拿到它真实的 DOM 句柄去点。

一句话直觉

uid 是页面元素的"门牌号"。 快照是一次给所有门挂门牌;之后模型报门牌号,服务器按号找门。 门牌在页面小改动时尽量不换号(§4),页面大改后旧号作废、报错让你重拍(§7)。


2. 顶层全景(一次"看见→点中"怎么转)

两个类分工:TextSnapshot 负责"造树 + 发号",McpPage 负责"收号 + 找元素"。中间靠一张 跨快照存活的映射表 uniqueBackendNodeIdToMcpId 把号码钉稳。

┌─────────────────────────── 看见(拍快照) ───────────────────────────┐
│ │
│ 浏览器 a11y 树 ──accessibility.snapshot()──▶ assignIds 递归赋 uid │
│ │ │ │
│ │ ▼ │
│ │ idToNode: uid→节点 │
│ │ (本次快照的号码簿) │
│ ▼ │ │
│ SnapshotFormatter.toString() ◀───────────────────┘ │
│ │ 缩进文本 "uid=3_4 button \"登录\"" │
└────────┼────────────────────────────────────────────────────────────┘
▼ 发给模型
┌─────────┐ 模型回 click(uid="3_4") ┌─────────────────────────┐
│ 模 型 │ ─────────────────────────▶ │ 点中(定位) │
└─────────┘ │ McpPage.getElementByUid │
│ idToNode.get(uid)→节点 │
│ → node.elementHandle() │
│ → 真实 DOM 句柄,可点 │
└─────────────────────────┘

部件一句话职责:

部件干什么在哪
TextSnapshot.create抓 a11y 树、递归赋 uid、插入额外节点、算选中项src/TextSnapshot.ts:47
idToNode本次快照的"号码簿":uid → 节点(含 elementHandle())src/TextSnapshot.ts:69
uniqueBackendNodeIdToMcpId跨快照存活的表:让同一元素复用同一 uidsrc/McpPage.ts:67
SnapshotFormatter把树渲染成缩进文本 / 结构化 JSONsrc/formatters/SnapshotFormatter.ts:17
McpPage.getElementByUid收 uid,反查节点,解析出真实 DOM 句柄src/McpPage.ts:471
resolveCdpElementId把 DevTools 选中项 / DOM 节点反查成 uidsrc/TextSnapshot.ts:151

3. 为什么用 a11y 树,而不是整棵 DOM

TextSnapshot.create 的第一步不是遍历 DOM,而是问浏览器要无障碍树 (src/TextSnapshot.ts:56-59):

const rootNode = await page.pptrPage.accessibility.snapshot({
includeIframes: true,
interestingOnly: !verbose,
});

这一步的三个设计意图:

  • a11y 树天生"以交互为中心"。 无障碍树本就是给屏幕阅读器用的——它保留 role(按钮/输入框/ 链接)、可读的 name(标签文本),丢掉纯装饰的 <div><span> 层。这正好是 agent 想要的"能操作 的东西",不是完整 DOM 的噪声。
  • interestingOnly: !verbose 是"降噪开关"。默认(非 verbose)只保留"有意思"的节点,让快照短、 省 token;verbose=true 时才展开完整 a11y 树(选中项藏在被折叠节点里时会用到,见 §5)。
  • includeIframes: true 让跨 iframe 的元素也进同一棵树,模型不用关心 frame 边界。

要点: 快照是 role + name 的语义视图,不是像素也不是完整 DOM。模型据此判断"这是个登录按钮", 靠的是 button "登录",而不是坐标或 class 名。


4. uid:怎么发号,又怎么让号码稳定

这是本章的技术心脏。难点不在"给节点编号"(那只是个计数器),而在 让同一个真实元素在连续多次快照里尽量拿到同一个 uid——否则模型刚记住 uid=3_4,下一拍就变了号, 没法连续操作。

4.1 赋号:递归 assignIds

assignIds 深度优先遍历整棵 a11y 树,给每个节点挂一个 id(即 uid),并登记进 idToNode(src/TextSnapshot.ts:73-110)。核心是怎么决定这个 id:

// src/TextSnapshot.ts:78-87(节选)
const uniqueBackendId = `${node.loaderId}_${backendNodeId}`; // 元素的"指纹"
const existingMcpId = uniqueBackendNodeIdToMcpId.get(uniqueBackendId);
if (existingMcpId !== undefined) {
id = existingMcpId; // 见过 → 复用旧 uid
} else {
id = `${snapshotId}_${idCounter++}`; // 没见过 → 发新 uid
uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
}

拆开看两个 id:

名字形态谁给的作用
uniqueBackendId${loaderId}_${backendNodeId}浏览器 CDP元素的真实指纹,同一元素跨快照不变
id / uid${snapshotId}_${idCounter}本服务器暴露给模型的门牌号,如 3_12

backendNodeId 是 Chrome 给 DOM 节点的内部稳定编号;拼上 loaderId(本次页面加载的标识)构成 uniqueBackendId,避免跨页面导航后 backendNodeId 复用导致的混淆。

4.2 稳定复用:一张跨快照存活的表

关键在 uniqueBackendNodeIdToMcpId 不属于单次快照,而挂在 McpPage (src/McpPage.ts:67),连续多次 TextSnapshot.create 共享同一张表。于是:

第 1 拍: 元素A(指纹 L_100) 没见过 → 发号 uid=1_5 ,记 {L_100 → 1_5}
(页面局部变化,但元素A还在)
第 2 拍: 元素A(指纹 L_100) 查表命中 → 复用 uid=1_5 ← 号码没变!
元素B(新出现的) 没见过 → 发号 uid=2_0

这就是 uid 抗页面变化的来源:只要那个真实 DOM 节点还在(backendNodeId 不变),它的 uid 就跟着不变, 即便它周围的兄弟节点增删、即便快照号从 1 涨到 2。模型上一步记的 uid 在下一步仍然有效。

4.3 清理:号码簿不能无限膨胀

每拍一次,用 seenUniqueIds 记录"这次见到过哪些指纹"(src/TextSnapshot.ts:88)。造完树后,把 表里这次没再出现的指纹删掉(src/TextSnapshot.ts:141-146):

for (const key of uniqueBackendNodeIdToMcpId.keys()) {
if (!seenUniqueIds.has(key)) {
uniqueBackendNodeIdToMcpId.delete(key); // 元素已离场 → 释放它的旧 uid
}
}

效果:留在页面上的元素保号,离场的元素销号,表大小跟随实际页面,不会随会话时长无限增长。

4.4 一个小补丁:option 节点补 value

a11y 树里 <option> 节点不带它的 value,于是把它的可见文本(name)回填成 value,方便下拉选择类工具 使用(src/TextSnapshot.ts:99-106)。这是"快照要对下游工具够用"的一处细节。


5. 选中项:把 DevTools Elements 面板的选中元素也标出来

除了模型自己拍的快照,chrome-devtools-mcp 还想让人机协作:如果开发者在 DevTools 的 Elements 面板里 手动选中了某个元素,快照要把它标注出来,好让模型接着操作"我正在看的这个"。

链路是"DevTools 选中 → backendNodeId → uid":

  • McpPage.getDevToolsData 从 DevTools 页面里读出当前选中节点的 cdpBackendNodeId (src/McpPage.ts:506-537)。
  • TextSnapshot.create 拿到它后,调用 resolveCdpElementId 把这个 backendNodeId 反查成 uid,存进 selectedElementUid(src/TextSnapshot.ts:133-139)。

resolveCdpElementId 是一次树的广度/栈遍历(BFS 式)——从 root 出发压栈,逐个比对 backendNodeId,命中即返回该节点的 uid(src/TextSnapshot.ts:151-168):

const queue = [this.root];
while (queue.length) {
const current = queue.pop()!;
if (current.backendNodeId === cdpBackendNodeId) {
return current.id; // 找到了 → 返回它的 uid
}
for (const child of current.children) queue.push(child);
}

源码里留了 // TODO: index by backendNodeId instead——目前是线性遍历,未来想改成 backendNodeId 建索引直查。

坑:选中项可能不在(非 verbose)快照里。 如果开发者选中的是个"没意思"的节点,默认快照被 interestingOnly 折叠掉了它,resolveCdpElementId 就找不到。此时 SnapshotFormatter.toString 会在开头加一句提示:选中元素不在当前树里,建议拍一张 verbose 快照 (src/formatters/SnapshotFormatter.ts:22-30)。


6. 把不在 a11y 树里的 DOM 塞进树:insertExtraNodes

有些真实 DOM 元素根本不在 a11y 树里——最典型的是第三方开发者工具(见 05-response-collectors)执行后返回的元素句柄。它们是活的 DOM 节点,模型 却看不见,也就没法用 uid 点它们。insertExtraNodes 负责把这些"编外节点"手工缝进快照树 (src/TextSnapshot.ts:173-343)。

这些编外元素以 ElementHandle[](即 extraHandles)传入。McpPage.executeThirdPartyDeveloperTool 在工具返回 DOM 元素时就会把它们收集成 extraHandles 并重拍快照 (src/McpPage.ts:409-421)——那是本章关心的唯一接口点,全流程不在此展开。

缝合分四步:

步骤函数干什么
造节点createExtraNode给 handle 造一个 TextSnapshotNode,用 custom_${backendNodeId} 当指纹发/复用 uid,role 取标签名
找爹findAncestorNodeparentElement 往上爬,找到已在树里的最近祖先当挂载点
找子孙findDescendantNodesDOM.describeNode 列出该元素的全部子孙 backendNodeId
搬孩子moveChildNodes把挂载点下本属于该元素子孙的孩子重新认领到编外节点名下,再把编外节点插进去

直觉:a11y 树漏掉了中间一层,insertExtraNodes 找到这层该在的位置,把它插回去,并把原来"越级挂在 祖先下"的那些孩子重新归位到它底下,让树结构和真实 DOM 一致。缝完后这些节点就有了 uid,模型 照常能点。

  • createExtraNode:src/TextSnapshot.ts:185-218(指纹前缀 custom_,复用同一张 uid 表)
  • findAncestorNode:src/TextSnapshot.ts:220-250(找不到就挂到 root)
  • findDescendantNodes:src/TextSnapshot.ts:252-290(CDP DOM.describeNode, pierce: true)
  • moveChildNodes:src/TextSnapshot.ts:292-317(返回插入位置,保持子孙相对顺序)

7. 点中:uid 怎么反查回真实元素,过期了会怎样

看见的另一半是点中。模型给的 uid 要变回一个能真正点击/输入的 DOM 句柄。入口是 McpPage.getElementByUid(src/McpPage.ts:471-482):

async getElementByUid(uid) {
if (!this.textSnapshot) {
throw new Error(`No snapshot found ... Use take_snapshot to capture one.`);
}
const node = this.textSnapshot.idToNode.get(uid); // 号码簿反查
if (!node) {
throw new Error(`Element uid "${uid}" not found on page ${this.id}.`);
}
return this.#resolveElementHandle(node, uid); // 节点 → 真实 DOM 句柄
}

三种失败,三种语义,值得记牢:

情况报错含义
从没拍过快照No snapshot found ... Use take_snapshot先拍一张
uid 不在当前快照uid "X" not found on page号码是旧的/编错了,重拍
uid 在,但元素已从页面消失Element with uid X no longer exists on the page.快照过期:DOM 已变

第三种最微妙:uid 还在 idToNode 里,但 #resolveElementHandle 去解析真实句柄时,那个 DOM 节点 已经不在页面上了(src/McpPage.ts:484-500)。它把底层错误包成一句人话——"这个 uid 指向的元素 不存在了"——提示模型这张快照已经陈旧,该重拍。

// src/McpPage.ts:490-499(节选)
const handle = await node.elementHandle();
if (!handle) throw new Error(message); // message = "... no longer exists ..."
return handle;

配套还有 getAXNodeByUid(只取 a11y 节点、不解析句柄,src/McpPage.ts:502-504),供只需要读节点 属性、不需要操作的场景用。

心智模型: uid 是"对某次快照有效"的号码。页面没大变→旧号还能用(§4.2 的复用);页面大变→ 旧号解析失败并明确报错。系统不会静默点错元素,而是让你重拍——这是安全的失败方式。


8. 渲染:同一棵树,两种输出

模型看到的文本由 SnapshotFormatter 生成。它把树渲染成两种形态(src/formatters/SnapshotFormatter.ts):

  • toString() 缩进文本(默认给模型看):每层缩进 2 空格,一行一个节点,格式是 uid=<id> <role> "<name>" <属性...>(src/formatters/SnapshotFormatter.ts:39-5567-104)。
  • toJSON() 结构化:同样的属性以嵌套对象 + children 数组给出,供程序化消费 (src/formatters/SnapshotFormatter.ts:35-65)。

一行是怎么拼出来的(#getAttributes,src/formatters/SnapshotFormatter.ts:67-104):

  1. 永远先放 uid=<id>;
  2. 放 role(role 为 none 时写成 ignored);
  3. 有 name 就加 "name";
  4. 其余属性排序后逐个加:布尔 true 写属性名,字符串/数字写 attr="值"

两张小表控制"显示什么、怎么显示":

作用位置
booleanPropertyMapdisabled/expanded/focused/selected 额外映射成 disableable/expandable/focusable/selectable 等能力标签src/formatters/SnapshotFormatter.ts:152-157
excludedAttributes不渲染的内部字段:id/role/name/elementHandle/children/backendNodeId/loaderIdsrc/formatters/SnapshotFormatter.ts:159-167

excludedAttributes 里那几个是给系统内部用的(如 elementHandle 是解析句柄的闭包、 backendNodeId/loaderId 是指纹),不该泄给模型,所以从可见输出里剔掉。

选中项标注:渲染到 id === selectedElementUid 的那行时,行尾追加 [selected in the DevTools Elements panel](src/formatters/SnapshotFormatter.ts:45-48)——把 §5 算出的选中项在文本里显式点出。


9. 底层数据结构:TextSnapshotNode

整套机制围着一个类型转(src/types.ts:15-20):

export interface TextSnapshotNode extends SerializedAXNode {
id: string; // uid,暴露给模型的门牌号
backendNodeId?: number; // 浏览器的真实指纹(不渲染)
loaderId?: string; // 页面加载标识,拼进 uniqueBackendId(不渲染)
children: TextSnapshotNode[];
}

它就是 Puppeteer 的 SerializedAXNode(role/name/一堆属性 + elementHandle() 方法)外加三样: 暴露给模型的 id、用于稳定复用的 backendNodeId/loaderId、以及类型收窄后的 children。一个节点 同时握着"给模型看的名字(id)""给系统找元素的指纹(backendNodeId)""能拿到真实句柄的方法 (继承自 SerializedAXNodeelementHandle())"——这三者的合体,正是"看见"与"点中"能共用一个 uid 的根本原因。


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

  • 指纹与门牌分离。 用户可见的是短门牌 3_12,内部真正锚定用不可见指纹 loaderId_backendNodeId。 门牌好读、指纹好认,互不干扰(src/TextSnapshot.ts:78-87)。
  • 把稳定性表挂在 page 而非 snapshot 上。 一个字段位置的选择(McpPage.uniqueBackendNodeIdToMcpId, src/McpPage.ts:67)就换来了 uid 跨快照复用——多数复杂度靠"表活得比快照久"这一点消化掉。
  • see/act 前缀语义共享。 snapshotId_idCounter 既是发给模型的号,又是 idToNode 的键,渲染和 定位天然对齐,不需要第二套映射。
  • 失败要吵、不要静默。 元素消失时明确报 "no longer exists" 而非点到错的元素 (src/McpPage.ts:488)——宁可让 agent 重拍,也不误操作。
  • 给不在 a11y 树里的节点留后门。 insertExtraNodes 让第三方工具产出的 DOM 也能被 uid 寻址, 把"可点中"的边界从 a11y 树扩到任意 DOM(src/TextSnapshot.ts:173)。

11. 边界与局限

  • 快照是一次性照片。 页面在两次快照之间可任意变化;系统靠"过期即报错"兜底,而非实时同步。
  • 默认快照会漏节点。 非 verbose 下 interestingOnly 折叠"无意思"节点,选中项/目标可能不在树里, 需 verbose 重拍(src/TextSnapshot.ts:56-59src/formatters/SnapshotFormatter.ts:22-30)。
  • 选中项查找是线性的。 resolveCdpElementId 每次全树遍历,源码自带 TODO 想改索引 (src/TextSnapshot.ts:156)。
  • uid 复用依赖 backendNodeId 稳定。 若浏览器重建了 DOM 节点(即使视觉上"同一个"按钮),指纹变了, uid 也会换号——复用是尽力而为,不是保证。

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

主题文件路径符号名
造树主流程src/TextSnapshot.ts:47TextSnapshot.create
抓 a11y 树src/TextSnapshot.ts:56accessibility.snapshot({includeIframes, interestingOnly})
递归赋 uidsrc/TextSnapshot.ts:73assignIds
元素指纹src/TextSnapshot.ts:78uniqueBackendId = ${loaderId}_${backendNodeId}
本次号码簿src/TextSnapshot.ts:69idToNode
option 补 valuesrc/TextSnapshot.ts:99assignIds(role === 'option')
号码清理src/TextSnapshot.ts:141未见指纹删除循环
选中项反查src/TextSnapshot.ts:151resolveCdpElementId
插入编外节点src/TextSnapshot.ts:173insertExtraNodes
造编外节点src/TextSnapshot.ts:185createExtraNode
找祖先挂载点src/TextSnapshot.ts:220findAncestorNode
列子孙src/TextSnapshot.ts:252findDescendantNodes
重认领孩子src/TextSnapshot.ts:292moveChildNodes
跨快照稳定表(字段)src/McpPage.ts:67uniqueBackendNodeIdToMcpId
编外句柄(字段)src/McpPage.ts:68extraHandles
uid → 元素src/McpPage.ts:471getElementByUid
解析真实句柄 / 过期报错src/McpPage.ts:484#resolveElementHandle
只取节点不解析src/McpPage.ts:502getAXNodeByUid
读 DevTools 选中项src/McpPage.ts:506getDevToolsData(cdpBackendNodeId)
产出 extraHandles 的接口点src/McpPage.ts:409executeThirdPartyDeveloperTool
缩进文本渲染src/formatters/SnapshotFormatter.ts:17SnapshotFormatter.toString / #formatNode
一行属性拼装src/formatters/SnapshotFormatter.ts:67#getAttributes
能力标签映射src/formatters/SnapshotFormatter.ts:152booleanPropertyMap
内部字段黑名单src/formatters/SnapshotFormatter.ts:159excludedAttributes
结构化输出src/formatters/SnapshotFormatter.ts:35toJSON / #nodeToJSON
节点类型src/types.ts:15TextSnapshotNode

相邻章: index · 01-tool-model · 02-browser-context-pages · 04-autowait-input · 05-response-collectors · 06-devtools-power