从“观察—决策—行动”到可验证的浏览器 Agent

使用 OpenAI Agents SDK、Playwright MCP 与一个客服控制台,搭建最小可运行闭环

你会完成什么

本文分三条路径:

  1. 离线成功案例:不用模型、浏览器或 API Key,先跑通“观察—决策—行动—验证”闭环。
  2. 失败分流案例:库存不足时停止自动处理,返回人工复核,而不是硬做决定。
  3. 真实浏览器扩展:用 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,而是下面这个闭环:

任务 + 当前页面状态
        ↓
      观察
        ↓
  解释状态并决定动作
        ↓
      执行动作
        ↓
   产生新的页面状态
        ↓
  验证完成 / 继续循环 / 转人工

它至少需要两个通道:

本文采用原文选择的组合:结构化页面状态 + 元素级动作。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

验收条件:

四、案例 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

手工验收一次:

  1. 点击 Choose Replacement
  2. 填写内部备注;
  3. 点击 Submit resolution
  4. 确认状态变为 Resolved
  5. 确认 Audit log 出现记录。

如果遗漏解决方式或备注,页面必须显示 Resolution and internal note are required.,且状态保持 Open

六、接入 OpenAI Agents SDK 与 Playwright MCP

6.1 依赖与版本

2026-07-29 查询到的版本:

文件: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

八、常见失败与排查

8.1 页面能打开,但 Agent 找不到按钮

可能原因:

处理:

8.2 npx 每次拿到不同版本

不要长期使用:

@playwright/mcp@latest

固定到验证过的版本,并把升级作为单独变更。本文示例固定为 0.0.78,它只是 2026-07-29 的检查结果,不是永久推荐版本。

8.3 达到 max_turns

不要简单提高上限。先检查:

8.4 Agent 提交了错误处理结果

立即停止自动写操作。检查并补齐:

8.5 代码因 SDK 版本变化无法运行

文章和教程都依赖快速变化的 SDK。先核对:

uv run python -c "import agents; print(agents.__file__)"
npx -y @playwright/[email protected] --help

若接口已变化,应按当前官方文档调整,并同时更新依赖版本、调用方和验证证据,不要只修改一个 import。

九、从演示走向真实系统的安全边界

原文展示的是无后端、无数据库的静态页面。它不能证明方案已经适合真实客服后台。

进入真实环境前,至少补齐:

  1. 最小权限:测试账号只允许访问指定队列和字段。
  2. 只读先行:先让 Agent 读取和生成候选方案,不直接提交。
  3. 人工确认:退款、支付、账户、隐私和不可逆操作必须审批。
  4. 确定性校验:提交前校验政策、库存、工单版本和必填字段。
  5. 幂等与并发控制:避免重复提交或覆盖人工刚完成的修改。
  6. 审计与 trace:保存观察、动作、工具错误和最终验证结果,并对敏感字段脱敏。
  7. 失败分流:把歧义、缺证据、权限不足和外部系统错误路由到人工。
  8. 回滚能力:明确哪些操作可撤销、由谁撤销、如何验证恢复成功。

十、最终验收清单

离线 Mock

真实浏览器合同

验证证据级别

结论

可维护的浏览器 Agent 不是“给模型一个浏览器”这么简单,而是把观察、动作、业务规则、终止条件和结果验证做成一个有边界的闭环。

推荐顺序是:

  1. 用离线 Mock 固定状态和验收规则;
  2. 用反向案例验证人工分流;
  3. 在静态测试页面接入 Playwright MCP;
  4. 最后才连接真实模型;
  5. 在任何生产写操作前补齐权限、审批、审计和回滚。

这样即使模型、SDK 或网页发生变化,你仍然知道系统在哪一步失败,以及它是否真的完成了任务。