掘金 人工智能 前天 13:23
我用 Cursor 复刻了一个 Manus,带高仿 WebUI 和沙盒
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文分享了一个基于LLM Agent的项目开发经验,旨在复刻Manus的功能,实现云端部署,集成浏览器、Shell、Python等工具,并提供Ubuntu沙盒环境。项目核心在于通过Docker沙盒、Plan-and-Act Agent设计模式、FunctionCall和网页交互技术,构建一个能够执行复杂任务的AI Agent。文章详细介绍了系统架构、工具实现、AI Agent设计和部署方法,为读者提供了深入了解LLM Agent的实践案例。

🛠️ 项目核心架构:项目由Web、Server和Sandbox三个模块组成,用户通过Web界面与系统交互,Server负责管理Agent和沙盒,Sandbox提供运行环境和工具。

💻 工具集成:项目集成了Terminal、Browser、File、Web Search等工具,并为每个任务分配独立的Ubuntu沙盒,确保任务隔离和安全性。

🤖 AI Agent设计:采用Plan-and-Act Agent设计模式,将任务分解为规划和执行两个阶段,支持打断机制,并使用FunctionCall调用工具。

🌐 浏览器工具实现:项目使用Playwright + Chrome实现浏览器工具,通过Xvfb和x11vnc提供VNC服务,并使用websockify将VNC转为Websocket,实现Web UI的浏览器查看。

🚀 部署与技术栈:项目推荐使用Docker Compose进行部署,依赖Docker、Docker Compose和OpenAI兼容的LLM,如Deepseek或ChatGPT。

最近在学习 LLM Agent,但终觉“纸上学来终觉浅,绝知此事要躬行”,所以想写个小项目试试手。在这个人均写一个 Manus 的时代,在这个半开卷的情况下,况且 Manus 的提词已经泄漏的情况下,是不是我也可以写一个,我整理了一下自己的需求如下:

结合 Cursor 应该可以快速地写出一个 Manus 的示例,听说 OpenManus 4 小时就写出来了。

项目地址:

github.com/Simpleyyt/a…

QQ 交流群:

QQ群(1005477581)

Demo 演示

Browser Use

Code Use

效果可以说是相当的凑合,但是目前是学习目的,提示词与 Agent 流程可能都需要优化一下,懒得再调了,就交给广大网友了。

主要功能

整体设计

整体系统由三个模块组成:Web、Server 与 Sandbox,用户使用流程如下:

当用户发起对话时:

    Web 向 Server 发送创建 Agent 请求,Server 通过/var/run/docker.sock创建出 Sandbox,并返回会话 ID。Sandbox 是一个 Ubuntu Docker 环境,里面会启动 chrome 浏览器及 File/Shell 等工具的 API 服务。Web 往会话 ID 中发送用户消息,Server 收到用户消息后,将消息发送给 PlanAct Agent 处理。PlanAct Agent 处理过程中会调用相关工具完成任务。Agent 处理过程中产生的所有事件通过 SSE 发回 Web。

当用户浏览工具时:

    Sandbox 的无头浏览器通过 xvfb 与 x11vnc 启动了 vnc 服务,并且通过 websockify 将 vnc 转化成 websocket。Web 的 NoVNC 组件通过 Server 的 Websocket Forward 转发到 Sandbox,实现浏览器查看。

AI Agent 设计模式

AI Agent 是什么?相信大家都听烂了,我在这里说的肯定不如别人说的好,简单来说就是:AI Agent = LLM + Planning + Memory + Tools。

FunctionCall or ReAct or LangChain?

先来说一下 Tool Use 部分,目前可以使用的方式是:1)高阶模型自身的 FunctionCall 能力;2)ReAct Prompt 框架;3)LangChain Agent 框架,其实它是前两者的高度封装。

最简单是使用 LangChain 这样高度封装的框架了,但是对于学习目的的项目来说,我一直强烈想知道它背后做了什么,所以这个先 Pass 掉了。

使用 FunctionCall 来使用工具要求比较高阶的模型,但 ReAct 的设计又太繁琐,思来想去,还是先使用 FunctionCall,先用好的模型开发,后面再来研究 ReAct 框架。关于 ReAct ,感兴趣可以看看:ReAct 框架 | Prompt Engineering Guide

因此,该项目对 LLM 的要求如下:

    兼容 OpenAI 接口支持 FunctionCall支持 Json 格式输出(因为抛弃了 LangChain 又想省事)

Plan-and-Act Agent 设计模式

整体使用 Plan-and-Act 的 Agent 设计模式,相关论文:Plan-and-Act : Improving Planning of Agents for Long-Horizon Tasks,它的相关流程如下:

即将系统分成 Planner 和 Executor,Planner 将任务进行规划拆分,Executor 负责任务分步执行,将执行结果返回给 Planner 重新规划。

项目中的状态流转图如下:

系统支持被打断,所有打断的消息都会流向 Planner Agent,Planner Agent 为根据用户的打断消息重新规划。

Sandbox 设计

为了实现每个任务使用单独的 Docker 沙盒,我们 Server 通过/var/run/docker.sock进行机器上 Docker 沙盒的创建与销毁。

Sandbox 进程与生命周期管理

整个 Sandbox 通过 supervisord 进程管理,并且通过 supervisord 实现 Sandbox 的 TTL 管理。因为 Agent 目前没有主动销毁机制,所以需要 Sandbox 自动过期自销毁,并实现续时等接口。

File & Shell 工具

文件操作与 Shell 命令执行没有什么难度,Cursor 最擅长这些,我把 Manus 的工具描述丢给 Cursor 后,很快就用 FastAPI 帮我生成了一整套代码,不得不说很稳定,基本没有怎么改过。

Browser 工具

目前遍地的 Manus 都在使用 browser-use(github.com/browser-use…)这个库,为了学习和研究的目的,我还是决定使用 Playwright + Chrome 自己搞一个。由于目前还没有能力使用视觉模型,所以还是以文字模型为基础来操作浏览器。

为了让 Sandbox 更加纯粹,Sandbox 只启动 Chrome,并且暴露 CDP 和 VNC 让 Server 来操作。

启动 Browser

坑点一:启动参数

Chrome browser 的启动有很多参数,遇到问题再找参数有点费时费力,直接站在巨人的肩膀上,参考 browser-use 的启动参数:github.com/browser-use…

坑点二:CDP 监听地址不支持0.0.0.0

新版本 Chrome 似乎已经不支持--remote-debugging-address参数(参考:issues.chromium.org/issues/3275…),解决方案可以通过端口转发:

# 假设 CDP 监听在 127.0.0.1:8222socat TCP-LISTEN:9222,bind=0.0.0.0,fork,reuseaddr TCP:127.0.0.1:8222

VNC 访问

由于 Docker 镜像内没有 X Server 等图形环境,所有通过虚拟 X11 显示服务器Xvfb来给 Chrome 绘制窗口,并通过x11vnc提供 VNC Server:

# 启动 Xvfb 在 Display :1Xvfb :1 -screen 0 1280x1029x24# Chrome 浏览器指定 Displaygoogle-chrome \    --display=:1 \    ...# 启动 VNC 服务x11vnc -display :1 -nopw -listen 0.0.0.0 -xkb -forever -rfbport 5900

由于 VNC 的四层端口对反向代理转发不友好,所以这里还使用websockify将 VNC 转成七层Websocket

# 将暴露 5901 端口 Websocket 服务websockify 0.0.0.0:5901 localhost:5900

以便于后面NoVNC连接。

AI 网页元素操作与信息提取

一开始天真地把整个 html 丢给大模型,发现是行不通的,一丢过去就爆 Token 了。小调研了一下,发现现在主流的做法是:1)可交互元素提取;2)网页信息提取。

首先是提取可见的、可交互的元素,以便让大模型识别哪些可以输入、点击等。一般将元素提取成index <tag>text</tag>,例如:

1 <input>手机号</input>2 <input>密码</input>3 <button>确认</button>...

并在原标签中把 ID 号标注上去,这里是 Cursor 给我生成的代码,我也没有细看,但它能 work:

const interactiveElements = [];const viewportHeight = window.innerHeight;const viewportWidth = window.innerWidth;// Get all potentially relevant interactive elementsconst elements = document.querySelectorAll('button, a, input, textarea, select, [role="button"], [tabindex]:not([tabindex="-1"])');let validElementIndex = 0; // For generating consecutive indicesfor (let i = 0; i < elements.length; i++) {    const element = elements[i];    // Check if the element is in the viewport and visible    const rect = element.getBoundingClientRect();    // Element must have some dimensions    if (rect.width === 0 || rect.height === 0) continue;    // Element must be within the viewport    if (        rect.bottom < 0 ||         rect.top > viewportHeight ||        rect.right < 0 ||         rect.left > viewportWidth    ) continue;    // Check if the element is visible (not hidden by CSS)    const style = window.getComputedStyle(element);    if (        style.display === 'none' ||         style.visibility === 'hidden' ||         style.opacity === '0'    ) continue;    // Get element type and text    let tagName = element.tagName.toLowerCase();    let text = '';    if (element.value && ['input', 'textarea', 'select'].includes(tagName)) {        text = element.value;        // Add label and placeholder information for input elements        if (tagName === 'input') {            // Get associated label text            let labelText = '';            if (element.id) {                const label = document.querySelector(`label[for="${element.id}"]`);                if (label) {                    labelText = label.innerText.trim();                }            }            // Look for parent or sibling label            if (!labelText) {                const parentLabel = element.closest('label');                if (parentLabel) {                    labelText = parentLabel.innerText.trim().replace(element.value, '').trim();                }            }            // Add label information            if (labelText) {                text = `[Label: ${labelText}] ${text}`;            }            // Add placeholder information            if (element.placeholder) {                text = `${text} [Placeholder: ${element.placeholder}]`;            }        }    } else if (element.innerText) {        text = element.innerText.trim().replace(/\\s+/g, ' ');    } else if (element.alt) { // For image buttons        text = element.alt;    } else if (element.title) { // For elements with title        text = element.title;    } else if (element.placeholder) { // For placeholder text        text = `[Placeholder: ${element.placeholder}]`;    } else if (element.type) { // For input type        text = `[${element.type}]`;        // Add label and placeholder information for text-less input elements        if (tagName === 'input') {            // Get associated label text            let labelText = '';            if (element.id) {                const label = document.querySelector(`label[for="${element.id}"]`);                if (label) {                    labelText = label.innerText.trim();                }            }            // Look for parent or sibling label            if (!labelText) {                const parentLabel = element.closest('label');                if (parentLabel) {                    labelText = parentLabel.innerText.trim();                }            }            // Add label information            if (labelText) {                text = `[Label: ${labelText}] ${text}`;            }            // Add placeholder information            if (element.placeholder) {                text = `${text} [Placeholder: ${element.placeholder}]`;            }        }    } else {        text = '[No text]';    }    // Maximum limit on text length to keep it clear    if (text.length > 100) {        text = text.substring(0, 97) + '...';    }    // Only add data-manus-id attribute to elements that meet the conditions    element.setAttribute('data-manus-id', `manus-element-${validElementIndex}`);    // Build selector - using only data-manus-id    const selector = `[data-manus-id="manus-element-${validElementIndex}"]`;    // Add element information to the array    interactiveElements.push({        index: validElementIndex,  // Use consecutive index        tag: tagName,        text: text,        selector: selector    });    validElementIndex++; // Increment valid element counter}return interactiveElements;

这样大模型就可以根据 ID 号来操作元素了。

还要进行网页信息提取,目前主流做法是先去掉不可见元素后,先转成 markdown,再给大模型进行提取,以节省 Token,如下:

# Convert to Markdownmarkdown_content = markdownify(visible_content)max_content_length = min(50000, len(markdown_content))response = await self.llm.ask([{    "role": "system",    "content": "You are a professional web page information extraction assistant. Please extract all information from the current page content and convert it to Markdown format."},{    "role": "user",    "content": markdown_content[:max_content_length]}])

至此,大模型就可以与网页交互与阅读网页信息内容了。

Web UI 设计

Web UI 编写虽然属于我的软肋,但属于 Cursor 的强项,结合对正版 Manus 的借鉴也可以搞得七七八八,页面比较简单。

如何部署?

环境要求

本项目主要依赖 Docker 进行开发与部署,需要安装较新版本的 Docker:

模型能力也是要求比较高:

推荐 Deepseek 与 ChatGPT。

部署

推荐使用 Docker Compose 进行部署:

services:  frontend:    image: simpleyyt/manus-frontend    ports:      - "5173:80"    depends_on:      - backend    restart: unless-stopped    networks:      - manus-network    environment:      - BACKEND_URL=http://backend:8000  backend:    image: simpleyyt/manus-backend    depends_on:      - sandbox    restart: unless-stopped    volumes:      - /var/run/docker.sock:/var/run/docker.sock:ro    networks:      - manus-network    environment:      # OpenAI API base URL      - API_BASE=https://api.openai.com/v1      # OpenAI API key, replace with your own      - API_KEY=sk-xxxx      # LLM model name      - MODEL_NAME=gpt-4o      # LLM temperature parameter, controls randomness      - TEMPERATURE=0.7      # Maximum tokens for LLM response      - MAX_TOKENS=2000            # MongoDB connection URI (optional)      #- MONGODB_URI=mongodb://mongodb:27017      # MongoDB database name (optional)      #- MONGODB_DATABASE=manus      # MongoDB username (optional)      #- MONGODB_USERNAME=      # MongoDB password (optional)      #- MONGODB_PASSWORD=            # Redis server hostname (optional)      #- REDIS_HOST=redis      # Redis server port (optional)      #- REDIS_PORT=6379      # Redis database number (optional)      #- REDIS_DB=0      # Redis password (optional)      #- REDIS_PASSWORD=            # Sandbox server address (optional)      #- SANDBOX_ADDRESS=      # Docker image used for the sandbox      - SANDBOX_IMAGE=simpleyyt/manus-sandbox      # Prefix for sandbox container names      - SANDBOX_NAME_PREFIX=sandbox      # Time-to-live for sandbox containers in minutes      - SANDBOX_TTL_MINUTES=30      # Docker network for sandbox containers      - SANDBOX_NETWORK=manus-network      # Chrome browser arguments for sandbox (optional)      #- SANDBOX_CHROME_ARGS=      # HTTPS proxy for sandbox (optional)      #- SANDBOX_HTTPS_PROXY=      # HTTP proxy for sandbox (optional)      #- SANDBOX_HTTP_PROXY=      # No proxy hosts for sandbox (optional)      #- SANDBOX_NO_PROXY=            # Google Search API key for web search capability (optional)      #- GOOGLE_SEARCH_API_KEY=      # Google Custom Search Engine ID (optional)      #- GOOGLE_SEARCH_ENGINE_ID=            # Application log level      - LOG_LEVEL=INFO  sandbox:    image: simpleyyt/manus-sandbox    command: /bin/sh -c "exit 0"  # prevent sandbox from starting, ensure image is pulled    restart: "no"    networks:      - manus-network  mongodb:    image: mongo:7.0    volumes:      - mongodb_data:/data/db    restart: unless-stopped    #ports:    #  - "27017:27017"    networks:      - manus-network  redis:    image: redis:7.0    restart: unless-stopped    networks:      - manus-networkvolumes:  mongodb_data:    name: manus-mongodb-datanetworks:  manus-network:    name: manus-network    driver: bridge

保存成 docker-compose.yml 文件,并运行:

docker compose up -d

注意:如果提示 sandbox-1 exited with code 0,这是正常的,这是为了让 sandbox 镜像成功拉取到本地。

打开浏览器访问 http://localhost:5173 即可访问 Manus。

如何开发?

环境准备

环境要求在部署章节已经做了说明。

下载项目:

git clone https://github.com/Simpleyyt/ai-manus.gitcd ai-manus

复制配置文件:

cp .env.example .env

修改配置文件:

# Model provider configurationAPI_KEY=API_BASE=http://mockserver:8090/v1# Model configurationMODEL_NAME=deepseek-chatTEMPERATURE=0.7MAX_TOKENS=2000# MongoDB configuration#MONGODB_URI=mongodb://mongodb:27017#MONGODB_DATABASE=manus#MONGODB_USERNAME=#MONGODB_PASSWORD=# Redis configuration#REDIS_HOST=redis#REDIS_PORT=6379#REDIS_DB=0#REDIS_PASSWORD=# Sandbox configurationSANDBOX_IMAGE=simpleyyt/manus-sandboxSANDBOX_NAME_PREFIX=sandboxSANDBOX_TTL_MINUTES=30SANDBOX_NETWORK=manus-network#SANDBOX_CHROME_ARGS=#SANDBOX_HTTPS_PROXY=#SANDBOX_HTTP_PROXY=#SANDBOX_NO_PROXY=# Optional: Google search configuration#GOOGLE_SEARCH_API_KEY=#GOOGLE_SEARCH_ENGINE_ID=# Log configurationLOG_LEVEL=INFO

开发

开发模式下只会全局启动一个沙盒。

运行调试:

# 相当于 docker compose -f docker-compose-development.yaml up./dev.sh up

Web、Sandbox、Server 都会以 reload 模式运行,即代码改动会自动 reload。暴露的端口如下:

当依赖变化时,即requirements.txt或者package.json变化时,可以清理并重新构建一下:

# 清理掉所有相关资源./dev.sh down -v# 重新构建镜像./dev.sh build# 调试运行./dev.sh up

发布

export IMAGE_REGISTRY=your-registry-urlexport IMAGE_TAG=latest# 构建镜像./run build# 推送到相应的镜像仓库./run push

写在最后

本项目主要用于学习与研究目的,共同学习和进步,也是代码工程师未来跃变提词工程师做点准备。

后续计划:

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

LLM Agent 云端部署 Docker Manus AI Agent
相关文章