3930 words
20 minutes
chat-miniagent APK 设计与技术:一个薄 Android 客户端怎么接住 mini-agent

chat-miniagent APK 不是一个”把 Web 页面套进 Android 壳”的项目。它更像 mini-agent 的手机遥控器:用户在手机上输入自然语言,APK 把这句话送到服务端的 /message,服务端跑 agent、做意图判断、调用工具,再把结果通过 SSE 一段一段推回来。

这篇文章按后端开发者的视角来讲。你可以把 APK 想成一个很克制的 BFF 客户端:它负责登录、发消息、展示流式响应、保存本机滚动历史,以及给 todo / schedule 提供两个轻量 Tab。除此之外,它不试图在手机端复刻 mini-agent 的业务大脑。

一句话架构#

Android 端只负责交互和状态呈现,mini-agent 服务端负责意图路由、任务执行和最终结果。

这条边界很重要。它决定了 APK 里不会出现这些东西:

  • 不在客户端做自然语言意图识别。
  • 不在客户端做 todo / schedule 的本地镜像同步。
  • 不在客户端跑 agent、shell、任务编排或计划任务。
  • 不用 WorkManager 在后台继续生成。
  • 不依赖服务端 /history 来做 Android 聊天滚动历史。

唯一的窄例外是 todo / schedule Tab。它们走服务端已有 REST 接口,目的是让手机上常用的查看、添加、完成、删除更顺手;但数据仍然以服务端为准,Android 不做离线数据库和双向同步。

后端同学先看这张映射表#

后端里常见的概念APK 里的对应物它做什么
Controller / RouteMiniAgentApi封装 /auth/message、todo、schedule HTTP 调用
ServiceChatRepository / TasksRepository把远程 API 和本地存储组合成 ViewModel 能用的接口
状态机ChatViewModel消费 ack / typing / delta / end / error,驱动聊天 UI
DTOMessageRequestTodoItemDtoScheduleItemDto用 kotlinx serialization 对齐后端 JSON
Stream parserSseParser增量解析 data:{json}\n\n,能处理半包和 keepalive
Session middlewareAuthInterceptor + TokenHolder给请求加 Bearer token,遇到失效态回登录
本地表Room LocalChatStore只存 Android 本机聊天滚动历史
Secret storeEncryptedSharedPreferences保存 token 和密码,用于恢复登录态
DI containerHilt AppModule组装 OkHttp、Room、Repository、AuthManager 等对象

如果你平时写 FastAPI、aiohttp、Spring 或 Express,这套分层应该不陌生。差异主要在两处:第一,聊天响应是一个长连接式的流;第二,Android 有自己的生命周期,用户点 Stop、切后台、旋转屏幕、网络断开,都要能把状态收干净。

一次聊天请求怎么走完#

从用户点发送开始,链路大致是这样:

ChatScreen
-> ChatViewModel.send(text)
-> ChatRepository.send(text)
-> MiniAgentApi.streamMessage(text)
-> OkHttp POST /message
-> mini-agent WebChannel._handle_message()
-> MessageHandler._handle_chat()
-> IntentRouter / executor / tools
-> HttpResponder writes SSE frames
-> SseParser emits SseEvent
-> ChatViewModel updates UI state

Android 发给服务端的是普通 JSON:

POST /message
Authorization: Bearer <token>
Accept: text/event-stream
Content-Type: application/json
{"text":"帮我看看今天还有什么待办"}

服务端的 WebChannel 会看 Accept 头。如果包含 text/event-stream,它创建 aiohttp StreamResponse,再把这个响应交给 HttpResponder。如果不是 SSE,它会走 JSON fallback,用 BufferResponder 收集最终文本。

这意味着 Web、Telegram、Android 可以共用同一个 MessageHandler。区别只在 responder:Telegram 不逐 token 展示,HTTP SSE 可以逐段写回,JSON fallback 只拿最后答案。

SSE 协议:小而固定#

APK 和服务端之间的流式协议没有做成 OpenAI 那种大而泛的 chunk 格式,而是固定成五类事件:

type含义Android 怎么处理
ack服务端确认收到,通常是一句提示显示顶部 chip
typing服务端开始处理或仍在处理显示打字状态
delta增量文本追加到 streamingText
end最终完整文本用完整文本替换增量缓冲,并持久化
error出错切到错误状态,显示错误

一个简化的 SSE 长这样:

data: {"type":"ack","text":"收到,开始处理"}
data: {"type":"delta","text":"今天"}
data: {"type":"delta","text":"还有 2 个待办。"}
data: {"type":"end","text":"今天还有 2 个待办。"}

这里有个设计点:完成信号不是 [DONE],而是 type:end,并且 end.text 带完整最终答案。

为什么要这么做?因为移动网络和流式 UI 都会遇到半包、丢帧、重复渲染、Markdown 没闭合这些小问题。Android 可以一边用 delta 展示打字效果,一边在 end 到来时用服务端给的完整文本做一次自修正。最终保存到 Room 的也是 end.text,不是本地拼出来的临时 buffer。

Android 端怎么接 SSE#

MiniAgentApi.streamMessage() 的核心是 callbackFlow 包一层 OkHttp Call

callbackFlow {
val call = client.newCall(request)
val job = ioDispatcher.launch {
val response = call.execute()
response.body.byteStream().read(buffer)
parser.feed(chunk).forEach { trySend(it) }
}
awaitClose {
call.cancel()
job.cancel()
}
}

这段逻辑解决了三个 Android 上很实际的问题。

第一,OkHttp 是阻塞读流,Compose / ViewModel 更适合用 Flow 表达状态。callbackFlow 把它们接起来。

第二,Stop 按钮不需要另做一套取消 API。ChatViewModel.stop() 取消 collector job,Flow 关闭时执行 awaitClose,最终落到 call.cancel()。这条链路短、可测试,也不让后台偷偷继续读网络流。

第三,SSE 解析不能按行拍脑袋切。网络 chunk 可能刚好停在半个 JSON 中间,所以 SseParser 是增量 parser:它保留未完成 frame,直到看到完整的 \n\n 再解析;空 data: keepalive 也不会把状态机弄乱。

ChatViewModel 是聊天状态机#

后端同学看 Android ViewModel,可以把它理解成”前端 service + reducer”。它不负责执行业务,只负责把事件归并成屏幕状态。

发送消息时,ChatViewModel 会先把用户消息放进当前列表,然后进入 streaming 状态。之后它逐个消费 SseEvent

  • 收到 ack:更新状态 chip,比如”已收到”。
  • 收到 typing:让 UI 显示等待中的点点。
  • 收到 delta:追加到 streamingText,屏幕上出现正在生成的回复。
  • 收到 end:把最终 assistant 消息加入列表,清空 streaming buffer,并写入 Room。
  • 收到 error:停止生成,保留用户消息,显示错误。

这里没有复杂魔法,反而是这套设计最稳的地方:服务端协议是有限状态,客户端状态机也跟着保持有限状态。后面如果要改 SSE schema,必须同时改 SseParserChatViewModel 的分支处理;这两个文件天然是一对。

Markdown 为什么分两种渲染#

APK 里用了 Markwon 渲染 Markdown,并加了 Prism4j 语法高亮。最终消息和历史消息会走 Markdown 渲染,所以代码块、列表、标题都能正常显示。

但流式生成中的文本没有强行边收边 Markdown。原因很简单:AI 输出 Markdown 时经常先吐出半个代码围栏、半张表格、一个还没闭合的链接。边收边完整渲染容易闪、容易重排,也容易让 Compose 的测量成本变高。

所以当前策略是:

  • streaming 中:用普通 Text 展示,优先保证响应及时、布局稳定。
  • end 之后:用 Markwon 渲染完整 Markdown,并把完整答案写入历史。

这不是功能退让,而是移动端体验上的取舍。用户最先感知的是”它在回应我”,最终阅读时再拿到排版完整的 Markdown。

输入框和聊天页布局#

这次 UI 重构后,聊天页的输入框采用自动增高设计:单行时保持紧凑,文本超过一行后向上扩展,达到最大高度后内部滚动。它有两个好处:

  • 底部操作区不会一开始就占很大面积。
  • 长问题不会被压成一个很窄的小窗口,用户能检查自己写了什么。

聊天返回消息的 Markdown 宽度也被固定在一个舒服的阅读范围里。移动端天然窄,问题不大;平板或横屏时,如果 Markdown 跟着屏幕无限拉长,代码和段落都会变难读。固定正文宽度后,聊天流更像文档阅读,而不是横向摊开的日志。

视觉上,APK 没有继续使用紫色,而是换成 Porcelain / Ink / Sage 这一组更安静的配色:浅底、深墨文字、绿色主色和低饱和的功能色。这个选择和项目气质更贴近:它是一个日常使用的 agent 客户端,不是营销页,也不是游戏化工具。

认证:把 444 当成 session invalid#

mini-agent 的云实例有一个特殊点:开启 web.silent_errors=true 时,认证失败可能返回 HTTP 444,body 为空或不是 JSON。很多客户端会在这里犯错:看到 4xx 还尝试 JSON parse,最后把真正的登录失效吞成解析异常。

APK 的处理更直接:

  • /auth 成功后拿 token。
  • token 和密码保存到 EncryptedSharedPreferences。
  • TokenHolder 保存内存热拷贝,AuthInterceptor 从这里给请求加 Bearer。
  • 遇到 401 或 444,不解析业务 JSON,直接认为 session invalid。
  • AuthManager.invalidate() 清掉 token,UI 回到 AuthGateScreen

服务端 token 当前是 24 小时、内存态。服务端重启后,即使 Android 本地还有 token,也可能已经失效。因此 APK 启动恢复登录态时不能只相信本地存储,还要能处理下一次请求返回的 401 / 444。

本地历史:只为 Android 滚动服务#

Android 端用了 Room 保存聊天历史,但它的角色很窄:只保存这台手机上的 scrollback。

这点容易和服务端 /history 混在一起。当前设计刻意不把 Android 做成 Web 历史的镜像,也不做多端一致性。原因有三个:

第一,mini-agent 的能力入口是自然语言 /message,历史不是 agent 执行所需的客户端状态。

第二,移动端本地历史的主要价值是”回到 App 时还能看到刚才聊了什么”,不等于跨端会话系统。

第三,一旦做同步,就会引入冲突、分页、删除、加密、账号切换、离线队列等一串问题。它们不是现在这个 APK 的核心目标。

所以 ChatRepository.loadHistory() 读 Room;send() 时先保存用户消息,收到 end 后保存最终 assistant 消息。streaming 中间态不落库,避免把半截回答写成正式历史。

todo / schedule:一个被允许的例外#

虽然项目原则是”所有 agent 能力都在 /message 后面”,但 todo / schedule 被做成了底部 Tab。这个例外不是因为客户端要接管业务,而是因为这两类信息在手机上很适合快速查看和轻操作。

当前 REST 面包括:

功能接口
查看待办GET /todo
新增待办POST /todo/add
完成待办POST /todo/done
查看日程GET /schedule
新增日程POST /schedule/add
删除日程POST /schedule/remove

Android 的 TasksViewModel 做的事很少:加载列表、提交新增、提交完成或删除、成功后刷新。失败时按类型处理:400 这类业务错误变成 snackbar;401 / 444 这类认证错误走统一登出。

它没有 Room cache,没有后台 sync,也没有复杂的本地 mutation queue。这样后端同学看接口行为时会很轻松:服务端仍然是 todo / schedule 的唯一真实来源。

APK 用到的主要组件#

组件用途设计考虑
Kotlin 2.1主语言协程、Flow、serialization 都是 Kotlin 生态里顺手的工具
Jetpack ComposeUI聊天流、输入框、Tab、Dialog 都用声明式状态驱动
Material 3组件和主题提供基础控件、色彩语义、暗亮主题适配
Hilt依赖注入把 API、Repository、Room、AuthManager 等绑定清楚
OkHttp 4HTTP/SSE直接控制长连接、超时和 Call.cancel()
kotlinx serializationJSON DTO与后端 JSON contract 对齐,避免手写 map
Room本地历史保存 Android 本机聊天 scrollback
EncryptedSharedPreferences凭据保存保存 token/password,支持重启后恢复
Markwon + Prism4jMarkdown / 代码高亮最终消息和历史消息可读性更好
JUnit / Truth / Turbine / MockWebServer / Robolectric测试覆盖 parser、ViewModel、Repository、HTTP fixture

有一个细节值得单独说:OkHttp 的 read timeout 设置为 0。普通 REST 请求不应该这么干,但 SSE 是长时间读流,如果 read timeout 太短,服务端稍微思考久一点,客户端就会自己断开。

为什么不用更重的移动端方案#

后端开发者很容易问:要不要后台任务?要不要 push?要不要本地同步?要不要多端历史?

这些都可以做,但现在不做。

原因不是技术上不会,而是它们会改变产品边界。chat-miniagent APK 当前要解决的是”手机上能稳定、清楚地使用 mini-agent”,不是重建一个完整移动协作系统。

如果加入 WorkManager,用户关掉页面后生成仍在后台跑,Stop 语义会变复杂。如果加入 FCM 或 WebSocket,服务端要维护设备、推送权限、离线补偿。如果加入 todo/schedule 本地库,就要处理冲突和一致性。每加一个能力,APK 就更像一个完整业务端,而不是薄客户端。

所以当前版本先把主链路做好:

  • 登录能恢复,失效能回登录。
  • /message 能稳定流式返回。
  • Stop 能取消真实网络请求。
  • Markdown 最终展示清楚。
  • 本机历史能回看。
  • todo / schedule 能做轻量操作。

这个范围小,但闭合。

和 Web 版体验的差别#

Web 端天然有更大的屏幕、更自由的 DOM、浏览器级调试工具,也更容易做复杂历史和多面板。Android 端则更在意:

  • 屏幕窄,Markdown 宽度要受控。
  • 输入法会顶起布局,底部输入区要稳。
  • 网络切换频繁,SSE parser 要能处理 chunk 边界。
  • 生命周期短,取消和恢复不能依赖页面一直活着。
  • 本地凭据要放进系统加密存储。

所以 APK 没有照搬 Web 的历史语义,而是选择 Room 本机历史;没有照搬 Web 的大布局,而是做成聊天、待办、日程三个紧凑 Tab;没有把 streaming Markdown 做得很花,而是先保证流畅,再在最终态渲染完整 Markdown。

测试怎么覆盖#

这类项目最该测的是协议和状态,而不是截图。

当前测试重点放在这些地方:

  • SseParserTest:半包、多个 frame、空 keepalive、错误帧。
  • ChatViewModelTestack / typing / delta / end / error 状态机。
  • ChatRepositoryTest:MockWebServer 提供 SSE fixture,验证 Repository 行为。
  • API/任务相关测试:todo、schedule、401/444、非 2xx 错误处理。

工程验证上,当前实现已经跑过:

Terminal window
./gradlew :app:testDebugUnitTest
./gradlew assembleDebug
./gradlew lintDebug

三者通过。真机或模拟器上的 connectedAndroidTest 还需要可用设备,这类验证不能用单元测试假装覆盖。

给后端开发者的阅读顺序#

如果你想快速接手这个 APK,我建议按这条线读代码:

  1. 先看 MiniAgentApi,理解 HTTP contract、SSE、401/444。
  2. 再看 SseParser,理解为什么 parser 是增量的。
  3. ChatViewModel,把五类 SSE 事件和 UI 状态对上。
  4. ChatRepository,确认 Room 只存本地滚动历史。
  5. AuthManagerTokenHolderAuthInterceptor,理解 token 生命周期。
  6. 最后看 ChatScreen 和主题文件,理解 UI 为什么这么组织。

读完这几处,基本就能知道:手机端什么时候发请求、后端什么时候回事件、什么东西会被保存、什么时候会回登录、Stop 到底停在哪里。

小结#

chat-miniagent APK 的核心设计不是”Android 做得越多越好”,而是”Android 只做它最该做的事”。

它把手机端体验需要的部分做实:Compose UI、自动扩展输入框、Markdown 最终渲染、本地历史、token 恢复、SSE 停止。与此同时,它把 agent 能力、意图路由、工具执行、todo/schedule 真实数据都留在 mini-agent 后端。

对网站后端开发者来说,理解这个 APK 的关键不是学习一堆 Android API,而是抓住三条线:

  • /message 是主链路,SSE 五类事件是前后端协议。
  • Android 是薄客户端,Room 只是本机 scrollback。
  • todo / schedule 是窄 REST 例外,但服务端仍是唯一真实来源。

抓住这三条线,再看 Kotlin、Compose、Hilt、OkHttp、Room,就不会迷路。

chat-miniagent APK 设计与技术:一个薄 Android 客户端怎么接住 mini-agent
https://sgjki547.top/posts/2026-07-31-chat-miniagent-apk-设计与技术/
Author
SGJki
Published at
2026-07-31
License
CC BY-NC-SA 4.0