1. 为什么流式响应在 Android 上是个"看起来简单、做起来全是坑"的活
如果你做过 Android 上的 AI 对话功能,大概率经历过这样的场景:接口在 Postman 里跑得好好的,日志一行行往外吐,但一搬到手机上,要么半天不出一句话,要么吐到一半突然卡死,要么用户切个后台回来整个对话就断了。这不是你的代码写得差,而是流式响应这套机制在移动端本身就比在服务端复杂得多——网络环境会变、系统会杀后台、UI 线程不能阻塞、用户还会随时点返回键。
先把概念理清楚。所谓流式响应,指的是服务端不把整段回答一次性返回,而是把内容切成一个个小块(chunk),边生成边推送。用户看到的效果就是文字像打字一样一个个蹦出来,而不是等三五秒后"啪"地出现一整段。这种体验对 AI 对话几乎是刚需,因为大模型的生成本身就是逐 token 进行的,如果强行等全部生成完再返回,用户等待时间会非常难熬。
而实现流式响应最常用的协议就是SSE(Server-Sent Events)。它的本质其实特别朴素:一个长连接的 HTTP 请求,服务端保持连接不关闭,持续往响应体里写数据,每条消息以data:开头,以两个换行\n\n结尾。浏览器端有原生的EventSource可以直接用,但 Android 上没有这个东西,你得自己用 OkHttp 之类的客户端去读这个流,然后手动解析。
这就引出了本文要讲的三件事:怎么在 Android 上把 SSE 流读下来并正确解析、流式链路里哪些地方最容易断、以及断了之后怎么优雅降级。我把它总结成"三级降级"的思路,后面会详细展开。这套东西适合已经能跑通普通 HTTP 请求、但对流式处理还没什么经验的 Android 开发者,也适合正在做 AI 对话类 App、被各种超时和断流折磨过的同学。
需要提前说明的是,本文涉及的具体参数(比如超时时间、缓冲区大小)都是基于常见实践给出的参考值,你需要根据自己的服务端行为和网络环境做调整。核心是理解每个参数背后的意图,而不是照抄数字。
2. SSE 在 Android 上的真实工作方式:从 OkHttp 到逐行解析
2.1 为什么不能用普通的 Retrofit 接口来接收
很多人第一反应是用 Retrofit 定义一个返回String或某个数据类的接口,然后enqueue一个 Call。这条路走不通,原因在于 Retrofit 默认会把整个响应体读完才回调给你,而 SSE 的响应体是永不结束的(直到服务端主动关闭)。你等它读完,就等于永远等不到。
正确的做法是拿到原始的ResponseBody,把它当成一个输入流来读。用 OkHttp 的话,大致是这样:
val request = Request.Builder() .url(chatUrl) .header("Accept", "text/event-stream") .header("Content-Type", "application/json") .post(jsonBody.toRequestBody("application/json".toMediaType())) .build() val client = OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) // 关键:读超时设为 0,表示不超时 .connectTimeout(15, TimeUnit.SECONDS) .build() client.newCall(request).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { val source = response.body?.source() ?: return while (!source.exhausted()) { val line = source.readUtf8Line() ?: break // 处理这一行 } } override fun onFailure(call: Call, e: IOException) { /* 处理失败 */ } })这里有个极其关键的坑:readTimeout必须设为 0 或者一个很大的值。OkHttp 默认的读超时是 10 秒,意思是"如果 10 秒内没有读到任何新数据就断开"。但 AI 生成有时候会思考很久才吐第一个字,或者两个 chunk 之间间隔超过 10 秒,这时候连接就会被 OkHttp 自己掐断,你会收到一个SocketTimeoutException,日志里看起来就像"流莫名其妙断了"。我见过太多人卡在这里,以为是服务端的问题,其实是客户端超时配置没改。
2.2 SSE 的报文格式与逐行解析逻辑
SSE 的报文格式看起来简单,但细节不少。一条完整的消息长这样:
data: {"choices":[{"delta":{"content":"你"}}]} data: {"choices":[{"delta":{"content":"好"}}]} data: [DONE]注意几个点:每条data:后面跟的是内容,消息之间用空行分隔,流结束时会发一个特殊的结束标记(OpenAI 风格是data: [DONE])。有些服务端还会发event:、id:、retry:这些字段,但 AI 对话场景里通常只用data:。
解析的时候不能假设"一次 read 就是一条完整消息"。TCP 是字节流,一次读取可能拿到半条消息,也可能拿到一条半。所以正确姿势是按行读取,遇到空行就认为一条消息结束:
val dataBuffer = StringBuilder() while (!source.exhausted()) { val line = source.readUtf8Line() ?: break when { line.isEmpty() -> { // 空行 = 一条消息结束 if (dataBuffer.isNotEmpty()) { handleMessage(dataBuffer.toString()) dataBuffer.clear() } } line.startsWith("data:") -> { dataBuffer.append(line.removePrefix("data:").trim()) } // 其他字段(event/id/retry)按需处理 } }readUtf8Line()这个方法是 OkHttp 提供的,它会一直读到换行符为止,帮你处理了跨 buffer 的半行问题。如果你自己用InputStream.read()拼字符串,很容易在 UTF-8 多字节字符被切断的地方出现乱码——中文尤其明显,一个汉字三个字节,正好卡在边界上就会变成问号。这是第二个高频坑。
2.3 把解析结果安全地送到 UI 层
解析出来的内容不能直接在网络线程里更新 UI。常规做法是用Flow或者LiveData把数据往上层抛。用 Kotlin 协程的话,可以包一个callbackFlow:
fun streamChat(prompt: String): Flow<String> = callbackFlow { val call = client.newCall(buildRequest(prompt)) call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { val source = response.body?.source() ?: run { close(IOException("empty body")); return } try { val buffer = StringBuilder() while (!source.exhausted()) { val line = source.readUtf8Line() ?: break if (line.isEmpty()) { if (buffer.isNotEmpty()) { trySend(buffer.toString()) buffer.clear() } } else if (line.startsWith("data:")) { val payload = line.removePrefix("data:").trim() if (payload == "[DONE]") { close(); return } buffer.append(payload) } } close() } catch (e: Exception) { close(e) } } override fun onFailure(call: Call, e: IOException) { close(e) } }) awaitClose { call.cancel() } }awaitClose里取消 call 很重要。用户如果中途退出页面,Flow 被取消,这个 call 必须跟着取消,否则连接会一直挂着,既浪费流量也占着线程。我踩过一次坑:没写awaitClose,结果用户快速进出对话页十几次,后台堆了十几个没关闭的连接,最后触发了系统的文件描述符上限,整个 App 的网络请求全挂了。这个教训很深刻。
3. 流式链路为什么会断:把"断流"这件事拆开看
3.1 移动网络下的三类典型断流
流式响应断掉,原因五花八门,但归纳起来主要是三类。
第一类是客户端主动超时。前面说的 OkHttpreadTimeout就是典型。还有一种是应用层自己设的"整体超时",比如你规定 30 秒内必须收到响应,超时就 cancel,但 AI 生成长回答可能超过 30 秒,于是被你自己掐了。
第二类是网络层中断。手机从 WiFi 切到 4G、进电梯信号丢失、地铁里频繁切换基站,都会导致 TCP 连接断开。这种情况下你会收到IOException,日志里常见的是unexpected end of stream或者stream disconnected before completion。这类断流客户端无能为力,只能靠重连或降级。
第三类是服务端或中间层主动关闭。比如网关有 idle timeout(空闲超时),如果两个 chunk 之间间隔太久,网关认为这个连接没用了就关掉。你可能会看到idle timeout waiting for sse这样的报错。这种情况可以通过让服务端定期发送心跳(比如每 15 秒发一个: keep-alive注释行)来缓解,但客户端也要做好应对准备。
3.2 一个容易被忽略的细节:切后台与进程优先级
Android 系统对后台进程的管理很激进。用户把 App 切到后台,如果内存紧张,系统可能直接杀掉进程,你的流式连接自然就断了。就算没被杀,Doze 模式也会限制网络访问。
这里有个实操经验:不要指望流式连接能在后台稳定存活。正确的产品逻辑应该是——用户切后台时,要么暂停接收(保留已收到的内容),要么把这次请求标记为"可能中断",等回到前台时检查状态。我见过有团队试图用前台 Service 硬扛,结果被应用商店审核质疑"为什么聊天要常驻前台服务",得不偿失。
如果确实需要在切后台后继续接收(比如用户希望回答生成完能收到通知),更稳妥的做法是让服务端把结果落库,客户端回到前台后拉取完整结果,而不是死磕那条流式连接。
3.3 用日志把断流现场还原出来
排查断流问题,日志是第一手证据。建议在几个关键节点打点:请求发出时、收到第一个 chunk 时、每收到 N 个 chunk 时、连接关闭时、发生异常时。异常里要打印e.message和e.cause,因为 OkHttp 经常把真正的错误包在 cause 里。
一个典型的排查链路是这样的:用户反馈"回答到一半停了"→ 查日志发现最后一条是SocketTimeoutException: timeout→ 看时间戳,距离上一个 chunk 正好 10 秒 → 确认是readTimeout没改 → 改成 0 后问题消失。整个过程如果没日志,你只能靠猜。
提示:线上环境建议把流式相关的日志做采样上报,不要全量打,否则日志量会爆炸。但异常日志要全量收集,这是定位问题的命脉。
4. 三级降级:让对话在断流时依然"能用"
4.1 降级思路的整体设计
所谓"三级降级",核心思想是:当理想方案(流式)不可用时,逐级退到次优方案,保证功能始终可用,而不是直接报错。这三级的划分是这样的:
| 级别 | 方案 | 触发条件 | 用户体验 |
|---|---|---|---|
| 一级 | 正常流式 | 连接正常 | 逐字输出,最佳体验 |
| 二级 | 流式重连 + 断点续传 | 中途断流 | 短暂停顿后继续输出 |
| 三级 | 非流式一次性请求 | 流式多次失败 | 等待后整段出现 |
这个设计的关键在于每一级都要有明确的触发条件和退出机制,不能无限重试。我的经验是:一级到二级允许重试 1 次,二级到三级直接切换,三级失败才向用户报错。重试次数太多会让用户等得心焦,还不如早点给个完整结果。
4.2 二级降级:断点续传怎么实现
断点续传的前提是服务端支持。常见做法是客户端记录已经收到的内容长度或最后一个 chunk 的序号,重连时把这个信息带上,服务端从断点继续生成。如果服务端不支持,那就只能退而求其次——重连后重新请求,但把已收到的内容先展示出来,新内容追加在后面(可能重复,需要去重)。
实现上,重连逻辑要包一层:
suspend fun streamWithRetry(prompt: String, maxRetry: Int = 1): Flow<String> = flow { var attempt = 0 var received = StringBuilder() while (attempt <= maxRetry) { try { streamChat(prompt, received.length).collect { chunk -> received.append(chunk) emit(chunk) } return@flow // 正常结束 } catch (e: Exception) { attempt++ if (attempt > maxRetry) throw e delay(500) // 稍等再重连,避免立刻又撞上同一个问题 } } }这里的delay(500)不是随便写的。断流后立刻重连,很可能撞上服务端还没释放资源、或者网络还没恢复的情况,稍等一下成功率更高。但也不能等太久,否则用户会觉得卡住了。
4.3 三级降级:非流式兜底的价值
非流式请求就是最普通的 POST,等全部生成完一次性返回。它的缺点是慢,但优点是极其稳定——没有长连接、没有超时困扰、没有解析复杂度。当流式反复失败时,切到非流式往往能救回来。
这里有个产品层面的取舍:非流式请求的等待时间可能长达十几秒,用户会以为 App 卡死了。所以切到三级降级时,一定要给明确的加载提示,比如"正在生成完整回答,请稍候",而不是让界面一片空白。我见过有 App 在降级时什么都不显示,用户以为死机了直接卸载,非常可惜。
另外,三级降级的超时时间要设得比流式长很多,比如 60 秒甚至 120 秒,因为大模型生成完整回答本来就慢。但也不能无限等,超过阈值还是要报错,让用户重试。
5. 那些文档里不会写的实操细节
5.1 缓冲区与背压:别让 UI 被 chunk 淹没
流式响应来得快的时候,一秒可能来几十个 chunk。如果每个 chunk 都触发一次 UI 更新,主线程会被频繁的 recomposition 或 notifyItemChanged 拖垮,界面反而卡顿。解决办法是做批量合并:攒够一定数量或者间隔一定时间(比如 50ms)再统一刷新一次 UI。
// 用 Flow 的 buffer + sample 做节流 streamChat(prompt) .buffer(Channel.UNLIMITED) .sample(50) // 每 50ms 取最新值 .collect { updateUI(it) }sample会丢弃中间值只保留最新的,对于"文字追加"这种场景刚好合适——反正最终显示的是完整文本,中间过程丢几帧无所谓。但要注意,如果服务端返回的是结构化数据(比如带工具调用),就不能随便丢,得用buffer保证不丢数据。
5.2 中文乱码与 UTF-8 边界问题
前面提过,中文在流式解析里特别容易出乱码。根本原因是 UTF-8 是变长编码,一个汉字占 3 字节,如果一次读取正好把某个汉字切成两半,直接按字节转字符串就会得到乱码。readUtf8Line()内部处理了这个问题,但如果你自己用InputStream读,一定要用InputStreamReader指定 UTF-8,或者干脆用 OkHttp 的source。
还有一个隐蔽的坑:有些服务端在 chunk 边界处会插入换行,导致你以为一条消息结束了,其实只是内容里本来就有换行。所以解析时要以"空行"为消息分隔符,而不是"任意换行"。这个区别在返回 Markdown 格式内容时尤其重要,因为 Markdown 里换行很常见。
5.3 用户主动取消时的资源清理
用户点"停止生成"或者退出页面,必须把连接干净地关掉。除了前面说的awaitClose里 cancel call,还要注意协程作用域的取消。如果用的是viewModelScope,页面销毁时协程自动取消,但 OkHttp 的 call 不会自动取消,必须手动处理。
我建议把"取消"做成一个显式的操作,而不是依赖生命周期。因为用户可能只是想停止当前这次生成,但页面还在,这时候如果依赖onDestroy才取消,连接会一直挂着。显式取消的代码大概是这样:
private var currentCall: Call? = null fun stopGenerating() { currentCall?.cancel() currentCall = null }5.4 心跳与保活:让连接别被中间层掐掉
如果你的服务端前面有网关或负载均衡,它们通常有 idle timeout。客户端能做的是配合服务端发心跳。SSE 里心跳通常是一个以冒号开头的注释行,比如: ping,客户端解析时直接忽略即可。但要注意,心跳行也会触发readUtf8Line()返回,所以你的解析逻辑要能识别并跳过它,别把它当成数据。
if (line.startsWith(":")) { // 心跳或注释,忽略 return@loop }如果服务端不发心跳,你也可以在客户端设一个"静默计时器",超过一定时间没收到任何数据就主动重连。这个阈值建议设得比网关的 idle timeout 小一点,比如网关是 60 秒,你就设 45 秒。
6. 从一次真实断流事故看排查与修复的完整链路
说个我亲身经历的事故。某次上线后,用户反馈"回答经常只出一半就停了",而且集中在特定运营商网络下。我们按下面的顺序排查:
第一步,看客户端日志。发现断流时抛的是IOException: unexpected end of stream,不是超时。这说明不是readTimeout的问题,而是连接被对端关了。
第二步,看断流的时间点。统计发现大部分断流发生在请求发出后 30 秒左右。这个数字很可疑,像是某个中间层的超时。
第三步,抓包对比。在 WiFi 下复现不了,在特定运营商 4G 下能复现。抓包发现,运营商侧有个透明代理,对超过 30 秒没有"完整响应"的连接会强制关闭。
第四步,验证方案。让服务端每 15 秒发一次心跳,客户端忽略心跳行。改完后,同样网络下断流率从 30% 降到 2% 以下。
第五步,加兜底。即使有心跳,还是会有零星断流,于是把二级降级(重连)和三级降级(非流式)都补上,确保极端情况下功能可用。
这个事故给我的最大启发是:流式链路的稳定性是客户端、服务端、中间网络三方共同决定的,任何一方的问题都会表现为"客户端断流"。所以排查时不能只盯着客户端代码,要学会从时间规律、网络环境、对端行为多个角度去推断。
7. 关于参数配置和工程化的一些个人经验
最后聊几个工程化层面的东西,这些是我做了几个 AI 对话项目后攒下来的。
超时参数要分层设置。连接超时(connectTimeout)可以短一点,15 秒足够;读超时(readTimeout)对流式请求必须设 0 或很大;整体超时(用协程的withTimeout)要单独控制,比如 120 秒。这三者职责不同,别混在一起。
重试要有退避。断流后立刻重试容易连续失败,建议第一次等 500ms,第二次等 1s,最多重试 1-2 次。重试次数和间隔最好做成可配置的,方便线上调整。
降级要可观测。每次触发降级都上报一个埋点,统计各级降级的触发频率。如果三级降级频繁触发,说明流式链路有系统性问题,需要重点优化。这个数据比任何主观感受都可靠。
测试要覆盖弱网。用 Android Studio 的网络限速功能,或者真机在电梯、地铁里测。很多断流问题只有在真实弱网下才暴露,模拟器上跑得再顺也没用。
别忽略服务端配合。客户端能做的降级是有限的,真正稳定的流式体验需要服务端支持心跳、断点续传、合理的超时配置。如果服务端不配合,客户端再怎么优化也是治标不治本。推动服务端一起改,比在客户端堆补丁有效得多。
这套流式响应加三级降级的方案,我在两个项目里落地过,整体断流导致的用户投诉下降了八成以上。核心不是某个技巧多高明,而是把"会断"当成常态来设计,而不是假设它永远不断。想清楚每一级降级的触发条件和用户体验,剩下的就是把这些逻辑老老实实写进代码里。