OpenManus 开发实战:从架构解析到浏览器自动化踩坑指南

本文基于一次 OpenManus 开发实战课程整理,内容涵盖:OpenManus 六层架构、Daytona 沙箱使用、核心源码逐行剖析(主循环 / think / act / 内存结构)、BrowserUseTool 浏览器状态管理机制,以及一个完整的踩坑案例——让 Agent 在携程查询机票,并分别用 RAG 策略 和 GUI 视觉策略 两条路线解决日期选择器难题。

一、OpenManus 是什么

OpenManus 是 MetaGPT 团队于 2025 年 7 月推出的开源通用 AI 智能体框架,定位"无堡垒、纯开放",目标是零成本复刻闭源 Manus 的"手脑并用"能力。

它是一个开箱即用的多智能体协作平台:把自然语言需求直接变成可执行的工作流,支持浏览器自动化、Python 代码执行、文件处理、数据可视化等 12 类工具,且全部可插拔替换。

说到 Manus 本身:它是 2025 年 3 月上线的通用 AI Agent,出自蝴蝶效应团队,可自主完成市场研究、编码等任务,上线 8 个月 ARR 破 1 亿美元;2025 年 12 月,Meta 以约 20 亿美元收购,创始人肖弘出任 Meta 副总裁,团队留驻新加坡,产品保持独立运营。OpenManus 正是对这一能力的开源复刻。

典型应用场景:

  1. 数据采集与可视化:自然语言指令 → 自动写爬虫 → 清洗数据 → 生成可视化报告并导出 HTML/PPT;
  2. 电商运营:输入"给三款新品写多平台文案"→ 市场分析、竞品价格抓取、本地化文案生成、合规检测全自动完成。

如果不喜欢命令行,还可以搭配社区的可视化界面 openmanus-ui:将 assets、static、templates 文件夹以及 app.py 拷贝到 OpenManus 项目根目录,运行 python app.py 即可。

二、核心结构:六层架构

OpenManus 的整体架构可以拆成六层:

层级 内容
用户交互层 CLI、Web API、可视化界面三选一
应用入口层 单智能体(main.py)、多智能体协作(run_flow.py)、远程工具接入(run_mcp.py)
智能体层 BaseAgent → ReActAgent → ToolCallAgent → Manus 四级继承,逐级叠加"推理—行动—工具调用"能力
工具层 统一 BaseTool 接口,内置浏览器、搜索、代码执行、文件保存等工具,可通过 MCP 协议扩展外部服务
流程控制层 PlanningFlow 自动把复杂任务拆成线性或 DAG 步骤,支持多智能体按角色分工(项目经理 → 产品经理 → 执行专员)
基础设施层 Docker 一键部署、Redis 缓存、FAISS 向量长期记忆,LLM 适配器同时支持 DeepSeek、Claude、Qwen 等 10+ 模型

其中最值得初学者关注的就是智能体层的四级继承:

  • BaseAgent:状态管理、内存管理、执行循环;
  • ReActAgent:引入"思考—行动"(Think-Act)模式;
  • ToolCallAgent:处理工具调用决策与执行;
  • Manus:面向通用任务的完整 Agent,叠加浏览器上下文管理等能力。

三、Daytona:给 AI 生成的代码一个安全沙箱

3.1 Daytona 是什么

Daytona 的作用是安全沙盒执行器,负责把 AI 生成的代码放到一个隔离的容器里运行,防止恶意操作:

LLM 想跑代码 → OpenManus 把代码丢进 Daytona 容器
            → Daytona 在封闭环境里执行 → 只把结果返回给 LLM,宿主机不受影响

使用前需要到 Daytona 官网 申请 API Key。

3.2 最小使用示例

# Step1:安装 pip install daytona
# Step2:创建 API KEY
# Step3:创建 Sandbox 并执行代码
from daytona import Daytona, DaytonaConfig

# 定义配置
config = DaytonaConfig(api_key="dtn_XXX")

# 初始化 Daytona 客户端
daytona = Daytona(config)

# 创建 Sandbox 实例
sandbox = daytona.create()

# 在沙箱中安全地运行代码
response = sandbox.process.code_run('print("Hello World from code!")')
if response.exit_code != 0:
    print(f"Error: {response.exit_code} {response.result}")
else:
    print(response.result)   # 输出:Hello World from code!

3.3 Daytona 在 OpenManus 中怎么用

相关代码分布:

app/daytona/
├── sandbox.py      # Daytona 沙箱管理(创建、启动、删除)
├── tool_base.py    # 沙箱工具基类(SandboxToolsBase)
└── README.md       # 使用说明

app/tool/sandbox/
├── sb_browser_tool.py  # 沙箱浏览器工具
├── sb_files_tool.py    # 沙箱文件工具
├── sb_shell_tool.py    # 沙箱 Shell 工具
└── sb_vision_tool.py   # 沙箱视觉工具

app/agent/
└── sandbox_agent.py    # SandboxManus Agent

Daytona 提供的是一个隔离的云端执行环境:

  • 远程沙箱容器:基于 Docker 的隔离环境;
  • 预配置环境:包含浏览器、文件系统、Shell 等工具;
  • VNC 远程访问:可以实时查看浏览器操作过程(VNC 是一种远程桌面协议,允许你远程访问和控制另一台计算机的图形界面);
  • 自动资源管理:自动停止和归档,节省资源。

注意:使用 SandboxManus Agent 时,浏览器在远程云端沙箱中运行,而不是在你的本地电脑上。

通过 Daytona 沙箱,Agent 可以使用四个专用工具:

工具 说明
SandboxBrowserTool 在远程沙箱中运行浏览器,支持浏览器自动化操作,可通过 VNC 实时查看
SandboxFilesTool 在沙箱中读写文件,管理 /workspace 目录下的文件
SandboxShellTool 在沙箱中执行 Shell 命令,支持命令行操作
SandboxVisionTool 在沙箱中进行视觉识别和分析

3.4 专用 Agent:SandboxManus

SandboxManus 是一个专门使用沙箱工具的 Agent:

# app/agent/sandbox_agent.py
class SandboxManus(ToolCallAgent):
    """使用沙箱工具的专用 Agent"""

    available_tools = [
        SandboxBrowserTool(),  # 沙箱浏览器
        SandboxFilesTool(),    # 沙箱文件操作
        SandboxShellTool(),    # 沙箱 Shell
        SandboxVisionTool(),   # 沙箱视觉
        AskHuman(),
        Terminate(),
    ]

它的完整工作流程是:

  1. 用户启动 SandboxManus Agent;
  2. Agent 初始化时调用 create_sandbox();
  3. Daytona SDK 创建远程沙箱容器;
  4. 沙箱启动 supervisord 服务(supervisord 是一个用 Python 写的进程管理工具,作用是 24 小时盯着子进程,挂了就重启);
  5. Agent 获得 VNC URL 和 Website URL;
  6. Agent 使用沙箱工具执行任务;
  7. 用户可通过 VNC 实时查看浏览器操作;
  8. 任务完成后,沙箱自动停止或归档。

使用方式也非常简洁:

# 创建并初始化 SandboxManus(沙箱的 API Key 需要在 config.toml 中配置)
agent = await SandboxManus.create()

# 运行任务
await agent.run("你的任务提示词")

# 清理资源
await agent.cleanup()

初始化流程内部做了五件事:创建沙箱 → 启动 supervisord 并等待浏览器自动化服务就绪(端口 8003,最多等 60 秒)→ 生成 VNC URL 和 Website URL → 将沙箱工具添加到可用工具列表 → 初始化 MCP 服务器连接(如果配置)。

踩坑提示:如果用 Daytona,tier1 和 tier2 的用户没法上网,需要升级到 tier3 或 tier4。

四、两个快速上手的实战案例

4.1 案例一:网站 SEO 建议

直接对 Manus 输入:

对 http://class.wucai.com 进行 SEO 审查,提供详细的优化报告和可操作的建议

Agent 会自动打开浏览器访问目标站点、提取页面内容、分析 SEO 问题并生成报告。

这里有一个容易混淆的问题:网页是在 Daytona 沙箱中打开的,还是在本地打开的?

答案是:本地。因为我们使用的是 Manus Agent(不是 SandboxManus),Manus 使用 BrowserUseTool(),这是基于 Playwright 的本地浏览器工具,会在本地打开浏览器窗口。两者对比:

特性 BrowserUseTool(本地) SandboxBrowserTool(沙箱)
运行位置 本地电脑 Daytona 云端沙箱
使用的 Agent Manus SandboxManus
可视化方式 直接看到浏览器窗口 通过 VNC URL 查看
资源占用 占用本地资源 不占用本地资源

如果想在 Daytona 沙箱中运行,需要使用 SandboxManus Agent(入口 python .\sandbox_main.py),它会创建 Daytona 沙箱、在沙箱中运行浏览器,并提供 VNC URL 和 Website URL 供查看。

4.2 案例二:竞品分析

帮我写一份关于 2025 年中国新能源汽车出海欧洲的竞品分析报告,重点关注比亚迪和小鹏。

Agent 会自动搜索资料、访问网页、提取信息,最终产出一份完整报告。这个案例背后有一个值得注意的机制:代码里给单任务设置了 20 步硬上限,而 OpenManus 会把每一步再拆成若干子任务——比如"收信息"会被拆成"搜索 A 产品功能""搜索 A 定价""搜索 A 用户评价"……一个环节就可能占 3~6 步。所以复杂任务很容易把 20 步用完,后文的机票案例就踩到了这个坑。

五、源码剖析:从 main.py 到工具执行

5.1 主逻辑总览

用户输入
   ↓
Agent.run(request) —— 主循环
   ↓
循环执行 step()(最多 max_steps=20 次)
   ├─ think() —— 思考阶段:LLM 决策使用哪些工具
   └─ act()   —— 行动阶段:执行选定的工具
   ↓
工具执行结果 → 添加到内存 → 下一轮循环

5.2 Step1:初始化阶段

# main.py 或 app.py
agent = Manus(
    name="Manus",
    description="A versatile agent that can solve various tasks using multiple tools"
)
result = await agent.run(prompt)

初始化做了四件事:

  1. 创建 Manus Agent 实例;
  2. 初始化可用工具:BrowserUseTool、WebSearch、PythonExecute、StrReplaceEditor、AskHuman、Terminate;
  3. 初始化 LLM(默认 qwen-max);
  4. 初始化内存(Memory)为空。

5.3 Step2:主循环 BaseAgent.run()

# app/agent/base.py:116-154
async def run(self, request: Optional[str] = None) -> str:
    if request:
        self.update_memory("user", request)  # 添加用户请求到内存

    while (self.current_step < self.max_steps and self.state != AgentState.FINISHED):
        self.current_step += 1
        step_result = await self.step()  # 执行单步:think + act
        results.append(f"Step {self.current_step}: {step_result}")

关键参数:max_steps = 20(最多执行 20 步),current_step 为当前步数(从 1 开始)。

5.4 Step3:单步执行 ReActAgent.step()

# app/agent/react.py:33-38
async def step(self) -> str:
    should_act = await self.think()  # 思考:决定下一步行动
    if not should_act:
        return "Thinking complete - no action needed"
    return await self.act()          # 行动:执行工具调用

5.5 Step4:思考阶段 think()

Manus 的 think() 会先检查浏览器工具是否可用,如果可用,就通过 browser_context_helper 获取浏览器状态并格式化进 prompt,然后再调用父类的 think():

# app/agent/manus.py:144-221
async def think(self) -> bool:
    # 1. 检查浏览器工具是否可用
    if browser_tool_available:
        # 2. 使用 browser_context_helper 格式化 prompt
        #    浏览器状态包括:URL、标题、可交互元素列表(格式:[index]<type>text</type>)
        self.next_step_prompt = await self.browser_context_helper.format_next_step_prompt()

    # 3. 调用父类的 think() 方法
    result = await super().think()
    return result

父类 ToolCallAgent.think() 是实际的决策核心:

# app/agent/toolcall.py:40-129
async def think(self) -> bool:
    # 1. 构建消息列表(包含历史消息 + 当前 prompt)
    if self.next_step_prompt:
        user_msg = Message.user_message(self.next_step_prompt)
        self.messages += [user_msg]

    # 2. 调用 LLM,传入工具列表
    response = await self.llm.ask_tool(
        messages=self.messages,
        system_msgs=[Message.system_message(self.system_prompt)],
        tools=self.available_tools.to_params(),  # 工具列表
        tool_choice=self.tool_choices,           # AUTO
    )

    # 3. 解析 LLM 响应
    self.tool_calls = response.tool_calls  # 工具调用列表
    content = response.content             # LLM 的思考内容

    # 4. 记录日志
    logger.info(f"{self.name}'s thoughts: {content}")
    logger.info(f"{self.name} selected {len(tool_calls)} tools to use")

    # 5. 将响应添加到内存
    assistant_msg = Message.from_tool_calls(content=content, tool_calls=self.tool_calls)
    self.memory.add_message(assistant_msg)
    return bool(self.tool_calls)  # 返回是否有工具需要执行

概括一下 LLM 的输入输出:

  • 输入:系统提示 SYSTEM_PROMPT(来自 app/prompt/manus.py)、历史消息(之前的对话和工具执行结果)、当前 prompt(next_step_prompt,可能包含浏览器状态)、工具列表(所有可用工具的描述,JSON Schema 格式);
  • 输出:思考内容(Agent 的推理过程,例如"我需要先搜索比亚迪在欧洲市场的信息……")、工具调用(选定的工具和参数,例如 web_search(query="比亚迪 欧洲市场"))。

5.6 Step5:行动阶段 act()

# app/agent/toolcall.py:131-164
async def act(self) -> str:
    if not self.tool_calls:
        return "No content or commands to execute"
    results = []
    for command in self.tool_calls:
        # 1. 执行工具
        result = await self.execute_tool(command)
        # 2. 记录日志
        logger.info(f"Tool '{command.function.name}' completed its mission! Result: {result}")

        # 3. 将工具结果添加到内存
        tool_msg = Message.tool_message(
            content=result,
            tool_call_id=command.id,
            name=command.function.name,
            base64_image=self._current_base64_image,  # 如有截图
        )
        self.memory.add_message(tool_msg)
        results.append(result)
    return "\n\n".join(results)

单个工具的执行在 execute_tool() 中:

# app/agent/toolcall.py:166-219
async def execute_tool(self, command: ToolCall) -> str:
    name = command.function.name
    args = json.loads(command.function.arguments or "{}")

    # 1. 执行工具
    logger.info(f"Activating tool: '{name}'...")
    result = await self.available_tools.execute(name=name, tool_input=args)

    # 2. 检查是否有截图返回
    if hasattr(result, "base64_image") and result.base64_image:
        self._current_base64_image = result.base64_image

    # 3. 返回结果
    return str(result)

一句话总结:Think 阶段是 Agent 的大脑(智能决策、上下文理解、工具选择),Act 阶段是 Agent 的执行器(工具执行、结果收集、错误处理)。

5.7 内存结构

内存是 Agent 的记忆,存储了完整的对话历史和工具执行结果:

消息类型 说明 包含内容
user 用户输入 任务请求文本
assistant LLM 响应 思考内容 + 工具调用列表
tool 工具执行结果 执行结果文本 + 截图(可选)
system 系统提示 Agent 行为规范

对应的数据模型非常精简:

# app/schema.py
class Memory(BaseModel):
    messages: List[Message] = Field(default_factory=list)

class Message(BaseModel):
    role: str  # "user", "assistant", "tool", "system"
    content: str
    tool_calls: Optional[List[ToolCall]] = None
    base64_image: Optional[str] = None  # 截图(如果有)

5.8 一次真实的执行流程(竞品分析案例)

以"新能源汽车出海欧洲竞品分析"为例,实际日志长这样:

Step 1:初始思考

Manus's thoughts: 我需要先搜索比亚迪和小鹏汽车在欧洲市场的信息...
Manus selected 1 tools to use
Tools being prepared: ['web_search']
Tool arguments: {"query": "比亚迪 欧洲市场 销量", "num_results": 5}

执行流程:think() → LLM 决定使用 web_search 工具 → act() 执行搜索 → 结果添加到内存。

Step 2-8:浏览器操作

Manus's thoughts: 根据搜索结果,我需要访问具体网页获取详细信息...
Tools being prepared: ['browser_use']
Tool arguments: {"action": "go_to_url", "url": "http://..."}

执行流程:think() 决定访问网页 → browser_context_helper.format_next_step_prompt() 获取浏览器状态(URL、标题、可交互元素列表)→ act() 执行 browser_use.go_to_url(url) → 浏览器工具返回文本结果和 base64 截图(存到 _current_base64_image)→ 结果(含截图)添加到内存。

Step 9-20:持续迭代

每次循环:think() 基于历史消息和当前浏览器状态决定下一步(可能选择 click_element、scroll_down、extract_content、web_search 等)→ act() 执行 → 所有结果累积到内存,供下一轮思考使用。

内存中的数据流大致是:

Memory.messages = [
    Message(user, "帮我写一份关于2025年..."),
]
        ↓ think() → LLM 分析 → 决定使用 web_search
        ↓ act() → 执行 web_search → 返回搜索结果
Memory.messages = [
    Message(user, "帮我写一份关于2025年..."),
    Message(assistant, "我需要先搜索...", tool_calls=[web_search(...)]),
    Message(tool, "Search results: 1. 比亚迪在欧洲市场..."),
]
        ↓ think() → LLM 分析搜索结果 → 决定使用 browser_use
        ↓ ...(循环继续)

5.9 代码结构速览

OpenManus/
├── app/                        # 核心应用代码
│   ├── agent/                  # Agent 实现层
│   │   ├── base.py             # Agent 基类(状态管理、内存管理、执行循环)
│   │   ├── react.py            # ReAct Agent(思考-行动模式)
│   │   ├── toolcall.py         # 工具调用 Agent(处理工具调用决策)
│   │   ├── manus.py            # Manus Agent(主要通用 Agent)
│   │   ├── sandbox_agent.py    # SandboxManus Agent(沙箱环境 Agent)
│   │   ├── browser.py          # 浏览器 Agent(浏览器上下文管理)
│   │   ├── data_analysis.py    # 数据分析 Agent
│   │   ├── swe.py              # SWE Agent(软件工程 Agent)
│   │   └── mcp.py              # MCP Agent(MCP 协议 Agent)
│   ├── tool/                   # 工具集合层
│   │   ├── base.py             # 工具基类(BaseTool、ToolResult)
│   │   ├── tool_collection.py  # 工具集合管理(注册、执行、管理)
│   │   ├── python_execute.py   # Python 代码执行工具
│   │   ├── browser_use_tool.py # 浏览器自动化工具(Playwright)
│   │   ├── web_search.py       # 网络搜索工具(多引擎支持)
│   │   ├── str_replace_editor.py
│   │   ├── file_operators.py   # 文件操作工具(本地/沙箱)
│   │   ├── planning.py         # 规划工具
│   │   ├── bash.py             # Shell 命令执行工具
│   │   ├── crawl4ai.py         # 网页爬虫工具
│   │   ├── terminate.py / ask_human.py / mcp.py ...
│   │   ├── search/             # 搜索引擎实现(Baidu/Google/Bing/DuckDuckGo)
│   │   ├── sandbox/            # 沙箱工具(Daytona 沙箱)
│   │   └── chart_visualization/ # 图表可视化工具
│   ├── flow/                   # 流程管理层(PlanningFlow 等)
│   ├── daytona/                # Daytona 沙箱集成
│   ├── sandbox/                # Docker 沙箱系统
│   ├── mcp/                    # MCP 协议支持
│   ├── prompt/                 # Prompt 模板(manus/browser/toolcall/planning/swe/mcp...)
│   ├── llm.py                  # LLM 封装(OpenAI 兼容接口)
│   ├── config.py               # 配置管理(TOML 配置、环境变量)
│   ├── schema.py               # 数据模型(Message、Memory、ToolCall 等)
│   └── logger.py               # 日志配置(loguru)

几个值得记住的设计细节:

  • tool/ 目录是 Manus 的核心,包括常用工具、搜索工具、沙箱工具、可视化工具等;
  • WebSearch 支持多引擎回退(Baidu → DuckDuckGo → Google → Bing),确保搜索成功率;
  • prompt/ 把 Agent 的角色定义模块化管理,方便维护和调整。

六、BrowserUseTool:浏览器自动化的核心

6.1 浏览器状态管理

浏览器状态管理是浏览器自动化的核心,OpenManus 的设计有四个要点:

  • 元素识别:自动识别页面上的可交互元素(按钮、输入框、链接等);
  • 文本描述:为每个元素生成文本描述,LLM 根据文本匹配选择元素;
  • 成本优化:使用文本描述而非截图,节省 API 调用成本;
  • 状态同步:每次操作后自动更新浏览器状态,确保 LLM 始终了解当前页面。

浏览器元素的格式如下:

[0]<button>搜索</button>
[1]<input>出发地</input>
[2]<input>目的地</input>
[3]<button>查询</button>
[4]<link>登录</link>

LLM 根据这些文本描述选择元素,不需要视觉模型,这是 OpenManus 控制成本的关键设计。

6.2 工具执行代码

# app/tool/browser_use_tool.py
async def execute(self, action: str, url: str = None, index: int = None, ...) -> ToolResult:
    # 1. 确保浏览器已初始化
    context = await self._ensure_browser_initialized()

    # 2. 执行浏览器操作
    if action == "go_to_url":
        await context.goto(url)
    elif action == "click_element":
        await context.click(index)
    elif action == "extract_content":
        content = await context.extract_content(goal)
    ...

    # 3. 获取当前状态(包含截图和元素列表)
    browser_state = await self.get_current_state()

    # 4. 返回结果(包含 base64_image)
    return ToolResult(
        content=result_text,
        base64_image=browser_state.get('screenshot')  # base64 编码的截图
    )

BrowserUseTool 基于 browser_use 库实现,支持的操作包括:go_to_url(导航)、click_element(点击指定索引元素)、input_text(输入文本)、scroll_down/up(滚动)、send_keys(发送键盘按键)等。工具执行链路是:

ToolCallAgent.execute_tool()
    → available_tools.execute(name=name, tool_input=args)
    → BrowserUseTool.execute() 处理具体操作
        → context.get_dom_element_by_index(index)  # 通过索引获取元素
        → context._click_element_node(element)     # 执行点击

6.3 BrowserContextHelper:连接 Agent 与浏览器

BrowserContextHelper 是浏览器上下文辅助类,负责获取浏览器当前状态、格式化浏览器相关的提示词、管理浏览器截图:

class BrowserContextHelper:
    def __init__(self, agent):
        self.agent = agent
        self._current_base64_image = None

    async def format_next_step_prompt(self):
        # 获取浏览器状态
        browser_state = await self.get_browser_state()
        url = browser_state.get('url')
        title = browser_state.get('title')
        interactive_elements = browser_state.get('interactive_elements')

        # 构建 prompt,包含 URL、标签页、滚动信息
        prompt = NEXT_STEP_PROMPT.format(
            url_placeholder=f"\n   URL: {url}\n   Title: {title}",
            ...
        )
        # 添加元素列表
        prompt += "\n\n[Current state starts here]\n"
        prompt += "Interactive Elements:\n"
        prompt += interactive_elements
        return prompt

6.4 关键函数速查表

Agent 层:

函数 位置 作用
run() base.py 主循环,执行 step 直到完成
step() react.py 单步执行:think() + act()
think() toolcall.py LLM 决策,选择工具
act() toolcall.py 执行选定的工具
execute_tool() toolcall.py 执行单个工具调用

浏览器层:

函数 位置 作用
get_browser_state() browser.py 获取浏览器当前状态
format_next_step_prompt() browser.py 格式化浏览器提示词
cleanup_browser() browser.py 清理浏览器资源
execute() browser_use_tool.py 执行浏览器操作
get_current_state() browser_use_tool.py 获取页面状态和元素
_ensure_browser_initialized() browser_use_tool.py 初始化浏览器实例
cleanup() browser_use_tool.py 关闭浏览器

6.5 DOM 树与元素识别机制

DOM(Document Object Model)是浏览器将 HTML 文档解析后生成的树形结构,每个 HTML 标签都是树中的一个节点,具有父子关系:

html
├── head
│   ├── title
│   ├── meta
│   └── script
└── body
    ├── div (header)
    │   ├── button (酒店)
    │   ├── button (机票)
    │   └── button (火车票)
    └── div (content)
        ├── input (出发地)
        ├── input (目的地)
        └── button (搜索)

browser_use 库通过 DomService 遍历 DOM 树,识别可点击/可交互元素(button、input、a 等),并通过 JavaScript 在页面上渲染索引标签。以携程机票频道为例:

URL: https://flights.ctrip.com/online/channel/domestic
Title: 【携程机票】飞机票查询,机票预订,机票价格查询,打折特价机票
Element Count: 133

=== All Interactive Elements ===
[0]<button 酒店/>
[1]<button 机票/>
[2]<button 火车票/>
[3]<button 旅游/>
...
[37]<input />   <- 出发地输入框
[39]<input />   <- 目的地输入框
[40]<input />   <- 出发日期输入框
[43]<button 搜索/>  <- 搜索按钮

页面 DOM 与编号的对应关系:

<div class="header">
    <button>酒店</button>     <- 被识别为 [0]
    <button>机票</button>     <- 被识别为 [1]
    <span>文字</span>         <- 不可交互,跳过
    <button>火车票</button>   <- 被识别为 [2]
</div>

同时 browser_use 维护一张索引映射表(0 -> DOM节点(button:酒店)、1 -> DOM节点(button:机票)……),确保点击准确。

回答一个常见疑问:只能操作有编号的元素吗? 不是。主要通过索引操作(click_element(index=2) 点击 [2] 号元素),但也支持坐标点击 click_coordinates(x, y) 和滚动到文本 scroll_to_text(text)。

这套机制的关键设计总结:

  • 文本描述替代视觉:使用元素文本描述而非截图,节省成本;
  • 状态持久化:浏览器会话在步骤间保持;
  • 索引映射:browser_use 维护索引到 DOM 元素的映射,确保点击准确。

七、Prompt 详解

Prompt 定义了 Agent 的角色和行为边界,OpenManus 将其模块化管理在 app/prompt/ 目录下。

Manus SYSTEM_PROMPT(系统提示):

你是 OpenManus,一个全能的 AI 助手,旨在解决用户提出的任何任务。
你拥有各种工具可以使用,能够高效地完成复杂的请求。
无论是编程、信息检索、文件处理、网页浏览,还是人机交互,你都能处理。
初始目录是:{directory}

重要提示:对于需要实时信息的任务(如机票价格、当前天气、股票价格、新闻等),
你必须使用浏览器工具访问实时网站。永远不要编造或猜测信息。
请使用中文回复用户。

Manus NEXT_STEP_PROMPT(下一步提示):

根据用户需求,主动选择最合适的工具或工具组合。
对于复杂任务,你可以分解问题并逐步使用不同工具来解决。
使用每个工具后,清楚地解释执行结果并建议下一步。
对于需要实时或当前信息的任务(机票价格、天气、新闻等),
你必须使用浏览器工具搜索并从网站检索实际数据。不要提供编造的信息。
如果你想在任何时候停止交互,请使用 terminate 工具/函数调用。

Browser SYSTEM_PROMPT(核心规则):

输入格式包含:任务、之前的步骤、当前 URL、打开的标签页、交互元素([index]<type>text</type>,index 为数字标识符,type 为 HTML 元素类型,text 为元素描述)。

响应规则:

  1. 响应格式:必须始终以 JSON 格式响应;
  2. 操作:可以指定多个操作按顺序执行;
  3. 元素交互:只使用交互元素的索引;
  4. 导航和错误处理:如果卡住,尝试替代方法;
  5. 任务完成:完成后使用 done 操作。

Browser NEXT_STEP_PROMPT(操作指引):

下一步我应该做什么来实现目标?
当你看到 [Current state starts here] 时,请关注以下内容:
- 当前 URL 和页面标题 {url_placeholder}
- 可用标签页 {tabs_placeholder}
- 交互元素及其索引
- 视口上方 {content_above_placeholder} 或下方 {content_below_placeholder} 的内容

浏览器交互操作:
- 导航:使用 browser_use,action="go_to_url", url="..."
- 点击:使用 browser_use,action="click_element", index=N
- 输入:使用 browser_use,action="input_text", index=N, text="..."
- 提取:使用 browser_use,action="extract_content", goal="..."
- 滚动:使用 browser_use,action="scroll_down" 或 "scroll_up"

如果你想在任何时候停止交互,请使用 terminate 工具/函数调用。

八、实战踩坑:让 Manus 在携程查机票

这是本次实战最有价值的部分。任务很简单:

查询 1月30日 从上海到北京的机票

运行 python main.py 后,任务没有成功。复盘整个过程,几乎把浏览器自动化的典型坑都踩了一遍。

8.1 问题一:Agent 陷入重复操作

查看日志发现 Agent 一直在重复:没有找到正确的按钮——本来应该"选择单程 → 选择日期 1 月 30 日 → 出发地上海 → 目的地北京 → 查询",但它始终定位不到正确的元素。

调试手段:固定 prompt 输入,增加调试日志,把网页 HTML 保存下来,检查为什么没有找到日期选择器——实际上点击"出发日期"后是有日历选择弹窗的,但日历格子是 JavaScript 动态渲染的,很难通过常规 DOM 遍历定位和编号。

8.2 问题二:20 步上限耗尽

修复定位问题后,Agent 终于找到了日期,而且正确选择了"单程"——但 20 个 step 已经用完了。

解决方法:把 max_steps 改成 25。注意 max_steps 不止一处定义,browser.py 中的 max_steps 也要同步修改,否则运行还是 20 步就结束了。

经验:复杂网页任务的步数消耗远超直觉,调大 max_steps 时记得全局搜索所有定义点。

8.3 问题三:成功不稳定

即使能跑通,1 月 30 日的设置也是"有时候成功",并不稳定。这就引出了两条系统性的优化路线。

九、优化路线一:RAG 策略(知识库 + 工程化加固)

第一条路线的思路是:既然日历选择器难交互,那就尽量绕开它;把"怎么绕"的经验沉淀成知识库,让 Agent 启动时就能读到。

最终落地的改造有四项:

9.1 元素分类器 ElementClassifier

新增 app/tool/element_classifier.py,为浏览器交互元素添加智能分类和置信度评分:

  • 支持 14 种元素分类:CALENDAR、DATE、INPUT、BUTTON、LINK、SELECT、TAB 等;
  • 每个元素有 0-100 的置信度评分;
  • 特别优化日历日期元素识别(1-31 数字自动标记为 CALENDAR)。

这样编号不再是一个扁平的大列表,而是按 HTML 标签和语义分了大类,LLM 选择时更有的放矢。

9.2 知识库 RAG 系统

新增 knowledge/travel_tools.txt,并在 app/prompt/manus.py 中实现知识库自动加载机制:

  • Agent 启动时自动读取 knowledge/*.txt 文件;
  • 知识库内容注入到系统提示词中;
  • 首个场景:机票查询使用 URL 参数直接跳转,避免日历选择器交互。

携程机票 URL 格式:

https://flights.ctrip.com/online/list/oneway-{出发}-{到达}?depdate={日期}

也就是说,查"1 月 30 日上海到北京",与其在页面上点十几下,不如直接构造 URL 一步到位。知识库采用纯文本、按"情况 1、情况 2……"逐条补充的方式,简单但有效,而且天然可泛化——以后遇到新的场景就往 txt 里加一条。

9.3 系统时间注入

在系统提示中添加当前时间,帮助 Agent 正确处理日期:

当前系统时间:2026年01月21日 22:20:25 星期三

没有这个信息,LLM 很容易把"1 月 30 日"理解成错误的年份(实际踩坑中 Agent 就构造过 2023 年的 URL)。

9.4 任务完成策略优化

优化 app/prompt/browser.py 和 app/prompt/manus.py 中的任务完成逻辑:

  • 优先从元素列表获取信息,减少 extract_content 调用;
  • 对于"查询"类任务,获取信息后立即返回结果;
  • 不再反复询问用户确认,提升效率。

改之前的问题很典型:Agent 明明已经从页面元素列表里看到了航班信息,还在不停地迭代、提取、滚动。改之后,日志里可以看到它直接基于元素列表给出了结果:

Manus's thoughts: 从页面内容来看,我们已经滚动并加载了航班列表。
尽管提取操作没有成功抓取到具体的航班信息,但我们可以直接从原始元素列表中
观察到航班的详细情况。以下是1月30日从上海到北京的一些航班选项:
- 四川航空 3U5163:空客330-300,共享,07:00出发(虹桥国际机场T2),
  09:20到达(首都国际机场T2),¥750起,经济舱3.5折。
- 东方航空 MU5099:空客330-300,07:00出发(虹桥国际机场T2),
  09:20到达(首都国际机场T2),赠接送机最高8折券,¥750起,经济舱3.5折。
- 厦门航空 MF3515:空客330-300,共享,07:00出发...,¥760起,经济舱3.6折。
...

十、优化路线二:GUI 策略(视觉模型定位)

第二条路线的思路更激进:既然 DOM 编号的方式一直有问题,干脆换成视觉模型,让模型看着截图直接给坐标。

10.1 gui-plus 模型调用方式

以阿里云百炼的 gui-plus 模型为例:

import os
from openai import OpenAI

client = OpenAI(
    # 若没有配置环境变量,请用阿里云百炼 API Key 替换为:api_key="sk-xxx"
    api_key=os.getenv("DASHSCOPE_API_KEY"),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

messages = [
    {
        "role": "system",
        "content": """1. 核心角色 (Core Role)
你是一个顶级的AI视觉操作代理。你的任务是分析电脑屏幕截图,理解用户的指令,
然后将任务分解为单一、精确的GUI原子操作。

2. [CRITICAL] JSON Schema & 绝对规则
你的输出必须是一个严格符合以下规则的JSON对象。任何偏差都将导致失败。
[R1] 严格的JSON: 你的回复必须是且只能是一个JSON对象。禁止在JSON代码块前后添加任何文本、注释或解释。
[R2] thought对象的结构: "在这里用一句话简要描述你的思考过程。"
[R3] 精确的Action值: action字段的值必须是工具集中定义的一个大写字符串(例如 "CLICK", "TYPE")。
[R4] 严格的Parameters结构: parameters对象的结构必须与所选Action的模板完全一致。

3. 工具集 (Available Actions)
#CLICK 功能: 单击屏幕。Parameters: {"x": <integer>, "y": <integer>, "description": "<string, optional>"}
#TYPE 功能: 输入文本。Parameters: {"text": "<string>", "needs_enter": <boolean>}
#SCROLL 功能: 滚动窗口。Parameters: {"direction": "<'up' or 'down'>", "amount": "<'small', 'medium', or 'large'>"}
#KEY_PRESS 功能: 按下功能键。Parameters: {"key": "<string: e.g., 'enter', 'esc', 'alt+f4'>"}
#FINISH 功能: 任务成功完成。Parameters: {"message": "<string: 总结任务完成情况>"}
#FAILE 功能: 任务无法完成。Parameters: {"reason": "<string: 清晰解释失败原因>"}

4. 思维与决策框架
目标分析 → 屏幕观察(必须基于截图中存在的视觉证据,看不见的元素不能交互)
→ 行动决策 → 构建输出 → 最终验证(是否纯JSON?action是否大写无空格?parameters结构是否100%一致?)
"""
    },
    {
        "role": "user",
        "content": [
            {"type": "image_url", "image_url": {"url": "https://.../screenshot.png"}},
            {"type": "text", "text": "帮我打开浏览器。"}
        ]
    },
]

completion = client.chat.completions.create(
    model="gui-plus",
    messages=messages
)
print(completion.choices[0].message.content)

模型返回的就是一个带坐标的原子操作:

{
  "thought": "用户想打开浏览器,我看到了屏幕上的Google Chrome图标。因此,下一步是点击该图标来启动浏览器。",
  "action": "CLICK",
  "parameters": {
    "x": 1086,
    "y": 127
  }
}

10.2 用 gui-plus 解决日历选择

把携程页面截图发给模型,问"帮我选择 1 月 30 日的出发日期",模型直接返回:

{
  "thought": "用户想要选择1月30日作为出发日期。在截图中,我可以看到日历界面,并且找到了1月30日这个日期。因此,下一步操作应该是点击该日期。",
  "action": "CLICK",
  "parameters": {
    "x": 652,
    "y": 537
  }
}

DOM 编号搞不定的日历格子,视觉模型一次就点中了。

10.3 但纯 GUI 路线也有自己的坑

实际接入后并不顺利,典型问题包括:

  • 日志发现程序仍在走旧的编号路径,需要加调试日志确认 GUI 路径真正生效;
  • 程序运行时间过长,出发城市识别错误(还停留在新加坡),日期也没选对;
  • 输入框输入文字不成功(输入"上海""北京"失败);
  • 写 prompt 时不应该给具体的数值示例和城市名称示例,否则模型会过拟合到示例,失去泛化能力。

10.4 最终方案:简化的 click + type 接口 + 三层定位

反复权衡后,最终没有把宝全押在任何一条路线上,而是把复杂的元素索引操作简化为两个核心接口:

  • click:智能点击,通过 element_description 描述目标元素;
  • type:智能输入,通过 element_description 和 text 完成输入。

内部实现三层定位策略,逐级回退:

  1. Playwright Locator 精确/包含匹配——最快最稳,优先使用;
  2. JavaScript 动态元素查找——处理动态渲染元素;
  3. GUI-Plus 视觉模型回退——新增 vision_click 和 vision_type 操作,使用视觉模型(qwen-vl-plus)识别页面元素坐标进行操作,解决动态日历选择等传统方法难以处理的场景。

同时配套两项加固:

  • 日期处理优化:Prompt 增强(在 SYSTEM_PROMPT 中明确当前年份和日期规则)+ 工具层兜底(在 go_to_url 中自动检测并修正携程 URL 中的过期日期);
  • 调试能力增强:自动保存每次操作的网页 HTML 和元素信息到 debug_html/ 目录,视觉操作保存截图并标记点击坐标,便于问题排查。

十一、总结

这次实战可以浓缩成几句话:

  1. OpenManus 的主干非常清晰:run() 主循环驱动 step(),每步就是 think()(LLM 决策)+ act()(工具执行),结果全部沉淀到 Memory,如此往复直到任务完成或步数耗尽。读懂 base.py → react.py → toolcall.py → manus.py 这条继承链,就读懂了整个框架。
  2. 本地与沙箱两条路线按需选择:Manus + BrowserUseTool 在本地跑、直观省事儿;SandboxManus + Daytona 提供完全隔离的云端环境,还能通过 VNC 围观 Agent 操作,适合对安全和资源隔离有要求的场景。
  3. 浏览器自动化的本质是"让 LLM 看懂页面":OpenManus 默认用 [index]<type>text</type> 的文本描述替代截图,成本低、效果好,但面对 JavaScript 动态渲染的复杂控件(如日历选择器)会失效。
  4. 复杂页面没有银弹:RAG 策略(知识库 + URL 直达 + 元素分类 + 时间注入 + 完成策略优化)相对稳定可控;GUI 视觉策略更通用,但不确定性更高、速度更慢。工程上最务实的做法是分层组合——Playwright 定位优先,JS 查找兜底,视觉模型救场。
  5. 调试手段决定迭代速度:保存 HTML、保存截图、标记点击坐标、精简日志,这些看似琐碎的工作,是让 Agent 从"偶尔能跑"走向"稳定可用"的关键。

参考链接:

posted @ 2026-11-05 22:00  coderliu_cn  阅读(15)  评论(0)    收藏  举报