1. 为什么我拿登录模块来搭 Agent 开发环境
先说清楚这套东西到底在干什么。Harness 这个词在 Agent 开发圈子里,指的是包裹在模型外面那一层负责调度、约束、观测的工程骨架——模型本身只会吐 token,真正让它变成一个能干活、不出格、可复现的智能体,靠的全是这层骨架。而 Android 登录模块,是我试过最适合拿来练手 Harness 工程的场景:它有明确的状态机、有真实的异步 IO、有失败重试、有凭据管理、有 UI 反馈,几乎把 Agent 开发里所有容易翻车的地方都浓缩进了一个不到两千行的模块里。
你可能会问,搭 Agent 环境为什么不去搞个 Python 脚本或者 Node 服务,非要折腾 Android。我的理由很直接:Android 的 Kotlin 协程模型和 Jetpack Compose 的声明式 UI,天然就是一套“状态驱动 + 副作用隔离”的架构,这跟 Agent 的 Harness 层要解决的问题高度同构。Agent 执行过程中最怕的就是状态漂移和副作用失控,而 Compose 的State加协程的Flow,恰好给了你一套强制你把这些东西想清楚的工具。用登录模块当靶子,你能在一个真实可运行的 App 里,把 Harness 的每一个环节都摸一遍。
这套环境适合谁?如果你已经会写一点 Kotlin,能看懂suspend函数,但对“Agent 到底怎么被管起来”这件事只有模糊概念,那这篇就是给你写的。如果你是完全的新手,也没关系,我会把每个关键点拆到能直接抄的程度。整篇内容围绕一个核心目标:让你在 Android Studio 里跑起来一个登录模块,并且把这个模块的骨架改造成一个可复用的 Agent 执行环境。
我踩过的坑先摆一个出来:一开始我以为 Harness 就是写个try-catch把模型调用包起来,结果发现真正难的是超时后的状态回收和并发请求下的凭据刷新。登录模块里“token 过期后自动刷新”这个逻辑,跟 Agent 里“工具调用失败后重新规划”几乎是同一类问题。所以别小看这个场景,它比你想的深。
2. 环境搭建与项目骨架设计
2.1 Android Studio 与 Kotlin 工具链的选型理由
工欲善其事,第一步是把 Android Studio 装对。我目前稳定用的是 Android Studio 的较新稳定版,配套 JDK 17。为什么不追最新预览版?因为 Harness 工程本身要处理大量协程和编译期注解,预览版的 Kotlin 编译器偶尔会在suspend相关的类型推断上抽风,排查起来非常浪费时间。稳定版加 JDK 17 这个组合,我连续跑了三个月没出过编译层面的幺蛾子。
Kotlin 版本我锁在 1.9.x 系列。这里有个细节:compileOnly filetree(dir: 'libs', include: ['*.aar'])这种写法在 Gradle 里很常见,用来引入那些只在编译期需要、运行期由宿主提供的 AAR。如果你在做 Agent 的插件化 Harness,这个配置会经常用到——把 Agent 的能力接口编译进主工程,但具体实现留给运行时注入。注意compileOnly不会把 AAR 打进最终产物,所以别指望它能在运行时被反射加载到,这是两码事。
Gradle 的依赖配置我建议这样组织:
dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.compose.material3:material3:1.2.0") compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) }compileOnly那一行是给 Agent 能力包预留的。假设你有一个agent-core.aar,里面定义了工具调用的接口,主工程编译时需要它来通过类型检查,但运行时这个 AAR 由外部加载。这种设计在 Harness 工程里很关键,因为它让主工程和 Agent 实现解耦,你可以换不同的 Agent 后端而不用重新编译整个 App。
2.2 登录模块作为 Harness 载体的结构拆解
登录模块我拆成四层,这个分层直接对应 Harness 的四个职责:
- UI 层:Jetpack Compose 写的登录表单,负责展示状态和收集输入。对应 Harness 里的“观测与反馈”。
- ViewModel 层:持有登录状态机,暴露
StateFlow。对应 Harness 里的“状态管理”。 - Repository 层:封装网络请求和凭据存储。对应 Harness 里的“工具调用与副作用隔离”。
- Harness 层:这是我额外加的一层,夹在 ViewModel 和 Repository 之间,负责超时、重试、并发控制、日志埋点。
为什么要单独抽 Harness 层?因为如果你把重试逻辑写在 ViewModel 里,UI 状态和调度逻辑就搅在一起了,测试起来极其痛苦。抽出来之后,Harness 层可以独立做单元测试,ViewModel 只管把 Harness 吐出来的状态映射成 UI 状态。这个边界划清楚,后面加 Agent 能力的时候才不会乱。
2.3 用 Compose 构建可观测的登录界面
Compose 的声明式写法在这里帮了大忙。登录界面我只需要描述“状态是什么样,界面就长什么样”,不用手动去findViewById再setText。核心状态用一个密封类表示:
sealed interface LoginState { data object Idle : LoginState data object Loading : LoginState data class Success(val token: String) : LoginState data class Error(val message: String, val retryable: Boolean) : LoginState }retryable这个字段是 Harness 层塞进来的。网络超时是可重试的,密码错误是不可重试的,这个区分直接决定了 UI 上要不要显示“重试”按钮。你看,Harness 的决策就这样自然地流到了 UI 层,而不是让 UI 去猜。
进度条我用的是LinearProgressIndicator,绑定Loading状态。这里有个小技巧:进度条不要用if (loading) show()这种命令式写法,而是让状态驱动。因为 Agent 执行过程中状态切换非常频繁,命令式写法很容易出现“状态已经变了但 UI 没跟上”的鬼影。
3. Harness 核心机制:把登录流程改造成 Agent 执行骨架
3.1 状态机设计:从 Idle 到 Success 的完整流转
登录的状态流转看起来简单,但加上 Harness 的重试和超时之后,分支会变多。我画过一版状态转移表,贴在下面:
| 当前状态 | 事件 | 下一状态 | 说明 |
|---|---|---|---|
| Idle | 用户点击登录 | Loading | 校验输入后进入 |
| Loading | 请求成功 | Success | 保存 token |
| Loading | 请求超时 | Error(retryable=true) | 允许重试 |
| Loading | 凭据错误 | Error(retryable=false) | 不允许重试 |
| Loading | 用户取消 | Idle | 协程取消 |
| Error(retryable) | 用户点击重试 | Loading | 重新发起 |
这张表的价值在于,它逼你把每个分支都想清楚。Agent 执行也是一样,agent execution terminated due to error这种报错,十有八九是因为状态机里漏了某个分支,导致状态卡死。我在 Harness 层用一个MutableStateFlow<LoginState>来承载这个状态机,所有状态变更都通过一个transition函数走,禁止直接赋值。这样任何非法转移都会在编译期或运行期被拦住。
3.2 超时与重试:Harness 层最容易被低估的部分
超时和重试是 Harness 的灵魂。我见过太多 Agent 项目,模型调用一超时就整个崩掉,或者无脑重试把配额烧光。登录模块里我用withTimeout加指数退避:
suspend fun <T> withRetry( maxAttempts: Int = 3, initialDelayMs: Long = 500, factor: Double = 2.0, block: suspend () -> T ): T { var delayMs = initialDelayMs repeat(maxAttempts - 1) { attempt -> try { return withTimeout(10_000) { block() } } catch (e: TimeoutCancellationException) { delay(delayMs) delayMs = (delayMs * factor).toLong() } } return withTimeout(10_000) { block() } }这段代码有几个点值得说。第一,withTimeout抛的是TimeoutCancellationException,它是CancellationException的子类,所以如果你在外层用try-catch (e: Exception)会把协程取消也吞掉,这是大忌。第二,指数退避的factor我设成 2.0,初始 500ms,三次重试的总等待是 500 + 1000 = 1500ms,加上三次请求本身的时间,用户体验上还能接受。如果设成 3.0,等待时间会飙到 500 + 1500 = 2000ms,再往上就明显卡顿了。
注意:重试次数不是越多越好。Agent 场景下,如果工具调用本身有副作用(比如写文件、发请求),盲目重试可能造成重复执行。登录这种幂等操作可以放心重试,但涉及副作用的操作,重试前一定要做幂等性检查。
3.3 凭据管理:token 刷新与并发安全
token 过期刷新是登录模块里最像 Agent 的地方。Agent 执行长任务时,中间步骤的凭据也可能过期,这时候如果多个并发任务同时发现过期,就会触发多次刷新,造成竞态。我用一个Mutex加双检锁解决:
private val refreshMutex = Mutex() suspend fun getValidToken(): String { tokenCache?.let { if (!it.isExpired()) return it.value } return refreshMutex.withLock { tokenCache?.let { if (!it.isExpired()) return it.value } val newToken = api.refreshToken() tokenCache = newToken newToken.value } }withLock内部再检查一次,是因为可能在等锁的过程中,别的协程已经刷新好了。这个模式在 Agent 的 Harness 里会反复出现——任何共享资源的懒加载都要考虑并发。我实测下来,不加这层双检,在快速切换账号的场景下会出现 token 被刷新两次、旧 token 覆盖新 token 的问题。
3.4 日志与可观测性:让 Agent 的每一步都留痕
Harness 工程如果没有日志,出问题就是两眼一抹黑。我在 Harness 层埋了一个轻量的日志器,记录每次状态转移、每次请求的耗时、每次重试的原因。日志不直接println,而是走一个Flow收集起来,方便后面接 UI 或者上报。
private val _events = MutableSharedFlow<HarnessEvent>(extraBufferCapacity = 64) val events: SharedFlow<HarnessEvent> = _events.asSharedFlow() fun log(event: HarnessEvent) { _events.tryEmit(event) }用tryEmit而不是emit,是因为日志不应该阻塞主流程。extraBufferCapacity设 64 是防止高频事件把缓冲区撑爆。这个设计在 Agent 场景下特别有用,因为 Agent 执行会产生大量中间事件,你不可能每个都同步处理。
4. 实操全流程:从零跑通一个可复用的 Agent 环境
4.1 项目初始化与依赖配置的完整步骤
第一步,在 Android Studio 里新建一个 Empty Compose Activity 项目。包名随便起,我习惯用com.example.harnesslab。建好之后,打开build.gradle.kts(模块级),把依赖补全。除了前面提到的协程和 Compose 依赖,还要加上lifecycle-runtime-compose用来在 Compose 里收集StateFlow。
第二步,在libs目录下放你的 Agent 能力 AAR。如果没有现成的,可以先建一个空的 AAR 占位,或者干脆先注释掉compileOnly那行,等后面需要了再加。我建议一开始先不加,把登录流程跑通,再引入 AAR,这样出问题容易定位。
第三步,配置AndroidManifest.xml的网络权限。登录要发请求,INTERNET权限是必须的。如果你用的是明文 HTTP 调试,还要加usesCleartextTraffic,但生产环境千万别这么干。
第四步,建包结构。我按ui、viewmodel、repository、harness、model五个包来分。这个结构不是死的,但 Harness 单独成包很重要,因为它将来要复用到别的模块。
4.2 登录请求的 Harness 封装与参数计算
登录请求本身很简单,POST 一个用户名密码,拿回 token。但 Harness 封装要考虑的参数不少。超时时间我设 10 秒,这个值怎么来的?移动网络下,DNS 解析加 TLS 握手加服务端处理,正常在 2 秒内完成,10 秒是留了 5 倍余量。如果超过 10 秒,基本可以判定网络异常,重试比干等更划算。
重试次数设 3 次,前面算过总等待 1.5 秒。这个值是在“用户能忍受”和“给网络足够恢复时间”之间取的平衡。我做过小范围测试,超过 3 次重试,用户放弃率明显上升。
并发控制方面,登录按钮在Loading状态下要禁用,防止用户连点触发多个请求。这个在 Compose 里用enabled = state !is Loading就能搞定。但更稳妥的做法是在 Harness 层用一个AtomicBoolean做闸门,因为 UI 层的禁用有可能被绕过(比如通过无障碍服务触发)。
private val inFlight = AtomicBoolean(false) suspend fun login(username: String, password: String): LoginState { if (!inFlight.compareAndSet(false, true)) { return LoginState.Error("请求进行中", retryable = false) } return try { // 实际请求逻辑 } finally { inFlight.set(false) } }compareAndSet是原子操作,比if (!flag) { flag = true }这种写法安全得多。后者在并发下会有窗口期,两个协程可能同时通过检查。
4.3 把登录状态接到 Compose UI 的实操记录
ViewModel 里持有 Harness 实例,暴露StateFlow<LoginState>。Compose 侧用collectAsStateWithLifecycle()收集。这里有个细节:一定要用WithLifecycle版本,它会在界面不可见时自动停止收集,避免后台无谓的状态更新。
@Composable fun LoginScreen(viewModel: LoginViewModel) { val state by viewModel.state.collectAsStateWithLifecycle() Column(modifier = Modifier.padding(16.dp)) { when (val s = state) { is LoginState.Loading -> LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) is LoginState.Error -> Text(s.message, color = MaterialTheme.colorScheme.error) else -> Unit } Button( onClick = { viewModel.login(username, password) }, enabled = state !is LoginState.Loading ) { Text("登录") } } }实测下来,这套写法在旋转屏幕、切后台再回来这些场景下状态都不会丢,因为状态在 ViewModel 里,而 ViewModel 的生命周期比 Composable 长。这也是为什么我坚持把状态机放在 ViewModel 而不是 Composable 内部。
4.4 用 Harness 事件流做调试面板
调试 Agent 的时候,光看最终状态是不够的,你需要看到中间发生了什么。我在 App 里加了一个隐藏的调试面板,长按标题栏触发,展示 Harness 事件流。这个面板在开发阶段帮了大忙,能看到每次重试的间隔、每次请求的耗时。
事件流用LazyColumn渲染,每条事件显示时间戳和内容。因为事件可能很多,我给SharedFlow设了缓冲区,并且调试面板只保留最近 100 条。这个面板的代码不复杂,但价值极高,建议你在自己的项目里也加一个。
5. 踩坑记录与常见问题排查
5.1 协程取消被吞导致的诡异卡死
最常见的坑就是CancellationException被当成普通异常处理了。比如你写了catch (e: Exception),协程取消时抛出的CancellationException会被捕获,然后你的代码继续往下走,但协程其实已经被取消了,后续的suspend调用会立刻抛出取消异常,造成状态错乱。
排查方法:在 Harness 层的所有catch块里,第一件事就是检查if (e is CancellationException) throw e。或者更彻底一点,只捕获你明确知道怎么处理的异常类型,别用宽泛的Exception。
5.2 token 刷新引发的并发问题速查
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 刷新后仍报 401 | 旧 token 覆盖新 token | 打日志看刷新次数 | 加 Mutex 双检 |
| 刷新请求风暴 | 多个协程同时发现过期 | 统计单位时间刷新次数 | 加单飞机制 |
| 刷新死循环 | 刷新接口本身返回 401 | 看刷新接口的响应 | 刷新失败直接登出 |
这张表是我实际遇到问题后整理的。特别是“刷新死循环”,如果刷新 token 的接口也需要鉴权,而鉴权又依赖 token,就会绕不出来。解决办法是刷新接口用独立的凭据,或者刷新失败直接跳登录页。
5.3 Compose 重组导致的重复请求
Compose 的重组很频繁,如果你在 Composable 里直接调viewModel.login(),每次重组都会触发一次。正确做法是把请求放在LaunchedEffect里,或者通过按钮点击触发。我见过有人在LaunchedEffect(Unit)里发请求,结果因为 key 设错,每次重组都重新执行。记住LaunchedEffect的 key 决定了它什么时候重启,Unit表示只执行一次,但前提是这个 Composable 不被销毁重建。
5.4 网络超时与重试的边界情况
超时重试有个边界情况:请求其实已经到了服务端并且执行成功,但响应在返回路上超时了。这时候重试会导致服务端执行两次。登录这种幂等操作无所谓,但如果是“创建订单”这种非幂等操作,就会出大问题。Harness 层要支持给每个操作打上“是否幂等”的标记,非幂等操作要么不重试,要么带幂等键。
6. 从登录模块到通用 Agent Harness 的扩展思路
6.1 把工具调用抽象成可插拔的能力
登录模块里的“发请求”本质上就是一次工具调用。我把这个抽象出来,定义成Tool接口:
interface Tool<I, O> { val name: String val idempotent: Boolean suspend fun execute(input: I): O }登录工具实现这个接口,将来 Agent 要调用别的能力,也实现这个接口。Harness 层只认Tool,不认具体实现。这样你换一个 Agent 后端,只要它提供的工具实现了这个接口,Harness 层完全不用改。
6.2 用 Skill 组合多个工具完成复杂任务
单个工具能力有限,Agent 真正干活靠的是把多个工具串起来。我引入Skill的概念,一个 Skill 是一组工具的编排。比如“登录并拉取用户信息”就是一个 Skill,它先调登录工具,再用拿到的 token 调用户信息工具。Skill 内部可以用协程的async做并行,也可以用awaitAll等所有结果。
这里要注意错误传播。Skill 里某一步失败,后面的步骤要不要继续?我的做法是默认快速失败,但允许 Skill 声明“容错步骤”,容错步骤失败只记日志不中断。这个策略在 Agent 场景下很实用,因为有些工具调用失败不影响整体任务完成。
6.3 观测数据回流:让 Harness 越用越聪明
Harness 收集的事件流不只是用来调试,还可以回流做优化。比如统计每个工具的平均耗时,如果某个工具经常超时,就可以调整它的超时阈值。或者统计重试成功率,如果某个操作重试三次成功率还是很低,说明重试策略需要改。
我把这些统计做成一个简单的MetricsCollector,定期把聚合数据打日志。将来如果接入更复杂的 Agent 调度,这些数据就是决策依据。比如“这个工具最近失败率高,先别用它,换个备选”,这就是 Harness 智能化的雏形。
6.4 移植到其他平台的注意事项
这套 Harness 骨架不绑定 Android。核心的状态机、重试、并发控制都是纯 Kotlin 逻辑,可以抽成 Kotlin Multiplatform 模块,在 iOS 或者后端复用。唯一需要平台适配的是日志输出和 UI 反馈。我试过把 Harness 层抽出来放到一个纯 Kotlin 模块里,Android 侧只保留 UI 和平台相关的网络实现,编译通过,运行正常。
移植的时候注意协程调度器的差异。Android 主线程调度器是Dispatchers.Main,后端可能是Dispatchers.Default。Harness 层不要硬编码调度器,而是通过构造函数注入,这样换平台时只改注入的地方。
7. 我个人在实际操作中的几点体会
这套东西我前前后后搭了大概两周,中间推翻重来过一次。最大的体会是:Harness 的价值不在于它多复杂,而在于它把“意外情况”都变成了“预期情况”。登录模块里每一个可能出错的地方,我都提前在 Harness 层处理了,所以上层 UI 写起来特别省心,只需要关心正常流程。
另一个体会是关于测试。Harness 层因为不依赖 Android 框架,可以用纯 JUnit 测试。我写了十几个测试用例,覆盖超时、重试、并发刷新这些场景,跑起来只要几秒。如果没有这层抽象,这些逻辑混在 ViewModel 里,测试就得用 Robolectric,慢不说,还容易 flaky。
最后分享一个小技巧:Harness 层的事件流,我在开发阶段会同时输出到 Logcat 和一个内存环形缓冲区。Logcat 方便实时看,环形缓冲区方便出问题后回溯。环形缓冲区的大小设成 256 条,足够覆盖一次完整的登录流程。这个双通道日志的设计,让我排查问题的效率至少提高了一倍。