上层只管刷新哪条气泡,下层只管逐行读字节------把传输细节彻底挡在业务之外
前言
做流式问答时,最常踩的坑不是"发请求",而是"流式回答来了不知道该刷哪条气泡"。用户已经发出消息,界面立刻生成一个"正在输入"的占位气泡;大模型一个字一个字往回吐,吐到一半停住了,我们要么整个界面卡死,要么消息错位、张冠李戴。另一头的现象更反常识:接口明明是流式的,界面却是转半天圈后整段"哐"一下冒出来。
这其实是两处不同层面的问题。往上看,是业务调度层缺少"流分片该写进哪个气泡"的映射;往下看,是网络层的字节流被客户端的读取超时或日志拦截器"截胡"了。很多人把它们混在一起排查,越查越乱------一会儿怀疑服务器没流式返回,一会儿又去改界面刷新逻辑,其实都没打在点上。
这篇我把这两层拆开讲:上面用分层门面把传输细节彻底挡在业务之外,下面再把门面挡住的那层------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 一节)的规定,分帧规则可以拆成这几条:
- 整体按 UTF-8 解码,流开头的单个 BOM(U+FEFF)会被忽略;
- 按行解析 ,行结束符可以是
CRLF、单独的LF或单独的CR; - 以冒号
:开头的行是注释 ,直接忽略------服务器常用它发心跳保活(例如: keep-alive); - 字段行形如
field: value:冒号后如果紧跟一个空格,这个空格会被去掉;如果一行里没有冒号,则整行当作字段名、值为空; - 空行 = 事件边界:遇到空行(或流结束)时"派发"事件。若 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)
几个要点:
readUtf8Line()是阻塞的 ,所以整体必须跑在 IO 线程(flowOn(Dispatchers.IO)),否则会卡主线程;- 只看
data:前缀 ,event:/id:行直接忽略------我们的业务只关心数据; [DONE]是结束哨兵,遇到就跳出循环;- 心跳事件单独跳过,避免误当成业务数据去反序列化报错;
- 每个
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