
代码基线:ooder-biz-browser · ooder-browser-spi · ooder-test · ooder-a2a(2026-10-07 实测)
2026-10-08
财务同事每月要干一件事:登录电子税务局,切到"开票明细查询",填起止日期,点查询,等表格出来,导出 CSV,再贴回内部的发票台账。全流程大概两小时,其中真正"有判断"的部分不超过五分钟。
这五分钟是登录——CA 证书、短信验证码、偶尔还有图形验证码。剩下的两小时是重复的鼠标动作。
平台里其实已经接了钉钉、有成这类系统的 OpenAPI,开票台账的写入早就自动化了。卡住的恰恰是"读"那一头:税务侧没有对外的开放接口,只有一个人用的 Web 页面。于是数据进来之前,必须先有人坐在浏览器前。
一句话概括我们要解决的问题:在"对方只有网页"这个前提下,把重复动作交给 Agent,把必须由人做的那五分钟留给人——并且让这两半在同一个协议里衔接,而不是靠人肉搬。
先说结论:这不是"选一个 RPA 工具"的问题,而是分层的问题。我们把候选方案按"底座 / 增强 / 兜底"分了三层,各自只解决自己那一段。
方案 | 它能解决什么 | 为什么不选它做底座 | 我们的定位 |
|---|---|---|---|
Selenium / WebDriver | 生态最大、资料最多 | 要引入 driver 二进制与一整套依赖树;内网离线环境与 JDK 11 基线下成本过高 | 不选 |
Playwright for Java | 自动等待、多浏览器、API 现代 | 实为 Node 驱动的封装,运行时仍要拉一份 Node 与浏览器包;跨源 iframe 体验好但代价大 | 按需升级 |
进程内自建 CDP | 直接说 Chrome DevTools Protocol | 要自己写 WebSocket 帧、事件路由、超时 | 底座 |
LLM 自愈定位(browser-use 一类) | 页面改版后自动找元素 | 容易"幻觉成功"——找不到元素时可能自圆其说;财务数据不能赌 | 仅 waitFor 超时兜底 |
商业 RPA | 流程编排、录制回放现成 | 不重造轮子,但要能嵌进我们的 A2A 协议才有用 | 适配,不重造 |
内嵌浏览器薄壳 | 人可以在同一个窗口里接管 | 它是"人工接管通道",不是驱动底座 | 保留为人工通道 |
所以底座是进程内自建 CDP:JDK 11 自带的 java.net.http.WebSocket 就够了,零第三方依赖、零 Node 常驻、零 driver 二进制。这不浪漫,但它同时满足了内网离线、JDK 基线和"产物必须是薄 jar"三条硬约束。
优先级铁律:开放接口永远优先于浏览器模拟。税局若有直连通道,就别爬页面;浏览器 Agent 是"对方只有网页"时的最后手段,不是首选。
平台有两种宿主:Studio L0(StudioNew.jar,靠 -Dloader.path 外挂业务 jar)和沙箱(SandboxServer.jar,端口 8081)。这次我们选择在沙箱里跑 chromeAgent,因为沙箱是"验证产物能否真跑起来"的地方,天然适合做端到端。

图 1 · chromeAgent 在沙箱内的形态:三个 bean 同处一个进程,Chrome 是唯一的外挂进程
两个模块:ooder-browser-spi(零依赖契约,只有端口与常量)和ooder-biz-browser(薄 jar,不可执行、没有 application.yml)。拆开是为了让"驱动可替换"这件事在编译期成立——今天用自建 CDP,明天换 Playwright,agent 代码一行不动。
这是第一个坑。沙箱不是 L0。
通道 | 机制 | 能否让 agent 成为 A2A 执行端 | 适用 |
|---|---|---|---|
-Dloader.path | PropertiesLauncher 按目录挂外部 jar | 可以 | Studio L0 |
POST /api/test/deploy | 收 jar → 编译 → 自定义 ClassLoader 装载 | 不行 | 产物验证 |
-Pagent-host 内嵌 | Maven runtime 依赖打进 BOOT-INF/lib | 可以 | 沙箱 |
沙箱的产物是普通 ZIP fat jar,没有 loader.properties,压根不解析 -Dloader.path。而"热装载"那条路更容易被误以为可行——它确实能把 jar 编译装载进去,但:
ooder-test/src/main/java/net/ooder/test/service/SandboxCompileService.java:22-26
private final Map<String, SandboxClassLoader> sandboxClassLoaders = new ConcurrentHashMap<>();
private final Map<String, Class<?>> sandboxClasses = new ConcurrentHashMap<>();
private final Map<String, URLClassLoader> jarClassLoaders = new ConcurrentHashMap<>();
// 装载只落这三个 Map;全模块搜不到任何 BeanDefinitionRegistry / registerSingleton / registerHandler类进来了,但没进 Spring 容器、也没调 A2AProtocolService.registerHandler。它只能在 executeInSandbox 里被反射调一个无参方法。所以"热装载一个 agent"在这套架构下不成立。
于是接线点只能落在构建期:
ooder-test/pom.xml · agent-host profile
<dependency>
<groupId>net.ooder</groupId>
<artifactId>ooder-biz-browser</artifactId>
<version>1.0.j11</version>
<scope>runtime</scope> <!-- 与 ooder-biz-finance 等既有的 B/C 包同一范式 -->
</dependency>Windows 上的一条硬规则:运行中的沙箱进程会锁住 target/SandboxServer.jar,clean/repackage 直接失败。先停进程,再打包。(L0 同理)
jar 装进去了,下一步是"被认出来"。平台里 agent 的注册分两层,两层都成功才算注册:
chromeAgent 用的是两个 ApplicationRunner,顺序是硬约束:

图 2 · 启动期注册顺序:@Order(20) 必须晚于 @Order(10),否则执行端覆盖不了占位 handler
# | 必须 | 不满足会怎样(都是静默的) |
|---|---|---|
M1 | ||
名册条目必须带 nodeId | ||
normalizeAddressability 标 addressable=false 并 WARN;跨节点定向投递直接不可用 | ||
M2 | 执行端 @Order 必须晚于注册器 | 目录里有能力,但 TASK_REQUEST 只落到占位 handler,不报错 |
M3 | 包名必须落 net.ooder.* | jar 装进去了但组件扫描不到,没有任何日志 |
M4 | claim.enabled=true | 注册器与执行端都早退,GET /agents 看不到,任务无消费者 |
M1 是这次真踩到的:修复前那个沙箱实例的启动参数里没有 -Dooder.a2a.node-id,于是名册里的两条记录 nodeId 为空。表面一切正常——GET /agents 能看到 agent,本地也能派发——但"能被别的节点找到"这件事其实早就是残废的。所以我们把启动脚本固化了:
_deploy/sandbox/start-sandbox-browser.ps1(节选)
-Dooder.agent.host.enabled=true # 激活白名单扫描 view + net.ooder
-Dooder.a2a.node-id=sandbox-8081 # M1:名册 addressable 的唯一来源
-Dooder.a2a.agent.namespace=browser
-Dooder.a2a.agent.claim.enabled=true # M4
-Dstudio.instance.data.dir=...\data\studio-db # 名册与消息库随实例隔离
-Dooder.a2a.store.dir=...\data\studio-db
-Dooder.browser.profile.base=...\browser-profiles顺带说一句:AgentHostConfiguration 的排除项一共 8 条,net.ooder.browser.* 一条都没命中;而 net.ooder.studio.chat.a2a.* 也在扫描范围内——这意味着沙箱会在 agent-host 模式下顺带暴露 L0 的 A2A 运维面 /api/studio/a2a/*。这一步是白捡的:既不需要 MQTT,也不需要另起一个 L0 实例。
现在发一条消息。整个链路只有 5 跳,但每一跳都有"看起来成功其实没成功"的陷阱。

图 3 · 一次同步调用的五跳,以及"没人接"时的真实表现
调用形态我们认两种:扁平的 {op, ...},和 A2ARequest 产出的包裹形态 {action, parameters:{op, ...}}。本来以为这只是"兼容一下",结果它成了两个真实缺陷的产地(见第 8 节)。
还有一个 ops 面的缺口必须补。原来的 /api/studio/a2a/dispatch 走的是单向 sendMessage:只返回 messageId,答案要去 /messages 里翻;更麻烦的是,当请求方不是本节点的注册执行体时,对方的 TASK_RESPONSE 会被如实标成 UNDELIVERABLE——这不是 bug,是"单向 + 无回投通道"的必然结果。于是我们加了同步端点,且坚持走同一条生产协议链(sendRequest → pending future → completePendingResponse),不另写一套:
POST /api/studio/a2a/request
{
"toAgentId": "browser.web-automate",
"parameters": { "op": "health" }, // 与 "payload" 等价;action 可省
"headers": { "replyNodeId": "sandbox-8081" },
"timeoutMs": 20000
}
// 200 — A2A 层的失败也是 200,成败看 data(与 dispatch「接口成功≠链路成功」同口径)
{ "code": 200, "data": {
"success": true,
"result": { "ok": true, "driverPresent": true,
"driver": { "id": "cdp-jdk11-20261007" }, "pendingGates": [] },
"errorCode": null, "elapsedMs": 52 } }回到开头那五分钟。Agent 走到登录页,发现要 CA 证书——这时候它只有三个选择:放弃、硬闯、叫人。硬闯验证码既不可靠也不合规,所以只剩叫人。
关键在于怎么叫。最直觉的做法是在 handler 里死等,但 A2A 请求默认超时是 30 秒,而等人扫码通常几十秒到几分钟。死等的结果是:请求方先超时,拿到 TIMEOUT,编排方误判失败并可能重试——重复开浏览器、重复登录,甚至触发风控。
所以门被拆成两段:

图 4 · 两段式门:请求立即回执,人做完再放行;门的等待不占用任何 A2A 请求超时
人在门里有三个选择,语义必须一一落到执行上,否则"点了取消但机器继续跑"就是事故:

图 5 · 门的四个终态;"否决"是唯一会让会话进入冻结的分支
字段名我们刻意对齐了平台既有的 HumanConfirmEventFactory(confirmId / phase / message / options / timeoutSeconds / waitingForUser),这样前端可以复用同一套门渲染;额外多给三个"让人看得见现场"的字段:screenshotPath · url · title / readyState。承载通道不同(我们是 A2A NOTIFICATION + x-human-gate 头,平台既有是 SSE human_confirm + /confirm),但语义同形——接不接引擎的 HUMAN 活动是产品决策,我们刻意保持不耦合,避免把浏览器 agent 绑死在流程引擎上。
一条不能商量的安全口径:CDP 调试端口 = 明文登录态。驱动只连 127.0.0.1、每会话独立 user-data-dir;薄壳脚本里的 -Cdp 默认关闭是刻意设计,不要为了"方便自动化"把它常开。
这一节是整件事里我最想留住的部分。我们做了四个探针,加起来 68 条判据,全部实测通过:
探针 | 回答什么问题 | 判据 | 关键证据 |
|---|---|---|---|
run-jar-probe | 装进去会不会被认出来(形态/包名/注解/顺序/SPI) | 13 | classpath 只用 jar;无 BOOT-INF/application.*;@Order(10/20);ServiceLoader 命中 |
run-probe | 驱动真能操作浏览器吗 | 19 | 受控输入回显、表格抽取、上传/下载、跨源 iframe、Shadow DOM、否定用例 |
run-a2a-probe | A2A 派发与 human 门在协议层对不对 | 15 | 真实 A2AProtocolServiceImpl 本地闭环,无 MQTT、无 Spring 容器 |
verify-sandbox-a2a | 接进沙箱后到底通不通 | 21 | 沙箱进程内真起 Chrome 跑完 open→act→extract→close + 全链路 UTF-8 |
三个都不是"编译不过"那种显式错误,而是静默的行为错误——这也是为什么必须写断言而不是跑一遍看日志。
A2ARequest.toA2AMessage() 产出的 payload 是 {action:"human.resume", parameters:{action:"cancelled"}}。摊平时我们原本"顶层键优先",于是协议动作名 human.resume 把业务参数 cancelled 覆盖了。
后果:人在门里点"中止本会话",human.resume 依然把动作当成"放行",会话不冻结,后续自动操作照常执行。安全语义被静默吞掉。
修法:摊平时排除顶层 action;parameters 的键优先,顶层只补缺。
修缺陷 1 时引入的回归:顶层 action 被排除后,op 的兜底也没了(因为兜底读的是"摊平后的 action")。于是所有 parameters 里没写 op 的请求都掉到默认 op = health。
后果:发 capabilities 拿回来的是 health 的结果,success=true,没有任何报错——"消息到了但干的是别的事"。
修法:摊平前先把 wrapperAction 取出来留底,摊平后用它兜底 op。
(这条 1 分钟内被同一条测试抓住——这正是"用户故事测试"该有的样子:不是走一遍 happy path,而是逐条断言协议字段与安全语义。)
// A2ARequest.toA2AMessage(),修复前
.payload(Map.of("action", action, "parameters", parameters))Map.of 的键值都不允许 null。新增的 /request 端点允许只传 parameters.op(op 才是业务语义,action 只是标签),于是 action 为 null → 抛 NullPointerException,且 message 为 null,调用方只能看到一个没有原因的 REQUEST_FAILED。
修法:改用 LinkedHashMap 构造 payload,允许 action 为 null;消费者按需回退到 parameters.op。
一条通用判定法:遇到"编码/内容不对",先用 curl -o resp.bin 拿原始字节看 hex,一次就能分清"是服务端错了,还是我的解码器错了"。
回头看,这件事真正的收获不是"能用 CDP 驱动浏览器了"——那是已知技术。收获是把一件"不可能自动化"的事切成了两半:
切完之后,剩下的两小时变成:Agent 跑到登录页 → 发一条门通知 → 人花五分钟登录 → 点"已完成,继续" → Agent 继续把表格抽回来。人没有变少,但人的注意力只剩那五分钟。
三条工程口信: ① 装载形态决定一切:沙箱不解析 -Dloader.path,热装载不进容器——先确认宿主能吃哪种 jar,再谈注册。 ② "静默失效"比报错更贵:nodeId 空、@Order 反了、包名不在扫描范围、入参摊平覆盖——四个都是不报错的。所以每条都要写成断言。 ③ 人的门口不要阻塞:门是事件,不是同步等待;否决必须真的冻结。
OODER Studio · 工程实践系列 · 2026-10-08
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。