Android 原生客户端集成 adk-python AI Agent 的实践总结

stock-android-native 是一个 Kotlin + Jetpack Compose 的纯原生 Android 项目,其中 AI 助手模块直接对接 Google adk-python 暴露的 HTTP 接口(/run_sse、Session CRUD),中间没有自建网关。这篇文章按功能维度总结它的集成方式:session 列表分页、chat streaming、思考过程与正式文本分离、工具调用展示、token 统计、Markdown 渲染、页面滚动跟随,以及背后的并发控制细节。

涉及的核心文件:

  • 协议模型:app/src/main/java/com/anonymous/stocknative/nativeapp/core/agent/AgentModels.kt
  • 网络层:app/src/main/java/com/anonymous/stocknative/nativeapp/core/agent/AgentRepository.kt
  • 状态层:app/src/main/java/com/anonymous/stocknative/nativeapp/feature/agent/AgentViewModel.kt
  • UI 层:AgentSessionsScreen.ktAgentChatScreen.ktMarkdownText.kt

总体架构与认证

集成思路是「客户端直连 ADK」,不包一层自己的后端。ADK 服务的地址、应用名、Basic Auth 凭据都在构建期注入 BuildConfigapp/build.gradle.kts:25-75):

  • AGENT_BASE_URL(如 https://agent.arloor.com
  • AGENT_APP_NAME(默认 astome_agent
  • AGENT_BASIC_AUTH_USER / AGENT_BASIC_AUTH_PASSWORD(放在不入库的 local.properties

AppContainer 里为 Agent 服务单独建了一个 OkHttpClient,用拦截器给每个请求加上 Basic Auth 头(di/AppContainer.kt:53-74)。注意这和主站 API 的认证是两套体系:主站走 Bearer token(AuthInterceptor),Agent 服务走 Basic Auth,互不干扰。

AgentRepository 在传入的 client 之上又派生出两个 client(AgentRepository.kt:30-37):

private val httpClient = httpClient.newBuilder()
    .connectTimeout(15, TimeUnit.SECONDS)
    .build()

/** SSE 需要无限读取超时,单独派生一个 client。 */
private val sseClient = httpClient.newBuilder()
    .readTimeout(0, TimeUnit.SECONDS)
    .build()

SSE 长连接必须把 readTimeout 设为 0,否则流式响应会被 OkHttp 默认超时掐断——这是集成流式接口时最容易踩的坑之一。

数据模型:对齐 ADK Event 协议

AgentModels.kt 的模型几乎是 ADK Event 结构的镜像,用 kotlinx.serialization 反序列化(Json { ignoreUnknownKeys = true; isLenient = true },对服务端字段演进很宽容):

  • AgentSession:id / appName / userId / state / lastUpdateTime / events。会话自定义名称存在 session state 的 displayName 字段里,通过一个计算属性读出(AgentModels.kt:19-24)。
  • AgentEvent:id / author / timestamp / partial / content / usageMetadatapartial 是流式增量事件的标志,后面合并文本时至关重要。
  • AgentParttextthoughtfunctionCall 三个字段,对应 ADK 里文本 part、思考 part、工具调用 part。

模型层直接把「怎么解读一个事件」的逻辑收敛成了几个计算属性(AgentModels.kt:44-65):

/** 仅拼接模型标记为思考过程的文本 part。 */
val thoughtText: String
    get() = content?.parts
        ?.filter { it.thought }
        ?.mapNotNull { it.text }
        ?.joinToString("")
        .orEmpty()

/** 仅拼接非思考文本 part,用作面向用户的正式回复。 */
val responseText: String
    get() = content?.parts
        ?.filterNot { it.thought }
        ?.mapNotNull { it.text }
        ?.joinToString("")
        .orEmpty()

/** 事件中 functionCall part 的工具名列表。 */
val toolCalls: List<String>
    get() = content?.parts?.mapNotNull { it.functionCall?.name }.orEmpty()

思考/正式回复/工具调用在模型层就完成了分流,上层不需要再碰 parts 结构。

Session 列表:游标分页 + 预览补齐

分页接口

列表走的是分页接口(AgentRepository.kt:39-58):

GET {baseUrl}/apps/{appName}/users/{userId}/session-pages?limit=10&cursor=xxx

返回 AgentSessionPage(sessions, nextCursor, hasMore),是典型的游标分页——相比 offset 分页,在会话持续新增的场景下不会翻页错位。

ViewModel 的分页状态机

AgentSessionsViewModelAgentViewModel.kt:32-260)维护的状态里有两套错误:error(首屏加载失败,整页错误态)和 loadMoreError(翻页失败,列表尾部出现「加载更多失败,点击重试」),互不清空对方的数据。翻页做了几件细致的事:

  • PAGE_SIZE = 10nextCursor 存在 ViewModel 私有字段里,不暴露给 UI;
  • loadMore() 入口有四重守卫:正在首屏加载、正在翻页、没有下一页、翻页错误未重试,任何一个命中都直接返回;
  • 追加新页时按 session id 去重(AgentViewModel.kt:126-127),防止服务端数据在翻页间隙变化导致重复项;
  • loadGeneration 单调递增计数器:每次 loadSessions() 刷新都会使之前所有未完成的请求回调失效(generation != loadGeneration 时直接丢弃结果),切账号(loadedUserId 变化)也会重置游标和列表。

滚动触发预加载

UI 侧在 AgentSessionsScreen.kt:124-152snapshotFlow 监听列表布局信息,当最后一个可见项距离末尾不足 LoadMoreThreshold = 3 项时触发 loadMore()

snapshotFlow {
    val layoutInfo = listState.layoutInfo
    val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
    val loadMoreIndex = (layoutInfo.totalItemsCount - LoadMoreThreshold).coerceAtLeast(0)
    lastVisibleIndex >= loadMoreIndex
}
    .distinctUntilChanged()
    .collect { shouldLoadMore ->
        if (shouldLoadMore) viewModel.loadMore()
    }

snapshotFlow + distinctUntilChanged 保证只在「到达阈值」这个布尔值翻转时触发一次,不会滚动过程中连发请求。

列表项预览的补齐

一个现实问题:分页列表接口不返回 events,列表项没有内容摘要。解法是 loadPreviews()AgentViewModel.kt:159-185)——对当页每个 session 并发地调一次详情接口,取第一条 role 为 user 的消息文本作为标题预览,逐条回填到 previews: Map<String, String>。回填前再次校验 generation 和 session 是否还在列表里,避免过期数据上屏。标题的兜底顺序是:displayName(用户改的名)→ 首条用户消息预览 → 「新会话」。

另外从聊天页返回列表时,通过 LifecycleEventObserver 监听 ON_START 自动刷新列表(AgentSessionsScreen.kt:111-122),因为聊过之后会话的最后更新时间和摘要都变了。

会话管理:左滑改名/删除

列表项是自绘的左滑手势组件(AgentSessionsScreen.kt:321-413):底层放「改名」「删除」两个按钮,前层卡片用 Animatable + detectHorizontalDragGestures 随手势左移,松手时根据是否超过半程决定吸合到展开位还是弹回。改名走 ADK 原生的 session PATCH 接口,把名字写进 state_delta.displayNameAgentRepository.kt:82-97),成功后本地同步更新 state,不需要重新拉列表。

Chat Streaming:手写 SSE 客户端

/run_sse 请求

发消息走 ADK 的 /run_sseAgentRepository.kt:112-179),请求体里的 streaming = true 用了 @EncodeDefault(ALWAYS) 强制序列化——kotlinx.serialization 默认会省略等于默认值的字段,而这个字段恰好是服务端开启流式模式的开关,省了就不流式了。这是个很隐蔽的坑,值得记住。

逐行解析而不是用 SSE 库

没有用 OkHttp 的 okhttp-sse 扩展,而是在 callbackFlow 里起一个线程,用 Okio 的 source.readUtf8Line() 逐行读,只处理 data: 前缀的行,跳过空数据和 [DONE],每行 JSON 反序列化成 AgentEventtrySend 进 Flow。单行解析失败直接丢弃(runCatching{...}.getOrNull()),不让一条脏数据杀掉整个流。

资源回收也很干净:awaitClosecall.cancel() + thread.interrupt(),Flow 下游取消(比如用户退出页面、ViewModel 销毁)时连接和线程一起释放;如果是用户主动取消,就不会把 InterruptedIOException 当成错误上抛(AgentRepository.kt:166-172)。

partial 事件的文本合并

ADK 流式推送有两种事件:partial = true 的增量片段,和 partial = false 的完整汇总事件。如果无脑拼接,汇总事件会把已收到的内容重复一遍。mergeStreamText()AgentViewModel.kt:468-482)就是解决这个问题的:

internal fun mergeStreamText(accumulated: String, incoming: String, partial: Boolean): String {
    if (incoming.isEmpty()) return accumulated
    if (partial) return accumulated + incoming
    return when {
        // 完整事件是对已流式增量的汇总,跳过避免重复。
        accumulated.endsWith(incoming) -> accumulated
        incoming.startsWith(accumulated) -> incoming
        // 多段回复场景,追加新段。
        else -> accumulated + incoming
    }
}
  • 增量事件:直接追加;
  • 完整事件是已收增量的子串/重复:丢弃;
  • 完整事件包含已收增量:以完整事件为准(修正作用);
  • 否则视为新的一段(多轮工具调用中间穿插文本的场景):追加。

思考和正式回复各自独立累积一份,调用两次 mergeStreamText,避免两个通道互相串扰。这个函数有独立单测(AgentStreamTextTest.kt),是整个流式链路里最值得测试的纯函数。

发送时的状态组织

send()AgentViewModel.kt:355-462)先把用户消息和一条 pending = true 的空 AI 消息插入列表,然后收集 Flow:

  • 每个事件到达就更新 responseText/thoughtText/toolCalls/usage 四个本地变量并立即 updateAssistant() 上屏,「服务端每推一个事件就立刻上屏,不做额外缓冲」;
  • 纯工具调用/纯用量事件没有文本,也要触发一次上屏(AgentViewModel.kt:449-454);
  • .catch 把错误写进 AI 气泡本身(发送失败: xxx),而不是弹全局错误,会话上下文不丢;
  • .onCompletion 里把 pending 置 false、sending 置 false,此时才把 usage 挂到消息上。

思考过程与正式文本的分离展示

UI 上两者是彻底分开的两个区域(AgentChatScreen.kt:237-276):

  • 思考过程ThoughtSection 用独立浅灰底色圆角块展示,标题为「思考过程 · 进行中 / 思考过程」。流式进行中(pending)默认展开,让用户能看到模型在「想」;结束后默认收起,可点击展开。内容用普通 Text 而不是 Markdown——思考文本不需要排版,也能省掉流式期间频繁 Markdown parse 的开销。
  • 正式回复:当思考文本非空时,正式文本前会加一个「正式回复」小标签,正文走 Markdown 渲染。

这个设计的取舍很务实:思考过程满足「透明化」的诉求但不抢视觉焦点,正式回复才是主体。

工具调用:相邻合并 + 超限折叠

Agent 一次回复往往触发多次工具调用,每次调用又是一个独立事件。如果每个事件一条气泡,屏幕会被工具调用 chip 刷屏。处理分两层:

ViewModel 层send() 里把本轮所有事件的 toolCalls 累积到一个列表,本轮回复只有一条 AI 消息。

UI 层mergeAdjacentToolCallMessages()AgentChatScreen.kt:341-366)处理历史消息——历史里每次工具调用都是独立 event/消息。规则是:

  • 相邻的「纯工具调用消息」(无文本无思考、只有工具调用)合并成一条,toolCalls 拼接;
  • 纯工具调用消息后面紧跟一条带文本的模型消息,且那条消息也带工具调用,则把工具调用并进前面的组,文本留在原消息里;
  • 合并后保留首条消息的 id 作为 LazyColumn 的稳定 key,组内 usage/pending 取最新值。

展示上 ToolCallList 把工具名渲染成主题色 chip,超过 3 个折叠成「展开其余 N 个工具调用」,点击整组展开/收起(AgentChatScreen.kt:368-417)。这个合并逻辑同样有独立单测(AgentChatMessageMergeTest.kt)。

Token 统计

ADK 事件的 usageMetadata 字段直接映射为 AgentUsageMetadata:prompt / candidates / total / cached / thoughts token 数。展示上有两个讲究(AgentViewModel.kt:390-394AgentChatScreen.kt:419-437):

  1. 流式过程中不上屏:usage 在流式期间只记录不展示,等 .onCompletion(文本、工具调用全部完毕)后才挂到消息上——否则文字还在输出时,底部会残留上一段的 token 统计,视觉上是错位的。
  2. 缓存项恒显示缓存 N 即使为 0 也显示,目的是能一眼确认上下文缓存是否生效;输入/输出/思考则只在大于 0 时显示。最终形如:tokens · 输入 1203 · 输出 245 · 缓存 1024 · 思考 89

Markdown 渲染:Markwon + AndroidView

Compose 没有原生 Markdown 组件,这里选了 Markwon 4.6.2,额外挂了删除线、表格、任务列表三个插件(build.gradle.kts:187-191)。MarkdownText.kt 的封装有几个性能要点:

val renderedMarkdown = remember(renderer, markdown) {
    renderer.render(renderer.parse(markdown))
}
  • Markwon 实例用 remember(applicationContext) 全局只建一次;
  • parse + render 的结果用 remember(renderer, markdown) 缓存,重组时只要 markdown 文本没变就不重新解析;注意这也意味着流式期间文本一变就会全量重 parse,这是选择「流式也用 Markdown」的固有成本(思考过程用普通 Text 渲染,部分对冲了这个成本);
  • 最终通过 AndroidView 包一个原生 TextViewrenderer.setParsedMarkdown() 上屏——绕过 setMarkdown() 可以避免重复 parse;
  • TextView 开了 setTextIsSelectable(true)(长按选择复制)和 LinkMovementMethod(链接可点),includeFontPadding = false + 1.2 倍行距让气泡排版更紧凑。

页面滚动:自动跟随与手势让位

聊天页滚动是流式 UI 的经典难题:内容不停变长,要自动滚到底;但用户往上翻历史时,绝不能被拽回底部。这个项目的策略(AgentChatScreen.kt:67-106):

1. 底部锚点 item。 LazyColumn 末尾放一个 1dp 的 Spacer(key = agent-chat-bottom-anchor),自动滚动时 scrollToItem(displayMessages.size) 定位到这个锚点,而不是估算偏移量。

2. 直接跳转,不做动画。 用的是 scrollToItem 而非 animateScrollToItem——流式期间内容更新频繁,连续动画会互相取消造成回弹抖动。

3. 手指一拖动就取消跟随。 通过 listState.interactionSource.collectIsDraggedAsState() 监听拖动,拖动的瞬间(不等松手)就把 followStreamingResponse 置 false——避免滚动动画和手势争抢控制权。

4. 滚回底部才恢复跟随。snapshotFlow 同时观察「在拖动 / 在滚动(含 fling)/ 还能否继续向前滚」三个状态,只有当用户主动滚过、且松手、且惯性滚动结束、且已到底部(canScrollForward == false)时,才恢复自动跟随:

snapshotFlow {
    Triple(isListDragged, listState.isScrollInProgress, listState.canScrollForward)
}.collect { (dragged, scrolling, canScrollForward) ->
    if (userHasDraggedList && !dragged && !scrolling && !canScrollForward) {
        followStreamingResponse = true
    }
}

5. 发送即跟随。 点发送按钮时强制 followStreamingResponse = true,因为用户发了消息必然期待看到回复。

另外整个页面套了 imePadding(),键盘弹出时输入框和列表自动上顶。

并发控制:到处都是 generation 计数器

这个项目处理「异步回调时序错乱」的方式统一而简单——每个可能发生竞态的通道都有一个单调递增的 generation:

计数器 防的是什么
loadGeneration 刷新会话列表后,旧列表请求/预览请求的结果回流覆盖新数据
historyLoadGeneration 聊天页重复触发 loadHistory 时旧结果覆盖新结果
localConversationGeneration 加载历史期间用户发了消息,较慢的历史详情响应覆盖正在流式的本地消息

第三个场景最微妙:loadHistory() 发起时快照当前的 localConversationGenerationsending,响应回来时发现「期间有发送行为」或「正在流式」,就只关 loading、不替换消息列表(AgentViewModel.kt:294-336)。注释写得很清楚:「若刷新期间已经开始发送消息,保留更实时的本地流式状态,避免较早发出的详情请求覆盖刚发送的内容」。

测试

流式合并这类纯逻辑被刻意写成了无 Android 依赖的顶层/internal 函数,配套了 JVM 单测:

  • AgentStreamTextTest.ktmergeStreamText 的增量追加、汇总去重、多段追加;
  • AgentChatMessageMergeTest.ktmergeAdjacentToolCallMessages 的各种相邻组合;
  • AgentModelsTest.kt:事件模型的文本/思考/工具调用提取。

UI 和 ViewModel 反而没有重测试投入——把易错的逻辑抽到纯函数里测,是这个小项目很聪明的取舍。

总结

这套集成没有引入任何花哨的框架,几个关键决策值得复用:

  1. 直连 ADK,Basic Auth + 构建期注入配置,网络层只有两个派生 client 的区别(SSE 要 readTimeout(0));
  2. 手写逐行 SSE 解析 + callbackFlow,比引入库更可控,取消语义干净;
  3. partial 双形态事件用 merge 函数去重,且思考/正式文本双通道独立累积;
  4. usage 延迟到流式结束后上屏、思考过程流式默认展开/结束默认收起、相邻工具调用聚合——都是围绕「流式期间的视觉稳定」做的取舍;
  5. 滚动跟随用「手势优先、到底恢复」状态机,配合底部锚点和无动画跳转;
  6. generation 计数器统一解决所有异步竞态,简单但有效。