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

这三个问题回答不清楚,Browser Agent 上线第一天就会出事。
本讲我们就围绕这三个问题来展开,从安全策略设计出发,把 Browser Agent 的核心逻辑讲透,再配合 Demo 场景让各位真正理解代码背后的决策路径。
在动手写代码之前,我们先把风险分清楚:
| 风险类型 | 典型场景 | 后果 |
|---|---|---|
| 环境风险 | AI 直接操作生产数据库/后台 | 数据污染、线上故障 |
| 动作风险 | AI 执行了"删除订单"、"审批通过"、"确认支付" | 不可逆的业务操作 |
| 确认风险 | 没人知道 AI 今天点了什么、什么时候点的 | 审计黑洞 |
传统做法靠人来挡:测试环境人工测,生产操作审批流。但 AI 来了之后,这套机制被绕过去了——你给 AI 一个任务,它自己去点,如果没有人明确告诉它"哪里能点、什么不能点",它就会按照自己的理解去操作。
Browser Agent 的设计思路就是:把安全策略做成代码逻辑,让每一次 AI 操作都经过显式检查。
这是整讲的核心。请先把这个决策树记在心里,再去看代码。
复制代码用户发起 BrowserTestRequest
│
▼
┌───────────────────────────────────────┐
│ 第一层:请求级拦截(BrowserTestRequest)│
│ rejectReason(request) │
└───────────────────────────────────────┘
│
[有原因?]
Yes ↓ No
返 │
回 ▼
rejected ┌───────────────────────────────────────┐
plan │ 第二层:URL 白名单检查 │
│ targetUrl 必须以 │
│ 或 开头 │
└───────────────────────────────────────┘
│
[有原因?]
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
请求级拦截(第一~三层):
复制代码// 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 = 放行
}
动作级兜底拦截(第四层):
复制代码// 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;
}
这里有个设计细节值得单独说一下:请求级拦截和动作级拦截不是重复的,它们各自承担不同的职责。
请求级拦截:在计划生成阶段执行,目的是提前阻止危险请求进入执行队列。如果一个请求被请求级拦截,createPlan 直接返回 rejected plan,根本不会生成任何 step。
动作级兜底:在执行阶段逐个 step 执行,目的是兜住请求级过滤的漏网之鱼。比如某些危险操作可能不会在 task 文本中直接写"删除",但 LLM 在生成计划时把某个动作生成了 DELETE,此时动作级拦截就能把它拦住。
换句话说:请求级是"进门查身份",动作级是"出门再验一遍票"。 两层缺一不可——没有请求级,大量的危险请求会直接涌进来;没有动作级,LLM 生成的 step 可能绕过高危关键词检查。
当用户说"搜索订单 O202606050001,并截图订单详情页"时,LLM 并不知道"O202606050001"是一个需要提取的参数。BrowserPlanService 需要先把这个参数解析出来:
复制代码// 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 自由发挥。
参数提取完成之后,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 负责记录证据。拆得越细,安全检查点就越多,出问题时越容易定位。
输入:
复制代码{
"environment": "test",
"targetUrl": "http://localhost:8080/admin/orders",
"task": "搜索订单 O202606050001,并截图订单详情页"
}
拦截判断链路:
复制代码第一层 (environment) → null test 环境,放行
第二层 (URL) → null localhost,放行
第三层 (task 关键词) → null 不含删除/审批/支付,放行
extractOrderId → "O202606050001"
生成计划 → 4步计划
输出:
复制代码{
"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"
执行判断链路:
复制代码plan.rejected → false 不需要拦截
confirmedBy → "qa-user" 非空,有授权人
Step 1 OPEN → null 通过
Step 2 TYPE → null 通过
Step 3 CLICK → null 通过
Step 4 SCREENSHOT → null 通过,ScreenshotRecorder 生成截图
输出:
复制代码{
"status": "PASSED",
"confirmedBy": "qa-user",
"screenshotPath": "artifacts/screenshots/plan-001-orderDetailPanel.png",
"executedSteps": 4
}
截图路径由系统自动生成,plan-001 表示这是 plan 编号 001 的截图。
输入:
复制代码{
"environment": "prod",
"targetUrl": "https://admin.example.com/orders",
"task": "搜索订单 O202606050001,并截图订单详情页"
}
拦截判断链路:
复制代码第一层 (environment) → "ONLY_TEST_ENVIRONMENT_ALLOWED" 第一层就拦住了
输出:
复制代码{
"status": "REJECTED",
"reason": "ONLY_TEST_ENVIRONMENT_ALLOWED",
"rejectedAt": "layer-1-request"
}
关键点:第一层就返回了,不会再检查后面的逻辑。 这是防御深度(defense in depth)的体现——越前面的关卡越严格,越早拦截越好。
输入:
复制代码{
"environment": "test",
"targetUrl": "http://localhost:8080/admin/orders",
"task": "删除订单 O202606050001"
}
拦截判断链路:
复制代码第一层 (environment) → null test 环境,放行
第二层 (URL) → null localhost,放行
第三层 (task 关键词) → "HIGH_RISK_ACTION_NOT_ALLOWED" 命中"删除"关键词
输出:
复制代码{
"status": "REJECTED",
"reason": "HIGH_RISK_ACTION_NOT_ALLOWED",
"rejectedAt": "layer-3-task-keyword",
"matchedKeyword": "删除"
}
注意:这个请求虽然环境是 test、URL 是 localhost,但仍然被第三层拦住了。测试环境不等于可以随便操作,危险动作在任何环境下都需要额外审批。
计划生成之后,由 BrowserRunService 负责执行:
复制代码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 的能力"转移到了"人的授权"。
Browser Agent 在企业中可能有三种触发方式,每种方式的调用链路和适用场景不同:
复制代码LLM 生成测试用例(自然语言)
↓
用例评估系统判断:需要操作后台验证?
↓
构造 BrowserTestRequest(environment=test)
↓
BrowserPlanService.createPlan(request)
↓
BrowserRunService.run(plan, confirmedBy="auto-test-runner")
↓
截图 → 存入测试报告
这种场景适用于自动化回归测试:AI 生成用例 → 自动执行 → 自动截图存证。全程无人干预,适合高频执行的冒烟测试。
注意事项: confirmedBy 字段不能为空,此时传入一个系统级的虚拟用户名(如 auto-test-runner),并在日志中记录该操作由哪个测试用例触发,便于事后审计。
复制代码测试工程师 → 打开操作界面 → 填写任务描述
↓
系统返回待确认的 Plan(状态=PENDING_CONFIRMATION)
↓
测试工程师查看计划,确认操作安全
↓
输入 confirmedBy,提交执行
↓
执行并截图 → 人工复审截图
这是最安全的接入方式。Browser Agent 在人工确认模式下,本质上是一个"高级录屏工具"——人决定做什么,AI 来执行并记录。
复制代码CI Pipeline → 调用 REST API
↓
构造 BrowserTestRequest
(带环境标识、目标 URL、任务描述、CI 触发者身份)
↓
BrowserPlanService + BrowserRunService
↓
结果写入 CI 日志,截图作为制品存档
↓
Pipeline 根据结果决定:通过 / 失败
这种场景适合 CI 阶段的 UI 自动化测试替代。但要注意:CI 触发的 confirmedBy 通常是 CI 系统账号(如 jenkins-agent),必须有完整的操作日志和截图留存,否则出了事故无法回溯。
很多团队用 Browser Agent 只关注"AI 能不能点对",忽略了截图作为测试证据的复盘价值。
一个截图能告诉我们什么?
在企业实践中,建议把截图路径纳入测试报告的结构化字段中,而不是简单存成文件:
复制代码{
"testCaseId": "TC-ORDER-001",
"executedAt": "2026-06-15T10:30:00Z",
"screenshotPath": "artifacts/screenshots/plan-001-orderDetailPanel.png",
"planStatus": "PASSED",
"confirmedBy": "qa-user",
"plan": { ... }
}
这样每次执行都有完整的证据链可查。
Browser Agent 的能力是通用的,但企业的使用场景一定是受限的。如果不做好环境限制和操作限制,AI 迟早会访问到它不该访问的页面。限制比能力更重要。
startsWith("http://localhost") 这个白名单在本地开发时很方便,但如果你的测试环境有多个服务分布在不同机器上,这个白名单就不够用了。建议从一开始就设计一个可配置的 URL 白名单服务,而不是硬编码 localhost。
confirmedBy 只是一个字符串,任何人都可以传 confirmedBy="admin"。需要结合 SSO/权限系统,验证 confirmedBy 对应的账号是否有执行该操作的权限,而不是仅靠一个字段名来挡事。
即使请求级拦截全部通过,LLM 在生成具体 step 时仍然可能生成危险动作(如把"查看详情"误解为"删除记录")。动作级兜底拦截是最后的防线,但不是银弹。 建议在高风险操作场景下,保留人工审核 step 列表的环节,而不是全自动执行。
单独存截图而不记录"这是哪个 Plan、谁触发的、哪个环节出问题",三个月后这些截图就变成了无法解读的垃圾文件。截图必须和执行上下文一起存储,至少包含 Plan ID、触发者、执行时间、Plan 状态。
Demo 阶段的截图和操作是抽象的,实际落地需要接入 Playwright(或 Selenium)来实现真实的浏览器控制:
复制代码BrowserRunService.executeStep(step)
↓
PlaywrightClient.click(locator) // CLICK
PlaywrightClient.fill(locator, value) // TYPE
PlaywrightClient.screenshot(element) // SCREENSHOT
Playwright 的 locator 策略建议使用 ARIA role + name,而不是 XPath,这样对页面结构变化的鲁棒性更强。
把 Browser Agent 嵌入现有的 CI 流水线(推荐在集成测试阶段,而非单元测试阶段):
复制代码# .github/workflows/browser-test.yml
- name: Run Browser Agent Tests
run: |
curl -X POST
-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 每次都从零理解任务。建议预置一套测试用例库,每条用例包含:
AI 的工作是填充参数并执行,而不是每次都重新设计操作流程。
Browser Agent 执行失败后,截图是异步生成的,工程师可能不会主动去查看。建议:
网络波动、页面加载慢、元素定位失败都可能导致 step 执行失败。重试策略建议:
复制代码executeStep(step)
↓ 失败
重试 1(等待 2s)→ 执行
↓ 失败
重试 2(等待 5s)→ 执行
↓ 失败
标记 step 为 FAILED,记录错误截图,终止 plan
同时要注意:重试不等于重复执行同一个 step,如果是因为页面状态问题导致的失败,重复执行相同的操作可能产生累积效应(如重复提交表单)。建议在重试前先截一张图,确认页面状态后再决定下一步。
Browser Agent 的核心价值不是"让 AI 帮你点网页",而是把 AI 操作后台的风险变成可配置、可审计、可拦截的确定性流程。掌握这个思路,你就能理解为什么安全策略要前置到计划生成阶段,为什么需要请求级和动作级两层拦截,为什么 confirmedBy 不是可选参数而是必填项。
下一讲我们将把视角从"测试"转向"生产",看看在非测试环境下,如何用受控的方式让 AI 真正参与业务决策——包括本讲中被拦截的那些高风险操作,在什么条件下才能被安全地解锁。
相关源码文件:
| 文件 | 职责 |
|---|---|
BrowserSafetyPolicy.java | 三层请求级拦截 + 单步动作级拦截 |
BrowserPlanService.java | 计划生成、参数提取、四步计划组装 |
BrowserRunService.java | 执行引擎、确认检查、截图记录 |
BrowserTestRequest.java | 请求数据结构 |
BrowserPlan.java | 计划数据结构(含 rejected 标记) |
BrowserStep.java | 单步操作数据 |
ScreenshotRecorder.java | 截图持久化 |
前置回顾:
SQL Agent:参数提取的设计思路与本讲 extractOrderId 一脉相承AI 工单助手:多 Agent 协作框架是 Browser Agent 的基础设施