
1. 这个项目到底解决什么问题先说说我为什么会做这个东西。用过 Cursor、Copilot 这类编码工具的都知道AI 补全代码已经不算新鲜事了真正卡脖子的是“AI 只能改代码不能替你操作电脑”。你在 IDE 里让它改个文件没问题可一旦涉及“打开某个 GUI 程序、点几个按钮、截个图看看界面长什么样、把结果同步给另一个工具”传统 Agent 基本就哑火了。我当时手头正好有这样一个需求帮一个不太熟悉命令行的人做一个小工具需要自动打开桌面软件、读取界面内容、再按规则点击操作。这活儿交给大模型本身不难难的是怎么让大模型“看见”和“操作”图形界面。这个项目就是我自己写的一个免费 AI 编码代理AI Coding Agent核心卖点有三个支持操控 GUI、支持 MCP、单文件运行。意思是它不需要复杂的安装流程一个文件拉下来就能跑它能把大模型的判断能力接到图形界面的点击、输入、读取上它还能通过 MCPModel Context Protocol模型上下文协议跟外部工具、数据源对接把 AI 的能力延伸到代码编辑之外的场景。如果你正在研究 AI Agent、想给自己的插件或工具加“电脑操作”能力或者单纯被各种 Agent 框架绕得头疼、想要一个能看懂源码的轻量方案这个东西应该能给你一些启发。我会把整个项目的设计思路、技术选型、踩坑过程全部拆开讲争取你看完能照着思路自己搭一个差不多的。2. 整体设计思路与方案选型2.1 为什么是“单文件”而不是一个完整项目现在市面上 Agent 框架多如牛毛比如 AutoGPT、MetaGPT、微调的 SWE-Agent随便拉一个出来都是成千上万行代码依赖一堆库环境配置就能劝退一半人。我做这个事儿的出发点很简单我想让用户下载一个脚本运行之后立刻能用不要装 Python 环境、不要配 API Key、不要读几十页文档。所以“单文件”对我来说不是炫技而是核心使用场景倒逼出来的设计约束。我做的第一版其实是个完整项目结构是 src/、config/、tests/ 那种标准布局跑起来要先 pip install、再设置环境变量、再初始化配置结果我自己在另一台干净机器上试用时都费了半天劲更别说普通用户了。后来我做了个很激进的决定把全部代码塞进一个文件里。这个决定带来的直接好处是分发成本趋近于零。用户只需要做一件事python agent.py。而代价也很明显——代码组织和可维护性变差了但只要控制好文件内部的分区结构比如用大段注释把“GUI 控制模块”“MCP 对接模块”“LLM 交互模块”隔开这问题完全可控。经验单文件项目最适合工具型、演示型、教学型的 Agent不适合需要长期多人维护的大型系统。如果你的 Agent 定位是后者别学我。2.2 GUI 操控我是怎么让 AI “看见”屏幕的让 AI 操控 GUI 最核心的难点是让大模型理解图形界面当前的状态然后把它的决策翻译成鼠标键盘动作。这需要两件事截图给模型看、动作交给系统执行。我选择的技术栈是截图用 mss 这个库跨平台、速度快实测截一屏大约 30ms比 PIL 的 ImageGrab 快不少。速度很关键因为 GUI 操作是一个“截图-思考-动作”的循环截图太慢会拖慢整个节奏。界面元素定位一开始我想用纯视觉方案就是直接让多模态模型看截图判断该点哪里后来发现可靠性不够。比如一个小按钮在截图里只有十几个像素模型经常点歪。于是我加了一层OpenCV 模板匹配和屏幕坐标归一化。简单说给模型两种信息整个屏幕的截图、以及一组可交互元素的坐标标签。模型只需要说“我要点击按钮A”我这边把“按钮A”映射到具体坐标。动作执行Windows 上用 pyautoguimacOS 上用 Quartz 事件Linux 上用 xdotool这个没有跨平台的完美方案所以我做了一层抽象接口按操作系统分发。这里有一个非常重要的经验千万别把“让 AI 完全自由地控制鼠标”当成第一版目标。我最初试过完全开放的方式让模型爱点哪点哪结果它能把设置界面点得乱七八糟甚至差点把系统音量拉满。后来我改成了白名单步骤确认的机制模型只能在用户给定的应用窗口内操作每个动作执行前会把意图输出到控制台用户按回车确认才执行。这样既保留了自动化能力又不会出大乱子。2.3 MCP为什么值得专门接一层协议MCP 是最近很热的协议标准简单理解就是给 AI 模型一个统一的方式去调用外部工具。以前每个 Agent 接一个工具就要写一套专用代码有了 MCP 之后那个工具只要提供一个 MCP ServerAgent 就能动态发现它能干什么、按规范调用它。我支持 MCP 的动机很直接我不想只为“操控 GUI”这一个功能写死 API。GUI 操作只是这个 Agent 的能力之一用户可能还需要让它查数据库、读文件、调浏览器、发请求。这些能力如果都靠我硬编码那工作量没上限而且每加一个功能就要重新发一版。MCP 把这个问题优雅地解决了。实现的方案是把 MCP Server 的调用封装成统一的工具接口Agent 在启动时加载一个配置文件里面可以声明要连接哪些 MCP Server。每个 Server 暴露的方法被自动注册为 Agent 的“可调工具”。比如有一个 MCP Server 可以操作浏览器那 Agent 的消息循环里就多了一个“open_url”“click_element”这样的工具。MCP 的语义特别像“USB-C 接口”——各种设备不管内部怎么实现只要按统一规范接上就能用。这大大提高了我的 Agent 的扩展性也让它可以混用不同生态下的现成工具而不是重复造轮子。2.4 技术栈和核心依赖除了上面提到的截图和 GUI 工具库整个项目骨架是 Python。选 Python 不光是生态成熟还有一个重要原因这种 Agent 的核心逻辑是“循环调用 LLM-执行工具-观察结果-再调 LLM”Python 写这种胶水代码最顺手。核心依赖清单openai或兼容 OpenAI 接口的 SDK用来请求大模型接口支持本地模型如 Ollama、vLLM 等mss截图pyautogui/Quartz/xdotool控制鼠标键盘Pillow处理图像opencv-python模板匹配和元素定位jsonsubprocessosplatformPython 自带库处理配置和系统交互整个文件大约 1200 行内部结构分四段配置区、GUI操作类、MCP客户端封装、Agent主循环。3. 核心细节解析与实操要点3.1 单文件里的模块划分我是怎么不把自己绕晕的如果你以为单文件就是把所有代码从头写到尾那就错了很快就会陷入改一处坏三处的泥潭。我的做法是在文件里用非常清晰的分区注释把它当成一个“伪多文件”项目# # [Section 1] 全局配置解析 (仅供参考保留原始代码的学生可照着分块) # # [Section 2] GUI 自动化操作类 # # [Section 3] MCP Client 封装 # # [Section 4] Agent 主逻辑 # 每个 Section 内部保持内聚Section 之间只通过少数几个接口函数交互。这里的关键点是接口要尽量窄。比如 GUI 操作类对外只暴露三个方法capture_screen(),get_element_coordinates(description),perform_action(action)。MCP 模块对外只暴露load_server(config)和call_tool(server_name, tool_name, args)。这样即使整个文件有上千行实际牵一发而动全身的链路非常短。3.2 大模型的工具调用循环怎么做Agent 主循环是整个文件的核心逻辑上跟 OpenAI Function Calling 的标准模式一致把系统提示词、历史消息、当前工具定义一起发给大模型大模型返回两种结果之一一段最终回答或者一个工具调用请求如果是工具调用我从本地函数映射表里找到对应实现执行把结果作为新消息追加回到第 1 步继续直到模型给出最终回答理解这个循环是理解一切 Agent 的钥匙。很多初学者第一次写 Agent 容易犯一个错只发一次请求拿到结果就用完全没想过“工具调用后还需要把观察结果送回模型”这一步。没有第二步的“行动-观察”循环模型就永远无法基于工具执行结果做进一步推理Agent 跟一个普通的 API 调用就没区别了。举个例子。我让 Agent 帮我查一下当前目录有哪些 Python 文件然后对其中一个做语法检查。正确流程是模型先调用工具list_files拿到目录列表我把它作为工具结果传回给模型模型看到test.py存在再调用run_compile_check拿到结果后模型才能给出最终判断。如果只发一次请求模型会直接猜测文件名然后给出一个假结果这在 GUI 场景下就是灾难——它可能觉得自己已经点了按钮实际上什么都没发生。3.3 GUI 元素定位从“看图猜位置”到“边界框标注”需要操控 GUI 时我的 Agent 并不是直接把原生截图丢给大模型而是先做一层预处理。具体流程是用 mss 截取当前屏幕对窗口图片做一次颜色分析和轮廓检测并把检测到的可能是按钮、输入框、图标的区域用红色矩形框标注把每个标注框按顺序编号同时输出一份 JSON 描述“编号 1位置 (120, 300)大小 (80x30)颜色偏灰推测是按钮”把标注后的截图和 JSON 一起发给模型这个方案比纯视觉方案好在哪大模型不需要自己数像素坐标了它只需要说“点编号 3”或者“在编号 5 的输入框里填写 xxx”。把坐标空间折叠成语义标签是提升 GUI Agent 可靠性最有效的一招。实际使用中点击准确率从裸视觉方案的 60% 左右提升到了 90% 以上。但这里也有一个必须先解决的坑分辨率和缩放问题。Windows 上如果开启了 125% 或 150% 显示缩放pyautogui 拿到的坐标和截图里的像素坐标对不上点击会偏移。我的做法是在程序启动时调用ctypes.windll.shcore.GetScaleFactorForDevice(0)拿到缩放比然后把所有坐标统一换算成物理像素。缩放比截图逻辑坐标pyautogui 物理坐标最终换算100%1920x10801920x1080不处理125%1536x8641920x1080乘以 1.25150%1280x7201920x1080乘以 1.5这玩意儿看着简单踩过一次才知道多疼——辛辛苦苦把点击逻辑调通了换台电脑又全歪排查半天发现是缩放设置换了。3.4 MCP 对接的配置约定MCP 部分我设计得很轻。配置文件是一个 JSON里面列出要连接的 Server 名称、启动命令和相关参数{ mcp_servers: [ { name: browser_tool, command: npx, args: [-y, some/mcp-browser-server], env: {API_KEY: 12345} } ] }启动时依次拉起这些进程通过标准输入输出 JSON-RPC 消息完成通信。这个方案需要的代码量不大关键是消息分包——MCP 走的是 stdio多条 JSON-RPC 消息可能会粘包必须根据 Content-Length 头来分包。def read_message(stream): headers {} while True: line stream.readline() if line in (b\r\n, b\n, b): break key, _, value line.decode().partition(:) headers[key.strip().lower()] value.strip() length int(headers.get(content-length, 0)) body stream.read(length) if length else b return json.loads(body)这个细节很多人会忽略但它是 MCP 客户端最容易出错的地方。如果你觉得这一段看得有点累可以先跳过用现成的mcpPython SDK但在单文件实现里我宁愿自己写省得引入一个大依赖。4. 实操过程与核心环节实现4.1 从头搭建一个最小可运行的 Agent 骨架我不想空口讲原理直接给一套最小实现。下面这个骨架大约 150 行跑通后你就拥有一个支持工具调用的 Agent 雏形然后再往上加 GUI 和 MCP 能力。import json import sys from openai import OpenAI client OpenAI(api_keyYOUR_KEY, base_urlYOUR_BASE_URL) tools [ { type: function, function: { name: run_shell, description: Run a shell command and return output, parameters: { type: object, properties: { cmd: {type: string, description: command to run} }, required: [cmd] } } } ] def run_shell(cmd): import subprocess result subprocess.run(cmd, shellTrue, capture_outputTrue, textTrue, timeout10) return result.stdout result.stderr def agent_loop(user_query): messages [{role: user, content: user_query}] while True: response client.chat.completions.create( modelgpt-4o, messagesmessages, toolstools, tool_choiceauto, ) msg response.choices[0].message if not msg.tool_calls: print(最终回答:, msg.content) break messages.append(msg) for call in msg.tool_calls: if call.function.name run_shell: args json.loads(call.function.arguments) result run_shell(args.get(cmd, )) messages.append({ role: tool, tool_call_id: call.id, content: result }) if __name__ __main__: agent_loop(sys.argv[1] if len(sys.argv) 1 else 请列出当前目录的文件)这段代码跑通以后你就具备了一个最基础的 Agent 循环。很多人在这个阶段会犯一个致命错误把工具执行结果直接 print 出来给用户看然后重新把结果拼到用户输入里再发一次请求。千万不要这样。正确做法是原封不动的工具调用 ID、角色“tool”这样模型才能正确对应“哪个工具、返回了什么结果”。4.2 接入 GUI 截图与点击能力下一步是把 GUI 能力加进这个循环。我要做的不是改变循环结构而是新增两个工具screenshot_now和click_on_screen。先做截图工具。关键点是初始化时只创建一次 mss 实例不要每次截图都重新初始化否则性能很差。import mss sct mss.mss() def screenshot_now(): monitor sct.monitors[1] img sct.grab(monitor) from PIL import Image img Image.frombytes(RGB, img.size, img.rgb) img.save(screen.png) return 已保存截图到 screen.png下次对话将基于此图分析截图保存好后为了让模型能“看见”图我把图片以 base64 形式作为一条 image 消息送入对话同时附上工具提示说 “请描述图中你看到的界面内容并告诉我如果要点击某个按钮它在哪个编号区域。”真正的好体验来自结合前面的边界框标注。这里我给一个稍微完整一点的实现思路实际上就是四步截屏保存为screen.png用 OpenCV 找轮廓画出所有可能区域的边界框保存为screen_annotated.png读取标注框的坐标和编号生成可发给模型的文本描述把screen_annotated.png和文本描述一起发给多模态模型import cv2 def annotate_screen(path): img cv2.imread(path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) edges cv2.Canny(gray, 50, 150) contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes [] for i, cnt in enumerate(contours[:20]): x, y, w, h cv2.boundingRect(cnt) # 过滤掉太小的噪声区域 if w 20 or h 20: continue cv2.rectangle(img, (x, y), (xw, yh), (0, 0, 255), 2) cv2.putText(img, str(len(boxes)), (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) boxes.append((x, y, w, h)) annotated screen_annotated.png cv2.imwrite(annotated, img) description [ {id: i, bbox: [x, y, w, h]} for i, (x, y, w, h) in enumerate(boxes) ] return annotated, description点击动作就简单了import pyautogui def click_on_screen(x, y): pyautogui.click(x, y) return 已执行点击这里要提醒一下多模态模型对截图的分析不是瞬时完成的如果截图内容比较复杂比如整个桌面模型的分析质量会明显下降。我在实际使用中的体会是让它分析“某个特定窗口”比分析“整个屏幕”靠谱得多。所以后续版本里我加了一个窗口前置捕获逻辑先通过win32gui.FindWindow找到目标窗口句柄再只截取这个窗口区域效果好了不少。4.3 把 MCP Server 挂载为 Agent 工具等 GUI 能力稳定了我再把 MCP 部分接进来。这里不从头实现协议细节了我直接用mcpPython SDK 把启动和调用封装成一个工具类伪代码如下from mcp.client.stdio import stdio_client class MCPToolWrapper: def __init__(self, config): self.server stdio_client(config[command], config[args]) def discover_tools(self): return self.server.list_tools() def call(self, tool_name, args): return self.server.call_tool(tool_name, args)在 Agent 主循环里我启动时遍历配置文件里的所有 MCP Server把每个 Server 暴露的工具都注册到 tools 数组里。这样模型在整个对话过程中就能自由选择调用范围不再局限于我预先写死的几个函数。这里有一个特别好的“化学反应”MCP 和 GUI 能力叠加后Agent 才能完成真正意义上的“电脑操作闭环”。比如我可以让 Agent 通过 MCP 调用浏览器工具去查某个网站的接口文档然后根据文档内容用 GUI 工具在本地软件里执行对应操作最后再把结果同步到另一个 MCP 对接的数据系统里。这不是多个功能的简单堆叠而是一个跨系统的自动化流水线。4.4 实操现场记录一个完整的任务演示为了让你有更直观的感受我把一次完整的实操过程记录下来。假设目标是自动打开一个本地小工具读取窗口上的三个数字然后求和将结果写入一个文本文件。第一步启动 Agent输入任务描述。第二步Agent 调用 MCP 工具find_window定位目标窗口得到窗口坐标。第三步Agent 调用screenshot_now截取目标窗口加上边界框标注后通过视觉模型识别出三个数字的位置输出 JSON。{ok: true, numbers: [ {value: 12, bbox: [180, 220, 90, 40]}, {value: 23, bbox: [310, 220, 90, 40]}, {value: 45, bbox: [440, 220, 90, 40]} ]}第四步Agent 调用 Python 计算 12234580。第五步Agent 调用工具write_file把 80 写入result.txt。整个过程没有写一行针对这个应用的专用代码这在我看来就是 Agent 的意义——它作为一个通用执行框架通过工具的组合完成了一个原本需要定制脚本的任务。你可以把这个思路平移到任何你自己的场景里桌面应用自动化、浏览器辅助操作、Excel 数据整理、批量文件处理等。5. 常见问题与排查技巧实录5.1 模型“假执行”它说点过了实际没点这是我在整个项目开发过程中遇到最多、也最坑的问题。现象是模型在回复里自信地说“已点击完成”但界面上什么事情都没发生。原因很简单——模型只是在生成文本并没有真正触发我的工具函数。这个问题的根源通常是两种情况第一工具调用参数格式错误比如模型的工具调用里把参数写成嵌套 JSON 或者有空字段我的解析函数没兜住直接跳过了执行。排查办法是把response.choices[0].message原始输出 dump 出来看而不是只看最终回复。第二循环写成了单轮。也就是说模型请求了工具调用但我的代码没有把工具结果追加回消息列表里继续对话而是直接把模型那句“已点击”当成了最终输出给出去了。这种错误常见的表现就是LLM 自己编造一个工具调用的叙事实际函数从没被调用。解决方法是严格检查消息历史里是否存在role: tool的记录。5.2 坐标偏移截图坐标和真实光标位置不一致除了前面提到的显示器缩放率问题还有一个容易被忽略的坑任务栏和窗口装饰边框。截图工具截取的是整个虚拟屏幕区域而 pyautogui 的坐标体系是从主显示器左上角开始计算的。如果机器接了双显示器这个偏移会更复杂。我的排查技巧是在点击之前先做一次“光标回显测试”。让程序在目标坐标画一个十字光标或者移动鼠标过去然后截图确认这个位置和模型认为的按钮位置是否一致。如果差几个像素多半是缩放问题如果差一个屏幕宽度多半是双显示器布局问题。5.3 MCP Server 启动失败或通信超时这类问题最常见的表现是Agent 启动时卡住或者调用某个 MCP 工具时报 timeout。我排查的顺序是手动在终端执行配置里的command和args看有没有报错。很多 MCP Server 是 npx 启动第一次会下载依赖慢得很容易导致超时。检查 MCP Server 的 SDK 版本和我的 SDK 版本是否兼容。尤其是 stdio 传输方式版本不匹配会出现握手失败。加上日志输出把 MCP 收到的原始消息打印到文件里。很多问题是消息格式不符合规范导致的看到原始消息就明白了。注意MCP 的 stdio 模式在 Windows 上有一个特殊问题就是换行符。Server 端如果用的是 LFWindows 下可能因为管道处理差异导致消息读不到。我最后的解决办法是不依赖 MCP SDK 的底层管道直接用 subprocess 的 PIPE手动按\n分割兼容性好了很多。5.4 常见问题速查表问题可能原因解决办法模型说已点击但界面无反应单轮循环、参数解析失败检查消息历史确保 tool_calls 被正确回传点击位置偏移显示器缩放比、双屏坐标启动时读取缩放系数统一坐标换算MCP 调用超时Server 首次启动慢或握手失败手动启动 Server 验证调大超时时间图片发不进去多模态接口参数格式不对检查图片 base64 编码和 content 字段格式截图全黑目标窗口最小化或被遮挡先恢复窗口再截取窗口区域Agent 答非所问工具描述不清晰重写工具 description写明参数含义和用途5.5 避坑技巧给工具命名和描述的艺术很多人写 Agent 时忽略了一个关键点工具的name和description对模型的行为影响极大。模型是通过描述来理解“这个工具什么时候该用”的。如果描述写得太含糊比如“用于执行操作”模型就会在完全无关的场合也乱调用它。我现在的习惯是给每个工具写一段“什么时候用、什么时候不用”的说明。举个例子description: 截取当前活动窗口截图返回图片路径。 当用户需要查看图形界面内容时使用。 如果没有活动窗口或需要获取后台数据不要使用此工具。这个改动看起来不起眼但实实在在地提升了工具的调用准确率。Agent 工具描述是给 LLM 看的文档不是给程序员看的注释要多写意图少写实现细节。6. 从单文件项目到通用 Agent 的经验沉淀这套东西做完之后我最大的感受是单文件 Agent 不是什么玩具而是一种很好的“最小可行产品”形态。它逼着你做减法把真正需要的逻辑留下来把花里胡哨的抽象剥离掉最后剩下的核心循环其实就是“解析模型意图—调用工具—观察结果—再喂回模型”这一个简单模式。如果你也想做类似的事情我的建议是不要从框架开始从最简单的while True tools循环开始跑通了再加功能。GUI 自动化一定要做安全护栏白名单和人工确认不是可选项是必须项。MCP 集成不用一步到位先用两个 Server 打通链路再扩展。日志是最重要的调试工具尤其是在单文件项目里没有日志的话出问题只能靠猜。最后分享一个小技巧给 Agent 加一个“自省”能力。在系统提示词里明确告诉模型当工具调用失败时要主动把失败信息带进下一轮对话而不是假装成功。这个简单的指令能避免大量“假执行”类的问题。我在自己的项目里加了这段话之后整体可靠性提升非常明显。做完这个项目再回头看命令行里的 AI 写代码只是冰山一角真正有价值的是把这些代码世界和图形世界、协议世界打通。单文件只是个分发形式但把“大模型—GUI—工具协议”串成一条线的思路才是这个项目让我最兴奋的地方。