
最近半年被问得最多的一个问题不是“你能写多少代码”而是“你的编码代理能不能帮我把那个界面上的按钮点了”。市面上的 AI 编码代理大多把精力花在理解仓库、生成 diff、跑测试上对真实世界的操作能力基本为零。我做了个免费的小项目思路很简单让 AI 编码代理既能和 MCP 生态打通又能直接操控 GUI 应用同时把整个程序打包成单文件运行目标机器上不用装 Python 也不用配任何依赖。这篇文章就是我自己从零到一实现这个工具的全过程复盘包括架构设计、核心模块拆解、单文件打包的坑、以及实跑过程中遇到的典型问题希望能给正在做同类东西的朋友一点参考。1. 项目定位与整体方案拆解1.1 先看清楚问题编码代理缺的不是代码能力我发现行业里其实有两类工具长期处在“各玩各的”状态。第一类是专注代码上下文的编码代理它们能读懂仓库、补全函数、跑单测但你让它“打开桌面软件、登录、点个导出按钮”它完全没辙。第二类是传统 GUI 自动化脚本比如按键精灵、RPA、Selenium 之类它们能精准操作界面但脚本逻辑是写死的界面一变就崩更谈不上根据任务动态决策。我在项目调研阶段的结论是这两类能力之间的空白地带恰恰是很多人真正需要的。一个 AI 编码代理如果能做到“看得见屏幕、摸得到控件、听得懂 MCP 工具调用”那它就不只是写代码的工具而是一个能帮你把重复操作干完的数字员工。所以这个项目从第一天起就定了三个关键指标后续所有设计都围绕它们展开支持操控 GUI包括读取界面上的控件树、点击按钮、输入文本、截屏观察而不是只依赖命令行。支持 MCP接入模型上下文协议Model Context Protocol让代理能调用用户已有的 MCP server 能力比如文件系统、数据库、浏览器工具等。单文件运行最终交付物是一个可执行文件拷贝到任何同架构机器上直接能跑不需要安装解释器或第三方依赖。1.2 三条设计原则能力、生态、分发的平衡第一个原则是“一切能力皆工具”而且我做了个比较激进的决定连 GUI 操作本身都封装成 MCP 工具。也就是说Agent 的主循环不直接调用 Python 函数去拿鼠标而是通过统一的工具路由层把“查找窗口”“点击按钮”“输入文字”这些都注册成标准的 MCP 工具。这样主模型只需要学会一种调用方式就能同时操作命令行、文件系统、数据库和 GUI 应用架构干净很多。第二个原则是“能接入的生态就不重复实现”。MCP 这两年的发展速度很快社区里已经有很多高质量的 server比如文件系统、PostgreSQL、浏览器控制等。如果我全部自己重写一遍项目体量会爆炸。所以我在代码里内置了一个轻量的 MCP 客户端能够加载用户配置的任意 MCP server同时也内置了一个“GUI MCP server”算是给系统补上其他 server 覆盖不到的桌面操控能力。第三个原则是“单文件交付零环境玷污”。团队协作交付一个自动化工具时最痛苦的就是环境配置。你写好的 Python 脚本换台机器跑不起来对方机器没装依赖、没有网络权限装包、包管理器镜像地址不对各种问题能把一个简单的任务拖死。单文件分发虽然不是新技术但对于一个涉及 AI 模型调用和 GUI 操作的复合工具来说这个选择几乎决定了工具能不能被真正用起来。1.3 技术选型为什么是 Python MCP 无障碍树实现语言我选的是 Python理由很朴素MCP 生态的 Python SDK 最成熟GUI 无障碍接口的绑定也齐全PyInstaller 打包虽然有一些坑但整体可控。项目核心逻辑大约两千行如果换成 Go 或者 Rust先把 MCP 协议和各家 GUI 框架的封装重写一遍四周都未必够。模型接口走的是 OpenAI 兼容的 chat completions 协议底模可以用云端的也可以接本地部署的大模型。这样设计是为了避免把项目绑定在某一家厂商上用户填一个 base_url 就能切换供应商。GUI 操控层我最终选择了“无障碍树优先图像识别兜底”的路线。Windows 上用 UI AutomationLinux 上走 AT-SPImacOS 上调用 Accessibility API。这三个方案都不是截图然后找像素而是直接读取系统暴露的控件结构稳得多。2. 核心模块设计与实现细节2.1 GUI 操作层别在一开始就想着截图像素匹配很多做界面自动化的朋友一上来就写 OpenCV 模板匹配结果换来换去在不同的软件上各种失灵。我自己踩过无数次坑结论是对于一个要服务于 AI Agent 的 GUI 操作层优先级应该是“结构信息 图像信息”。结构信息的准确定义是操作系统能告诉我们的“界面上有什么东西它们在哪”。比如 Windows 的 UI Automation 树里“确定”按钮不是一个像素坐标而是一个带有 Name、ControlType、BoundingRectangle 等属性的元素节点。用这套接口拿到的信息是“语义级”的模型很容易理解。图像识别只能拿到“这块区域长这样”本质上是把连续像素抽象成了字符丢失了大量结构特征。我的 GUI 操作层抽象成了这样一个接口class GUIElement: element_id: str # 全局唯一的控件句柄 name: str # 控件文字 control_type: str # Button, Edit, Window... rect: tuple # 相对屏幕的边界框 enabled: bool children: list # 子元素用于树遍历 class GUIHost: def get_tree(self) - list[GUIElement]: ... def click(self, element_id: str) - bool: ... def double_click(self, element_id: str) - bool: ... def type_text(self, text: str) - None: ... def screenshot(self) - bytes: ...用这个抽象之后Windows 和 Linux 的差异就被隔离到了 GUIHost 的具体实现中。我分别在两个平台上写了适配器Windows 用 pywinautoLinux 用 dogtail。macOS 的适配器由于手头没有设备没有细测但接口保留好了后续补上不会伤筋动骨。2.2 MCP 客户端用最小的代码量接入整个生态MCP 协议本质上就是基于 JSON-RPC 2.0 的一套远程过程调用约定核心就三个方法initialize 做能力握手tools/list 获取工具列表tools/call 执行工具。我不想引入太重的 SDK直接在代码里写了一个精简的客户端通过标准输入输出和 MCP server 进程通信。一个核心调用长这样import json, subprocess class MCPClient: def __init__(self, cmd, args): self.proc subprocess.Popen( [cmd, *args], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, ) self.next_id 0 self._initialize() def _rpc(self, method, params): self.next_id 1 req { jsonrpc: 2.0, id: self.next_id, method: method, params: params, } self.proc.stdin.write((json.dumps(req) \n).encode()) self.proc.stdin.flush() line self.proc.stdout.readline() return json.loads(line) def _initialize(self): # 必须首先握手交换协议版本和能力清单 self._rpc(initialize, { protocolVersion: 2024-11-05, capabilities: {}, clientInfo: {name: freeagent, version: 0.1.0}, }) self._rpc(notifications/initialized, {}) def list_tools(self): return self._rpc(tools/list, {})[result] def call_tool(self, name, args): return self._rpc(tools/call, {name: name, arguments: args})这套实现的好处是零第三方依赖PyInstaller 打包时不需要处理各种动态库。坏处是如果以后 MCP 协议升级需要自己跟着改。但对于个人项目来说性价比极高。2.3 把 GUI 操作变成 MCP 工具MCP 客户端和服务端都是“进程外通信”但 GUI 操作本身必须在当前进程内执行因为鼠标和键盘控制的是整个桌面。所以我把 GUI 操作注册进了一个内置的工具路由表伪装成一个“本机 MCP server”挂在 Agent 的工具列表里。工具描述大概是这样的{ name: gui_click, description: Click a visible UI element by its element_id. Find the element_id via gui_get_tree first., inputSchema: { type: object, properties: { element_id: {type: string, description: Element ID from GUI tree} }, required: [element_id] } }真正处理工具调用的函数也很直接def handle_call(name, args): if name gui_get_tree: return gui.dump_tree() elif name gui_click: ok gui.click(args[element_id]) return {ok: ok, screenshot: gui.screenshot_b64()} elif name gui_type_text: gui.type_text(args[text]) return {ok: True, screenshot: gui.screenshot_b64()}这里有一个我很坚持的小设计每次 GUI 工具执行完成后都把当前截屏作为 base64 数据返回给模型。因为 GUI 的状态只会通过视觉和结构变化体现如果模型看不到“点击之后发生了什么”它就很难决定下一步。截图返回虽然会多消耗一些 token但对稳定性的提升是决定性的。2.4 Agent 主循环观察、决策、行动、反馈Agent 主循环本质上是四步的往复从工具列表和观察结果中获取当前状态让大模型根据用户任务做出决策执行工具调用再把执行结果反馈给模型。在 GUI 场景下“观察”这一步需要特别小心界面元素成千上万直接把整棵控件树全部丢给模型上下文会爆炸。我的做法是做一个“控件清单精简器”。先把控件树按层级展开过滤掉不可见、不可用的元素再对每个元素截短名称保留前 40 个字符。这样一棵 3000 个节点的树最终送给模型的文本通常只有 6~10KB 左右模型能处理的过来。系统提示词里我会写这么一段你是一个可以操作电脑桌面的智能代理。你必须遵循以下规则 1. 永远先调用 gui_get_tree 观察当前界面再决定下一步。 2. 只允许调用工具列表里出现的工具。 3. 如果一个操作没有生效先截屏确认界面状态不要盲目重复执行。 4. 复杂任务拆成多次小操作执行每步只做一个动作。 5. 操作完成后给用户一个简洁的自然语言说明。坦白说这套主循环本身并没有太复杂真正的复杂度全都沉淀在“工具集的设计是否合理”“工具返回的信息是否有效”“模型是否有足够的上下文做决策”这三件事上。把这三点想清楚整个系统就像搭积木一样立起来了。3. 单文件分发的完整落地过程3.1 单文件打包的底层原理项目开发阶段跑起来很容易真正让人头疼的是“交付”。我一开始也用了 PyInstaller 的目录模式后来为了测试“能不能放到一台干净机器上直接用”改成了 --onefile 模式。PyInstaller 的 onefile 机制是这样的打包时把所有 Python 字节码、依赖库、资源文件压缩进一个可执行文件里启动时先把整个包解压到系统临时目录然后运行。这种模式的好处是分发时只有一个文件坏处是启动速度慢得离谱。我第一次打包出来的产物有 120MB解压需要小十秒启动时间直逼 15 秒。这在交互式工具里是完全不可接受的。后来做了一轮针对性的瘦身砍掉了几个用不上的子依赖又把模型相关的库改成延迟加载最终交付包降到了 52MB启动控制在 3 秒左右。3.2 资源文件与定位路径的坑单文件打包最难处理的是资源文件路径。代码在开发环境里访问 config.yaml 用的是相对路径打包后 config.yaml 被塞进了临时目录相对当前工作目录的路径基本全失效。正确写法是这样的import os, sys def resource_path(relative_path): base_path getattr(sys, _MEIPASS, os.path.dirname(os.path.abspath(__file__))) return os.path.join(base_path, relative_path)sys._MEIPASS 是 PyInstaller 在 onefile 模式下设置的临时解压目录开发环境下不存在就用项目根目录兜底。所有的配置文件、图标、内置 MCP server 描述文件都必须通过这个函数去拿。打包命令我放在一个 build.sh 里关键参数如下pyinstaller --onefile --name freeagent \ --add-data config.example.yaml:. \ --hidden-import queue \ --hidden-import PIL._tkinter_finder \ --collect-all mcp \ entry.py--collect-all mcp 会把这个库的 schema、初始化文件等资源都打进去少了它经常会出现 MCP 握手时报缺少文件这类问题。3.3 隐藏导入和动态库的经典翻车现场PyInstaller 静态分析 import 语句时对“通过字符串名称导入”的模块完全无能为力。我有一次在代码里写了一句importlib.import_module(scripts.collectors. plugin_name)结果打包后运行到那一步直接报 ModuleNotFoundError。解决办法是在项目入口文件里显式导入一遍所有插件模块或者用 --hidden-import 参数把它们挨个列出。更优雅一点的做法是维护一个 PLUGINS 白名单列表一次性把插件都规划好。另外一代言 Windows 上打包的常见问题某些动态库文件在目标机器上没有 VC Redistributable 运行时程序就会随机崩溃。最省心的方案是在打包时带上 ucrt 库或者在 README 里明确要求用户装一次微软常用运行库。3.4 性能优化从15秒启动到3秒的实操我给项目的启动流程加了一个“两级加载”机制启动进程后先显示一个极简的控制台界面告诉用户“正在初始化”(这是为了有反馈)真正的 AI 模型客户端、MCP server 连接、GUI 适配器全部放到后台线程里初始化。另一个优化点是裁剪依赖。我原本想用pip install openai来访问模型接口但后来发现 OpenAI SDK 会顺带拉入 httpx、pydantic、certifi 等一大串库其中大半我用不到。最后我直接用 urllib 自己写了一个最小化的 chat completion 客户端代码少了两个数量级。4. 实操验证与典型应用场景4.1 真实演示让代理操作一个桌面软件开发阶段结束后我找了一个真实场景来验证让代理打开一个桌面端的数据分析程序输入一批数据执行一次分析把结果导出成 CSV 文件。整个执行过程的决策链大致如下第一步调用 gui_get_tree拿到当前屏幕上所有窗口和控件的结构清单。第二步模型在清单里找到“导入数据”按钮的 element_id调用 gui_click。第三步出现文件选择对话框模型通过控件树识别路径输入框调用 gui_type_text 输入文件路径然后点击“确定”。第四步程序返回主界面模型再根据控件树寻找“导出”菜单项完成导出。这个过程中还有一个亮点模型一开始没有找到“导出”按钮的直接入口于是它调用了一次 gui_get_tree 之后主动生成了一个展开菜单的操作。这正是我之前说的“结构信息 模型决策”的组合价值传统脚本到这一步可能就直接不知道该怎么办了。完整任务执行了大约 2 分钟比人工操作慢但整个过程代理不需要任何代码逻辑干预这对我而言是合格的结果。4.2 安全边界GUI 操作不是沙箱有一点我必须强调GUI 自动化和代码执行不一样Agent 每点一下鼠标都是真实行为一旦误操作可能把用户正在编辑的文档弄坏。我在项目里做了三层安全措施。第一层是操作白名单所有 gui_click 只允许操作那些“当前界面友好可见”的控件隐藏窗口里的控件即使被模型识别到也会被拒绝执行。第二层是危险动作确认机制如果模型要执行的工具名称里包含 delete、format、remove 这类高危关键词系统会先暂停弹出交互式确认。第三层是我自己在文档里强烈建议如果你只是在技术验证阶段跑这个工具请务必放到虚拟机里远比对着一台真实工作机心惊胆战地测试靠谱。4.3 配置文件把系统参数和模型参数分开为了让工具能适配不同用户的环境我把所有可变参数收敛在一个 YAML 配置文件里model: provider: openai-compatible base_url: http://127.0.0.1:11434/v1 api_key: local model: qwen2.5-coder:latest gui: host: auto screen_timeout_sec: 10 enabled: true mcp_servers: - name: filesystem command: npx args: [-y, modelcontextprotocol/server-filesystem, /tmp/workspace] - name: database command: uvx args: [mcp-server-sqlite, --db-path, ./app.db] history_file: ~/.freeagent/sessions.dbmodel 部分支持任意 OpenAI 兼容的接口base_url 指向本地或者云端服务都行本地模型推荐用 qwen 或者 llama 系列的中型参数模型。mcp_servers 支持加载用户自己的 MCP server这是这个项目最有想象力的部分。5. 常见问题与排查技巧实录5.1 环境与打包类问题表格是我整理出的高频问题速查后面有针对每个问题的补充说明。问题现象可能原因排查方式单文件启动后控制台白屏闪退临时目录无写权限检查 TEMP 环境变量指定的路径启动超时到 10 秒以上杀毒软件实时扫描临时解压目录临时添加白名单测试打包后运行报 ModuleNotFoundError模块通过动态字符串导入用 --hidden-import 显式声明换一台机器运行报缺少 DLL目标机缺少 VC 运行库随包附带安装一次常见运行库MCP server 连接失败npx / uvx 不在 PATHSubprocess 里显式传环境变量第一类问题里我发现 PyInstaller 解压临时目录默认在系统盘下很多企业的终端电脑没有对临时目录的写入权限这种环境下单文件会直接秒退。解决办法需要给工具加一个--temp-dir参数运行初始化时把解压路径改到用户目录下。5.2 MCP 协议与权限类问题MCP server 连不上是第二大类高频问题。用户配置一个 MCP server 之后经常报 spawn ENOENT 之类的错误。排查时先看本机命令行能不能直接执行 server 的 command比如配的是npx那就要确保 Node.js 装了且 npx 在 PATH 里。但 GUI 环境下 PATH 往往被桌面启动器截短我通常在启动 MCP server 时手动把系统的完整 PATH 注入进去。权限问题在 Linux 上尤其多发。AT-SPI 需要桌面会话的辅助功能权限很多轻量窗口管理器默认没开启。如果你在 Ubuntu 上用 GNOME需要先安装 at-spi2-core然后在设置里打开 Universal Access。macOS 要手动给终端授予辅助功能权限这也是上手门槛的一部分。5.3 模型决策稳定性问题最后一个高频问题是模型偶尔会“幻觉工具”比如调用一个不存在的工具名或者传入重复的参数。我的解决办法是加了一个智能校验层根据工具 schema 对模型输出做二次校验解析失败时自动向模型返回错误信息让它重新生成一次调用。实测下来第二轮生成的成功率超过 95%。还有一个很小但影响很大的细节模型在连续操作同一个界面时容易“过度自信”点击一次失败后立刻重试同样的操作完全忽略截屏反馈。我在提示词里加了强硬约束如果一个操作连续两次返回相同结果必须调用截屏重新观察。这个约束执行之后任务成功率提升了差不多三成。6. 实操心得与后续扩展方向项目从立项到跑通前后写了大概三周。做这类“Agent GUI MCP”组合工具我最深的体会是真正的技术壁垒并不在某个单独模块里而是在各个模块的衔接处把细节抠干净。比如 GUI 操作返回的截屏要不要给模型看给的时候用什么压缩比例模型上下文窗口装不装得下这些二十年前做桌面自动化的人完全不关心的问题在 AI Agent 时代全都变成了关键决策点。这个工具后续的扩展空间也很明确。第一是支持更多 GUI 框架目前 Linux 下对 Qt 应用已经比较稳Electron 应用还需要补一层 Chromium 的调试协议对接。第二是让用户能录制一段手动操作自动转成 Agent 的可执行轨迹这样针对高频流程就不需要每次让模型从头推理。第三是把单文件体积再压一压考虑把模型客户端按需下载到用户目录而不是全部塞进包里。最后再分享一个小技巧开发和调试这类工具时一定要把 Agent 的每轮“思考–工具调用–反馈”记录成 JSONL 日志文件。这个文件能让你快速定位是模型判断错、工具执行错还是界面变化导致的问题比在控制台里看彩色输出高效得多。按照我个人实际使用的经验这个习惯帮我省掉了至少一半的排错时间。