手机GUI Agent记忆层设计:三层架构与写入链实践
1. 为什么手机 GUI Agent 需要一个“记忆层”
做过手机端 GUI Agent 的人大概都有过这种体验:模型在单步操作上表现还行,点按钮、滑列表、输文字都能应付,但一旦任务链条拉长到十几步甚至几十步,它就开始“失忆”。上一轮明明已经打开了设置页,下一轮它又回到桌面重新找入口;刚填过的账号密码,翻个页面就忘得一干二净;更离谱的是,同一个 App 里踩过的坑,换个任务再来一遍,它照样往里跳。
这不是模型能力不行,而是架构里缺了一层专门管记忆的东西。大模型的上下文窗口再大,也架不住 GUI 操作里那种高频、碎片、跨页面的信息流。你不可能把每一步的截图、控件树、操作结果全塞进 prompt,token 烧不起,延迟也扛不住。所以我的思路很直接:给 Agent 单独做一层记忆,把“什么该记住、什么该忘掉、什么时候调出来”这件事从模型推理里剥离出来,变成一个独立的、可控的模块。
这层记忆要解决的核心问题就三个。第一,事实常驻——那些跨任务、跨会话都不会变的信息,比如设备型号、屏幕分辨率、常用 App 的包名和入口路径,必须随时可查,不能每次都重新探测。第二,技能按需——操作技巧、页面套路、避坑经验这类东西,不应该一股脑全塞给模型,而是等它真的遇到相关场景时再精准投放。第三,一条写入链——所有进入记忆的信息,从产生到落库到被召回,得有一条清晰的、可追溯的链路,不然记忆库很快就会变成一锅粥,存了一堆没用的东西,真正要用的又找不到。
这套东西适合谁参考?如果你正在做 Android 端的自动化测试、RPA 工具、无障碍辅助,或者任何需要在手机界面上跑多步任务的 Agent,这套记忆层的设计思路都能直接搬。哪怕你用的是纯 prompt 方案,没有微调条件,也能通过外挂记忆层把长任务的成功率拉上来一大截。
2. 记忆层的整体架构与设计取舍
2.1 三层记忆的划分逻辑
我把记忆分成三层,划分依据是信息的生命周期和召回频率,而不是按数据类型硬切。
第一层是常驻事实层,存的是几乎不变或者变化极慢的信息。设备相关的有屏幕宽高、DPI、系统版本、输入法状态;App 相关的有包名、主 Activity、关键页面的跳转路径;环境相关的有当前网络状态、存储路径约定。这些东西的特点是:写一次,读无数次,而且读的时候要求极低延迟。我的做法是直接放在内存里的一个 dict,启动时从持久化存储加载一次,之后只读不写,除非检测到环境变化才触发更新。
第二层是技能层,存的是“怎么做”的知识。比如“在某个电商 App 里领优惠券需要先点右下角我的,再进优惠券中心,注意弹窗要等 1.5 秒再点关闭”。这类信息的特点是:数量会持续增长,但每次任务只用到其中一小部分,而且用的时候需要根据当前页面状态做匹配。所以我把它做成按需召回,用一个轻量的检索逻辑,根据当前页面特征去技能库里捞相关的几条,拼进 prompt。
第三层是会话层,存的是当前任务链里的临时状态。比如“已经打开了设置页”“用户名已填入”“上一步点击后出现了 loading,需要等待”。这层信息生命周期最短,任务结束就清掉,但任务进行中读写最频繁。我把它放在一个环形缓冲区里,只保留最近 N 步的状态,避免无限膨胀。
三层之间的边界不是死的。有些信息一开始在会话层,但如果发现它在多个任务里反复出现,就可以“晋升”到技能层甚至事实层。这个晋升机制后面会细说。
2.2 为什么不做成一个大而全的向量库
很多人第一反应是:记忆嘛,搞个向量数据库,全部 embedding 存进去,用的时候相似度检索。我一开始也试过,踩了几个坑之后放弃了。
第一个坑是延迟不可控。手机端跑 embedding 模型本身就吃力,再加上向量检索,每步操作前多出几百毫秒,任务链条一长,整体耗时直接翻倍。GUI Agent 对实时性是有要求的,用户看着屏幕等,超过两秒没反应体验就崩了。
第二个坑是精度不够。向量检索擅长语义相似,但 GUI 场景里很多匹配是结构化的。比如“包名为 com.xxx.yyy 的 App 的当前 Activity 是 MainActivity”,这种精确匹配用向量做就是杀鸡用牛刀,还容易召回一堆似是而非的东西。
第三个坑是不可解释。向量检索出来一条记忆,你很难跟用户解释为什么这条被选中了。而 GUI Agent 调试的时候,你需要清楚知道“这一步为什么用了这条记忆”,不然出了问题根本没法排查。
所以我最后采用的是结构化存储 + 规则匹配 + 轻量语义兜底的混合方案。事实层和会话层用结构化 KV 存储,技能层用“页面特征标签 + 文本描述”的方式存,召回时先按标签做精确过滤,再用一个很小的文本匹配模型做排序。这样延迟可控,精度够用,而且每一步召回都能说清楚原因。
2.3 写入链的设计原则
写入链是整个记忆层里最容易被忽视、但最关键的部分。我的原则是:不是所有信息都值得记,不是所有值得记的信息都值得马上记。
具体来说,写入分三种触发方式。第一种是显式写入,Agent 在执行过程中主动调用一个 write 接口,把当前确认的事实或技能存进去。比如它成功完成了一次“从桌面到设置页”的跳转,就可以把这条路径作为技能写入。第二种是隐式写入,由外层监控模块在任务结束后做复盘,把成功的操作序列提炼成技能,把反复出现的事实晋升到常驻层。第三种是人工写入,调试阶段我手动往技能库里塞一些已知的套路和避坑点,作为冷启动的种子数据。
写入的时候必须带元信息:来源、时间戳、置信度、命中次数。置信度低的记忆召回时权重低,命中次数多的记忆优先级高。这样记忆库自己会“优胜劣汰”,不需要人工频繁清理。
3. 事实常驻层的落地细节
3.1 设备与环境事实的采集
常驻事实层的第一批数据来自设备探测。Android 上获取屏幕信息用WindowManager的getDefaultDisplay()或者更现代的WindowMetrics,拿到宽高和密度。系统版本用Build.VERSION.SDK_INT。这些信息在 Agent 启动时采集一次,存进内存 dict,同时序列化到本地文件,下次启动直接读文件,避免重复探测。
这里有个细节:屏幕旋转和分屏会改变可用区域。我一开始只存了物理分辨率,结果在分屏模式下 Agent 算坐标全错。后来改成存两组数据:物理分辨率和当前可用窗口的 bounds,后者在每次任务开始前刷新一次。这样既保证了常驻层的稳定性,又兼顾了动态变化。
App 相关的信息采集稍微麻烦一点。包名和主 Activity 可以通过PackageManager拿到,但关键页面的跳转路径需要实际跑一遍才能确定。我的做法是:第一次遇到某个 App 时,让 Agent 用探索模式跑几条典型路径,把成功的跳转序列记下来,人工确认后写入常驻层。之后再用这个 App,就直接查表,不用重新探索。
3.2 存储格式与读取性能
常驻层的数据量不大,几百条顶天了,所以存储格式不用太讲究。我用的是 JSON,按类别分文件:device.json、apps.json、paths.json。启动时全部加载进内存,之后只读。
读取性能方面,因为都在内存里,单次查询是微秒级,完全不是瓶颈。真正需要注意的是写入频率。常驻层不应该频繁写,每次写都意味着环境发生了变化,需要触发一次全量刷新。我设了一个阈值:同一个 key 在短时间内被更新超过三次,就打印警告,提示可能有异常。
注意:常驻层的数据一定要做版本标记。不同 Android 版本、不同 App 版本,页面结构可能完全不同。我吃过亏,升级系统后旧的路径记忆全部失效,Agent 还在按老路径点,结果到处乱飞。后来加了版本号字段,版本不匹配的记忆直接标记为过期,不参与召回。
3.3 事实层的召回策略
事实层的召回是精确匹配优先。Agent 在每一步操作前,会带着当前上下文去查事实层:当前 App 包名是什么、当前 Activity 是什么、屏幕可用区域是多少。这些查询都是 O(1) 的 dict 查找,没有任何模糊空间。
只有在精确匹配失败时,才会走兜底逻辑。比如遇到一个没见过的 App,事实层里没有它的入口路径,这时候 Agent 会切换到探索模式,而不是硬用其他 App 的记忆。这个“宁可探索也不乱用”的原则很重要,GUI 操作里用错记忆比没有记忆更危险。
4. 技能层的按需召回机制
4.1 技能的记忆单元设计
技能层里每一条记忆我叫它一个“技能单元”,结构是这样的:
{ "id": "skill_001", "app_package": "com.example.shop", "page_tags": ["home", "bottom_nav"], "trigger": "需要进入优惠券中心", "action_sequence": [ {"type": "click", "target": "我的", "position": "bottom_right"}, {"type": "wait", "duration": 1.5}, {"type": "click", "target": "优惠券", "position": "list_item_2"} ], "confidence": 0.9, "hit_count": 12, "last_used": "2025-01-15T10:30:00" }关键字段是page_tags和trigger。page_tags是当前页面的特征标签,用来做粗筛;trigger是自然语言描述的使用场景,用来做精排。action_sequence是具体的操作步骤,confidence和hit_count决定召回优先级。
4.2 召回流程:从粗筛到精排
召回分两步。第一步是粗筛,根据当前页面的特征标签,从技能库里过滤出候选集。页面特征标签怎么来?我在 Agent 的感知模块里加了一个轻量的页面分类器,它不依赖大模型,而是用控件树的统计特征做规则判断。比如底部有四个可点击的图标且文字包含“首页/分类/购物车/我的”,就标记为bottom_nav;页面顶部有搜索框且列表项超过十个,就标记为list_page。
粗筛之后,候选集通常从几百条降到十几条。第二步是精排,用一个很小的文本匹配模型(我用的是一个蒸馏过的小模型,参数量控制在 10M 以内)计算当前任务描述和trigger的匹配度,取 top 3 拼进 prompt。
这里有个经验:精排模型不需要太强,但一定要快。我试过用大模型做精排,效果确实好一点,但每步多出 300 毫秒延迟,长任务下来用户明显感觉卡。换成蒸馏小模型后,延迟降到 20 毫秒以内,效果损失不到 5%,完全可接受。
4.3 技能注入 prompt 的格式
召回出来的技能不能直接原样塞进 prompt,得做格式化。我的模板是这样的:
[相关技能] 场景:需要进入优惠券中心 步骤:1. 点击底部“我的” 2. 等待1.5秒 3. 点击列表第二项“优惠券” 注意:该页面可能有弹窗,若出现弹窗先关闭再操作注意最后那句“注意”,这是从技能单元的notes字段来的。很多技能单元里会附带避坑提示,这些提示往往比步骤本身更有价值。我在写入技能时,会强制要求写一条 notes,哪怕只是“无特殊注意事项”。
提示:注入 prompt 的技能条数不要超过 3 条。我实测过,超过 3 条后模型反而容易混淆,把不同技能的步骤串在一起。宁可少给,让模型自己探索,也不要给多了造成干扰。
5. 一条完整的写入链是怎么跑的
5.1 从操作日志到技能单元的提炼
写入链的起点是操作日志。Agent 每执行一步,都会往日志里追加一条记录:时间戳、页面标签、操作类型、目标控件、操作结果(成功/失败/异常)。任务结束后,复盘模块会读取这次任务的全部日志,做提炼。
提炼逻辑分三种情况。第一种,任务成功且路径清晰,就把整个操作序列压缩成一个技能单元,trigger用任务描述填充,confidence设为 0.7(初始值,后续命中会提升)。第二种,任务成功但中间有试错,就把试错部分剔除,只保留有效路径。第三种,任务失败,但失败原因明确(比如某个按钮位置变了),就把这个失败经验写成一条“避坑技能”,trigger描述为“在 XX 页面不要点 XX”,confidence设低一点,0.5。
5.2 事实晋升:从会话层到常驻层
会话层里的信息,如果在一个任务里被反复读取,或者在多个任务里都出现,就会触发晋升检查。晋升条件我设了两个:跨任务出现次数 ≥ 3,且信息在任务间保持一致。
举个例子,“某个 App 的包名”这个信息,第一次遇到时存在会话层,第二次、第三次任务又用到,且值没变,就自动晋升到常驻层。晋升后从会话层删除,避免重复存储。
这个机制的好处是,常驻层不需要人工维护,它会自己“长出来”。我跑了大概两周后,常驻层里自动积累了三十多个 App 的入口路径,基本覆盖了日常测试用的所有应用。
5.3 写入的幂等与去重
写入链必须做幂等。同一个技能单元,如果已经存在且内容一致,就只更新hit_count和last_used,不新增记录。去重用的是app_package + page_tags + trigger的哈希值作为唯一键。
这里有个坑:自然语言描述的 trigger 很难完全一致。比如“进入优惠券中心”和“打开优惠券页面”,语义一样但字符串不同。我的处理方式是,写入前先用精排模型算一下和已有技能的相似度,超过 0.85 就认为是同一条,做合并而不是新增。合并时保留 hit_count 高的那条,notes 做拼接。
6. 实操中踩过的坑与排查技巧
6.1 记忆污染:错误信息被反复强化
最严重的一次事故是记忆污染。某个 App 的页面改版后,旧的路径记忆失效了,但 Agent 按旧路径操作时偶尔也能“歪打正着”成功(比如点错了但碰巧跳到了目标页)。结果这条错误路径的 hit_count 被不断推高,confidence 涨到 0.95,之后一直优先召回,导致大量任务失败。
排查这个问题的关键是监控 hit_count 和成功率的背离。我后来加了一个统计:每条技能单元的命中次数和命中后的任务成功率。如果命中次数高但成功率低,就自动降权并标记为“待复核”。这个机制上线后,类似问题再没出现过。
6.2 召回延迟毛刺:小模型加载时机
精排小模型如果每次召回都重新加载,延迟会有明显毛刺。我的做法是常驻内存,Agent 启动时就加载好,之后只做推理不做加载。模型文件放在 assets 里,首次启动时解压到私有目录,后续直接读。
另外,推理时的 batch size 设为 1,不要贪心做批量。GUI 操作是串行的,批量推理没有收益,反而增加内存占用。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| Agent 反复打开同一页面 | 会话层状态未更新 | 检查会话层写入日志 | 确认每步操作后都更新状态 |
| 技能召回不相关 | page_tags 分类错误 | 打印当前页面标签 | 调整分类器规则或补充标签 |
| 常驻层数据过期 | 未做版本校验 | 检查版本号字段 | 版本不匹配时标记过期 |
| 写入链重复记录 | 去重阈值过低 | 查看相似度分数 | 调高阈值到 0.85 |
| 任务成功率突然下降 | 记忆污染 | 查 hit_count 与成功率背离 | 降权并复核问题技能 |
6.4 调试记忆层的实用技巧
调试记忆层最有效的手段是把召回过程完整打出来。我在 Agent 里加了一个 debug 模式,每一步操作前打印:当前页面标签、粗筛候选集大小、精排 top 3 的 trigger 和分数、最终注入 prompt 的技能内容。这样一旦出问题,一眼就能看出是粗筛漏了还是精排排错了。
另一个技巧是定期做记忆库的“体检”。我写了一个脚本,每周跑一次,统计技能库的总数、平均 hit_count、confidence 分布、以及长期未被召回的“僵尸技能”。僵尸技能超过一定比例就触发清理,避免库越来越大、召回越来越慢。
7. 提示词与蒸馏在记忆层里的配合
7.1 提示词工程在召回环节的应用
记忆层的召回虽然主要靠规则和小模型,但提示词工程在几个关键位置仍然有用。第一个位置是技能单元的 trigger 生成。复盘模块提炼技能时,需要把操作序列概括成一句自然语言 trigger,这个概括用大模型做效果最好。我的提示词模板是:
根据以下操作序列,用一句话概括这个技能的使用场景,不超过20字: 操作序列:[序列内容] 要求:以“需要”开头,描述任务目标而非操作步骤。第二个位置是notes 的生成。避坑提示需要从失败日志里提炼,提示词会引导模型关注“哪个步骤导致了失败”和“下次如何避免”。
7.2 模型蒸馏在精排模型上的实践
精排模型是我用蒸馏方式做出来的。教师模型是一个 7B 的文本匹配模型,学生模型是一个 4 层的轻量 Transformer。训练数据来自教师模型对技能库的标注:给一对(任务描述,trigger),教师模型输出匹配分数,学生模型去拟合这个分数。
蒸馏的关键是数据要覆盖真实分布。我一开始只用成功任务的描述做训练,结果学生模型对失败场景的匹配很差。后来把失败日志里的描述也加进去,覆盖了各种表达方式,学生模型的泛化能力明显提升。
蒸馏后的模型大小从 7B 压到 10M 以内,推理延迟从 300ms 降到 20ms,匹配准确率从 92% 降到 88%,这个 trade-off 在手机端完全值得。
7.3 蒸馏一本书的思路能不能用在记忆提炼上
有人问过,用 AI 蒸馏一本书的方法能不能用来蒸馏操作日志。我的看法是:思路可以借鉴,但不能照搬。蒸馏一本书是提取核心观点,而操作日志的蒸馏是提取可复用的操作模式。前者重语义压缩,后者重结构归纳。
我实际的做法是分两步:先用规则做结构归纳,把操作序列里的重复模式识别出来;再用蒸馏模型做语义概括,把模式转成自然语言 trigger。两步分开做,比一步到位用大模型硬蒸效果更稳,也更容易调试。
8. 记忆层的扩展方向与个人体会
这套记忆层跑了一段时间后,我发现它最大的价值不是让 Agent 变“聪明”,而是让它变“稳定”。没有记忆层的时候,Agent 的表现波动很大,同样的任务跑十次可能成功六次;有了记忆层之后,成功率稳定在九成以上,而且失败的原因也变得可追溯。
后续我打算在几个方向继续打磨。一是技能单元的自动泛化,把“在 A App 里领券”的经验泛化到“在 B App 里领券”,减少重复写入。二是记忆的跨设备同步,把常驻层和技能层做成可导出的格式,换设备时直接迁移。三是召回的可解释性增强,让每一步召回都能生成一句人类可读的理由,方便非技术用户理解 Agent 的决策。
最后分享一个小技巧:记忆层的冷启动不要空着跑。我一开始让 Agent 从零开始积累,前两周效率很低。后来手动塞了二十条常用技能和十条设备事实进去,Agent 第一天就能跑通大部分常见任务,后续再慢慢积累。冷启动的种子数据不需要多,但一定要准,宁可少而精,不要多而杂。