面向受限平台的 Agent Harness 改造方法论 —— 以 Android 为例
把一个在桌面或服务器上运转良好的 Agent 运行时搬到移动端,难点通常不在模型,而在平台假设的全面失效:
进程不再长驻、存储不再任意可写、运行时不再完整、后台不再自由、工具面不再由操作系统提供。本文不展开某一款产品的实现细节,而是把一轮真实的 Android 化改造(把一个微内核 Agent 框架的
ReAct 核心抽成跨平台 Harness)沉淀成一套可复用的方法论:怎么盘点差异、怎么切抽象层、
怎么设计可靠性、怎么验证、以及哪些反模式一定会咬人。
一、方法论总览
平台化改造容易失败在"边写边发现":写到最后才发现某处假设在两个平台上不可能同时成立。
我们最终收敛成五步,顺序本身就是方法:
| 步骤 | 做什么 | 交付物 |
|---|---|---|
| ① 差异盘点 | 把运行时的隐含假设逐条写出来,而不是先写代码 | 平台能力矩阵(同时就是抽象层的需求清单) |
| ② 抽象层切分 | 沿"变化轴"切开:平台能力 / 领域形态 | 一组零平台类型的接口 + 宿主适配器 |
| ③ 契约化 | 把"必须永远成立的事"写成契约与不变量 | 接口契约文档 + 安全默认值 |
| ④ 可靠性分层 | 按"状态 / 副作用 / 恢复"三层各自设计,而不是笼统地"加异常处理" | 三层机制(快照、写前记录、分级恢复) |
| ⑤ 可验证性设计 | 判定依据先于实现;门禁与它的边界一起写 | 纯函数判定、契约链测试、构建期门禁 |
贯穿五步的一条衡量标准:换一个宿主时,需要改的是适配器还是核心?
只需改适配器,说明抽象层切对了。
二、差异盘点:把"平台假设"写成表
不要从代码结构出发,而是把运行时依赖的隐含假设列出来,逐条追问:
这项假设还成立吗?不成立时的替代物是什么?替代物该由哪一层承担?
以 Android 相对桌面(PC/JVM)为例,通用维度是这九项:
| 假设维度 | 桌面上的默认 | 受限平台上的现实 | 替代物落在哪一层 |
|---|---|---|---|
| 进程寿命 | 长驻,崩溃是异常 | 被杀是日常(后台回收、内存压力、厂商省电策略) | 可靠性层:可恢复的快照与副作用记账 |
| 存储语义 | 任意路径可读写、对外可见 | 私有目录 + 范围存储;对外不可见的目录等于"没交付" | 平台层:逻辑路径 → 物理路径的映射与可见性策略 |
| 运行时完整性 | 完整标准库 | 缺少高版本 API,且发布构建会裁剪类 | 平台层:能力探测 + 降级 + 如实上报 |
| 后台执行 | 基本无限制 | 会被挂起、延迟、限制启动 | 宿主层:任务级租约、看门狗、用户授权引导 |
| 能力面 | 操作系统提供完整工具链 | 能力多为应用自定义(注册表实现或系统子集) | 领域层:工具协议 + 能力缺口如实上报 |
| 人机交互 | 随时能问人 | 常常无人在场(锁屏、后台、无人值守) | 平台层:确认门,且必须默认拒绝 |
| 资源预算 | 相对宽裕 | 电量、内存、流量都紧 | 全层:分级预算与截断策略 |
| 扩展机制 | 可跨平台 | 动态加载多为平台专有 | 领域层:只保留一个跨平台的扩展点 |
| 设备一致性 | 一个系统一种行为 | 同一安装包在不同 ROM 上行为可能不同 | 宿主层:真机巡检与灰度交付 |
这张表的用处不是"参考",而是需求清单:每一行的"替代物落点"都会变成抽象层里的一项能力。
盘点做得越早,后期返工越少 —— 我们踩到的多数问题,本质都是某一行假设从未被写下来。
三、抽象层:切在变化轴上
3.1 两条轴必须分开
平台化最容易犯的错,是把两种变化混在一个抽象里:
- 平台能力:宿主能提供什么(文件、路径、时间、日志、确认、持久化);
- 领域形态:工具是什么、怎么执行(内置函数表、命令行、远程协议……)。
混轴的后果很具体:换一种工具形态,就要改平台实现;换一个平台,又要重写工具层。
正确的切法是能力用聚合值对象注入,形态用执行协议隔离,核心只认接口。
3.2 四条设计纪律
- 能力注入,不用全局单例。全局可变状态会让同进程无法安全地跑两个独立实例
(多租户、测试隔离全部失效)。新能力一律作为可替换的字段加进聚合根,并带默认值。 - 核心零平台类型,用构建期门禁强制。但门禁一定有边界:例如只匹配导入语句时,
全限定调用与间接引用都抓不到。边界必须写进文档 —— 否则下一个人会把"门禁绿"
读成"零平台依赖",而这恰恰是最危险的误解。 - 新增能力必须带安全默认:确认类能力默认拒绝、存储类能力默认内存实现、
危险能力默认关闭。默认值的选择标准是"漏配时不出事",不是"跑 demo 方便"。 - 最小可用面尽量小:把必填项压到最低(例如只要求"文件系统 + 路径解析"两项),
其余全部有安全默认。必填项越多,接入越容易出错。
四、契约化:把不变量写死
抽象层解决"能不能接",契约解决"接上以后会不会出事"。值得写成契约的有五类:
① 安全默认的方向性。 需要人来决策才能放行的操作,在无人在场时必须拒绝,
并且要区分"用户明确拒绝""无人可问""超时未决"三种结果 —— 它们的审计语义与对模型的措辞都不同。
把确认门改成"永远允许"能让测试全绿,但那是安全漏洞,不是捷径。
② 副作用协议的一致性。 工具执行器不用抛异常表达业务失败(用结果对象),
因为异常会把"工具失败"升级成"循环失败";但取消异常必须原样向上传播,否则"停止"语义失效。
这类规则要写进接口契约,而不是留给实现者猜。
③ 状态机的最小集。 例如"只有进行中的任务可以被接续,终态不可续"、
"终态恰好写一次(放在 finally 里收口)"。状态机越小,出错面越小。
④ 幂等与修复。 同一标识的重复写入必须保留既有状态(并发与重放都会触发);
追加式日志在写入前要确认上一条记录的完整性(崩在写一半时,新记录会粘在残行上,两条一起报废)。
⑤ 顺序即语义。 副作用记账必须"执行前写、执行后写",差一步就退化成事后记账,
崩溃窗口依然裸奔;循环里的终止分支若从"返回"改成"跳出",会继续执行循环之后的收口代码,
静默覆盖已得结论 —— 这类顺序问题只能靠契约与测试守住。
还有一条元契约:可靠性增强组件不得成为新的失败点。
快照写失败、日志写失败都不应该中断主任务,但必须留下可见的上报通道(默认静默是可接受的,
静默且不可观测不是)。
五、可靠性分层:状态 / 副作用 / 恢复
"进程随时会死"这一个事实,需要三层分别应对。三层缺一层都会留下真实的损失:
5.1 状态层:让每一步都可恢复
- 快照粒度按"最坏损失"定:每步一次,而不是每 N 步一次 —— 后者在长任务上会丢掉整段上下文与工具观察。
- 快照要分级截断:把"最近若干条保完整 + 更早的压成摘要"作为默认策略,
否则单份快照会随任务长度线性膨胀(我们的实测是从 1.6 MB 降到约 215 KB,落盘中位耗时 29 ms → 8 ms,
读取从"全量解码"降到 2–6 ms 的早退)。 - 例外必须显式保留:有些模型厂商要求思维链在后续请求中回传,
压缩时若把它一起丢掉,续跑会直接报错。协议要求优先于体积优化。 - 原子性替代:如果抽象层没有重命名能力,"先写后删 + 永不删除刚写入的那一份 + 墓碑式清除"
是可行组合 —— 保证任意时刻至少有一份完整快照可读。
5.2 副作用层:定义崩溃窗口,然后堵住它
工具副作用不可回滚,因此**"执行后记录"永远不够**:
- 崩溃窗口的定义是"副作用已经发生、但完成记录尚未落盘";
- 对策是执行前写一条待确认记录,并且这一步的持久化屏障不能省;执行后的结果记录可以放宽
(因为"结果记录丢失"的方向是安全的); - 恢复时的处理是这套机制的灵魂:待确认记录既不能自动重放(会重复写文件、发消息、下单),
也不能当作没发生过(任务会凭空少一步)。唯一安全的动作是把它作为事实注入上下文,
把判断权交回模型 —— "先核对实际结果,再决定是否重试,不要直接重复执行"; - 记账本身要遵守凭据禁区:参数只落摘要,错误文本先剔除命令行原文,日志里不出现任何密钥。
5.3 恢复层:判定与互斥
- 恢复判定做成纯函数:没有快照 / 已是终态 / 自动续跑被关闭 / 上一轮连续出错 /
锁被他人持有 / 可以自动续跑。凡是"需要问用户"的情况一律折算为"按新任务跑",
避免"判定要问用户却把任务静默跑掉"。 - 恢复入口往往不止一个(冷启动自动续跑、用户手动继续、后台保活触发),
它们可能落在不同进程,进程内锁挡不住;跨进程互斥需要组合:文件原子替换 + 写入后回读校验 +
进程内监视器。只靠原子替换给不出"恰好一个赢家",只靠回读则两个执行者可能各自回读到自己。 - 租期取值要做双向论证:太短会把正在运行的续跑判为过期并被抢占(最危险的重复执行);
太长则进程被杀后留下的"幽灵锁"会把用户挡在门外。我们最终取的是"单次最坏一步"的量级,
并让每步落盘顺带续租,把锁的有效期从"一次任务时长"解耦为"两次落盘的间隔"。 - 冷启动分两阶段:先只做判定与记账,等运行所需的依赖就绪后再执行。
否则会在"依赖尚未注入"时启动任务,必然失败一次。
六、资源治理:分级预算与租约
移动端资源治理有两条通用做法:
① 预算是分层的,且每层的截断行为必须明确规定。
同一个"输出太大"的问题,在不同层有不同的上限与不同的处理(标注截断 / 静默截断 / 落盘留路径)。
不统一的后果是排查者永远说不清"输出到底丢在哪一层"。
一个实用的默认策略是:近期内容保完整、久远内容压摘要、超限内容离屏存放并给出可检索的位置。
② 保活不是"一直持锁",而是"活跃时才持锁"。
把保活建成任务状态驱动的租约,参数每一项都要有理由,而不是照抄经验值:
| 参数 | 取值思路 |
|---|---|
| 空闲释放 | 覆盖任务收尾与用户连续追问的间隔;固定长持锁的耗电可以差一个数量级 |
| 单次租期 | 到期必须重新判定,绝不无限持锁到电量耗尽 |
| 续租阈值 | 留出足够安全余量,保证看门狗周期一定小于它 |
| 看门狗 | 与系统既有唤醒同频(不额外增加唤醒次数),并在每次唤醒时确认"真的有活" |
| 精确定时 | 无精确权限时回退免权限的模糊定时 —— 后者同样能穿透省电模式,只是精度放宽 |
再补三条边界:冷启动不预持锁(无任务时零耗电);权限引导只做一次并保留手动入口;
所有策略抽成显式传时的纯函数,从而可以脱离负载测试。
③ 观测要回答"值不值",而不是"看起来快不快"。
指标里必须有历史最坏值与最近一次值 —— 平均值会被"大多数步都很快"稀释,
而真机上的卡顿恰恰来自最坏那一步。埋点本身守一条纪律:只自增、不改变控制流。
七、可验证性:判定依据先于实现
平台化改造的验证有几条与常规业务开发不同的要求:
- 把判定做成纯函数、把时钟显式化。分级判定、保活策略、引导时机这类逻辑抽成纯函数并显式传入时间,
"超时""租期到期"这类边界可以秒级、负载无关地测;否则它们只能靠真实等待,最终没人测。 - 跨组件必须做契约链测试。不经过模型、甚至不经过引擎,只按生产上的调用顺序把各组件串起来,
验证"键、读法、默认值"在边界处仍然一致。各组件单测全绿从来不等于链路还接得上。 - 做破坏验证:把判定改回旧写法,测试必须变红。否则你无法区分"这条判定真的在守东西"
与"它只是恰好没被触发"。同理,涉及时间窗的测试夹具必须真的跨过窗口本身,而不是制造现实中不会出现的信号。 - 门禁要覆盖全部子包,边界要写进文档。覆盖不到的地方比没有门禁更危险,因为它制造虚假的安全感。
- 真机边界要如实声明。例如我们不远程控制真机做 UI 测试,
因此"需要长时间真机观察"的行为(省电模式下的实际续航、厂商 ROM 差异)只做到策略层与编排层的覆盖,
真实数据靠灰度与用户反馈持续收集。 - 一致性要机器守。凡是"给模型看的文本"(系统提示词、技能文档、命令索引)与实现,
必然出现漂移;正确做法不是"记得同步",而是把它们纳入静态扫描,
删能力时把退役名单登记进守护测试。
八、反模式清单
方法论最实用的产出,是一份"不要这样做"的清单。以下是这一轮改造中真实遇到过的:
| 反模式 | 后果 | 正确做法 |
|---|---|---|
| 同一份抽象层存在两份副本,靠"记得同步" | 静默语义分叉,测试不会变红 | 单一事实源;搬运是移动不是复制 |
| 危险能力默认打开,或提供"自助开启"的元工具 | 模型可自行提权,开关形同虚设 | 默认关闭 + 结构化开启指引 + 需用户显式开启 |
| 把确认门写成"永远允许"或默认放行 | 无人值守场景下高危操作静默执行 | fail-closed,并区分拒绝 / 无人可问 / 超时 |
| 可靠性组件自身抛错 | 磁盘满、权限不足变成新的崩溃源 | 快照/日志失败不中断主任务,但可观测 |
| 只记录"执行后"的副作用 | 崩溃窗口内重复执行(重复写、重复发) | 执行前记账 + 恢复时只提示核对、禁止自动重放 |
| 用文件系统时间戳反推身份 | 有损映射导致串档 / 串会话 | 内容为权威,文件名只做定位 |
| 路径安全只做字符串前缀匹配 | .. 与相对路径可绕过 |
真实路径规范化 + 按工作目录解析 + 无基准不判定 |
| 同一个判定只挂在一条通路上 | 另一条通路完全绕过保护 | 单一判定入口,所有通路共用 |
| 高版本 API 直接静态引用 | 构建期裁剪报错、运行期缺类 | 能力探测 + 降级 + 如实上报 |
| 用平均值评估性能 | 最坏情况被掩盖 | 记历史最坏值与最近值 |
| 只做单元测试就宣称链路可用 | 跨组件的键/读法/默认值不一致 | 契约链集成测试 |
| 门禁覆盖不全且不写边界 | 门禁绿 ≠ 无平台依赖 | 覆盖全部子包 + 把盲区写进文档 |
| 全局单例承载可变状态 | 多实例 / 测试隔离失效 | 能力聚合根注入 |
| 把"进程长期存活"当默认前提 | 长任务一断就从头再来 | 每步可恢复 + 分级续跑 |
九、迁移到下一个平台的检查清单
把上面各节收敛成一份可以直接照着做的清单:
- 把运行时的隐含假设列成表,每行写清"不成立时的替代物落在哪一层";
- 抽象层沿"平台能力 / 领域形态"两条轴切开,核心保持零平台类型;
- 每条平台能力都带安全默认值;必填项压到最少;
- 把确认、副作用、状态机、幂等、顺序这五类不变量写成契约;
- 可靠性按状态 / 副作用 / 恢复三层实现,缺一层都不算完成;
- 快照按"最坏损失"定粒度,并做分级截断(协议要求的字段显式豁免);
- 恢复判定做成纯函数,跨进程互斥用"文件 + 回读 + 进程内监视器"组合;
- 保活的每个参数都有理由,精确定时准备好降级路径;
- 输出与上下文按层设预算,并规定截断行为(标注 / 静默 / 离屏);
- 观测必须包含最坏值,埋点不得改变控制流;
- 判定逻辑纯函数化、时钟显式化,边界靠假时钟而不是真实等待;
- 跨组件补契约链测试,关键判定做破坏验证;
- 构建期门禁覆盖全部子包,并把盲区写进文档;
- 给模型看的文本(提示词 / 文档 / 索引)与实现纳入静态一致性扫描;
- 明确声明真机验证的边界与替代手段(巡检 + 灰度 + 用户反馈)。
结语
把 Agent 搬上一个新的平台,考验的不是模型能力,而是工程上对平台现实的尊重程度:
进程会死,所以每一步都要可恢复;后台会被限,所以要用租约而不是长持锁;
权限要运行时申请,所以"声明"不等于"可用";能力面由应用自定义,所以给模型看的文本必须与实现同步;
路径、类库、进程间通信都与桌面不同,所以平台能力从第一天起就该走注入,而不是硬编码。
最终标准只有一条:换一个宿主时,你改的是适配器,还是核心。