OpenSider实战:把浏览器变成AI Agent的“手和脚”全解析
做Agent这一年多我踩得最深的坑不是模型选型也不是Prompt调优而是“手和脚”的问题。模型再聪明最后总得有人去点按钮、填表单、翻页面不然它就只能当一个只聊不做的顾问。OpenSider就是为解决这个问题来的它把浏览器变成Agent的驱动入口通过一个浏览器扩展让Agent能真正看见网页、操作网页、读取网页的请求与响应。这篇文章会从项目定位、核心架构、从零跑通Demo的完整过程到我在实际部署中踩过的坑一次性讲清楚。适合正在做AI Agent开发、浏览器自动化或者想给自己的Agent加一个“能上网动手操作”能力的朋友。全文不写空话都是我实际跑过的方案。1. 项目定位与整体设计思路1.1 OpenSider解决的到底是什么问题很多人一说Agent第一反应就是“对话、推理、调用API”但真正把Agent放到真实生产环境里跑过的人都知道Agent的瓶颈往往不在大脑而在手脚。模型能理解“帮我把这个页面上所有商品价格整理成表格”但如果它没法真的去滚动页面、点击加载更多、翻页那这个理解就只能停留在口头上。OpenSider的核心思路是把浏览器本身变成Agent的“外置感知和动作系统”。你不需要给Agent单独做一个爬虫服务也不需要让用户去复制粘贴网页内容浏览器扩展直接就承担了三个工作采集页面信息、执行用户操作、回传操作后的状态。Agent侧只需要维护一个任务循环不断“看一眼页面、决定下一步动作、执行动作、再看结果”整个闭环就跑起来了。这个定位听起来简单实则在架构上绕开了很多传统方案的大坑。比如你用Selenium做网页自动化是让程序去“打开一个浏览器窗口”而OpenSider是直接寄生在用户已经打开的浏览器里用户正在用的页面、已经登录的会话、当前浏览器的Cookie状态全部可以直接复用。这一下就省掉了最头疼的登录态同步问题也让Agent真正变成了“陪你一起上网的助手”而不是“另外一个独立运行的机器人”。1.2 为什么选择浏览器作为Agent的交互层我在做技术选型前其实对比过几条路线RPA界面自动化、纯API对接、以及浏览器扩展方案。RPA最重需要单独装客户端而且对网页结构变化的抵抗力很差页面稍微改个class名脚本就要重写。纯API对接最干净但前提是目标平台得有开放API而在现实场景里大量长尾网站根本没有API甚至有的网站连登录都是扫码验证码。浏览器作为Agent的交互层最大的价值在于“通用性”。它本身就是一个跨平台、跨厂商的“万能客户端”无论目标网站是用React、Vue还是老掉牙的jQuery写的浏览器都能正常渲染Agent也都能通过DOM去解析内容。你不需要关心对方的前端框架是什么只要页面能打开OpenSider就能让Agent去读它、操作它。更关键的是浏览器提供了统一的能力边界标签页管理、网络请求监听、本地存储、调试协议这些都是Web平台标准能力。落在扩展层就意味着Chrome、Edge、Firefox这些主流浏览器都能支持。我在设计时就定了目标扩展不得绑定某一个浏览器厂商的私有能力所有关键模块都走标准API。这样一方面用户不需要专门去下一个冷门浏览器另一方面Windows、macOS、Linux三端也能保持一致的行为。1.3 技术选型wxt TypeScript WebSocket的组合逻辑扩展开发早期我用过原生Manifest V3后来团队规模大了就发现两个问题第一Chrome和Firefox的manifest字段差异虽然不算大但多浏览器构建时要维护的东西很琐碎第二MV3里background变成了Service Worker调试体验和热更新都不太直观。所以OpenSider的扩展端最终选了wxt作为开发框架。wxtWeb Extension Tools是一个基于Vite的扩展开发框架它的好处是约定式目录结构入口文件放在entrypoints目录里框架帮你做多浏览器构建、自动生成不同浏览器对应的manifest、内置HMR热更新。对于团队开发来说最直接的好处是“写一份代码打包成Chrome、Edge、Firefox三个版本”这也是热词搜索里“跨浏览器支持的设计与实现”这个问题的落地答案。通信层我选了最朴素的WebSocket而不是自己发明一套RPC。原因很简单浏览器扩展运行在用户本地Agent服务不管是一段Python脚本还是一个独立的推理服务通常也跑在本地或者局域网内WebSocket在这个场景下够稳定、够简单而且天然支持双向消息推送。Agent要给浏览器下指令浏览器给Agent回传页面状态两个方向都有实时性要求WebSocket比HTTP轮询要自然得多。2. 核心架构与关键模块拆解2.1 扩展层content script与background各管什么浏览器扩展的架构听起来玄拆开其实就两个主要角色content script和backgroundMV3里是Service Worker。这两个角色各司其职搞混了就会出现“页面卡死但扩展没反应”这种莫名其妙的问题。content script是注入在网页上下文里的脚本能直接操作当前页面的DOM也能读取页面的window、document等对象。它最适合干的事是“读取页面信息”和“模拟用户交互”比如提取标题、收集按钮位置、触发点击事件、填写表单。但content script有个隔离限制它虽然能访问DOM但用的是isolated world访问不到页面自己的JavaScript变量除非你主动往页面里注入脚本所以在设计时我只让content script负责“视图层”的事情绝不让它碰Cookie、网络请求这类底层数据。background或Service Worker是扩展的“大脑”它的生命周期不由网页决定而是由浏览器统一调度。OpenSider里扩展与Agent服务的WebSocket连接、标签页管理、消息路由统统放在background里。原因很实际content script会随着页面刷新而销毁如果连接建立在content script里页面一刷新Agent就断线没法看而background是常驻的MV3下尽量用事件驱动保持存活连接稳定性要高很多。2.2 通信层页面、扩展与Agent进程之间的消息通道扩展里真正复杂的不是功能而是消息怎么在三个角色之间转一圈页面里的content script、扩展的background、外部Agent进程。OpenSider把这套通信简化为两层第一层是background与Agent之间的WebSocket长连接。Agent作为客户端连接扩展暴露的本地WebSocket服务双方互相发JSON消息消息里必须带一个id字段用于关联请求和响应。为什么要带id因为Agent下了一条指令后浏览器可能要等几秒钟才执行完如果没有请求idAgent收到响应时根本不知道这个响应对应的是哪条指令。第二层是background与content script之间的chrome.runtime消息。background通过chrome.tabs.sendMessage向指定标签页的content script发指令content script处理完页面操作后再通过sendResponse把结果回传。这一层的关键是“精确指定标签页”一定要先通过chrome.tabs.query拿到当前活动标签页的id否则消息会发到所有标签页去。我在早期版本里就踩过这个坑一次广播导致所有打开的页面都弹了个“提取成功”的提示框用户当场就懵了。2.3 调度层Agent的“观察-思考-操作”闭环是怎么落地的架构层的另外一半是Agent进程内部怎么设计这个循环。我参考了当前主流Agent框架的思路但没有直接用底层库而是自己定义了一套轻量的“观察-思考-操作”协议。每个轮次开始Agent先通过OpenSider发送一个EXTRACT_PAGE_INFO指令拿到当前页面的标题、可见文本截断到5000字符以内、页面里所有链接和按钮的位置信息。然后把这段文本和用户的任务描述一起交给模型让模型决定下一步调哪个工具。模型输出的不是自然语言而是结构化的工具调用比如“click_button(index3)”或“type_input(index1, valuehello)”。这些工具调用会被映射成浏览器操作原语走通信层发到content script执行。执行完以后content script会再抓一次页面状态作为“执行之后的新观察结果”返回给Agent。Agent把新旧状态一对比就知道这个操作是成功了还是没生效然后决定是继续下一步还是调整策略。这个循环跟人上网的思维模式其实一模一样看一眼页面想一下点一下再看一眼。OpenSider实现的不是某个具体业务的自动化而是把“看、想、点”这个通用能力装进了Agent的身体里。3. 实操从零跑通一个OpenSider Demo3.1 环境准备Node.js、Docker Desktop、浏览器的版本要求先把环境说清楚免得新手在第一步就被卡住。扩展端开发需要Node.js 18以上版本我用的是20 LTS包管理器建议用pnpmwxt对它支持最友好。如果你平时习惯npm也没关系只是安装速度和磁盘占用会比pnpm差一些。Agent侧的服务我选择用Python写所以需要Python 3.10以上环境。搜热词时很多人提到“docker desktop for windows”的安装问题这里也提一句如果你希望Agent运行环境跟宿主隔离比如要在里面装各种依赖库建议把Agent服务打成Docker镜像跑。但要注意用Docker跑Agent服务时WebSocket端口一定要映射到宿主机否则扩展根本连不上。我一般在docker run时显式指定-p 8765:8765并且用127.0.0.1绑定避免端口暴露到局域网。浏览器建议准备Chrome或Edge的稳定版Firefox也可以但Firefox的插件签名机制比较麻烦开发调试时需要在about:config里临时关闭签名校验。我的日常开发习惯是Chrome配一个专门负责调试的独立Profile不挂个人常用账号避免扩展权限和插件互相影响。3.2 初始化项目wxt创建扩展的完整过程创建项目非常简单一行命令的事pnpm create wxtlatest opensider-demo cd opensider-demo pnpm install pnpm dev跑完pnpm dev以后wxt会在.tmp目录里生成当前浏览器的dev版本manifest并在浏览器里自动加载扩展前提是你在Chrome的扩展管理页面打开了“开发者模式”。如果没自动加载就复制项目根目录生成的dist文件夹或者说.output/chrome-mv3目录手动在chrome://extensions里“加载已解压的扩展程序”。初始化完成后的目录结构长这样opensider-demo/ ├── entrypoints/ │ ├── background.ts │ ├── content.ts │ └── content-scripts/ // 其他页面脚本 ├── wxt.config.ts ├── package.json └── ...我最开始用这个框架时犯过一个低级错误写完代码刷新扩展不生效后来才发现wxt的HMR对content script支持是“重新加载扩展与刷新页面”双管齐下有时候页面不刷新你就看不到新代码效果。遇到“明明改了代码但行为没变”的情况第一件事就是刷新目标页面第二件事才是去扩展管理页点重新加载。3.3 核心代码content script、background、Agent服务端三段各写什么下面我把Demo最核心的三段代码贴出来代码量不大但每一行都有讲究。第一段是wxt.config.ts把扩展需要的权限声明清楚。这里要特别说下permissions和host_permissions的区别permissions是扩展可调的浏览器API比如tabs、scriptinghost_permissions是扩展可以访问哪些网站。如果host_permissions留空content script就只在你声明的匹配网站上生效其他网站注入不了。// wxt.config.ts import { defineConfig } from wxt; export default defineConfig({ manifest: { name: OpenSider Demo, permissions: [tabs, scripting, storage, activeTab], host_permissions: [all_urls], action: { default_title: OpenSider Demo } } });第二段是content script负责听消息、抓页面信息。这里有两个细节一是chrome.runtime.onMessage监听器必须返回true表示异步响应否则sendResponse会提前执行二是innerText获取的文本量很大一定要做截断常用页面一整页文本可能几十万字符直接全部发给模型token消耗和网络开销都扛不住。// entrypoints/content.ts export default defineContentScript({ matches: [all_urls], main() { chrome.runtime.onMessage.addListener((message, _sender, sendResponse) { if (message.type EXTRACT_PAGE_INFO) { const title document.title; const bodyText document.body?.innerText ?? ; const links Array.from(document.querySelectorAll(a)) .slice(0, 50) .map(a ({ text: a.innerText?.trim()?.slice(0, 50), href: a.href })); sendResponse({ title, // 截断到6000字符避免页面过长时消息体爆炸 text: bodyText.slice(0, 6000), links }); } return true; }); } });第三段是background里的WebSocket服务。注意这里用了chrome.runtime.connectNative或者直接用WebSocket我在生产版本里是直接在background里new WebSocket()去做client因为Agent服务才是server。这里为了Demo简单我让background作为WebSocket客户端连接本地Agent服务。但如果你希望多个Agent进程都能操作同一个浏览器推荐反过来background里起一个WebSocket Server用ws库Agent主动连进来。这样更符合“浏览器是被驱动的执行器”的角色定位。// entrypoints/background.ts import { defineBackground } from wxt; export default defineBackground(() { let socket: WebSocket | null null; let pending new Mapstring, (result: unknown) void(); function connect() { socket new WebSocket(ws://127.0.0.1:8765); socket.onopen () console.log([OpenSider] connected to agent); socket.onmessage (event) { const msg JSON.parse(event.data); if (msg.type NAVIGATE) { chrome.tabs.create({ url: msg.url }).then((tab) { pending.get(msg.id)?.(tab.id); pending.delete(msg.id); }); } else if (msg.type EXTRACT) { chrome.tabs.query({ active: true, currentWindow: true }).then(([tab]) { if (!tab?.id) return; chrome.tabs.sendMessage(tab.id, { type: EXTRACT_PAGE_INFO }, (res) { socket?.send(JSON.stringify({ id: msg.id, result: res })); }); }); } }; socket.onclose () setTimeout(connect, 3000); } connect(); });第四段是Agent侧最简单的Python循环。这里我用了websockets库发两条指令先让浏览器打开MDN的一个API页面再提取页面信息打印出来。import asyncio import json import websockets async def main(): async with websockets.connect(ws://127.0.0.1:8765) as ws: await ws.send(json.dumps({ id: 1, type: NAVIGATE, url: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map })) await asyncio.sleep(3) await ws.send(json.dumps({id: 2, type: EXTRACT})) resp json.loads(await ws.recv()) print(页面标题:, resp[result][title]) print(前500字文本:, resp[result][text][:500]) asyncio.run(main())这里sleep(3)是为了等页面加载完实际生产里不能这么粗暴我在4.3节会讲怎么处理这种动态渲染的等待问题。3.4 跑通一个完整流程让Agent自动查文档并提取用法把上面几段代码跑起来完整链路就是启动Agent服务python agent.py确保浏览器已经打开并且OpenSider扩展已加载然后Agent通过WebSocket发NAVIGATE指令给backgroundbackground新建一个标签页打开MDN页面等待3秒后Agent发EXTRACTbackground把消息转给content scriptcontent script抓取页面信息后原路返回Agent打印出页面标题和正文片段。我第一次跑通这个流程的时候还挺激动的因为它意味着一件很本质的事Agent不再局限于文本输入输出而是有了一个能实时操作网页的“身体”。这个Demo虽然只做“打开页面、提取信息”两件事但你已经可以在这个骨架上扩展出点击、输入、滚动回页面顶部、下载文件等更多能力。如果你想快速验证扩展有没有正常工作可以在background的onMessage里加一行console.log然后去chrome://extensions里打开Service Worker的“检查视图”直接在DevTools里看background日志。这是调试WebSocket连接最直观的方式比在内容脚本里打日志清楚得多。4. 常见问题与排查技巧实录4.1 扩展加载不上、一开浏览器就失效这是被问得最多的一类问题。先分情况如果是在chrome://extensions里“加载已解压的扩展程序”时提示清单文件错误那多半是wxt构建出来的manifest版本和浏览器不兼容或者你在wxt.config.ts里写了不合法的字段。解决方法是看浏览器控制台的具体报错把manifest里对应字段删掉。更麻烦的一类情况是扩展在有的电脑上装完以后一重启浏览器就被禁用。这通常不是扩展代码问题而是浏览器策略限制。热词里“您的浏览器由贵单位管理”说的就是这个——公司或学校通过组策略强制托管了浏览器外部加载的开发者模式扩展会被直接禁用。遇到这种情况要么找管理员把这台机器的开发者模式扩展白名单放开要么换一台非托管环境开发。个人自用电脑很少出现这个问题所以先排查环境再排查代码。另外一个容易被忽略的点Chrome对开发者模式扩展有“加载后不会主动更新”的特性你改了代码重新build但浏览器里还跑着旧版本。最稳妥的办法是在扩展管理页点刷新按钮然后一定要刷新一下目标网页因为content script是页面加载时注入的页面不刷新旧脚本就一直在。4.2 页面数据抓不全webRequest、debugger API怎么选做浏览器Agent免不了要抓接口。wxt生态里有人问“自定义监控浏览器所有请求”怎么做这里统一回答一下如果你只是想看某个页面发了哪些XHR请求chrome.webRequest就能干它在MV3里虽然被限制只能“观察”不能“篡改”但对Agent来说观察就够了拿到URL和响应头能提供大量信息。但如果你需要拿到真实响应体而不是只看URLchrome.webRequest是拿不到的它看不到body。这时候要上chrome.debugger API也就是Chrome DevTools ProtocolCDP。OpenSider在“高级抓包模式”下会用debugger协议给页面挂上Network.enable然后监听Network.responseReceived事件再通过CDP的Network.getResponseBody拿响应内容。用CDP有几个坑要先说清楚第一chrome.debugger一旦attach到标签页该标签页会提示“扩展正在调试此浏览器”用户会有感知第二不能同时对同一个标签页attach两个调试方所以OpenSider在开启CDP模式后会自动释放掉上一个调试会话避免冲突第三CDP监听请求会把内存占用拉得很大一次长会话抓上万个请求很常见所以我在抓包里加了个上限超过5000条就自动丢弃最老的记录防止扩展把浏览器拖垮。4.3 Agent操作与页面异步渲染的竞态问题这是所有浏览器自动化方案都躲不开的经典问题Agent点击了“加载更多”按钮但页面是异步请求数据等接口返回后DOM才更新。如果你的Agent立刻就去读页面读到的还是点击前的状态于是错误地认为“点击没生效”又去点了第二次。解决思路不是加大sleep时间而是做“条件等待”。我在OpenSider里封装了一个waitFor函数它能反复检查某个选择器或者某个文本是否出现超时时间内一直轮询。比如点击完“加载更多”之后Agent会调用waitFor(.product-item, timeout5)来等新的商品条目出现而不是简单睡两秒。这样既能应对慢接口也能在接口真的失败时快速超时报错。还有一个细节页面里经常出现“加载中”的遮罩层或者骨架屏此时DOM已经存在但内容还是空的。条件判断不能只看元素存不存在还要看元素里有没有实际文本。我在waitFor里增加了innerText非空判断宁可多等一秒也不想读到一堆空白骨架。4.4 内存和性能脚本注入多了页面卡顿浏览器扩展最容易被吐槽的问题就是占用高。Edge和Chrome本身内存就不算小扩展里如果再出点幺蛾子用户第一个想到的就是关掉扩展。OpenSider在性能上做过几轮优化几个原则可以分享给所有做扩展的人。原则一能用一次性监听就不要常驻监听。有些事件页需要持续监听但很多页面状态通过一次性查询就能搞定。原则二注入content script的监听器要及时清理尤其是用window.addEventListener挂的scroll和resize事件在页面跳转时一定要remove掉否则会内存泄漏。原则三大对象不要跨消息传递content script抓到的页面文本如果超过几万字符不要直接sendMessage传给background先在content script里做一次摘要或截断减小消息体。热词里“edge浏览器内存占用”提到很多如果你的OpenSider在Edge上跑得很卡先关掉CDP抓包模式看是否好转如果好转问题基本出在Network事件监听上。这类抓包能力一定要做成“按需开启”绝不能不开启就默认挂载。5. 应用场景、安全边界与后续扩展5.1 三种已经跑通的场景巡检、填表、回归测试拿OpenSider做了几个实际项目后我手头有场景已经跑得比较稳定了。第一个是数据巡检。每天早上Agent会打开内部数据后台的若干个报表页逐一刷新提取“今日新增”“异常告警”这些关键字段汇总成日报发到钉钉群。这个场景以前用人来做每天要花20分钟机械地点来点去换成OpenSider以后Agent只需要一个任务描述“检查这5个页面并把异常项提取出来”剩下全是自动的。第二个是表单自动填写。有一次处理一个老外的简历系统没有API只能靠网页上传。Agent读取简历PDF里的关键字段姓名、邮箱、过往经历再根据页面提示逐个填入对应输入框最后检查一遍预览信息没有错位再点提交。这里有个值得说的细节遇到下拉选项时Agent不是一个个去试而是先读出下拉框的所有option文本再让模型从中选一个与简历文本最匹配的。成功率比直接推动端要高不少。第三个是网页自动化回归测试。开发团队每周发版以前要人工把核心流程走一遍现在OpenSider配合断言脚本每轮发版后自动打开关键页面检查标题、操作按钮是否存在、核心文案有没有变。这本质上就是让Agent自己当测试员效率提升非常明显。5.2 如何接入自己的Agent框架把它当成一个skill我注意到很多人分不清skill和agent的区别也有人在纠结harness和agent的区别。我自己的理解是Agent是决策者负责判断做什么skill是能力包负责把“怎么做”具体化harness是执行环境负责给Agent提供工具和运行时。OpenSider的角色其实是“浏览器harness”它给Agent提供了一组与网页交互的工具。按这个思路接入自己的Agent框架就很简单不管你是用LangChain、通义、Lepton还是自己撸的循环只要给模型增加一个新的工具定义名字叫browser操作相关的工具组工具参数是“操作类型目标定位输入内容”然后把OpenSider的WebSocket协议暴露成工具函数Agent就能用了。这也是openai function calling或者各大模型工具调用标准落地时最自然的方式。5.3 安全边界权限最小化、操作确认、凭证隔离让浏览器Agent走向生产首先要过安全这道坎。我的原则很简单权限最小化敏感操作做确认。OpenSider在manifest里默认只申请tabs、scripting、storage、activeTab坚决不申请bookmarks、history这些与核心功能无关的权限。host_permissions虽然开发阶段用了all_urls但发布版会改成用户主动添加的网站白名单。每一次Agent要执行“提交表单”“删除数据”“转账”这类高风险操作时扩展会先在页面顶部弹一个醒目的确认条等用户点“允许”后再执行。这样做的代价是自动化程度降低但换来了用户的可控感非常值。凭证隔离这块也要提Agent如果要填写的表单里有账号密码不要把密码明文藏在任务描述里更不要写进content script的日志。我一般让Agent从本机安全存储里按key读取或者启动时通过环境变量注入任务闭环结束后立即清空。浏览器扩展虽然能操作页面但它不应该成为新的凭证泄露口。5.4 Agent记忆把历史操作沉淀成可复用的任务模板热词里agent记忆也是个高频话题。在OpenSider里我实现了一个非常朴素的“任务记忆”机制每次Agent成功跑完一个任务后把当时的操作序列导航到哪里、点击了什么元素、输入了什么文本压缩成一份模板存到扩展本地storage里。下次接到类似任务时Agent先查模板库能匹配就直接复用匹配不到再一步步自学。这个机制的好处是越用越聪明坏处是模板也可能过时——网页改版了原来的选择器就失效了。所以我在模板里存的不是硬编码的CSS选择器而是“文本标签位置提示”的组合描述比如“包含‘提交’文字的button靠近页面上方”。这样即使class变了Agent还能靠语义信息定位到目标。等模板积累多了再考虑用定期向量化去重。我个人现在的做法是每跑完一个新场景就手动标一下“这个模板质量高/低”质量高的才进公共库质量低的只保留在个人库。积累两个多月以后常见任务的自动化成功率已经能稳定在九成以上。这个经验可供大家参考浏览器Agent本身是一个执行框架真正让它产生长期价值的一定是围绕它沉淀下来的任务资产。技术和经验攒够了后面不管是接更多Agent框架还是扩展更多浏览器能力都是在打好的地基上继续盖楼。