Android 流式对话实现:分层门面与 SSE 手工解析

上层只管刷新哪条气泡,下层只管逐行读字节------把传输细节彻底挡在业务之外

前言

做流式问答时,最常踩的坑不是"发请求",而是"流式回答来了不知道该刷哪条气泡"。用户已经发出消息,界面立刻生成一个"正在输入"的占位气泡;大模型一个字一个字往回吐,吐到一半停住了,我们要么整个界面卡死,要么消息错位、张冠李戴。另一头的现象更反常识:接口明明是流式的,界面却是转半天圈后整段"哐"一下冒出来。

这其实是两处不同层面的问题。往上看,是业务调度层缺少"流分片该写进哪个气泡"的映射;往下看,是网络层的字节流被客户端的读取超时或日志拦截器"截胡"了。很多人把它们混在一起排查,越查越乱------一会儿怀疑服务器没流式返回,一会儿又去改界面刷新逻辑,其实都没打在点上。

这篇我把这两层拆开讲:上面用分层门面把传输细节彻底挡在业务之外,下面再把门面挡住的那层------SSE 传输------拎出来讲透。 你看完会明白:流式聊天最难的不是流本身,而是"流的每一段该写进哪个气泡上",以及"怎么保证那段流别在客户端被偷偷掐掉"。

一、现象:流式对话最常见的四种"翻车"

流式接口的翻车现场大致分两类:"碎片找不到家"和"流退化了"。

1.1 碎片找不到家:多气泡互相串台

传统一问一答的接口是同步的:请求发出去,等一个完整的 JSON 回来,一次性渲染。可大模型聊天是流式的,服务器会像打字机一样,分多次把同一段回答的碎片推过来。

于是出现一个尴尬的局面:界面上有好几个正在回复的气泡(用户可能连发两条、或者打断重问),服务器推回来的碎片到底属于哪一条?

复制代码
用户:帮我查一下设备状态        → 界面生成气泡 A(loading)
用户:算了,换个角度再问一次    → 界面生成气泡 B(loading)
服务器:流式碎片1(属于 A?)  
服务器:流式碎片2(属于 B?)

如果只靠"按顺序对应",两次请求几乎同时发出、返回交错时,气泡 A 和 B 的内容就会互相串台。这是流式对话最反直觉、也最容易做错的地方。

1.2 流退化了:要么被缓存成整段,要么被读到一半掐断

另一类现象更隐蔽:功能"看起来能用",只是没有流式效果,或者偶尔残缺。典型表现是:

复制代码
现象A:整段一次性冒出(无流式)→ 客户端把流缓存了
现象B:读到一半断流         → 客户端把流超时了

代码明明用的是流式接口,可表现却是:转圈转半天,然后"哐"一下整段回答同时冒出来------没有打字机效果。或者更糟:读到一半连接被中断,回答残缺。

排查方向往往被带偏到"是不是服务器没流式返回"。但多数时候,问题出在客户端:两个现象,一个指向"日志拦截器",一个指向"读取超时",都不是协议问题。

二、根因:两处"信息断点"

把两类现象分开看,根因就清楚多了:调度层少了一张表,网络层多了一次不该有的读取。

2.1 调度层:缺少"流分片 ↔ 界面气泡"的映射

问题不在网络层,而在调度层缺少一张映射表。服务器其实给每个会话/每个回复都打了唯一标识(taskId),而界面上每个气泡也有自己的消息 id(msgId)。两头都有"身份证",但没人把这两个 id 对应起来,碎片到了就不知道往哪塞。

复制代码
服务器侧:taskId = t1  → 这一段回复属于哪个会话
界面侧:  msgId  = m1  → 这一条气泡是哪个
缺失:t1 ↔ m1 的对应关系

另一个附带问题:回复是流式增量的,界面不能等全部收完再显示,必须把累积的文本边收边刷新。这意味着状态必须是"可累积的",而不是"一锤子买卖"。

2.2 网络层:字节流被"截胡"或"掐断"

2.2.1 为什么日志拦截器会毁掉流式

在 HTTP 客户端里加一个"打印请求体/响应体"的日志拦截器很常见。但流式响应下,日志拦截器要打印完整响应体,就得先把整个响应读进内存。这一"读",就把流给吞了------等它打印完,原本逐帧到达的流已经全部读完,后面真正消费流的地方拿到的是一整块内存数据,流式效果荡然无存。

复制代码
正常:  逐行读 → 逐行吐(打字机)
加日志:整段读进内存 → 打印 → 一次性吐(退化成非流式)
2.2.2 为什么读取超时等于"定时炸弹"

普通接口设个 10 秒读取超时很合理。但流式对话是长时间挂着的连接,两段事件之间可能间隔很久(大模型在思考、在检索知识库)。一旦设置了读取超时,等待下一帧时超时器到期,连接被强制关闭------回答就被"掐"在中间。

这两处根因,正对应标题里的两件事:门面把映射理清楚,SSE 解析把字节流稳定读出来。下面先讲门面。

三、上半场:分层门面,把传输细节挡在业务之外

3.1 四层门面,各管一段

把整个对话能力拆成四层,越往上越接近用户,越往下越接近网络:

  • 界面层:只展示气泡列表,监听事件流,不做任何网络逻辑;
  • 调度层(视图模型):编排业务,维护 taskId↔msgId 映射,决定"这个事件该刷新哪个气泡";
  • 数据层(仓库):负责发起请求、解析流,把原始字节转成结构化事件;
  • 网络层:封装 HTTP 客户端与接口定义。

界面层永远只看到两类东西:不可变的事件流 和方法调用。它不知道也不关心大模型是谁、走什么协议。

3.2 不可变事件流:让界面只订阅、不操办

界面需要的不是"命令",而是"通知"。所以调度层对外只暴露只读的 asSharedFlow(),内部才持有可写的 MutableSharedFlow:

kotlin 复制代码
// 对外只读,界面只能订阅
val chatUIEvent = _chatUIEvent.asSharedFlow()

// 内部可写,业务逻辑往里推事件
private val _chatUIEvent = MutableSharedFlow<ChatUiEvent>(
    extraBufferCapacity = 1,                 // 事件可能来不及消费,留一点缓冲
    onBufferOverflow = BufferOverflow.DROP_OLDEST  // 聊天气泡刷新允许丢最旧的
)

事件本身用密封结构描述,"开始流式回复 / 流式过程中 / 流式结束 / 出错"各是一种事件,携带这次回复属于哪个气泡 的 id。界面只需 when 分派。

3.3 那张关键映射表:taskId ↔ msgId

这是整个方案的精髓。用户一发消息,界面立刻生成占位气泡并拿到 msgId;随后请求发出时,把这个 msgId 作为自定义输入一起带给服务器。服务器每次流式推送都会回带 taskId,调度层在"工作流开始"事件里抓到这个 taskId,把 taskId ↔ msgId 写进一张内存表:

kotlin 复制代码
// 收到"工作流开始",建立映射
difyTaskIdToMsgIdMap[taskId] = respMsgId

// 后续每个流式碎片/结束/错误事件,都靠 taskId 反查该刷哪个气泡
when (response.event) {
    Message -> {
        answerBuilder.append(response.answer)   // 累积文本
        _chatUIEvent.tryEmit(Streaming(
            text = answerBuilder.toString(),      // 推累积后的整段,界面直接覆盖
            responseMsgId = difyTaskIdToMsgIdMap[response.taskId]!!
        ))
    }
    MessageEnd -> {
        // 流式结束,附带引用文档等信息
        _chatUIEvent.tryEmit(StreamingEnd(
            refDocs = ...,
            responseMsgId = difyTaskIdToMsgIdMap[response.taskId]!!
        ))
    }
    Error -> {
        _chatUIEvent.tryEmit(Error(..., difyTaskIdToMsgIdMap[response.taskId] ?: ""))
    }
}

注意一个细节:推给界面的是"累积后的完整文本"而不是"增量片段"。界面拿到的永远是"到目前为止的完整回答",直接覆盖气泡即可。这避免了界面自己拼字符串、拼错还要纠正的麻烦。

3.4 停止响应也能精准定位

打断(停止生成)同样依赖映射表。用户点"停止",界面传 msgId,调度层反查 taskId,再调停止接口:

kotlin 复制代码
// 用 msgId 反查 taskId,因为停止接口是按 taskId 调的
difyTaskIdToMsgIdMap.forEach { (taskId, msgId) ->
    if (msgId == responseMessageId) {
        stopChatMessage(taskId, ...)
    }
}

四、下半场:被门面挡住的 SSE,长什么样

门面把传输细节挡在了门外,但"挡在门外"不等于"不用懂",真出问题时还是得掀开门帘看里面。

SSE(Server-Sent Events)是 HTML 标准里定义的服务器推送机制,MIME 类型是 text/event-stream:服务器把事件一行一行发过来,客户端逐行读、逐行解析。真正让人翻车的往往不是协议本身,而是 2.2 里那两个隐藏的坑。

4.1 SSE 分帧规则:不是"读到一行就是一条事件"

按 HTML 标准(Server-sent events 一节)的规定,分帧规则可以拆成这几条:

  1. 整体按 UTF-8 解码,流开头的单个 BOM(U+FEFF)会被忽略;
  2. 按行解析 ,行结束符可以是 CRLF、单独的 LF 或单独的 CR;
  3. 以冒号 : 开头的行是注释 ,直接忽略------服务器常用它发心跳保活(例如 : keep-alive);
  4. 字段行形如 field: value:冒号后如果紧跟一个空格,这个空格会被去掉;如果一行里没有冒号,则整行当作字段名、值为空;
  5. 空行 = 事件边界:遇到空行(或流结束)时"派发"事件。若 data 缓冲为空则不派发;否则去掉末尾换行后作为一条消息。

标准字段有四个:data:(数据)、event:(事件类型,缺省为 message)、id:(最后事件 ID)、retry:(重连间隔毫秒数)。要注意第 5 条的反面:一条事件可由多个 data: 行组成 ,规范要求用 \n 拼接后再处理;只是多数大模型服务每个事件只发一行 data,才让"逐行取 data"成了够用的简化做法。

4.2 客户端能拿到的"原始字节源"是什么

在 OkHttp 里,Response.body 是 ResponseBody,它的 source() 返回一个 BufferedSource(Okio 的缓冲字节源),也就是我们真正要逐行读的东西。两个必知事实:

  • ResponseBody 是一次性 的:source()、string()、bytes() 都会消费它;string()/bytes() 还会把整个响应读进内存并关闭它------这是"退化成非流式"的另一条路径,流式场景必须走 source()。
  • readUtf8Line() 读到换行或 EOF(EOF 时返回 null),是阻塞 调用,会一直等到有数据或流结束------这正是它必须放在 IO 线程、且要能用 call.cancel() 打断的原因。

五、合体:逐行解析 SSE 的完整实现

5.1 客户端配置

kotlin 复制代码
val client = OkHttpClient.Builder()
    .readTimeout(0, TimeUnit.SECONDS) // 流式必须:读取永不超时
    .connectTimeout(30, TimeUnit.SECONDS) // 建连超时仍要
    .addInterceptor { chain ->
        // 业务头注入在这里做,但绝不打印响应体
        val builder = chain.request().newBuilder()
        builder.addHeader("Authorization", "Bearer $apiKey")
        chain.proceed(builder.build())
    }
    // 千万别加 BODY 级别的日志拦截器,会把流缓存成整段
    .build()
  • readTimeout(0):关闭读取超时,流式连接想挂多久挂多久;
  • 日志拦截器只打请求、不碰响应体:如果要调试,在消费流的地方逐条打印解析结果,而不是拦截器里打整段;
  • 建连超时仍然要设 :connectTimeout 只在建连阶段生效,与流式长连接不冲突。

5.2 逐行解析:核心就一个 while 循环

回到分帧规则:我们关心的只是 data: 行,取出来就是 JSON。

kotlin 复制代码
fun chatStream(request: Request): Flow<Chunk> = flow {
    val response = client.newCall(request).execute()
    if (!response.isSuccessful) {
        throw HttpException(response)
    }
    val body = response.body ?: throw IllegalStateException("Empty body")

    // 关键:拿到原始字节源,逐行读,边读边 emit
    body.source().use { source ->
        while (!source.exhausted()) {
            val line = source.readUtf8Line() ?: continue
            if (!line.startsWith("data:")) continue   // 只关心 data 行
            val json = line.removePrefix("data:").trim()

            if (json == "[DONE]") break                // 结束标记
            if (json == "event: ping") continue        // 心跳事件,跳过

            emit(gson.fromJson(json, Chunk::class.java))
        }
    }
}.flowOn(Dispatchers.IO)

几个要点:

  1. readUtf8Line() 是阻塞的 ,所以整体必须跑在 IO 线程(flowOn(Dispatchers.IO)),否则会卡主线程;
  2. 只看 data: 前缀 ,event:/id: 行直接忽略------我们的业务只关心数据;
  3. [DONE] 是结束哨兵,遇到就跳出循环;
  4. 心跳事件单独跳过,避免误当成业务数据去反序列化报错;
  5. 每个 data: 帧就是一个独立 JSON,emit 出去就是"流式的一次推进"。

要诚实说明的是:[DONE]、event: ping 这类标记不是 SSE 规范的一部分,而是各家服务端自己的约定,写法并不统一;接入前应以服务端文档为准核对结束标记与心跳形式。

5.3 线程分离:流在 IO 线程读,界面在哪刷新?

解析循环在 IO 线程逐个 emit。这些"流式推进"必须最终回到主线程刷新 UI。用协程流的话,就是在消费端用 withContext(Dispatchers.Main) 切回主线程后再更新界面;或者让调度层在订阅时把线程切好。核心是:读流在 IO,渲染在主线程,二者不能混。

5.4 一个容易被漏掉的边界:取消不会自动唤醒阻塞读

emit 挂起时会响应协程取消,但若协程正卡在 readUtf8Line() 上,阻塞读并不知道自己被取消了。要做到"点停止就立刻断开",可靠做法是持有这次调用的 Call,取消时主动 call.cancel() 关掉底层连接,让阻塞读抛错返回------只依赖协程取消不够。TCP 粘包/拆包则交给 BufferedSource 处理,应用层不必自己切行。

一个可选的工程化增强:把"按行切分"抽成可复用的 SSE 解析器,输入字节流、输出事件行序列,上层只管 when 分派。

六、升华:这是一种"边界设计"的通用思路

这套方案不止于"跑通一个聊天框",它把两类边界都画清楚了:门面把业务与传输的边界 画在数据层,SSE 解析把协议与应用的边界画在解析器。

先说门面那半边。双标识 + 映射表的思路不限于大模型聊天,任何"异步多实例 + 回执需要归位"的场景都适用:

  • 多设备并发指令:同一界面同时给多台设备发指令,回执靠设备 id 归位;
  • 批量任务进度:多个上传/下载任务并行,进度回调靠任务 id 找对应条目;
  • 多通道推送:同一消息从不同通道回来,靠通道标识去重、聚合。

核心要点就一个:上游给每个实例打唯一标识,下游用标识反查归属,而不是依赖"先来后到"的顺序假设。顺序会交错,id 不会。

另外,"对外只读流、对内可写流"的封装,让界面代码极干净------它只订阅、只渲染,永不直接触碰网络。想换一个问答服务商,只动数据层和网络层,界面一行不改。

再说解析那半边。很多人把"流式问答"和大模型强绑定,其实 SSE 就是个通用的服务器推送通道,跟具体内容无关:

  • LLM 流式补全(大模型逐字生成);
  • 实时日志推送(服务端把日志一行行推给前端);
  • 知识库检索过程推送(把"开始检索 → 检索到文档 → 生成回答"每个阶段推出来)。

只要掌握了"逐行读 → 按前缀分拣 → 遇到哨兵收尾"这个套路,任何 SSE 服务都能吃下来。而且手工解析比引库更可控:你能精确处理心跳、哨兵、脏数据,还能按业务定制。

结论

  • 流式对话的真正难点是碎片归位 ,不是流本身;用一张 taskId ↔ msgId 映射表解决"该刷哪条气泡"。
  • 用 MVVM 分四层,界面只消费不可变事件流,不碰网络,天然解耦、可替换;读流放 IO 线程、渲染回主线程,两者不混。
  • 推给界面的用累积后的完整文本,界面只覆盖不拼接,避免自维护增量状态出错。
  • 打断/停止操作同样走映射表反查,保证精确定位到目标实例。
  • 这类"上游打 id、下游按 id 归位"的思路,可迁移到多设备、批量任务、多通道等异步场景。
  • SSE 就是"逐行文本协议",一个 readUtf8Line() 循环即可解析,不必引第三方库;只取 data: 帧、跳过心跳、遇到 [DONE] 收尾。
  • 流式客户端必须 readTimeout(0) 关闭读取超时 ,否则两帧间隔稍长就被掐断;同时严禁 BODY 级日志拦截器,它会先把整段流缓存进内存再输出,流式退化成一次性。
  • 按 SSE 规范,空行才是事件边界、多行 data 需拼接、: 开头是注释;而 [DONE] 之类的哨兵属服务端自定义,接入前要核对文档。

你们做流式对话时,是怎么处理多气泡归位和断流重连的?欢迎在评论区分享自己的做法。

关键词标签 :#Android #流式对话 #SSE #分层架构 #MVVM #事件流 #OkHttp #taskId

相关推荐
墨狂之逸才1 小时前
Android扫码异常排查小白教程
android
恋猫de小郭2 小时前
Kotlin 2.5 的 Companion 改造,对 KMP 的重要升级
android·前端·flutter
lpfasd1232 小时前
2026年第41周GitHub趋势周报:Agent进入技能化与记忆工程阶段
github
怕浪猫2 小时前
给AI配一台电脑,开源版Muse来了,OpenMuse赶紧去二次开发吧
javascript·面试·github
Kapaseker3 小时前
写了这么久 Kotlin,你真的理解 in、out 和 * 吗?
android·kotlin
小七在进步3 小时前
C++内存管理:new/delete vs malloc/free深度解析
android·java·c++
miofly3 小时前
GitHub 日榜趋势速报 | 2026-10-14
开源·github
RuoyiOffice3 小时前
Springboot + Vue3 + FormCreate表单引擎,直接用AI对话进行单据设计,告别拖拽画表单
spring boot·低代码·vue3·sse·formcreate·ruoyi office·ai表单