上个月朋友甩给我一个需求:他们公司有个老旧的桌面进销存系统,每天要重复往表格里填一百多条数据,每单要点七八次鼠标,他问我能不能用AI自动搞定。我第一反应是让AI写个脚本,结果试了一圈现有的AI编码代理(coding agent),发现它们大多只会读写文件、跑终端命令,面对一个只能在GUI里操作的老软件完全无能为力。于是我才动手做了这个免费的小工具:一个支持操控GUI和MCP协议、还能单文件运行的AI编码代理。
简单说,这个项目就是一个自带Agent循环的智能体程序,你给它一句自然语言指令,它能自动拆解任务、调用工具、观察界面状态、再决定下一步动作。它最大的特点是三个:一是能像人一样操作桌面图形界面,不是只写代码;二是实现了MCP(Model Context Protocol)客户端,可以直接接入文件系统、浏览器、数据库、GitHub这类第三方工具服务器;三是整个程序打包成一个可执行文件,下载就能跑,不用装Node、不用配Python环境依赖。
这篇文章我会把整个项目的设计思路、核心实现、以及我踩过的坑完整写出来。适合两类人看:一类是想自己搭建AI编码代理的开发者,另一类是需要在桌面GUI场景里做自动化的朋友。我不准备把代码全贴出来(项目还在打磨),但核心架构和关键实现都会讲清楚,照着这个思路基本能复现一版。
1. 从"装环境装到崩溃"说起:为什么需要单文件AI代理
1.1 AI编码代理的现状扫描
现在的AI编码代理大致分成三类,我列个表方便对比:
| 类型 | 代表产品 | 强项 | 痛点 |
|---|---|---|---|
| 终端型 | Claude Code、Codex CLI、Codeium | 重构代码、跑命令、git操作 | 只管命令行,碰不了GUI,依赖Node等运行时 |
| IDE型 | 通义灵码、GitHub Copilot | 补全、对话、上下文感知 | 绑死在IDE里,自动化能力弱 |
| 自定义Agent | 各类LangChain/Dify项目 | 灵活、可接工具 | 依赖满天飞,配置复杂,不适合分发给非技术同事 |
我遇到的实际问题是:朋友要的不是"帮忙写代码",而是"直接把那个老系统里的表单填完"。这种任务的落点根本不是代码,是GUI操作。终端型代理做不了,IDE型代理更不可能。
那为什么不用现成的RPA工具?我也考虑过,但RPA的录制脚本太脆,界面稍微动一下位置就全废。AI代理的优势是"看情况决策"——按钮位置变了、弹窗内容变了,它能重新理解。
1.2 三个硬性需求
在动手之前,我给自己定了三个需求,后面所有技术选型都围着它们转:
- 能操控GUI:屏幕截图、识别界面元素、点击、输入、拖拽,跨Windows和macOS。
- 支持MCP协议:不自己造一堆私有工具接口,直接对接MCP生态,今天接文件系统明天接浏览器,互操作。
- 单文件运行:好朋友那边不懂Python,不可能让他去装依赖、配虚拟环境。我要给他一个文件,双击就能跑。
第三个需求其实是最反常规的。很多开发者写工具习惯了一堆依赖、环境变量、Docker,但做出来之后根本没法分发给别人用。我后来单文件化的时候吃了不少苦,具体方案放在第5节。
1.3 单文件带来的分发优势
单文件不只是一个"懒人福利",它直接改变了这个工具的使用方式:
- 拷到U盘就能走,朋友电脑上没有Python也能跑(用PyInstaller打包的exe),或者有Python但不想装依赖也能跑(用zipapp打包的.pyz)。
- 升级就是替换一个文件,不用处理依赖冲突。
- 我可以在自己的机器上编译好,直接发给别人,源码不暴露(虽然对这种个人项目不太重要)。
所以整个项目的第一原则是:先把运行形态想清楚,再决定怎么写代码。我后面所有模块设计都考虑了"要不要把某个依赖打进去",这跟平时写web服务是完全不同的思路。
2. Agent核心循环:规划、执行、观察是如何串起来的
2.1 工具调用是骨架,不是锦上添花
AI编码代理跟普通聊天机器人的本质区别在于:它每回答一步,都有可能真的去执行一个操作,然后根据操作结果继续推理。这个"推理-行动-观察-再推理"的循环,是Agent的骨架。
很多人第一次写Agent会犯一个错:用"生成一大段JSON然后解析"的方式做工具调用。这在早期少量工具时还行,工具一多、参数一复杂,各种解析bug就来了。正确做法是用LLM官方的function calling / tool calling能力,让模型结构化成"要调用哪个工具、传什么参数",而不是让它自由格式输出JSON。
2.2 主循环最小实现
我的核心循环非常朴素,伪代码大概是这样的:
def agent_loop(user_request: str, max_steps: int = 20) -> str: messages = [system_prompt(), user_request] for step in range(max_steps): response = llm.chat(messages, tools=tool_schema) if response.is_final_answer: # 模型没有要求调用工具 return response.text for call in response.tool_calls: result = dispatch_tool(call.name, call.arguments) messages.append(tool_result_message(call.id, result)) log(f"[step {step}] {call.name}({call.arguments}) => {truncate(result)}") return "已达到最大步数,任务可能未完成"关键就三步:
- 把距今为止的所有消息发给LLM,附带工具定义列表。
- LLM决定是直接回答,还是调用某个工具。
- 如果是调用工具,我执行它,把结果以tool_result消息塞回对话,进入下一轮。
循环本身没有任何魔法,真正的复杂度在两个地方:工具调度和上下文管理。
2.3 上下文窗口管理与工具结果截断
Agent跑长了之后,消息列表会越来越膨胀。尤其是GUI截图返回的base64字符串,动不动几十KB,两三轮就把上下文塞爆了。
我的处理方案是三级:
- 工具结果截断:纯文本结果最多保留2000字符,超过的部分头尾各留500字符,中间用
...省略N字符...代替。截图工具返回的图片单独走"最近一张可见"策略,上一轮的截图自动从消息里删掉,只留描述。 - 历史压缩:超过10轮之后,把最老的消息用LLM做一次摘要,替换成一条
<history_summary>消息。实测下来摘要损失可以接受。 - 强制终止:单任务最多20步。超过直接停,防止Agent陷入死循环(这种场景在GUI自动化时特别常见,后面会讲)。
2.4 工具注册表设计
工具不是一个一个硬编码的if-else,我做成了一张注册表,每个工具是一个带元数据的函数:
@tool( name="gui_click", description="点击屏幕上指定坐标位置,坐标基于屏幕截图", parameters={ "x": {"type": "integer", "description": "横坐标像素值"}, "y": {"type": "integer", "description": "纵坐标像素值"}, "double": {"type": "boolean", "description": "是否双击", "default": False} } ) def gui_click(x: int, y: int, double: bool = False): import pyautogui pyautogui.click(x, y, clicks=2 if double else 1)注册表本质上就是:函数名字、描述、参数JSON Schema、可执行函数四个字段的组合。MCP工具接入的时候,就是把外部工具描述翻译成同一套Schema,执行时再翻译回去。这个抽象在后面省了非常多的事。
3. GUI操控:让代理真正"看得见、点得着"桌面应用
3.1 两条技术路线:屏幕视觉 vs 辅助功能接口
GUI自动化有两条完全不同的路线,我一开始都试了,各自的优缺点很鲜明:
| 路线 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 视觉路线 | 截屏 -> 多模态模型识别元素 -> 映射坐标点击 | 跨平台、通用性强、不依赖具体应用 | 识别精度受模型影响,DPI换算容易出问题 |
| 辅助功能路线 | Windows UI Automation / macOS Accessibility | 元素定位精准、支持读取文本属性 | 很多老软件没有无障碍接口,跨平台要写两套 |
我最终选择以视觉路线为主、辅助功能为辅。原因很现实:那个进销存系统是Delphi写的二十年前的老程序,UI Automation完全读不到任何控件信息,但视觉路线只要"看得见"就能操作,不挑应用。
3.2 实测最佳组合:mss截图 + 多模态模型 + pyautogui
视觉路线的三个核心动作是:截图、看、点。
截图我用的是mss库,它是目前Python里最快的跨平台截屏方案,比pyautogui自带的截图快好几倍,支持多显示器:
import mss def capture_screen() -> str: with mss.mss() as sct: # 截取主显示器全屏,保存为临时文件 sct.shot(mon=1, output="/tmp/agent_screen.png") return "/tmp/agent_screen.png"截完图,把这张图连同用户指令一起发给多模态模型,让它在图的坐标层面做标注:
response = llm.chat( messages=[ {"role": "user", "content": [ {"type": "text", "text": "请定位表单中'客户名称'输入框的中心坐标"}, {"type": "image", "image_path": "/tmp/agent_screen.png"} ]} ] )模型返回类似"输入框位于(530, 420)"之后,调用pyautogui执行点击和输入:
import pyautogui def gui_type_text(text: str): pyautogui.hotkey("ctrl", "a") # 全选已有内容 pyautogui.typewrite(text, interval=0.02)这套组合跑通之后,整个Agent就有了"眼睛"和"手"。实际用下来的体验是:对于表单填写、按钮点击、菜单导航这类任务,视觉方案的准确率在90%左右,已经可以规模化用了。
3.3 坐标换算和DPI缩放坑
这是我踩得最深的一个坑,必须单独拿出来说。
在Windows上如果显示器缩放比例不是100%,比如常见的125%、150%,那么mss截屏返回的像素坐标和pyautogui实际点击的物理坐标是不一致的。后果就是:模型明明看到按钮在(500, 400),鼠标却精确地点到了按钮偏左下的位置。
解决方案是做一个坐标换算层:
import ctypes def get_win_scaling() -> float: try: ctypes.windll.shcore.SetProcessDpiAwareness(1) scale = ctypes.windll.shcore.GetScaleFactorForDevice(0) / 100 return scale except Exception: return 1.0 def screen_to_physical(screen_x: int, screen_y: int) -> tuple[int, int]: scale = get_win_scaling() return int(screen_x * scale), int(screen_y * scale)每台机器启动Agent时先检测一次缩放比,之后所有GUI点击坐标都先换算再执行。macOS上还有Retina屏幕,截图分辨率是逻辑分辨率的两倍,也是同样的逻辑。我后来把截图统一放在逻辑分辨率下进行,这样模型看到的坐标就是pyautogui能直接用的坐标。
3.4 权限配置清单
GUI自动化涉及系统级权限,不同系统的配置差异很大,我整理了一份清单:
- Windows:管理员权限不是必须的,但有些老的桌面软件以管理员运行时,屏幕内容会被隔离,Agent只能截到黑屏。这种情况需要用提权方式启动Agent。
- macOS:需要到"系统设置-隐私与安全性"里给终端打开两个权限,一个是辅助功能(控制鼠标键盘),一个是屏幕录制(截屏)。这两个权限不打开,程序会静默失败,不报错,只是动作无效果,排查起来很痛苦。
- Linux(X11):需要有显示环境权限,有些桌面环境还需要装
xdotool辅助点击。
4. MCP协议接入:从工具函数到开放生态
4.1 MCP是什么、解决了什么问题
MCP全称Model Context Protocol,模型上下文协议。它的核心思想非常简单:把"模型能力"和"外部工具"解耦,用一个统一协议连接起来。
我自己的理解是:MCP就是把工具做成了USB接口,模型是电脑,插上哪个设备就能用哪个设备。以前我要给Agent加一个"读取数据库"能力,得自己写连接池、写查询函数、写权限控制。现在只要跑一个现成的MCP server,告诉Agent"这个server提供哪些工具",就能直接用。
这个协议现在生态已经非常丰富了。文件系统、SQLite、GitHub、浏览器控制、Figma、各类数据库都有官方或社区的MCP server实现。我接入这些东西再也不用自己写适配代码。
4.2 最小MCP客户端:握手、列工具、调用
MCP基于JSON-RPC 2.0。我一开始是自己用WebSocket和stdio实现的客户端,后来发现官方提供了Python SDK,直接用更省心:
import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def connect_server(command: str, args: list[str]): server_params = StdioServerParameters(command=command, args=args, env=None) reader, writer = await stdio_client(server_params) session = await ClientSession(reader, writer) await session.initialize() tools = [] async for tool in session.list_tools(): tools.append({ "name": tool.name, "description": tool.description or "", "parameters_schema": tool.inputSchema }) return session, tools建立连接就三步:启动子进程、握手初始化、拉取工具列表。拉回来的工具描述直接转成Agent的tool schema,我们的Agent循环完全不用改,就能使用MCP server上的工具。
调用工具时通过session.call_tool()转发,参数就是JSON格式:
result = await session.call_tool("read_text_file", {"path": "/tmp/foo.log"})4.3 把MCP工具接入Agent工具表
接入MCP之后马上会遇到一个实际问题:工具重名。比如很多MCP server都有read_file、write_file这类通用命名,如果同时挂了三个server,Agent的tool list里就会出现三份同样的名字,模型调用时会混乱。
我的处理方案是给每个MCP工具加命名空间前缀:
def build_mcp_tool(session_name: str, tool) -> dict: namespaced_name = f"{session_name}__{tool.name}" return { "name": namespaced_name, # ... "dispatch": lambda args: asyncio.run(call_mcp_tool(session_name, tool.name, args)) }比如文件系统server的read_file会变成filesystem__read_file,浏览器server的click会变成browser__click。这样即使两个server有同功能工具,Agent也能区分,而且提示词里可以明确告诉它"查数据库用database__search,写代码用filesystem__write_file"。
4.4 调试MCP server的三个常见错误
MCP调试是我整个项目里最耗时的一环,踩过的坑基本上就三类:
- server往stdout打印日志导致协议崩溃。MCP的stdio传输规定了子进程的stdout只准输出JSON-RPC消息,如果server代码里有个
console.log("hello"),客户端解析直接断掉。解决方式:写MCP server时,日志一律写stderr或日志文件,永远别碰stdout。 - 握手超时。很多node实现的MCP server首次启动要下载依赖,几十秒没响应。我给
initialize加了30秒超时,并在启动前先用命令行手动跑一遍npx xxx确保依赖已就绪。 - 工具参数类型不匹配。MCP的
call_tool参数要求是JSON对象,有些server内部是用严格类型校验的,传"1"和传1结果完全不同。我给Agent的提示词里加了一条规则:调用MCP工具前,先检查参数的JSON类型与Schema定义一致。
接入MCP最大的价值是:今天我可以接一个Playwright MCP让Agent自己开浏览器操作网页,明天接一个数据库MCP让Agent查线上数据,后天再接一个Figma MCP读取设计稿——这些都是现成的能力,我用同一套代码就全部打通了。
5. 单文件打包的完整操作:zipapp、内嵌依赖与启动体验
5.1 zipapp原理:Python官方自带的单文件方案
单文件打包我首选了Python标准库的zipapp模块,而不是PyInstaller。原因有三个:生成的.pyz文件很小(不带Python解释器,只有代码和依赖)、跨平台效果一致、而且排查问题方便。
zipapp的原理并不复杂:.pyz本质上就是一个在前面拼了一段Python引导代码的zip压缩包。执行时,Python解释器先运行这段引导代码,把zip包挂在sys.path上,然后去执行包里的__main__.py。
5.2 依赖内嵌与二进制解压
真正麻烦的是依赖。zipapp虽然能把.py文件直接放进zip,但动态链接库和.pyd/.so这类二进制文件不能直接从zip里加载,因为它们需要被真实文件系统加载。
我的解决方案是"bootstrapper解压模式":把这些二进制依赖在启动时自动解压到系统临时目录,然后往sys.path插入路径:
import sys, tempfile, pathlib, zipfile def bootstrap_binary_deps(): # 项目内嵌了依赖解压标记文件 marker = pathlib.Path(__file__).parent / "_embedded_deps.json" if not marker.exists(): return # 源码运行时不需要 import json deps = json.loads(marker.read_text()) dest = pathlib.Path(tempfile.mkdtemp(prefix="agent_deps_")) for rel_path in deps: # 从当前zip中读取二进制文件,解压到临时目录 data = pathlib.Path(__file__).parent.joinpath(rel_path).read_bytes() target = dest / rel_path target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) sys.path.insert(0, str(dest)) # 让动态库可见这个函数必须在import任何第三方库之前执行。比如mss这个库在Windows下有mss.dll、pyautogui依赖的一些底层的Windows API库,都要走这条路。
而纯Python的依赖(比如mcp官方SDK的纯Python部分)可以直接把包源码放进zip包的根目录,不需要解压,import就能用。
5.3 完整打包命令和启动体验
打包脚本大致是这样:
# 1. 把项目源码组织成 src/agent/ 包结构 # 2. 把纯Python依赖直接复制到 src/agent/_vendor/ 下 # 3. 把二进制依赖放置在 src/agent/_bin/ 下 # 4. 写一个 __main__.py 作为唯一入口 python -m zipapp src/agent \ -p "/usr/bin/env python3" \ -o ai-coding-agent.pyz \ -m "agent.__main__:main"加上-p参数后,Linux和macOS上直接chmod +x ai-coding-agent.pyz就能当可执行文件运行。Windows上双击会调用关联的Python解释器,也可以改名成ai-coding-agent.pyz后手动运行:
python ai-coding-agent.pyz --gui-demo如果目标是给完全没有Python环境的Windows用户,我会额外用PyInstaller生成ai-coding-agent.exe。但日常我自己用还是.pyz,因为体积小(20MB以内)、启动快,改动代码后重新打包只需要一秒。
打包完成后一定要做三件事:
- 在一台干净的机器上测试(没有开发依赖)。
- 检查临时目录解压是否成功,看启动日志。
- 测试MCP相关的动态库能否正常加载。因为MCP的客户端SDK涉及网络IO,某些环境下需要额外拷贝证书相关文件。
6. 实测场景与翻车案例:哪些任务真正能提效
6.1 场景一:GUI表单批量填写
回到开头那个进销存系统。实际跑通的流程是这样的:
- 用MCP的文件系统工具读取Excel里的客户订单数据。
- 对每条记录,用GUI工具打开系统、定位"新增订单"按钮、点击。
- 视觉模型识别表单各个输入框位置,依次输入客户名、金额、备注。
- 点击"保存",然后截图确认系统弹出了"保存成功"的提示框。
一轮任务的Agent步骤大概是15-20步,单条数据耗时约15秒。原来是人工1分钟一条,提效4倍,而且是无人值守的。
6.2 场景二:浏览器MCP自动化测试
我在自己的web项目里试了用浏览器MCP server + Agent跑前端测试。流程是:让Agent打开某个页面,点击"注册"按钮,填测试账号,故意输错两次密码观察校验提示。
最有价值的一次是:Agent发现了一个bug——点击"提交"后没有出现校验错误提示,原因是前端两个字段的name属性对不上,导致校验规则没绑定。这个bug如果人工回归测试,可能要翻好几轮页面才能发现。Agent通过"截图-观察-再操作"的循环,能注意到肉眼容易略过的界面细节。
6.3 场景三:日志定位与补丁生成
这个场景是我的日常:程序报错后,让Agent先用MCP的文件系统工具读日志文件,再用数据库MCP查关联数据,最后直接用工具链修改代码文件并运行测试。
整个过程Agent是自主完成的,我只需要在最后review diff。和之前相比,排查"这个报错数据是哪来的"这类问题,时间从半小时压缩到了十几分钟。
6.4 翻车案例:三个必须写下来的教训
翻车1:多显示器坐标偏移
我的副屏幕在笔记本左边,Windows显示器的虚拟屏幕坐标系是有负坐标的。mss截图只能按显示器1、2分别截,模型看到的是独立画面,返回的坐标却是基于单屏的。我在点击时直接把坐标用在全局坐标系里,结果每次都点到主屏幕的对应位置。
修复方式:截图前记录每块屏幕的物理偏移量,模型返回坐标后先加上该屏幕的偏移,再换算成物理坐标。代码很简单,但这个问题不实测根本发现不了。
翻车2:GUI自动化的死循环
有一次Agent在填写表单时,截图识别"保存"按钮的位置,但点击后页面没反应(因为某个必填项没填),Agent截图看到还是"保存"按钮,又点,又没反应,来回打了十几个回合。
我的修复方案是在系统提示词里加了一条规则:同一个操作连续执行两次后,如果界面状态没有明显变化,必须停止并报告"操作可能无效",建议尝试切换方案,并在主循环里加了"动作去重检测"。现在这个情况基本不会再发生了。
翻车3:MCP工具超时导致整个Agent卡死
MCP的call_tool如果遇到网络慢的server,会一直阻塞。而我的Agent主循环是同步的,导致整个程序假死。我后来给每个MCP工具调用套了asyncio超时控制,超时后返回一个特殊的工具错误结果,让LLM自行决策是跳过还是重试,而不是卡死整个任务:
async def call_mcp_with_timeout(session, tool_name, args, timeout=60): try: return await asyncio.wait_for(session.call_tool(tool_name, args), timeout=timeout) except asyncio.TimeoutError: return {"_error": f"工具 {tool_name} 调用超时,请检查server状态或更换方案"}7. 安全边界与后续计划:能控GUI的Agent必须先谈风险
7.1 为什么"能跑GUI的Agent"要先谈安全
当Agent能截图、能点鼠标、能敲键盘、还能执行本地命令的时候,它实际上已经拥有了"使用这台电脑"的完整权限。这意味着如果指令设计有缺陷、或者prompt被注入(比如网页内容喂给Agent后诱导它执行恶意操作),后果会非常直接。
我在项目初期就定了一条原则:能力越强,安全约束就要做得越硬,不能指望模型自己守规矩。
7.2 我的安全机制
目前项目里有五层防护:
- 命令黑名单:终端命令执行器内置正则黑名单,
rm -rf、format、diskpart等一律拒绝执行。这些命令连"用户确认"的机会都不会给。 - 危险操作确认:删除文件、覆盖文件、执行系统级命令,默认需要用户在控制台输一次
y确认。Agent在等待期间会提示"我需要执行XX操作,是否同意"。 - MCP server白名单:默认不启动任何MCP server,必须用户显式指定才会挂载。Agent对MCP工具的使用不受干预,但"能用哪些工具"由用户决定。
- 工作目录沙箱:文件操作、代码运行默认限制在一个指定目录内。Agent想读写工作目录之外的文件,需要显式路径并在日志中留下记录。
- 全量操作日志:每一步工具调用、参数、关键截图、最终结果都会写成日志文件。出事后可以复盘Agent到底干了什么。
这些机制不需要做得特别复杂,但它们保证了"即使模型想干坏事或者被诱导,它也翻不出太远"。
7.3 已知不足和后续路线
这个Agent目前的局限也很明显:
- 视觉识别上限:对过小、过密、或者图标化的按钮识别率不稳定。有些中文软件里按钮文字细、间距小,模型会把"确定"看成"取消"。这类情况我会在提示词里强制要求"点击前先描述相对位置,不要只给坐标"。
- 内存占用偏高:因为内嵌了多模态模型调用的客户端、MCP SDK和各类GUI库,打包后的.pyz大约20MB,运行时要额外解压依赖到临时目录,内存峰值能到300MB左右。对现代机器不算什么,但在老电脑上会有点吃力。
- MCP依赖网络资源:很多MCP server要用
npx或uvx启动头一次会下载依赖,首次启动会有十几秒延迟。
后续我计划做三件事:一是把GUI操作从"坐标点击"升级到"控件语义操作",优先用辅助功能接口读取界面元素属性,配合视觉做兜底;二是增加一个人机协作模式,复杂步骤让我确认后再执行;三是把Agent做成MCP server本身,这样其他Agent也能通过MCP来调用我的GUI能力,Agent之间互相协作。
如果你也想动手做一个类似的东西,我的建议是:先想清楚你的"单一核心场景"是什么,然后让最小闭环跑通,再去追MCP生态和单文件化。我最初也是一步一步把这三块拼起来的——先是命令行跑通Agent,再接入GUI操作,最后加MCP和打包。别一开始就求大而全,那只会把自己淹没在依赖地狱里。