← blog 技术原理与实践 · 2026-08-07

从后台流式到可恢复执行:MiniAgent Android 客户端的容灾改造

记录 MiniAgent Android 客户端如何从前台服务托管流式请求,扩展到断网重连、SSE 断点续接和 APK 进程重启后的幂等任务恢复。

10 min read

问题不在网络,而在请求跟着界面一起消失

MiniAgent 的 Android 客户端是一个很薄的客户端:用户把文本交给 POST /message,服务端用 SSE 依次推送 ack、typing、delta、end 或 error。客户端负责把过程显示出来,把最终对话保存在 Room;任务执行、路由和业务判断都留在服务端。

这个边界很简单,也带来一个很具体的问题。原来的流由 ChatViewModel 持有,用户切到后台后,页面不再是一个适合承载长连接的位置。一次几十秒的 agent 执行可能仍在服务端进行,APK 却已经不再可靠地持有 SSE 请求。用户回到应用时,最糟的结果不是看不到增量文字,而是不知道这次任务到底有没有结束。

8 月 6 日深夜到 8 月 7 日凌晨的几组提交,先用前台服务解决后台切换,再把一次消息执行变成可恢复任务:断网或 SSE 连接被关闭时可以重连,APK 进程被杀后可以用原来的身份重新接管任务。用户显式点击 Stop 时,客户端仍会取消前台服务,并在已经拿到服务端任务 ID 的情况下请求后端取消。

先定边界:不把聊天客户端做成同步引擎

改造前先排除了几条看似完整、但不符合当前产品边界的路。

  • 不增加客户端意图路由,也不新增聊天历史同步。
  • 不用 WorkManager 承接实时 SSE。它适合可延后、可调度的后台工作,不适合用户正在看的长连接和即时停止。
  • 不把每个 delta 写入 Room。局部增量是 UI 的临时状态,持久化会制造频繁写入和恢复语义问题。
  • 不持久化所有 delta,也不承诺后端进程重启后还能回放完整 SSE 事件;这部分仍是后续阶段。

因此,选的是短生命周期前台服务:它只在一条活动流存在时运行,显示“正在生成回复”的低优先级通知,在 end、error 或 Stop 后立刻结束。Android 对这类用户可感知的持续工作要求状态栏通知;dataSync 也正好覆盖网络取数和设备与云端之间的数据传输。Android 前台服务概览 和 前台服务类型要求 都把这个约束说得很清楚。

改造后的数据流

下面是这次提交落下的执行路径。它没有改动 /message 的协议,只调整了 Android 端谁来托管请求。

ChatViewModel.send()
  |  创建本地会话,写入用户消息
  v
ChatStreamLauncher
  |  ContextCompat.startForegroundService()
  v
ChatStreamingService
  |  在 IO 协程里执行 ChatStreamExecutor
  v
ChatRepository -> MiniAgentApi.callbackFlow -> OkHttp Call -> POST /message SSE
  |                                                |
  |                         ack / typing / delta / end / error
  v                                                v
ChatStreamCoordinator <----------------------- ChatStreamExecutor
  |
  v
ChatViewModel 更新界面;end 时服务写入完整 assistant 消息

ChatViewModel 仍然拥有界面状态:收到 ack 显示确认信息,typing 切换正在输入,delta 追加到 streamingText,end 用服务端给出的完整文本替换增量缓冲。它不再直接调用 repository 维持网络连接,而是启动服务,并订阅一个按 requestId 过滤的进程内事件流。

ChatStreamingService 是非导出的 dataSync 服务。它在 onStartCommand() 里先进入前台,再以 SupervisorJob() + Dispatchers.IO 启动执行器;无论执行器正常结束、报错,还是因 Stop 被取消,finally 都会调用 stopSelfResult()。onDestroy() 取消协程作用域,同时移除前台通知。

执行器复用已有的 ChatRepository 和 MiniAgentApi。底层仍是 OkHttp 的 callbackFlow:awaitClose 会取消 Call 和读取协程。这样 Stop 不需要另写一套“停止网络”的机制,停止服务导致协程取消,取消沿 Flow 向下传到 OkHttp。

为什么在服务和 ViewModel 之间放一个协调器

这里最容易写成“服务直接改 ViewModel 状态”,但这会把 Android 组件生命周期和网络执行绑死在一起。服务也不应该绑定页面:页面可以离开、重建,流却还在继续。

这次加了一个应用级的 ChatStreamCoordinator,内部是 MutableSharedFlow。ChatStreamExecutor 只把 SSE 事件转换为 ChatStreamUpdate 并发布;ChatViewModel 只订阅属于当前 requestId 的更新。两边通过小的事件 DTO 协作,而不是相互依赖。

这样分层后,每个对象的职责很窄:

  • ChatViewModel 管 UI 状态和本地会话。
  • ChatStreamingService 管 Android 前台服务生命周期和通知。
  • ChatStreamExecutor 管一次流的终止条件、文本缓冲和最终持久化。
  • ChatStreamCoordinator 只负责把执行事件转交给观察者。
  • MiniAgentApi 仍是 HTTP/SSE 边界。

服务端结束时,执行器优先使用 end.text,缺失时才退回本地累计的 delta。这是有意保留的“最终文本优先”规则:增量是展示过程,end 是服务端给出的最终结果。只有拿到这个终止事件才写入 assistant 消息;error 和 Stop 都不会留下半截的 assistant 气泡。

第二层:把一次消息变成有身份的任务

前台服务只能解决“应用还活着但退到后台”的情况。真正的容灾需要先回答一个问题:SSE 断开后,客户端重发这段文本,会不会让服务端执行两次?答案不能靠客户端猜,所以这批提交给每次发送生成稳定的 UUID request_id,并让后端按用户和请求 ID 做幂等接管。

客户端在启动流之前先把 requestId、原文、会话 ID 和创建时间写入 Room 的 pending_tasks。服务端接受 /message 后通过 X-Task-Id 返回任务 ID;SSE 帧里的 id: 或 event_id 则作为事件游标,客户端收到后立刻回写 pending 记录。于是一次任务至少有三种身份信息:客户端的 request_id 用来防重复执行,服务端的 task_id 用来取消或查询,lastEventId 用来重连时说明已经看到哪里。

这几个 ID 没有合并成一个,是因为它们解决的是不同问题。request_id 是提交幂等键,task_id 是后端资源的鉴权句柄,事件号是短期 SSE 订阅的进度位置。把三者分开,后端可以更换任务存储或接入其他客户端,APK 也不会把服务端 UUID 当成本地会话 ID。

断网和 SSE 断连:重连,但不重复执行

MiniAgentApi 将两类“传输层没走到终点”的情况统一标记为 transportError:读取过程中 OkHttp 抛异常,或者连接正常关闭却没有收到 end/业务 error。ChatStreamExecutor 不会把它们立即当成执行失败,而是保留已经累计的文本和 pending task,按 1s、3s、8s 的退避时间最多重连三次。

每次重连都提交同一个 request_id,并带上最近保存的 Last-Event-ID。后端看到相同的请求 ID 时,不会再启动第二个 executor,而是把订阅接回原任务;如果内存中的短期事件还在,就从事件游标继续推送,如果任务已经结束,则返回完整 end。因此网络断开影响的是客户端的观察连接,不是后端任务本身。

如果三次重连仍然失败,UI 会显示“连接中断,任务仍在后台执行;稍后可恢复”,pending 记录不会被删除。这个状态很重要:它告诉用户当前只是回传链路不可用,不能把一次暂时断网误报成任务执行失败,也不能因为重试而制造重复消息。

APK 被杀:Room 记录负责把任务接回来

进程被系统回收时,内存里的 ChatViewModel、SharedFlow 和 OkHttp Call 都会消失,所以恢复不能依赖它们。pending_tasks 是本地唯一需要跨进程保留的最小状态。ChatViewModel.loadHistory() 在登录后扫描未完成记录,为每条记录重新启动 ChatStreamingService,传入原来的 requestId、taskId、文本和 lastEventId。

恢复路径仍然走同一条 /message SSE 链路,而不是另做一套“恢复消息”协议。后端用 request_id 判断这是不是已经存在的执行:存在且文本一致时返回原任务的当前状态或最终结果,不再次执行;收到 end 后,执行器写入完整 assistant 文本并删除 pending 记录。这样即使进程杀死发生在执行完成之后、客户端还没来得及保存结果的窗口里,重新订阅仍能拿回同一份结果。

这里的幂等边界也很明确:同一个 request_id 只能对应原来的文本;如果客户端拿同一个 ID 换了一段文本,后端应拒绝请求,而不是把它当作新消息。客户端不会自动生成新 ID 来“碰碰运气”。

错误不能只剩一句 HTTP 403

同一天还有一处小但很实用的改动。SSE 流内的 error 本来就能带 trace_id,但网关、鉴权或配额检查可能在 SSE 建立之前直接返回非 2xx。此前这类失败容易退化成笼统的 HTTP 错误,服务端日志很难和客户端现场对起来。

现在 MiniAgentApi 会把非 401/444 的 /message HTTP 响应转换为统一的 error 事件:优先读取 JSON body 的 error 和 trace_id,再回退到 X-Trace-Id 响应头。429 还会把 Retry-After 拼进展示文本。401 和云端返回空响应的 444 仍被识别为登录失效,不去强行解析 JSON。

这不是多加一层错误包装。目标是让两条原本分叉的路径在 UI 汇合:无论错误来自一帧 SSE,还是发生在 SSE 建立前,ChatViewModel 都能进入同一份错误状态,并把 trace_id 显示给需要查服务端日志的人。

测试覆盖什么,也明确不覆盖什么

这次没有把“后台可用”只写成手工感受。

  • ChatStreamExecutorTest 验证 delta -> end 后只保存完整结果;取消和 error 不保存 assistant 消息;流在没有 end 时转成明确错误。
  • MiniAgentApiTest 用 MockWebServer 覆盖 444 登录失效、403 JSON 错误中的 trace_id,以及 429 的 Retry-After 和 X-Trace-Id 回退。
  • 前台服务路径在 2026-08-06 做过一次真机端到端验证:发送一条持续数秒的请求,应用切后台后等待,回到应用仍能看到最终回复;Stop 会终止服务和请求。

这并不等于所有 Android 生命周期场景都已经覆盖。ChatStreamExecutorTest 已经验证传输错误会用同一请求重连并最终保存结果;SseParser、MiniAgentApi 和 Room pending task 的单测也覆盖了事件号、任务 ID 和迁移路径。强杀 APK 后重开的真实设备验收仍在运行清单中,当前 instrumented SmokeTest 也只是编译过,尚未通过 connectedAndroidTest 执行。

另外,当前保证的是“最终结果可恢复”,不是“每个 delta 永不丢失”。Last-Event-ID 的回放只依赖后端进程内的短期缓冲;后端自身进程重启后的持久化事件回放、完整 delta 日志和通知栏任务操作仍未实现。

这次改动留下的原则

前台服务不是让应用“永远活着”的工具。这里它是一次用户可见、可取消的执行所对应的 Android 生命周期容器;request_id、task_id 和 Room pending task 才是让这次执行跨过网络和进程边界的依据。把恢复建立在稳定任务身份上,复用已有 SSE 协议,再只在终止事件持久化最终结果,改动才能留在薄客户端的边界内。

这也是为什么架构没有被做成一套本地任务系统:服务端依然是执行的唯一权威,Android 端只保存恢复所需的最小 pending 元数据,负责在用户发起的一次请求期间托管连接、显示过程,并在最终结果到达时写入本地会话。

Sources

  1. feat: keep active chat streams alive in foreground service — 前台服务、协调器、ViewModel 改造、Manifest 声明和执行器测试的实现提交。
  2. feat: preserve trace details for HTTP stream errors — 非 SSE HTTP 错误的 JSON/header trace_id 与 Retry-After 处理。
  3. feat: recover pending chat tasks — request_id 幂等提交、Last-Event-ID、Room pending_tasks、传输重连和进程重启恢复。
  4. docs: record background streaming rollout — 端到端验证结论、测试边界和后续事项的项目记录。
  5. docs: record resilient execution rollout — 第一阶段容灾实现的范围、验收清单和 deferred 边界。
  6. Foreground services overview — Android 对用户可感知前台服务和状态栏通知的官方说明。
  7. Foreground service types are required — dataSync 类型、相应权限与运行时要求的官方说明。

Related