ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手写GUI Agent:感知、决策、执行三行代码实现界面自动化

从零手写GUI Agent:感知、决策、执行三行代码实现界面自动化

GUI Agent这东西,说到底是给大模型装上一双眼睛和一只手,让它能看着屏幕、分析当前界面,再像人一样点击按钮、输入文字,把一件具体任务办完。最近我一直在折腾怎么从0到1手搓一个最小可用的GUI Agent,试过各种现成框架和自研方案,最后发现最稳的学习路径反而是不用框架,从感知到决策再到执行,每个环节自己写一遍。这篇就把搭建思路、核心代码和踩过的坑一次性讲清楚,保证是那种"有手就行"的实操版本。

先说它适合谁。如果你每天要做大量重复的界面操作,或者正在做UI自动化测试但受够了维护CSS选择器和XPath,又或者只是想把大模型API玩出点新花样,这篇值得读完。不需要你有深度学习基础,懂Python基本语法就能跟下来。我尽量把每一步背后的原因也讲明白,免得你只是照抄代码,换个场景又不会了。

1. GUI Agent到底是什么,它和传统自动化有啥区别

1.1 传统脚本是"复读机",GUI Agent是"临时工"

可以这么理解:你以前写过的自动化脚本,本质上是复读机。你把操作步骤提前录好,录的时候就定死了要点击坐标(500, 400),要输入某个字符串,然后脚本就一遍遍地重复。这种模式最大的问题在于:界面不是一成不变的。窗口位置会变、按钮会挪、弹出的提示框时长不定,只要其中一个环节对不上,脚本就从头错到尾。

GUI Agent的思路完全不一样。它每一次操作之前都会先截一张当前屏幕的图,把这张图连同你的目标一起交给大模型去分析,由模型判断"现在该做什么",然后才动手。也就是说,它不是按照固定剧本走,而是每走一步都重新观察、重新决策。按钮位置变了就重新找,弹窗出来了就先处理弹窗,真正像一个临时工入职第一天——先看看环境再干活,而不是背熟了工作手册就闷头猛干。

举个具体例子,以前用传统脚本打开记事本,最省事的方案是模拟Win+R呼出运行框,然后输入notepad回车,因为这个流程不需要识别界面、不需要知道图标在哪。但GUI Agent可以直接看桌面上的"记事本"图标,用鼠标像人一样点开它。这个差别本质上是"写死流程"和"实时规划"之间的差别:前者只要环境出现一点点变化就失效,后者每次都能根据当前界面重新生成步骤。

当然,光说概念没用,得看它能干什么。正因为决策是每步实时做的,GUI Agent在UI自动化测试里特别能抗界面变化,传统测试框架最怕页面重构,改一次版就要跟着改半天定位器,而GUI Agent每次看图再决定操作,窗口挪了位置、按钮改了个样式,它依然能凭视觉把目标找出来。第二个场景是跨应用、跨窗口的重复劳动,比如每天要把A系统里的报表数据搬到B系统,这类机械操作没有统一接口,让Agent充当"胶水层"正合适。第三个是教学演示,用Agent现场操作桌面软件给人看,比讲一堆抽象架构直观得多。

1.2 它能做多少事,取决于你的想象力

坦白讲,GUI Agent的能力边界完全取决于大模型对界面的理解能力。我测试下来,常见的桌面软件、网页、系统设置面板这些界面,绝大多数情况下模型都能看懂布局,知道哪些是不可点击的文本、哪些是按钮、哪些是输入框。对英文界面和中文界面的识别都还行,尤其是中文,用现成的多模态大模型基本不存在语言障碍。

但它也不是万能的。纯图形化的界面,比如画图软件里那种没有文字标签的图标按钮,模型就有可能猜错。还有那种自定义渲染的软件,界面在模型眼里可能是一团奇怪的色块,这时候就需要靠下一章讲的架构来补救了。所以先别急着拔高期望,把它理解成一个"能看屏幕、能动手的实习生"就好,重要操作还是需要你来兜底。

2. 先把架构想清楚:感知、决策、执行三层

2.1 为什么一定要拆成三层

很多人一上来就找框架,结果被框架的抽象层绕晕。其实GUI Agent的核心架构非常简单,就是三条流水线:感知、决策、执行。

感知层的职责是解决"怎么看到"的问题:把屏幕从硬件层面变成程序能处理的数据。对视觉模型来说,它就是一张图片;对文本模型来说,它还可以是界面元素的文字和坐标清单。决策层的职责是解决"怎么想"的问题:拿到感知层的数据,结合用户目标,输出下一步的具体指令,这一层现在几乎都是交给大模型来做。执行层的职责是解决"怎么动"的问题:把模型输出的抽象指令翻译成真实的鼠标点击、键盘输入和快捷键动作。

拆成三层最大的好处是每一层都能独立替换。今天用截图给模型看,明天想改成OCR提取文字坐标,只需要动感知层;今天用这家模型API,明天想换成另一家,只需要动决策层的调用代码;想从pyautogui换成更底层的系统事件注入,也只需重写执行层。接口设计得干净,后面做功能迭代会非常轻松。

2.2 技术选型:为什么是这些库

感知层我很推荐用mss加Pillow的组合。mss是一个跨平台截图库,截图速度比Pillow自带的ImageGrab快不少,连续截图场景下不容易卡顿。Pillow则负责把截图转成PIL Image对象,方便后续缩放、编码、保存。这两个库组合起来,几行代码就能完成一次高质量截屏。

决策层不需要自己写模型,直接接一个支持图片输入的多模态大模型API,让模型根据截图输出结构化指令。这里没必要纠结模型品牌,你的服务商只要提供OpenAI兼容的调用格式,代码就几乎不用改。入门阶段用现成API先把链路跑通,远比一开始就折腾本地模型省时间。

执行层我选了pyautogui,理由很朴素:跨平台、API简单、覆盖面广。点击、拖拽、输入文字、按快捷键,一个小Demo需要的操作它全都有。更关键的是,它模拟的是系统级鼠标键盘事件,而不需要去拿窗口句柄、不需要理解每个控件的内部ID,这正好匹配我们想要的"像人一样操作界面"的模式。

pip install mss pillow pyautogui requests

Python 3.9以上版本就行,不需要GPU,不需要深度学习框架,不需要浏览器驱动。如果是在macOS上跑,首次运行会被系统询问"屏幕录制"和"辅助功能"权限,需在系统设置里给对应的终端或IDE打开,Windows下一般不用特别配置,但有DPI缩放时需要加两行代码处理坐标,这个放到下一章细说。

3. 三个核心模块,逐个拆开讲

3.1 感知层:先让程序"看见"屏幕

import mss from PIL import Image def capture_screen(): with mss.mss() as sct: monitor = sct.monitors[1] shot = sct.grab(monitor) img = Image.frombytes("RGB", shot.size, shot.rgb) return img

这段代码获取主屏画面,转成PIL的Image对象。mss的monitors[1]代表主显示器,如果你的目标窗口在副屏,就需要遍历monitors列表找到对应的显示器,或者改成指定区域截图。我在Demo阶段只用主屏,已经能覆盖大部分场景。

在把图片发给大模型之前,强烈建议先缩小图片再压缩。界面识别通常不需要原始分辨率,把图像缩小一半,再以JPEG格式、质量80编码,体积能降到原来的十分之一二。这个操作直接影响请求延迟和费用,多模态API是按图片分辨率计费的,图片越小单位成本越低,而且模型对小幅缩放的容忍度很高,识别准确率几乎不受影响。

img = img.resize((img.width // 2, img.height // 2), Image.LANCZOS) import io import base64 def image_to_base64(img): buf = io.BytesIO() img.save(buf, format="JPEG", quality=80) return base64.b64encode(buf.getvalue()).decode()

这里有个新手必踩的坑:Windows的DPI缩放。系统显示缩放设为125%或150%时,截图得到的图像像素尺寸和pyautogui的鼠标坐标可能不在同一坐标系里,模型看到按钮在图像坐标(x, y),pyautogui却点到了(x, y)对应的另一个位置。解决办法是在脚本最开头让进程声明DPI感知:

import ctypes try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: pass

加了这行之后,pyautogui的坐标和截图像素就能对齐。我第一次没加,结果模型说"点击桌面计算器图标",鼠标却点了旁边的回收站,排查了半天才意识到是缩放问题。

3.2 决策层:让大模型站在"上帝视角"给指令

感知层把屏幕变成图片后,决策层的任务是结合用户目标和图片内容,输出下一步操作。这里最关键的其实不是模型选多强,而是提示词和返回格式的设计。我建议要求模型严格返回JSON,并且不要输出任何解释文字。可以把action字段限制成几个枚举值:click、type、press、done,每种动作对应固定的参数结构。

SYSTEM_PROMPT = ( "你是我的电脑操作员。你会收到屏幕截图和用户目标,请判断下一步操作并输出严格JSON。" "可选格式:" '{"action": "click", "x": 100, "y": 200}' '{"action": "type", "text": "hello"}' '{"action": "press", "keys": ["ctrl", "s"]}' '{"action": "done", "reason": "说明理由"}' "约束:坐标以图片左上角为原点,使用整数;如果一次操作完成不了目标,输出当前最合理的下一步;" "只有明确看到目标完成时才输出done;不要输出JSON以外的任何内容。" )

为什么要强调"不要输出JSON以外的任何内容"?因为模型偶尔会在JSON前面加一句"好的,我来分析一下"或者在结尾补一句说明,json.loads直接解析就会失败。把这句话写死进提示词,能明显降低解析出错率。

另一个小技巧:调用接口时把temperature参数设为0。虽然不同模型对这个参数的敏感度不一样,但对大多数模型来说,设为0可以显著减少输出的随机性。对GUI Agent这种高频决策场景,稳定优先,探索其次。

import requests def ask_llm(img, goal): payload = { "model": MODEL, "temperature": 0, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": [ {"type": "text", "text": "用户目标:" + goal}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_to_base64(img)}"}} ]} ] } resp = requests.post(API_URL, headers={"Authorization": f"Bearer {API_KEY}"}, json=payload) data = resp.json() content = data["choices"][0]["message"]["content"] return content

API_URL、API_KEY、MODEL这三个变量填你自己的即可,只要服务商兼容主流接口格式,这套调用代码基本通用。返回的content是字符串,所以我还需要做一步JSON解析,这个解析逻辑放到第4章的完整代码里演示。

3.3 执行层:模拟鼠标和键盘动作

import pyautogui pyautogui.FAILSAFE = True pyautogui.PAUSE = 0.3 def execute(action): atype = action.get("action") if atype == "click": pyautogui.click(int(action["x"]), int(action["y"])) elif atype == "type": pyautogui.typewrite(action.get("text", ""), interval=0.02) elif atype == "press": pyautogui.hotkey(*action.get("keys", [])) elif atype == "done": return True return False

第一行pyautogui.FAILSAFE = True是保命设置。它的机制是:当你把鼠标甩到屏幕左上角时,程序会立刻抛出一个FailSafeException,终止当前脚本。GUI Agent谁也不敢保证100%不出错,万一模型开始乱点,你可以用这个动作紧急刹车,这是一个必须养成的习惯。

pyautogui.PAUSE = 0.3是给每次操作之间加固定间隔,防止操作太快导致系统还没反应过来就进行下一步。typewrite的interval参数控制每个字符之间的输入间隔,0.02秒对普通文本输入是够用的,如果遇到某些软件丢字,可以稍微调大到0.05。

执行层还有一个容易忽略的细节:typewrite只是把文字打出来,焦点在哪里就输出到哪里。如果界面焦点不在输入框,字就打到了别的地方。稳妥的策略是让模型在输入文字前先click一下目标输入框,把"点击输入框"和"输入文字"拆成两步操作,这样即使上一步状态异常,Agent也能及时修正。

4. 串起来:一个能直接跑的最小Demo

4.1 演示任务设计

为了把核心链路跑通,我设计了一个足够小但覆盖主要动作类型的任务:用鼠标点击打开记事本,在编辑区输入一行字,然后按Ctrl+S调出保存对话框,再按Esc关闭。这个任务同时包含点击、输入文字、快捷键三类动作,足够验证感知、决策、执行三层都在正常工作。

如果把任务设计得太简单,比如只让Agent点一下桌面图标,那就验证不了循环决策的能力;设计得太复杂,比如让它写一个完整文档,又不利于Debug。从点击图标到输入再到快捷键,就是一个恰到好处的"最小闭环"。

4.2 主循环与安全阀

主循环的逻辑非常直观:截图、问模型下一步要干嘛、执行、再截图,循环往复,直到模型输出done或者达到最大步数。

最大步数这个限制必须加。原因很简单:大模型不是神,它完全可能陷入"反复点同一个位置"的死循环,如果没有步数上限,程序就永远停不下来。我习惯默认设10步,实际跑这个任务一般在5步左右就能完成,10步的余量足够应对一些波折。

再一个是人工确认机制。Demo阶段强烈建议每步执行前都按一下回车确认,这样即使模型判断错了,你也有机会阻止。我常用"手动挡模式"来形容这个设计,等后续跑熟了再切成"自动挡"也不迟。

4.3 完整代码:复制就能跑

import io import json import base64 import ctypes import time import mss import requests import pyautogui from PIL import Image # 让坐标系统和截图对齐 try: ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: pass API_URL = "你的接口地址" API_KEY = "你的密钥" MODEL = "你的模型名" GOAL = "打开记事本,输入 hello world,按 Ctrl+S" pyautogui.FAILSAFE = True pyautogui.PAUSE = 0.3 SYSTEM_PROMPT = ( "你是我的电脑操作员。你会收到屏幕截图和用户目标,请判断下一步操作并输出严格JSON。" "可选格式:" '{"action": "click", "x": 100, "y": 200}' '{"action": "type", "text": "hello"}' '{"action": "press", "keys": ["ctrl", "s"]}' '{"action": "done", "reason": "说明理由"}' "约束:坐标以图片左上角为原点,使用整数;如果一次操作完成不了目标,输出当前最合理的下一步;" "只有明确看到目标完成时才输出done;不要输出JSON以外的任何内容。" ) def capture_screen(): with mss.mss() as sct: monitor = sct.monitors[1] shot = sct.grab(monitor) img = Image.frombytes("RGB", shot.size, shot.rgb) img = img.resize((img.width // 2, img.height // 2), Image.LANCZOS) return img def image_to_base64(img): buf = io.BytesIO() img.save(buf, format="JPEG", quality=80) return base64.b64encode(buf.getvalue()).decode() def ask_llm(img, goal): payload = { "model": MODEL, "temperature": 0, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": [ {"type": "text", "text": "用户目标:" + goal}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_to_base64(img)}"}} ]} ] } resp = requests.post(API_URL, headers={"Authorization": f"Bearer {API_KEY}"}, json=payload) data = resp.json() content = data["choices"][0]["message"]["content"] start = content.find("{") end = content.rfind("}") action = json.loads(content[start:end + 1]) return action def execute(action): atype = action.get("action") if atype == "click": pyautogui.click(int(action["x"]), int(action["y"])) elif atype == "type": pyautogui.typewrite(action.get("text", ""), interval=0.02) elif atype == "press": pyautogui.hotkey(*action.get("keys", [])) elif atype == "done": return True return False def run(goal=GOAL, max_steps=10): for step in range(max_steps): print(f"--- 第{step + 1}步 ---") img = capture_screen() action = ask_llm(img, goal) print("模型决定:", action) confirm = input("回车执行,输入n跳过,输入q退出: ") if confirm.strip().lower() == "q": break if confirm.strip().lower() == "n": continue if execute(action): print("任务完成:", action.get("reason", "")) break time.sleep(1) else: print(f"达到最大步数{max_steps},手动中止") if __name__ == "__main__": run()

复制到你的Python环境,填好三个变量就能跑。建议先在桌面上保持一个干净环境,只留记事本图标,这样模型判断更容易。

这段代码里有几个细节说一下。JSON解析我用的是find("{")和rfind("}")而不是直接json.loads(content),因为模型偶尔会在JSON外加一句说明文字。从第一个左花括号截到最后一个右花括号,能最大程度容忍这类干扰,这是我在测试中验证过的最稳兜底写法,比反复要求模型"严格按照格式"更可靠。

图片缩小一半也是基于经验。界面操作不追求像素级精确识别,缩小后模型依然能看懂按钮位置和文字,而请求体积小了一大截,响应速度快得多。如果遇到某些细小的图标识别不出来,可以临时把缩放比从2改成1.5,或者直接用原图对比测试。

4.4 实际跑起来是什么感觉

第一次跑的时候,我预期模型会先点一下桌面图标,再点一下编辑区,然后打字。实际上第一轮它就准确找到了桌面上的"记事本"图标,输出了click指令;第二轮它看到记事本窗口已经打开,判断应该点击编辑区空白处;第三轮它决定点击输入区并输入文字;第四轮按了Ctrl+S;第五轮按Esc关闭了保存对话框。全程没有多余步骤,说明在视觉模型眼里,桌面图标、输入框、弹窗这些元素确实是可以辨认的。

不过也得泼盆冷水:这并不代表每次都能一次成功。运行环境复杂一点,比如桌面上有其他窗口遮挡,或者目标图标被折叠进任务栏,模型就可能给出错误判断。所以Demo阶段保持人工确认真的很有必要,我至今跑新环境时仍然保留确认步骤,等任务连续成功跑过几次,才会考虑切到自动挡。

5. 踩坑实录、常见问题与进阶玩法

5.1 最常遇到的几个问题速查

问题现象直接原因解决办法
截图里的位置和鼠标点下去的位置对不上Windows DPI缩放未处理脚本开头调用SetProcessDpiAwareness
模型返回的JSON解析失败模型在JSON前后加了说明文字用find/rfind截取JSON段,temperature设为0
点击太快,界面还没加载完缺少等待策略执行后sleep 1秒,或检测目标出现再继续
模型反复点同一个地方对界面状态的理解出错加入步数上限,人工确认,用OCR辅助定位
多个显示器时坐标混乱截屏只截了主屏,操作却在副屏发生按需要截取对应显示器,统一坐标基准

除了表格里的这些问题,还有一类问题出在权限上。macOS上跑pyautogui,终端或IDE需要在"系统设置 -> 隐私与安全性"里同时开启"屏幕录制"和"辅助功能"两项权限,缺一个都会出现脚本安静地失败,而且没有任何报错。我第一次遇到时排查了很久,最后发现是权限没开全,开了就好了。

Linux下如果桌面环境是Wayland,pyautogui的全局模拟点击也可能不生效,切回X11会话基本能解决。实在不行,退到Xvfb虚拟显示器里跑也是一种思路,只是看不到真实桌面。

5.2 想跑得更稳,可以这样优化

第一个建议是别让模型直接输出精确坐标。多模态模型对像素位置的估计并不精确,让它输出"点击标题为记事本的元素"这类语义操作,再通过OCR或者视觉定位工具把语义翻译成真实坐标,往往更稳。这样模型的负担更小,准确率也能上去。

第二个优化是引入OCR文本地图。界面里几乎每个按钮都有文字,先调本地OCR比如RapidOCR把整屏文字和坐标提取出来,形成一份"文字清单",再把这份清单交给普通大模型去决策。优点是不用每次传一张大图给API,成本和延迟都大幅降低,对传统软件界面特别有效。缺点是纯图形图标识别不了,这时候可以用图标模板匹配来兜底。

第三个优化是加入记忆机制。让模型在决策时带上前面几步的简要历史,比如"上一步我点了一个图标,但界面没有任何变化",模型就会意识到可能点错了,转而尝试其他位置。这个在纯无状态的单步循环里是做不到的,但对提升任务成功率非常有效。

第四个优化是窗口级截图。与其每步都截全屏,不如先定位目标窗口的边界,只截窗口区域。这样图片清晰度更高,干扰更少,模型更容易聚焦。定位窗口在Windows下可以用系统API拿窗口矩形,在macOS下可以用辅助功能接口。代码量会增加一些,建议等你的Demo跑顺手了再考虑。

5.3 几句真实体会

我个人感觉,GUI Agent听起来很高端,但剥开以后就是"截图交给模型、听模型的话、帮忙点一下鼠标"这三板斧。真正决定它能跑多稳的,不是框架选得多高级,而是你对提示词和接口的打磨程度。多模态模型对界面的理解能力在快速进步,但现阶段它依然会犯错,所以安全阀、步数上限、人工确认这些机制一个都不能省。

我建议你从自己日常工作中最烦的一个重复界面操作入手,比如每天要导数据、导报表、填表单这种,先做一个只解决单一任务的Agent。目标越小越好,任务越具体,越容易把决策质量调到稳定。等这个跑顺了,再往多任务、多应用方向扩展。这条路走下来,你对"AI如何理解真实世界"的理解会比刷十篇教程都深。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进