一、从一个问题说起:为什么需要"页面内"的 AI Agent?
过去两年,浏览器自动化领域热闹非凡。browser-use、Playwright MCP、各类 Headless 方案层出不穷,但它们都有一个共同特征------需要一个"外部大脑":Python 后端、无头浏览器实例、或浏览器扩展的特殊权限。
阿里巴巴开源的 PageAgent 提出了一个极为简洁的逆向思路:不从外部操控浏览器,让 AI Agent 直接"住在"网页里。 一行 <script> 标签,Agent 就在当前页面的 JavaScript 上下文中运行------不要 Python,不要无头浏览器,不要截图和多模态模型,甚至不要浏览器扩展。
下面这张图能直观地感受到区别:
xml
┌─────────────────────────────────────────────────────────────────┐
│ 传统方案 vs PageAgent │
├─────────────────────────────┬───────────────────────────────────┤
│ browser-use / Playwright │ PageAgent │
│ │ │
│ ┌───────────┐ │ ┌─────────────────────────────┐ │
│ │ Python │──WebSocket──▶│ │ 你的网页 │ │
│ │ 后端服务 │ / CDP │ │ ┌─────────────────────┐ │ │
│ └───────────┘ │ │ │ PageAgent (JS) │ │ │
│ │ │ │ │ ┌───────┐ ┌──────┐ │ │ │
│ ▼ │ │ │ │ Agent │→│ DOM │ │ │ │
│ ┌───────────┐ │ │ │ │ 循环 │ │ 操控 │ │ │ │
│ │ Headless │ │ │ │ └───┬───┘ └──────┘ │ │ │
│ │ Browser │ │ │ │ │ ↕ LLM API │ │ │
│ └───────────┘ │ │ └──────┼──────────────┘ │ │
│ │ └─────────┼───────────────────┘ │
│ 需要: Python + 无头浏览器 │ 只需: 一行 <script> 标签 │
└─────────────────────────────┴───────────────────────────────────┘
这篇文章将从最简单的用法出发,逐层深入到源码架构的核心设计,配有丰富示例和图解,帮你完整理解 PageAgent 的工作原理。
二、实战示例:从入门到高级
🟢 入门级:一行代码,5 秒体验
如果你只想快速感受效果,把下面这行代码贴到任意网页的控制台或 HTML 里:
html
<script src="https://cdn.jsdelivr.net/npm/page-agent@1.5.9/dist/iife/page-agent.demo.js" crossorigin="true"></script>
页面右下角会出现一个对话面板,输入自然语言指令即可操作页面。这个 Demo CDN 自带免费测试 LLM,开箱即用。
🟡 进阶级:NPM 集成 + 自选模型
实际项目中,你需要接入自己的 LLM:
js
import { PageAgent } from 'page-agent'
const agent = new PageAgent({
model: 'qwen3.5-plus',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: 'YOUR_API_KEY',
language: 'zh-CN',
})
// 方式一:程序化执行
const result = await agent.execute('在搜索框输入 "iPhone 16",然后点击搜索按钮')
console.log(result.success) // true / false
console.log(result.data) // Agent 的执行总结
// 方式二:弹出对话面板,让用户自行输入
agent.panel.show()
支持的模型非常丰富------OpenAI GPT 系列、Claude、Qwen、DeepSeek、Gemini、Grok、MiniMax、Kimi、GLM,甚至通过 Ollama 本地部署的开源模型都可以。只要兼容 OpenAI 的 /chat/completions 接口即可。
🟡 进阶级:知识注入------让 AI 懂你的业务
裸用 Agent 时,它只知道页面上有什么元素,但不了解你的业务规则。通过 instructions 你可以注入领域知识:
js
const agent = new PageAgent({
// ...LLM config
instructions: {
// 全局指令:所有页面生效
system: `
你是一个专业的电商运营助手。
规则:
- 提交订单前必须先确认价格和数量
- 遇到错误时立即停止,不要盲目重试
- 优先使用筛选器缩小搜索范围
`,
// 页面级指令:根据 URL 动态返回
getPageInstructions: (url) => {
if (url.includes('/checkout')) {
return '这是结算页面。请先核对收货地址,再检查是否有优惠券可用。'
}
if (url.includes('/products')) {
return '这是商品列表页。先使用左侧筛选器缩小范围,再帮用户选择商品。'
}
return undefined
}
}
})
指令的工作方式如下图所示:
xml
每一步执行前,prompt 的组装结构:
┌────────────────────────────────────────┐
│ <instructions> │
│ <system_instructions> │
│ 你是电商运营助手... │
│ </system_instructions> │
│ <page_instructions> │ ← 仅当 URL 匹配时才出现
│ 这是结算页面... │
│ </page_instructions> │
│ </instructions> │
│ │
│ <agent_state> │
│ 用户请求 + 步数信息 │
│ </agent_state> │
│ │
│ <agent_history> │
│ 之前每步的反思 + 动作结果 │
│ </agent_history> │
│ │
│ <browser_state> │
│ 当前页面 URL、可交互元素、滚动位置 │
│ </browser_state> │
└────────────────────────────────────────┘
🟡 进阶级:数据脱敏------敏感信息不出页面
在把页面内容发送给 LLM 之前,transformPageContent 钩子允许你过滤敏感数据:
js
const agent = new PageAgent({
// ...LLM config
transformPageContent: async (content) => {
// 手机号脱敏:138****1234
content = content.replace(/\b(1[3-9]\d)(\d{4})(\d{4})\b/g, '$1****$3')
// 邮箱脱敏
content = content.replace(
/\b([a-zA-Z0-9._%+-])[^@]*(@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/g,
'$1***$2'
)
// 银行卡号脱敏
content = content.replace(/\b(\d{4})\d{8,11}(\d{4})\b/g, '$1********$2')
return content
}
})
LLM 看到的是脱敏后的内容,但页面上的真实数据不受影响,Agent 的操作仍然作用于原始 DOM 元素。
🔴 高级:自定义工具------给 AI 接上后端 API
内置工具只能操作 DOM,但通过 customTools 你可以让 Agent 调用任意业务接口:
js
import { z } from 'zod/v4'
import { PageAgent, tool } from 'page-agent'
const agent = new PageAgent({
// ...LLM config
customTools: {
// 添加购物车工具:AI 可以直接调 API 而非点按钮
add_to_cart: tool({
description: '通过商品 ID 添加到购物车',
inputSchema: z.object({
productId: z.string(),
quantity: z.number().min(1).default(1),
}),
execute: async function (input) {
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify(input),
})
return `✅ 已添加 ${input.quantity} 件 ${input.productId} 到购物车`
},
}),
// 搜索知识库工具:让 AI 先查资料再操作
search_kb: tool({
description: '搜索内部知识库',
inputSchema: z.object({
query: z.string(),
limit: z.number().max(10).default(3),
}),
execute: async function (input) {
const res = await fetch(`/api/kb?q=${encodeURIComponent(input.query)}&limit=${input.limit}`)
return JSON.stringify(await res.json())
},
}),
// 移除内置工具:比如禁止 AI 向用户提问
ask_user: null,
},
})
🔴 高级:完全自定义 UI(React 示例)
不想用内置面板?核心逻辑和 UI 完全解耦,你可以用 React/Vue/任何框架搭建自己的界面:
jsx
import { PageAgentCore } from '@page-agent/core'
import { PageController } from '@page-agent/page-controller'
import { useState, useEffect } from 'react'
// 1. 自定义 React Hook 监听 Agent 事件
function useAgent(agent) {
const [status, setStatus] = useState(agent.status)
const [history, setHistory] = useState(agent.history)
const [activity, setActivity] = useState(null)
useEffect(() => {
const onStatus = () => setStatus(agent.status)
const onHistory = () => setHistory([...agent.history])
const onActivity = (e) => setActivity(e.detail)
agent.addEventListener('statuschange', onStatus)
agent.addEventListener('historychange', onHistory)
agent.addEventListener('activity', onActivity)
return () => {
agent.removeEventListener('statuschange', onStatus)
agent.removeEventListener('historychange', onHistory)
agent.removeEventListener('activity', onActivity)
}
}, [agent])
return { status, history, activity }
}
// 2. 创建无 UI 的 Core Agent
const agent = new PageAgentCore({
pageController: new PageController({ enableMask: true }),
baseURL: 'https://api.openai.com/v1',
apiKey: 'your-key',
model: 'gpt-5.1',
})
// 3. 你的自定义 UI 组件
function MyAgentPanel() {
const { status, history, activity } = useAgent(agent)
return (
<div className="my-agent-ui">
<div>状态: {status}</div>
{activity?.type === 'thinking' && <div>🧠 思考中...</div>}
{activity?.type === 'executing' && <div>⚡ 执行: {activity.tool}</div>}
{history.filter(e => e.type === 'step').map((step, i) => (
<div key={i}>步骤 {i+1}: {step.action.name} → {step.action.output}</div>
))}
</div>
)
}
🔴 高级:对接外部 Agent 系统
把 PageAgent 作为工具注册到你现有的 AI 客服/助手系统中:
js
// 你的主 Agent 系统中
const pageAgentTool = {
name: 'operate_webpage',
description: '在当前网页上执行操作,如点击、填写表单、查询信息',
parameters: {
type: 'object',
properties: {
instruction: { type: 'string', description: '操作指令' }
},
required: ['instruction']
},
execute: async (params) => {
const result = await pageAgent.execute(params.instruction)
return { success: result.success, message: result.data }
}
}
// 注册到你的 Agent 框架...
这样你的客服机器人就不再只会说"请点击左上角的设置按钮",而是直接帮用户操作。
三、Monorepo 架构全景图
PageAgent 采用 monorepo 结构,packages/ 下 7 个子包分层清晰:
javascript
┌─────────────────────────────────────────────────────────────┐
│ 用户代码 │
│ import { PageAgent } from 'page-agent' │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 📦 page-agent (门面层,28行代码) │
│ 组装 Core + PageController + UI Panel │
└───────┬───────────────────┬──────────────────┬──────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────────┐ ┌────────────┐
│ 📦 core │ │ 📦 page- │ │ 📦 ui │
│ Agent 循环 │ │ controller │ │ 交互面板 │
│ 提示词工程 │ │ DOM 提取与简化 │ │ │
│ 工具系统 │ │ 元素动作模拟 │ │ │
│ AutoFixer │ │ 遮罩层管理 │ │ │
└──────┬───────┘ └──────────────────┘ └────────────┘
│
▼
┌──────────────┐
│ 📦 llms │ 📦 extension (可选)
│ OpenAI 协议 │ Chrome 扩展,多标签页
│ 模型补丁 │
│ 重试机制 │ 📦 website
└──────────────┘ 官方文档站
核心设计原则:core 不依赖 ui,page-controller 不依赖 core,任何一层都可以独立替换。想换 UI?用 PageAgentCore 监听事件自己画。想换 DOM 操作方式?实现 PageController 接口即可。
四、核心引擎:Re-Act Agent 循环
PageAgent 的灵魂在 PageAgentCore 类中。它实现了经典的 Re-Act(Reasoning + Acting)循环。
4.1 一次任务的完整生命周期
arduino
agent.execute("填写上周五出差的报销单")
│
▼
┌─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
│ while (step < maxSteps) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 1.Observe│───▶│ 2.Think │───▶│ 3.Act │──┐ │
│ │ 观察页面 │ │ LLM 推理 │ │ 执行动作 │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │ │
│ ▲ │ │
│ │ ┌──────────┐ │ │
│ └───────────│ 4.Record │◀────────────────┘ │
│ │ 记录历史 │ │
│ └──────────┘ │
│ │ │
│ action == 'done'? │
│ ├── Yes → 返回结果 │
│ └── No → 继续循环 │
└─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
第一阶段 Observe :PageController.getBrowserState() 扫描 DOM 树,提取所有可交互元素并编号索引,输出一份 LLM 可读的简化文本。同时进行环境感知------URL 是否变化?累计等待时间是否过长?剩余步数是否告急?这些观察被推入历史流。
第二阶段 Think :系统提示词 + 用户提示词 + 浏览器状态 + 完整历史事件被一起发送给 LLM。这里有一个核心设计------MacroTool(详见下节)。
第三阶段 Act:从 LLM 输出中解析出动作名和参数,通过 PageController 在页面上执行真实的 DOM 操作。
第四阶段 Record :执行结果、LLM 的反思内容、token 用量等打包成 AgentStepEvent 推入历史数组,下一轮循环时回传给 LLM 形成连续记忆。
循环终止条件有三个:LLM 调用 done(任务完成)、步数超过 maxSteps(默认 40)、或不可恢复错误。
4.2 MacroTool:强制"先想后做"
传统方案让 LLM 从多个工具中自由选择。PageAgent 走了一条不同的路------把所有工具合并 成一个叫 AgentOutput 的巨型工具:
css
┌────────────────────────────────────────────────┐
│ MacroTool: AgentOutput │
│ │
│ { │
│ evaluation_previous_goal: "上一步成功了...", │ ← 反思
│ memory: "已找到搜索框,index=5...", │ ← 记忆
│ next_goal: "在搜索框输入关键词", │ ← 规划
│ action: { │
│ input_text: { index: 5, text: "iPhone" } │ ← 动作
│ } ▲ │
│ } │ │
│ │ │
│ action 字段是所有内置工具的联合类型: │
│ click_element_by_index | input_text | │
│ scroll | select_dropdown_option | │
│ wait | done | ask_user | ... │
└────────────────────────────────────────────────┘
源码中用 Zod 的 z.union 将所有工具的 inputSchema 合并成 action 字段的类型。LLM 每次调用 AgentOutput 时,必须同时输出反思和具体动作。这种设计大幅减少了"冲动行为"------Agent 不会跳过思考直接行动。
4.3 两条事件流:记忆 vs 反馈
scss
┌───────────────────────────────────────────────────────┐
│ PageAgentCore │
│ │
│ Historical Events Activity Events │
│ (historychange) (activity) │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ step │ │ thinking │ │
│ │ observation │ │ executing │ │
│ │ user_takeover│ │ executed │ │
│ │ retry │ │ retrying │ │
│ │ error │ │ error │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ 持久化 │ 传给 LLM 瞬态 │ 仅 UI 用 │
│ ▼ ▼ │
│ agent.history[] UI 状态动画/loading │
└───────────────────────────────────────────────────────┘
History Events 构成 Agent 的"记忆",每轮都发送给 LLM。Activity Events 是瞬态 UI 反馈("正在思考"/"正在点击按钮"),不进入 LLM 上下文。这种分离保证了 LLM 的上下文始终干净。
五、DOM 翻译官:不靠截图的页面理解
5.1 纯文本路线 vs 截图路线
css
截图路线 (Claude Computer Use 等) 文本路线 (PageAgent)
页面 → 截图 → 多模态LLM 页面 → DOM树 → 简化文本 → 文本LLM
✓ 能看到图片/Canvas ✗ 看不到图片/Canvas
✗ 需要多模态模型 ✓ 普通文本模型即可
✗ 截图=更多token≈更贵 ✓ token 更少更便宜
✗ 需要特殊权限 ✓ 零权限
对于大多数 SaaS 后台、表单填写、数据录入场景,文本路线是极为务实的选择。
5.2 DOM 提取:从真实页面到 LLM 可读文本
PageController.getBrowserState() 是整条链路的入口。它的内部流程:
ini
真实 DOM 树
│
▼ getFlatTree()
遍历 DOM,识别可交互元素,分配数字索引
标记新出现的元素 (WeakMap 缓存)
│
▼ flatTreeToString()
转换为 LLM 友好的文本格式
│
▼ 组装 BrowserState
header: "Current Page: [商品列表](https://...)
Page info: 1920x1080px viewport...
[Start of page]"
content: "[0]<a aria-label=首页 />
[1]<input placeholder=搜索商品... />
[2]<button>搜索</button>
今日推荐
*[3]<div>iPhone 16 Pro ¥7999</div> ← * 号表示新出现
*[4]<button>加入购物车</button>
[5]<select>选择颜色</select>"
footer: "... 1200 pixels below (2.3 pages) - scroll to see more ..."
flatTreeToString 做了大量优化细节:去除重复属性(aria-label 与文本内容相同时只保留一个)、截断过长属性、标注可滚动容器的滚动距离、缩进表示 DOM 层级关系。
5.3 动作模拟:为什么不用 .click()?
简单调用 element.click() 在很多前端框架中不能正确触发事件。PageAgent 的 clickElement 模拟了完整的用户行为链:
scss
clickElement(element) 的执行序列:
scrollIntoView ← 确保元素可见
↓
movePointerTo ← 移动指针到元素中心(触发UI动画)
↓
mouseenter ← 模拟鼠标进入
mouseover
↓
mousedown ← 模拟按下
↓
focus ← 聚焦(确保 React 等框架的事件能触发)
↓
mouseup ← 模拟释放
↓
click ← 最终点击事件
文本输入更复杂------对 contenteditable 富文本编辑器,按顺序派发 beforeinput(清空)→ 修改 innerText → 派发 input(插入),以兼容 React 受控组件和 Quill 等编辑器。对普通 input/textarea,则使用原生 value setter 绕过框架拦截,再手动触发 input 事件。
六、LLM 层:兼容万家,容错为先
6.1 OpenAI 兼容协议统一天下
@page-agent/llms 没引入任何 LLM SDK,直接用 fetch 调 /chat/completions 接口。如今几乎所有主流模型商都支持这套协议,因此 PageAgent 天然兼容数十种模型。
6.2 模型补丁:实战踩坑的结晶
源码中的 modelPatch 函数根据模型名称动态调整请求参数:
ini
模型 补丁内容
─────────────────────────────────────────────────
Qwen 系列 → temperature ≥ 1.0,关闭 thinking
Claude 系列 → tool_choice 格式转换为 Claude 风格
Grok 系列 → 删除 tool_choice,禁用 reasoning
GPT-5 系列 → reasoning_effort = 'low'
GPT-5-mini → reasoning_effort = 'low', temperature = 1
Gemini 系列 → reasoning_effort = 'minimal'
MiniMax 系列 → temperature 钳位到 (0, 1],删除 parallel_tool_calls
这些全是真实环境下踩坑后的总结,对多模型兼容开发极有参考价值。
6.3 AutoFixer:当 LLM 不守规矩时
不同 LLM 的输出格式千差万别。normalizeResponse 穷举了各种异常并逐一修复:
css
LLM 的常见"不规矩"输出 AutoFixer 的修复
把 JSON 放在 content 里 → 提取 JSON,包装成 tool_calls
而不是 tool_calls
返回动作层级而非 → 包装一层 { action: ... }
AgentOutput 完整结构
双重 JSON 字符串化 → 递归 JSON.parse
"{ \"action\": \"...\" }"
原始值输入 → 根据 Zod schema 推断字段名
{ click_element_by_index: 2 } → { click_element_by_index: { index: 2 } }
content 里还套了一层 → 解析嵌套的 function 结构
function wrapper
这套容错机制是 PageAgent 能稳定兼容这么多模型的关键原因之一。
七、提示词工程:Agent 的"岗位说明书"
系统提示词(system_prompt.md)详细规定了 Agent 的输入格式、行为准则和能力边界。几个值得注意的设计:
输入格式约定 :交互元素的格式是 [index]<type>text</type>,只有带数字索引的元素才可操作。新出现的元素用 *[ 标记。缩进表示 DOM 层级。
行为规则亮点:不要重复同一动作超过 3 次;输入文本后如果被中断,很可能弹出了建议列表(要去选择);遇到验证码告知用户无法解决;区分"精确步骤"和"开放式任务"两种模式。
"示弱"设计------这是最有意思的部分:明确告知 LLM "可以失败"、"用户可能是错的"、"网页可能有 bug"、"过度尝试可能有害"。避免 Agent 在无法完成任务时陷入无意义的死循环。
八、生命周期钩子:完整的可观测性
arduino
onBeforeTask ──▶ ┌───────────────────────────────┐
│ onBeforeStep ──▶ step ──▶ onAfterStep │ × N 步
└───────────────────────────────┘
onAfterTask ◀── 返回 ExecutionResult { success, data, history }
onDispose ◀── agent.dispose()
配合 transformPageContent(数据脱敏)和 customSystemPrompt(完全自定义提示词),开发者拥有对 Agent 行为的完全控制权。
九、使用限制:诚实面对能力边界
PageAgent 选择了"纯文本 DOM"路线,这意味着:
能做的:点击、文本输入、下拉选择、表单提交、页面滚动、焦点切换、执行 JavaScript。
做不到的:悬停(hover)、拖拽、右键菜单、键盘快捷键、坐标定位操作、图片/Canvas/WebGL/SVG 等视觉内容识别、Monaco/CodeMirror 等特殊编辑器。
语义化的 HTML 和良好的可访问性(ARIA 标签等)会显著提升 Agent 效果。反常识的交互逻辑、纯视觉的操作提示则会降低成功率。
十、总结:一个务实的工程决策
通读源码后,PageAgent 的核心设计哲学可以归纳为三个词:
务实------纯文本 DOM 而非截图,牺牲视觉理解换来对普通模型的兼容性和更低的成本。MacroTool 强制"先想后做",在可控性和灵活性之间找到平衡。
容错------从 AutoFixer 对畸形输出的修复,到 modelPatch 对不同模型的适配,到提示词中鼓励"可以失败",整个系统对不确定性有很高包容度。
解耦------Core、PageController、UI、LLMs 四层分明,任何一层可独立替换。你可以只用 Core 做无头自动化,也可以换上自己的 React UI,还可以把它嵌入你现有的 Agent 系统作为"手和眼"。
对于 SaaS 开发者想快速给产品加 AI Copilot、企业想做管理后台的智能化改造、或者无障碍增强场景,PageAgent 提供了目前门槛最低的入口------一行 <script> 标签,你的网页就有了一个 AI 操作员。