最近我一直在琢磨一件事:市面上那么多 AI 编码助手,能聊代码、能生成 diff、能补全,可一旦要让它们像人一样操作电脑——打开桌面软件、点几个按钮、填一张表单——能做到的却寥寥无几。我做的这个免费 AI 编码代理,就是为了补上这块拼图:它本身单文件运行,内嵌 GUI 操控能力,同时通过 MCP 协议去接各种外部工具和数据源。这篇东西不是项目官网介绍,而是把整个项目的设计思路、工程细节和踩坑过程整理出来,给想自己动手做 AI 代理、想给 AI 接上 GUI 和 MCP 的开发者一份能直接参考的经验。
1. 为什么我坚持做一个能操控 GUI、内置 MCP 的单文件代理
1.1 大多数 AI 编码代理做不到的“最后一公里”
先说背景。现在的 AI 编码代理,主流做法是给你一个命令行终端,让模型自己读代码、改代码、跑测试。这套玩法在纯代码项目里确实好用,但一旦任务涉及桌面软件,就会立刻卡住。
我遇到的真实场景是这样的:本地有个行业软件,导出报告时需要先在设置界面里把格式改成 PDF,再勾选几个选项,最后点导出按钮。整个过程不到十次点击,但没有任何一个主流 AI 编码代理能替我完成。它们能生成调用自动化脚本的代码,却没法自己“看着屏幕把这一步点了”。
这就是所谓的最后一公里问题:编码代理擅长处理文本和代码,但真实业务里大量操作发生在 GUI 上。要么你写脚本去调 API,要么自己手动点,AI 帮不上忙。我决定做一个能直接操控 GUI 的代理,本质上是把“AI 的双手”还给它。
1.2 MCP 让代理的边界不再取决于我写了多少代码
另一个驱动力是 MCP。如果你还没接触过,简单说,MCP 是一套标准化协议,让 AI 应用能统一接入各种工具和数据源,而不是每个工具写一套私有接口。像 Figma、数据库、浏览器这些生态都在往 MCP 上靠。
这意味着什么?意味着我做代理时,不需要为每个外部服务单独写适配层。只要对方提供了 MCP 服务端,代理内置一个 MCP 客户端就能直接调用。把 GUI 操控、文件操作、Shell 执行这些能力打包成 MCP 工具后,模型拿到的是一张统一的工具清单,来什么任务就调什么工具。这比在代码里写死一堆 if-else 判断要干净得多。
我选择在项目里内嵌 MCP 客户端,而不是简单调用第三方库,主要有三个原因:一是减少对外部进程的依赖,单文件分发时不用带一堆服务进程;二是可以自由选择 stdio 或 HTTP 传输方式;三是这样整个代理本身也能作为 MCP 宿主,被其他 AI 应用使用,等于多了一层扩展口。
1.3 单文件不是洁癖,是分发策略
为什么非要单文件运行?因为我在实际分发时发现,绝大多数潜在用户不是开发者。他们看到“需要 Python 3.10+”“先 pip install xxx”“再配个虚拟环境”就直接放弃了。哪怕是我自己在内网部署,遇到一台没装 Python 的机器,也会为环境折腾半小时。
单文件运行解决的是这些具体问题:拷过去就能跑,不需要预装运行时,不会污染系统环境,升级时覆盖一个文件就行。我选 PyInstaller 的 onefile 模式来完成这件事,后面专门有一章讲打包过程中的坑。这个决定在项目早期就定下来了,所以编码时一直在避免依赖写在外部配置文件里的路径,所有资源都往包内收。
2. 代理主循环与工具调度:从任务描述到动作序列
2.1 一个足够简单又能跑通的主循环
整个代理的核心不是某个神奇算法,而是一个朴素的主循环。我把这个过程叫“感知-规划-执行-观察”,循环往复直到任务完成或用户中止。
流程是这样的:先把用户的任务描述(比如“把导出格式改成 PDF 并导出到桌面”)交给模型,模型判断需要调用哪些工具,代理执行工具并拿到结果,再把结果作为上下文交回模型,模型根据观察结果决定下一步。代码层面大概是这样:
def run_agent(task: str, tools: list[dict], max_steps: int = 20): messages = [{"role": "user", "content": task}] for step in range(max_steps): resp = llm.chat(messages, tools=tools) if resp.tool_calls: messages.append(resp.message) for call in resp.tool_calls: result = dispatch_tool(call.name, call.arguments) messages.append(call.result_message(result)) else: print(resp.content) break每轮循环都要把工具执行结果塞回消息队列,这样模型才知道刚才那次点击到底有没有生效。如果没有这个观察反馈,代理就变成了盲人摸象,点了一下就以为自己完成了,实际界面可能根本没反应。
2.2 function calling 作为决策入口
主循环里的关键设计是让模型通过 function calling 决定调哪个工具。换句话说,我不让代理自己自由发挥地生成任意代码,而是给它一张清晰的工具清单,每个工具都有名字、描述和参数格式,模型只能在清单里挑。
这样做的理由很实际:限制比开放更容易控制风险。要是让模型自由写代码去操作系统,一次错误的 eval 就可能搞乱整个环境。用 function calling 之后,模型的每次动作都是可枚举、可审计的,至少我知道它干了什么。
工具清单我会定义成 JSON Schema 数组,例如 GUI 点击工具就长这样:
{ "name": "gui_click", "description": "点击屏幕坐标(x,y)处的元素", "parameters": { "type": "object", "properties": { "x": {"type": "integer"}, "y": {"type": "integer"} }, "required": ["x", "y"] } }模型调用时必须按这套格式给参数,dispatch 层拿到参数后直接执行对应函数。整个过程可预测、可回放。
2.3 工具注册表与上下文压缩
工具一多,就得考虑管理问题了。我实现了一个简单的工具注册表,每个工具在启动时通过装饰器注册进来:
TOOLS = {} def register(name, description, schema): def wrapper(func): TOOLS[name] = {"func": func, "schema": schema, "name": name} return func return wrapper @register("gui_click", "点击屏幕坐标", {...}) def gui_click(x, y): ...注册表同时输出两个东西:一份给模型的 JSON Schema 列表,一份给 dispatch 层的函数映射。模型看到的是 schema,实际执行的是函数。
还有一个必须处理的问题是上下文膨胀。每轮循环都会塞入截图信息、工具返回值、模型思考过程,几十轮下来上下文直接爆掉。我用两种方式控制:一是对图片类结果做降采样,截屏压缩后再交给模型,二是对历史工具结果做摘要,只保留关键信息。比如 MCP 返回的一长串 JSON 数据,我不会原样丢给模型,而是先截断、再做结构化摘要。
3. GUI 操控链路上的那些细节
3.1 技术选型:PyAutoGUI + OpenCV 的组合
GUI 操控是整个项目里最容易出问题、也最容易被低估的部分。我最初考虑过几条路:一是直接用 Windows 的 UIA 做控件级操作,二是纯视觉方案,三是混合方案。
最终我选了 PyAutoGUI 负责鼠标键盘模拟,OpenCV 负责视觉定位,理由是这套组合对三个平台(Windows、macOS、Linux)都有相对成熟的支持,且单文件打包时体积可控。控件级方案(比如 pywinauto)在 Windows 上确实更精准,但跨平台能力弱,而且不同软件的控件树差异极大,反而不如视觉定位通用。
代码层面,PyAutoGUI 的使用非常简单:
import pyautogui pyautogui.moveTo(x, y, duration=0.3) pyautogui.click() pyautogui.write("hello", interval=0.05)真正的难点不在“移动和点击”,而在“知道该往哪儿点”。这就是 OpenCV 发挥作用的地方。
3.2 从截图到点击:视觉定位的完整流程
一次完整的 GUI 操作,内部其实是这个流水线:
- 全屏截图(PIL.ImageGrab.grab)
- 在截图里找到目标元素(模板匹配或 OCR)
- 算出目标在屏幕上的绝对坐标
- 移动到坐标、点击
- 再截图确认结果
模板匹配的代码很直观:
import cv2 import pyautogui import numpy as np def find_on_screen(template_path, threshold=0.85): img = np.array(pyautogui.screenshot()) img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) template = cv2.imread(template_path) result = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc = cv2.minMaxLoc(result) if max_val < threshold: return None return max_loc这里最容易踩的坑是:模板匹配对尺度和旋转完全无容忍度。同一按钮在不同分辨率下可能就匹配不上了。我后期引入了多尺寸模板,运行时以多个倍率分别匹配,取置信度最高的结果。另外按钮可能有 hover 态、按下态,截图模板要尽量选静态态,不然匹配率会忽高忽低。
还有个细节是坐标换算。PyAutoGUI 的坐标原点是屏幕左上角,而截图拿到的像素坐标也是从左上角算的,理论上应该一致。但多显示器场景下,副屏处于主屏“延伸”区域时,截图拼接和坐标处理就需要单独处理,这部分留到下一节说。
3.3 高 DPI、多显示器、输入法这些真实环境变量
如果说模板匹配是精度问题,那高 DPI、多显示器、输入法就是让人当场崩溃的兼容问题。
Windows 系统如果开了 125% 缩放,PyAutoGUI 获取的坐标和实际像素之间会出现比例偏差。最简单的处理方式是在打包时给程序声明系统 DPI 感知,让系统不再做缩放:
# Windows 下通过 ctypes 声明 DPI 感知 import ctypes try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: pass多显示器更麻烦。PyAutoGUI 的尺寸感知在多屏下并不总是可靠,尤其是当副屏物理尺寸和分辨率不成比例的时候。我的处理方案是:先获取全部显示器的边界信息,再让视觉定位的搜索范围限定在主屏,如果找不到再扩展搜索副屏区域。
输入法问题出现在用 pyautogui.write 输入中文时。这个库对中文输入的支持本质上是模拟按键,无法直接输出中文。我最终的解决方案是使用剪贴板:把文本写入系统剪贴板,再用 Ctrl+V 粘贴。这个方法几乎兼容所有输入法:
import pyperclip pyautogui.hotkey("ctrl", "v")3.4 安全护栏:代理操作电脑必须有熔断机制
让 AI 代理直接操控鼠标键盘,最大的风险是“它乱点就完蛋”。所以我从一开始就加了四层安全护栏:
第一层,PyAutoGUI 自带的 FAILSAFE。鼠标被拖到屏幕左上角时,程序立刻抛出异常退出。这个机制我保留并扩大了范围,还在自己的循环里加了 ESC 按键检测。
第二层,操作预检。每次 GUI 工具真正执行前,代理会先输出“即将点击坐标为 (x,y),置信度 xx%”的日志。这在终端里可能只是一行字,但配合我做的简单交互模式,用户可以随时取消当前动作。
第三层,敏感区域保护。我定义了一个黑名单,比如任务栏、系统托盘、开始菜单这些区域禁止自动点击。这个黑名单在视觉定位结果出来后再做一次过滤,命中就直接丢弃。
第四层,超时与重试。一次点击之后必须有截图反馈,如果连续 N 次尝试都发现界面没有变化,就停止并向上报错,而不是无限重复点击。这一步能避免很多死循环式的误操作。
4. 内嵌 MCP 客户端,把 GUI 也变成 MCP 工具
4.1 MCP 协议的本质:一套标准化的 JSON-RPC 工具箱
MCP 这个名字近半年出现频率很高,但它本质上不复杂。它的核心是一套基于 JSON-RPC 2.0 的消息协议,定义了客户端和服务端之间的通讯格式。你可以把 MCP 理解为“AI 世界的 USB 接口”——无论后面插的是鼠标、键盘还是显示器,接口形状都统一。
MCP 定义了三个主要原语:Tools(可执行的函数)、Resources(可读取的资源)、Prompts(可复用的提示词模板)。代理场景里用到最多的就是 Tools:客户端先调用 tools/list 拿到服务端能提供的工具清单,再通过 tools/call 执行具体工具。
传输层面有两种常见方式:stdio 和 HTTP。stdio 适合本地子进程,HTTP 适合远程服务。我自己的代理两者都支持,这样既能启动本地的 MCP 服务进程,也能连接局域网内的 MCP 服务。
4.2 最小 MCP 客户端:初始化、列工具、调工具
如果不想引入沉重的官方 SDK,手写一个最小 MCP 客户端其实不难。核心就是三个方法:initialize、list_tools、call_tool。下面是我基于标准库实现的一个简化版,走 stdio 传输:
import json import subprocess class MCPClient: def __init__(self, cmd): self.proc = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True ) self.next_id = 0 def request(self, method, params=None): self.next_id += 1 payload = { "jsonrpc": "2.0", "id": self.next_id, "method": method, "params": params or {} } self.proc.stdin.write(json.dumps(payload) + "\n") self.proc.stdin.flush() line = self.proc.stdout.readline() res = json.loads(line) return res.get("result") def initialize(self): self.request("initialize", { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "my-agent", "version": "0.1"} }) self.request("notifications/initialized") def list_tools(self): return self.request("tools/list") def call_tool(self, name, arguments): return self.request("tools/call", { "name": name, "arguments": arguments })这个实现把 JSON-RPC 的报文收发包装成了普通方法调用,逻辑非常直白。有了它,任何提供 MCP 服务的工具都能接入代理。
需要注意一个细节:MCP 协议的版本迭代很快,协议版本号要和服务端协商一致,否则握手会失败。我的做法是安装时读取服务端支持的版本清单,取交集,而不是写死一个版本号。
4.3 把 GUI 能力封装成 MCP 工具后的效果
项目里最让我满意的一件事,是把第 3 章的 GUI 操控能力整体封装成了 MCP 工具。也就是说,模型视角下,GUI 操作和其他外部工具没有任何区别,都通过 tools/list 暴露:
{ "name": "gui_screenshot", "description": "截取当前屏幕内容" }这个设计带来的好处是架构上的彻底解耦。以前我想给代理加一个“能用微信发消息”的能力,需要改主循环代码;现在只需要再加一个 MCP 服务端,配置文件里多一行连接信息,代理立刻多了一项新技能。
模型调用 GUI 工具的流程是这样的:它先调用 gui_screenshot 拿到当前画面,然后调用 gui_locate 寻找目标按钮坐标,再调用 gui_click 点击。三个工具串起来,就实现了一次完整的视觉交互。整个过程对模型来说是透明的函数调用,但背后是截图、模板匹配、坐标换算、模拟输入这一整套工程逻辑。
5. 单文件打包实战:PyInstaller 的坑与对策
5.1 打包方案选型对比
项目所有功能跑通后,最痛苦的环节来了:怎么把这一堆 Python 依赖变成一个单文件。我评估了几种主流方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| PyInstaller | 文档全、坑都有解、社区大 | 打包体积较大,误报率高 |
| Nuitka | 编译后性能好、反编译难 | 构建时间长,依赖兼容问题更多 |
| Docker 容器 | 依赖干净、跨平台 | 不是单文件,启动需要 Docker |
| pipx 安装 | 简单,适合发布 CLI 工具 | 需要 Python 环境,不是单文件 |
最终选了 PyInstaller。不是因为它是最好用的,而是因为它的“坑”已经被无数人踩平了,出问题基本都能搜到解法。对于这种工具链层面的选择,我倾向选最成熟的,而不是最好看的。
5.2 处理 hidden import 与数据文件
PyInstaller 一个较常见的坑是隐藏导入:代码里通过字符串或者动态方式 import 的模块,打包时不会被自动收集。我的 GUI 模块就遇到了这个问题,因为 pyautogui 在不同操作系统会动态加载不同的底层后端。
解决办法是在 spec 文件里显式声明 hiddenimports:
a = Analysis( ["main.py"], hiddenimports=[ "pyautogui._pyautogui_x11", "pyautogui._pyautogui_win", ], )让模板图片等资源文件随包分发,也要在打包参数里明确指定。这里有平台差异要注意:Windows 下多个路径用分号分隔,Linux/macOS 用冒号:
pyinstaller --onefile --add-data "templates:templates" main.py我在这里踩过一次坑:在 Windows 上开发时用冒号写路径,结果本机没事,放到 Linux 上就崩了。后来统一改成在 spec 文件里用 os.pathsep 处理,才彻底解决。
5.3 体积、启动速度与杀毒误报
单文件打包后体积一般会到 50MB 以上,主要是 GUI 相关库和模型依赖占大头。启动时因为 PyInstaller 要把打包内容解压到临时目录,所以会比源码运行慢 2-3 秒。这属于单文件模式的固有代价,只能接受。但可以通过加--noupx关闭压缩来换取更快的解压速度和更低的杀毒误报率,这个取舍我认为划算。
误报是单文件项目绕不开的话题。PyInstaller 打包出来的 exe 经常被 Windows Defender 等工具标记,因为“单文件”和“运行时解压”这两个特征太接近加壳行为。我的处理方式:一是用 UPX 压缩会明显增加误报,所以直接关掉;二是给 exe 加上版本信息和数字签名(哪怕自己签一个),能显著降低某些杀毒软件的拦截概率;三是发布时附上源码链接和 SHA256 校验值,让用户自行核对文件完整性。
6. 实测:让代理自动完成一次桌面交互任务
6.1 一个真实的任务回放
理论说了一堆,最后看一个能完整复现的例子。我在测试机上让代理执行这样一个任务:“打开测试工具,在设置里把输出格式改成 PDF,然后保存设置。”
代理的实际执行轨迹大致如下:
- 调用 gui_screenshot,截取屏幕,模型识别出桌面底部有测试工具的图标。
- 调用 gui_click 双击图标,等待窗口出现(截图确认)。
- 再截图,模型定位工具栏里的“设置”按钮,点击。
- 在设置窗口里,通过 gui_locate 找到输出格式的下拉框,点击后弹出列表,再截图找到 PDF 选项点击。
- 点保存按钮,最后截图确认设置已更新。
整个过程用了 14 轮工具调用,耗时约 40 秒,最终成功。真正让我觉得这项目成了的时刻,是模型在遇到下拉框选项没弹出来时,自己多截了一次图重新观察,而不是机械地继续点。
6.2 从失败中总结的操作稳定性要点
这个任务不是第一次就成功的。前几版跑同一任务时反复出问题,让我总结出几个稳定性要点:
GUI 自动化最大的不稳定因素是等待时间。界面元素没加载出来就去点击,必然失败。我的解决方式是每次 gui_click 之前先自动 sleep 0.3 秒,并且提供 wait_until 机制——截图判断某个区域是否出现变化,再继续下一步。
模型定位不准的时候,宁可让视觉置信度阈值高一点而失败,也不要低阈值乱点。我用阈值 0.85,匹配不到就明确告诉模型“没找到,请调整策略”,让模型自己想着换模板或者换路径。实测下来,这种“主动失败上报”比硬匹配靠谱太多。
6.3 这些场景现在就能用,哪些场景别硬上
用了这段时间,我对这个代理的适用边界有了明确判断。适合的任务有几个特征:步骤清晰、界面稳定、操作频率低,比如导出文件、配置软件、填表单、跑一遍测试流程。这类任务属于“重复劳动但每次又不太一样”,AI 代理最擅长干这个。
不适合的任务也很明显:一是连续动画界面,比如视频播放器里的精确拖拽,截图反馈跟不上;二是需要超低延迟操作的游戏场景,视觉定位来回耗时太长;三是高度依赖真实拖拽(drag-and-drop)的界面,PyAutoGUI 的模拟拖拽经常被界面判定为无效操作。遇到这些,我会直接告诉用户这个代理不适合,省得大家浪费时间。
最后分享一个我个人的经验:在动手实现 GUI 操控之前,先把 MCP 客户端和工具层跑通,再把 GUI 能力作为普通 MCP 工具注册进去。这个顺序让整个项目的架构清爽很多——GUI 只是众多工具里的一项,而不是嵌在主循环里的一个特殊分支。后面想接别的能力,也保持了同样的扩展路径。