1. 为什么我拿 Android 登录模块来搭 Agent 开发环境
先说清楚一件事:这个项目标题听起来像是两个八竿子打不着的东西硬凑在一起——Android 登录模块和 Agent 开发环境。但我实际做下来发现,用 Android 登录模块作为切入点来搭建一套可靠的 Agent 开发环境,是一个非常务实的选择。原因很简单:登录模块天然包含了状态管理、异步任务编排、错误重试、多步骤流程控制这几个核心要素,而这些恰好是 Agent 开发中最常打交道的场景。
你可能会问,Agent 开发不是应该用 Python 或者 Node.js 吗?确实,大部分 Agent 框架的官方 SDK 都是 Python 优先。但如果你是一个 Android 开发者,手头最熟悉的工具链就是 Kotlin、Jetpack Compose、Android Studio,那强行切到 Python 生态反而会拖慢你的学习节奏。我的思路是:用自己最熟悉的语言和框架,先把 Agent 的核心概念跑通,再考虑跨语言迁移。
这套环境能做什么?简单说,它能让你在 Android 项目里模拟一个完整的 Agent 执行链路:接收用户输入、调用工具函数、处理中间状态、返回最终结果。适合谁?适合有 Kotlin 基础、了解 Jetpack Compose、想入门 Agent 开发但不想离开 Android 舒适区的开发者。如果你完全没写过 Android,那这篇文章可能门槛偏高,但核心思路仍然可以参考。
我踩过的第一个坑就是:一开始想直接引入某个 Agent 框架的 Java SDK,结果发现文档少得可怜,版本兼容性也一塌糊涂。后来我换了个思路——不依赖任何第三方 Agent 框架,用纯 Kotlin 手写一个最小可用的 Agent 调度器,反而跑通了。下面我把整个搭建过程拆开讲。
2. 整体设计思路与方案选型
2.1 为什么选 Kotlin + Jetpack Compose 而不是传统 View 体系
Kotlin 的协程体系是这套方案的核心基石。Agent 的执行过程本质上是异步任务链:用户发起请求 → 解析意图 → 调用工具 → 等待结果 → 决定下一步 → 返回响应。这个链路里充满了挂起、恢复、超时、重试。用 Kotlin 协程来处理这些,代码量比回调地狱少一个数量级。
Jetpack Compose 的价值在于状态驱动 UI。Agent 的执行状态是不断变化的——空闲、思考中、调用工具中、等待用户确认、完成、出错。用 Compose 的State来驱动界面,状态一变 UI 自动重组,不需要手动去findViewById再setText。我实测下来,用 Compose 写 Agent 状态面板,代码量只有传统 View 的三分之一。
至于为什么不用 Flutter 或者 React Native,理由很直接:我需要调用 Android 原生的ContentResolver、SharedPreferences、WorkManager这些能力来做工具函数的模拟,跨平台框架在这块会有额外的桥接成本。
2.2 Agent 调度器的核心架构
我把整个 Agent 调度器分成四层:
- 输入层:接收用户文本输入,做基础清洗和意图分类
- 规划层:根据意图决定调用哪个工具,生成执行计划
- 执行层:实际调用工具函数,处理返回结果和异常
- 状态层:维护整个执行过程的状态机,驱动 UI 更新
这四层之间通过Flow来传递数据。为什么用Flow而不是LiveData?因为 Agent 的执行过程会产生连续多个中间状态,Flow的操作符(map、flatMapLatest、catch、retry)能非常优雅地处理这些场景。比如工具调用失败时,我直接用.retry(3)就能实现自动重试,不需要写额外的重试逻辑。
2.3 登录模块为什么适合作为切入点
登录模块的流程是:用户输入账号密码 → 校验格式 → 发起网络请求 → 处理成功/失败 → 保存登录态 → 跳转。这个流程和 Agent 执行链路几乎一一对应:
| 登录模块环节 | Agent 对应环节 |
|---|---|
| 输入账号密码 | 接收用户指令 |
| 校验格式 | 意图解析与参数校验 |
| 发起网络请求 | 调用工具函数 |
| 处理成功/失败 | 处理工具返回结果 |
| 保存登录态 | 维护会话上下文 |
| 跳转首页 | 返回最终响应 |
所以我把登录模块改造成了一个"Agent 执行器":账号密码输入框变成指令输入框,登录按钮变成执行按钮,登录结果展示区变成 Agent 执行日志区。这样我可以在一个熟悉的场景里,把 Agent 的核心机制全部跑一遍。
3. 核心细节解析与实操要点
3.1 协程作用域的选择与生命周期绑定
Agent 执行器需要一个协程作用域来启动协程。这里有个关键决策:用GlobalScope、viewModelScope还是自定义CoroutineScope?
我的选择是自定义CoroutineScope并绑定到 ViewModel 的生命周期。原因如下:
GlobalScope的生命周期和 App 进程一致,Agent 执行到一半用户退出页面,协程还在跑,容易造成内存泄漏和状态错乱viewModelScope虽然绑定了 ViewModel,但 Agent 执行可能需要跨页面存活(比如用户切到后台再回来),viewModelScope在 ViewModel 清除时会取消所有子协程- 自定义
CoroutineScope配合SupervisorJob,可以精确控制哪些协程需要取消,哪些需要继续执行
具体代码是这样的:
class AgentViewModel : ViewModel() { private val agentScope = CoroutineScope( SupervisorJob() + Dispatchers.Default + CoroutineName("AgentExecutor") ) override fun onCleared() { super.onCleared() agentScope.cancel() } }注意:
SupervisorJob的作用是让子协程的失败不会影响兄弟协程。Agent 执行过程中,某个工具调用失败不应该导致整个 Agent 崩溃,所以必须用SupervisorJob而不是普通的Job。
3.2 工具函数的注册与发现机制
Agent 需要调用各种工具函数。我用一个ToolRegistry来管理所有可用工具:
data class ToolDefinition( val name: String, val description: String, val parameters: Map<String, String>, val executor: suspend (Map<String, Any>) -> ToolResult ) class ToolRegistry { private val tools = mutableMapOf<String, ToolDefinition>() fun register(tool: ToolDefinition) { tools[tool.name] = tool } fun find(name: String): ToolDefinition? = tools[name] fun allTools(): List<ToolDefinition> = tools.values.toList() }每个工具的定义包含名称、描述、参数列表和执行函数。执行函数是suspend的,因为工具调用通常涉及 IO 操作。
我注册了三个示例工具来模拟真实场景:
query_user_info:模拟查询用户信息,延迟 500ms 返回calculate:模拟数学计算,立即返回send_notification:模拟发送通知,可能随机失败
实操心得:工具的描述字段非常重要。如果你后续要接入真正的 LLM 来做意图识别,工具描述就是给 LLM 看的"说明书"。描述写得越清晰,LLM 选对工具的概率越高。我建议描述格式统一为"动词 + 名词 + 用途说明",比如"查询用户信息,根据用户 ID 返回姓名和邮箱"。
3.3 状态机的设计与状态流转
Agent 的状态我用一个密封类来表示:
sealed class AgentState { object Idle : AgentState() data class Thinking(val input: String) : AgentState() data class ExecutingTool(val toolName: String, val params: Map<String, Any>) : AgentState() data class ToolResult(val toolName: String, val result: String) : AgentState() data class Completed(val finalResponse: String) : AgentState() data class Error(val message: String, val cause: Throwable? = null) : AgentState() }状态流转规则是:Idle → Thinking → ExecutingTool → ToolResult → Completed,任何环节出错都跳到Error。
为什么用密封类而不是枚举?因为不同状态需要携带不同的数据。Thinking需要携带用户输入,ExecutingTool需要携带工具名和参数,枚举做不到这一点。密封类配合when表达式,编译器会强制你处理所有分支,避免遗漏状态。
3.4 超时与重试策略的参数计算
Agent 执行过程中,工具调用可能超时。我设置的超时时间是 5 秒,重试次数是 2 次(总共最多执行 3 次)。这个参数是怎么算出来的?
假设单个工具调用的平均耗时是 500ms,网络抖动导致的 P99 耗时是 2 秒。那么 5 秒的超时时间可以覆盖 99.9% 的正常情况。重试 2 次的理由是:如果第一次失败是网络抖动,第二次大概率能成功;如果连续 3 次都失败,说明不是偶发问题,继续重试也是浪费资源。
重试的退避策略我用的是指数退避:第一次重试等待 1 秒,第二次重试等待 2 秒。代码实现:
suspend fun <T> retryWithBackoff( maxRetries: Int = 2, initialDelay: Long = 1000L, block: suspend () -> T ): T { var currentDelay = initialDelay repeat(maxRetries) { attempt -> try { return block() } catch (e: Exception) { if (attempt == maxRetries - 1) throw e delay(currentDelay) currentDelay *= 2 } } throw IllegalStateException("Unreachable") }注意:重试只对幂等操作安全。如果工具函数有副作用(比如发送通知、扣款),重试可能导致重复执行。我在
ToolDefinition里加了一个isIdempotent字段,只有幂等的工具才允许自动重试。
4. 实操过程与核心环节实现
4.1 项目初始化与依赖配置
首先在 Android Studio 里创建一个新的 Compose 项目。build.gradle.kts里需要添加的依赖:
dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("androidx.compose.material3:material3:1.2.0") implementation("androidx.compose.ui:ui-tooling-preview:1.6.0") debugImplementation("androidx.compose.ui:ui-tooling:1.6.0") }如果你要引入本地的 AAR 文件(比如某些工具库只提供了 AAR),在build.gradle.kts里这样写:
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))实操心得:
compileOnly和implementation的区别在这里很关键。如果某个 AAR 只在编译时需要(比如注解处理器),用compileOnly;如果运行时也需要,用implementation。我一开始把某个运行时库写成了compileOnly,结果运行时报ClassNotFoundException,排查了半天。
4.2 Agent 执行器的完整实现
核心执行逻辑放在AgentExecutor类里:
class AgentExecutor( private val toolRegistry: ToolRegistry, private val scope: CoroutineScope ) { private val _state = MutableStateFlow<AgentState>(AgentState.Idle) val state: StateFlow<AgentState> = _state.asStateFlow() fun execute(input: String) { scope.launch { try { _state.value = AgentState.Thinking(input) val plan = planExecution(input) val result = executePlan(plan) _state.value = AgentState.Completed(result) } catch (e: Exception) { _state.value = AgentState.Error(e.message ?: "未知错误", e) } } } private suspend fun planExecution(input: String): ExecutionPlan { delay(300) // 模拟思考耗时 val tool = toolRegistry.allTools().firstOrNull { input.contains(it.name) } ?: return ExecutionPlan.DirectResponse("抱歉,我没有找到合适的工具来处理这个请求。") return ExecutionPlan.ToolCall(tool.name, mapOf("input" to input)) } private suspend fun executePlan(plan: ExecutionPlan): String { return when (plan) { is ExecutionPlan.DirectResponse -> plan.response is ExecutionPlan.ToolCall -> { val tool = toolRegistry.find(plan.toolName) ?: throw IllegalStateException("工具 ${plan.toolName} 未注册") _state.value = AgentState.ExecutingTool(plan.toolName, plan.params) val result = retryWithBackoff { tool.executor(plan.params) } _state.value = AgentState.ToolResult(plan.toolName, result.content) result.content } } } }这段代码里,planExecution是规划层,executePlan是执行层,_state是状态层。三层通过StateFlow串联起来。
4.3 Compose UI 与状态绑定
UI 层用 Compose 实现,核心是把AgentState映射到界面元素:
@Composable fun AgentScreen(viewModel: AgentViewModel) { val state by viewModel.agentState.collectAsStateWithLifecycle() var input by remember { mutableStateOf("") } Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { OutlinedTextField( value = input, onValueChange = { input = it }, label = { Text("输入指令") }, modifier = Modifier.fillMaxWidth() ) Button( onClick = { viewModel.execute(input) }, enabled = state is AgentState.Idle || state is AgentState.Completed || state is AgentState.Error, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) ) { Text("执行") } AgentStatePanel(state) } }AgentStatePanel根据状态显示不同的内容:Thinking显示进度条,ExecutingTool显示工具名,Completed显示结果,Error显示错误信息和重试按钮。
注意:
collectAsStateWithLifecycle比collectAsState更适合 Android 场景,因为它会在页面不可见时自动停止收集,避免不必要的重组和资源浪费。这个 API 在lifecycle-runtime-compose库里。
4.4 工具函数的具体实现
以query_user_info为例:
val queryUserInfoTool = ToolDefinition( name = "query_user_info", description = "查询用户信息,根据用户 ID 返回姓名和邮箱", parameters = mapOf("userId" to "string"), isIdempotent = true, executor = { params -> val userId = params["userId"] as? String ?: throw IllegalArgumentException("缺少 userId 参数") delay(500) // 模拟网络请求 ToolResult( success = true, content = "用户 $userId 的信息:姓名=张三,邮箱=zhangsan@example.com" ) } )send_notification工具模拟随机失败:
val sendNotificationTool = ToolDefinition( name = "send_notification", description = "发送通知,可能失败", parameters = mapOf("message" to "string"), isIdempotent = false, executor = { params -> delay(300) if (Random.nextInt(100) < 30) { throw RuntimeException("通知服务暂时不可用") } ToolResult(success = true, content = "通知已发送") } )因为send_notification不是幂等操作,所以它不会自动重试。如果失败,Agent 会直接进入Error状态,由用户决定是否手动重试。
4.5 会话上下文的维护
Agent 执行不是一次性的,需要维护会话上下文。我用一个ConversationContext来保存历史记录:
class ConversationContext { private val history = mutableListOf<ConversationTurn>() fun addTurn(turn: ConversationTurn) { history.add(turn) if (history.size > MAX_HISTORY_SIZE) { history.removeAt(0) } } fun recentTurns(n: Int): List<ConversationTurn> = history.takeLast(n) companion object { private const val MAX_HISTORY_SIZE = 20 } }MAX_HISTORY_SIZE设为 20 的理由是:大部分 Agent 对话在 20 轮以内就能完成任务,超过 20 轮的历史记录对当前决策的参考价值很低,反而会占用内存和增加处理时间。
5. 常见问题与排查技巧实录
5.1 协程泄漏导致的内存问题
现象:Agent 执行到一半退出页面,再进入时发现状态错乱,或者 App 内存持续增长。
排查思路:首先检查CoroutineScope是否绑定了正确的生命周期。用 Android Studio 的 Profiler 查看协程数量,如果发现协程数量只增不减,基本可以确定是泄漏。
解决方法:确保在onCleared里调用agentScope.cancel()。另外,所有在agentScope里启动的协程都要用launch而不是async,除非你确实需要返回值。async如果忘记调用await(),异常会被吞掉。
5.2 工具调用超时但协程没有取消
现象:设置了 5 秒超时,但工具调用卡了 30 秒才返回。
原因:withTimeout只能取消协程,不能取消底层的阻塞操作。如果工具函数内部用的是阻塞式 IO(比如Thread.sleep或者阻塞式网络请求),withTimeout无法中断它。
解决方法:工具函数内部必须使用可取消的挂起函数。把Thread.sleep(500)改成delay(500),把阻塞式网络请求改成基于协程的请求库。如果实在无法避免阻塞操作,用withContext(Dispatchers.IO)包裹,并确保底层库支持取消。
5.3 Compose 重组导致的性能问题
现象:Agent 执行过程中界面卡顿,日志区滚动不流畅。
原因:AgentState变化过于频繁,导致 Compose 频繁重组。特别是ExecutingTool状态如果携带了大量参数数据,每次状态更新都会触发整个界面重组。
解决方法:用derivedStateOf把派生状态缓存起来,减少不必要的重组。另外,日志区用LazyColumn而不是Column,只渲染可见区域的日志项。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Agent 卡在 Thinking 状态 | 规划层协程被阻塞 | 检查planExecution里是否有阻塞调用 | 改用delay或挂起函数 |
| 工具调用返回空结果 | 参数解析失败 | 打印params内容 | 检查参数类型转换逻辑 |
| 重试次数超过预期 | 重试逻辑嵌套 | 检查retryWithBackoff调用层级 | 确保只在一处重试 |
| UI 状态与 Agent 状态不一致 | StateFlow 收集时机问题 | 检查collectAsStateWithLifecycle位置 | 确保在 Composable 顶层收集 |
应用崩溃在onCleared | 协程取消时资源未释放 | 查看崩溃堆栈 | 在cancel前先关闭资源 |
实操心得:我建议在开发阶段打开
Dispatchers.setMain(UnconfinedTestDispatcher())来做单元测试,这样可以同步执行协程,避免测试用例因为协程调度而随机失败。这个技巧在调试 Agent 状态流转时特别有用。
5.5 关于工具注册的命名规范
我踩过一个坑:工具名用了驼峰命名(比如queryUserInfo),结果在意图匹配时因为大小写问题匹配失败。后来统一改成下划线命名(query_user_info),问题解决。
注意:工具名一旦确定就不要随意更改,因为会话历史里可能已经记录了旧工具名。如果必须改,要做好版本兼容或者数据迁移。
6. 从登录模块到通用 Agent 环境的扩展思路
这套环境跑通之后,我做了几个扩展实验,效果不错,分享出来供参考。
第一个扩展是多工具串联。原来的执行计划只调用一个工具,我改成了支持工具链:query_user_info的结果作为send_notification的输入。实现方式是在ExecutionPlan里增加ToolChain类型,按顺序执行工具列表,前一个的输出作为后一个的输入。
第二个扩展是人工确认环节。对于非幂等操作(比如发送通知),在执行前插入一个AwaitingConfirmation状态,UI 上弹出确认对话框,用户点击确认后才继续执行。这个机制在真实 Agent 场景里非常重要,可以避免 Agent 自主执行危险操作。
第三个扩展是执行日志持久化。把每次 Agent 执行的完整状态流转记录到 Room 数据库里,方便事后回溯和调试。表结构很简单:execution_id、timestamp、state_type、state_data。查询时按execution_id分组,就能还原完整的执行链路。
如果你想把这套环境接入真正的 LLM 来做意图识别,只需要替换planExecution方法:把原来的关键词匹配改成调用 LLM API,把工具列表作为上下文传给 LLM,让 LLM 返回工具名和参数。其他层完全不需要改动,这就是分层架构的好处。
最后分享一个我在调试 Agent 时常用的小技巧:在AgentExecutor里加一个debugMode开关,打开后每个状态流转都打印日志并额外延迟 1 秒,这样你可以肉眼观察整个执行过程,排查状态跳转是否符合预期。这个技巧帮我定位了好几个状态机逻辑错误。