
前两天帮一个团队排查 Agent 开发环境的问题代码跑了三个月一直好好的换了一台测试机直接起不来。查到最后不是模型接口的问题不是算法的问题是环境初始化顺序错了——登录态还没就绪Harness 就开始加载插件插件里又有模块强依赖用户配置于是一连串玄学报错接踵而至。那次之后我下定决心把整个 Agent 开发环境重新拆了一遍。核心思路就一句话用 Android 登录模块当环境初始化的入口再用 Harness 把 Agent 的运行周期、插件加载、配置下发统一管起来。这篇就把这套方案的完整落地过程写出来适合要在 Android 端跑 Agent、想做插件化框架、或者正在被环境搭建折磨的移动端开发者。1. 先搞清楚 Harness 在 Android Agent 工程里到底管什么1.1 Agent 开发环境为什么需要一个“壳”很多人第一次听到 Harness第一反应是“又多了一个框架要学”。其实换个角度理解就顺了Agent 不是一个函数调用你调完它就结束了。它有完整的生命周期要初始化模型配置、加载技能插件、建立会话上下文、接受任务指令、执行动作、释放资源。这一整套流程如果散落在业务代码里前期跑 Demo 没感觉等插件多了、并发上来了、用户量大了问题会集中爆发。打个比方Agent 的核心能力像一匹马的力气力气再大没有马具你也拉不了车。Harness 就是那副马具——它规定了马怎么发力、车怎么挂、缰绳怎么握。具体到工程里Harness 管三件事插件加载与版本管理Agent 的技能以插件形式存在Harness 负责在启动时扫描、校验、加载这些插件生命周期调度Agent 从创建到销毁每一步由谁触发、状态怎么流转都由 Harness 统一管理配置注入与运行时隔离登录态、用户偏好、模型参数等通过 Harness 注入到 Agent 运行环境避免插件之间直接读全局变量互相污染。没有这个壳你会遇到最典型的问题两个插件各自带了一份配置文件初始化顺序不对后加载的覆盖了先加载的或者某个插件在后台线程里偷偷改了全局状态下一个任务莫名其妙就崩了。这些都是我在实际项目里踩过的。1.2 Android 登录模块为什么能当初始化入口那为什么环境初始化要放在登录模块里道理很简单登录是用户在 App 里唯一的必经通道没有第二个更合适的时机。你想Agent 运行需要哪些信息第一是用户身份决定了权限边界和数据归属第二是环境配置包括模型服务的地址、插件清单、功能开关第三是会话上下文上一轮对话的摘要、偏好设置、常用工具。这些东西全都和“这个用户是谁”强相关。如果不在登录成功这个节点去拉取和注入那 Agent 初始化的时候就只能靠默认值跑起来之后再切换用户身份整个上下文都得重建非常别扭。所以我把登录模块做成了一个“环境初始化发射器”登录成功后依次完成 token 换取、配置拉取、Harness 装配、插件加载四个动作。这样用户一进主界面Agent 就已经处于“可对话”状态而不是先给个空壳再慢慢等配置。1.3 选型结论轻量框架 登录态绑定 插件化扩展当时摆在我面前的有三条路自己手写一套初始化流程、引入商业化的容器框架、用轻量 Harness 自研定制。对比下来方案优点缺点我的取舍手写初始化灵活、无依赖状态管理容易失控插件一多就乱不推荐除非只有两个插件商业化容器功能全、文档多重、侵入性强Android 端适配成本高适合大团队不适合快速验证轻量 Harness 自研核心机制简洁可定制需要自己补一些能力最终选了这个选了第三条路之后整个项目的气质就变了插件开发只需要遵循 Harness 的接口协议不需要关心宿主怎么初始化宿主也不关心每个插件内部怎么实现只管通过登录态把配置喂进去。这是我在这套方案里最满意的一个决策。2. 工程骨架与依赖准备Android Studio 侧的最小可运行环境2.1 工程目录怎么摆Android Studio 里我采用的是单 App 模块 两个 Library 模块的结构。有人会问一个小项目拆那么细干嘛我的答案很简单插件化的前提是边界清晰。如果所有代码都堆在 app 模块里今天能拆明天就拆不动了。HarnessDemo/ ├── app/ # 宿主壳工程 │ ├── src/main/java/... # 登录模块、主界面、Agent 入口 │ └── src/main/assets/harness/ # harness 配置与插件资产离线兜底 ├── harness-core/ # Harness 框架本体 │ ├── plugin-api/ # 插件接口定义插件开发者只依赖这个 │ ├── loader/ # 插件扫描、校验、加载逻辑 │ └── runtime/ # Agent 生命周期与调度器 └── plugins/ ├── plugin-device/ # 设备控制插件 ├── plugin-notify/ # 消息通知插件 └── plugin-weather/ # 天气查询插件这个结构里app 只负责把用户登录态转换成 Harness 的初始化参数真正的 Agent 能力全部由 plugins 提供。插件之间不允许互相直接调用只能通过 Harness 暴露的接口通信。这规则前期会显得麻烦但到了第三个插件出现之后你会感谢这个约束。2.2 依赖与配置Harness 本身不依赖任何重量级框架核心就用了协程和 OkHttp避免给插件开发者增加心智负担。下面是 app 模块 build.gradle 里最核心的依赖按用途做了标注dependencies { // Harness 核心 implementation project(:harness-core) implementation project(:plugin-device) implementation project(:plugin-notify) implementation project(:plugin-weather) // 协程Agent 生命周期调度的基础 implementation org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 // 网络拉取远端配置、上报 Agent 运行状态 implementation com.squareup.okhttp3:okhttp:4.12.0 // 本地加密存储登录 token 和敏感配置 implementation androidx.security:security-crypto:1.1.0-alpha06 // JSON 解析配置文件和插件描述 implementation com.google.code.gson:gson:2.10.1 }依赖这块有个建议插件接口模块plugin-api里面尽量不要放任何实现代码最好连 Android 的 Context 都不依赖。这样以后做单元测试、跨端复用都会轻松很多。我见过一个项目为了图方便在接口里直接传 Activity后来插件跑到后台任务里直接崩溃这种耦合坑的是自己。2.3 关于 FileProvider 和文件路径的那点坑我记得你搜过content://com.baidu.searchbox.fileprovider或者content://com.tencent.wework.fileprovider这种路径这背后其实是 Android 7.0 开始的一个大坑应用之间传递文件路径不再允许直接传file://必须通过 FileProvider 转成content://URI。在 Harness 场景里这个问题出现在插件资源加载上。插件除了代码可能还需要图库、模型文件、本地知识库。这些文件如果放在应用私有目录插件内部可以随便读但要传给另一个应用或者系统组件比如让系统相册选一张图再喂给 Agent 做多模态输入就必须走 FileProvider。我当时在AndroidManifest.xml里加了这样一段provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider然后在res/xml/file_paths.xml里把插件需要对外共享的目录列出来paths files-path nameplugin_resources pathplugin_resources/ / cache-path nameshared_cache pathshared_cache/ / /paths这里有个容易被忽略的点如果插件通过 Harness 接口返回了一个文件路径给宿主宿主再拿去给别的应用用一定要用FileProvider.getUriForFile()转换成 URI同时带上临时读权限。我踩过一次很隐蔽的坑转换出来的 URI 是对的但没加Intent.FLAG_GRANT_READ_URI_PERMISSION目标应用直接FileNotFoundException。日志还特别误导人报的却是路径不存在查了半天才发现是权限位问题。3. 登录模块设计把用户态变成环境配置的发射器3.1 登录态与 Agent 会话的绑定思路登录模块在这套方案里不是简单地把 token 存下来就完事它要把“用户态”翻译成“Agent 环境变量”。我是这样做的登录接口返回三个东西——accessToken、refreshToken、userProfile。拿到之后先写入EncryptedSharedPreferences别用普通 SharedPreferences 存 token明文存等于裸奔然后构造一个AgentEnvironment对象data class AgentEnvironment( val userId: String, val accessToken: String, val profile: UserProfile, val configSource: ConfigSource, // 远端/本地缓存/默认值 val extraArgs: MapString, String )这个AgentEnvironment就是 Harness 初始化的唯一入参。Harness 拿到它会做三件事把 userId 写入会话上下文所有插件都能通过接口查“当前是谁”把 accessToken 作为默认请求头注入网络层插件调用模型服务时自动带上鉴权把 extraArgs 里的偏好设置分发给各个插件。这里的设计要点是插件永远不要自己读 SharedPreferences也不要自己持有 token。谁都不读就不会乱只有 Harness 注入就只有一个来源。这个原则执行到位之后多用户切换、退出登录再登录Agent 上下文都能干干净净地重建。3.2 配置拉取与本地缓存一个可靠的初始化流程登录成功只是起点真正影响可靠性的是配置拉取的流程设计。我的初始化流程分三层远端配置优先、本地缓存兜底、内置默认值保底。完整链路是这样的登录成功后启动一个带超时的协程去拉取远端配置/api/v1/agent/config拿到完整的插件清单、各插件参数、模型服务地址、功能的开关状态把远端配置写入本地缓存文件下次启动即使断网也能用如果远端请求超时或失败直接读本地缓存如果本地缓存也没有首次安装且断网用assets/harness/harness.default.json里的默认配置顶上去界面给出“离线模式”提示。这个“三层降级”的思路是我从移动端推送消息的离线策略里借过来的。Agent 环境初始化和消息拉取本质上是一回事能拿到最新的最好拿不到也不能让用户干瞪眼。实际运营下来这个策略最大的价值是让 Harness 在弱网环境下依然能启动虽然功能可能受限但“用户能打开 App”这个底线保住了。3.3 token 过期与静默续期别让登录态卡住 Agent登录态过期是 Agent 环境里最磨人的问题之一。你想一个场景用户和 Agent 聊到一半后台模型服务返回 401这时候如果直接把会话打断弹登录页体验非常糟糕。更严重的是一批定时任务型的 Agent 插件比如每天定时汇总数据如果 token 在凌晨过期了任务直接失败用户早上起来一脸懵。我的方案是三层配合第一层网络层统一拦截。OkHttp 的Authenticator接口会对 401 响应自动尝试刷新 token刷完自动重放原请求。对业务代码来说它根本感知不到 token 过期。class TokenAuthenticator( private val tokenStore: TokenStore ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { synchronized(this) { val refreshSuccess tokenStore.refreshToken() if (!refreshSuccess) return null return response.request.newBuilder() .header(Authorization, Bearer ${tokenStore.accessToken}) .build() } } }第二层预判续期。在 token 有效期的最后 10 分钟如果检测到 App 在前台且 Agent 处于活跃状态就主动发起一次静默续期避免对话过程中触发刷新。第三层RefreshToken 也失效时的降级。这时候 Harness 会把 Agent 切换到“只读模式”已有的会话历史还能看但新任务不再执行等用户重新登录再恢复。这个设计是为了避免在鉴权失效后还在执行写操作造成数据错乱。这三层做完之后“登录态卡住 Agent”的问题基本绝迹。我后来复盘这套机制花了一天时间写但省掉了未来无数个凌晨三点被 call 起来的夜晚。4. Harness 的配置写法与插件加载机制4.1 harness 配置文件的基本盘Harness 的核心配置是一份 JSON 文件我习惯叫它harness.config.json。它放在 assets 目录下同时有一份远端版本可以热更新。下面是一份精简示例{ name: agent-runtime, version: 1.3.0, lifecycle: { initTimeoutSeconds: 15, sessionIdleTimeoutSeconds: 300, maxRestartCount: 3 }, plugins: [ { id: plugin-device, version: 1.2.0, path: plugins/plugin-device, autoload: true, permissions: [device.control, system.info] }, { id: plugin-notify, version: 2.0.0, path: plugins/plugin-notify, autoload: true } ], settings: { modelEndpoint: default, maxConcurrency: 8, enableSandbox: true } }字段含义逐个说lifecycle.initTimeoutSecondsAgent 初始化的最大容忍时间超过就按失败处理不允许无限等下去plugins.autoload是否在 Harness 启动时自动加载。非核心插件可以设成 false等用户用到时再懒加载permissions插件声明自己需要的权限Harness 在加载时会检查权限不满足直接拒绝加载而不是等到运行时才崩溃settings.maxConcurrencyAgent 同时执行的最大任务数后面讲并发时会细说。这份配置有意思的地方在于它把“哪些能力可用”和“能力怎么运行”从代码里拆出来了。运营同学以后想关掉某个插件不需要发版后台改一下配置就行。4.2 插件加载失败的排查链路网上关于 Harness 报错有个高频关键词harness failed to load plugins具体日志像web boot: 2 entries did not activate这种。我第一次看到这个报错的时候是懵的因为它没有直接告诉你哪个插件挂了、为什么挂。后来总结出一条排查链路按顺序走基本十分钟内能定位排查步骤操作判断标准第一步检查插件目录是否存在日志里会有plugin not found路径写错最常见第二步检查插件 Manifest 声明插件 APK 或目录里缺少plugin.json/plugin.xmlHarness 直接跳过第三步检查版本约束配置要求1.2.0本地是1.1.0就会did not activate第四步打开 Harness debug 日志看每个插件被拒绝的具体原因在logcat里过滤HarnessLoader一条重要经验千万不要在日志信息不完整的时候瞎猜。我见过有人盯着2 entries did not activate看了两个小时最后发现只是插件目录名多了一个字母。先看目录再看声明再看版本最后看 debug 日志这个顺序是我踩过坑才总结出来的。4.3 插件目录与版本约束Harness 对插件加载的约束核心是“目录即边界”。我的插件发布物是一个plugin-xxx文件夹里面包含plugin-weather/ ├── plugin.json # 插件元信息id、版本、入口类、权限声明 ├── lib/ │ ├── libplugin-weather.so # JNI 实现如果有 │ └── plugin-weather.jar # 插件字节码 ├── resources/ │ └── data.db # 插件自带的知识库或模型文件版本约束这块我吃过大亏。早期没有做版本校验插件 A 更新后要求新版本的接口协议但宿主里还留着旧版插件 B 反向依赖老接口结果就是一场线上事故。后来我增加了两条硬性规则插件声明自己依赖的协议版本范围Harness 加载时统一做版本冲突检测发现互相矛盾直接拒绝加载并上报冲突详情。宁可不加载也不要带病运行。5. 联调实测并发、沙盒更新与进度反馈5.1 并发请求怎么扛连接池与任务队列“AI Agent 怎么扛并发”是我搜自己项目相关问题时高频出现的词。先说结论Agent 的并发瓶颈基本不在计算而在网络和状态管理。网络层最容易忽略的是 OkHttp 的并发配置。默认连接池也许够用但 Agent 场景比较特殊一个用户可能同时发起多个子任务每个子任务又要调模型服务、查知识库、调用第三方 API瞬间就是几十个并发请求。如果每个请求都新建连接TCP 握手开销就够让接口层先崩。我当时的做法是OkHttp 连接池设成maxIdleConnections 20、keepAliveDuration 120sDispatchers 上给 Agent 执行任务单独用一个限流调度器信号量控制在配置里的maxConcurrency以下。val agentDispatcher Dispatchers.IO.limitedParallelism(8) class AgentTaskExecutor(private val semaphore: Semaphore) { suspend fun T runWithLimit(block: suspend () - T): T { semaphore.withPermit { block() } } }这里的关键信号量的值不是拍脑袋定的。我当时压测 100 个任务并发发现模型服务响应时间在 8 并发以内很稳超过 12 就开始排队20 直接超时。所以最终maxConcurrency 8响应时间和吞吐的平衡点最好。5.2 沙盒更新要有进度反馈别让用户干等Agent 的沙盒sandbox是用来隔离插件执行环境的模型、插件脚本、临时文件都在里面。沙盒版本更新是一个耗时操作尤其是要拉取新的基础镜像或者重跑初始化脚本。如果只给一个转圈图标用户大概率想杀 App。我的做法是给 Harness 加了一套进度回调接口interface SandboxUpdateListener { fun onStageChanged(stage: SandboxStage) fun onProgress(percent: Int) fun onFinished() fun onFailed(errorCode: Int, message: String) } enum class SandboxStage { DOWNLOADING, // 拉取沙盒更新包 VERIFYING, // 校验完整性与签名 INSTALLING, // 安装到本地沙盒目录 RESTARTING // 重启沙盒进程 }界面上用通知栏进度条承接这个回调。用户退到后台也能看到“沙盒更新中 45%”切回前台时显示具体阶段。这里要提醒一句不要把更新流程做成“只能等”。我给沙盒更新加了取消和恢复用户如果着急用可以跳过更新先用旧沙盒跑等空闲了再补更新。这个“可跳过”的设计在后来的发布中救了大命——有一次沙盒更新包出了问题全量卡死但因为用户可以跳过事故影响面被限制住了。5.3 消息发送失败与进程保活的兜底方案联调时最容易翻车的还有两件小事消息发送失败、进程被杀。消息发送失败类似 Codex 或 Agent 客户端常见的“无法发送消息”问题。根因大多是队列积压、网络抖动、服务端超时。我做的兜底方案是所有 Agent 的对外消息先落本地队列发送成功后移除失败则按指数退避重试30s、2min、5min最多三次三次还失败就标记为“发送失败点击重试”并存一条系统通知提醒用户。进程保活这块在 Agent 场景里有个悖论你既希望后台执行任务又知道 Android 系统随时可能回收进程。我的方案不是硬抗系统而是让任务可中断、可恢复。核心流程用 WorkManager 跑把执行状态持久化到本地每一步开始、拉到半途、完成都记录 checkpoint进程被杀了重启之后从最近的 checkpoint 续跑。val workRequest OneTimeWorkRequestBuilderAgentResumeWorker() .setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() ) .addTag(agent-resume) .build()这个方案的逻辑是不追求进程永远不死而是死了之后能在 30 秒内原地复活任务不丢、进度不丢、用户无感。6. 从失败里捞出来的经验插件加载、并发与登录态的隐性耦合6.1 插件加载失败的顺序问题根出在登录态没就绪回到文章开头那个排查案例。当时插件加载失败我一直按“插件目录、声明、版本”查全都没问题。最后发现是协程时序问题Harness 在登录态回调完成之前就开始扫描插件而某个插件初始化的逻辑要读用户配置读不到就抛异常Harness 判定插件不可激活就把整个插件跳过了。解决办法很“笨”在 Harness 的启动方法里加一个前置条件明确要求AgentEnvironment非空且configSource ! ConfigSource.NONE不满足就直接抛“环境未就绪”异常而不是继续执行。错误要早爆爆得清楚比晚爆之后查半天强得多。6.2 并发上限不是越大越好要先算三个数前面说到maxConcurrency 8这个值不是固定不变的。换模型服务商、换机型、换网络环境最优并发数都会变。我的建议是每次调环境时重跑一次压测统计三个数平均单次请求耗时、服务端成功率曲线、客户端内存水位。以这三个数决定并发上限而不是为了“显得快”盲目调大。并发调大还有一个隐性代价内存峰值和电量消耗会同步上涨移动端更容易被系统判为“耗电大户”。6.3 调试模式日志、开关与灰度最后一个经验是给 Harness 工程加一把“调试后门”。我用 Gradle 的 Build Variant 区分了release和debug其中 debug 变体会打开 Harness 的 verbose 日志、插件热替换、模拟登录态注入三个能力。verbose 日志每个插件加载的耗时、依赖项、阶段状态都打印出来插件热替换改完插件代码不用重启 App通过 adb 命令推送新版本到插件目录Harness 监听到文件变化自动重新加载模拟登录态注入本地调试时绕开真实登录直接填入测试 userId 和环境参数方便快速复现某个用户的问题。我后来处理线上问题基本都是靠这套 debug 后门拿到用户 ID本地模拟登录态热替换一个加了埋点的插件版本问题复现后日志直接打到 logcat定位效率比之前快了好几倍。这也是我自己在这个项目里最得意的一项基建。