构建能够自主导航网络的视觉代理
构建能够自主导航网络的视觉代理
原文:
towardsdatascience.com/building-visual-agents-that-can-navigate-the-web-autonomously-1184efbfe895/
本文与 Rafael Guedes 共同撰写。
简介
在人工智能指数增长的年代,当前的热门话题是代理 AI 的兴起。这些 AI 系统利用大型语言模型(LLM)来做出决策、规划和与其他代理或人类协作。
当我们将角色、一组工具和特定目标包裹在一个 LLM 中时,我们创建了我们所说的代理。通过专注于一个明确的目标,并能够访问相关的 API 或外部工具(如搜索引擎、数据库,甚至是浏览器界面——关于这一点稍后还会详细介绍),代理可以自主探索路径以实现其目标。因此,代理 AI 开启了一个新的范式,其中多个代理可以处理复杂的多步骤工作流程。
约翰·卡马克(John Carmack)和安德烈·卡帕西(Andrej Karpathy)最近在 X(前身为 Twitter)上讨论了一个激发本文主题的话题。卡马克提到,AI 驱动的助手可以通过基于文本的界面推动应用程序暴露功能。在这个世界中,LLM 与图形用户界面(简称 GUI)下的命令行界面(CLI)进行交互,绕过了纯基于视觉导航的一些复杂性(这是因为我们需要它)。卡帕西提出了一个合理的观点,即高级 AI 系统在开发者能够为每个应用程序提供全面的文本界面之前,可能会在自动化 GUI 方面做得更好。我们同意卡帕西的这一观点。
建立在这些想法的基础上,本文探讨了如何实现并赋予 AI 代理视觉导航能力。我们将详细介绍如何构建仅依靠其视觉技能(无需 API 或抓取)自主导航网络的代理。我们可以浏览网站,翻阅页面以实现预定义的目标,并检索必要的信息,无需人工干预。

图 2:视觉代理 AI(作者与 DALL-E 共同创作)
和往常一样,代码可在我们的GitHub上找到。
多模态 LLM:它们是如何工作的?
多模态 LLM(MLLM)是为了解决 LLM 的局限性而开发的。后者在大多数 NLP 任务上的零样本推理表现良好,但在处理视觉元素时表现不足。另一方面,MLLM 补充了大型视觉模型(LVM),它可以处理视觉元素,但缺乏 LLM 的高级推理能力。通过结合两者,MLLM 将 LLM 推理与 LVM 视觉处理相结合,使分析不同的输入(如文本和图像)成为可能[1]。图 2 显示了当前最先进的 MLLM 及其随时间的发展。

图 3:现有的 MLLM 景观(来源)
架构
典型的 MLLM 架构由三个元素组成:
1. 预训练模态编码器负责理解文本与其他模态(如音频或图像)之间的关系。它在共享的潜在空间中对它们的各自表示进行对齐。
在这种情况下,我们的模型接收图像和文本作为输入。因此,它有两个编码器,一个针对图像,另一个针对文本。图像编码器通常是卷积神经网络(CNN)或视觉 Transformer(ViT),它将图像转换为高维向量表示,即嵌入。文本编码器通常是基于 Transformer 的语言模型,它将文本转换为嵌入表示。
之后,模型在共享的潜在空间中对两个编码器的输出进行对齐,使得相似图像和文本描述的嵌入在该空间中更接近。
这种对齐对于模型理解哪些图像与文本描述匹配至关重要,并且是通过使用对比损失来实现的,该损失:
-
计算批次中每个图像-文本对的相似度(点积)。
-
对成对的元素应用 softmax 函数以创建概率分布。
-
使用交叉熵损失等优化模型。它最大化正确图像-文本对的相似度,最小化无关图像-文本对的相似度。

图 4:训练一个多模态模型以对齐文本和图像嵌入(图片由作者提供)
2. 模态接口由一个可学习的连接器组成。它负责弥合模态之间的差距。这个可学习的连接器比以端到端方式训练 MLLM 更快、更便宜,其目标是对齐视觉/音频编码器的输出和输入文本。它可以有两种实现方式:
-
标记级融合是将图像/音频编码器的输出转换为标记(通过基于查询的学习或简单地使用线性 MLP)并与文本标记连接起来的地方。
-
特征级融合通过交叉注意力层添加额外的模块,以捕捉文本和视觉/音频特征之间的更深层次交互。

图 5:标记和特征级融合(图片由作者提供)
3. 预训练大型语言模型(LLMs)负责接收不同输入模态的对齐表示作为推理和生成文本答案的输入。也可以添加一个可选的生成器来创建除文本之外的其他模态。
我们可以在这一层使用任何 LLMs,如 GPT-4o、LLaMA、Mixtral、Gemini、Qwen 等。LLMs 的选择取决于具体用例,因为这些模型的大小各不相同(通常较大的模型意味着更好的性能)。一些模型是多语言的,而其他模型则专注于单一语言,最常见的是英语。某些模型,如 Mixtral,通过使用专家混合(MoE)技术实现了更快的推理时间。这种技术通过不显著增加总参数数量来扩展模型的表达能力。

图 6:MLLM 架构(来源)
谷歌 GenAI SDK
谷歌最近推出了其 GenAI SDK,可以通过运行以下命令轻松安装:pip install google-generativeai。这个新包是开发者与谷歌 DeepMind 开发的 Gemini 多模态模型交互的最简单方式,Gemini 是谷歌的多模态模型[2]。
谷歌团队为此新包开发了几个不同用例的笔记本,可以与该包一起使用[3]。其中一些最有趣的用例包括:
- 目标检测可以通过发送图像和要提取的对象作为输入,简单地使用
gemini-1.5-flash-002轻松执行。在下面的例子中,用户针对图像中的雨伞进行了目标定位。

图 6:使用 Gemini 提取边界框(来源)
视频分析可以通过利用 Gemini 的多模态能力来分析视频。可以使用它来识别不同帧中的感兴趣元素或总结其内容。
- 从大型语料库中实体提取对于人类来说可能是一个困难且耗时的工作,但对于 Gemini 来说则不是。在这个例子中,谷歌团队展示了如何通过提示以下内容从长文本中提取街道和交通方式:
directions_prompt = f"""
From the given text, extract the following entities and return a list of them.
Entities to extract: street name, form of transport.
Text: {directions}
Street = []
Transport = [] """
- 图表、图形和幻灯片的解释。正如我们之前看到的,多模态意味着 Gemini 可以处理文本和图像输入,并在两种输入类型之间进行推理。在这个例子中,Gemini 被要求使用下面的图像解释不同的 pub/sub 模式:
图像显示了三种不同的 Pub/Sub 模式:
多对一(扇入):在这个模式中,多个发布者向单个主题发送消息。单个订阅者监听此主题并消费所有消息。
多对多: 这种模式允许多个发布者向单个主题发送消息,并且多个订阅者可以消费来自该主题的消息。这对于负载均衡和确保消息在多个消费者之间分发很有用。
一对一(Fan-Out): 这种模式有一个单独的发布者向一个主题发送消息,而多个订阅者正在监听该主题。这种模式用于将消息分发到大量消费者。

图 7:带有 Cloud Pub/Sub 解释的幻灯片(来源)
使用 Google GenAI SDK 导航网页
在本节中,我们使用 Google GenAI SDK 来处理我们的用例。我们创建了一个能够导航网页并从中提取内容的智能体。目标是让智能体导航到ZAAI的网页,搜索其博客,并从最近发表的文章中获取一些元数据。这个智能体利用 Gemini 的多模态能力,仅根据网站(图像)的截图和通过文本提供的指令来提取必要的信息。
这些智能体将开启许多以前不可能用例的大门。甚至几个月前,智能体还仅依赖于 API 从第三方系统中获取信息。在这种情况下,无需花费时间构建 API,因为智能体可以像人类一样导航和提取信息。与传统的抓取不同,我们的智能体将适应网站 UI/UX 的变化,并且不需要编写自定义代码来找到页面上的特定元素。
我们首先导入库,定义全局变量,并从 env 文件中加载 Gemini API 密钥(API 密钥可以在这里获取):
import subprocess
import time
import pyautogui
import base64
import google.generativeai as genai
import json
import re
import os
from dotenv import load_dotenv
from PIL import Image, ImageDraw
CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
SCREENSHOT_PATH = "assets/zaai_homepage.png"
SCREENSHOT_BBOXED_PATH = "assets/zaai_homepage_bboxed.png"
SCREENSHOT_BLOG_PATH = "assets/zaai_lab.png"
ZAAI_URL = "https://zaai.ai"
load_dotenv()
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
raise ValueError("API key not found. Please set GEMINI_API_KEY in your .env file.")
genai.configure(api_key=api_key)
我们创建了三个实用函数来处理 Gemini 的输入和输出。第一个函数读取图像文件并将其转换为 Base64 编码的字符串(这是 Gemini API 所必需的)。第二个函数从 LLM 的响应中提取 JSON 数据,并执行必要的预处理以确保数据可用。第三个函数将边界框坐标转换为实际的像素值,这些坐标是 Google 标准化到 0 到 1000 范围内的。
def encode_image_to_base64(image_path: str) -> str:
""" Read an image file and return its Base64-encoded string. """
with open(image_path, "rb") as img_file:
image_data = img_file.read()
return base64.b64encode(image_data).decode("utf-8")
def extract_json_from_response(response) -> dict:
"""
Extract JSON content from the LLM response, removing any code fence markers.
"""
if not hasattr(response, "candidates") or not response.candidates:
raise ValueError("Response does not contain valid candidates.")
raw_text = response.candidates[0].content.parts[0].text
json_str = re.sub(r"^```json|```py$", "", raw_text.strip(), flags=re.MULTILINE)
try:
parsed_data = json.loads(json_str)
return parsed_data
except json.JSONDecodeError as e:
raise ValueError(f"Failed to parse JSON: {e}nRaw LLM Response:n{raw_text}")
def update_coordinates_to_pixels(detection_info: dict, width: int, height: int) -> None:
"""
Convert normalized bounding box coordinates ([0..1000]) to actual pixel values.
"""
for key, value in detection_info.items():
coords = value["coordinates"]
xmin, ymin, xmax, ymax = coords
value["coordinates"] = [
(xmin / 1000.0) * width,
(ymin / 1000.0) * height,
(xmax / 1000.0) * width,
(ymax / 1000.0) * height
]
然后,我们开发了函数,使工具对智能体可用。首先,我们定义了一个工具,该工具在图像上叠加边界框并将其保存。第二个工具捕获屏幕截图。第三个工具启动 Chrome 浏览器并导航到指定的 URL。最后,最后一个工具识别并点击边界框,使与特定 UI 元素交互成为可能。
def draw_bounding_boxes(image_path: str, detection_info: dict, output_path: str, color: str = "red") -> None:
"""
Draw bounding boxes on the image and save to output_path.
Expects detection_info to have pixel coordinates already.
"""
try:
with Image.open(image_path) as img:
draw = ImageDraw.Draw(img)
for label, details in detection_info.items():
coords = details["coordinates"]
description = details.get("description", "")
draw.rectangle(coords, outline=color, width=2)
draw.text((coords[0], coords[1] - 10), label, fill=color)
img.save(output_path)
print(f"Image saved with bounding boxes at: {output_path}")
except Exception as e:
print(f"Failed to create image with bounding boxes: {e}")
def take_screenshot(output_path: str):
"""Take a screenshot of the main screen (or the active window)."""
time.sleep(2) # wait a bit for the page to load
screenshot = pyautogui.screenshot()
screenshot.save(output_path)
print(f"Screenshot saved to {output_path}.")
def open_chrome(url: str):
"""Open Chrome to a specific URL using a subprocess."""
print(f"Opening Chrome at {url} ...")
subprocess.Popen([CHROME_PATH, url])
time.sleep(5)
def find_and_click_lab_element(bounding_box_data: dict):
"""
Click the bounding box that should lead to the 'Lab' (or blog page).
For simplicity, let's assume we pick the bounding box whose label
or description references "Lab" or "Blog"
"""
target_label = None
for label, info in bounding_box_data.items():
lower_desc = info["description"].lower()
lower_label = label.lower()
if "lab" in lower_desc or "lab" in lower_label:
target_label = label
break
if "blog" in lower_desc or "blog" in lower_label:
target_label = label
break
if not target_label:
print("Could not find a bounding box that references Lab/Blog in the description.")
return
coords = bounding_box_data[target_label]["coordinates"]
# coords is [xmin, ymin, xmax, ymax]
# let's pick the down left corner of the bb
x_center = coords[0] / 2 # bc of retina res
y_center = coords[1] / 2 # bc of retina res
print(f"Clicking element: {target_label}")
pyautogui.moveTo(x_center, y_center, duration=0.5)
pyautogui.click()
最后,我们为我们的智能体实现了从其“所见”中提取特定信息的能力。
def identify_elements_with_descriptions(image_path: str) -> list:
"""
Step 1: Ask the model to identify clickable elements and include descriptions.
Return a list of objects, each containing 'label' and 'description'.
"""
model = genai.GenerativeModel(model_name="gemini-1.5-pro-latest")
encoded_image = encode_image_to_base64(image_path)
prompt = """
You are given a screenshot of a website homepage.
Identify all the relevant clickable elements (text, buttons, icons, tabs, images, etc.)
on the website page only (discard browser elements if they appear in the image) and provide:
- A semantically rich name as the label (e.g., "Lab Link" or "Blog Tab")
- A short description of its purpose on the page and any relevant visual details
Output JSON in this format:
{
"elements": [
{
"label": "some descriptive label",
"description": "short description with visual nuances"
},
...
]
}
"""
response = model.generate_content([
{"mime_type": "image/png", "data": encoded_image},
prompt
])
parsed_data = extract_json_from_response(response)
if "elements" not in parsed_data:
raise ValueError("No 'elements' field found in the JSON response.")
return parsed_data["elements"]
def propose_bounding_boxes(image_path: str, identified_elements: list) -> dict:
"""
Step 2: Provide the list of elements from Step 1 (labels + descriptions).
Ask the model to propose bounding boxes in [xmin, ymin, xmax, ymax] with 0..1000 scale.
Return a dict where keys are labels, and values have 'coordinates' + 'description'.
We also copy the 'description' from the elements so that we keep it in the final output.
"""
model = genai.GenerativeModel(model_name="gemini-1.5-pro-latest")
encoded_image = encode_image_to_base64(image_path)
elements_json_str = json.dumps(identified_elements, indent=2)
prompt = f"""
The following clickable elements were identified (labels + descriptions):
{elements_json_str}
Propose a bounding box (in [xmin, ymin, xmax, ymax], 0..1000 scale) for each element
so we can locate them on the screenshot.
Output JSON in the format:
{{
"<element_label>": {{
"coordinates": [xmin, ymin, xmax, ymax],
"description": "<the same description from above>"
}},
...
}}
"""
response = model.generate_content([
{"mime_type": "image/png", "data": encoded_image},
prompt
])
parsed_data = extract_json_from_response(response)
return parsed_data
def retrieve_latest_blog_info(image_path: str) -> (str, str):
"""
Getting the latest post title and date
"""
model = genai.GenerativeModel(model_name="gemini-1.5-pro-latest")
encoded_image = encode_image_to_base64(image_path)
prompt = """
You are given a screenshot of a website blog page.
Identify the latest article and get its title and date.
Output JSON in this format:
{
"title": "title of the article",
"pub_date": "date of publishing of the article"
},
"""
response = model.generate_content([
{"mime_type": "image/png", "data": encoded_image},
prompt
])
parsed_data = extract_json_from_response(response)
if "title" not in parsed_data:
raise ValueError("No 'title' field found in the JSON response.")
return parsed_data['title'], parsed_data['pub_date']
该过程的可视化工作流程可以在图 8 中看到。

图 8:视觉代理执行的操作,以从最新的博客文章中提取元数据(图片由作者提供)。
结论
代理式人工智能开辟了许多新的可能性。随着我们使这些代理更加自主,根据目标行动和适应新信息的能力使得旧的和复杂的问题突然变得容易解决。它们也在获得越来越多的类似人类的技能。例如,解释图像和提取信息的能力直到最近一直是只有人类才能做到的。几个月前,如果我们想让机器能够从图像中提取这种详细程度的信息,我们就会 1)为特定目标训练一个模型,或者 2)编程机器执行一个非常具体的任务。由于如果有什么变化,我们很可能需要重新训练或重新编程我们的方法,所以这些方法都没有可扩展性。
现在,多模态模型可以通过简单地遵循文本指令和推理输入来解释和分析视觉信息。这意味着,即使上下文或所需信息的视觉外观发生变化,模型也可以不进行任何修改而继续工作。
代理式人工智能仍在迈出第一步,我们期待看到接下来会发生什么!
关于我
作为 AI 领域的连续创业者和领导者,我为企业开发 AI 产品,并投资于专注于 AI 的初创公司。
ZAAI 创始人 | LinkedIn | X/Twitter
参考文献
[1] Shukang Yin, Chaoyou Fu, Sirui Zhao, Ke Li, Xing Sun, Tong Xu, Enhong Chen. (2024). A Survey on Multimodal Large Language Models. arXiv:2306.13549.
[2] github.com/google-gemini/generative-ai-python/blob/main/README.md

浙公网安备 33010602011771号