大概半年前,我被一个极其低级的任务憋到怀疑人生:让 AI 编码代理帮我改完配置之后,顺手去桌面端的管理工具里点几个按钮。结果我发现,市面上大多数 agent 写代码时猛如虎,一旦面对屏幕上的图形界面就彻底抓瞎。终端命令它执行得行云流水,可 GUI 按钮在它眼里就是一张看不懂的图片。云端的 Computer Use 方案倒是能"看屏幕",但按次计费加高延迟,还意味着把整个工作现场的截图传到别人服务器上——想想就头皮发麻。
所以我一咬牙,自己做了个免费开源的单文件 AI 编码代理:它既能通过截图和模拟输入操控 GUI,也能通过 MCP 协议接入各种外部工具集,而且整个工具就一个可执行文件,拷到任何 Windows 机器上直接跑,不用装 Python,不用配环境。文章没有任何标题党的成分,这半年里我踩过的坑、用过的方案、最后沉淀下来的架构,全在这篇博文里。如果你是折腾 Agent、想做本地 GUI 自动化或正在研究 MCP 接入的人,这篇内容应该能省你不少时间。
1. 为什么还要自己造轮子:现成方案的三个硬伤
1.1 终端 Agent 的"手"根本够不到 GUI
先说说我最初遇到的场景。当时我在调试一套嵌入式设备的配套上位机程序,每次改完固件参数,都得打开桌面管理软件,按固定顺序点开三个选项卡、填两组数值、再点保存。这种操作重复性极高,我自然想交给 AI agent 去干。
结果一试就懵了。主流的编码代理工具都是围绕"代码、终端、文件系统"设计的,工具的边界基本锁死在 shell、HTTP、读写文件这几类。它能帮你写 Python 脚本调用接口,但没办法替你按下屏幕上那个"确定"按钮。道理很简单:GUI 应用根本没有暴露编程接口,按钮、输入框、下拉菜单只存在于渲染出来的像素里,终端 agent 看不见也摸不着。
有人会说,那我可以让 agent 写一个 UI 自动化脚本来跑啊。话是没错,但前提是你得先搞清楚这个 GUI 程序的控件结构、坐标系、有没有无障碍接口,这本身就是一堆活儿。更关键的是,脚本写完了只能跑这一条固定流程,界面稍微改个版式就全废了。我要的是一个能"看着屏幕自己判断、自己动手"的代理,而不是一套被写死的自动化脚本。
1.2 商业 Computer Use 服务的隐形成本
既然自己搞麻烦,市面上有没有现成的"看屏幕操作电脑"的服务?有。OpenAI 的 Operator、Anthropic 的 Computer Use,都是这条路子:给模型截屏,让模型输出下一步该点哪,再模拟鼠标键盘。原理我完全认同,可真拿去用的时候,几个问题把我劝退了。
首先是成本。每一步操作都要上传一张高分辨率截图,模型要基于截图推理再返回结果,一轮操作下来 token 消耗相当可观。做一个十步以内的 GUI 流程,单次花费可能比买一个月会员还贵。其次是延迟。云端抽一次卡就要两到三秒,一步点一下,整个流程跑下来,比我手动操作还慢。再有就是隐私。我是真的不想把公司内部工具的截图、客户数据界面这些画面传到第三方推理服务里去,哪怕协议里写着不保留数据,心理那关也过不去。
还有一个在国内网络环境里特别实在的问题:这些云端服务对网络的依赖极重,网络稍微不稳,整个 session 就断了,前功尽弃。我需要的是一个完全本地运行、只把"需要理解的内容"交给大模型、最好还能自由切换各家模型的方案——这才是我决定自己写这个工具的导火索。
1.3 MCP 生态很好,但被各家产品焊死了
再聊 MCP。MCP(Model Context Protocol,模型上下文协议)这两年发展很快,它本质上是给 AI 应用定了一个统一接外部工具的标准:不管底下是浏览器、数据库、IDE 还是调试器,只要实现了 MCP 服务器,客户端就能通过同一套 JSON-RPC 协议调用它的工具列表。我翻了一圈社区,好家伙,什么 IDA MCP、x32dbg 的 MCP 插件、Cheat Engine 桥接 MCP、Figma MCP、浏览器 MCP 全都有人做,光是把这些工具的能力串起来就足够香了。
但问题是,这些 MCP 服务器各自的作品散落在不同的生态里。有些只能在特定商业 IDE 的插件环境里跑,有些要装全家桶才能用,还有的只支持某个云端编辑器。我想找的是一个轻量、本地、能随身带走的客户端:双击 exe 起来之后把配置文件指过去,家里的 MCP、办公室的 MCP、临时要用的 MCP 说接就接。市面上没找到完全符合的,那就自己写一个呗。
2. 整体架构设计:一个循环,加两套"眼睛和手"
2.1 Agent 核心循环是怎么转起来的
我最终实现的 DeskBot,核心是一个标准的 Agent 循环,只不过观察和行动的维度比纯编码代理多了一层。每次迭代做四件事:采集观察结果、交给大模型推理、执行模型返回的动作、把动作结果喂回去。观察结果包括三样东西:当前屏幕截图、当前可用的 MCP 工具列表、以及一些简单的上下文信息(比如工作目录、最近一次操作是否成功)。模型根据这些信息决定下一步调用什么工具,工具执行完的结果再回到对话历史里,循环往复直到任务完成。
用伪代码描述就是:
while not task_finished(): screenshot = capture_screen() mcp_tools = mcp_client.list_tools() context = build_context(screenshot, mcp_tools, history) action = llm.decide(context) # 返回结构化动作 result = executor.run(action) # 执行点击/输入/MCP调用/文件操作 history.append(action, result)这件事本身不复杂,真正决定成败的是下面两个子系统:GUI 操控模块和 MCP 客户端模块。GUI 模块管的是"看见并操作屏幕",MCP 模块管的是"调用外部能力"。两个模块都实现为 Agent 的工具函数,模型通过统一的工具调用接口来使用它们,彼此互不干扰。
2.2 为什么外壳用 Go,策略层却挂了 Python
做单文件工具之前,我第一反应是用 Python 写全套:pyautogui 做鼠标键盘、opencv 做模板匹配、mcp 官方 SDK 一把梭。但 Python 有个致命问题——没法交付单文件,除非用 PyInstaller 打成一个大包,而 PyInstaller 打出来的"单文件"每次启动都要解压到临时目录,杀软报毒率极高,第一次启动也慢得离谱。
后来我把壳换成了 Go。Go 编译出来是真单文件,一个静态链接的 exe,直接就能跑,不需要解释器,杀软误报也少很多。但 GUI 操控这块,Windows 生态里最成熟的库全在 Python 那边:pywin32 封装了完整的 Win32 API,opencv 的模板匹配用起来顺手,还有一堆现成的 OCR 工具。让我全部用 Go 重写一遍,工作量翻三倍都不止。
所以我选了个折中方案:Go 负责主流程、窗口管理、SendInput 鼠标键盘模拟、MCP 的 stdio 通信和与 LLM 的 HTTP 调用;Python 则作为一个嵌入式运行时被 Go 打包进文件系统镜像里,专门负责截图处理和图像识别这类"需要花哨库"的脏活。工程上用类似 go-py 的思路把精简后的 Python 解释器嵌入 Go 二进制,启动时释放到临时目录并在子进程中调用。后面讲打包的时候会细说体积的取舍。
2.3 模块划分与数据流
整个工具按职责拆成六个模块,它们的协作关系很清楚:
| 模块 | 职责 | 技术选型 |
|---|---|---|
| capture | 截取屏幕或指定窗口位图 | Go 调用 GDI / Desktop Duplication |
| locate | 根据文字或图像从截图中定位坐标 | 视觉模型 / OpenCV 模板匹配 / UI Automation |
| input | 模拟鼠标点击、键盘输入 | Win32 SendInput |
| mcp | MCP 客户端,加载服务器配置并调用工具 | Go 实现 JSON-RPC over stdio |
| llm | 与大模型对话,解析工具调用 | 各类 OpenAI 兼容接口 / 本地模型 |
| audit | 记录每一步截图、动作与结果 | 本地 JSONL 日志 |
数据流是一条直线:capture 拿到像素,locate 从像素里提炼出"这里有按钮 A、那里有输入框 B"的结构化描述,llm 综合这些描述做决策,input 或 mcp 负责执行,结果写回 audit 日志并反馈给 llm。这个设计的好处是任何一个环节都可以替换。比如 locate 一开始用视觉模型返回坐标框,后来为了省钱切到模板匹配,再后来改用 UI Automation 拿控件树,上层 Agent 循环一行不用改。
3. GUI 操控落地:一张截图是如何变成一次点击的
3.1 截屏:先解决多显示器和高 DPI 两个拦路虎
如果你只在单显示器、100% 缩放的机器上开发,永远遇不到截屏的坑。但我日常办公是双屏加 125% 缩放的 Windows,第一个版本做出来,点击位置能偏出按钮两三个身位。
问题出在 DPI 虚拟化上。进程没有声明自己感知 DPI 时,Windows 会默认对进程做缩放虚拟化:你的程序以为屏幕宽 1536,实际物理像素是 1920。截图的时候如果用虚拟化坐标,截出来的图尺寸和屏幕真实像素对不上,后续按比例算坐标自然全错。解决办法是在进程一开始就调用:
SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2);这样拿到的 GetSystemMetrics 数值才是真实物理像素。多显示器则要枚举所有显示器并分别截图,不能只截主屏。我用的是 Go 调用 EnumDisplayMonitors 拿每块屏幕的 bounds,然后逐屏捕获再按坐标拼接成一张完整的大位图。Deliverable 是一张标了各显示器坐标偏移的整屏图,后续模型拿到的是"某个位置对应哪个屏幕"的明确信息。
3.2 从像素到坐标:三种识别方案的取舍
截完图,接下来是最关键的环节:怎么让模型知道"确定按钮在哪"。我试过三条路线,各有适用场景:
第一种是纯视觉模型定位。把截图喂给支持视觉输入的大模型,让它直接返回目标元素的边界框坐标。GPT-4V、Qwen-VL 这些都能做到,准确率在按钮位置明显、界面整洁时很高。这是最省事的方案,缺点是大尺寸截图的视觉 token 消耗不小,多轮下来成本感人。
第二种是本地模板匹配。预先截一张目标按钮的模板图,用 OpenCV 的 matchTemplate 在整个屏幕快照里搜相似区域,返回最佳匹配坐标。好处是完全免费、速度极快、离线可用;坏处是对界面变化很敏感,按钮换个图标、换个底色就搜不到了。实际使用中我会把模板匹配作为视觉模型的兜底。当模型说"没找到按钮"时,再尝试从历史截图里抠出模板做一次本地匹配。
第三种是 Windows UI Automation。通过 COM 接口读取界面控件树,能拿到按钮名字、控件类型、坐标矩形这些结构化数据。对标准 Win32 控件、WPF、Electron 的部分组件都有效,定位极准。但很多老旧软件或自绘界面根本不暴露控件树,这套就抓瞎了。
我在 agent 的工具接口里把三种方式全暴露给了模型,让模型自己根据情况挑选"观察方式"。工具列表大概是这样的:
{ "name": "look_at_screen", "description": "截取屏幕并返回视觉模型对元素的定位", "parameters": { "query": "要找的元素描述,例如'右上角的保存按钮'" } }, { "name": "find_template", "description": "用本地模板匹配寻找图标/按钮位置", "parameters": { "template_path": "模板图片路径" } }, { "name": "ui_tree", "description": "获取当前活动窗口的 UI Automation 控件树", "parameters": {} }3.3 点击与输入:为什么我坚持用 SendInput
定位到坐标之后,模拟点击这块我一开始用的 pyautogui,后来全部换成了底层 SendInput。pyautogui 内部走的是 mouse_event/keybd_event 那一套,在某些会做输入校验的程序(比如银行控件、游戏反作弊)里会被识别成"非真实输入"而直接丢弃。SendInput 是 Win32 里更底层的输入注入接口,Windows 自身把它当作硬件输入队列的数据来对待,绝大多数应用都识别为真实操作。
Go 里调用 SendInput 要自己拼结构体,代码略繁琐但可控性好:
func clickAt(x, y int32) { var mi MouseInput mi.Dx = x * 65535 / screenWidth mi.Dy = y * 65535 / screenHeight mi.DwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE sendInput(mi) mi.DwFlags = MOUSEEVENTF_LEFTDOWN sendInput(mi) mi.DwFlags = MOUSEEVENTF_LEFTUP sendInput(mi) }注意我把绝对坐标换算成了 0 到 65535 的归一化坐标,这是 SendInput 的接口约定,不换算的话多屏场景下会点错屏幕。键盘输入同理,中文输入最好走剪贴板粘贴而不是逐字符模拟按键,否则输入法状态一乱就全变拼音了。
3.4 高成本动作的护栏设计
GUI 自动化最怕的是模型"以为点对了,其实点错"之后继续操作,把配置改得乱七八糟。我这个项目定位是给开发者用的效率工具,不是无人值守系统,所以默认开启确认机制:凡是涉及鼠标点击、键盘输入或者调用写类型的 MCP 工具时,agent 先把动作计划打到终端上,等我在命令行按一下回车才真正执行。还有一个--dry-run参数,加上之后干脆只输出动作序列不执行,纯看它脑袋里想的步骤对不对。这个设计帮我躲过了无数次灾难,等模型跑顺了再关确认也不迟。
4. MCP 接入:配置格式、动态加载与调通记录
4.1 MCP 服务器配置与握手细节
MCP 对我来说是熟面孔,但真正动手写客户端才发现细节不少。它的通信基于 JSON-RPC 2.0,传输层有 stdio 和 SSE 两种。本地工具首选 stdio:客户端启动服务器进程,通过标准输入输出交换消息。一个典型的配置长这样:
{ "mcpServers": { "browser": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-puppeteer"], "env": {} }, "local_db": { "command": "node", "args": ["C:/mcp-servers/db-server/index.js"] } } }客户端连接服务器后要按顺序做三次握手:initialize 交换协议版本,notifications/initialized 表示初始化完成,tools/list 拉取工具清单。这一步不能省,很多服务器如果你直接调工具,它会直接拒绝。我调试的时候经常犯的错是 initialize 请求里 ProtocolVersion 写的太新,对面是旧实现的服务器会直接报错。后来我做了个兼容处理:先发最新版本,收到版本不匹配错误就回退到旧版本重试。
4.2 在 Agent 循环里把 MCP 工具与 GUI 工具编排起来
MCP 工具接进来之后,Agent 的可用工具集就从一个 GUI 工具包扩充到"任何实现了 MCP 的东西"。我需要让模型理解这些工具是动态出现的,所以每次循环都会把 tools/list 拉回来的工具定义注入系统提示词里。模型看到的 MCP 工具跟内置工具没有本质区别,只是命名空间里带了 server 前缀,比如mcp__browser__navigate表示来自 browser 服务器的 navigate 工具。
一个我实际跑通的协作例子:任务是把网页后台导出的数据表填进本地一个老旧的客户管理程序。流程是先用浏览器 MCP 的navigate和evaluate工具从网页里取出表格数据,写到临时 CSV 文件;然后切到桌面端程序,用 GUI 模块定位"导入"按钮,点击后弹出文件选择框——这里又是两层 GUI,文件对话框本身也是一个窗口,需要用 UI Automation 找到文件名输入框并填入路径,最后点打开。整个流程里 MCP 工具负责取数,GUI 工具负责操作,Agent 循环只是把两者串起来。
4.3 实测哪些 MCP 服务器值得接
这半年我陆陆续续接了不少社区 MCP 服务器,测下来值得长期留在配置里的有几类:
| 服务器 | 用处 | 稳定度 |
|---|---|---|
| Figma MCP | 拿设计稿的图层坐标,直接喂给 GUI 自动化做像素级对齐 | 高 |
| 浏览器 MCP(Puppeteer/Playwright) | 网页操作比 GUI 模拟可靠十倍,能救回大量鸡肋场景 | 高 |
| 数据库 MCP | 让 agent 直接查库验证操作结果,不用靠肉眼对界面 | 高 |
| IDA MCP / x32dbg MCP | 逆向场景下把反汇编窗口操作交给 agent | 中,时灵时不灵 |
| Cheat Engine 桥接 MCP | 内存搜索类工作流,社区有人做了,能用但需要配套权限策略 | 中 |
拿 Figma MCP 举例,有一次我需要让 agent 照着设计稿把桌面端工具里的一个页面布局调成一致。之前纯靠视觉模型看截图猜位置,误差肉眼可见。接了 Figma MCP 之后,让 agent 直接从设计稿里读按钮的绝对坐标和间距,再换算成目标窗口的坐标,一次到位。这就是 MCP 的价值:它绕过了"猜",把结构化数据直接喂给模型。
5. 单文件运行:打包思路、体积权衡与密钥处理
5.1 单文件不等于"zip 解压跑",而是运行时融合
很多人的第一反应是,单文件不就把源码、配置、依赖打成一个压缩包,双击自解压运行呗。那是假单文件,杀软报警、启动慢、还可能释放出可疑文件。我这里说的单文件是真正的单体可执行文件:Go 编译好的二进制里通过go:embed把所有需要的东西嵌进去,启动时全部加载进内存或临时目录,不往系统目录写东西。
具体嵌入的东西有这几样:精简过的 Python 运行时(大约 25MB)、OpenCV 的 Python 绑定和相关 DLL(约 30MB)、MCP 客户端依赖(因为我用 Go 自己实现了 JSON-RPC,所以这块为零外部依赖)、内置配置文件模板。所有资源加起来压进二进制之后,最终体积在 85MB 上下,比 PyInstaller 动不动 200MB 还误报的效果好太多。
打开发布页的人拿到的是一个deskbot.exe,双击就起,不需要系统预装任何运行时。我在机器上实测过三台干净的虚拟机,Windows 10 21H2、Windows 11 23H2 都能直接跑。启动速度约 0.6 秒,内存占用约 120MB,对于一个要加载 Python 和 OpenCV 的工具来说,这个数字完全可以接受。
5.2 体积为什么要抠着用
单文件方案最纠结的就是体积。最开始我贪心,把 Tesseract OCR 也嵌进去了,体积直接飙到 180MB,启动时间也翻倍。后来我想明白一件事:OCR 的需求完全可以用"视觉模型代替"来解决,而且准确率还更高。所以我把 Tesseract 从内置资源里删掉,需要 OCR 时由 Agent 调用视觉模型完成,省下的 90MB 换来了更快的启动速度。这个取舍提醒我:单文件的大小不只是下载体验问题,它直接影响运行时内存和杀软检测概率,能精简则精简。
5.3 密钥不在单文件里,配置走外部
既然是单文件,一个天然的问题是:API Key 放哪?我的答案是不放。可执行文件里只内置一个默认配置路径的查找逻辑:程序启动时按优先级查找./deskbot.yaml、$HOME/.deskbot/config.yaml、环境变量DESKBOT_CONFIG。配置文件里保存 LLM 的 base URL、API Key、模型名、MCP 服务器列表、是否开启人工确认等。这样做有几个好处:换机器只要带一个 yaml 就能复用配置;升级程序时密钥不会跟着 exe 被重新分发;也方便别人 fork 之后换成自己的模型服务。模型侧我做了 OpenAI 兼容接口适配,意味着 DeepSeek、通义、Moonshot 这类国产服务商和本地 Ollama 都能切,谁便宜用谁。
为了方便排查,每次运行的完整记录会严格按照 JSONL 格式写到$HOME/.deskbot/logs/下,每条记录包括当时的截图(存成缩略图)、模型输入输出、执行的动作和返回结果。排查 GUI 偏位问题的时候,这个日志几乎是救命稻草。我还做了哈希校验文件随 release 发布,防止下载到被篡改的二进制。
6. 踩坑记录:文档里不会写,但实战必遇的四个问题
6.1 高 DPI 下点击偏位:问题出在进程声明
第一个正式版本发布给朋友试用之后,收到的第一个 bug 反馈是"点啥都偏一点,但截图看着是准的"。我一开始怀疑是 SendInput 归一化坐标的问题,排查了半天,最后发现根子是进程没有声明 DPI aware。Windows 给未感知 DPI 的进程做了位图虚拟化,我截屏拿到的是物理像素,但系统层认为我的程序还活在虚拟像素里,坐标换算全乱了。解决方式前面已经提到:进程一启动就调SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2),并且所有坐标严格使用物理像素。这个问题必须最先处理,否则在缩放不为 100% 的机器上根本没法用。
6.2 窗口遮挡:你截到的不一定是目标窗口
截屏模块写的是截全屏,这在目标窗口置顶、战士保底的情况下没问题,可实际用起来经常翻车:我想操作的窗口被另一个聊天窗口挡住,截图能看到,SendInput 也点过去了,但点在头顶的窗口上。第一次跑流程时,agent 愣是把旁边聊天窗口里的联系人挨个点了一遍,还好开着人工确认,不然误触真的要命。
后来我加了"激活目标窗口"的前置工具。执行点击前先用EnumWindows找到目标进程的顶层窗口,调SetForegroundWindow把它拉到最前面,再用BringWindowToTop确保它不被其他窗口盖住。对特定画布区域,还实现了基于 Desktop Duplication API 的指定窗口截图——只截目标窗口的内容,哪怕它被遮挡,这套 API 也能拿到它背后的帧内容。实测下来,加了激活步骤之后,误点率直线下降。
6.3 MCP 服务器启动慢,连接超时防不胜防
用 npx 启动 MCP 服务器时,第一次会现场拉 npm 包,慢的时候十几秒都起不来。我的客户端最初只给了 10 秒超时,导致浏览器 MCP 经常"秒失败"。后来我把连接 MCP 服务器的超时调成 60 秒,tools/list 的超时单独设 10 秒,并且会在连接阶段打印明确的进度提示。更彻底的办法是建议用户把常用的 MCP 服务器用npm i -g全局安装,配置里直接写node加全局路径,跳过 npx 的下载环节。这套组合下来,MCP 连不上的情况就很少了。
6.4 LLM 的"幻觉点击"必须靠护栏拦一下
最后聊聊模型本身。大模型在 GUI 操作时有一种很典型的幻觉:明明界面上没有"确认"按钮,它会脑补一个位置出来,然后信誓旦旦地告诉我点完了。这种问题靠提示词压不住,只能靠工程护栏。除了前面说的人工确认和 dry-run,我还实现了"操作前后界面差异检测":每次点击前截一张图,点击后再截一张,如果两张图几乎一模一样(像素差异低于阈值),就把"界面没有变化,可能点击未生效"作为反馈塞给模型,让它重新判断。这个方法特别有效,因为 GUI 操作无论成没成,界面总会发生变化(按钮高亮、弹窗出现、内容刷新)。加了这套校验之后,幻觉点击被当场纠正的概率高了很多。
7. 一点使用体验与后续方向
工具做到现在这个状态,已经成了我日常工作中的固定配角。最常用的场景是处理各种"只给按钮不给接口"的老系统:周报填写、审批流程、数据导入导出。以前这些活儿要么手动点,要么写一次性脚本,现在直接把需求描述给 DeskBot,它自己看着界面一步步操作,我在旁边看着日志就行。有时候它也犯轴,同一个步骤反复尝试好多次,但最后基本都能自己找补回来。
后面我计划加两个能力。一个是把操作历史渲染成可回放的录屏视频,方便把一次失败的操作链路导出给其他人分析;另一个是把界面变化更精细地建模成状态签名,这样 agent 可以明确识别"点击后弹出了什么类型的窗口",而不是笼统地说"界面变了"。如果这篇文章的读者也想试自己搞一个,我的建议是别上来就追求完整架构,先接一个浏览器 MCP 服务器,再把最简单的截图+点击跑通,后面这些高级玩法都是在这个基础上慢慢长出来的。项目本身是免费开源的,Release 页面里有现成的 exe 可下载,配置好 API Key 就能直接用,算是给同样被 GUI 自动化折磨的人一个少走弯路的起点。