☰
AI编码代理如何通过MCP操控Windows GUI:一个单文件工具的工程实践
2026/10/15 9:15:16 网站建设 项目流程

我最近被一个"看着简单、做起来烦"的需求逼着写了个小工具:一个免费的 AI 编码代理,它能自己看电脑屏幕、操作 Windows 桌面程序,也能通过 MCP 协议调用外部工具,还打包成了单文件。写的时候没想太多,结果发出去以后收到不少同行私信,问架构和排坑思路。干脆把工程笔记整理出来,聊清楚从"为什么需要"到"怎么落地"的整个过程。

先交代前提:这个项目不是让 AI 陪你聊天,而是让模型进入一个"看界面、做决策、动鼠标键盘、再看界面"的闭环。它能主动调用 MCP Server 暴露的工具,也能自己充当一个 MCP Server,把 GUI 操作能力暴露给模型。适合正在做 Agent 工程化、MCP Server,或者手头有一堆只有图形界面、没有 API 的老系统需要自动化的人参考。文章不吹不黑,都是实际踩过的东西。

1. 为什么编码代理必须长出眼睛和手:从 shell 到桌面的那一步

现在大家熟悉的编码代理,比如 Claude Code 这类终端里的 Agent,本质上活在文本世界里。它们能看到的是命令行输出、文件内容、git diff 结果,能操作的是 shell、文件系统、代码编辑器。这套模型在大部分开发场景里够用,因为程序员的工作流终点十有八九是代码仓库和终端。

但现实里还有很多工作流的终点是一个只有 GUI 的老软件。我这次遇到的真实场景是:一个设备配置工具,没有 API、没有命令行参数、没有数据库直连权限,所有操作只能靠鼠标键盘在界面上点。数据在 Excel 里,流程是要打开这个工具,把参数一项项填进几个页面,点校验,通过以后导出报告。整个过程人工点大概五分钟,点错一步就得重来。

这个问题触碰到了编码代理的核心盲区:它能跑命令,但看不见桌面,也伸不出手去操作桌面。

我当时的第一反应是,要不给 Agent 接一堆工具,比如截图、模拟点击、读窗口控件。做个 MCP Server 把这些能力暴露出来就行。但真正做的时候才发现,问题不在于"能不能调用工具",而在于工具返回的信息模型能不能理解、理解以后能不能稳定地完成操作闭环。终端 Agent 是天然闭环的:命令失败,错误信息就在 stdout 里。GUI Agent 不是,它必须靠"感知"来建立任务状态。

那时候我做了个对比,大概是这样:

维度终端 AgentGUI Agent
可感知范围文本流、文件、环境变量窗口树、控件属性、截图、OCR 结果
操作方式shell 命令、文件读写点击、输入、滚动、拖拽、等待窗口状态
失败模式命令报错,错误可读鼠标点错位置、窗口被弹窗挡住、按钮灰色不可点
验证方式看退出码、看输出日志再截一张图,再读一次控件树,对比界面状态
调试复杂度相对可控坐标、分辨率、DPI、控件加载延迟全都要管

做完这个表,结论非常清楚了:编码代理想碰 GUI,缺的不只是一双手,还有一双眼睛。眼睛负责把屏幕上杂乱的信息变成模型能读的结构化上下文,手负责在这个结构上执行操作。而 MCP 恰好可以把"眼睛和手"都包装成标准工具,让模型用统一的接口去调用。所以这个项目最终选择了"AI 编码代理 + MCP + GUI Bridge"的组合,而不是自己写一套封闭的自动化脚本。

2. 单文件压顶:架构怎么选、代码怎么拆,才不变成一堆依赖

项目一开始给自己定了几个硬性要求:免费、支持 MCP、能操控 GUI、单文件运行。前三个都好说,最后一个"单文件"几乎把技术栈逼上了绝路。

最初我用 Go 搭了一个原型,因为 Go 天然编译成单二进制,启动快,跨平台省心。但写了两周就发现一个问题:GUI 自动化生态主要拥抱的是 Python。Windows 上要读 UIA(UI Automation)控件树、要解析控件属性、要调 COM 接口,Python 有 pywinauto、pyuia、win32gui 这些现成轮子。Go 不是不能做,但意味着我要自己封装一大堆底层 API,开发周期直接拉长好几倍。

于是换回 Python,然后把"单文件"这个目标交给 PyInstaller。打包用--onefile,最终产物是一个单独的 .exe。这里必须说一句,Python 的单文件其实不是真正的单文件,它启动时会把依赖释放到临时目录再加载,所以冷启动会慢一点,而且某些杀毒软件喜欢对这种自解压程序误报。后面我会专门讲这个坑怎么绕。

架构上我把程序拆成两条执行路径,但它们共享同一个代码库,最后打包成一个文件:

free_agent/ agent.py # 入口:根据 argv[0] 决定是 agent 主进程还是 gui bridge core/ loop.py # Agent 主循环:tool calling 的反复拉锯 mcp_client.py # MCP 客户端:连 stdio transport 的 MCP Server config.py # 配置解析 policy.py # 安全策略:只允许操作指定窗口和动作 bridge/ gui_server.py # 用 FastMCP 暴露 GUI 工具 windows_backend.py # 真正调用 Windows UIA / pywinauto 干活的地方 ocr_backend.py # 界面拿不到控件树时的兜底方案

关键在于入口设计。同一个可执行文件可以这样用:

# 正常启动 agent free_agent run --task "打开 DeviceTool,填写参数,校验并导出" --config config.yaml # 启动 GUI MCP Server,会被上面的 agent 当成子进程拉起 free_agent gui-server

为什么要做成同一份二进制分饰两角?因为 MCP Server 如果是独立脚本,用户就得装两遍环境;如果让config.yaml里的 MCP server 配置指向free_agent gui-server,那么用户只需要一个文件加一个配置,非常干净。实际配置长这样:

model: base_url: ${MODEL_BASE_URL} api_key_env: MODEL_API_KEY name: ${MODEL_NAME} mcp: gui: transport: stdio command: ./free_agent args: ["gui-server"]

Agent 主循环本身不复杂,就是一个反复调用大模型、解析工具调用、执行、把结果喂回去的过程。伪代码大概这样:

messages = [system_prompt, user_task] while not done: reply = llm.chat(messages, tools=all_tools) if not reply.has_tool_calls: done = True continue for call in reply.tool_calls: result = mcp_client.call_tool(call.name, call.args) messages.append(tool_result_message(call, result))

所有工具都通过 MCP 暴露,包括 GUI 工具。这样一来,Agent 不需要知道 GUI 工具是怎么实现的,只需要按 MCP 协议调用。逻辑边界很清楚:Agent 负责规划,GUI Bridge 负责感知和动作。

3. 把 GUI 变成 MCP 工具集:同一份二进制分饰两角

MCP 全称 Model Context Protocol,是让大模型调用外部工具的一种标准协议。协议本身不复杂,核心是tools/list和tools/call两个请求。客户端先问服务端"你有哪些工具",每个工具带一份 JSON Schema 描述参数,然后客户端在需要的时候发起tools/call执行具体工具。模型接到的就是一个工具列表,它自己决定什么时候调、传什么参数。

我的 GUI Bridge 就是用 FastMCP 写的,把 Windows 桌面操作暴露成一组工具:

from mcp.server.fastmcp import FastMCP mcp = FastMCP("gui") @mcp.tool() def list_windows() -> str: """返回当前所有可见窗口,包含句柄、标题、进程名和窗口类名""" ... @mcp.tool() def inspect_window(title: str, depth: int = 3) -> str: """返回窗口的控件树,节点带 name、automation_id、control_type、坐标、是否可见""" ... @mcp.tool() def click_on_text(title: str, text: str) -> str: """在目标窗口中找到文本匹配的控件并点击""" ... @mcp.tool() def input_text(title: str, text: str, field_name: str) -> str: """向指定控件输入文本,field_name 来自 inspect_window 返回的标签或 automation_id""" ... @mcp.tool() def screenshot(title: str = "", output: str = "debug.png") -> str: """截取整个屏幕或指定窗口,保存为图片""" ... @mcp.tool() def wait_for_text(title: str, text: str, timeout: int = 10) -> str: """等待指定文本出现在窗口中,常用于校验操作结果""" ... mcp.run()

这几个工具其实就是把人类操作 GUI 的基本动作拆开了:找窗口、看控件树、点击、输入、截图、等待状态。最后一步wait_for_text特别重要,因为 GUI 自动化最大的不确定因素就是时序。你点了按钮以后,控件可能过两秒才刷新,如果没有等待机制,模型下一步直接读界面状态就会读到旧数据。

说回操控原理。Windows 上比较靠谱的路径是拿到窗口句柄以后,通过 UIA 接口读整棵控件树。控件树有点像网页的 DOM:每个控件有名字、类型、位置、是否可见、是否可点这些属性。模型真正需要理解的不是一张像素图,而是一棵树。这一步信息密度极高,而且比截图便宜太多。如果某个界面是自绘的,比如一些游戏引擎写的 UI,UIA 树是空的,这时候才需要截图加 OCR 兜底。

关于工具集,我个人认为不要急着加太多炫技工。至少要有"读界面"和"点界面"这两类,读到什么程度决定模型能生成多可靠的计划。宁可慢一点,也要让模型在执行前先inspect_window,执行后再wait_for_text或再读一次树,形成闭环。不要迷信让模型一次规划完所有操作,GUI 世界里一次规划完等于送死。

4. AI 自己填表全流程:一个真实任务的运行轨迹

光说不练没意思,我放一个保护过客户信息的真实任务出来。目标软件叫 DeviceTool,是一个设备参数配置工具,界面长得很传统:顶部 Tab,左边树形菜单,右边一堆文本框和下拉框。任务是打开它,从params.csv读参数,填进界面的三个 Tab,点"校验",通过后导出report_202412.csv。

Agent 的实际执行轨迹大概是这样的:

  1. 任务进来以后,Agent 先调list_windows,看到了"DeviceTool - 设备参数配置"这个窗口。
  2. 然后调inspect_window("DeviceTool - 设备参数配置", depth=3),拿到控件树。树里能看到第一个 Tab 下三个输入框:edtSerial、edtModel、edtVersion,还有一个btnNext按钮。
  3. Agent 根据任务要求,调input_text往edtSerial填序列号,再往edtModel填型号,点btnNext。
  4. 点到第二个 Tab,继续读树,发现这一页都是下拉框,Agent 先读当前选中值,然后用click_on_text点开下拉框,再点目标选项。
  5. 第三页是校验配置,Agent 调click_on_text点"校验",然后立刻wait_for_text等校验结果。
  6. 第一次校验失败,Agent 截图后发现有个红色错误提示,原因是某个参数超出范围。它重新回到参数的来源文件,把预设表里对应项改成另一个候选值,再次提交,这次通过。
  7. 通过后 Agent 点"导出",最后用 shell 工具确认导出文件存在且行数正确。

最顺的一次,全程 4 分 37 秒,没有任何人工介入。我不能说它每次都能成,但稳定跑通的比例大概在四分之三以上。失败的情况大多出现在嵌套 Tab 页面里,模型只打开了第一个 Tab,就以为已经把后面的表单填完了。后面我在系统提示词里加了一条硬规则:"看到 Tab 页时必须先点击每个 Tab,再 inspect_window,才能认为表单项完整。"成功率明显上升。

真正有意思的是,由于这是一个编码代理,Agent 不只操作 GUI,它还能在中间调用 shell 工具去读 CSV、跑一个数据预处理脚本,甚至把校验逻辑临时写成 Python 脚本去跑一遍参数范围检查。也就是说,MCP 把 GUI 操作、代码执行、文件读写都统一成了工具调用,模型可以自由编排。这比单独做一个 GUI 自动化脚本要灵活得多。

5. 坐标漂移、上下文爆炸、打包误报:三个躲不掉的坑

这部分内容是我最想在文章里留下的,因为网上大部分教程不会写这些。

5.1 坐标漂移:为什么不能简单算像素

最初版本的screenshot工具会把整张截图传给模型,让模型算出目标坐标,然后直接pyautogui.click(x, y)。听起来很自然,实际用起来一塌糊涂。第一个问题是 DPI 缩放,Windows 系统如果开了 125% 缩放,PyAutoGUI 拿到的坐标和截图里的坐标经常对不上。第二个问题是多显示器,副屏是负坐标,模型算坐标的时候很容易脑子烧掉。

我的解法是:不直接让模型输出屏幕坐标,而是让工具输出控件在窗口内部的相对坐标,并且用 UIA 的 bounding box 来定位控件。click_on_text这个工具不是"在某个屏幕坐标点击",而是"在指定窗口里找到文本匹配的控件,用控件本身的坐标点击"。这样模型只需要理解窗口里有哪个控件,不需要跟像素坐标搏斗。

5.2 上下文爆炸:一棵控件树吃掉三万 token

GUI 工具最容易犯的错误是把整个控件树一股脑塞给模型。一个稍微复杂点的窗口,完整 UIA 树可能有几千个节点,格式化以后两三万 token 是常有的事。多操作几个窗口,上下文窗口直接爆炸。

所以inspect_window一定要做裁剪。我的默认策略是:深度只取到 3 层,隐藏控件全部过滤,每个控件只保留name、automation_id、control_type、is_enabled、bounds,其他属性一律不要。这样一棵树压到三千 token 以内。模型需要更细的树的时候,再额外要求某个节点展开。还有一个小技巧是让工具返回紧凑文本而不是 XML,比如用|分隔属性,模型读起来更省,解析也更快。

5.3 单文件打包:杀毒误报和启动变慢

PyInstaller 的--onefile在 Windows 上很省事,但有两个副作用。第一,进程启动时会把依赖解压到系统临时目录,慢的时候能明显感觉到延迟,如果程序里还偷偷做什么 GUI 初始化,用户会觉得卡。第二,杀毒软件对自解压型可执行文件很敏感,误报率不低,给我发私信的人里至少有三个第一反应是"这个工具是不是病毒"。

后来我做了两步处理。第一步,发布包同时提供 zip 版和单文件 exe 版,zip 版就是普通目录解压即用,启动速度快得多,单文件版留给不想折腾的人。第二步,给 exe 加上合法的代码签名,虽然免费证书不好搞,但哪怕只是通过signtool加一个测试证书,也能降低一部分误报概率。另外,代码里不要用tmpfile这种特征特别明显的临时目录,最好指定到程序同级的cache目录,并定期清理。这些都能减少杀毒软件的注意。

6. 安全边界和免费这件事,我想说清楚

最后聊点不太性感但必须聊的事。

一个能操控 GUI 的 AI Agent,本质上是把鼠标键盘交给了模型。模型一旦在某些操作用例里出现幻觉,比如它以为自己在点"保存",实际点到了"清除数据",那后果不是开玩笑的。所以我在项目里加了一层 Policy 机制:配置里仅允许操作特定窗口标题和特定进程,凡是匹配不上的窗口一律拒绝访问。另外内置了一个危险动作词表,任务描述或者工具参数里出现"删除、格式化、清空、卸载"这类词时,需要二次确认。第二道防线是建议所有人在虚拟机里跑不可信的任务,尤其当任务来源是 AI 用户自己时,永远不要把生产环境的桌面直接暴露给一个未经调教的模型。

再来说"免费"。这个项目本身完全免费开源,但一个残酷的事实是:大模型 API 不是免费的,尤其是要处理截图、读界面状态的任务,对视觉模型和工具调用稳定性要求都很高。如果完全不用云 API,本地跑一个小模型,简单的表单填写也能试,但那个体验基本属于"科研性质"。免费的是项目本身,不是算力。想低成本的话,可以用本地视觉模型跑截图理解,但建议至少 32B 以上,7B 级别的本地模型在复杂的多步 GUI 任务里基本都会迷路。

整个项目做下来,我最大的感受是:把一个 AI 编码代理接到 GUI 上,技术难度没有想象中高,真正难的是把界面状态变成模型能高效理解的信息,以及为每一步操作建立足够的验证机制。MCP 最大的好处不是协议本身多惊艳,而是很多原本封闭的设备、软件、浏览器能力,终于可以用同一套工具接口暴露给 Agent 用了。拿这个思路,下一步我打算把打印机设备状态和工单系统也接进来,让 Agent 自己诊断、自己报修,那又是另一个坑了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询