1. 先搞清楚GUI Agent到底是什么,以及我们第一步该做什么
做这行久了你会发现,很多听起来很高端的东西,拆到最底层往往就是一个朴素的循环。GUI Agent(图形界面智能体)也是这样,它的核心就一句话:让程序像人一样看着屏幕,决定下一步干什么,然后动手操作鼠标键盘,完成任务后停下。
近几年多模态大模型火起来之后,这件事的门槛被大幅拉低了。以前想做一个能操作电脑的程序,你要么走RPA(机器人流程自动化)路线,用专门的工具去录制和回放操作脚本,要么走计算机视觉路线,训练一个识别按钮和图标的小模型。这两条路的共同问题是:遇到界面改版就废了,遇到没见过的页面就懵了。而大模型带来的是另一种可能性——它确实没见过你这个软件,但它见过海量的图形界面交互样本,它能理解一个界面大概长什么样、按钮一般出现在什么位置、点击某个按钮之后通常会跳转到哪里。
所以这篇文章想带你做的事,不是去搭一个工业级的、能应对所有软件的通用Agent,而是手搓一个最小可用的版本出来。目标很朴素:给定一个自然语言任务描述,比如“打开记事本,输入一行字,然后把窗口拖到右上角”,它能一步一步自动完成。整个过程不涉及微调模型、不涉及复杂的强化学习,也不需要你去训练任何东西,纯粹靠几个开源库加上一个带视觉能力的模型API就能跑通。
适合看这篇文章的人,我默认你是这样的:写过一点Python,知道什么是pip install,但对Agent本身没什么概念,想看看这东西到底怎么实现,或者你脑子里有几个重复的电脑操作想自动化掉,但不想学那种厚重的RPA工具。看完之后你会得到一个能跑的骨架代码,以及一套改造成你自己任务的思路。
2. 环境准备与思路选型:为什么不用现成方案,偏要自己搓
很多人在接触Agent这个概念时,第一反应是去看看那些现成的开源框架。坦白讲,现在市面上的通用Agent框架确实不少,有的能帮你解析任务,有的能调用各种工具,还有的干脆把浏览器操作集成好了。但你会发现一个问题:它们太黑了。封装得太好的框架,你跑通了也只是会用了,出问题的时候完全不知道去哪排查,更别提想改造成自己专属的Agent时那种寸步难行的感觉。所以这篇文章坚持用最朴素的方案,每个环节都是自己写得出来的逻辑,这样你才算真的把GUI Agent拿捏住了。
2.1 最小闭环需要哪些组件
先列一下我们要用到的组件,其实只有四块:
- 截屏模块,把当前屏幕的像素信息抓下来。
- 理解模块,调用一个支持视觉输入的大模型,把屏幕图像和任务指令一起丢给它。
- 执行模块,模拟鼠标移动、点击和键盘输入。
- 控制循环,负责“截图给模型看,等它回复指令,执行指令,再截图确认”这个循环。
这套架构里,没有任何一个组件是特别新的东西。截屏用Pillow库自带的ImageGrab就能搞定,模拟鼠标键盘用PyAutoGUI,理解模块直接调用大模型API。核心的技术含量其实在于那个控制循环怎么设计,以及怎么防止模型瞎点。
2.2 为什么不直接用现成方案,而是走视觉这条路
这里得说一个很多人忽略的点。做GUI自动化,传统上有一条偷懒的路径:很多软件支持通过可访问性接口(Accessibility API)暴露内部控件树,比如Windows上的UI Automation。走这条路,你会拿到非常精确的控件名称和坐标,点击某个按钮就像直接调用函数一样可靠。但问题也很现实——这条路径只对支持这些接口的传统桌面软件有效,遇到网页、截图、自制渲染引擎的界面、游戏客户端,基本就抓瞎了。而且这类接口在不同版本、不同系统上的表现差异很大,维护成本极高。
而视觉方案的天花板更高。人怎么操作电脑,它就怎么操作电脑——靠眼睛看屏幕,靠大脑推理该点什么。所以GUI Agent从视觉出发,天然就能应对那些没有开放接口的软件。现在这一步之所以行得通,是因为大模型的界面理解能力已经足够强。你给它一张Word文档的截图,它不仅能认出菜单栏、工具栏,还能推理出“保存”按钮大概率在左上角。这种能力放在几年前,得专门训练一个CV模型才能勉强做到,现在直接开箱即用。
我自己的经验是,第一次跑通视觉方案的时候,你会产生一种“这竟然真的能行”的错觉。但请注意,这只是错觉,后面你会被现实毒打的。视觉方案的不确定性远高于传统控件的确定性,所以我们需要在控制循环里做文章,降低这种不确定性——这部分后面专门讲。
3. 手写核心代码:从截屏到执行的完整闭环
下面开始搭骨架。我不会给你一个巨大无比的工程文件,而是按照层来拆解,你照着敲一遍就能理解每一块在干什么。
3.1 感知层:先把屏幕变成模型能看懂的输入
感知层的任务很简单:截屏,然后把图像以合适的格式喂给模型。这里有两个细节要注意:截屏的分辨率和图片的编码格式。
先说分辨率。现在的屏幕动辄2K、4K,整张截图直接发给模型,一方面浪费token,另一方面模型在解析超大图片时反而容易忽略关键细节。所以我们可以做一次缩放,把图片的长边限制在1500像素左右。这个尺寸既能保留足够的界面细节,又能减少传输和解析的开销。实测下来这个数值比较平衡,如果你只需要操作某个特定软件的窗口,还可以裁剪得更狠一点,只截那块区域。
再说编码格式。模型API接受的一般是base64编码的图片数据。Pillow截图拿到的是Image对象,我们转换成JPEG或PNG,再base64编码。JPEG文件小、传输快,但文字边缘会有压缩伪影,对模型识别界面文字稍微有点影响。我的建议是:界面风格偏图形化、图片比较多时用JPEG;如果你的目标软件是文字密集型的编辑工具,用PNG更稳,识别率会有可感知的提升。
from PIL import ImageGrab import base64 import io def capture_screen(max_size=1500): # 截取整个屏幕 img = ImageGrab.grab() # 缩放,控制图片尺寸 if max(img.size) > max_size: ratio = max_size / max(img.size) new_size = (int(img.width * ratio), int(img.height * ratio)) img = img.resize(new_size, Image.LANCZOS) # 转成base64字符串 buffer = io.BytesIO() img.save(buffer, format="PNG") encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") return encoded, img.size注意,我这里把缩放后的尺寸也返回了。这个尺寸后面有大用:模型给出一串坐标时,指的是它看到的那张图上的坐标,而我们要把坐标映射回真实屏幕的实际尺寸上才能正确点击。这个映射关系如果不处理,你的Agent操作就会像喝醉酒的人一样点到奇怪的位置。
考虑到不同操作系统权限限制不同,Windows上直接ImageGrab就能截全屏,macOS、Linux上可能需要额外的权限配置。这一步没什么好的绕过方式,系统怎么要求就怎么给权限,否则截图会变成一张黑屏或者只截到你的桌面壁纸。
3.2 规划层:用结构化输出来约束模型的“想法”
规划层是整个Agent的大脑。我们给模型看的不是一张图加一句话,而是精心构造的一段提示词,要求它按固定的JSON格式返回操作指令。这一步极其重要,因为如果不加约束,模型可能会给你回复一大段文字,告诉你“我建议你点击左上角的保存按钮”,而不是直接告诉你该执行什么动作。我们必须把“建议”变成“指令”。
提示词里定义的动作类型,我建议你从这四个开始就够了:点击(click)、输入(type)、滚动(scroll)、等待(wait)。点击需要给出坐标,输入需要给出文本内容,滚动需要给出方向和滚动量,等待只需要一个时间值。这四个动作覆盖了绝大多数桌面软件的基本操作。
另外,必须告诉模型两个原则。第一个原则:如果屏幕上的信息不足以判断下一步怎么走,就返回一个特殊动作“unknown”,不要瞎猜。第二个原则:每次只允许执行一个动作,执行完之后会给你新的截图,你再决定接下来的动作。这两条规则是防止模型“放飞自我”的关键。
system_prompt = """ 你是一个GUI自动化助手。你会收到一张屏幕截图和一个任务描述,你需要输出下一步操作。 输出必须是严格的JSON格式,不要输出任何其他内容。格式如下: {"action": "click", "x": 100, "y": 200} {"action": "type", "text": "hello world"} {"action": "scroll", "direction": "down", "amount": 3} {"action": "wait", "seconds": 2} {"action": "unknown", "reason": "描述为什么无法确定下一步操作"} 规则: 1. 坐标是你看到的图片像素坐标。 2. 一次只输出一个动作。 3. 如果截图信息不足以判断下一步操作,输出unknown。 """用文本模型调用接口的时候,把这套提示词和图片base64一起传过去。现在的多模态模型基本都支持“图像 + 文本”的输入方式。你会得到一个字符串,用json解析库解析它,拿到动作类型和参数。我遇到过很多次模型不严格遵守JSON格式的情况——多了一行解释,或者用Markdown代码块包住了JSON。所以解析的时候要做一层容错处理,把反引号去掉,找到第一个“{”字符和最后一个“}”字符,中间的部分才拿去解析。
3.3 执行层:让鼠标键盘听话
执行层用的是pyautogui,这个库胜在简单直接。坐标映射那块要格外小心,上面说了模型看的是缩放后的图,如果我们不做逆变换,点就会错位。逆变换公式很简单:
- 真实坐标 = 模型输出坐标 × (真实屏幕宽 / 图片宽)
除了坐标映射,还有两个实操细节。第一个是点击前的停顿。真实的人操作电脑,鼠标从一个位置移到另一个位置是需要时间的,界面元素的响应也需要时间。程序操作太快,上一个操作引发的界面变化还没完成,下一个操作就来了,很容易踩空。所以每次执行动作前,我习惯性地加一个0.3到0.5秒的暂停。这个过程看起来不起眼,但能显著提升稳定性。
第二个细节是输入法的干扰。pyautogui的typewrite方法遇到中文基本是废的,它会按普通键盘键位去输入,打出来的是一堆乱码。处理中文字符的可靠方案是先拷贝到剪贴板,再模拟Ctrl+V粘贴。实测下来这个方案最稳,避免了输入法状态导致的种种问题。
import pyautogui import pyperclip def execute_action(action_data, screen_size, original_size): action = action_data["action"] scale_x = original_size[0] / screen_size[0] scale_y = original_size[1] / screen_size[1] if action == "click": real_x = int(action_data["x"] * scale_x) real_y = int(action_data["y"] * scale_y) pyautogui.click(real_x, real_y) elif action == "type": pyperclip.copy(action_data["text"]) pyautogui.hotkey("ctrl", "v") elif action == "scroll": direction = action_data["direction"] amount = action_data["amount"] # 注意:负数表示向下滚动 pyautogui.scroll(-amount if direction == "down" else amount) elif action == "wait": time.sleep(action_data["seconds"])3.4 控制循环:给Agent装上一个“确认-执行-确认”的保险丝
控制循环是整个系统里最容易被新手忽略、但也最值得花心思的地方。最简单的循环长这样:截屏,给模型看,拿动作,执行,再截屏。听着像是个人都会写,但实际跑起来你会发现,这个朴素的循环在现实中很容易陷入死循环,比如模型反复对同一个位置发出点击指令,或者明明任务已经完成了,但它还停不下来,继续在那里瞎操作。
因此,一个可用的控制循环至少要包含三样东西:
第一,任务完成判断。每次拿到新的截图后,除了让模型输出操作指令,我会再单独问一次模型:“任务是否已经完成?”,让它只是简短地回一个“是”或“否”。把这个判断做成独立的步骤,而不是让模型在生成动作时顺带判断,因为实践下来,“动作生成”和“完成判断”混在一起容易让模型产生幻觉,它会在任务进行到一半时突然觉得完成得差不多了。
第二,重复动作检测。维护一个最近五步的操作历史,如果模型连续输出的动作完全一致,比如一直点同一个坐标点,说明它卡住了。这时候不要继续执行,强制结束循环并报错。这个机制简单粗暴但非常有效,毕竟一个真实的人如果在电脑前反复点同一个地方,不是界面卡死了就是自己卡死了。
第三,最大循环次数。我给循环设置一个总上限,比如20步。超过上限不管任务有没有完成都停下来。这是个兜底策略,防止上面两道防线都失效时Agent烧掉你的显卡。
def run_agent(task_description, max_steps=20): history = [] for step in range(max_steps): screen_b64, view_size = capture_screen() action = ask_model_for_action(task_description, screen_b64) if action["action"] == "unknown": print(f"模型无法判断下一步,终止。原因:{action.get('reason', '未知')}") break # 检查重复动作 if len(history) >= 5 and all(h["action"] == action for h in history[-3:]): print("检测到连续重复动作,终止。") break execute_action(action, view_size, get_screen_size()) history.append(action) # 检查任务完成 done = ask_model_if_done(task_description, screen_b64) if done == "是": print(f"任务在第{step + 1}步完成。") break这段代码逻辑没问题,但有一个细节值得注意:我每次“是否完成”的判断用的是执行动作之前的截图,理论上应该是执行动作之后再截图来判断。我故意把它放在动作前,是因为少一次截屏。你会看到在真实运行中这种程度的“偷懒”其实影响不大,动作执行前后界面差异通常不大。不过如果你在做比较精确的操作,还是改成动作后判断更严谨。
4. 跑通第一个真实任务:打开记事本并自动输入内容
理论知识讲了半天,不跑一个真实任务等于白搭。这一节我带你把整个流程走一遍。任务很简单:打开Windows自带的记事本,自动输入一段话,然后把窗口最小化。之所以选记事本,是因为它打开速度快、界面元素少、不存在复杂的登录和弹窗干扰,非常适合作为第一个验证脚本。
4.1 任务定义和目标
任务描述就写一句话:“打开记事本,输入文字‘这是GUI Agent的第一次实操’,然后将窗口最小化。”
我们期望看到的现象是:脚本启动后,程序先按住Win键按下R打开运行对话框,输入notepad并回车,等记事本窗口出现后自动输入文字,然后点击窗口右上角的最小化按钮把它收起来。
4.2 完整可复现的运行脚本
为了让这个Demo能直接在你机器上跑出效果,我把几个关键环节都做了硬化处理。第一个环节是打开记事本。我直接用PyAutoGUI模拟快捷键,比让模型去点击开始菜单再搜索要可靠得多,毕竟模型在这一步很容易产生五花八门的操作方案。
import json import time import os def get_env_prompt(): return system_prompt def ask_model_for_action(task, image_b64): # 这里调用你选定的多模态模型API # 把system_prompt、task、image_b64拼成消息发送 # 返回解析后的dict pass def ask_model_if_done(task, image_b64): # 这里调用模型,让它只回复“是”或“否” pass def get_screen_size(): import pyautogui return pyautogui.size()上面细化到调模型接口的部分我用pass占位了,因为你用的模型服务不同,接口代码会有差异。核心思路就是你调用一个支持视觉输入的大模型,把system prompt、用户任务、图片base64三个部分传进去,拿到Return字符串后解析。我自己常用的是通用的OpenAI兼容接口,网上找一下你用的模型对应的调用方式,改一下请求函数就行。
4.3 实测记录与运行效果
第一次运行的时候,别指望一次就能成功。我的第一轮跑出来还是让我比较惊喜的,模型识别出了运行对话框里输入notepad后的回车操作,然后在新截图中看到了记事本窗口,最后在窗口右上角识别到了最小化按钮,整个过程大概花了15秒、6次循环。但注意,这是“一切顺利”的情况。
第二次我重新运行,模型在第一轮就翻车了:它在打开运行对话框后的截图上,把中间那段空白区域当成了输入框,直接点击了一下,然后模型误以为已经有输入内容了,直接按下回车打开的是运行指令的历史记录下拉框,整个流程乱套。这个案例非常有代表性,很能说明视觉Agent的一个核心毛病——界面上有很多可以点的地方,但模型对这些区域的语义理解还达不到人类的水平。
要改善这类问题,核心手段就是在截图上把鼠标指针移动到一个候选位置并“悬停”上去,很多界面元素在悬停时有高亮效果,模型识别起来会容易很多。不过这个优化细节我们放到后面下一节讲。目前的这个版本,如果你运行翻车,多试几次大概率能成功,这就是大模型方案“概率性成功”的典型体验。
5. 实操过程中的典型坑与解法:从炸裂到稳定
从我第一次跑通GUI Agent到现在,踩过的坑比想象中多得多。有些问题一眼就能看出来,有些坑简直让人怀疑人生。这一节挑几个有代表性的,结合排查过程讲清楚。
5.1 坐标错位:最常见也最容易忽视的坑
坐标错位问题的表象是你看到程序运行得很起劲,但鼠标点到的地方跟它“认为”的地方差了十万八千里,可能它本来想点窗口右上角的关闭按钮,结果点到屏幕正中间去了。
排查思路其实不复杂,你只需要在调用模型之前和拿到坐标之后各打印一次截图尺寸,对照一下就能发现问题所在。这类问题根源就是截图缩放后的坐标没有映射回原始屏幕坐标。我见过有人在这种问题上排查了整整两天,最后发现是缩放之后屏幕尺寸取错变量导致比例完全反了。这个坑防御其实特别简单,哪怕你嫌麻烦不加代码,每次截屏后才把图的尺寸和真正屏幕尺寸的比值打印出来人工核对一遍,也能避免绝大部分悲剧。
5.2 模型胡言乱语和JSON解析失败
多模态模型在当前阶段还不稳定,偶尔会输出一些跟指令完全不搭边的话。尤其是模型在没有足够视觉细节的时候,会倾向于脑补。我遇到过最离谱的一次是任务描述是打开计算器,模型给出的动作却是“请输入你要计算的内容”,然后它还真配了一个type动作。
对付这类问题,可以分三层来解决。第一层是在提示词层面采取强约束,明确告诉模型只能输出指定格式的JSON,其他任何解释都不被允许。第二层是做容错解析,如果解析失败尝试用正则抽取JSON片段,把模型包裹它输出的Markdown代码块标记剥离掉。第三层是设置兜底策略,三次解析失败或者动作类型不在白名单里就直接终止循环。宁愿任务做不完,也不能让程序乱点鼠标,这关乎安全问题。
5.3 界面加载延迟导致的点击失效
大部分人第一次调GUI Agent时都会碰到这个情况:程序用瞬移一样的速度发出指令、点击按钮,但界面元素还没渲染出来,导致点击落在空处。解决这个问题有两种思路。你要是图省事,就在每次动作执行之后加固定的延时,比如0.5秒,这种“无脑等待”简单有效但很浪费。你要是想做得聪明点,可以做一个简易的屏幕差异检测——动作执行后截一张图,跟执行前比较像素差异,如果差异接近零,说明界面没变化,可能还在加载中,那就多等一会儿。这个思路实现也不算复杂,用Pillow直接比较两张图的像素差异就行。
不过我得诚实一点,固定延时在实际使用中因为实现简单,占了上风,我也一直在用。AI Agent这个领域现在还是工程实践走在学术前面,“实用优先”是主旋律,以后有更好的办法再迭代。
5.4 误操作之后的安全恢复
GUI Agent跑在真实桌面上,操作是实打实的。万一模型发疯点到了删除文件、格式化之类的危险动作,后果很严重。所以强烈建议你第一次测试时用一个不太重要的虚拟环境或临时用户,反正多做一层隔离没坏处。同时,在代码层面给动作白名单加一道防线:高危动作强制拦截。
我自己的做法是维护了一个“禁区列表”,里面的坐标或动作类型一旦触发直接中止脚本并弹出警告。比如“ctrl+w”关窗口这种还好,你要是跑在浏览器自动化里,“ctrl+w”会关掉整个浏览器,你这个Agent连兜底的机会都没了。这只是个形象例子,重点是提醒你这东西跑起来失控的破坏力比你想象的大,隔离环境真的很重要。
6. 从能跑到好用:几个提升成功率的关键优化
如果上面的代码你已经跑通了,恭喜,你已经拥有一个最朴素的GUI Agent。但这个版本距离“能用”还有一段路。接下来分享几个我实际用下来对成功率提升最明显的优化手段。
6.1 局部截图裁剪与focus区域
不一定每次都把整个屏幕丢给模型。很多时候我们操作的目标只集中在某一个窗口或某一个面板里。与其给模型一大张含杂七杂八信息的图片,不如先把目标窗口定位出来,只截取这个窗口的区域。窗口定位用系统级的接口就可以实现,Windows上可以调用Win32 API来枚举窗口和获取窗口矩形。
在裁剪之后有个联动的好处:图片变小了,可以不做缩放或者缩放比例放低,模型的识别效果会好很多。而且裁剪之后坐标映射也简化了,模型的注意力集中在一个窗口里,识别和点选的准确率会有一个肉眼可见的提升。
6.2 引入“悬停预览”降低识别难度
屏幕上有一些区域本身就是“暗桩”,模型的判断可能不准,但让鼠标在对应位置悬停一瞬间,对应的按钮高亮、提示气泡出现,模型的识别率就上来了。这个技巧的实现代价极低,只是让pyautogui.moveTo过去停0.2秒再截屏。代价小收益高的优化我会优先做。
6.3 从“单步决策”升级为“计划-执行”两步式
我们目前为止的架构是让模型自己走一步看一步,每一步都要结合最新的截图来预判。这种方式的好处是灵活,缺点是没有长远眼光,容易在界面流转里迷路,同时每步都从屏幕上重新开始推理,既费token又费时间,还很累。
比较成熟的方案是改成两步式:在最开始,先给模型一张初始截图,让它基于任务描述先给出一个多步骤计划,比如先打开应用,再找到输入框,再输入内容,最后保存并关闭。然后在执行计划中的每一步时,用具体的截屏让模型基于当前计划步骤做决策而不是从零开始。这种方式比原先更稳定,因为每一步模型的行动边界被上下步骤约束住了,出错概率也下降了不少。
6.4 人类反馈回路
你还可以在Agent执行中插入一个人工确认机制。最简单的方式是:模型每次输出动作,你都弹一个小框展示“即将执行的动作”,由人来点确认或否决。否决的样本记录下来,收集到一定量后可以拿去微调模型,也可以作为实时决策的依据。当然这一步已经超出“手搓”的范畴了,但如果你做的Agent是用于重要操作场合,这套人工确认流程比任何优化都管用,算是工程上的兜底手段。
7. 写在最后的一点个人实操体会
做GUI Agent这段时间,我最大的感受是:它其实是在拼凑一个正在慢慢接近“人类操作方式”的系统。模型负责理解,脚本负责执行,出错的时候你要学会容忍一定的“概率性成功”。它不像写传统程序那样能做到确定性输出,每一次运行都像在抽卡,但你通过优化可以让中奖概率从六成涨到九成,已经能解决不少实际问题了。
我个人的习惯是,每次准备让Agent跑一个操作任务前,先把任务描述写清楚,按“打开某软件→在某位置输入什么→点击哪里→预期看到什么”这种结构拆条。这个习惯让我减少了大量来回调提示词的次数,现在我自己搭的这类小工具,已经能稳定帮我处理一些重复的报告整理和软件操作演示了。
如果你也打算上手试试,我给你几个最后的建议。第一,从最简单的场景开始,不要一上来就挑战高难度软件,先拿记事本和计算器练手。第二,日志一定要记录,每一步截屏、每一步模型返回的原始输出最好都留痕,因为你会频繁碰到同一个问题:上次效果不错,这次怎么开始发疯?有日志和截图,排查起来顺手很多。第三,不要迷信任何一个框架,已有的轮子可以借力,但关键的核心数据通路值得你自己从头梳理一遍,这对你理解和改造整个系统都有巨大帮助。
这个方向我相信还远远没到天花板,你手搓的这个版本虽然朴素,但已经是好多年前需要一整个团队才能实现的系统雏形了。接下来,就靠你在实际任务里拧螺丝了。