ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

构建桌面 Agent:从视觉理解到自动化操作的技术实践

构建桌面 Agent:从视觉理解到自动化操作的技术实践

1. 从痛点开始:为什么我会去写一个桌面 Agent

半年前我每天都在重复做一类事:从不同的来源收集资料,打开某个网站,填表格,整理下载目录,再给一批文件批量改名。这些事情单看不难,但架不住量大、重复度高、规则还经常变。我试着用传统自动化脚本去顶,结果每次网站改个版、按钮换个位置,脚本就要从头调一遍坐标和选择器,维护成本比手动干还高。

后来大模型火起来之后,我突然意识到思路可以换一下:与其把每一步怎么点写死,不如让模型看懂屏幕,自己去决定下一步怎么操作。这就是我现在手上这个项目的起点,我给它起名叫 Ally。Ally 是一个跑在桌面上的 Agent,核心玩法非常简单——用户用自然语言说一句“帮我把下载文件夹里最近三天的安装包归类”,Ally 就自己截图、看图、规划步骤、操控鼠标键盘把事办完。

这篇文章我想完整记录一下 Ally 是怎么跑的:从整体设计到代码结构,从跑通第一个任务到中间踩过的各种坑。如果你也想做一个类似的桌面 Agent,或者只是想搞清楚这类项目到底是怎么运转的,下面的内容应该能帮你省掉不少弯路。

1.1 传统自动化的痛点,以及 Agent 为什么是另一条路

先说传统自动化为什么难维护。我早期写过不少点击类脚本,核心逻辑就是“找到控件 → 触发动作”。听起来简单,但控件不是总找得到的。有的软件用自绘界面,根本不给系统暴露标准的控件信息;有的网页是动态渲染,等待时间差一秒,后面的步骤全崩;还有的窗口会遮挡、会弹提示,一个意外就把整个流程卡死。最要命的是,规则本身也是活的,业务逻辑一变,脚本里的判断条件就得跟着改。

Ally 换了一种方式处理这个问题。它不关心某个按钮在系统层级里的“控件 ID”是什么,而是直接看屏幕上的画面。人类怎么操作电脑,它就从视觉层面去模拟:看到文字,知道它代表哪个区域;看到按钮,知道大概能点哪里;根据当前界面状态,决定下一步做什么。这套思路的好处在于,绝大多数图形界面对人类是友好的,视觉识别天然跨应用、跨平台,不需要每个软件都专门适配。

当然,这并不代表视觉方案是万能的。它带来的新问题是:模型看到的是一张像素图,怎么把它和“点击某个坐标”这件事对应起来?这一步是整个桌面 Agent 的技术核心,后面我会详细讲。

1.2 技术选型的权衡:视觉理解优先,控件树作为补充

我在选型的时候其实在两条路上纠结过。第一条是走系统辅助功能接口,也就是读取应用的控件树,拿到的信息结构化程度高,定位精度也高;第二条是纯视觉思路,截图后用模型做界面理解。

最后我选了视觉为主、控件树为辅的混合方案。原因是实际测试下来,很多常用软件对辅助功能接口的支持并不好。一些用网页壳打包的软件,控件树里要么信息缺失,要么是一堆无法理解的节点;反而是截图画面里,按钮上的字、图标的位置一眼就能看出来。视觉方案覆盖面广,代价是需要额外处理好坐标换算、多显示器、缩放比例这些细节。

用一句话概括我的取舍:能看懂画面,就能操作一切;掌握控件树,只是在画面看不清时多一条路。后续 Ally 的每一次点击、拖拽、输入,追根溯源都是“图像 → 坐标 → 动作”这条链路。

2. 拆开看 Ally 的四个模块:调度、感知、执行、记忆

很多人第一次看桌面 Agent 的代码会觉得乱,因为所有功能混在一起:又要截图,又要识别,又要点击,还要惦记任务做到哪一步了。我在写 Ally 时做了一件很重要的事,就是把整个系统按职责拆成四个模块。模块之间只通过标准的数据结构通信,这样任何一个模块出问题,都可以单独替换和调试。

2.1 四个模块各自的职责边界

模块职责关键产出
调度器接收用户目标,拆解任务步骤,维护执行状态结构化的步骤列表
感知层截屏、OCR 识别文字、定位可操作元素元素坐标与界面状态描述
执行层执行鼠标点击、键盘输入、窗口切换操作系统层面的输入事件
记忆层保存短期任务上下文、执行历史、错误日志可回溯的状态快照

调度器是“大脑”,负责把一句自然语言目标变成一串可执行动作。我最初做过一个版本,让模型直接输出“点击坐标序列”,结果发现完全不可用:一旦界面跟模型预期的不一样,后面所有坐标全部白费。后来改成让调度器输出的是“高级动作指令”,比如“在左上角区域查找文件管理窗口”“如果看到名称为 xxx 的文件夹则双击”,感知层再去把指令解析成具体的屏幕坐标。这样层级一分开,容错能力立刻上来了。

感知层是“眼睛”,做的事情比想象中多。它不只是截图,还要做区域划分、文字识别、相似图标匹配,以及最重要的——把识别结果映射回屏幕坐标系。例如,OCR 识别出一段文字后,拿到的是文字所在的像素框,这个框的中心点就是后续要点击、双击或者拖拽的目标位置。模型看到的是语义,执行层需要的是坐标,感知层就是这两者之间的桥梁。

执行层则是“手脚”。为了让动作足够可控,所有输入事件都会经过统一接口,再分发到系统层面去执行。每次动作完成后都会记录日志,包括执行时间、目标坐标、动作类型和最终结果。这点在排障时非常有用。

记忆层负责回答“我们刚才干什么了”。它会记录当前任务的上下文、上一步识别到的界面状态、以及哪些尝试已经失败过。Agent 不像普通函数,它运行在动态环境里,如果没有记忆,就会像鱼一样只有七秒记忆,第二步就把第一个目标忘了。

2.2 工作循环:Observe-Act-Loop 的落地方式

Ally 的运行主体是一个循环,简单来说就是反复执行“观察 → 思考 → 行动 → 再观察”这个闭环。伪代码大概长这样:

def run_agent(goal: str): context = memory.create_context(goal) for step in range(max_steps): screen = perception.capture_screen() elements = perception.find_interactive_elements(screen) planned_action = scheduler.next_action(goal, context, elements) if planned_action.is_finished(): return context.success() if planned_action.is_aborted(): return context.aborted() execution.execute(planned_action) context.record(planned_action, screen)

这个循环里有三个细节值得展开。

第一,为什么每步都要重新截图?因为桌面环境是实时变化的。点击一个按钮之后,弹窗可能晚半秒才出现;你以为输入框已经有了,实际上页面还在加载。每步拿最新画面做决策,比一次性规划全部动作要稳健得多。

第二,为什么需要 max_steps 上限?如果没有这个限制,模型在一个错误状态里反复打转,会一直执行到天荒地老。我设的默认值是 20 步,大多数任务在 10 步以内能完成,超过 20 步基本可以断定进入死循环了。

第三,为什么记忆层要保存每一步的屏幕快照?这相当于给 Agent 装了行车记录仪。一次任务跑完,如果结果不对,我可以把整个操作回放一遍,看它是在哪一步产生误判的。没有这些快照,出了错根本无从下手。

2.3 关键参数设置:让 Agent 既快又稳

跑桌面 Agent 不只是调用模型接口这么简单。下面这组参数是我多次调优后比较稳定的一套配置,适合做参考:

参数推荐值设置理由
模型 temperature0.1桌面操作需要确定性,温度太高会随机乱点
单次截图分辨率与屏幕缩放对齐避免坐标换算漂移
OCR 置信度阈值0.8 以上低于这个值识别出的文字不可靠
动作间隔0.3 - 0.5 秒给界面响应留出时间,太快容易操作失败
最大执行步数20防止死循环
失败重试次数2超过两次不再重试,转而向用户求助

最难调的是动作间隔。太小了,界面上一个动画还没播完,第二次点击就已经发出去了;太大了,整个任务慢得让人失去耐心。0.3 秒是我在大部分场景下测出来的经验值,弹窗、动画多的场景我会主动调成 0.5 秒。

3. 跑通最小闭环:从环境搭建到第一次点击

理论说再多,不如动手跑一遍。这一节我完整记录 Ally 从零到一跑通“点击一个按钮”这个最小闭环的过程。不要小看这一步,它包含了桌面 Agent 所有关键环节:截图、识别、定位、点击、确认结果。

3.1 前期准备:三件事必须做到位

环境准备上,我没有用太复杂的东西。一位同事建议我用的方案如下:Python 3.10 及以上版本建一个独立虚拟环境,安装截图、OCR、自动控制相关的通用库。这三个库的选择很关键,截图的方式决定了能否拿到干净的像素数据;OCR 决定了识别的准确率;自动控制库决定了鼠标键盘事件能不能被系统正确接收。

然后是系统权限。桌面自动化绕不开权限问题:在 macOS 上需要给终端授权辅助功能权限,在 Windows 上需要确保屏幕录制权限可用。这一步最容易被忽略,我之前曾调试了半小时,代码没问题,就是鼠标没动静,最后发现是权限没开。

最后做一个冒烟测试:让 Ally 截一张当前屏幕图,标注出识别到的所有可点击元素,输出成 JSON 文件。如果这一步能看到完整的元素列表,说明感知链路是通的,可以进行下一步。

3.2 核心代码拆解:截图、识别、定位、点击

最小闭环的代码逻辑并不复杂,我用伪代码把核心部分写在这里。

# 1. 获取当前屏幕画面 def capture_screen(): image = screen_capture.capture() return image # 2. 在画面中识别所有带文字的可交互区域 def recognize_text(image): boxes = ocr_engine.extract_text_boxes(image) elements = [] for box in boxes: if box.confidence >= threshold: elements.append({ "text": box.text, "center_x": box.center_x, "center_y": box.center_y, "bounds": box.bounds }) return elements # 3. 根据目标文字找到对应的点击坐标 def find_element_by_text(text, elements): for element in elements: if text in element["text"]: return element return None # 4. 执行点击动作 def click_element(element): mouse_control.move_to(element["center_x"], element["center_y"]) mouse_control.click()

这段代码里有一个隐藏的关键点:屏幕坐标必须经过缩放补偿。如果你的系统开启了显示缩放,截图分辨率是 1920x1080,实际逻辑分辨率可能只有 150%,这时候如果不做换算,点下去的位置就会偏离。Ally 在初始化阶段会读取当前显示器的缩放比例,对所有坐标统一做一次变换,彻底规避这个问题。

定位到目标之后点击,是不是就完事了?远没有。我还会再加一个“确认步骤”:点击之后重新截屏,检查目标区域的状态是否发生了变化。比如目标是“点击保存按钮”,那点击后应该出现“已保存”的提示,或者按钮变成禁用状态。这一步能证明动作真的发生了效果,而不是自我感动。这也是桌面 Agent 和我以前写的死板脚本最大的不同——它有反馈闭环。

3.3 安全机制:让它能干活,也得让它知道什么时候停

一个能自己动鼠标键盘的程序,安全问题永远要排在第一位。Ally 从一开始就内置了三层防护。

第一层是“允许区域”白名单。Ally 只会在用户预先画定的窗口或屏幕区域内执行操作,一旦感知到鼠标要移出这个范围,立刻暂停并请求确认。这样即使模型产生幻觉,想点一个奇怪的按钮,也不会造成大范围影响。当然,这需要在信任和安全之间做取舍,但我觉得对于个人桌面助手来说,这种保守策略是对的。

第二层是全局急停快捷键。我设置了一个组合键,任何阶段按下,当前循环立即终止,所有未执行的动作取消。听起来很简单,但真到模型失控乱点的时候,这个快捷键能救你无数次。

第三层是操作日志。Ally 每执行一个动作,都会在本地记录一条结构化日志,包含动作类型、坐标、识别到的界面元素、执行结果。任务出错时,我可以顺着日志一步步复盘,而不是看着一片混乱的桌面猜发生了什么。

4. 让任务跑起来:两个场景的完整运行记录

有了最小闭环,下一步就是接真实任务。这一节我选了两个有代表性的场景,完整记录了任务是怎么被拆解、执行和收尾的。这两个场景都不是特别复杂,但足以展示桌面 Agent 的实际价值。

4.1 场景一:自动整理下载目录

这个任务的用户意图是:“把下载文件夹里最近三天的安装包文件移到桌面备份文件夹,重命名加上日期前缀。”

Ally 拿到这句话后,调度器生成的步骤大概是:

  1. 打开文件管理器,进入下载目录
  2. 切换视图为列表模式,按修改日期排序
  3. 识别最近三天的文件列表
  4. 过滤出扩展名带安装包特征的文件
  5. 新建备份文件夹
  6. 对符合条件的文件逐个重命名并移动

有意思的是,前两步是用户没有明说的,但调度器根据自己的经验补全了。因为文件列表不按日期排序的话,Ally 根本不知道哪些文件是最近三天的。这一点我调了很久才发现,模型其实知道排序这回事,但如果不明确写进指令,它在执行时不一定想得到。后来我在提示词里加了一条:“每个文件操作任务开始时,先确认视图和排序条件是否满足操作需求。”问题就好多了。

执行过程中,最考验的是识别文件名。文件夹里的文件名混杂着中文、英文、数字和特殊符号,OCR 识别偶尔会出错。Ally 的策略是,不在点击文件之前做决定,而是先把整个目录的截图区域单独放大处理,逐行识别文件名,再做二次语义校验。比如安装包文件名通常以 .exe、.dmg、.msi 结尾,如果识别结果不符合这种模式,就标记为“低置信度”,强行跳过,不冒险操作。

整个任务跑了 14 步,耗时不到 40 秒。对比我手动操作,大概需要 3 分钟。省时间倒在其次,关键是这类任务以后每次跑都是一致的,不会出现我犯困时把文件拖错文件夹的情况。

4.2 场景二:根据本地文档填写一个网页表单

第二个场景是填表,这也是桌面自动化里公认的“硬骨头”。用户给了一篇本地文档,要求提取里面的关键信息,填入某个内部系统的表单页。

传统自动化遇到表单就头疼,因为输入框的结构经常变化。Ally 的做法是纯视觉定位:截屏之后,OCR 识别出表单页面上所有标签文字,比如“姓名”“联系电话”“备注”,任何一个标签的右侧附近区域就是该字段的输入框。然后依据文档内容,逐项填写。

这个流程里最值得说的是“右侧附近”这四个字。实际实现时,Ally 会先定位到“姓名”这个标签的坐标,然后在它右侧一个固定宽度范围内寻找可点击的输入区域。这个逻辑听起来简单,但解决了表单布局变化的大部分场景。后来我发现,有些表单是上下布局,标签在输入框上方,所以我给感知层加了一个策略:如果右侧找不到空的输入框,就尝试正下方区域。这种双模式探测极大提升了适配率。

还有一个非常关键的细节:输入文字不能太快。真实输入框都有事件监听,秒速输入几十个字符可能触发一些奇怪的行为。Ally 的键盘输入模块默认模拟人类的打字节奏,每输入一个字符间隔 20 到 50 毫秒。这样既不会慢得离谱,也不会触发异常校验。

这个任务最终跑了 9 步就完成了。用户复查后说信息填得完全正确。看到这个结果,我觉得之前那些调坐标、调识别阈值的折腾全都值了。

4.3 一次失败后的自我修复

记录一个翻车案例。某次让 Ally 点击“导出报表”按钮,它识别到了按钮位置并点了下去。结果程序界面在点击后弹出了一个安全提示框,导致界面整体下移,原来的按钮位置变了。

如果是一个写死的脚本,这时候它下一击会点错地方。但 Ally 有循环反馈,它点击后立即感知到界面状态变化,发现当前画面里“导出报表”按钮已经不在预设位置了,同时多出一个安全提示框。调度器当场改了计划:先读取提示框内容,判断是“确认”还是“取消”语义,点击对应按钮,再重新查找“导出报表”。

最后任务完成了,只是比预期多花了三步。这件事给我的启发是:桌面 Agent 的真正优势不是比人快,而是它能意识到“情况变了”,并且愿意重新观察。这种自适应能力,是传统自动化完全不具备的。

5. 翻车现场与修复手册:高频问题和排查心得

开发 Ally 到现在,我经历过的失败绝对比成功多。这一节把最高频的几类问题整理成速查表,再分享几个我认为最重要的避坑心得。

5.1 高频问题速查表

问题现象可能原因排查思路
点击位置总是偏系统显示缩放未补偿读取缩放比例,统一做坐标换算
OCR 识别不到文字窗口未激活或被遮挡先激活目标窗口再做识别
鼠标移出窗口被系统拦截权限不足重新检查系统辅助功能授权
任务卡在同一个步骤循环界面状态判断条件太弱增加状态变化检测,连续相同动作超过 3 次就中止
点击后无任何反应目标区域被透明层覆盖查看该区域是否有弹窗或悬浮层
多显示器环境下坐标错乱主副屏坐标系不统一使用虚拟坐标空间,把多屏映射成单一坐标系

这里我想特别展开一下多显示器的问题。Ally 最初只在单屏环境下测试,一切正常。拿到双屏环境一跑,点击位置全偏。原因是截图时用的是主屏的坐标系,但目标窗口在副屏上,两套坐标根本不统一。解决方法是把所有屏幕映射到一个虚拟的桌面坐标系里去处理:先拿到每个屏幕的边距和分辨率,建立一个统一的坐标空间,再让感知层在这个空间里做识别和定位。

还有一个 OCR 相关的坑。系统自带字体渲染和某些软件的自绘字体,在截屏后会有很强的手臂,OCR 识别率下降明显。后来我调整了截图的倍率:把目标区域先放大两倍再送入 OCR,识别准确率从不到 70% 提升到 95% 以上。代价是识别速度稍慢了一些,但比起识别错然后误操作的风险,这个代价完全可以接受。

5.2 四条避坑心得:按优先级排

第一,永远先做“只读”任务,再做“写”操作。我第一条 Ally 上线的任务就是整理文件,属于典型的写操作。现在回想起来,应该先用一个只读任务验证全链路,比如“帮我读一下当前打开文档的第一段内容”。只读任务不会造成危害,但能把截图、识别、推理、动作、反馈这一整条链路全部跑通。

第二,操作日志比你想象的更有价值。Ally 到现在积累了几千条操作日志,很多问题我都是通过回放日志定位的。不要嫌日志麻烦,每一条动作都记录清楚,后面排查问题时省的是你的时间。

第三,人类的确认机制不要省。我的设计是:所有不可逆的破坏性操作执行前,Ally 必须先输出“我准备执行某某操作,影响范围是什么”,由用户确认后才真正执行。这会让自动化程度看起来没有那么高,但代价是值得的,因为一次误操作的成本远超多按几次确认键。

第四,模型能力不够的时候,先用规则兜底。Ally 不是所有判断都靠大模型。凡是能用确定性规则解决的,我都优先写死。比如文件扩展名的判断、按钮颜色的识别、窗口标题的匹配,这些用规则又快又稳。大模型只负责做“规划”和“语义理解”这两件规则搞不定的事情。混合架构跑下来,稳定性比全交给模型高了一大截。

最后再分享一个小技巧:给 Ally 加一个“单步模式”。在这个模式下,每执行完一步都会停下来,把屏幕截图、识别结果、准备做的动作显示出来,等用户确认后才走下一步。开发调试的时候用它,能非常清晰地看到模型的理解过程。等确认整个链路稳定了,再切回全自动模式。我到现在还保留着这个开关,因为真实桌面环境变化太快,没有任何一个 Agent 敢保证永远不出错。留一个确认入口,既能让它放手干活,又能在关键时刻兜住底。

RELATED READING

延伸阅读

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