Stagehand — 架构与原理
30 秒导读: Stagehand 是一个"用自然语言 + 代码一起写"的浏览器自动化框架。它对外只暴露四个动词——
act(做一个动作)、extract(抽结构化数据)、observe(先看有哪些可点的元素)、agent(多步自主完成任务)。它最核心的一招是:不让模型去"看图猜坐标",而是给模型一棵带唯一 ID 的无障碍树文本,模型只需回一个 ID + 方法名,框架再用这个 ID 反查出精确 XPath,确定性地落到真实元素上。配上"动作缓存 + 自愈",让 AI 写的自动化脚本第二次跑可以不再调用模型、页面变了又能自己修。
1. 这是什么(零基础也能懂)
一句话定义: Stagehand 是一个 AI 浏览器自动化框架——你既能像用 Playwright 那样写精确的代码,也能夹一句自然语言让 AI 替你决定"点哪、填啥"。
解决什么问题 / 给谁用:
假设你要写一个自动化脚本,去 GitHub 上找某个仓库最新的 PR 并抓出作者和标题。用传统工具(Selenium / Playwright)你得手写一堆易碎的 CSS 选择 器,页面一改版脚本就挂;纯用"AI agent"又太黑盒、生产环境不敢用。Stagehand 让你按需混用:确定的步骤写代码,不确定的页面交给 AI。
它面向要在生产环境跑浏览器自动化的开发者:爬取、表单填写、端到端测试、以及给 LLM 应用当"手脚"去操作网页。
它能做什么(四个动词):
| 动词 | 干什么 | 典型调用 |
|---|---|---|
act | 执行一个原子动作(点击/输入) | stagehand.act("click the sign in button") |
observe | 只"看"不"做":返回候选可交互元素 | stagehand.observe("find the login button") |
extract | 按 Zod schema 从页面抽结构化数据 | stagehand.extract("get the PR title", schema) |
agent | 给一个高层目标,自主多步完成 | agent.execute("Get to the latest PR") |
用起来什么样(摘自 packages/core/README.md / 项目 README.md):
const page = stagehand.context.pages()[0];
await page.goto("https://github.com/browserbase");
await stagehand.act("click on the stagehand repo"); // 一步一步的动作
const agent = stagehand.agent();
await agent.execute("Get to the latest PR"); // 多步自主任务
const { author, title } = await stagehand.extract( // 抽结构化数据
"extract the author and title of the PR",
z.object({ author: z.string(), title: z.string() }),
);
一句话直觉/类比: 把它想成给 LLM 装了一副"读屏眼镜"和一只"精确的手"。眼镜(无障碍树快照)让模型读到页面结构而不用看图猜;手(自研 CDP 引擎)拿着模型给的"元素编号"精确落点。模型只负责"选哪个",落点交给确定性的查表——这就是它比"纯视觉 agent"更可靠的原因。
2. 顶层全景(它大概怎么转)
怎么读下面这张图: 从上到下是一次 act("click the login button") 的完整生命周期。左边一列是引擎/浏览器,右边是AI 推理;注意 LLM 那一步的输入是文本树、输出是元素 ID,不是坐标。
主要部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
V3(即导出的 Stagehand) | 顶层门面,拿着四个 Handler + 缓存,做参数归一化、缓存判定、历史记录 | packages/core/lib/v3/v3.ts:154(class V3) |
ActHandler | act 的大脑:抓快照 → 问 LLM → 落动作 → 两步/自愈 | packages/core/lib/v3/handlers/actHandler.ts:39 |
ObserveHandler | 只观察:抓快照 → 问 LLM 要一批候选元素 → 映射成带 XPath 的 Action[] | packages/core/lib/v3/handlers/observeHandler.ts:22 |
ExtractHandler | 抽数据:把 a11y 树 + 可选截图喂给 LLM,按 Zod schema 返回 | packages/core/lib/v3/handlers/extractHandler.ts:65 |
captureHybridSnapshot | grounding 地基:用 CDP 抓无障碍树 + DOM,产出文本树和 ID→XPath 映射,跨 iframe 拼接 | packages/core/lib/v3/understudy/a11y/snapshot/capture.ts:58 |
| understudy(CDP 引擎) | 自研的、不依赖 Playwright 的浏览器控制层,直接讲 Chrome DevTools Protocol | packages/core/lib/v3/understudy/cdp.ts:61(class CdpConnection) |
V3AgentHandler | DOM 模式 agent:用 Vercel AI SDK 的工具调用循环,工具就是 act/extract/goto… | packages/core/lib/v3/handlers/v3AgentHandler.ts:120 |
V3CuaAgentHandler | computer-use 模式 agent:纯坐标动作(截图→模型给 (x,y)→CDP 点) | packages/core/lib/v3/handlers/v3CuaAgentHandler.ts:39 |
ActCache | 按"指令+URL+变量名"哈希缓存已成功的动作,可无模型重放 | packages/core/lib/v3/cache/ActCache.ts:20 |
主线走一遍(高层,不进代码):
- 你调
stagehand.act("click the login button"),V3.act先看能不能命中缓存(同指令+同 URL 之前跑过)——命中就直接重放存好的 XPath,一次模型都不调(v3.ts:1293起)。 - 未命中 →
ActHandler.act让自研 CDP 引擎抓一张混合快照:一棵人类可读的无障碍文本树, 加一张**"元素 ID → XPath"字典**(actHandler.ts:152)。 - 文本树 + 你的指令喂给 LLM。LLM 不返回坐标,只返回一个
elementId(如"1-42")、一个method(如click)、arguments,外加一个布尔twoStep(要不要第二步)(inference.ts:410的act)。 - 框架用
elementId去字典里反查出精确 XPath(actHandler.ts:467),这一步是确定性查表,不是猜。 performUnderstudyMethod通过 CDP 把动作落到真实 DOM;成功则把结果写回缓存下次可重放(v3.ts:1346起)。若失败且开了selfHeal,重新抓快照再问一次模型,换个 XPath 重试(actHandler.ts:334)。
一个关键认知:V3 是当前架构,understudy 是它的命门。 代码里到处是 v3 前缀;老的 v2 已被取代。整个项目最有价值的设计,都集中在 understudy/(自研 CDP 引擎)+ a11y/snapshot(把网页变成"带 ID 的文本树")。理解这两块,就理解了 Stagehand 为什么"比纯视觉 agent 稳"。
3. 核心机制
3.1 DOM Grounding:把"那个登录按钮"落到真实元素(项目的命门)
它要解决的小问题: 模型说"点登录按钮",可页面上到底哪个 DOM 节点是登录按钮?这一步(grounding,落地)是所有浏览器 agent 最难、最容易碎的地方。
思路/直觉: 大多数视觉系 agent 让模型直接盯着截图输出 (x, y) 坐标——但模型的空间定位并不可靠。Stagehand 反其道而行:
- 先把整页渲染成一棵无障碍树(accessibility tree)文本,每个可交互节点前面挂一个唯一 ID(形如
frameOrdinal-backendNodeId,如1-42)。 - 让模型只从这些 ID 里选一个返回。
- 框架再拿这个 ID 去一张预先建好的
ID→XPath字典里查——查表是确定性的,没有"猜坐标"的误差。
一句话:把"视觉定位"降级成"从带编号的目录里挑一项",难题从模型身上卸到了确定性代码里。
真实实现:
- 快照入口
captureHybridSnapshot(understudy/a11y/snapshot/capture.ts:58)分五步:①可选按 selector 聚焦子树;②每个 CDP session 调一次DOM.getDocument建索引;③逐帧抓 AX 树,得到该帧的相对 XPath/tag/url 映射;④走帧树算出每个 iframe 的绝对 XPath 前缀;⑤mergeFramesIntoSnapshot把所有帧合并成全局的combinedTree文本 +combinedXpathMap(capture.ts:808)。跨 iframe 的元素 ID 也能查到 XPath,是靠给每个子帧的 XPath 加上宿主 iframe 的前缀(capture.ts:833的prefixXPath)。 - observe 里 LLM 返回的
elementId就是这样被翻译成xpath=...选择器的:combinedXpathMap[lookUpIndex]一查即得(observeHandler.ts:154-157)。act 的normalizeActInferenceElement做同样的事(actHandler.ts:467)。 - LLM 侧的返回结构由 zod schema 约束死:
elements[].elementId / method / arguments(packages/core/lib/inference.ts的observe,elementId字段定义在inference.ts:281)。模型被迫"选 ID + 选方法",而不是自由发挥。
关键细节/坑:
- shadow DOM 里的元素查不到 XPath 时,会返回一个
method: "not-supported"的占位,直接被过滤掉(observeHandler.ts:218)——这是它诚实标注"我落不了这个点"的方式。 dragAndDrop特殊:参数里的目标元素也是个 ID,需要二次反查成 XPath(actHandler.ts:475)。
3.2 act 的"两步动作 + 自愈":为什么一次调用可能问模型好几次
它要解决的小问题: 有些"一个动作"其实是两步——比如"从下拉框选『中国』",得先点开下拉、再点选项。而且页面会变,存好的选择器可能失效。
思路/直觉: act 不是"问一次模型、落一个动作"就完。它有两条增强路径:
- 两步(twoStep): 模型在第一步返回时可以说"我还没做完"(
twoStep: true)。框架于是再抓一张快照,和第一张做 diff(diffCombinedTrees)只取变化的部分喂回模型,让它决定第二步(actHandler.ts:198-252)。diff 是为了省 token——下拉框弹出后只有新增的选项是相关的。 - 自愈(self-heal): 落点动作抛错时,若开了
selfHeal,框架重新抓快照、重新问一次模型要新的可交互元素,用新 XPath 但保留原方法/参数重试(actHandler.ts:334-419)。页面小改版导致老选择器失效时,这一招能自动救回来。
真实实现:
- 两步的触发点:
if (actInferenceResponse?.twoStep !== true) return firstResult;(actHandler.ts:198)。 - 第二步用 diff 树:
diffCombinedTrees(combinedTree, combinedTree2),diff 为空则回退用整棵新树避免上下文空掉(actHandler.ts:209-213)。 - 自愈重试沿用原
method+resolvedArgs,只换fallbackAction.selector(actHandler.ts:392-405)。
关键细节/坑: 两步里第二步故意排除了 selectOptionFromDropdown 这个方法(actHandler.ts:220),避免死循环式地一直"再选一次下拉"。
3.3 两种 agent:DOM 工具循环 vs Computer-Use 坐标循环
它要解决的小问题: act 是单步的,但"帮我订张票"是多步任务,需要一个能自己规划、循环执行、知道何时收手的 agent。Stagehand 提供两种风格的 agent,针对不同模型能力。
两种模式对比:
| 维度 | DOM 模式(V3AgentHandler) | CUA 模式(V3CuaAgentHandler) |
|---|---|---|
| 落点方式 | 复用 3.1 的无障碍树 grounding | 纯视觉:模型直接给屏幕 (x, y) |
| 循环驱动 | Vercel AI SDK 的 generateText 工具循环 | 各 provider 客户端自己的 while 循环 |
| 停止条件 | stopWhen: stepCountIs(maxSteps) | while (!completed && currentStep < maxSteps) |
| 工具/动作 | act / extract / goto / scroll / screenshot… | click / type / scroll / screenshot(坐标级) |
| 适用模型 | 任意会 tool-calling 的模型 | 专门的 computer-use 模型(Claude/GPT/Gemini CUA) |
| 代码位置 | handlers/v3AgentHandler.ts:120 | handlers/v3CuaAgentHandler.ts:39 |
DOM 模式怎么转: V3AgentHandler.execute 把一组"工具"(每个工具内部就是调 v3.act / v3.extract 等)交给 AI SDK 的 generateText,由它反复"模型出一个工具调用 → 执行 → 把结果喂回"直到 stepCountIs(maxSteps) 触发或模型调用了 done 工具(v3AgentHandler.ts:458-472)。关键在于:agent 的 act 工具就是重新走一遍 3.1/3.2 的完整 grounding 流程(agent/tools/act.ts:42 直接 await v3.act(...))——所以 DOM agent 天然继 承了缓存和自愈。
CUA 模式怎么转: provider 客户端(如 AnthropicCUAClient)自己跑循环:截图 → 发给 computer-use 模型 → 模型回一批 computer_call(带坐标)→ executeAction 用 CDP 落点 → 再截图进下一轮,while (!completed && currentStep < maxSteps)(agent/AnthropicCUAClient.ts:153)。落点是纯坐标的:page.click(x, y) 最终发 CDP 的 Input.dispatchMouseEvent(understudy/page.ts:1867)。
一个巧妙细节: CUA 在"录制模式"下,会把模型给的坐标点击反解成一个 XPath(page.click(x, y, { returnXpath: true }),v3CuaAgentHandler.ts:342),这样一次视觉驱动的运行也能被存成确定性的 XPath 动作、之后不靠模型重放。视觉与 DOM 两条路在"可复用性"上被统一了。
4. 巧妙之处与局限
值得偷师的设计:
- 用 a11y 树把 grounding 从"视觉猜测"变成"字典查表"。 这是最该学的一点:让模型只从带 ID 的候选集里选,而不是自由生成坐标/选择器。模型的输出空间被压缩到"选一个 ID + 一个方法",既降幻觉又能被 zod schema 校验(
inference.ts:410的 act schema)。 - 不依赖 Playwright,自研 CDP 引擎(understudy)。 直接讲 Chrome DevTools Protocol(
cdp.ts:61的CdpConnection用裸wsWebSocket),换来对多帧、shadow DOM、截图、坐标输入的完全掌控,以及更轻的依赖。截图、 点击、键盘全走 CDP 原语(page.ts:1487screenshot、page.ts:1867mouse、page.ts:2132key)。 - act 缓存 = "第一次用 AI,之后免 AI"。 缓存键是
指令 + 归一化 URL + 变量名集合的哈希(ActCache.ts:57的buildActCacheKey),命中就重放存好的 XPath 动作,把"AI 驱动的探索"沉淀成"确定性的回放",省钱省时(README 说的 "run without LLM inference")。 - extract 的 URL 小魔术。 LLM 直接吐 URL 常出错(截断/编造)。extract 先把 schema 里的
z.string().url()字段换成z.number(),让模型只返回元素的数字 ID,再用combinedUrlMap把真实 URL 注回去(extractHandler.ts:205的transformUrlStringsToNumericIds+extractHandler.ts:247的injectUrls)。同样是"别让模型生成易错内容,让它选 ID"的思想。 - 两条执行路:本地 vs 托管 API。 每个动词内部都有
if (this.apiClient)分支(v3.ts:1242等),可把 act/extract/observe/agent 全部代理到 Browserbase 的托管服务器执行,本地只当薄客户端。
边界与局限(诚实):
- 强依赖无障碍树质量。 若页面 a11y 语义差(纯 canvas、大量无 role 的 div、被 ARIA 隐藏),文本树里就没有好的候选,DOM 模式会退化——这时才需要 CUA 视觉模式兜底。
- shadow DOM 有明确盲区。 查不到 XPath 的 shadow 元素被直接标
not-supported过滤(observeHandler.ts:218),即"我知道我够不着"。 - 缓存对 URL 敏感。 缓存键含归一化 URL(
ActCache.ts:59);URL 里的动态参数会影响命中率,SPA 内部软导航也可能让"同一页"哈希不同。 - 每次 act/observe/extract 都要抓一次全页快照。 多帧 +
DOM.getDocument+ AX 树在大页面上有成本;为此才有focusSelector聚焦子树(capture.ts:161的tryScopedSnapshot)和两步的 diff 树来控 token。 - CUA 模式的可靠性回到"模型给的坐标准不准"。 一旦走视觉路,3.1 的确定性优势就不再适用。
代码地图(导航索引)
| 关注点 | 文件 | 说明 |
|---|---|---|
| 顶层门面 / 四个动词的路由 | packages/core/lib/v3/v3.ts | class V3(导出为 Stagehand);act/extract/observe/agent 方法、缓存判定、init 里装配各 Handler |
| act 主流程 | packages/core/lib/v3/handlers/actHandler.ts | ActHandler.act / takeDeterministicAction / normalizeActInferenceElement;两步、自愈、ID→XPath |
| observe 主流程 | packages/core/lib/v3/handlers/observeHandler.ts | ObserveHandler.observe;LLM 返回 ID → combinedXpathMap 查表 |
| extract 主流程 | packages/core/lib/v3/handlers/extractHandler.ts | ExtractHandler.extract;URL 字段转数字 ID 再回注(transformUrlStringsToNumericIds) |
| grounding 地基:混合快照 | packages/core/lib/v3/understudy/a11y/snapshot/capture.ts | captureHybridSnapshot / mergeFramesIntoSnapshot / prefixXPath;产出 combinedTree + combinedXpathMap |
| 自研 CDP 引擎(连接) | packages/core/lib/v3/understudy/cdp.ts | CdpConnection / CdpSession;裸 WebSocket 讲 DevTools Protocol,无 Playwright |
| CDP 页面原语 | packages/core/lib/v3/understudy/page.ts | Page.click / screenshot / type;Input.dispatchMouseEvent / Input.dispatchKeyEvent |
| DOM 模式 agent | packages/core/lib/v3/handlers/v3AgentHandler.ts | V3AgentHandler.execute;Vercel AI SDK generateText + stopWhen: stepCountIs |
| CUA(视觉)模式 agent | packages/core/lib/v3/handlers/v3CuaAgentHandler.ts | V3CuaAgentHandler.execute / executeAction;坐标动作、录制反解 XPath |
| CUA provider 循环 | packages/core/lib/v3/agent/AnthropicCUAClient.ts | execute 的 while (!completed && currentStep < maxSteps) 截图→动作循环 |
| agent 工具集 | packages/core/lib/v3/agent/tools/index.ts | createAgentTools;act.ts 等工具内部就是回调 v3.act |
| act 缓存 | packages/core/lib/v3/cache/ActCache.ts | ActCache.prepareContext / tryReplay / buildActCacheKey;免模型重放 |
| LLM 推理封装 | packages/core/lib/inference.ts | act / observe / extract;zod schema 约束模型只返回 ID+方法 |
| prompt 组装 | packages/core/lib/prompt.ts | buildActPrompt / buildStepTwoPrompt / buildObserveSystemPrompt |