1. 项目概述:当AI Agent学会“驾驭”浏览器

最近在折腾一个挺有意思的项目,核心就一句话:让AI Agent能像真人一样,在浏览器里“看”网页、“想”事情、“做”操作。这听起来有点像传统的网页自动化测试,比如Selenium或者Playwright,但内核完全不同。传统的自动化脚本是“死”的,写好了点哪里、输什么,它就按部就班执行。而基于浏览器的AI Agent,我们称之为“Harness Engineering”的实践,目标是打造一个“活”的智能体。它能够理解网页的语义结构,根据自然语言指令或既定目标,自主决策下一步该点击哪个按钮、在哪个输入框填写什么内容,甚至处理验证码、弹窗等意外情况。

这背后的驱动力,是AI Agent技术的快速演进。过去,我们谈论Agent,可能更多是在聊天机器人或者代码生成的语境里。但现在,Agent正在被赋予“手”和“眼睛”,去操作真实世界的数字界面——浏览器无疑是其中最重要、最通用的一个。无论是自动化处理繁琐的日常办公流程(比如每天登录十几个系统填报数据),还是构建一个能够自主搜集、分析网络信息的智能助手,甚至是开发一个能模拟用户行为进行复杂业务测试的AI测试员,这个方向都充满了想象力和实用价值。

简单来说,这个项目就是为AI Agent打造一套“神经系统”和“运动系统”,让它的“大脑”(通常是大型语言模型)发出的指令,能够精准地控制浏览器这个“身体”,完成从信息感知到动作执行的全链路闭环。接下来,我会详细拆解我是如何设计并实现这套系统的,包括核心架构、工具选型、实操步骤以及一路踩坑填坑的心得。

2. 核心架构与工具选型解析

要实现一个基于浏览器的AI Agent,不能只靠一个模型或一个库。它需要一个分层、协作的架构。我的设计主要分为三层: 感知层、决策层和执行层

2.1 感知层:让AI“看见”网页

AI Agent要操作浏览器,第一步是必须理解当前页面上有什么。这里不能简单截个图丢给视觉模型(VLM),虽然多模态模型如GPT-4V能力很强,但成本高、速度慢,且无法精确获取可操作元素(如输入框的ID、按钮的XPath)的元数据。

我的方案是:DOM树解析 + 关键信息抽取。

  • 核心工具:Playwright/Puppeteer 我最终选择了Playwright,而不是更老牌的Selenium。原因有几个:Playwright对现代Web框架(React, Vue, Angular)支持更好,能自动等待元素加载,内置了录制生成脚本的功能,并且其API设计更现代、一致。它提供了强大的页面内容获取能力。

  • 信息抽取策略:

    1. 获取纯净DOM与计算样式 :通过Playwright获取页面的完整HTML。但原始DOM树非常冗杂,包含大量脚本、样式标签和不可见元素。我编写了一个过滤函数,只保留可见的、具有交互可能性的元素(如 input , button , a , select 等),并排除掉 display: none visibility: hidden 的元素。
    2. 构建结构化页面描述 :对于过滤后的每个元素,提取其关键属性,形成一个结构化的JSON对象。这个对象通常包括:
      • tagName : 标签名(如 “button”, “input”)。
      • attributes : 关键属性(如 id , name , class , type , placeholder , aria-label )。
      • text : 元素内的可见文本(修剪后)。
      • boundingBox : 元素在视口中的位置和大小(用于辅助描述或未来可能的视觉校验)。
      • xpath / selector : 该元素的唯一CSS选择器或XPath,这是后续执行操作的“坐标”。
    3. 生成自然语言描述 :将上一步的结构化JSON,转换(或总结)成一段自然语言描述,提供给决策层的LLM。例如:“页面顶部有一个导航栏,包含‘首页’、‘产品’、‘关于我们’三个链接。页面中央是一个标题为‘用户登录’的卡片。卡片内有一个标签为‘用户名’的文本输入框(ID: username),一个标签为‘密码’的密码输入框(ID: password),以及一个文本为‘登录’的按钮。”

注意 :这个描述生成环节至关重要。描述既要简洁,又要包含所有关键操作元素和其语义。我尝试过让LLM直接解析简化后的DOM JSON,但发现先将其转换为一段规整的文本描述,能显著提高后续决策的准确性和降低提示词工程的复杂度。

2.2 决策层:AI的“大脑”与“规划器”

这是整个系统的智能核心。它的任务是:接收用户的自然语言指令(如“帮我登录邮箱”)和感知层提供的页面描述,然后输出一个具体的、可执行的“动作序列”。

  • 核心工具:大型语言模型(LLM) 我主要使用GPT-4 Turbo和Claude 3系列模型进行实验。它们的推理和规划能力足以应对大多数网页操作任务。对于成本敏感的场景,DeepSeek等开源模型经过精调后也是不错的选择。

  • 决策流程设计:

    1. 目标分解 :LLM首先将高层级指令(如“查询北京明天天气”)分解为原子步骤(如:1. 导航到天气网站;2. 在搜索框输入“北京”;3. 点击搜索或确认按钮;4. 提取天气结果)。
    2. 上下文感知 :将当前页面描述、历史操作步骤(记忆)以及当前指令一起构成提示词(Prompt),输入给LLM。
    3. 动作生成 :要求LLM输出严格格式化的动作指令。我定义了一个简单的JSON动作协议:
      {
        "action": "click | fill | scroll | wait | extract_text | ...",
        "selector": "CSS选择器或XPath",
        "value": "仅fill动作需要,表示要输入的文字",
        "reasoning": "模型做出此决策的简要原因,用于调试和迭代"
      }
      
    4. 安全与验证 :在复杂操作中,我会引入一个“验证步骤”。例如,在执行“点击删除按钮”前,让LLM再次确认当前页面上下文和元素文本,或者设计一个二次确认的子流程,防止危险操作。

2.3 执行层:精准的“手”

决策层输出动作指令后,需要可靠地执行。这就是Playwright再次登场的时候。

  • 动作映射 :将标准的动作类型( click , fill , scroll 等)映射到Playwright具体的API调用(如 page.click(selector) , page.fill(selector, value) )。
  • 健壮性处理
    • 等待与重试 :在执行任何操作前,确保目标元素已经处于可交互状态。Playwright内置的 locator 方法配合 waitFor 状态非常好用。我会为关键操作(如点击登录按钮后跳转)设置显式等待。
    • 异常捕获与恢复 :网络延迟、元素加载失败、弹窗干扰都是家常便饭。执行层需要捕获这些异常,并将错误信息(如“元素未找到”、“操作超时”)连同当前页面快照和描述,反馈给决策层。决策层LLM可以根据这些新信息重新规划或尝试替代方案(例如,“登录按钮没找到,但发现一个‘Sign In’链接,是否点击?”)。
  • 状态管理 :记录浏览器的状态,如当前URL、cookies、localStorage等。这在需要保持会话的多步骤任务中非常重要。

工具链总结

  • 浏览器自动化 :Playwright(首选,功能全面,生态好)
  • AI决策引擎 :OpenAI GPT-4/3.5-Turbo, Anthropic Claude 3,或本地部署的DeepSeek、Qwen等。
  • 应用框架 :使用Python的 asyncio 进行异步编排,因为Playwright和LLM API调用都是IO密集型操作。FastAPI可以用于构建一个提供服务的Web接口。
  • 辅助工具 BeautifulSoup4 lxml 用于辅助解析HTML, Pydantic 用于验证动作JSON的格式。

3. 实操构建:从零实现一个登录自动化Agent

理论讲完了,我们动手搭一个最简单的例子:一个能自动登录某个论坛的AI Agent。假设我们不知道具体的登录页面结构。

3.1 环境准备与初始化

首先,安装核心依赖:

pip install playwright openai
playwright install chromium  # 安装浏览器驱动

初始化Playwright和浏览器上下文。我建议使用 chromium.launch_persistent_context 来保留用户数据目录,这样可以保存cookies,模拟真实用户会话。

import asyncio
from playwright.async_api import async_playwright
import openai
import json

# 初始化OpenAI客户端(示例,请使用你的API Key)
openai.api_key = "your-api-key"

async def init_browser():
    async with async_playwright() as p:
        # 启动一个带持久化上下文的浏览器,方便管理cookies
        user_data_dir = "./user_data"
        browser = await p.chromium.launch_persistent_context(
            user_data_dir,
            headless=False,  # 调试时设为False,可以看到浏览器操作
            args=["--disable-blink-features=AutomationControlled"]  # 尝试绕过一些简单的反自动化检测
        )
        page = await browser.new_page()
        return browser, page

3.2 实现感知函数

编写一个函数,用于获取当前页面的结构化描述。

async def get_page_description(page):
    # 执行JavaScript,获取页面中所有潜在可交互元素的信息
    elements_info = await page.evaluate("""
        () => {
            const interactives = ['input', 'button', 'a', 'select', 'textarea', '[role=\"button\"]', '[role=\"link\"]', '[contenteditable=\"true\"]'];
            const items = [];
            document.querySelectorAll('*').forEach(el => {
                // 基础过滤:可见且非禁用
                const style = window.getComputedStyle(el);
                const isVisible = style.display !== 'none' && style.visibility !== 'hidden' && el.offsetWidth > 0 && el.offsetHeight > 0;
                const isInteractive = interactives.some(sel => el.matches(sel));
                if (isVisible && isInteractive && !el.disabled) {
                    // 生成一个相对稳定的选择器(这里简化处理,生产环境需更健壮)
                    let selector = '';
                    if (el.id) {
                        selector = `#${el.id}`;
                    } else {
                        // 尝试用tag和类名构造,这不是最优方法,仅作演示
                        selector = el.tagName.toLowerCase();
                        if (el.className && typeof el.className === 'string') {
                            selector += '.' + el.className.trim().replace(/\\s+/g, '.');
                        }
                    }
                    items.push({
                        tag: el.tagName.toLowerCase(),
                        text: (el.innerText || el.value || el.placeholder || '').slice(0, 100).trim(),
                        selector: selector,
                        type: el.type || '',
                        placeholder: el.placeholder || '',
                        name: el.name || '',
                        id: el.id || '',
                    });
                }
            });
            return items;
        }
    """)
    
    # 将元素信息组织成自然语言描述
    description_parts = []
    for elem in elements_info[:20]:  # 限制数量,避免上下文过长
        desc = f"一个{elem['tag']}元素"
        if elem['text']:
            desc += f",文本内容为“{elem['text']}”"
        if elem['placeholder']:
            desc += f",占位符为“{elem['placeholder']}”"
        if elem['type']:
            desc += f",类型是{elem['type']}"
        if elem['selector']:
            desc += f"(选择器参考:{elem['selector']})"
        description_parts.append(desc)
    
    full_description = "当前页面主要包含以下可交互元素:\\n" + "\\n".join(description_parts)
    return full_description, elements_info  # 返回描述和原始元素信息

3.3 实现决策函数(调用LLM)

构建提示词,让LLM根据指令和页面描述决定下一步动作。

async def decide_next_action(user_goal, page_description, action_history):
    prompt = f"""
你是一个控制网页浏览器的AI助手。你的目标是:{user_goal}

以下是当前页面的描述:
{page_description}

你最近执行的操作历史(用于参考):
{json.dumps(action_history[-5:], indent=2, ensure_ascii=False)}

请根据目标和当前页面,决定下一个最合理的原子操作。
你只能输出一个JSON对象,格式必须严格如下:
{{
  "action": "click | fill | scroll_up | scroll_down | wait | navigate | finish",
  "selector": "一个CSS选择器字符串,用于定位元素。如果action是navigate或finish,此项可为空字符串。",
  "value": "仅当action为fill时需要,表示要输入的文本。否则为空字符串。",
  "reasoning": "用一句话解释为什么选择这个操作。"
}}

动作说明:
- click: 点击一个元素(按钮、链接等)。
- fill: 向输入框(input, textarea)填写文本。
- scroll_up/scroll_down: 向上/向下滚动页面。
- wait: 等待2秒(用于页面加载)。
- navigate: 导航到一个新URL(需要与user_goal结合,此处不常用)。
- finish: 任务已完成或无法继续。

请确保selector尽可能精确且稳定。优先使用id选择器(如#username),其次是name、class等。
现在,输出你的决策JSON:
"""
    
    response = await openai.ChatCompletion.acreate(
        model="gpt-4-turbo-preview", # 或 "gpt-3.5-turbo"
        messages=[{"role": "user", "content": prompt}],
        temperature=0.1, # 低温度,使输出更确定
    )
    
    try:
        decision = json.loads(response.choices[0].message.content)
        # 简单验证决策格式
        assert decision["action"] in ["click", "fill", "scroll_up", "scroll_down", "wait", "navigate", "finish"]
        return decision
    except (json.JSONDecodeError, AssertionError, KeyError) as e:
        print(f"LLM返回了无效的决策: {response.choices[0].message.content}")
        print(f"错误: {e}")
        # 返回一个安全的默认等待动作
        return {"action": "wait", "selector": "", "value": "", "reasoning": "解析LLM响应失败,等待后重试。"}

3.4 实现执行函数与主循环

将决策转化为真实的浏览器操作。

async def execute_action(page, decision):
    action = decision["action"]
    selector = decision["selector"]
    value = decision["value"]
    
    print(f"[执行] {action} on '{selector}' with value '{value}'。 原因:{decision['reasoning']}")
    
    try:
        if action == "click":
            if selector:
                await page.click(selector)
            else:
                print("警告:click动作缺少selector。")
        elif action == "fill":
            if selector and value is not None:
                await page.fill(selector, value)
            else:
                print("警告:fill动作缺少selector或value。")
        elif action == "scroll_down":
            await page.mouse.wheel(0, 300)  # 向下滚动
        elif action == "scroll_up":
            await page.mouse.wheel(0, -300) # 向上滚动
        elif action == "wait":
            await page.wait_for_timeout(2000) # 等待2秒
        elif action == "navigate":
            if value: # 这里用value存储URL
                await page.goto(value)
        elif action == "finish":
            print("任务完成或终止。")
            return "FINISH"
        await page.wait_for_timeout(500) # 每个动作后稍作停顿
        return "SUCCESS"
    except Exception as e:
        print(f"执行动作时出错: {e}")
        return f"ERROR: {str(e)}"

async def main_agent_loop(user_goal, start_url):
    browser, page = await init_browser()
    await page.goto(start_url)
    
    action_history = []
    max_steps = 15  # 防止无限循环
    step = 0
    
    while step < max_steps:
        step += 1
        print(f"\\n--- 步骤 {step} ---")
        
        # 1. 感知
        page_desc, _ = await get_page_description(page)
        print(f"页面感知完成。")
        
        # 2. 决策
        decision = await decide_next_action(user_goal, page_desc, action_history)
        print(f"决策: {decision['action']}")
        
        # 3. 执行
        result = await execute_action(page, decision)
        action_history.append({"step": step, "decision": decision, "result": result})
        
        # 检查是否完成或出错
        if result == "FINISH":
            print("Agent判定任务完成。")
            break
        if result.startswith("ERROR"):
            print(f"执行出错,尝试等待后继续...")
            await page.wait_for_timeout(3000)
            # 可以在这里加入更复杂的错误恢复逻辑
        
        # 简单防呆:如果连续三个动作都是wait或scroll,可能陷入循环,主动停止
        recent_actions = [h["decision"]["action"] for h in action_history[-3:]]
        if len(recent_actions) == 3 and all(a in ["wait", "scroll_up", "scroll_down"] for a in recent_actions):
            print("检测到可能的行为循环,主动终止。")
            break
    
    await browser.close()
    print("\\n操作历史:")
    for h in action_history:
        print(f"  步骤{h['step']}: {h['decision']['action']} - {h['result']}")

# 运行Agent
if __name__ == "__main__":
    asyncio.run(main_agent_loop(
        user_goal="登录这个网站,用户名是test_user,密码是demo123",
        start_url="https://example.com/login"  # 替换成真实的登录页面URL
    ))

这个简单的Agent已经具备了感知-决策-执行的基本循环。你可以看到,它通过LLM理解页面,并尝试找到登录框和按钮来完成操作。

4. 高级技巧与工程化挑战

实现基础循环只是第一步。要让这个AI Agent真正可靠、实用,还需要解决一系列工程挑战。

4.1 提升感知精度与效率

  • 更智能的元素选择器生成 :上面示例中的选择器生成非常简陋。在生产环境中,需要使用更稳定的算法来生成唯一的CSS选择器或XPath。可以考虑使用 playwright 自身的 locator API,或者开源库如 selector-generator
  • 视觉信息辅助 :对于纯CSS渲染的复杂组件(如Canvas图表)、验证码,或者当DOM无法准确反映视觉布局时,需要引入截图和视觉模型(VLM)。策略可以是:先用DOM定位大致区域,再对该区域截图,用VLM识别具体内容或状态。但这会大幅增加成本和延迟,需权衡使用。
  • 页面摘要与焦点识别 :不是所有元素都需要上报给LLM。可以先用一个轻量级模型或规则,识别页面的主要功能区(如导航栏、主内容区、侧边栏、弹窗),并优先提供焦点区域内的元素描述,减少LLM的上下文负担。

4.2 优化决策提示词与规划能力

  • 分层任务规划 :对于复杂任务(如“从A网站找到某产品价格,然后去B网站比价”),需要让LLM进行高层规划(High-level Planning),分解成多个子任务,每个子任务再进入上述的感知-决策-执行循环。这需要设计更复杂的提示词或使用智能体框架(如LangChain、AutoGen)的任务规划能力。
  • 记忆与状态管理 :Agent需要记住它做过什么、看到了什么。简单的做法是把历史动作和关键的页面快照/描述作为上下文传递给LLM。更复杂的需要向量数据库来存储和检索长期记忆,避免上下文窗口爆炸。
  • 工具使用(Tool Use) :将浏览器操作(click, fill)、信息查询(计算器、搜索)、条件判断等都抽象成“工具”,让LLM学会在合适的时候调用合适的工具。这符合当前AI Agent开发的主流范式(如OpenAI的Function Calling)。

4.3 增强执行的健壮性

  • 反自动化对抗 :很多网站会检测Playwright/Selenium等自动化工具。对策包括:使用 p.chromium.launch_persistent_context 并加载真实用户配置文件;添加 --disable-blink-features=AutomationControlled 等启动参数;随机化操作间隔和鼠标移动轨迹;必要时使用更底层的CDP协议或结合浏览器指纹混淆技术。
  • 异常处理与恢复策略 :建立一套分级恢复策略。元素找不到?可以尝试等待更长时间、滚动页面、使用更宽松的选择器,或者反馈给决策层“元素可能不存在,请重新评估”。遇到验证码?可以触发人工干预流程,或者集成付费的验证码识别服务。
  • 并行与异步控制 :一个强大的Agent可能需要同时管理多个标签页或浏览器实例。需要妥善利用 asyncio 进行并发控制,管理好各页面之间的状态隔离和数据同步。

4.4 监控、评估与持续迭代

  • 可观测性 :记录每个循环的页面截图、DOM描述、LLM的决策(包括其reasoning)、执行结果。这些日志对于调试和后续分析至关重要。
  • 成功率评估 :定义清晰的任务成功标准(如最终页面是否包含特定关键词、是否跳转到预期URL),并自动化计算任务成功率。
  • 提示词迭代 :根据失败案例,不断优化感知描述的方式和决策提示词。A/B测试不同的提示词模板对复杂任务成功率的影响。

5. 常见问题与实战避坑指南

在实际开发中,我遇到了不少坑,这里分享一些典型的解决方案。

问题1:LLM总是选错元素,比如该点“登录”却点了“注册”。

  • 原因 :页面描述中,“登录”和“注册”两个按钮的文本很接近,LLM可能混淆。或者选择器不够唯一。
  • 解决
    1. 丰富元素描述 :在生成描述时,不仅提供文本,还提供元素在页面上的相对位置(如“位于表单中央的蓝色按钮”)、邻近元素文本等上下文信息。
    2. 强化提示词 :在决策提示词中强调“请仔细比较所有选项,选择最符合目标‘登录’的那一个”。可以要求LLM在 reasoning 中详细写出排除其他选项的原因。
    3. 后置校验 :在执行关键动作前,增加一个“确认”步骤。例如,让LLM输出它认为要点击的元素的完整文本和选择器,然后用一个简单的规则或另一个轻量级模型调用进行校验。

问题2:页面动态加载,元素还没出现Agent就尝试操作,导致失败。

  • 原因 :感知函数执行时,AJAX加载的内容可能尚未出现。
  • 解决
    1. 显式等待 :在 get_page_description 函数中,针对特定元素或页面状态增加等待逻辑。例如,等待某个加载 spinner 消失,或者等待关键区域出现。
    2. 重试机制 :在执行层 execute_action 中,用Playwright的 locator.wait_for() 代替简单的 click() fill() ,让Playwright自己处理等待逻辑。
    3. 超时与重试策略 :为整个感知-决策-执行循环设置步骤级超时。如果某一步连续失败,可以回退到 wait 动作,然后重试感知。

问题3:任务有时会陷入无限循环(例如,不断在登录页面刷新)。

  • 原因 :LLM的决策可能陷入局部最优或产生重复动作。
  • 解决
    1. 引入循环检测 :像示例代码中那样,检查最近N个动作是否都是 wait / scroll 等无效操作。
    2. 丰富历史上下文 :将更长的动作历史(包括成功和失败的)提供给LLM,帮助它意识到自己在重复。
    3. 设置最大步数 :这是最后的安全网,必须设置。
    4. 定义终止状态 :在提示词中明确定义 finish 动作触发的条件(如“当页面URL包含‘dashboard’时”、“当检测到‘登录成功’的文本时”),引导LLM主动结束任务。

问题4:运行速度慢,尤其是调用云端LLM API时。

  • 原因 :每个决策步骤都需要网络往返,延迟很高。
  • 解决
    1. 本地轻量模型 :对于简单的、模式固定的页面(如各种网站的登录),可以训练一个小的分类模型或使用规则引擎来决策,完全绕过大模型。将大模型只用于处理复杂、未知的页面。
    2. 批量预测 :如果任务步骤是可预测的线性序列,可以尝试让LLM一次性规划出多个步骤,然后依次执行。但这降低了应对动态变化的灵活性。
    3. 缓存 :对相同的页面描述和用户目标,缓存LLM的决策结果。

问题5:如何处理验证码、滑块等反机器人措施?

  • 现实建议 :对于公开的、重要的服务,绕过验证码可能违反服务条款。在合规的前提下:
    1. 人工介入点 :设计流程,当检测到验证码时,暂停Agent,通知人工处理,处理完毕后Agent再继续。
    2. 第三方服务 :集成商业验证码识别API(如2Captcha、DeathByCaptcha),但这会产生额外成本,且成功率并非100%。
    3. 技术尝试(仅供学习研究) :对于简单图形验证码,可以使用OCR库(如Tesseract)尝试识别;对于滑块,可以分析缺口位置并用Playwright模拟拖动轨迹。但这是一场持续的“军备竞赛”,且需谨慎评估法律风险。

构建一个健壮的、基于浏览器的AI Agent是一个系统工程,它融合了前端知识、自动化测试经验、提示词工程以及软件架构设计。从简单的脚本开始,逐步迭代,解决遇到的具体问题,是通往成功最实际的路径。这个领域正在飞速发展,新的框架和思路不断涌现,但万变不离其宗: 让AI能可靠地感知、合理地思考、精准地执行

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐