1. 为什么我要做一个"能碰 GUI"的 AI 编码代理
1.1 编码代理的天花板不在模型,而在"手伸不到的地方"
先说说我为什么会做这个项目。
之前很长一段时间,我都在用各种 AI 编码代理写代码。它们确实能帮忙改文件、跑命令、搜文档,但越用越觉得别扭——这些工具本质上只活在"终端 + 文件系统"这个二维世界里。你让它"打开设置面板把主题换成深色""在这个软件里导入 Excel 并生成图表""把某张截图里的内容填进表单",它立刻傻眼。因为它看不见图标、点不了按钮、拖不动窗口,甚至不知道屏幕上正在发生什么。
但现实里的编程任务,恰恰有大量绕不开 GUI 的部分:
- 你写了一个桌面工具,需要验证安装向导每一步的点击效果;
- 你要给别人做的软件写自动化验收用例,但被测程序只有图形界面;
- 你想让代理帮你配置一个 IDE 插件,配置入口在层层叠叠的设置页里;
- 甚至就是最简单的工作——"打开浏览器登录一下这个系统把验证码填进去",现有终端型代理也搞不定。
我当时的想法很简单:能不能做一个编码代理,它既具备普通 AI Agent 读写文件、执行命令的能力,又能像人一样"看屏幕、动鼠标、敲键盘"?也就是标题里说的"操控 GUI"。MCP 的支持则是另一个刚需——现在的工具生态里,越来越多的服务通过 MCP 暴露能力(数据库、浏览器、设计稿、CI 系统都有 MCP server),如果代理不支持 MCP,就等于拒绝接入整个生态。
1.2 为什么选择"MCP + GUI + 单文件"这套组合
先说 MCP(Model Context Protocol)。很多人刚听到 MCP 会以为是什么硬件协议,其实它就是个软件层的东西:定义了大模型应用和外部工具之间怎么互相发现、怎么调用、怎么传数据。你可以把 MCP server 想象成一个"USB 设备",把支持 MCP 的代理想象成"USB 接口"。任何厂商只要按协议做好一个 server,任何兼容 MCP 的客户端都能直接插上用,不需要每家都做一遍适配。
所以我的代理必须做 MCP 客户端,这是标准问题,不是特色问题。
再说"单文件运行"。市面上很多编码代理挺重的:要装 Python 环境、要 npm install 半天、要拉模型、要配数据库。我见过太多人连第一步"环境依赖"都没跑过去就放弃了。我自己也烦这种体验。所以我给自己定了个硬性目标:最终产物就是一个可执行文件,双击就能跑,顶多再加一个配置文件。Windows 上是一个 .exe,macOS 上是一个二进制,扔到哪都能运行。
这是这个项目从第一天起就确定的三个支柱:会操作图形界面、兼容 MCP 生态、单文件免安装。
我不指望它能替代 Cursor 或者 Cline 这类成熟产品,但如果你恰好需要的是一个不挑环境、能跑通完整"看屏-决策-操作-验证"闭环的代理,这篇文章里的设计思路和踩坑记录,应该能帮你省下不少时间。
2. 单文件的身体里,其实藏着一个五脏俱全的微内核
2.1 单文件不是"一个乱糟糟的大脚本",而是分层架构
很多人一看"单文件运行"就以为代码是乱七八糟的大杂烩。恰恰相反,正因为要打包成单文件,我对模块边界的划分反而更严格。我内部的结构大概是这样的:
| 模块 | 职责 | 对外暴露能力 |
|---|---|---|
| Agent 核心 | 维护上下文、规划步骤、调度工具调用 | 接收用户自然语言任务 |
| 终端/文件工具集 | 执行命令、读写文件、搜索 | shell、fs API |
| GUI 控制层 | 截屏、坐标定位、鼠标键盘操作、UI 元素树提取 | click(type, x, y)、type_text、scroll、wait_for |
| MCP 网关 | 加载 MCP server 配置、发现 tool、调用 tool | list_mcp_tools、call_mcp_tool |
| 安全网关 | 权限审批、危险操作拦截、GUI 点击确认 | 黑白名单 |
| 提示词组装 | 把环境状态转换成模型可读的上下文 | system prompt、observation 文本 |
Agent 核心是最典型的一层,Mark:你之前可能听过 ReAct 模式(Reason + Act),我就是这么做的:模型根据当前观察到的屏幕状态和任务目标,输出下一步动作,动作由对应模块执行,执行结果再回传给模型,循环往复直到任务完成。
2.2 为什么不直接套一个 Cline 或 OpenHands 的架构
我认真评估过改造成本。Cline、OpenHands 这类项目对 MCP 的支持已经很成熟了,但它们有一个共同的问题:它们的内核是为"代码仓库任务"设计的,工具集里默认优先的是文件操作和命令执行,GUI 控制最多算一个"锦上添花"的插件,而不是一等公民。
如果我在它们的架构里塞 GUI 控制,会遇到两个麻烦:
- 工具命名空间与提示词膨胀:本来已经很长的 system prompt 里再加上一堆 GUI 动作描述,模型更容易决策混乱;
- 状态同步困难:它们的上下文模型围绕"文件 diff""命令输出"构建,GUI 的屏幕状态是高度动态的,每轮都要重新截图重新理解,这跟"文件变更"的上下文形态完全不同。
与其强行改造,不如从零写一个以"屏幕操作"为核心输入的心。开发成本确实更高,但换来的是决策链路的干净:模型每次规划时看到的输入,就是一段紧凑的可读状态(当前窗口标题、UI 元素树摘要、鼠标位置)加一张按需截取的屏幕图,没有多余噪音。MCP 工具和内置工具在 Agent 眼里是同一套调用协议,只是来源不同。
2.3 运行时选型:为什么是 Deno 而不是 Python 或 Go
既然要单文件,首先得选一个能产出"单一可执行文件"的运行时。三个候选我都试过:
- Python + PyInstaller:生态最强,但要处理依赖泥潭,产物体积轻松上百 MB,而且不同系统上打包配置差异大;
- Go:打包最干净,单体二进制非常小,但写 GUI 自动化相关库时,生态明显比 Python/Node 弱,OCR、剪贴板、窗口树这类能力能找到的库有限;
- Deno(TypeScript):既有 Node 的生态调用能力(通过 npm 兼容),又内置了权限沙箱,最关键是
deno compile一条命令就能产出原生可执行文件。
我最后选了 Deno + TypeScript。原因有三:
- 权限模型天然适配"我要控制 GUI"这件事:Deno 允许我在启动时声明需要哪些系统权限(网络、文件、运行子进程、读取环境变量),这个权限声明在单文件运行场景下特别有用,让代理在用户机器上明确知道自己能用什么;
- 打包成本低:
deno compile --target x86_64-unknown-linux-gnu --output agent main.ts这种写法,交叉编译也方便; - TypeScript 写工具类项目太舒服了:MCP 协议本身是基于 JSON-RPC 的,TypeScript 的类型定义能帮我减少很多低级错误。
3. GUI 操控的核心实现:从"截图"到"看得懂、点得准"
3.1 屏幕感知:截图只是起点,还得有结构
第一步自然是截屏。但如果只把原始图片丢给多模态模型,有两个问题:一是贵、二是慢。每一轮推理都送一张 4K 截图,Token 消耗会迅速失控。所以我的方案是"结构优先、图像兜底":
- 能拿到 UI 元素树就用元素树:在 Windows 上通过系统无障碍接口读取窗口树,macOS 上有对应的 UI 元素 API,Linux 上可以依赖 AT-SPI。元素树本身是带层级和属性的文本结构,比如"按钮[显示文本=保存]",模型不需要看图就知道屏幕上有哪些可操作元素;
- 拿不到元素树时退化为视觉方案:截图,然后用内置 OCR 或视觉模型提取"文本 + 坐标"清单;
- 两种信息都失败时:才让多模态模型直接看整张截图(只有最终兜底)。
这个过程很像我们人点外卖时的操作顺序:先看菜单列表找"红烧肉"(结构树),菜单不显示文字就靠照片认菜(OCR),实在不行才放大图片反复确认(视觉模型)。有了这个分层,大多数常规 GUI 操作都能在较低成本下完成。
3.2 坐标、缩放和多显示器:GUI 自动化的三个隐形杀手
哪怕拿到了"屏幕上某个按钮在 (1200, 800)"这样的信息,真正去点击时也有一堆坑。我踩得最深的三个:
- 系统缩放率(DPI Scalling):Windows 上常见的 125%、150% 缩放,会让逻辑坐标和物理像素坐标不一致。你在普通截图里算出按钮在 (100, 100),实际点击时系统会把它当 (125, 125) 处理,直接点偏。解决办法是截屏时记录当前缩放因子,点击前把相对坐标换算成物理坐标。
- 多显示器负坐标:副屏在主屏左边时,它的 X 坐标是负值(比如 -1920 到 0)。很多 GUI 自动化库只处理主屏原点的坐标,你的代理一操作副屏就"点在空气中"。我在坐标归一化时就明确不把原点当左上角,而是让所有坐标基于虚拟桌面的绝对坐标系。
- 窗口移动了/动画还没结束:元素树里拿到一个按钮位置,但用户正好拖动了窗口,等代理真正去点击时按钮已经跑了。所以 GUI 操作循环里必须有一个"动作后校验"步骤:点击后立刻重新获取窗口状态,确认预期变化是否发生,没发生就回滚或重试。
3.3 实操示例:让代理自己完成一次"另存为"对话框
我平时测试项目时最常用的一种方式是给代理一句话:
"打开记事本,输入'Hello GUI Agent',然后按下 Ctrl+S,在弹出的保存对话框里,把文件名改成 demo.txt,保存到桌面。"
整个执行链路大概是这样的:
- 规划:Agent 判定需要依次调用"启动应用、激活窗口、输入文本、触发快捷键、等待对话框、在 GUI 上定位元素、点击、输入、确认"等动作;
- 启动与输入:调用终端工具
notepad.exe,然后用 GUI 层找到一个编辑区域(拿元素树),调用 type_text 写入内容; - 触发保存:通过 GUI 层的热键能力发送 Ctrl+S;
- 等待对话框出现:轮询窗口树,直到出现"另存为"窗口,这一步不是固定 sleep,而是"条件等待",对话框 1 秒弹出或 10 秒弹出都能兼容;
- 定位目标元素:从窗口树里定位文件名输入框和"保存"按钮,必要时切换到截图 OCR 辅助;
- 执行与校验:先点击输入框,全选,输入新文件名,再点击保存。校验点:窗口是否关闭、桌面是否出现 demo.txt。
这个过程看起来不复杂,但每一步背后都有失败处理。比如 OCR 把"另存为"识别成了"另存为"(零宽字符),或者在对话框打开瞬间点击被系统拦截。我在调试版本里专门加了一个"轨迹录制"功能:把每次截图、每次坐标点击、每次 UI 树快照保存下来,失败时回放,能看出是哪一步判断错了。这个能力强烈建议你自己做项目时也加上,没有轨迹记录的 GUI Agent 等于盲人走夜路,出了问题根本没法排查。
4. MCP 支持:把代理变成"一个能插所有工具的设备插座"
4.1 MCP 不是硬件协议,它解决的是工具调用的"即插即用"
有个热词搜索里有人在问"MCP 到底属于软件协议还是硬件协议",答案很清楚:它是纯软件协议,跟 USB、PCIe 这些没关系。MCP 定义的是"大模型应用"和"外部数据/工具"之间的交互方式,底层走 JSON-RPC 2.0。
现在 MCP 最主流的传输方式有两种:
- stdio(标准输入输出):客户端启动一个子进程,通过 stdin/stdout 和它对话。适合本地工具,比如某个文件处理脚本。
- HTTP/SSE:通过网络连接远端 MCP server,适合数据库、云端服务这类需要共享访问的工具。
我实现的 MCP 客户端同时支持这两种。你要插一个本地写的小工具,配置文件里 type 写stdio;要连远程服务,type 写http。
4.2 让 Agent 动态发现 MCP 工具,而不需要硬编码
关于 MCP,很多人第一次接触时最大的困惑是:"我该怎么告诉Agent有哪些工具可用?"
传统做法是在 system prompt 里写死工具列表。但这有个问题:MCP server 是动态加载的,今天用户配置了一个数据库 server,明天可能换成一个浏览器自动化 server,工具列表一变,又要改提示词。
我的处理方式是走标准的 MCP 握手流程:
- 启动时读取用户配置文件
mcp.json; - 对每个 server 发起 initialize 请求,建立会话;
- 调用
tools/list拿到该 server 支持的全部工具清单; - 把工具清单和描述动态拼接进当前任务的工具上下文里。
也就是说,用户不需要在 Agent 代码里为每个工具写适配层,只要 MCP server 本身实现规范,代理这边就能自动识别并调用。这一点是 MCP 协议最有价值的地方。下面是一段简化的内部逻辑:
for (const [name, config] of Object.entries(mcpServers)) { let client: MCPClient; if (config.type === "stdio") { client = await MCPClient.connect_stdio(config.command, config.args, config.env); } else { client = await MCPClient.connect_http(config.url, config.headers); } const { tools } = await client.listTools(); agent.registerTools(`mcp__${name}__${tool.name}`, tool.description, async (args) => { const result = await client.callTool(tool.name, args); return result; }); }有了这层注册逻辑,Agent 规划时看到的工具列表里就既有内置工具(执行命令、读写文件、截屏点击),也有外部 MCP 工具。它自己会判断该用谁。
4.3 用户侧的 MCP 配置应该长什么样
项目分发出去后,用户最关心的就是"我怎么接我自己的工具"。我给项目配了一个示例mcp.json,放在可执行文件同目录下。它长这样:
{ "mcpServers": { "filesystem": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], "env": {} }, "remote-db": { "type": "http", "url": "http://127.0.0.1:8000/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } }注意几个容易出错的地方:
- stdio 的 command 字段必须写能在系统 PATH 里找到的命令,否则子进程启动直接失败;
- env 里不要写死敏感凭据,我建议支持类似
${env:DATABASE_URL}这种从系统环境变量展开的写法,避免密钥被写进配置文件; - HTTP server 的 URL 需要后端实现 MCP Streamable HTTP 规范,不是随便一个 HTTP 接口都能叫 MCP。
有一位朋友拿来接自家的 Oracle 数据库查询,用的就是某个 IDE 插件里类似的 MCP 配置方式——只要你理解"服务端通过工具协议暴露 SQL 执行能力,代理侧就能调用",迁移到哪个环境都不费劲。MCP 的好处就是:同一个概念,在 IDE 插件里成立,在我这个单文件 GUI Agent 里也成立。
5. 单文件运行这件事,难点根本不在打包
5.1 打包本身很简单,一行命令的事情
只要选对了技术栈,打包真的不难。我用了 Deno 的 compile 能力:
deno compile \ --allow-run --allow-read --allow-write --allow-env --allow-net \ --target x86_64-pc-windows-msvc \ --output gui-agent.exe \ src/main.ts产物是一个带图标(我自己画了个丑图标)的可执行文件,把它复制到任何一台 Windows 机器上就能跑。macOS 和 Linux 同理,只是 target 参数不同。
但这只是表面上的"单文件"。
5.2 真正的麻烦:外部可执行文件、OCR 模型和系统权限
单文件只是"你提供的二进制只有一个",不代表运行时不需要外部依赖。我踩过的几个具体问题:
- OCR 引擎:我希望内置文字识别能力,但如果把本地 OCR 模型打进去,体积立刻膨胀到 900MB 以上,这违背了轻量初衷。妥协方案是:优先调用系统级 OCR(Windows 自带、macOS 有 Vision 框架),实在没有才提示用户"当前系统缺少 OCR 依赖"。
- MCP 工具子进程:很多 MCP server 本身依赖 Node.js 环境。用户机器上没有 Node,
npx命令就跑不起来。这不是我能打包解决的,只能在文档里写明"使用该 server 前请安装对应运行时"。 - GUI 结构 API 的系统权限:Windows 上读取窗口树比较容易,macOS 必须要给终端或应用授予"辅助功能/屏幕录制"权限,否则元素树拿不到、截屏是黑的。Linux 上依赖 AT-SPI 服务,某些精简发行版默认没装 D-Bus 接口,就得让用户手工装包。
所以我把"单文件"定位成:对外分发形态是单文件,运行时的系统集成能力按需发现。启动时代理会自己检查当前系统有哪些可用能力,缺了就给明确提示,而不是一崩溃了事。这样既保证了轻量,又不至于让用户陷入"软件打不开也不知道为什么"的困境。
5.3 跨平台差异如何收敛
我测试最多的三个平台是 Windows 11、macOS(Apple Silicon)和 Ubuntu 24.04。GUI 控制的 API 差异极大,这里有一张我项目里内部的兼容性表格:
| 能力 | Windows | macOS | Linux |
|---|---|---|---|
| 截屏 | 系统 API,稳定 | 需要屏幕录制权限 | X11/Wayland 差异大 |
| UI 元素树 | 无障碍接口,成熟 | 可用,需辅助功能权限 | AT-SPI,依赖桌面环境 |
| 模拟点击 | Win32 API,区分逻辑/物理坐标 | CGEvent 全局事件 | XTest 或 uinput |
| 多显示器 | 支持良好 | 支持良好 | X11 还行,Wayland 受限 |
我给 GUI 控制层定义了一个统一接口,每个平台自己实现。这样 Agent 核心层的代码不需要关心底层的坐标换算和权限问题,只要调用click(x, y)、get_ui_tree()、type_text()就够了。
6. 实测体验:能跑通,但"第一次就成功"的概率没那么高
6.1 第一次跑通一个完整任务的全过程
我拿最经典的测试任务来演示:让代理"打开系统计算器,计算 7 乘以 8,并把结果读出来"。
第一次跑通的日志大概是:
- Agent 调用内置终端工具,执行
calc(Windows 下这是计算器命令); - GUI 控制层轮询窗口树,确认"计算器"窗口出现,并拿到窗口边界坐标;
- 用元素树查找数字键"7"的坐标,调用 click 点击;
- 点击乘号,点击数字"8";
- 此时计算器上没有直接的等号按钮?其实有,但传统布局里等号按钮在窗口右侧;
- 点击等号后用 OCR 读取结果区域文本,得到"56";
- 回传给用户:"7 * 8 = 56"。
整个过程大约 20 秒。真正让我觉得可行的是:它并不是靠写死的步骤跑通的,而是模型实时看着元素树自己决策的。如果计算器按键布局换了,它也能重新看结构再决定点哪里。这就是"GUI Agent"和"写死脚本的 RPA"最本质的区别。
6.2 踩坑一:截屏全黑,但没有任何报错
我第一次在 macOS 上测试时,代理一直在说"我没看到任何窗口内容"。排查链路:
- 先确认不是代码问题:手动调用截屏函数,返回的图片是纯黑色;
- 再确认是不是权限问题:打开系统设置,发现我的终端没有"屏幕录制"权限;
- 验证是整个进程的问题还是单次调用的问题:重启终端后重新截图,正常。
结论:macOS 的权限提示有时不会弹窗,只在系统设置里默默拒绝。后来我在程序启动时主动检测权限状态,如果截屏 API 返回全黑或空内容,就直接提示"请到系统设置中授予屏幕录制权限",而不是让模型自己猜为什么看不到屏幕。
6.3 踩坑二:MCP stdio 工具一直报"连接已关闭"
另一个频发的坑来自 MCP 配置。用户配置了一个 stdio server,运行时告诉我"连接已关闭"。
排查过程:
- 先检查配置文件里的 command 是否在 PATH 中:终端里手动执行
npx -y @some/server --version,发现命令是存在的; - 再看是不是协议版本问题:用对照工具手动发起 initialize 请求,发现 server 返回正常;
- 最后才发现:程序的工作目录和 MCP server 要求的当前目录不一致。我在启动子进程时没有把 cwd 设置为 server 配置里指定的目录,server 一启动就崩了。
修复很简单:stdio 配置里增加cwd字段,启动子进程时传进去。但这个 bug 我查了整整半天。所以如果你的 MCP server 总是莫名断开,优先检查"子进程的工作目录、环境变量、PATH 路径"这三样,别一开始就去翻协议实现。
6.4 安全边界:为什么我加了"危险操作确认"开关
让一个 AI 代理能控制鼠标和键盘,安全性怎么强调都不过分。我在设计之初就把安全网关放在最高优先级:
- 默认只读模式:GUI 操作里,滚动、读取窗口、截屏可以直接执行;点击、拖拽、输入这些"有副作用"的动作,如果用户开启了严格模式,需要用户按一次回车或说一句"继续"才会执行;
- 敏感命令拦截:终端工具内置一份危险命令黑名单(格式化磁盘、删除系统目录、下载并执行远程脚本等),命中就拒绝,除非用户在配置里显式允许;
- 操作可观测性:每次 GUI 动作都会打印一个结构化日志,包括坐标、动作类型、涉及的元素文本,用户随时能停止任务。
我做这个项目最深的体会是:GUI Agent 的能力越强,越需要给用户"反悔"和"看着它干"的能力。如果一个代理能替你在屏幕上做任何事,那你至少应该能随时知道它在做什么。
7. 下一步:这个项目后续还能怎么长
7.1 让 GUI 操作变成可回放、可验证的脚本资产
现在代理虽然能操作 GUI,但每轮任务都是"即兴表演",没有沉淀。我希望后续把每次成功的操作轨迹保存成一种 DSL:窗口条件、点击位置、输入内容、校验断言全部结构化。这样下次跑同样的流程时,可以直接以低倍速"回放脚本 + 关键点校验"的方式执行,不用再让模型重新看图猜位置。这本质上是把 Agent 的灵活性跟 RPA 的稳定性结合——平时用 RPA 脚本跑,遇到异常再叫 Agent 来兜底。
7.2 让 MCP 工具支持热插拔
现在的 MCP server 必须在启动时加载。我计划做成可热插拔的:用户运行过程中编辑 mcp.json,代理监听文件变化,自动对新增 server 发起握手和工具发现,不需要重启。这在调试复杂流程时非常有用,不用为改一个工具配置就把整个会话推倒重来。
7.3 GUI 状态快照与时间回溯
调试 GUI Agent 最大的痛苦是"状态不可复现"。我会给每次交互打一个状态快照(截图 + UI 树 + 坐标),存成带时间戳的序列。下次复现 bug 时,可以直接跳到任意一步,从这里重新开始推理和操作。这有点像视频编辑里的时间线,既能前进也能回退,对排查复杂 GUI 自动化问题非常有用。
7.4 跑一个轻量本地模型做离线降级
最后我还想尝试把大模型换成端侧小模型做"低功耗模式"。日常的简单 GUI 操作(点击固定按钮、填写表单、读取固定区域文本)其实用不到复杂推理,一个 1B 到 3B 的本地模型配合规则引擎就能完成。只有在遇到突发情况时,才唤醒云端大模型做复杂决策。这样用户在没有外网的环境里也能跑一些基础自动化任务,功耗和延迟都会好看很多。
最后分享一点个人体会。这个项目最让我意外的不是"技术多难",而是"人的预期和实际行为的差距"。很多人第一次看到代理操控 GUI 时,会期待它像科幻电影里的 AI 一样无所不能,实际用起来却发现它连一个滚动条都可能抓不准。但如果把预期调整为"它像一个刚开始学用电脑的实习生:能看懂界面结构、会按步骤操作、出错了能自己重试",你会发现它其实已经能帮忙干不少事了。
单文件也好、MCP 也好、GUI 控制也好,说到底都是为了让 AI 离真实世界更近一步。我还会继续在这个方向打磨下去,也希望看到更多人用开源方式把同类工具推到更完善的状态。