ADK for Kotlin 实战:Android 端侧智能体与多 Agent 编排
1. 为什么值得花一个周末看 ADK for Kotlin1.1 ADK 的真实定位不是框架是一套骨架约定第一次看到 ADK 这个名字我下意识以为是又一个把 API 调用包一层的壳子。真读完官方的示例工程之后我的判断变了。ADK 全称 Agent Development Kit是 Google 开源的一套智能体开发工具最开始只有 Python 版本后来补上了 Java 支持再到 Kotlin 原生支持的落地这条路径其实非常明确——它想把 Agent 从一段 prompt 加几次模型调用的脚本形态升级成一个有明确生命周期、有工程边界的软件模块。这件事的意义在哪我举个自己踩过的例子。去年我做一个小助手功能最初就是用户输入 → 拼 prompt → 调模型 → 解析返回两三百行代码搞定了。等到要加工具调用、要加多轮记忆、要加失败重试代码迅速膨胀到两千行而且每加一个功能都要动核心流程。问题不在于模型不好用而在于没有骨架。ADK 想解决的就是这个它规定了四个基本概念Agent负责谁来思考Tool负责能做什么Session负责记得什么Event负责发生了什么然后用 Runner 把这四个东西串成一条可观测的事件流水线。只要接受了这层约定后面加记忆、加多智能体、加人工审核节点都是往骨架里插模块而不是推倒重来。Kotlin 版本的价值就在这里它把同一套约定搬到了 JVM 生态尤其是 Android 和 Spring Boot 这两个场景不用再为了一点 Agent 能力硬塞一个 Python 服务进去做 IPC。1.2 Kotlin 版和 Python 版到底差在哪很多人第一反应是Python 版文档全、示例多我直接用 Python 不就行了。如果你的产物是后端服务或者数据处理脚本这个选择没问题。但如果你要在 Android 应用里做端侧能力或者你现有的后端是 Kotlin/Java 技术栈那 Python 版就是个尴尬的存在——要么服务端多维护一个进程要么把模型调用全部改成 HTTP 转发网络抖动、超时、错误码映射全得自己写一遍。Kotlin 版本最实在的三个差异点我按重要程度排一下。第一是协程与异步模型的对齐。Agent 执行天然是流式的、可能长时间挂起的Python 用 async/awaitKotlin 用 suspend 函数加 Flow。ADK 的 Kotlin 封装在事件流上返回的是 Flow这跟 Android 里 Compose 的 collectAsState、ViewModel 的 viewModelScope 是同一套心智不用做桥接。第二是类型安全。工具函数的参数校验、返回结构定义Kotlin 的 data class 加注解能直接在编译期发现问题Python 只能靠运行时校验和文档约定。工具数量一多这个差距非常明显。第三是与 Android 生命周期和权限体系的天然衔接。工具要读通讯录、要调定位、要访问本地文件这些都需要运行时权限Kotlin 侧可以直接复用现有的权限封装Python 侧根本摸不到这些系统能力。注意Kotlin 版本的发布节奏比 Python 版本慢部分高级特性比如某些第三方集成可能还没覆盖。上生产之前一定先对着官方仓库的 release note 确认一遍别照着 Python 文档直接抄。1.3 三类人该上手两类人可以先等等我给个不那么政治正确的分类。该上手的一是 Android 开发者手里已经有应用在跑想加一个能查资料、能操作本地数据、能多轮对话的助手模块二是 JVM 后端开发者业务里有大量接收自然语言指令 → 查多个内部系统 → 汇总输出的流程三是已经在用 Python 版 ADK 但团队主力是 Kotlin想统一技术栈减少维护成本的团队。可以先等等的一是只做单次 prompt 调用、没有工具和多轮需求的场景用不用 ADK 差别不大反而多一层学习成本二是对延迟极其敏感的实时交互场景Agent 的多轮工具调用链路本身会带来秒级开销这个后面我会细讲怎么压。我个人的建议是先花半天把官方那个最简示例跑通感受一下 Event 流长什么样。跑通了再决定要不要深入比读十篇介绍文章都管用。2. 动手前的三件事依赖、密钥、心智模型2.1 Gradle 依赖与版本选择先说工程结构。如果你是在 Android 项目里试水我强烈建议先单独开一个纯 Kotlin/JVM 模块或者干脆新建一个空工程别一上来就塞进主 App 的 app 模块。原因很实际Agent 依赖会引入一堆网络库、序列化库跟你现有的 OkHttp、Gson、Retrofit 版本打架的概率不低混在主模块里排查依赖冲突能耗掉你一整天。依赖配置大致长这样具体版本号以官方仓库当前发布的为准// build.gradle.kts dependencies { // 核心 ADK 运行时 implementation(com.google.adk:google-adk:0.1.0) // Kotlin 侧的协程与 Flow 封装 implementation(com.google.adk:google-adk-kotlin:0.1.0) // 底层用到的响应式流ADK 的事件流建立在这之上 implementation(io.reactivex.rxjava3:rxjava:3.1.8) // 网络与 JSON implementation(com.squareup.okhttp3:okhttp:4.12.0) implementation(com.google.code.gson:gson:2.11.0) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0) testImplementation(kotlin(test)) }选择上有个取舍要说清楚。ADK 的 Java 版本底层用的是 RxJava 的 Flowable 做事件流Kotlin 封装把它转成了 Flow。这意味着你的工程里同时存在两套异步模型。我的做法是在 Agent 边界之内用 RxJava 的语义比如 blockingGet 拿 Session出了边界立刻转成 Flow业务层完全感知不到 RxJava。另外minSdkVersion如果你要在 Android 上跑建议至少 24低版本上协程调度和部分网络能力会有兼容性坑。别为了覆盖几个老设备把整个工程拖下水。2.2 模型接入直连还是走托管先想清楚ADK 调模型需要凭证。常见有两条路一条是通过开发者平台的 API Key 直连适合本地开发、个人项目、原型验证另一条是走云上的托管服务用服务账号鉴权适合有合规要求、需要配额管理和审计日志的生产环境。本地开发我一般这么配把 Key 放在环境变量里别硬编码进代码再提交export GOOGLE_API_KEY你的开发用密钥 export GOOGLE_GENAI_USE_VERTEXAIfalse然后代码里读val apiKey System.getenv(GOOGLE_API_KEY) ?: error(没有配置 GOOGLE_API_KEY先把它写进运行配置里)这里有个很多人第一次会栽的坑Android 应用里直接用 API Key 是不安全的。APK 是可以被反编译的字符串常量扒出来是分分钟的事。正确的做法是让客户端走你自己的后端后端持有密钥并做限流和鉴权客户端只跟后端通信。我在本地调试时图省事直接在 App 里读环境变量上线前一定要换掉这一步千万别偷懒。提示密钥管理这件事我踩过一次亏。曾经把测试 Key 写进了一个 demo 仓库并设成了公开几小时内配额就被跑光了。从那之后我的习惯是本地用.env加gitignoreCI 里用平台的 Secret 变量从来不写进源码。2.3 先分清 Agent、Tool、Session、Event 四个词在写第一行代码之前把这四个词的含义对清楚能省掉后面大量的困惑。Agent是决策者。它持有模型名、系统指令、可用工具清单。你可以把它理解成一个有职责边界的员工有的 Agent 只负责收集信息有的只负责写报告。Tool是能力。它是一个普通的 Kotlin 函数加一段描述模型看到描述后自己决定要不要调、怎么传参。注意这里的主动权在模型不在你。这一点极其关键后面排查工具为什么没被调用时全靠这个认知。Session是会话容器。它绑定了用户 ID 和会话 ID内部维护着一份对话历史和一份可变的状态字典。同一个 Session 内的多轮对话是连贯的。Event是流水线上的包裹。Agent 每做一步——收到用户输入、决定调用工具、工具返回结果、生成最终回答——都会产出一个 Event。你的 UI 层消费的就是这条 Event 流。我习惯用生活化的方式记Agent 是厨师Tool 是厨房里的锅碗瓢盆和各种食材Session 是这次订单的完整记录Event 是后厨传菜口不断递出来的每一道半成品和成品。你作为前厅只看传菜口不用管后厨怎么切的菜。3. 第一个能跑起来的 Agent逐行拆解3.1 最小可运行单元长什么样先看一个能跑通的最小例子。我刻意写得简单就是为了让你把注意力放在结构上而不是业务逻辑。import com.google.adk.agents.LlmAgent import com.google.adk.runners.InMemoryRunner fun main() { // 1. 定义 Agent val agent LlmAgent.builder() .name(assistant) .model(gemini-2.5-flash) .instruction( 你是一个简洁的助手。 回答控制在三句话以内不要输出无关的寒暄。 .trimIndent() ) .build() // 2. 创建 Runner绑定 Agent val runner InMemoryRunner(agent agent, appName demo) // 3. 建一个会话 val session runner.sessionService() .createSession(demo, user_001) .blockingGet() // 4. 发一条消息消费事件流 runner.runAsync( userId user_001, sessionId session.id(), newMessage userText(你好用一句话介绍一下你自己) ).blockingForEach { event - val text event.content()?.parts() ?.joinToString() { it.text() ?: } .orEmpty() if (text.isNotBlank()) println(text) } }四个步骤对应上一节说的四个概念一点不多。新手最容易犯的错是跳过 Runner 直接调 Agent。ADK 里 Agent 本身不负责编排执行Runner 才是那个调度器它管会话读写、管事件分发、管工具执行。理解这一点后面看到SequentialAgent之类的编排器就不会懵。instruction这个字段值得多说一句。它就是系统提示词但 ADK 会把它和当前会话的上下文一起组装。写的时候有几条经验明确角色边界、明确输出格式、明确禁止项。我见过太多人在生产环境里把 instruction 写成一大段模糊的你要尽力帮助用户结果模型输出格式每天都不一样前端解析直接崩。3.2 Runner 与 Event 流为什么它不是调一次返回一次很多从普通 API 调用转过来的人会本能地期望run方法返回一个字符串。ADK 返回的是一串事件。这个设计的理由很实在Agent 执行过程中可能调三次工具、可能切换到子 Agent、可能需要你中途插进去做个人工确认。如果把整条链路压成一次返回中间过程全丢了出问题根本没法查。事件流的每一帧大概包含这些东西是谁产生的author、内容是什么content、是不是最终回答isFinalResponse、有没有工具调用或工具结果。消费的时候我一般分三路处理runner.runAsync(userId, sessionId, newMessage).collect { event - when { event.isFinalResponse() - { // 最终回答推给 UI 收尾 renderFinal(event.text()) } event.hasToolCall() - { // 工具调用中给个 loading 提示 showStatus(正在调用 ${event.toolName()}...) } else - { // 中间过程开发期打日志 logDebug(中间事件${event.author()}) } } }分三路之后UI 体验会好很多。用户在等工具返回的时候能看到正在查询天气而不是对着一个转圈发呆。这点在移动端尤其重要超过两秒没有任何反馈用户就会以为卡死了。注意runAsync返回的流一定要在合适的协程作用域里消费并且在页面销毁时取消。我第一版忘了在onCleared里 cancel结果页面退出后还在后台跑日志刷得飞起流量也白白消耗。3.3 把输出接到 Android UI 上如果你是在 Android 里用推荐的结构是 ViewModel 持 RunnerUI 通过 StateFlow 订阅。class ChatViewModel( private val runner: InMemoryRunner, private val sessionId: String ) : ViewModel() { private val _state MutableStateFlow(ChatUiState()) val state: StateFlowChatUiState _state.asStateFlow() private var job: Job? null fun send(input: String) { job?.cancel() job viewModelScope.launch { _state.update { it.copy(sending true, streamingText ) } runner.runAsync( userId user_001, sessionId sessionId, newMessage userText(input) ).collect { event - when { event.isFinalResponse() - { _state.update { it.copy(sending false, messages it.messages event.text()) } } event.hasToolCall() - { _state.update { it.copy(statusText 正在处理...) } } } } } } override fun onCleared() { job?.cancel() super.onCleared() } }这里有几个实践点一是发送新消息前取消上一个任务否则用户连点发送会产生多条并发流UI 状态直接乱掉二是状态用不可变 data class方便 Compose 做 diff三是工具调用阶段单独暴露一个状态字段别和正文混在一起。实测下来这套结构在中等复杂度的对话场景里非常稳。真正需要额外设计的是流式输出的逐字渲染那个要看你用的模型是否返回增量 tokenKotlin 版本目前对这种场景的支持在不同版本间有差异用之前先确认一下。4. 工具系统让 Agent 真的能做事4.1 三种写工具的方式与各自适用场景只聊天的 Agent 价值有限工具才是分水岭。ADK 里写工具大致三条路。第一条普通函数加注解。适合绝大多数业务逻辑比如查数据库、调内部接口、做计算。class WeatherTool { Schema(description 查询指定城市在指定日期的天气情况) fun getWeather( Schema(description 城市名例如 北京、上海) city: String, Schema(description 日期格式 YYYY-MM-DD不填表示今天) date: String ): MapString, Any { // 真实项目里换成网络请求 return mapOf( city to city, date to date.ifBlank { today }, tempC to 24, condition to 晴, windLevel to 3 ) } }然后用FunctionTool.create(WeatherTool(), getWeather)把它包装成工具注册给 Agent。注意工具类本身是普通类不需要继承什么基类这是非常好的设计意味着你现有的 Service 层几乎可以原样复用。第二条内置工具。ADK 提供了一些开箱即用的能力比如联网检索、代码执行这类。适合原型阶段快速验证但生产环境要评估合规性和结果可控性。第三条Agent 作为工具。这个稍微进阶一点把一个完整的子 Agent 当成另一个 Agent 的工具来用。适合主流程需要调用一个专注做某件事的专家的场景。三条路的选择标准很简单逻辑是你自己写的就用函数工具逻辑想让平台兜底就用内置工具逻辑复杂到需要独立一整套 prompt 和工具集就封装成 Agent 工具。4.2 参数 Schema 写不好模型就不会调用这是我在实际项目里花时间最多的地方也是新手最容易忽略的地方。模型决定要不要调用工具、怎么填参数完全依赖你写的描述文本。描述写糊了模型要么不调用要么填错参数。几条实打实的经验第一描述里写清楚单位和格式。date这个参数如果不写格式 YYYY-MM-DD模型可能传明天下周三2025年3月5日你的解析代码就要写一堆兼容。我现在的习惯是把格式写死在描述里再补一句示例。第二参数名用英文描述用中文。参数名是代码层面的用英文避免编码问题描述是给模型看的用中文表达更精确。第三枚举值一定要列出来。比如状态查询工具描述里写状态只能是 pending、running、done 三者之一比写传入状态效果好一个量级。第四能不给可选参数就不给。可选参数越多模型越容易乱填。我一般先做成必填跑一段时间观察模型的行为确实高频缺失的再放开默认值。提示我做过一个反直觉的测试——同一份工具描述只改描述的措辞工具调用成功率从六成出头提升到九成以上。所以如果你遇到工具时灵时不灵先别怀疑模型回去改描述。4.3 工具里抛异常会发生什么这个问题必须提前想清楚。工具执行过程中抛异常ADK 会把异常信息作为工具结果回传给模型模型看到之后可能会尝试换个参数重试也可能直接跟用户说我遇到问题了。这个默认行为在开发期很方便但生产环境有两个隐患。一是敏感信息泄露。异常堆栈里可能包含数据库连接串、内部路径、表名。如果这段内容被原样喂给模型再输出给用户就是实打实的信息泄露。我的做法是工具内部先 catch把异常转成一个结构化的错误对象Schema(description 根据订单号查询订单状态) fun queryOrder( Schema(description 订单号纯数字长度 12 位) orderId: String ): MapString, Any { return try { val order orderRepo.find(orderId) ?: return mapOf(ok to false, reason to 订单不存在) mapOf(ok to true, status to order.status, updatedAt to order.updatedAt) } catch (e: Exception) { // 只回传安全信息堆栈写日志 log.error(查询订单失败 orderId{}, orderId, e) mapOf(ok to false, reason to 系统繁忙请稍后重试) } }二是无限重试。模型可能因为同样的错误反复调用同一个工具。控制手段是在 instruction 里明确写同一个工具连续失败两次就停止重试并告知用户或者用 LoopAgent 的退出条件来兜底。我在没有约束的情况下见过模型连续调同一个工具六次把配额烧掉一大截。5. 多 Agent 编排与状态管理5.1 顺序、并行、循环三种编排怎么选单 Agent 搞不定的复杂流程就要上编排。ADK 给了三种基本形态。SequentialAgent顺序适合有明确前后依赖的流水线。比如先收集用户需求 → 再查资料 → 最后生成报告每一步都依赖上一步的输出。val pipeline SequentialAgent.builder() .name(report_pipeline) .subAgents(listOf(collectorAgent, researcherAgent, writerAgent)) .build()ParallelAgent并行适合互相独立的子任务。比如同时查天气、查航班、查酒店三个都查完了再汇总。用并行的收益很直接原本串行 6 秒的活儿并行之后两秒多。LoopAgent循环适合需要迭代打磨的任务比如写草稿 → 评审 → 修改循环到评审通过为止。这里必须设最大轮次否则容易死循环。val refineLoop LoopAgent.builder() .name(refine_loop) .maxIterations(3) .subAgents(listOf(draftAgent, reviewAgent)) .build()选择标准有依赖用顺序无依赖用并行需要收敛用循环。实际项目里三种往往是嵌套的外层顺序、中间某一步并行、某一步内部循环这都很正常。我的建议是先全用顺序跑通再针对明显的性能瓶颈改并行别一上来就设计复杂拓扑。5.2 Session、State、Memory 的分层这三层的区别是我见过最多人绕晕的地方。Session是一次会话的容器绑定了 userId 和 sessionId。它的生命周期通常就是用户这次对话的时长。State是挂在 Session 上的一份可变字典用来在多个 Agent、多轮对话之间传递数据。比如收集 Agent 把用户想要的目的地写进 state后面的 Agent 从 state 里读。写法上一般是通过context.state()读写。Memory是更长期的东西跨 Session 存在。用户上次聊天说喜欢安静的地方下次打开应用时 Agent 还能记得。// 子 Agent 里写入状态 context.state().put(destination, 杭州) context.state().put(days, 3) // 后续 Agent 里读取 val destination context.state().getString(destination)这里有个坑我必须提醒State 的 value 要能序列化。我最早图方便往里塞了一个自定义对象本地内存模式下一切正常换成持久化存储之后直接报序列化错误。后来统一约定State 里只放 String、Number、Boolean 和它们的集合复杂对象转成 JSON 字符串存。Memory 的使用要克制。不是所有信息都值得长期记住无差别地存会让上下文越来越长成本和延迟同步上升。我的做法是只存显式的用户偏好和关键事实并且设一个上限比如只保留最近 20 条。5.3 Callback不改 prompt 也能改行为Callback 是 ADK 里我认为最被低估的能力。它让你在不修改 prompt、不修改工具代码的前提下在关键节点插入自己的逻辑。常用的是四个位置模型调用前、模型调用后、工具调用前、工具调用后。我实际用过几个场景。一个是权限校验在工具执行前检查当前用户有没有权限调这个工具没有就直接拦掉并返回拒绝信息val guardedAgent LlmAgent.builder() .name(guarded) .model(gemini-2.5-flash) .instruction(...) .beforeToolCallback { tool, args, context - val allowed permissionService.canUse(context.userId(), tool.name()) if (!allowed) { // 返回非 null 表示短路不再执行原工具 mapOf(ok to false, reason to 当前账号没有该操作的权限) } else { null // 返回 null 表示继续走原流程 } } .build()另一个场景是审计日志。在 afterModelCallback 里把模型输入输出记录下来用于事后复盘。这个对排查模型为什么给出奇怪答案特别有用比事后猜强一百倍。还有一个不太常见但很实用的用法是成本监控。在 afterModelCallback 里累加 token 用量超过阈值就熔断当前会话。上线初期我就是靠这个把一个失控的循环给拦下来的。6. 实操避坑与排查速查表6.1 最常见的几类报错与定位顺序我把实际碰到的问题整理成了一张表按出现频率排序。现象大概率原因定位手段启动就报缺少凭证环境变量没配或名字写错打印 System.getenv 确认模型返回 400模型名写错或当前账号不可用对着官方模型列表逐个核对事件流卡住不结束工具里死循环或阻塞调用给工具加超时打点日志工具一直不触发描述不清晰或参数类型不匹配单独测试工具函数 改描述多轮对话丢失上下文sessionId 每次新建导致换会话确认 sessionId 复用序列化异常State 里塞了不可序列化对象统一转 JSON 字符串依赖冲突报 NoSuchMethod网络库或 RxJava 版本不一致用依赖树命令排查内存持续上涨事件流没取消Session 没释放检查协程作用域与 onCleared定位顺序我一般遵循三步先看日志里最后一个成功的 Event 是什么能确定卡在哪一步再单独测试工具函数排除业务代码问题最后才怀疑模型和框架。实践中八成问题出在前两步。6.2 工具不触发的五个排查点这个现象太常见了值得单独说。第一个点工具有没有真的注册到 Agent 上。我犯过这个错工具类写完了忘了加进.tools(...)排查了半小时。第二个点描述里有没有明确的触发词。如果工具描述写的是处理数据模型根本不知道什么时候该用。改成查询指定用户在指定日期范围内的订单列表触发率立刻不一样。第三个点参数类型是不是模型难以生成的形式。比如要求传一个复杂的嵌套对象模型很容易填错。能拆成扁平的基本类型就拆。第四个点instruction 里有没有反向引导。我见过一个案例instruction 里写了尽量直接用你的知识回答不要调用外部工具结果工具永远不触发。这两处要保持一致。第五个点模型能力差异。同样的工具和描述不同模型的表现差别可能很大。测试阶段如果一直不触发换个模型对照一下能快速判断是描述问题还是模型问题。提示我一般会准备一个工具冒烟测试的脚本不经过 Agent直接把工具函数用几个典型参数调一遍。业务代码正确的前提下问题必然在描述侧。6.3 延迟与成本的实际控制手段Agent 的延迟和成本本质上是两个变量的函数模型调用次数和上下文长度。调用次数怎么压一是减少不必要的工具。工具列表越长模型每次决策的负担越重也越容易乱调。我一般控制在单 Agent 五个工具以内多了就拆分。二是避免多层嵌套。三层嵌套的 Agent 链路意味着至少三轮模型调用能用一层解决就别套三层。三是用并行替代串行前面说过了。上下文长度怎么压一是历史消息做窗口截断只保留最近 N 轮更早的总结成一段摘要塞进 instruction。二是工具返回值精简。我见过一个工具返回了完整的一条数据库记录、四十多个字段而模型只需要其中三个。后来改成只返回必要字段单次 token 消耗直接降了七成。三是State 里别堆垃圾用完的中间变量及时清掉。成本方面还有一个容易被忽略的点同样的请求重复调用。用户在输入框里改了两个字重新发送如果每次都全量重跑一遍浪费很可观。可以做一层简单的结果缓存key 用规范化之后的输入加会话状态摘要。7. 我实际想过的几个落地方向7.1 端侧个人助手这是 Kotlin 版本最自然的场景。Android 应用里有大量本地数据——日程、备忘、照片元信息、应用使用记录——以前要么做成一堆固定入口的页面要么做成关键词搜索。有了 Agent 之后用户可以用一句话完成复合操作比如把我这周拍的照片里带猫的整理到一个相册。实现路径上工具就是几个本地能力的封装读相册、读日程、写文件、发通知。Agent 负责把自然语言拆成工具调用序列。这套东西的好处是数据不出端隐私层面的顾虑小很多前提是你用的是端侧能力或者只把必要的文本摘要发出去。难点在权限和兜底。用户拒绝相册权限时Agent 要能优雅降级而不是直接抛异常。我的做法是在工具里统一返回结构化结果把权限不足当成一种正常的业务分支返回给模型让模型去跟用户解释。7.2 服务端流程自动化服务端的想象空间更大。典型的比如客服工单分派收到一段描述 → 判断类别 → 查历史相似工单 → 查相关订单信息 → 生成处理建议 → 写入工单系统。这整条链路用 SequentialAgent 加几个函数工具就能搭出来而且每一步都可观测、可回放。另一个方向是内部工具的自然语言入口。以前运维同学要查个服务状态得记住七八个内部平台的入口和参数格式。套一层 Agent 之后直接说看一下订单服务昨天的错误率Agent 去调监控接口、调日志接口把结果汇总成一段话。这类场景我特别看重的两点每一步都要有审计日志出了问题能还原高风险操作必须有人工确认节点别让 Agent 有直接删数据、改配置的权限。这个边界一定要划清楚。我在实际用 ADK for Kotlin 的这段时间里最大的体会是它的价值不在模型那一层模型换来换去其实差别没有想象中那么大真正的价值在于它逼着你把 Agent 当成一个有生命周期的软件模块来设计。什么时候用工具、状态存在哪、失败怎么恢复、成本怎么控制这些问题在骨架搭起来之后都会自然浮现而如果一开始就是一堆散乱的 API 调用这些问题会一直藏在水面下直到上线之后集中爆发。最后再分享一个我自己的小习惯每加一个新工具我都会先写三个反例测试——分别是参数缺失、参数格式错误、工具内部异常看 Agent 的反应是否符合预期。这三个测试加起来花不了十分钟但能提前挡掉绝大多数线上问题。