从“观察—决策—行动”到可验证的浏览器 Agent
使用 OpenAI Agents SDK、Playwright MCP 与一个客服控制台,搭建最小可运行闭环
- 原文:How to Give an LLM Agent a Browser
- 作者:Shuai Guo
- 来源:Towards Data Science
- 发布时间:2026-07-26
- 原文示例仓库:ShuaiGuo16/llm-browser-agent
- 本教程版本检查日期:2026-07-29
- 本文性质:基于原文方法整理,并加入离线 Mock、失败分流与生产安全边界
你会完成什么
本文分三条路径:
- 离线成功案例:不用模型、浏览器或 API Key,先跑通“观察—决策—行动—验证”闭环。
- 失败分流案例:库存不足时停止自动处理,返回人工复核,而不是硬做决定。
- 真实浏览器扩展:用 OpenAI Agents SDK 启动 Playwright MCP,让模型在本地客服页面中读取结构化状态、点击、填写、提交并复核审计日志。
前两条路径已经实际执行验证;第三条需要 Azure OpenAI 凭证和本机 Chrome,本文只完成接口与代码静态层面的教程整理,不宣称真实模型运行已验证。
原文方法覆盖清单
| 原文方法 | 是否纳入 | 落点 | 若遗漏原因 |
|---|---|---|---|
| 将浏览器 Agent 建模为观察—决策—行动循环 | 是 | 离线 Mock 与真实 Agent 架构 | — |
| 区分观察通道与行动通道 | 是 | 架构说明、MockBrowser、Playwright MCP | — |
| 使用结构化页面状态和元素级动作 | 是 | 本地客服页面与 Playwright MCP | — |
| 使用静态客服控制台演示工单处理 | 是 | toy_app/index.html |
— |
| OpenAI Agents SDK + Azure OpenAI Responses API | 是 | real_agent.py |
— |
| 通过 stdio 启动 Playwright MCP | 是 | MCPServerStdio 配置 |
— |
| 用任务提示传入 URL、工单和完成标准 | 是 | TASK 模板 |
— |
设置 max_turns |
是 | 真实 Agent 限制为 20 轮 | — |
| 提交后检查状态与审计日志 | 是 | Mock validator、任务提示和验收清单 | — |
| 扩展到通用桌面 computer use | 否 | — | 本教程只覆盖浏览器,不把文章中的架构推演写成已验证实现 |
一、先理解最小架构
原文的核心不是某个 SDK,而是下面这个闭环:
任务 + 当前页面状态
↓
观察
↓
解释状态并决定动作
↓
执行动作
↓
产生新的页面状态
↓
验证完成 / 继续循环 / 转人工它至少需要两个通道:
- 观察通道:截图、DOM、无障碍树或其他结构化页面状态。
- 行动通道:鼠标键盘坐标、元素引用,或更高层的浏览器命令。
本文采用原文选择的组合:结构化页面状态 + 元素级动作。Playwright MCP 默认通过无障碍快照暴露页面,并给可交互元素分配引用,使 Agent 不必只靠截图猜坐标。
二、项目结构
建立下面的目录:
browser-agent-tutorial/
├── cases/
│ ├── case.json
│ └── case-out-of-stock.json
├── toy_app/
│ └── index.html
├── mock_loop.py
├── real_agent.py
└── pyproject.toml创建目录:
mkdir -p browser-agent-tutorial/{cases,toy_app}
cd browser-agent-tutorial三、案例 1:离线跑通成功闭环
这一层是实践扩展,不是原文代码。目的不是模拟模型能力,而是先固定状态、动作、终止条件和验收规则,避免一上来就把 SDK、模型、浏览器和业务逻辑混在一起调试。
3.1 准备工单输入
文件:cases/case.json
{
"case_id": "CASE-4107",
"order_id": "ORD-1042",
"issue": "wrong_item",
"replacement_in_stock": true,
"policy": "wrong_item_and_in_stock_means_replacement"
}3.2 实现 Mock 浏览器与决策循环
文件:mock_loop.py
from __future__ import annotations
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
class NeedsHumanReview(RuntimeError):
pass
@dataclass
class MockBrowser:
case: dict[str, Any]
selected: bool = False
resolution: str | None = None
note: str = ""
status: str = "Open"
audit_log: list[str] = field(default_factory=list)
def observe(self) -> dict[str, Any]:
return {
"selected": self.selected,
"case": self.case,
"resolution": self.resolution,
"note": self.note,
"status": self.status,
"audit_log": list(self.audit_log),
}
def act(self, name: str, value: str | None = None) -> None:
if name == "select_case":
self.selected = True
elif name == "choose_resolution":
self.resolution = value
elif name == "add_note":
self.note = value or ""
elif name == "submit":
if not self.resolution or not self.note:
raise ValueError("resolution and note are required")
self.status = "Resolved"
self.audit_log.append(f"resolution={self.resolution}; note={self.note}")
else:
raise ValueError(f"unknown action: {name}")
def decide(state: dict[str, Any]) -> tuple[str, str | None]:
case = state["case"]
if not state["selected"]:
return "select_case", None
if case["issue"] != "wrong_item" or not case["replacement_in_stock"]:
raise NeedsHumanReview("policy or inventory does not allow automatic replacement")
if state["resolution"] is None:
return "choose_resolution", "Replacement"
if not state["note"]:
return "add_note", "Wrong item confirmed; replacement available under policy."
if state["status"] != "Resolved":
return "submit", None
return "done", None
def validate(browser: MockBrowser) -> None:
state = browser.observe()
assert state["status"] == "Resolved"
assert state["resolution"] == "Replacement"
assert state["audit_log"], "audit log must not be empty"
def main() -> int:
case_path = Path(sys.argv[1] if len(sys.argv) > 1 else "cases/case.json")
browser = MockBrowser(json.loads(case_path.read_text(encoding="utf-8")))
try:
for turn in range(1, 7):
action, value = decide(browser.observe())
print(f"turn={turn} action={action} value={value}")
if action == "done":
validate(browser)
print("STATUS=passed case=CASE-4107 resolution=Replacement audit=present")
return 0
browser.act(action, value)
except NeedsHumanReview as exc:
print(f"STATUS=needs_human_review reason={exc}")
return 2
print("STATUS=failed reason=max_turns_exceeded")
return 1
if __name__ == "__main__":
raise SystemExit(main())3.3 运行并验收
python3 mock_loop.py cases/case.json实际验证输出:
turn=1 action=select_case value=None
turn=2 action=choose_resolution value=Replacement
turn=3 action=add_note value=Wrong item confirmed; replacement available under policy.
turn=4 action=submit value=None
turn=5 action=done value=None
STATUS=passed case=CASE-4107 resolution=Replacement audit=present验收条件:
- 进程退出码为
0; - 最终状态是
Resolved; - 解决方式是
Replacement; - 审计日志非空;
- 循环在轮次上限前结束。
四、案例 2:库存不足时停止自动处理
真实 Agent 不应把“必须完成任务”理解为“无论条件是否满足都要提交”。我们添加一个反向案例。
文件:cases/case-out-of-stock.json
{
"case_id": "CASE-4108",
"order_id": "ORD-1043",
"issue": "wrong_item",
"replacement_in_stock": false,
"policy": "wrong_item_and_in_stock_means_replacement"
}运行:
python3 mock_loop.py cases/case-out-of-stock.json实际验证输出:
turn=1 action=select_case value=None
STATUS=needs_human_review reason=policy or inventory does not allow automatic replacement这里约定退出码为 2,表示“需要人工复核”,不是程序崩溃。自动化调用方可明确分流:
python3 mock_loop.py cases/case-out-of-stock.json
status=$?
if [ "$status" -eq 2 ]; then
printf '%s\n' 'Route this case to a human reviewer.'
fi本文提炼:浏览器 Agent 的终止状态不应只有“成功”和“超时”,至少还应有“拒绝执行”“需要澄清”“需要人工审批”等业务状态。
五、案例 3:搭建真实可操作的本地客服页面
下面开始进入原文路线。页面仍是静态演示,不含登录、数据库、并发或真实客户数据。
文件:toy_app/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Support Console</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; max-width: 760px; margin: 40px auto; padding: 0 20px; }
section { border: 1px solid #ccc; border-radius: 10px; padding: 18px; margin: 16px 0; }
textarea { width: 100%; min-height: 90px; }
button { margin: 6px 6px 6px 0; padding: 8px 14px; }
#audit-log { white-space: pre-wrap; background: #f5f5f5; padding: 12px; }
</style>
</head>
<body>
<h1>Customer Support Console</h1>
<section aria-labelledby="case-title">
<h2 id="case-title">CASE-4107 · Order ORD-1042</h2>
<p>Issue: Customer received the wrong item.</p>
<p>Expected item: Blue Mug. Received item: Red Mug.</p>
<p>Inventory: Blue Mug replacement is available.</p>
<p>Policy: Wrong item + replacement in stock → Replacement.</p>
<p>Status: <strong id="status">Open</strong></p>
</section>
<section aria-labelledby="resolution-title">
<h2 id="resolution-title">Resolution</h2>
<button id="replacement" type="button">Choose Replacement</button>
<p>Selected action: <strong id="selected-action">None</strong></p>
<label for="note">Internal note</label>
<textarea id="note" placeholder="Explain why this resolution follows policy"></textarea>
<br>
<button id="submit" type="button">Submit resolution</button>
<p id="message" role="status"></p>
</section>
<section aria-labelledby="audit-title">
<h2 id="audit-title">Audit log</h2>
<div id="audit-log">No entries</div>
</section>
<script>
let resolution = null;
const status = document.querySelector("#status");
const selectedAction = document.querySelector("#selected-action");
const note = document.querySelector("#note");
const message = document.querySelector("#message");
const auditLog = document.querySelector("#audit-log");
document.querySelector("#replacement").addEventListener("click", () => {
resolution = "Replacement";
selectedAction.textContent = resolution;
message.textContent = "";
});
document.querySelector("#submit").addEventListener("click", () => {
if (!resolution || !note.value.trim()) {
message.textContent = "Resolution and internal note are required.";
return;
}
status.textContent = "Resolved";
auditLog.textContent = `resolution=${resolution}; note=${note.value.trim()}`;
message.textContent = "Resolution recorded successfully.";
});
</script>
</body>
</html>启动页面:
python3 -m http.server 8000 --bind 127.0.0.1 --directory toy_app在浏览器打开:
http://127.0.0.1:8000手工验收一次:
- 点击 Choose Replacement;
- 填写内部备注;
- 点击 Submit resolution;
- 确认状态变为
Resolved; - 确认 Audit log 出现记录。
如果遗漏解决方式或备注,页面必须显示 Resolution and internal note are required.,且状态保持 Open。
六、接入 OpenAI Agents SDK 与 Playwright MCP
6.1 依赖与版本
2026-07-29 查询到的版本:
openai-agents==0.19.0@playwright/[email protected]
文件:pyproject.toml
[project]
name = "browser-agent-tutorial"
version = "0.1.0"
description = "A bounded browser-agent tutorial based on OpenAI Agents SDK and Playwright MCP"
requires-python = ">=3.13"
dependencies = [
"openai-agents==0.19.0",
]安装 Python 依赖:
uv sync确认 Node.js 与 npx 可用:
node --version
npx --version实践扩展:原文使用
@playwright/mcp@latest。教程改为固定版本,避免同一份代码因自动升级而出现不可复现的行为;升级时应显式改版本并重新验证。
6.2 配置凭证
不要把 API Key 写进代码或提交到 Git。下面沿用原文的 Azure OpenAI 接入方式:
export OPENAI_API_KEY='replace-with-your-key'
export OPENAI_API_VERSION='replace-with-your-api-version'
export OPENAI_API_BASE='https://your-resource.openai.azure.com/'
export OPENAI_MODEL='your-azure-deployment-name'
export APP_URL='http://127.0.0.1:8000'OPENAI_MODEL 应填写 Azure 部署名,而不是盲目复制文章中的模型字符串。
6.3 定义真实 Agent
文件:real_agent.py
from __future__ import annotations
import asyncio
import os
from agents import (
Agent,
ModelSettings,
Runner,
set_default_openai_api,
set_default_openai_client,
)
from agents.mcp import MCPServerStdio
from openai import AsyncAzureOpenAI
from openai.types.shared import Reasoning
AGENT_INSTRUCTIONS = """
You can interact with a web browser.
Stay inside the supplied APP_URL.
Do not submit a resolution unless the visible policy and inventory support it.
After submitting, verify both the case status and the audit log.
If required evidence is missing or contradictory, stop and request human review.
""".strip()
async def main() -> None:
app_url = os.environ.get("APP_URL", "http://127.0.0.1:8000")
model = os.environ["OPENAI_MODEL"]
azure_client = AsyncAzureOpenAI(
api_key=os.environ["OPENAI_API_KEY"],
api_version=os.environ["OPENAI_API_VERSION"],
azure_endpoint=os.environ["OPENAI_API_BASE"],
)
set_default_openai_client(azure_client)
set_default_openai_api("responses")
playwright_server = MCPServerStdio(
name="Playwright MCP",
params={
"command": "npx",
"args": [
"-y",
"@playwright/[email protected]",
"--browser",
"chrome",
],
},
)
agent = Agent(
name="Support Console Browser Agent",
model=model,
model_settings=ModelSettings(
reasoning=Reasoning(effort="medium"),
),
instructions=AGENT_INSTRUCTIONS,
mcp_servers=[playwright_server],
)
task = f"""
Open {app_url} and resolve CASE-4107 for order ORD-1042.
The customer received the wrong item. Use only information visible in the
application to determine whether a replacement is allowed. If allowed, choose
the resolution, add a concise internal note, and submit it.
Before reporting completion, verify that:
1. the case status is Resolved;
2. the selected action is Replacement;
3. the audit log contains the resolution entry.
If any condition is not satisfied, do not claim success.
""".strip()
async with playwright_server:
result = await Runner.run(agent, task, max_turns=20)
print("FINAL_OUTPUT")
print(result.final_output)
print("TRACE_ITEMS")
for item in result.new_items:
print(type(item).__name__, item)
if __name__ == "__main__":
asyncio.run(main())6.4 运行真实浏览器 Agent
先保持客服页面服务器运行,再开另一个终端:
uv run python real_agent.py预期输出形态如下;具体文字由模型决定,不能要求逐字一致:
FINAL_OUTPUT
Resolved CASE-4107 for order ORD-1042 with Replacement.
Verified status=Resolved and confirmed the audit log entry.
TRACE_ITEMS
...七、不要只看最终回复:验证真实页面状态
“Agent 说完成了”不是完成证据。至少检查四层:
| 验证层 | 要检查什么 | 失败时如何处理 |
|---|---|---|
| 任务边界 | 是否只访问 APP_URL |
立即停止,收紧允许域名与工具权限 |
| 决策依据 | 是否读取问题类型、库存和政策 | 缺证据时转人工,不提交 |
| 页面结果 | 状态是否为 Resolved,动作是否为 Replacement |
重新观察;不要相信最终文字 |
| 审计证据 | Audit log 是否出现对应记录 | 标记失败,保留 trace 供排查 |
同时检查 result.new_items:
- Agent 实际调用了哪些浏览器工具;
- 每次动作前看到了什么页面状态;
- 是否发生重复点击、无效输入或越界导航;
- 最后一次观察是否发生在提交之后。
八、常见失败与排查
8.1 页面能打开,但 Agent 找不到按钮
可能原因:
- 页面没有可访问名称;
- 按钮由复杂组件渲染,快照里不可见;
- Agent 仍在旧页面状态上决策。
处理:
- 使用语义化
button、label、role="status"; - 提交或导航后重新获取页面状态;
- 先查看 Playwright MCP 返回的无障碍快照和元素引用。
8.2 npx 每次拿到不同版本
不要长期使用:
@playwright/mcp@latest固定到验证过的版本,并把升级作为单独变更。本文示例固定为 0.0.78,它只是 2026-07-29 的检查结果,不是永久推荐版本。
8.3 达到 max_turns
不要简单提高上限。先检查:
- 任务提示是否缺少完成标准;
- 页面是否没有明确反馈;
- Agent 是否重复观察或重复点击;
- 工具调用是否失败但没有被识别;
- 是否应转人工,而不是继续循环。
8.4 Agent 提交了错误处理结果
立即停止自动写操作。检查并补齐:
- 政策证据是否明确展示;
- 库存、权限和工单状态是否来自同一时点;
- 高风险动作是否需要人工确认;
- 是否存在提交前 dry-run 或候选预览;
- 是否有可撤销动作或人工恢复流程。
8.5 代码因 SDK 版本变化无法运行
文章和教程都依赖快速变化的 SDK。先核对:
uv run python -c "import agents; print(agents.__file__)"
npx -y @playwright/[email protected] --help若接口已变化,应按当前官方文档调整,并同时更新依赖版本、调用方和验证证据,不要只修改一个 import。
九、从演示走向真实系统的安全边界
原文展示的是无后端、无数据库的静态页面。它不能证明方案已经适合真实客服后台。
进入真实环境前,至少补齐:
- 最小权限:测试账号只允许访问指定队列和字段。
- 只读先行:先让 Agent 读取和生成候选方案,不直接提交。
- 人工确认:退款、支付、账户、隐私和不可逆操作必须审批。
- 确定性校验:提交前校验政策、库存、工单版本和必填字段。
- 幂等与并发控制:避免重复提交或覆盖人工刚完成的修改。
- 审计与 trace:保存观察、动作、工具错误和最终验证结果,并对敏感字段脱敏。
- 失败分流:把歧义、缺证据、权限不足和外部系统错误路由到人工。
- 回滚能力:明确哪些操作可撤销、由谁撤销、如何验证恢复成功。
十、最终验收清单
离线 Mock
- 成功案例实际执行,退出码为
0 - 最终状态、处理动作和审计日志经过断言
- 库存不足案例实际执行并进入人工复核分支
- Mock 不需要 API Key、浏览器或网络
真实浏览器合同
- 本地客服页面可访问
- Azure OpenAI 环境变量已设置,代码中无硬编码密钥
- Playwright MCP 版本已固定
- Agent 仅允许访问测试页面
- 设置了
max_turns - 提交后重新检查状态和审计日志
- 已阅读工具调用 trace,而非只看最终回复
- 失败、歧义和高风险动作会转人工
验证证据级别
static_publish_ok:由发布步骤验证 Markdown、独立 HTML、目录、复制代码控件和远端非空文件。mock_code_run_ok:true。成功与库存不足两条离线路径均已执行。real_backend_contract_ok:仅教程级静态合同。代码保持环境变量、轮次上限、提交后验证和同一执行入口。real_backend_smoke_ok:false。本文未使用真实 Azure OpenAI 凭证运行 Agent,也未把静态演示冒充生产验证。
结论
可维护的浏览器 Agent 不是“给模型一个浏览器”这么简单,而是把观察、动作、业务规则、终止条件和结果验证做成一个有边界的闭环。
推荐顺序是:
- 用离线 Mock 固定状态和验收规则;
- 用反向案例验证人工分流;
- 在静态测试页面接入 Playwright MCP;
- 最后才连接真实模型;
- 在任何生产写操作前补齐权限、审批、审计和回滚。
这样即使模型、SDK 或网页发生变化,你仍然知道系统在哪一步失败,以及它是否真的完成了任务。