Java 8老系统Browser Agent实战:三层拦截把AI操作后台变成可审计流程

Java 8老系统Browser Agent实战:三层拦截把AI操作后台变成可审计流程

源码路径:chapter08-browser-test-agent/src/main/java/com/ynzz/lab/chapter08/agent/


0. 先问三个问题,再决定要不要上 Browser Agent

Browser Agent 做出一个能自动打开浏览器、点点填填的 Demo 并不难,网上随便一搜都是这类炫酷演示。但企业真正落地时,工程师们问的从来不是"AI 能不能操作浏览器",而是:

  1. AI 只能操作哪个环境? 生产?测试?还是 localhost?
  2. AI 能点"删除"吗? 能点"审批"吗?能点"支付"吗?
  3. 操作之前要不要人确认? 怎么确认?谁来确认?

这三个问题回答不清楚,Browser Agent 上线第一天就会出事。

本讲我们就围绕这三个问题来展开,从安全策略设计出发,把 Browser Agent 的核心逻辑讲透,再配合 Demo 场景让各位真正理解代码背后的决策路径。

前置知识: 本讲依赖第7讲 AI 工单助手的多 Agent 协作框架,以及第3讲 SQL Agent 的参数提取逻辑。extractOrderId 的设计与 SQL Agent 中的参数提取思路一脉相承,建议先回顾。


1. 核心问题:AI 操作后台的三大风险

在动手写代码之前,我们先把风险分清楚:

风险类型 典型场景 后果
环境风险 AI 直接操作生产数据库/后台 数据污染、线上故障
动作风险 AI 执行了"删除订单"、"审批通过"、"确认支付" 不可逆的业务操作
确认风险 没人知道 AI 今天点了什么、什么时候点的 审计黑洞

传统做法靠人来挡:测试环境人工测,生产操作审批流。但 AI 来了之后,这套机制被绕过去了------你给 AI 一个任务,它自己去点,如果没有人明确告诉它"哪里能点、什么不能点",它就会按照自己的理解去操作。

Browser Agent 的设计思路就是:把安全策略做成代码逻辑,让每一次 AI 操作都经过显式检查。


2. 安全策略:三层拦截决策树

这是整讲的核心。请先把这个决策树记在心里,再去看代码。

yaml 复制代码
用户发起 BrowserTestRequest
        │
        ▼
┌───────────────────────────────────────┐
│ 第一层:请求级拦截(BrowserTestRequest)│
│  rejectReason(request)                │
└───────────────────────────────────────┘
        │
    [有原因?]
   Yes ↓ No
   返   │
 回     ▼
rejected  ┌───────────────────────────────────────┐
 plan     │ 第二层:URL 白名单检查                   │
          │ targetUrl 必须以 http://localhost        │
          │ 或 http://127.0.0.1 开头                 │
          └───────────────────────────────────────┘
                    │
                [有原因?]
               Yes ↓ No
               返   │
              回     ▼
             plan  ┌───────────────────────────────────────┐
                   │ 第三层:危险任务关键词检查              │
                   │ task 文本含 "删除" / "审批" / "支付"   │
                   └───────────────────────────────────────┘
                            │
                        [有原因?]
                       Yes ↓ No
                       返   │
                      回     ▼
                     plan   生成 4 步执行计划
                            (OPEN → TYPE → CLICK → SCREENSHOT)
                            │
                            ▼
                    ┌─────────────────┐
                    │ 等待 confirmedBy │  ← 执行时再判断
                    └─────────────────┘
                              │
                      [confirmedBy 为空?]
                      Yes ↓ No
                      FAILED     │
                     /CONFIRMA   ▼
                    TION_REQUIRED  执行每个 Step
                                   │
                                   ▼
                          ┌────────────────────────┐
                          │ 第四层:动作级兜底拦截    │
                          │ rejectReason(step)     │
                          │ 拦截 DELETE/PAY/APPROVE │
                          └────────────────────────┘
                                   │
                               [有原因?]
                              Yes ↓ No
                              FAILED   PASSED
                             /HIGH_RISK

2.1 三层拦截代码

请求级拦截(第一~三层):

java 复制代码
// BrowserSafetyPolicy.java - 请求级拦截
public String rejectReason(BrowserTestRequest request) {
    // 第一层:环境校验
    if (!"test".equals(request.getEnvironment())) {
        return "ONLY_TEST_ENVIRONMENT_ALLOWED"; // 拦截所有非测试环境
    }

    // 第二层:URL 白名单(仅限 localhost)
    String targetUrl = request.getTargetUrl();
    boolean isLocalhost =
        targetUrl.startsWith("http://localhost") ||
        targetUrl.startsWith("http://127.0.0.1");
    if (!isLocalhost) {
        return "TARGET_URL_NOT_IN_TEST_ALLOWLIST"; // 拦截所有外部 URL
    }

    // 第三层:危险任务关键词
    String task = request.getTask();
    if (task.contains("删除") || task.contains("审批") || task.contains("支付")) {
        return "HIGH_RISK_ACTION_NOT_ALLOWED"; // 拦截高危操作
    }

    return null; // 三层全过,返回 null = 放行
}

动作级兜底拦截(第四层):

java 复制代码
// BrowserSafetyPolicy.java - 单步兜底
public String rejectReason(BrowserStep step) {
    String action = step.getAction().name();
    if ("DELETE".equals(action) || "PAY".equals(action) || "APPROVE".equals(action)) {
        return "HIGH_RISK_STEP_NOT_ALLOWED";
    }
    return null;
}

2.2 为什么需要两层拦截?

这里有个设计细节值得单独说一下:请求级拦截和动作级拦截不是重复的,它们各自承担不同的职责。

  • 请求级拦截 :在计划生成阶段执行,目的是提前阻止危险请求进入执行队列 。如果一个请求被请求级拦截,createPlan 直接返回 rejected plan,根本不会生成任何 step。

  • 动作级兜底 :在执行阶段逐个 step 执行,目的是兜住请求级过滤的漏网之鱼。比如某些危险操作可能不会在 task 文本中直接写"删除",但 LLM 在生成计划时把某个动作生成了 DELETE,此时动作级拦截就能把它拦住。

换句话说:请求级是"进门查身份",动作级是"出门再验一遍票"。 两层缺一不可------没有请求级,大量的危险请求会直接涌进来;没有动作级,LLM 生成的 step 可能绕过高危关键词检查。


3. 计划生成:从任务文本到可执行步骤

3.1 参数提取:extractOrderId

当用户说"搜索订单 O202606050001,并截图订单详情页"时,LLM 并不知道"O202606050001"是一个需要提取的参数。BrowserPlanService 需要先把这个参数解析出来:

java 复制代码
// BrowserPlanService.java
private String extractOrderId(String task) {
    // 从 task 文本中查找以 "O" 开头、接续字母数字的订单号
    // 格式约定:以 "O2026" 开头,后面跟数字
    Pattern pattern = Pattern.compile("O[0-9]{12}");
    Matcher matcher = pattern.matcher(task);
    if (matcher.find()) {
        return matcher.group(); // 返回 "O202606050001"
    }
    return null;
}

这段逻辑的假设是:业务系统中的订单号有固定格式(以"O"开头,年月日+序号,共14位),通过正则提取比让 LLM 去"理解"更可靠。在企业场景中,结构化提取参数永远是第一选择,而不是让 LLM 自由发挥。

对比第3讲 SQL Agent 的参数提取,两者的思路是一致的:先用规则/正则提取确定性参数,再把剩余的模糊意图交给 LLM 处理。

3.2 四步计划生成

参数提取完成之后,createPlan 生成一个包含 4 个 step 的执行计划:

Step # Action 输入 输出/目标
1 OPEN targetUrl 打开浏览器,导航到目标页面
2 TYPE orderIdInput, orderId 在订单号输入框填入订单号
3 CLICK searchButton 点击搜索按钮
4 SCREENSHOT orderDetailPanel 对订单详情区域截图,保存为 artifacts/screenshots/order-detail.png

这里的设计遵循了原子化原则:每个 step 只做一件事。OPEN 负责导航,TYPE 负责填值,CLICK 负责触发,SCREENSHOT 负责记录证据。拆得越细,安全检查点就越多,出问题时越容易定位。


4. 四个 Demo 场景:输入→判断→输出

场景一:安全计划生成(完全通过)

输入:

json 复制代码
{
  "environment": "test",
  "targetUrl": "http://localhost:8080/admin/orders",
  "task": "搜索订单 O202606050001,并截图订单详情页"
}

拦截判断链路:

csharp 复制代码
第一层 (environment)  → null        ✓ test 环境,放行
第二层 (URL)           → null        ✓ localhost,放行
第三层 (task 关键词)    → null        ✓ 不含删除/审批/支付,放行
extractOrderId         → "O202606050001"
生成计划               → 4步计划

输出:

json 复制代码
{
  "status": "PENDING_CONFIRMATION",
  "steps": [
    {"action": "OPEN",    "target": "http://localhost:8080/admin/orders"},
    {"action": "TYPE",    "inputRef": "orderIdInput",    "value": "O202606050001"},
    {"action": "CLICK",   "ref": "searchButton"},
    {"action": "SCREENSHOT", "element": "orderDetailPanel", "path": "artifacts/screenshots/order-detail.png"}
  ]
}

状态是 PENDING_CONFIRMATION,意味着计划已生成,但还需要人来确认才能执行。


场景二:确认后执行(完全通过)

输入: 场景一的计划 + confirmedBy = "qa-user"

执行判断链路:

vbscript 复制代码
plan.rejected          → false       ✓ 不需要拦截
confirmedBy            → "qa-user"   ✓ 非空,有授权人
Step 1 OPEN            → null        ✓ 通过
Step 2 TYPE            → null        ✓ 通过
Step 3 CLICK           → null        ✓ 通过
Step 4 SCREENSHOT      → null        ✓ 通过,ScreenshotRecorder 生成截图

输出:

json 复制代码
{
  "status": "PASSED",
  "confirmedBy": "qa-user",
  "screenshotPath": "artifacts/screenshots/plan-001-orderDetailPanel.png",
  "executedSteps": 4
}

截图路径由系统自动生成,plan-001 表示这是 plan 编号 001 的截图。


场景三:生产环境拦截

输入:

json 复制代码
{
  "environment": "prod",
  "targetUrl": "https://admin.example.com/orders",
  "task": "搜索订单 O202606050001,并截图订单详情页"
}

拦截判断链路:

arduino 复制代码
第一层 (environment)  → "ONLY_TEST_ENVIRONMENT_ALLOWED"  ✗ 第一层就拦住了

输出:

json 复制代码
{
  "status": "REJECTED",
  "reason": "ONLY_TEST_ENVIRONMENT_ALLOWED",
  "rejectedAt": "layer-1-request"
}

关键点:第一层就返回了,不会再检查后面的逻辑。 这是防御深度(defense in depth)的体现------越前面的关卡越严格,越早拦截越好。


场景四:危险任务拦截

输入:

json 复制代码
{
  "environment": "test",
  "targetUrl": "http://localhost:8080/admin/orders",
  "task": "删除订单 O202606050001"
}

拦截判断链路:

csharp 复制代码
第一层 (environment)  → null        ✓ test 环境,放行
第二层 (URL)           → null        ✓ localhost,放行
第三层 (task 关键词)    → "HIGH_RISK_ACTION_NOT_ALLOWED"  ✗ 命中"删除"关键词

输出:

json 复制代码
{
  "status": "REJECTED",
  "reason": "HIGH_RISK_ACTION_NOT_ALLOWED",
  "rejectedAt": "layer-3-task-keyword",
  "matchedKeyword": "删除"
}

注意:这个请求虽然环境是 test、URL 是 localhost,但仍然被第三层拦住了。测试环境不等于可以随便操作,危险动作在任何环境下都需要额外审批。


5. 执行引擎:BrowserRunService

计划生成之后,由 BrowserRunService 负责执行:

java 复制代码
public BrowserRunResult run(BrowserPlan plan, String confirmedBy) {
    // 1. 检查 plan 是否被拦截
    if (plan.isRejected()) {
        return BrowserRunResult.failed("PLAN_REJECTED", plan.getRejectReason());
    }

    // 2. 检查是否有人确认
    if (confirmedBy == null || confirmedBy.isBlank()) {
        return BrowserRunResult.failed("CONFIRMATION_REQUIRED",
            "此操作需要人工确认,请提供 confirmedBy 参数");
    }

    // 3. 逐个执行 step,每步都做动作级安全检查
    for (BrowserStep step : plan.getSteps()) {
        String reason = safetyPolicy.rejectReason(step);
        if (reason != null) {
            return BrowserRunResult.failed("HIGH_RISK_STEP_NOT_ALLOWED", reason);
        }

        // 执行 step
        executeStep(step);

        // 4. 截图 step 特殊处理:记录证据
        if (step.getAction() == Action.SCREENSHOT) {
            ScreenshotRecorder.record(step.getPath());
        }
    }

    return BrowserRunResult.passed(confirmedBy);
}

这里有一个设计值得注意:confirmedBy 的检查是在执行阶段做的,而不是在计划生成阶段。这意味着:即使 AI 生成了一个看似安全的计划,也必须有人来"认领"这个操作。这个设计把责任从"AI 的能力"转移到了"人的授权"。


6. 三种触发场景:代码链路差异

Browser Agent 在企业中可能有三种触发方式,每种方式的调用链路和适用场景不同:

场景 A:测试用例生成后自动触发

ini 复制代码
LLM 生成测试用例(自然语言)
        ↓
用例评估系统判断:需要操作后台验证?
        ↓
构造 BrowserTestRequest(environment=test)
        ↓
BrowserPlanService.createPlan(request)
        ↓
 BrowserRunService.run(plan, confirmedBy="auto-test-runner")
        ↓
截图 → 存入测试报告

这种场景适用于自动化回归测试:AI 生成用例 → 自动执行 → 自动截图存证。全程无人干预,适合高频执行的冒烟测试。

注意事项: confirmedBy 字段不能为空,此时传入一个系统级的虚拟用户名(如 auto-test-runner),并在日志中记录该操作由哪个测试用例触发,便于事后审计。

场景 B:人工发起(推荐初始阶段)

markdown 复制代码
测试工程师 → 打开操作界面 → 填写任务描述
        ↓
系统返回待确认的 Plan(状态=PENDING_CONFIRMATION)
        ↓
测试工程师查看计划,确认操作安全
        ↓
输入 confirmedBy,提交执行
        ↓
执行并截图 → 人工复审截图

这是最安全的接入方式。Browser Agent 在人工确认模式下,本质上是一个"高级录屏工具"------人决定做什么,AI 来执行并记录。

场景 C:CI/CD 流水线触发

css 复制代码
CI Pipeline → 调用 REST API
        ↓
构造 BrowserTestRequest
(带环境标识、目标 URL、任务描述、CI 触发者身份)
        ↓
BrowserPlanService + BrowserRunService
        ↓
结果写入 CI 日志,截图作为制品存档
        ↓
Pipeline 根据结果决定:通过 / 失败

这种场景适合 CI 阶段的 UI 自动化测试替代 。但要注意:CI 触发的 confirmedBy 通常是 CI 系统账号(如 jenkins-agent),必须有完整的操作日志和截图留存,否则出了事故无法回溯。


7. 截图的价值:测试证据的复盘意义

很多团队用 Browser Agent 只关注"AI 能不能点对",忽略了截图作为测试证据的复盘价值。

一个截图能告诉我们什么?

  • 操作结果是否正确:AI 点完之后,页面显示的是不是预期内容?
  • 页面是否有异常:有没有报错信息、加载失败、权限提示?
  • AI 的理解是否正确:AI 认为它点到了"A按钮",但截图显示它点的是"B按钮"------这一步的偏差对后续步骤有什么影响?
  • 回归测试的基线:每次截图都是一次快照,下次执行时可以对比,页面结构是否发生了变化?

在企业实践中,建议把截图路径纳入测试报告的结构化字段中,而不是简单存成文件:

json 复制代码
{
  "testCaseId": "TC-ORDER-001",
  "executedAt": "2026-06-15T10:30:00Z",
  "screenshotPath": "artifacts/screenshots/plan-001-orderDetailPanel.png",
  "planStatus": "PASSED",
  "confirmedBy": "qa-user",
  "plan": { ... }
}

这样每次执行都有完整的证据链可查。


8. 企业避坑:五条血泪经验

坑一:不要把 Browser Agent 做成"通用网页操作工具"

Browser Agent 的能力是通用的,但企业的使用场景一定是受限的。如果不做好环境限制和操作限制,AI 迟早会访问到它不该访问的页面。限制比能力更重要。

坑二:localhost 白名单在小团队够用,大规模部署会变成瓶颈

startsWith("http://localhost") 这个白名单在本地开发时很方便,但如果你的测试环境有多个服务分布在不同机器上,这个白名单就不够用了。建议从一开始就设计一个可配置的 URL 白名单服务,而不是硬编码 localhost。

坑三:confirmedBy 检查不等于权限控制

confirmedBy 只是一个字符串,任何人都可以传 confirmedBy="admin"需要结合 SSO/权限系统,验证 confirmedBy 对应的账号是否有执行该操作的权限,而不是仅靠一个字段名来挡事。

坑四:LLM 生成 step 的不确定性是最大的隐患

即使请求级拦截全部通过,LLM 在生成具体 step 时仍然可能生成危险动作(如把"查看详情"误解为"删除记录")。动作级兜底拦截是最后的防线,但不是银弹。 建议在高风险操作场景下,保留人工审核 step 列表的环节,而不是全自动执行。

坑五:截图不存储元数据,时间长了就是一堆废图

单独存截图而不记录"这是哪个 Plan、谁触发的、哪个环节出问题",三个月后这些截图就变成了无法解读的垃圾文件。截图必须和执行上下文一起存储,至少包含 Plan ID、触发者、执行时间、Plan 状态。


9. 从 Demo 到落地:五条工程路径

路径一:集成 Playwright,实现真实浏览器操作

Demo 阶段的截图和操作是抽象的,实际落地需要接入 Playwright(或 Selenium)来实现真实的浏览器控制:

scss 复制代码
BrowserRunService.executeStep(step)
        ↓
PlaywrightClient.click(locator)  // CLICK
PlaywrightClient.fill(locator, value)  // TYPE
PlaywrightClient.screenshot(element)  // SCREENSHOT

Playwright 的 locator 策略建议使用 ARIA role + name,而不是 XPath,这样对页面结构变化的鲁棒性更强。

路径二:CI 流水线集成,实现夜间自动化回归

把 Browser Agent 嵌入现有的 CI 流水线(推荐在集成测试阶段,而非单元测试阶段):

yaml 复制代码
# .github/workflows/browser-test.yml
- name: Run Browser Agent Tests
  run: |
    curl -X POST http://browser-agent-api/run \
      -H "Content-Type: application/json" \
      -d '{
        "environment": "test",
        "targetUrl": "http://localhost:8080/admin/orders",
        "task": "搜索订单 O202606050001,并截图订单详情页",
        "confirmedBy": "ci-pipeline"
      }'
  artifacts:
    - artifacts/screenshots/

截图作为 CI 制品存档,失败时自动附加到 PR 评论中。

路径三:构建测试用例库,覆盖核心业务流程

不要让 AI 每次都从零理解任务。建议预置一套测试用例库,每条用例包含:

  • 用例 ID 和描述
  • 标准化的 task 模板(支持参数化)
  • 预期的截图特征(用于自动化比对)
  • 执行频率(每日/每周/按需)

AI 的工作是填充参数并执行,而不是每次都重新设计操作流程。

路径四:失败告警体系,不是"跑完就行"

Browser Agent 执行失败后,截图是异步生成的,工程师可能不会主动去查看。建议:

  • 失败即告警:Plan 状态为 FAILED 时,通过企业微信/钉钉/邮件通知负责人
  • 告警内容包含截图:让负责人看一眼截图就能判断是 AI 操作失误还是应用本身的问题
  • 告警分级:PLAN_REJECTED(配置问题,HIGH 优先级)/ HIGH_RISK_STEP_NOT_ALLOWED(高危操作拦截,HIGH)/ CONFIRMATION_REQUIRED(流程问题,MEDIUM)

路径五:操作重试与容错设计

网络波动、页面加载慢、元素定位失败都可能导致 step 执行失败。重试策略建议:

arduino 复制代码
executeStep(step)
  ↓ 失败
重试 1(等待 2s)→ 执行
  ↓ 失败
重试 2(等待 5s)→ 执行
  ↓ 失败
标记 step 为 FAILED,记录错误截图,终止 plan

同时要注意:重试不等于重复执行同一个 step,如果是因为页面状态问题导致的失败,重复执行相同的操作可能产生累积效应(如重复提交表单)。建议在重试前先截一张图,确认页面状态后再决定下一步。


10. 本讲小结

Browser Agent 的核心价值不是"让 AI 帮你点网页",而是把 AI 操作后台的风险变成可配置、可审计、可拦截的确定性流程。掌握这个思路,你就能理解为什么安全策略要前置到计划生成阶段,为什么需要请求级和动作级两层拦截,为什么 confirmedBy 不是可选参数而是必填项。

下一讲我们将把视角从"测试"转向"生产",看看在非测试环境下,如何用受控的方式让 AI 真正参与业务决策------包括本讲中被拦截的那些高风险操作,在什么条件下才能被安全地解锁。


相关源码文件:

文件 职责
BrowserSafetyPolicy.java 三层请求级拦截 + 单步动作级拦截
BrowserPlanService.java 计划生成、参数提取、四步计划组装
BrowserRunService.java 执行引擎、确认检查、截图记录
BrowserTestRequest.java 请求数据结构
BrowserPlan.java 计划数据结构(含 rejected 标记)
BrowserStep.java 单步操作数据
ScreenshotRecorder.java 截图持久化

前置回顾:

  • 第3讲 SQL Agent:参数提取的设计思路与本讲 extractOrderId 一脉相承
  • 第7讲 AI 工单助手:多 Agent 协作框架是 Browser Agent 的基础设施

相关推荐
用户34232323763171 小时前
GPIO控制与按键中断入门
后端
Gopher_HBo1 小时前
Go语言学习笔记(十五)Http响应
后端
kfaino2 小时前
码农的AI翻身(六)你好,我叫 Parameter
后端·aigc
掘金者阿豪2 小时前
把业务数据变成共享仪表盘:Metabase可视化与远程访问实践
前端·后端
猪猪拆迁队3 小时前
虚拟工厂仿真引擎的架构设计:让一条产线可编程、可观测、可干预
后端·ai编程
字节跳动数据库3 小时前
文章分享——相似函数处理方法
人工智能·后端·程序员
云技纵横3 小时前
@Transactional 失效的 7 种场景:第 5 种最难排查
后端
用户6757049885024 小时前
你知道 Go 结构体和结构体指针调用的区别吗?一文带你彻底搞懂!
后端·go
程序员cxuan4 小时前
读懂 Claude Code 架构分析系列,第一篇,开始!
人工智能·后端·架构