1. 为什么拿 Android 登录模块来练 Agent 开发环境
很多人搭 Agent 开发环境的第一反应是找个"Hello World"级别的 Demo,跑通一个对话接口就收工。我一开始也这么干过,结果真正上手做项目时才发现,Demo 环境和真实工程之间隔着一整条鸿沟:状态管理、异步回调、错误重试、上下文持久化,这些东西在玩具项目里根本不会暴露。后来我换了个思路——用一个真实存在的、有完整业务闭环的模块来当"试验田",我选的是 Android 登录模块。
为什么偏偏是登录模块?因为它天然具备 Agent 开发环境需要的几乎所有要素。登录流程本身是一个多步骤状态机:输入校验、请求发送、结果解析、失败重试、成功跳转,每一步都有明确的输入输出和状态迁移。这跟 Agent 的"思考-行动-观察"循环在结构上高度同构。更重要的是,登录模块涉及异步回调,而 Agent 开发里最头疼的就是把回调式的异步逻辑改造成可挂起、可编排的流程。拿登录模块练手,等于用最小的成本把 Agent 环境里最容易翻车的那部分先趟一遍。
这里说的 Harness,不是某个具体产品的名字,而是指一套把模型能力、工具调用、状态管理串起来的工程骨架。你可以把它理解成 Agent 的"操作系统":模型是 CPU,工具是外设,Harness 就是负责调度、传参、兜底的那层内核。搭这套环境的核心诉求有三个:第一,可观测,每一步 Agent 做了什么、花了多久、失败在哪,都要能看见;第二,可复现,同样的输入要能稳定复现同样的执行路径,方便调试;第三,可扩展,加一个新工具、换一个模型,不需要推倒重来。
这套环境适合谁?如果你已经会用 Kotlin 写 Android 应用,对 Jetpack Compose 有基本了解,想从"调 API"进阶到"搭 Agent 系统",那这篇内容就是给你准备的。如果你是完全的 Kotlin 新手,建议先把协程和 Compose 的基础过一遍再回来,不然中间有些设计取舍你会看得云里雾里。整篇内容我会围绕登录模块这个具体载体,把 Agent 开发环境的搭建拆成可落地的步骤,同时把每一步"为什么这么设计"讲清楚。
2. 环境骨架的选型:Kotlin 协程 + Compose 为什么是当前最优解
2.1 回调地狱是 Agent 编排的头号敌人
先说说我踩过的第一个坑。最早我用传统的回调方式写登录流程,代码大概长这样:login(username, password, callback),callback 里再嵌套fetchUserInfo(callback),再嵌套saveToken(callback)。三层嵌套还能忍,等到 Agent 需要根据中间结果动态决定下一步走哪条分支时,回调嵌套直接爆炸。更致命的是,Agent 的执行路径是运行时决定的,你没法在编译期把所有分支都写死,回调式的写法根本表达不了这种动态性。
协程解决的正是这个问题。suspend函数把异步逻辑写成了同步的样子,Agent 的每一步都可以是一个挂起函数,用await拿到结果后再决定下一步。我实测下来,同样的登录流程,回调版本 200 多行还容易漏掉错误分支,协程版本 80 行不到,逻辑一目了然。而且协程的结构化并发特性,天然适合 Agent 的"并行工具调用"场景——比如同时查用户信息和拉取配置,用async并发发起,awaitAll收口,代码干净得不像话。
2.2 回调转挂起的工具类:别自己造轮子
从回调式 API 迁移到协程,绕不开的一个问题是:怎么把现成的回调接口包成 suspend 函数。Android 里大量系统 API 和第三方 SDK 都是回调式的,你不可能全部重写。标准做法是用suspendCancellableCoroutine,我把它封装成了一个通用工具类,核心逻辑是这样:
suspend fun <T> callbackToSuspend( block: (Callback<T>) -> Unit ): T = suspendCancellableCoroutine { cont -> val callback = object : Callback<T> { override fun onSuccess(value: T) { if (cont.isActive) cont.resume(value) } override fun onFailure(e: Throwable) { if (cont.isActive) cont.resumeWithException(e) } } block(callback) cont.invokeOnCancellation { /* 清理资源 */ } }这里有几个细节值得说道。第一,isActive判断不能省,否则协程已经被取消后回调才回来,resume会抛异常。第二,invokeOnCancellation里要做资源清理,比如取消网络请求、注销监听器,不然会内存泄漏。第三,如果你的回调可能被调用多次(比如进度回调),要用Channel或者callbackFlow而不是suspendCancellableCoroutine,后者只能 resume 一次。
提示:
callbackFlow配合awaitClose是处理"多次回调"场景的标准姿势,登录里的验证码倒计时、进度更新都适合用它。
2.3 Compose 在 Agent 环境里的真实价值
有人会问,Agent 开发环境跟 UI 框架有什么关系?关系大了。Agent 的执行过程是流式的,模型一个字一个字往外吐,工具调用一个接一个发生,这些都需要实时反映到界面上。传统 View 体系里做这种流式更新,你得手动管理一堆 View 的可见性和文本拼接,代码又臭又长。Compose 的声明式 UI配合State,数据变了界面自动重组,你只需要关心"当前状态是什么",不用管"怎么把界面从旧状态改成新状态"。
我在登录模块里用 Compose 做了一个 Agent 执行轨迹面板,把每一步的工具调用、耗时、结果都渲染成列表。因为用了mutableStateListOf,Agent 每执行一步往列表里加一条,界面自动刷新,一行手动刷新代码都不用写。这个面板在调试阶段帮了大忙,Agent 卡在哪一步、哪次工具调用超时,一眼就能看出来。Jetpack Compose 在这里不是"为了用而用",而是实实在在降低了流式状态管理的复杂度。
3. 登录模块拆解成 Agent 可编排的原子能力
3.1 把登录流程切成"工具"的粒度怎么定
Agent 的核心是工具调用,而工具粒度定得好不好,直接决定整套环境好不好用。粒度太粗,一个工具干太多事,Agent 没法灵活组合;粒度太细,工具数量爆炸,模型选择困难,调用开销也大。我拿登录模块试了好几轮,最后总结出一个判断标准:一个工具应该对应一个"可独立验证、可独立重试"的操作。
按这个标准,登录流程被切成了这么几个工具:
| 工具名 | 职责 | 输入 | 输出 | 是否可重试 |
|---|---|---|---|---|
| validateInput | 校验账号密码格式 | 原始输入 | 校验结果 | 是 |
| sendLoginRequest | 发起登录请求 | 账号密码 | Token 或错误码 | 是 |
| fetchUserProfile | 拉取用户信息 | Token | 用户对象 | 是 |
| persistSession | 持久化会话 | Token+用户 | 成功标志 | 是 |
| navigateToHome | 跳转主页 | 无 | 无 | 否 |
注意navigateToHome我标了"否",因为跳转是有副作用且不可逆的操作,重试会导致页面栈混乱。这类工具在 Agent 环境里要特殊标记,让编排层知道"这个工具只能调一次"。这个细节很多教程不会讲,但实际项目里不处理的话,Agent 重试逻辑一开就会出问题。
3.2 每个工具为什么要带"元数据"
光有工具函数还不够,Agent 要能"理解"工具,就得给每个工具配一份元数据:工具名、功能描述、参数 schema、超时时间、是否幂等。这份元数据是给模型看的,描述写得好不好,直接决定模型能不能选对工具。我一开始描述写得很随意,结果模型经常把sendLoginRequest和fetchUserProfile搞混,后来把描述改成"发起登录请求,需要账号和密码,返回登录凭证"这种动作+输入+输出的格式,准确率明显上来了。
元数据里我还会塞一个examples字段,给一两个调用示例。别小看这个,模型对示例的敏感度远高于纯文字描述。比如validateInput我给了一个{"username": "test@example.com", "password": "123456"}的示例,模型基本不会再传错参数格式。这套元数据我用 Kotlin 的 data class 定义,序列化成 JSON 喂给模型,结构清晰还好维护。
3.3 工具注册表:让新增工具不用改核心代码
工具多了之后,如果每加一个工具都要去改 Agent 的核心调度代码,那这套环境就没法扩展了。我的做法是搞一个工具注册表,所有工具实现同一个接口,启动时统一注册进去:
interface AgentTool { val metadata: ToolMetadata suspend fun execute(params: Map<String, Any>): ToolResult } class ToolRegistry { private val tools = mutableMapOf<String, AgentTool>() fun register(tool: AgentTool) { tools[tool.metadata.name] = tool } fun get(name: String): AgentTool? = tools[name] fun allMetadata(): List<ToolMetadata> = tools.values.map { it.metadata } }这样新增工具只需要实现接口、注册一下,调度层完全不用动。allMetadata()返回的列表直接喂给模型当"可用工具清单",模型据此决定调哪个。我实测下来,这套注册表机制让加工具的成本从"改三处代码"降到"加一个类",扩展性提升非常明显。
4. 让 Agent 真正跑起来:编排循环与状态管理
4.1 编排循环的骨架长什么样
Agent 的核心是一个循环:观察当前状态 → 决定下一步 → 执行工具 → 更新状态 → 再观察。这个循环写起来不难,难的是把终止条件、错误处理、超时控制都考虑进去。我最初的版本只写了"模型返回工具调用就执行,返回文本就结束",结果遇到模型反复调用同一个工具时直接死循环。后来加了三个保护:最大步数限制、重复调用检测、单步超时。
suspend fun runAgent(goal: String, maxSteps: Int = 10): AgentResult { val history = mutableListOf<Step>() var step = 0 while (step < maxSteps) { val decision = model.decide(goal, history, registry.allMetadata()) when (decision) { is Decision.CallTool -> { if (isRepeated(decision, history)) break val result = withTimeout(decision.timeout) { registry.get(decision.toolName)?.execute(decision.params) } history.add(Step(decision, result)) } is Decision.Finish -> return AgentResult.Success(decision.output) } step++ } return AgentResult.ExceededMaxSteps(history) }maxSteps我一般设 10,登录流程最多也就五六步,留点余量。isRepeated检测连续两次调用同一个工具且参数相同的情况,直接中断,避免烧 token。withTimeout给每个工具调用兜底,防止某个工具卡死拖垮整个循环。
4.2 状态管理:为什么不能只用一个变量
登录流程的状态比想象中复杂。除了"当前走到哪一步",还有"已经拿到了哪些中间结果"(比如 Token)、"哪些步骤失败了"、"失败了几次"。如果只用一个currentStep变量,这些信息全丢了,Agent 没法根据历史做决策。我的做法是维护一个结构化的状态对象,每一步执行完更新它:
data class AgentState( val goal: String, val history: List<Step>, val context: MutableMap<String, Any>, val retryCount: Map<String, Int> )context存中间结果,比如登录成功后把 Token 塞进去,后续工具直接从 context 里取,不用重复请求。retryCount记录每个工具失败了几次,超过阈值就不再重试,直接走降级逻辑。这个状态对象是整个 Agent 的"记忆",设计得好不好直接决定 Agent 聪不聪明。我踩过的坑是早期把 context 设计成了不可变的,每次更新都要复制整个 Map,性能很差,后来改成可变 Map 加版本号控制,才顺畅起来。
4.3 错误处理:Agent 环境里最容易被低估的部分
Demo 里错误处理基本是try-catch一把梭,但真实环境里错误分很多种:网络超时可以重试,参数错误重试也没用,鉴权失败要重新登录,服务端限流要退避等待。如果 Agent 不区分这些,要么无脑重试浪费资源,要么一遇错就放弃。我在工具层给每个错误打了标签:
sealed class ToolError { object Retryable : ToolError() // 可重试 object Fatal : ToolError() // 不可重试 data class NeedReauth(val msg: String) : ToolError() data class RateLimited(val waitMs: Long) : ToolError() }编排层根据错误类型决定策略:Retryable用指数退避重试,RateLimited等待指定时间,NeedReauth触发重新登录流程,Fatal直接终止并上报。这套分类让 Agent 的容错能力上了一个台阶,实测在弱网环境下成功率比无脑重试高出一大截。
5. 调试与可观测性:让 Agent 的每一步都看得见
5.1 执行轨迹面板:调试 Agent 的第一生产力
Agent 最让人抓狂的地方是"它为什么不按我想的走"。没有可观测性,你只能靠猜。我在 Compose 里做的那个执行轨迹面板,是整个环境里我最满意的部分。它把每一步的工具名、参数、结果、耗时、错误都列出来,还能展开看详情。调试时我盯着这个面板,经常一眼就发现问题:哦,模型把密码字段名传错了;哦,这个工具超时了但没触发重试。
面板的数据源就是前面说的AgentState.history,用LazyColumn渲染,每一条是一个可展开的卡片。因为 Compose 的重组机制,Agent 每执行一步,面板自动多一条,完全不用手动刷新。这个面板我建议每个搭 Agent 环境的人都做一个,投入产出比极高,省下的调试时间远超开发它的时间。
5.2 日志分级:别让日志淹没你
Agent 跑起来日志量很大,模型请求、工具调用、状态变更全打出来,控制台瞬间刷屏。我一开始不分级,结果关键错误被淹没在几百行 INFO 里。后来定了三级:DEBUG打完整的请求响应和状态快照,只在排查具体问题时开;INFO打每一步的工具名和结果摘要,日常开发看这个;ERROR只打失败和异常,生产环境默认级别。
日志里我还会带一个traceId,贯穿整个 Agent 执行过程。这样即使并发跑多个 Agent 实例,也能通过 traceId 把同一个实例的日志串起来。这个习惯是从后端开发带过来的,在 Agent 这种多步骤、异步的场景里尤其有用。
5.3 复现问题:把执行路径存下来
Agent 的问题往往难以复现,因为模型的输出有随机性,同样的输入两次跑可能走不同的路径。我的做法是把每次执行的完整路径序列化存下来,包括每步的输入、输出、模型决策。出问题时,加载这条路径就能"重放"整个执行过程,不用重新调模型。这个机制帮我定位过好几个偶发 bug,比如某个工具在特定参数组合下才会超时,靠重放很快就复现了。
存储格式我用 JSON,每条路径一个文件,按时间戳命名。路径里包含模型版本、工具版本、参数快照,这样即使后来代码改了,也能知道当时是什么环境。这个"执行快照"机制,我觉得是 Agent 环境从"能用"到"可靠"的关键一步。
6. 从登录模块迁移到通用 Agent 环境的经验
6.1 哪些设计可以直接复用
登录模块跑通之后,我把它迁移到了其他场景,发现有几个设计是通用的,直接搬过去就行。第一是工具注册表,任何 Agent 场景都需要动态管理工具,这套机制原封不动能用。第二是结构化状态对象,把context和retryCount的字段换一下,其他逻辑不用动。第三是错误分类体系,Retryable、Fatal、RateLimited这几类错误在任何场景都存在,直接复用。
第四是执行轨迹面板,把工具名和参数渲染逻辑稍微改改,就能适配新场景。第五是执行快照机制,序列化逻辑跟具体业务无关,直接搬。我统计了一下,登录模块里大概 70% 的代码是通用的,只有 30% 是登录特有的业务逻辑。这意味着搭好一套环境后,迁移到新场景的成本比想象中低得多。
6.2 哪些地方必须重新设计
当然也有不能直接搬的。工具的粒度必须根据新场景重新定,登录模块的工具切分标准放到别的场景未必合适。状态对象的字段要重新设计,登录场景的context里放 Token,别的场景可能要放文档、放对话历史。编排循环的终止条件也要调整,登录流程五六步就结束,但有些场景可能需要几十步,maxSteps得重新评估。
还有一个容易忽略的点是模型的提示词。登录模块的提示词里写满了"账号""密码""登录"这些词,换场景必须重写。我建议把提示词模板化,业务相关的部分抽成变量,通用的部分(比如"你是工具调用助手,根据当前状态决定下一步")固定下来。这样换场景时只改业务变量,通用部分不用动。
6.3 踩过的坑与避坑建议
最后分享几个我踩过的坑。第一个是工具描述写得太抽象,模型选错工具。避坑方法是描述里必须包含"什么时候用这个工具",而不只是"这个工具做什么"。第二个是忘了处理工具返回空结果的情况,模型拿到 null 直接懵了。避坑方法是所有工具统一返回ToolResult,空结果也要包装成明确的"无数据"状态。
第三个坑是并发调用时的状态竞争。我一开始用可变 Map 存 context,多个工具并发写的时候偶尔会丢数据。避坑方法是并发场景下用ConcurrentHashMap,或者干脆串行执行工具调用,牺牲一点性能换稳定性。第四个坑是模型输出的参数格式不对,比如该传数字传了字符串。避坑方法是在工具执行前加一层参数校验和类型转换,别指望模型每次都输出完美格式。
注意:Agent 环境里"模型输出不可信"应该当成一条铁律。所有来自模型的参数,执行前都要校验,宁可报错也不要带着脏数据往下跑。
这套环境我从登录模块起步,到现在已经迁移到好几个场景,整体稳定性比一开始的 Demo 版本强了不止一个量级。核心体会是:别追求一步到位,先用一个真实模块把骨架搭起来,再逐步补可观测性和容错能力。登录模块就是个很好的起点,业务简单但要素齐全,跑通它,你对 Agent 开发环境的理解就到位了。