GUI Agent 这个赛道最近非常热闹,但你只要真的在项目里跑过一版,大概率会遇到同一个让人头疼的场景:模型明明已经理解了页面的结构,却在最后一步自信地点击了旁边的广告横幅,或者弹窗一变就彻底不知所措。你把它当段子截图发到工作群里,但笑声背后是一个非常现实的问题——GUI Agent 的失败率,远比你想象中高。
这篇文章想聊的不是“怎么把准确率再往上调一档”,而是换一个思路:如果失败本身可以被结构化地保存下来,并且在下一次遇到相似页面时自动派上用场呢?EvoSkill-GUI 就是这类思路的一个代表,它的核心理念很简单,把一次误点、一次定位失败,变成能复用的技能。适合正在做 Agent、自动化测试、或者 RPA 相关工作的朋友参考,因为这套“失败转技能”的框架并不绑定具体模型,你可以把它接到自己的 Agent 上。
1. GUI Agent 为什么总是“点错”?
1.1 每个环节都有自己的“坑”
GUI Agent 的工作链路一般可以拆成三步:感知当前界面、决策下一步操作、执行这个操作。感知通常靠截图加视觉模型或者 DOM 解析;决策由大模型根据指令和界面状态生成;执行则通过浏览器调试协议、系统自动化接口或者直接发送模拟鼠标事件完成。听起来挺顺畅,但每一步都有各自的坑。
感知层面的坑最常见。同一个文字出现在页面多个地方,比如“更多”按钮在顶部和底部各有一个;元素状态跟截图不同步,明明已经加载完却还显示 loading;弹窗、气泡、遮罩层把真正的按钮盖住,视觉模型却毫无察觉。这些属于“看错了”。
决策层面的坑更加隐蔽。模型拿到了正确的界面信息,但指令本身有歧义,比如用户说“删除”,页面上有“删除文件”和“删除账户”两个入口;或者页面有多个符合描述的候选按钮,模型选错了。这个层面是“想错了”。
执行层面的坑则很现实:点击事件的坐标被浏览器缩放偏移、元素在点击前的一瞬间被异步渲染替换、权限弹窗中断了流程。这类错误经常被归类为“手滑”,但它发生的频率一点都不低。
如果你把一次完整任务的日志拉出来看,常常会发现真正的失败不是某个单一环节的问题,而是多个环节叠加后的结果。模型看到了错误的位置,生成了错误的点到坐标,点击又因为页面状态变化而落空。这种复合型失败如果只从单个环节去调参,几乎无法根除。这也是传统“修 Bug”思路在 Agent 场景里不太奏效的原因。
1.2 失败其实是唯一的“线下老师”
多数团队在开发 Agent 时,会把注意力放在成功轨迹上:跑通了,保存;跑了三次不通,最多看一眼报错,然后把失败样本丢进垃圾桶。问题在于,失败轨迹里包含着三个最有价值的信息:当时看到了什么、打算做什么、实际发生了什么。
举个例子,你的 Agent 在填写一个表单时,第一次点击“提交”后页面弹出了一个“请先同意用户协议”的红字提示,第二次点击却没有弹窗直接提交成功。如果只保留成功轨迹,你学到的是“点击提交”;如果同时保留失败轨迹,你学到的是“当页面有未处理的悬浮提示时,直接点击提交会被拦截,要先处理提示区”。后者才是一条真正可迁移的经验。
EvoSkill-GUI 选择把失败轨迹当成主要的数据来源,本质上是把“错误”从负面资产翻转为正面资产。它不追求一次性把成功率调到 99%,而是允许 Agent 犯错,但要求每次犯错后都能沉淀一条可以被再次使用的经验。这个思路很像人类的学习方式:吃过一次亏,下次就绕开;如果绕不开,就想想为什么。这种“线下老师”的价值,在自动化任务里往往比在聊天任务里更明显,因为 GUI 操作本质上是确定性的,同一个页面结构下,一个可复用的技能可以反复被触发。
2. EvoSkill-GUI 的核心思路:把失败封装成可复用技能
2.1 技能是什么:三段式封装
传统方案里,所谓“学习”通常意味着微调模型,代价高、周期长,而且效果不可控。EvoSkill-GUI 的做法是绕开大模型训练,直接定义一种中间表示,叫“技能”。
这里的技能不是代码函数,也不完全是提示模板,而是一个三段式封装:
- 触发条件:什么状态下这个技能适用,比如“页面上出现‘请先登录’弹出框”;
- 动作策略:在这种状态下应该怎么操作,比如“点击弹出框中的‘马上登录’,等待页面跳转后再继续原任务”;
- 校验结果:执行动作后怎么判断是否成功,比如“登录框消失且 URL 变化”。
这看起来很像测试用例里的“前置、步骤、预期”,但区别在于技能是供 Agent 在推理时动态检索和组合的,不是固定执行序列。它刻意把触发条件写成半语义化、半结构化的形式,方便做向量检索,同时也方便人读。
为什么采用三段式?因为失败轨迹通常具有“状态依赖”的特征。你点错了,往往是在某个特定界面状态下点错;换个界面,同样的点法可能就对了。如果不封装触发条件,只存操作序列,技能之间会互相污染。这就好比一个人知道“红灯要停”,但你不告诉他“其他方向是绿灯你也可以走”,这个知识就是残缺的。
2.2 演化机制:技能库不是静态“词典”
“Evo”这个词是 EvoSkill-GUI 的核心。技能库如果只是简单地把失败样本堆在那里,那叫日志中心,不叫技能库。真正的难点在于让技能随着新失败的发生而自动演化。
演化机制可以拆成四类动作:新增、合并、分裂、淘汰。
新增最容易理解,遇到一个完全没见过的新失败模式,归因后提取成新技能。合并发生在两个技能在触发条件上出现高比例重叠,动作策略也基本一致之时,这时候应该合并成一个更通用的技能,否则技能库会迅速膨胀。分裂则相反,一个技能覆盖的场景太宽,导致在某个子场景下频繁失效,那就需要把触发条件细分,拆成两个更精准的技能。淘汰最根本:一个技能在连续多次被检索到并执行后,仍然失败率偏高,就应该降低权重或者直接移除。
这四类动作不是纯靠规则,而是靠一个评分机制驱动的。每个技能保存了使用次数、成功次数、最近成功时间、失败原因类别。每次 Agent 执行任务时都会反馈这次是否使用了某个技能、使用后的结果如何。系统定期对这些技能做一次像垃圾回收一样的清理,把低频、失败率高、触发条件模糊的技能处理掉。
这里的关键设计是:技能不是写死的,而是带生命周期的。因为 GUI 环境本身在变,网页改版、按钮移动、弹窗样式调整,一个曾经有效的技能几个月后可能就失效了。如果没有演化机制,技能库就只有“越攒越乱”和“越攒越死”两条路。EvoSkill-GUI 试图做到,不用每次改版都人工去删技能,而是让技能随着真实反馈自我修正。
3. 手把手搭建一个失败转技能的闭环
3.1 第一步:定义统一的失败轨迹格式
无论你用的是什么 Agent 框架,要想让失败能被复用,第一步都是把轨迹“标准化”。我建议你把一次交互过程定义成一个 JSON 结构,至少包含下面这些字段。
{ "task_id": "task_20250613_001", "task_desc": "在后台管理系统中创建一个营销活动", "steps": [ { "step_id": 1, "observation": { "screenshot": "path/to/screen.png", "dom_snapshot": "path/to/dom.json", "accessibility_tree": "path/to/a11y.json", "visible_texts": ["创建活动", "营销中心", "昨日消耗 1200"] }, "action": { "type": "click", "target": "visible_text:创建活动", "coordinate": [840, 350] }, "feedback": { "success": false, "error": "element_not_found", "page_changed": false } } ], "final_result": "failed", "failure_reason": "按钮被折叠菜单遮挡,视觉模型误判为可见" }这个格式最核心的部分是 observation 里的 visible_texts 和 action 里的 target。不要把坐标当成唯一的动作目标,因为坐标不具备可迁移性。页面变了、分辨率变了,坐标就废了。要把 target 写成“visible_text:创建活动”或“semantic:页面右上角的关闭按钮”这种语义化形式,后续技能抽取才能泛化。
统一轨迹格式的意义在于让“失败”可以批量处理。如果没有统一格式,每个环节各存一份 log,归因时就得手工对照截图和日志,效率极低。有了标准化轨迹,你就能写脚本批量跑归因。
3.2 第二步:自动归因,区分“该学的失败”和“不该学的失败”
不是所有失败都值得沉淀成技能。失败的归因其实要回答三个问题:哪个环节失败了?失败的根本原因是什么?这个原因是否有普适性?
我建议用一个三层的归因逻辑。
第一层检查执行反馈。如果 action 执行后页面没有变化,而且报错信息是 element_not_found,那大概率是感知或决策阶段的定位错误。如果执行后页面变了,但变成了错误页面,那就属于动作策略错误。
第二层用大模型辅助分析。把轨迹的 JSON 喂给 LLM,让它输出一个结构化的失败原因。你可以用类似下面这样的提示词模板:
你是一个 GUI Agent 调试专家。给你一段失败轨迹,请判断失败原因。 要求输出 JSON 格式: { "failed_stage": "perception | decision | execution", "root_cause": "用一句话描述根本原因", "suggested_skill": "如果这次失败可以泛化为一个技能,请写出触发条件、动作策略、校验结果;否则返回 null" } 轨迹数据: {轨迹 JSON}这里有个技巧:不要让大模型自由发挥,一定要限死输出格式。否则你会得到各种散文风格的归因结果,后续根本没法处理。这一步会把原始失败整理成“技能候选”,但还不能直接入库,因为可能会出现过拟合或误归因。
第三层做一次统计去重。把连续多条失败轨迹的 root_cause 做向量相似度聚类。如果 10 条失败都是“弹窗遮住了目标元素”,那就只需要生成一个通用技能,而不是 10 个相似的技能。如果 10 条失败虽然原因相同但出现在完全不同的页面场景里,那这个技能就需要更强的触发条件设定。
归因的核心目标是区分“该学的失败”和“不该学的失败”。像登录态过期、网络抖动这类环境性失败,不应该变成技能;而像“弹窗遮挡”“页面歧义选择”“按钮状态变更”这类确定性失败,才值得沉淀。用上面的三层过滤,基本能把噪音剔掉。
3.3 第三步:从归因结果生成技能模板
归因完成之后,系统会得到一批技能候选。接下来要做的就是把概括性的文本转成结构化的技能。这一步我建议用一个独立的“技能生成器”,而不是让归因步骤顺带完成。
技能生成器接收归因结果,输出这样一段结构:
{ "skill_id": "skill_popup_block_001", "name": "处理弹窗遮挡目标元素", "trigger": { "type": "state_match", "description": "页面存在可见弹窗或遮罩层,且目标元素被遮挡或不可点击" }, "action": { "plan": [ "优先识别弹窗的关闭按钮并点击", "如果无关闭按钮,点击遮罩层外部区域尝试关闭", "等待 500ms 后重新定位目标元素并执行原操作" ], "expects": "弹窗消失,目标元素变为可见或可点击" }, "verification": { "type": "post_condition", "check": "目标元素在 accessibility_tree 中可见且 enabled" }, "source_failures": ["task_20250613_001", "task_20250613_007"], "confidence": 0.87 }注意 action 里的操作尽量写成一到三步的短指令,不要展开成一个小程序。因为技能最终会被注入给大模型作为提示文本,太长的大模型反而利用不了。你要是写成五步以外的操作,检索和执行的效率都会下降。
为了让技能具备泛化能力,你还需要在生成时做“变量槽位替换”。例如,失败轨迹里具体的“关闭按钮”在另一个页面叫“我知道了”按钮,你就不能写死,而应该把按钮文本抽象成“关闭弹窗的显式按钮”。这个替换可以用大模型辅助,也可以让技能生成器基于可见文本的类别(按钮、链接、图标)做规则化处理。
3.4 第四步:检索注入与使用策略
技能入库后,最重要的就是 Agent 怎么用这个技能。我推荐的做法是在 Agent 做每一步决策之前,把当前界面的观察状态编码成 query,用向量检索找到最匹配的技能,然后把技能的内容作为上下文注入到给大模型的 prompt 里。
这里的伪代码逻辑类似于这样:
def decide_action(observation, task, agent, skill_store): # 1. 把当前界面状态序列化为向量 query_vector = encode_observation(observation) # 2. 从技能库中检索最相关的技能 candidates = skill_store.search(query_vector, top_k=5) # 3. 构建带技能上下文的 prompt context_prompt = "" for skill in candidates: if skill.trigger_matches(observation): context_prompt += f"[可复用技能] {skill.name}\n" context_prompt += f"触发条件: {skill.trigger.description}\n" context_prompt += f"操作策略: {skill.action.plan}\n" context_prompt += f"校验方式: {skill.verification.check}\n\n" # 4. 调用 Agent 的决策模型 action = agent.act(observation, task, context_prompt) return action关键点是“触发条件匹配”和“技能自身置信度”要双重判断。只做向量相似度检索,很容易把“看起来有点像”的技能误触发;所以技能本身的 trigger_matches 必须再做一个规则或模型校验,确认当前页面确实符合技能的使用前提。
使用策略上我建议“先建议后干预”。第一版不需要强制 Agent 执行技能,而是把技能作为上下文提示的一部分,让模型自己决定是否采纳。等技能库足够成熟,再设置高置信度技能的自动执行阈值。这样可以在前期避免技能误伤任务流程。
3.5 技术选型参考
技能库的存储和检索我推荐用向量数据库,因为你的检索 input 是“界面状态描述”,本身就是非结构化文本。传统 SQL 的匹配能力太弱。常用的工具可以用 Qdrant、Milvus 或 Chroma。如果你只想快速验证,Chroma 最轻量,跑在本地就行。
轨迹数据的采集层,要看你接的 Agent 是什么环境。浏览器端可以用 Playwright 的 trace 功能,能拿到 DOM 快照、截图和动作序列;桌面端可以用 UI Automation 框架自带的日志;移动端需要额外接 adb 的窗口层级 dump。无论哪种端,都要把数据落成统一 JSON 格式,不要自己造多个格式。
大模型辅助归因和技能生成这一层,建议直接调用现成的 LLM API。归因和生成都很吃模型的理解能力,但不需要毫秒级响应,用中低延迟模型就够。如果你公司有成本压力,也可以选开源模型部署在本地 GPU 上,只是精度会略低。
最后是评估模块。一定要单独记录每个技能被使用了多少次、成功了多少次。这里我会用一个简单的 Google Sheet 或者线下数据库的记录表,不需要上线什么大系统。技能数据是稀疏的,一个刚入库的技能可能一周才被使用一次,所以需要积累。
4. 常见问题与排查技巧实录
4.1 技能库越来越乱,检索结果不可用怎么办
这是最容易遇到的问题。技能一旦超过一两百条,向量检索的精度会明显下降,经常出现“检索到的技能不知道在说什么”的情况。问题的根源不是向量库不行,而是技能之间的区分度不够。
排查技巧是定期做“技能归并”。每个月跑一次聚类,把触发条件相似度超过 90% 的技能标记出来。然后人工或让大模型决定是合并还是保留差异。我见过很多团队的技能库,里面其实有大量互相重复泛化的内容,归并后体积能减少 40% 左右。
另一个技巧是给技能增加“负面标记”,也就是明确写出本技能不适用的场景。比如“本技能适用于普通弹窗,不适用于强制升级弹窗”。这个负面场景可以在技能被误触发时自动记录,每次误触发都把这个场景写入负面标记列表。这样检索时如果当前界面状态和负面标记相似,就可以排除这条技能。
4.2 技能会误伤正常场景,怎么控制回退
技能注入到 prompt 后,模型可能明明可以正常完成某个操作,却因为检索到了一个“看起来很相似”的技能,走了偏路。比如页面上有弹窗但弹窗并没有阻挡点击目标,模型却先点了弹窗关闭按钮,结果反而点错。
这个问题我建议用两层策略控制。第一层是技能触发的门槛,不是所有检索到的技能都能进上下文,只允许置信度超过某个阈值(比如 0.85)的技能进入。第二层是 Agent 执行完后反馈判断,如果执行结果异常,会立即把这次任务的技能使用记录标记为失败,下次自动降低权重。
更稳妥的方案是“影子模式”。在一段时间里,技能检索照常进行,大模型仍按自己的方式决策,系统把技能建议和模型最终决策记录成对比日志。这个过程跑三五天,你就能看到哪些技能的建议跟 final action 是一致的,哪些是跑偏的。等筛选出高一致性技能后,再正式开启技能注入。
4.3 从失败里学到了“反模式”,越学越笨怎么办
有些失败虽然确实是失败,但根本原因其实是用户指令本身就不明确,或者是页面环境本身有问题。如果把这些也沉淀成技能,Agent 就相当于学会了在没有指令信息时硬猜,猜错了再学一个更偏的技能,形成恶性循环。
解决办法是给技能生成设置一个“必须包含预期结果”的硬约束。如果一个技能无法表达“操作之后应该观察到什么”,就不允许入库。这个约束看着简单,但能过滤掉大量因果不明的失败。因为在 GUI 环境里,因果不明意味着你的归因没做透,那这个技能大概率是带毒的。
另外,我强烈建议给技能库设一个人工审核开关。前 50 条技能入库时,必须有人眼确认。等技能生成器的准确率稳定了,再打开自动入库。不要省略这一步,因为失败归因天然偏向于“从结果反推原因”,而结果并不总是能可靠地指向原因。
4.4 怎么评估技能的真实价值,而不只是看数量
技能库的价值很难用“存了多少条技能”来衡量。我看到的经验是,真正有价值的技能往往是那种“能结束一个反复出现的失败模式”的技能,而不是那些单次挽救了一条任务的技能。
一个简单可用的评估指标是“技能有效使用率”,等于某个技能在所有被注入场景中导致任务最终成功的次数,除以该技能被注入的总次数。如果这个指标低于 20%,那这条技能基本就是噪音。反过来,如果某类技能的有效使用率稳定在 60% 以上,可以考虑把它提升为 Agent 的默认策略。
另一个指标是“失败模式覆盖率”。把所有失败轨迹按照归因出来的 root_cause 做分类,然后看每条根因是否已有对应的技能。理想状态下,前 20 个高频失败根因应该覆盖 80% 以上的技能。如果你发现库里 200 条技能覆盖的根因高度集中于某一个模式,那说明你的归因和泛化都太窄了,技能库并没有真正帮助解决多样性问题。
4.5 追踪一条失败到技能转化的关键经验
在落地这个框架的过程中,我最大的体会是“失败归因的速度决定了整个系统的下限”。如果你归因慢,技能生成就慢,技能库的时效性就会变差。网页改版很快,今天沉淀的技能可能下周就过时了。所以你要让归因流程尽量自动化,尽量在任务失败的几分钟内自动完成归因和入库,不是攒到月底再集中处理。
还有一个实操细节:截图一定不能省。技能库里的 trigger 描述有时写得再好,最终你还是需要肉眼确认当时的界面长什么样。每次入库时把轨迹里关键节点的截图一并存储,哪怕只是缩略图,都能在后期排查技能冲突时省下大量时间。
我个人在实际项目里的习惯是,让技能库的每个技能都带一个“最近一次成功使用时间”和“最近一次失败使用时间”。这两个时间字段帮助很大。当你看到某个技能长期没有被使用,却突然又开始出现时,大概率是又有新页面触发了同类问题。这往往能帮你识别出新的失败模式,比整天看成功率曲线更有启发。
这个内容后续还可以继续扩展:比如把技能库的检索与 Agent 的规划模块做深度集成,或者针对不同端(Web、桌面、移动)分别建立独立的技能库。只要你先把“失败转技能”的闭环跑通,后续的收益会非常明显。