一个求职 Agent 如何真正操控云端浏览器:从一句话到屏幕画面的完整链路
一个求职 Agent 如何真正操控云端浏览器:从一句话到屏幕画面的完整链路
这是我和一个做 Agent 的朋友的聊天实录。他把问题拆成四层往死里问,我把它整理成了这一篇。红字补齐的部分是当时没答完、事后补的。
问:你这个求职 Agent 是不是也用 Playwright?
是,Playwright 在整条浏览器控制链路的正中间。但如果你只记这一句,后面一定会懵。项目里的 PlaywrightBrowser 不是本地起一个浏览器,而是先启动 Playwright 客户端,再用 chromium.connect_over_cdp() 连上已经跑在 E2B 沙箱里的那个 Chrome。它接管的是一个远程、有界面、用户也能看到的浏览器。
问:接管和启动有什么区别?
区别很大。「Playwright 启动浏览器」是说浏览器由你的测试脚本在本机或 CI 里创建,脚本死了浏览器就没了。「Playwright 通过 CDP 接管浏览器」是说浏览器可以跑在另一台机器甚至云端,你这边只要拿到一个 DevTools 的 WebSocket 地址就能操控它。我这个项目是第二种。
这也顺带解释了为什么 Playwright 一个人干不完这活。它能点击、输入、滚动、扒 DOM,但它不知道用户到底想干嘛,也不会把一整个 Linux 桌面直播到网页上去。
问:那整套东西到底是怎么串起来的?
关键是别把它想成一条线,它是两条同时跑的通道,最后指向同一个 Chrome。
一条是控制通道,管「机器怎么动手」:
1 | 用户消息 → Chat API → PlannerAgent → ReActAgent → BrowserTool → Playwright → CDP → Chrome |
这条通道上传的是导航、点击、输入、滚动、按键这些语义动作。
另一条是观看通道,管「人怎么看到结果」:
1 | Chrome 桌面 → Xvfb/XFCE → x11vnc → noVNC WebSocket → 后端 VNC 代理 → 前端 RFB 组件 → 用户屏幕 |
这条通道传的是桌面图像,以及鼠标键盘对应的 RFB 消息。
两条通道指向同一个 Chrome:Agent 从 CDP 这边控制它,用户从 VNC 这边看它。这就是「Agent 自动执行」和「用户随时接管」能同时成立的原因。CDP 不是远程桌面,VNC 也不是浏览器自动化框架,一个管结构化控制,一个管画面和人机交互。把这两个混成一个概念,就会出现那个经典困惑:明明 Playwright 点击成功了,前端为什么还是白的。
问:好,先说控制通道。我发一句话,它怎么就到 Agent 了?
前端发消息进的是 /sessions/{session_id}/chat。这个接口不是等任务全干完再一次返回,而是开一条 SSE 流。
因为一次 Agent 任务里会持续冒事件:Planner 建了什么计划、现在跑到第几步、ReAct 调了哪个工具、工具返回了什么、最后的总结是什么。后端每产生一个事件就往 SSE 里推,前端于是看到的不是转圈圈的加载框,是一条边跑边长的时间线。
请求到 AgentService 之后,系统先把当前会话对应的沙箱、浏览器、LLM、工具集准备好,再交给 PlannerReActFlow。这里注意一点:用户那句话不会被直接翻译成一次点击。中间至少要先解决两件事,第一是目标是什么(完成某个岗位的申请),第二是下一步该干嘛(先打开招聘网站,而不是上来找提交按钮)。
问:为什么要拆 Planner 和 ReAct 两个 Agent?一个从头想到尾不行吗?
PlannerAgent 只负责把目标拆成计划:打开招聘网站、搜索岗位、进详情、开始申请、填资料、碰到登录或验证码请求用户接管、提交前等确认。
ReActAgent 负责执行当前这一步,循环是「观察页面 → 判断下一动作 → 调用工具 → 读工具结果 → 再判断」。
为什么不干脆让 Planner 一次性生成一串固定点击?因为网页是有状态的。登录弹窗可能出现也可能不出现,按钮文字可能变,某一步做完页面可能跳转也可能因为校验失败停在原地。计划应该描述「要达到什么状态」,执行器再根据当前页面选动作。这样页面小改一下,不用推翻整份计划。
PlannerReActFlow 用状态机把两个接起来:规划完进执行,执行一步回到更新计划,再继续下一步,没步骤了就进总结。
问:ReAct 它怎么真的点到东西?直接写 Python 吗?
不直接写。中间有一层 BrowserTool,把浏览器能力收敛成一组明确动作:看当前页面、导航到 URL、按索引或坐标点击、往输入框填文字、按键、选下拉、上下滚动、重启浏览器。
这层看着像包装,实际解决三件事。第一,限制了动作空间,模型不用在几百个 Playwright API 里猜,只从这几个业务动作里选。第二,把模型参数翻译成稳定的程序接口,比如模型说「点第 3 个可交互元素」,BrowserTool 把索引交给 PlaywrightBrowser。第三,它形成可记录的工具事件,前端能显示「Agent 调用了 browser_click」,不用懂底下的 Playwright 对象。所以它更像一份 Agent 和浏览器适配器之间的协议,不是单纯一堆工具函数。
问:ReAct 怎么「看懂」网页的?靠截图吗?
不是。只给模型一张截图,它得先认字、判断控件、估坐标,贵而且受分辨率影响。把整份 HTML 塞进去又全是样式、脚本和看不见的节点。我走的是以 DOM 语义为主的路线。
PlaywrightBrowser 往页面里注入 JS,抓出可见内容和可交互元素,把 HTML 转成更紧凑的 Markdown。可交互元素会被组织成带索引的信息,Agent 先跑一下 browser_view,拿到「页面上有哪些输入框和按钮」,再用索引点或填。比如页面观察结果是 [0] 手机号输入框、[1] 验证码输入框、[2] 登录按钮,Agent 就能调「往索引 0 填手机号」,而不去猜 (356, 421) 这个坐标。
坐标我也留着当补充,Canvas、地图或者缺少稳定 DOM 的界面只能靠视觉位置点。但默认走语义索引,页面稍微挪一下也稳。这点和 browser-use 的思路像,都是先把网页转成模型好理解的结构再让它决策。不一样的是我保留了自己的 Planner/ReAct 编排和 BrowserTool 协议,PlaywrightBrowser 只是底层的浏览器适配器,没有整体套用 browser-use 那套 Agent 框架。
问:然后就是最玄的一段,Playwright 在自己机器上,Chrome 在云里,怎么接上?
E2B 起一个云端沙箱,沙箱启动时项目按顺序拉起这些:Xvfb 提供虚拟显示器、XFCE 提供桌面环境、x11vnc 把虚拟桌面变成 VNC 服务、noVNC/websockify 把 VNC 包成浏览器能连的 WebSocket、有头模式的 Chrome 并开放 9222 的 CDP 端口。
Chrome 必须是有头的,不能 headless。因为产品要求用户登录、过验证码、确认提交的时候,能看到 Agent 正在操作的同一个窗口。
API 服务这边先访问 Chrome 的 /json/version,读出 webSocketDebuggerUrl,把这个地址交给 Playwright 的 connect_over_cdp()。
这里我踩过一个实坑:E2B 公网入口用的是域名,但 Chromium 的 DevTools HTTP 服务默认只认 IP 或 localhost 形式的 Host。最后用 E2B 的 mask_request_host,让边缘代理把 Host 改成 localhost:${PORT}。但这又带来副作用:Chrome 返回的 WebSocket 地址会写成 ws://localhost:9222/devtools/browser/...,原样交给 Playwright,它会去连 API 服务器自己的 9222。所以适配器还得做一次地址重写,保留 /devtools/browser/... 这段路径,把主机换成 E2B 暴露的公网主机,协议改成 wss://。
一句话,CDP 接入不只是「拿到 9222 端口」,还包括 Host 校验、WebSocket Origin、外部地址重写和就绪探测。
问:行,那一次点击,从头到尾到底发生了什么?
假设 ReActAgent 决定点页面上第 4 个按钮:
- ReActAgent 生成
browser_click(index=4)工具调用; - BaseAgent 按工具名找到 BrowserTool;
- BrowserTool 调
PlaywrightBrowser.click(); - PlaywrightBrowser 确认 CDP 连接和当前页面还有效;
- 它重新抓可交互元素,找到索引 4 对应的真实 DOM 节点;
- Playwright 等元素可操作,执行 click;
- 页面 JS 收到真实的浏览器事件,可能触发跳转或接口请求;
- ToolResult 返回成功或错误;
- ReActAgent 再看一眼页面,判断目标状态到了没有;
- 工具事件通过 SSE 回到前端时间线。
第 9 步很重要。一次 API 没抛异常不等于业务目标达成。按钮可能被挡住,页面可能弹出校验错误,也可能点完还停在原页。所以成熟的自动化必须把「执行动作」和「验证结果」分开。
问:控制讲完了,换观看。沙箱的画面怎么回到我屏幕上?
Playwright 控制页面的同时,Xvfb 里一直存在一个真实桌面。x11vnc 读这个桌面的帧缓冲,noVNC 的 websockify 再把原始 VNC/RFB 数据转成 WebSocket。
前端不会直接连 E2B 地址,连的是本站的 /sessions/{session_id}/vnc WebSocket。后端按会话找到对应沙箱的 noVNC WebSocket 地址,然后开两条转发协程:前端发来的 RFB 消息转去沙箱,沙箱返回的画面更新转给前端。前端的 VNCViewer 用 @novnc/novnc 的 RFB 客户端,把收到的帧画进页面的容器里。
所以你看到的不是 Playwright 截图轮播,是一个正在跑的远程桌面。这条链压成一句:
1 | E2B 桌面 → x11vnc → websockify → 后端 WebSocket 代理 → noVNC RFB 客户端 → 用户屏幕 |
它是持续的画面流,Agent 点完,你不用等下一次截图请求就能看到页面变了。
问:那我反过来操作呢?我鼠标键盘怎么进沙箱?
啊,这就是我实测撞到的边界。普通 VNC 路径里,前端 RFB 客户端把鼠标和按键编码成 RFB 消息,经本站 WebSocket、后端代理、websockify 送到 x11vnc,再注入 X 桌面。
但实测发现:输入事件能到 Chrome 自己的界面(标签栏、地址栏),却不能可靠地到达网页 renderer 里的表单。你要是忽略这个事实,用户会看到特别诡异的现象——鼠标好像能动、标签也能切,可手机号和验证码死活填不进网页。
我没继续把 VNC 当万能输入通道去死磕,而是加了一条「代输入」链路:
1 | 用户在前端 HumanInputBar 输入文本 → POST /human-input → SessionService → CDP/Playwright → 当前网页输入框 |
这个接口只往当前输入框写文本,不替用户点提交。既绕过了 VNC 到 renderer 的输入限制,又保留了敏感操作前的人工确认边界。这也算个挺典型的工程判断:底层链路有边界时,不一定非得无限修补底层协议,把「看桌面」和「往网页写敏感文本」拆成两条更可靠的通道就行。
问:最后帮我捋一下,这些组件各自负责啥?
- Web UI:收用户目标,展示 SSE 轨迹和远程桌面;
- PlannerAgent:把目标拆成步骤;
- ReActAgent:根据页面反馈决定下一动作;
- BrowserTool:定义 Agent 能调的浏览器动作协议;
- PlaywrightBrowser:把动作变成真实 DOM/CDP 操作,并提取页面语义;
- E2BSandbox:创建、恢复、探活、销毁云端执行环境;
- Chrome CDP:让远程 Playwright 结构化控制浏览器;
- Xvfb/XFCE:提供可见的 Linux 桌面;
- x11vnc/noVNC:把桌面转成能走 WebSocket 的 RFB 流;
- 后端 VNC 代理:把沙箱的 WebSocket 和当前会话安全绑定;
- 前端 RFB:把远程桌面画到用户页面;
- SSE:把计划、工具调用、执行结果持续推回前端。
问:那你为什么不直接上 browser-use,省得自己写?
browser-use 的好处是给它一句自然语言任务,它自己观察网页、规划动作、执行。临时任务和探索型页面,这种体验非常直接。
但我这个项目已经有自己的 Planner/ReAct、会话状态机、工具事件、沙箱生命周期和人工接管。要是把 browser-use 整体塞进来,就会出现两个规划器、两套状态、两套工具事件,统一起来是自找麻烦。
所以我选了更低的一层:Playwright 提供确定、可验证的浏览器原语,自己的 BrowserTool 暴露有限动作,自己的 ReActAgent 做决策,E2B 和 noVNC 补上隔离执行和人工接管。不是说 browser-use 不好,而是当一个系统已经有上层 Agent 编排时,它最需要的是可控的浏览器能力,不是再塞一个完整的自然语言 Agent 进来。
问:所以你这两天真正搞明白的是什么?
一开始我把问题理解成「让 Agent 会点网页」。做完之后更愿意这么定义它:在隔离环境里,让机器能理解和操作页面,让人能观察和接管,并且两者共享同一个浏览器状态。
这个定义带出三个判断。第一,浏览器自动化不只有执行动作,还要有页面观察和结果验证。第二,Agent 控制面和用户观看面应该分开设计,CDP 适合自动化,VNC 适合可视化和接管。第三,人工接管不是失败兜底,是求职投递这类业务的正常状态,登录、验证码、隐私信息和最终提交,本来就得有明确的人机边界。
一定要记一条链路的话,记这句:用户目标经 SSE 请求进 Planner/ReAct,ReAct 通过 BrowserTool 调 Playwright,Playwright 经 CDP 操作 E2B 里的 Chrome;同一个 Chrome 的桌面再经 x11vnc、noVNC、后端 WebSocket 和前端 RFB 回到用户屏幕。




