最近把AI编码代理接进日常工作流之后,有个感受特别深:终端里的agent再能打,一碰上“打开某个软件、点几下按钮、填个表单、等界面加载完再继续”这种活儿,基本就歇菜了。市面上的编码代理大多只会在命令行里折腾,可真实项目里有一大堆操作偏偏长在GUI里。所以我花了些时间,自己写了一个免费的AI编码代理,核心就一个Python文件,能操控GUI,也原生支持MCP协议,丢到哪都能跑。这篇文章就把我的设计思路、落地细节和踩过的坑完整拆一遍。
先说清楚这个工具解决什么问题。它本质上是一个带工具调用的agent循环:大模型负责规划和决策,具体动作由内置工具执行。工具分三类——终端命令、GUI操作、MCP调用。GUI操作不是那种“模拟按键”的玩具级实现,而是同时支持视觉定位和系统级控件读取两条路线。MCP这块直接兼容社区里大量现成的server,像Figma、IDA、x32dbg、蓝湖这些都有对应的MCP封装,接进来就能用。
适合谁来参考?想自己搭一个可扩展编码代理的开发者,被“AI只能写代码不能操作软件”困扰的自动化爱好者,还有想搞清楚MCP和GUI自动化到底怎么落地的人。接下来按我的实际构建顺序讲,先从最核心的架构说起。
1. 为什么我非要一个能“动手点界面”的编码代理
1.1 终端里的编码代理,能力边界太明显了
我最早用的是纯终端方案的编码代理,效果其实不错:改代码、跑测试、查日志、提交分支,这些它都能干。但我的工作流里有几个高频操作,终端方案完全覆盖不到。
举个例子,前端项目经常要对着Figma设计稿调样式。传统做法是我自己截图、量间距、看颜色,再把参数填进prompt里让AI改。这种“人的眼睛当传感器,人的手当执行器”的模式效率非常低。如果能直接让agent连Figma的MCP server,自己拉设计稿节点、导出图片、读样式数据,再结合GUI截图确认渲染结果,整条链路就自动化了。
另一个场景是硬件相关的开发流程。比如画完PCB要导出BOM表,或者要跑一遍封装库检查,Altium Designer这种大型EDA软件的操作几乎全在GUI里,脚本接口虽然有,但配置繁琐。如果agent能直接操控GUI,像人一样打开菜单、点击按钮、等待对话框出现、填入路径,再读取导出结果,很多重复劳动就能交给它。
1.2 “能看、能点、能回调”三个能力缺一不可
我复盘了自己平时用电脑完成开发任务的思维方式,发现拆解下来就三件事:看界面状态、操作界面元素、通过接口或脚本拉数据。
看界面状态,对应的是GUI截屏和控件树读取。操作界面元素,对应的是鼠标键盘模拟和控件级动作调用。拉数据和调用外部能力,对应的是MCP工具和终端命令。
市面上的agent要么只做第三件事,要么把第一第二件事做得非常粗糙。我的目标很简单:把这三件事统一封装成一组工具,注册进agent的工具表里,让大模型在规划时能像调用普通函数一样调用GUI能力和MCP能力。
1.3 一个文件就够,为什么这么执著
单文件运行不是为了炫技。我的开发环境经常在Windows、macOS、Linux之间切换,可以免安装的Python脚本是最省心的形态。写成一个.py文件,只要机器上有Python 3.10以上版本就能跑。分发的时候用python agent.py启动,或者用PyInstaller打成单文件可执行程序再分发,都行。
这个“单文件”约束反过来倒逼了架构设计。模块不能拆得太散,依赖不能太重,能不进数据库就不进数据库——所有状态都放内存和本地JSON文件里。代码里我用了一个核心类CodingAgent,把事件循环、工具注册、MCP客户端、GUI管理器全部挂在这个类下面,整个项目的主入口就是这一个文件。
提示:单文件的好处是部署简单,但代价是代码组织要更克制。建议用清晰的类和方法边界代替多文件模块拆分,我的做法是把三类工具分别放进
ToolNamespace的子类里,目录是逻辑上的,文件只有一个。
2. Agent骨架:一个单文件如何装下规划、执行与记忆
2.1 事件循环:agent的核心动力
整个agent的心脏是一个再简单不过的循环。每次循环做四件事:取上下文、问模型下一步动作、执行动作、把结果追加回上下文。然后重复,直到模型输出“任务完成”或者达到设定的最大轮数。
def run(self, task: str): messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}] for _ in range(self.max_steps): step = self.llm.next_action(messages, self.tool_schema()) if step.type == "finish": return step.summary result = self.dispatch(step.tool_name, step.arguments) messages.append({"role": "assistant", "content": step.render()}) messages.append({"role": "tool", "name": step.tool_name, "content": result})这段代码看着简单,但有几个细节是血泪换来的。
next_action返回的是一个结构化动作,不是自由文本。我要求模型必须输出JSON格式的动作,包含tool_name和arguments两个字段,这样dispatch层才能稳定路由。如果允许模型自由发挥,第三步解析动作就会变成噩梦。
tool_schema()返回的是所有工具的OpenAI function calling格式描述。每次循环都要把工具列表传给模型,所以工具描述写得精炼非常重要——描述写得越长,占的上下文窗口越多,模型还越容易糊涂。
SYSTEM_PROMPT里我写得最重的一段话是:“你可以调用GUI工具操控屏幕上的软件,也可以调用MCP工具访问外部服务。二者都是普通工具,没有优先级之分。当终端命令无法完成需求,或者需要读取GUI状态时,优先尝试GUI工具。”
2.2 工具注册表:所有能力平权的关键
GUI工具、MCP工具、终端工具,在agent眼里必须长一个样。我定义了一个Tool数据类,包含名称、描述、参数schema、执行函数四个字段。注册工具就是往全局字典里塞一个Tool实例。
tools = {} def register(name, description, parameters, fn): tools[name] = Tool(name=name, description=description, parameters=parameters, fn=fn) register( "gui_click", "Click a GUI element identified by name or automation id. Works on Windows/Linux/macOS.", { "type": "object", "properties": { "target": {"type": "string", "description": "Element identifier, e.g. '打开文件按钮'"}, "window": {"type": "string", "description": "Window title keyword"} }, "required": ["target"] }, gui_manager.click )MCP工具怎么平权?MCP server通过协议暴露工具列表,我在启动时把每个server的工具拉取下来,动态调用register()逐个注册。这样agent的工具表是运行时拼装出来的,本地内置工具和外部MCP工具完全统一。
2.3 为什么选Python,而不是Go或Rust
我考虑过用Go重写,最后放弃了。原因是GUI自动化这个领域,Python生态的优势太明显。pyautogui做跨平台鼠标键盘模拟,pywinauto做Windows控件自动化,macOS上有pyobjc的Accessibility API封装,Linux上有atspi的Python绑定。MCP官方也提供了Python SDK,跟asyncio配合得很顺。
用Go的话,没有几个库能直接平替这些能力,全得自己调系统API,工作量会翻好几倍。单文件的话,Python用PyInstaller也能做到,所以选型上没有任何纠结。
2.4 状态记忆:不依赖数据库的“轻量持久化”
agent跑长任务时不能失忆。我不引入数据库,所有会话状态存在一个JSON文件里,关键节点自动快照。
def snapshot(self): state = { "task": self.task, "messages": self.messages[-20:], "working_dir": str(self.cwd), "env": {k: v for k, v in os.environ.items() if k in ("PATH", "HOME", "USER")} } with open(self.state_file, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2)只存最近20条消息是有意的设计。MCP工具返回结果有时候非常长,比如一次读文件内容可能有几万字符,全存进JSON会把状态文件撑爆。我只保留最近20条,另外提供一个read_tool_log工具,模型需要追溯早期结果时可以主动查。
单文件运行的代码组织,我最后总结下来就一句话:把“工具”和“引擎”分离。引擎是agent循环本身,工具是插在上面的积木。这样哪怕只有一个文件,后期加新工具也不会把主循环搞得一团糟。
3. GUI操控的两种实现路线及我的混用策略
3.1 第一条路线:截图 + 视觉模型定位
视觉路线最初的动机很简单:控件树读不到的时候怎么办?很多软件不是标准的原生控件,比如用Qt自绘的界面、游戏引擎渲染的界面、某些老旧的MFC程序,Windows的UI Automation树要么残缺,要么全是空节点。这时候唯一的办法就是像人一样“看屏幕”。
具体做法是:agent调用screenshot工具截取当前屏幕,把截图交给多模态模型,让模型返回目标元素在图像中的坐标,然后由gui_click_coordinate工具完成点击。
def gui_screenshot(self, region=None): img = pyautogui.screenshot(region=region) b64 = base64.b64encode(img.tobytes()).decode("ascii") return {"mime": "image/png", "width": img.width, "height": img.height, "data": b64}但纯视觉方案有三个痛点。第一是坐标精度随模型能力波动,小按钮经常点偏。第二是截图传图很耗token,多模态模型处理一张1080p截图,成本比普通对话高不少。第三是同一个操作每次都要重新“看”,没有记忆,效率低。
3.2 第二条路线:系统级控件树读取
控件路线的原理是借助操作系统提供的辅助功能接口,直接把界面结构读取成树状数据,每个节点带有名称、类型、坐标、状态等属性。Windows上就是UI Automation,macOS上是Accessibility,Linux上是AT-SPI。
以Windows为例,我用pywinauto作为封装层。连接记事本并读取编辑框内容的代码大概是这样的:
from pywinauto import Application app = Application(backend="uia").connect(path="notepad.exe") dlg = app.window(class_name="Notepad") edit = dlg.child_window(control_type="Edit", found_index=0) text = edit.get_value()控件树的优势是定位精准,拿到的都是结构化数据,模型理解起来成本低。比如按钮的name是“确定”,坐标是(123, 456, 30, 20),模型不需要“看”就知道该点哪里。
难点有三个。一是每个操作系统的接口完全不同,要做一层抽象屏蔽差异。二是控件树有时候加载不全,尤其是软件刚启动的时候,要等待和重试。三是部分软件压根不暴露接口,控件树就是空的。
3.3 混合策略:控件优先,视觉兜底
我在GUI管理器里设计了一个统一的动作原语,上层模型只面对这几个工具:
| 工具名 | 作用 | 底层实现 |
|---|---|---|
gui_find | 按名称或条件定位元素,返回属性 | 控件树优先,视觉兜底 |
gui_click | 点击指定元素 | 控件坐标或视觉坐标 |
gui_type | 向指定元素输入文本 | 控件SetValue或键盘模拟 |
gui_read | 读取元素文本或状态 | 控件GetValue或OCR |
gui_wait_window | 等待窗口出现,带超时 | 轮询控件树 |
gui_find的逻辑很直接:先在控件树里按名称和类型匹配,匹配不到就截屏交给视觉模型定位。定位完记入本地的element_cache,同一个元素第二次找就直接用缓存坐标,不用再走一遍模型。
视觉兜底我建议加一层OCR辅助,而不是完全依赖模型。截图后先跑一次本地OCR(Windows可以用系统自带的Windows.Media.Ocr,Linux上用tesseract),把OCR识别的文字和模型返回的坐标做交叉验证。如果模型说坐标在“(500, 300)”,而OCR在那里没识别到任何文字,就触发重试。
3.4 操作安全:快照对比与确认机制
GUI自动化和终端命令不一样,一个误点击可能把正在编辑的文件关掉,或者把某个设置项改掉。我加了两个安全机制。
第一个是操作前快照。执行关键动作前截屏一次、读取一次控件树,动作完成后再次截屏和读取,对比差异。差异信息会作为工具结果中的一个字段返回给模型,模型据此判断操作是否成功,不需要再猜测。
第二个是高危操作确认。我在工具schema里给部分工具加了confirm_required标记,比如gui_click在点击“删除”“确定”“覆盖”这类高风险按钮时,会要求模型先输出一个确认token,然后由agent在终端询问用户是否放行。实测下来,这个机制能有效拦截一些模型“自作主张”的误操作。
4. MCP接入层:把Figma、IDA、x32dbg这些工具变成Agent的“手”
4.1 MCP解决了什么问题
MCP,全称Model Context Protocol,是一种让AI应用连接外部数据和工具的标准协议。打个比方,大模型是大脑,MCP server就是给大脑接的“神经末梢”,每一个server对外提供一组工具,agent通过标准接口调用这些工具。
以前每接一个外部服务就要写一套定制代码,还经常因为各家API风格不同导致维护困难。MCP把工具发现、参数描述、调用返回统一成了标准格式。agent只要实现一次MCP客户端,就能连接所有遵循该协议的server。
社区里的生态已经相当丰富。设计稿场景有Figma的官方MCP server和蓝湖的MCP封装,逆向分析有IDA MCP、x32dbg的MCP插件,前端有各类浏览器MCP。我接入MCP层之后,这些能力全部成了agent工具表里的普通工具。
4.2 客户端实现要点
MCP协议走JSON-RPC 2.0,传输层可以选stdio子进程通信,也可以走HTTP+SSE。单文件场景我用stdio子进程模式最多:每个server是一个可执行程序,agent启动时把它作为子进程拉起来,通过stdin/stdout交换JSON-RPC消息。
完整的调用链路分三步。第一步initialize握手,交换协议版本和capabilities。第二步tools/list,拉取server支持的工具列表。第三步tools/call,按名字和参数调用工具,server返回结构化结果。
async def list_tools(self): await self.write({ "jsonrpc": "2.0", "id": self.next_id(), "method": "tools/list", "params": {} }) resp = await self.read() return resp["result"]["tools"]async def call_tool(self, name, arguments): await self.write({ "jsonrpc": "2.0", "id": self.next_id(), "method": "tools/call", "params": {"name": name, "arguments": arguments} }) resp = await self.read() if "error" in resp: raise MCPServerError(resp["error"]) return resp["result"]["content"]4.3 参数校验:防止模型“幻觉参数”
模型在生成工具调用参数时,经常会把字符串类型和数字类型搞混,或者在枚举值上自由发挥。MCP的tools/list返回结果里带有inputSchema,是JSON Schema格式。我在调用前先做一次本地校验,能拦截大量明显错误的参数,避免把错误请求发到server端才报错。
from jsonschema import validate, ValidationError def validate_args(tool_name, arguments, schema): try: validate(instance=arguments, schema=schema) except ValidationError as e: suggested = auto_fix(arguments, schema) return suggested if suggested else {"error": str(e)} return arguments自动修正逻辑很简单:如果schema说某个字段类型是number而模型传了字符串,我就尝试用int()或float()转一次;如果schema里有enum,我就找枚举里最接近的值。实测下来,这套自动修正让MCP调用的成功率提高了不少,因为模型犯的错多数是类型层面的,不是逻辑层面的。
4.4 MCP工具的权限与审计
接入的server越多,风险面越大。我给每个server配置了一套权限规则:
- 只读工具自动放行,比如Figma读取设计稿节点、IDA查询函数列表。
- 有副作用的工具走确认机制,比如文件写入、数据库修改、提交发布。
- 每个工具的调用都会写入本地审计日志,包含时间、server、工具名、参数摘要、返回码。
审计日志其实很有用,后期排查问题或者回放一次自动化过程都靠它。有一次agent通过Figma MCP误改了设计稿的某个文本节点,我就是靠审计日志定位到是哪一步触发的。
5. 实测回放:三条指令从输入到落地的全过程
5.1 场景A:从Figma设计稿生成前端页面代码
我接入了Figma的官方MCP server,配置好访问令牌之后,给agent下了一条指令:“读取我正在编辑的这个Frame的样式,生成对应HTML和CSS代码。”
agent的动作链路如下:先通过MCP的tools/search_nodes定位到目标Frame节点,再调用tools/get_node读取节点样式和子元素结构,随后我把离线的设计稿图片导出传给了GUI层的截图工具做视觉参考。agent拿到所有信息后,生成了完整的HTML/CSS。
中间有个小插曲:Figma MCP返回的节点样式里,颜色值是#RRGGBBAA八位格式,但CSS标准用的是rgba()或八位十六进制补丁,agent第一次生成的代码颜色完全不对。后来我在系统提示词里加了一句“Figma颜色值是带alpha通道的,转成CSS时要去掉alpha或改用rgba”,这个问题就再没出现过。
5.2 场景B:用GUI操控Altium Designer导出BOM表
Altium Designer有脚本API,但日常操作还是GUI为主。我用GUI工具让agent完成了整个导出流程:通过gui_wait_window等待软件启动,用gui_find定位到“Reports”菜单,点击“Bill of Materials”,在弹出的对话框里填写导出路径和格式,最后确认导出。
Altium启动需要好几秒,控件树在加载过程中经常为空。我实现了一个带超时和重试的gui_wait_window,每500毫秒轮询一次控件树,最长等待30秒。第一次实测时,agent经常在软件还没加载完就尝试点击,结果控件不存在,工具报错后模型会反复重试,浪费好几轮。加了这个等待工具之后,问题就解决了。
5.3 场景C:IDA Pro里做自动化逆向分析
IDA的场景比较特殊。社区有现成的IDA MCP server,能把IDA的插件接口暴露成MCP工具。我先手动打开IDA加载样本,然后让agent连接MCP server做交叉引用分析。
agent的操作路径是:调用IDA MCP的list_functions获取所有函数列表,对目标函数调用decompile获取伪代码,再调用list_xrefs_to查看所有引用了该函数的位置,最后根据这些信息生成了分析报告。
这里有个细节值得提:IDA分析大文件时,反编译操作可能耗时很长,MCP默认超时只有30秒。我在MCP客户端里把超时设置成了可配置项,针对IDA server单独调到120秒,并且要求server端用流式输出报告进度。这样agent在等待期间可以主动查询任务状态,而不是干等一个超时错误。
5.4 实测后的几个体会
三条实测路径跑下来,我最深的感受是:AI编码代理的能力上限,不在大模型本身的智商,而在于你给它接了多少只手。终端命令是手,GUI操控是手,MCP工具更是手。手越多,它能独立完成的任务越复杂。
第二个体会是,工具返回结果的“结构化程度”直接决定agent的稳定性。Figma MCP返回的JSON很规整,模型处理起来极少出错。而某些GUI工具返回的是大段文本描述,模型需要自己解析,就容易断章取义。
6. GUI自动化和MCP集成最容易翻车的地方
6.1 DPI缩放导致坐标偏移
这是GUI自动化最容易踩的坑。Windows的显示缩放如果设置成125%或150%,pyautogui截图得到的坐标和实际物理像素之间的关系会错乱。控件树返回的坐标是逻辑坐标,而pyautogui.click用的是物理坐标,不换算的话点100%偏位。
我的解决办法是启动时给进程设置DPI感知,统一以实际像素为基准:
import ctypes try: ctypes.windll.shcore.SetProcessDpiAwareness(2) # PerMonitorV2 except Exception: ctypes.windll.user32.SetProcessDPIAware()然后控件树里的坐标直接当物理坐标用。多显示器场景还要额外处理负坐标值,两个显示器分辨率不一样时,坐标换算最好以主显示器为原点做一次归一化。
6.2 控件树为空:软件启动延迟加载
GUI程序的控件树经常不是一次性加载完的。尤其像Altium、Photoshop这种大型软件,启动后几秒内窗口标题都读不到。要么加等待工具,要么对gui_find做内部重试。我倾向于后者,因为模型不用显式等待,工具自己会在超时时间内反复尝试,这比让模型控制等待节奏可靠得多。
6.3 MCP server进程管理
stdio模式的MCP server是agent拉起的子进程,进程生命周期管理做不好很容易泄漏。agent退出时如果没杀掉子进程,系统里就会残留一堆僵尸进程占用资源。我在agent的__del__和atexit钩子里统一处理,确保所有MCP子进程都能被terminate。
另外,同一个server不要重复启动。每启动一次就要走一遍initialize握手,浪费好几秒。用mcp_servers字典做实例缓存,同一个server路径只启动一次。
6.4 MCP调用超时与流式输出
长耗时工具容易触发超时。与其调大超时,不如在工具层面实现“异步任务+状态查询”模式。server端启动任务后立刻返回task_id,agent通过另一个工具轮询任务状态,任务完成后再取结果。这个模式对复制大文件、跑大数据分析这类场景特别管用。
6.5 单文件打包的隐藏坑
用PyInstaller打成单文件时,最容易出的问题是非标准路径的资源找不到。比如pywinauto在运行时依赖的一些 DLL,打包时需要手动加--collect-all pywinauto。GUI截图依赖的 Pillow 也要显式 collect。
我的经验是:先在开发环境把一切调通,再用PyInstaller打单文件,打完后立刻在干净环境里跑一遍GUI场景和MCP场景。不要等到给用户分发之后才发现某个DLL缺失,那种排查体验很糟。
7. 后续能往哪些方向扩展
这个项目目前的状态是“够用”,但离我理想中的形态还有段距离。
一个是GUI这块,现在依赖系统辅助功能和截图视觉模型,遇到不支持辅助功能且视觉模型识别效果差的应用还是会吃瘪。我打算针对常见的国产软件和行业软件,各写一套专用的控件适配层,把它们的特有控件类型提前映射好。
另一个是MCP接入这块,社区server质量参差不齐,有的工具schema写得非常潦草,参数描述跟实际行为对不上。我想加一个“server健康度检测”功能,启动时自动对每个server做一次冒烟测试,跑一个最小工具调用,不通就自动跳过并在日志里标注原因。
还有一个方向是多模型切换。现在agent固定在单个大模型上,但GUI视觉定位和MCP工具调用,对不同模型的推理能力要求不一样。我想把“规划模型”和“工具调用模型”分开配置,规划用强模型,工具参数生成用快模型,降低成本的同时还能针对性调优。
最后再分享一个我在实际使用中的体会。做这类工具,真正花时间的地方不是写agent循环,也不是接GUI接口,而是让模型“理解工具边界”。工具描述写得不清不楚,模型就会乱调用。我花了很多精力打磨每个工具的描述文案,把参数含义、返回结构、常见失败原因都写进去——这比任何代码优化都有效。如果你也想做类似的AI编码代理,建议你从“工具描述质量”入手,优先把少而精的几个工具调到极致,再慢慢扩充。