流式对话真正的难点,是数据从不在一次请求里到齐。
做一个会"聊天"的 Android 端,最容易让人低估的不是模型有多聪明,而是"一次对话请求"在工程上到底要拆成多少环节。你以为难点是调通大模型接口?其实接口本身十分钟就能接好。真正咬人的地方是:用户输入的一句话,要先被"听懂"------判断他到底想让设备干什么;听懂之后,答案又不会一次性砸回来,而是像打字机一样一个字一个字往外吐;你要在它吐到一半、断流、或者用户突然打断的时候,依然能把每段碎片准确地贴回对应的那一条气泡。
这三件事,任何一件单独看都不算难。但把它们串在一条链路上,就会发现一个贯穿始终的反常识事实:链路里没有任何一层能假设"数据一次到齐"。你说的那句话,可能本地规则吃不准(半个意图);流式回来的回答,可能这次只到了一半(半个会话状态);哪怕是最底层的网络字节,一个 JSON 也可能被 TCP 分块截成两半(半个 JSON)。
本文要讲的,就是把"理解用户说什么"到"一个字一个字吐出来"这条完整链路拆开看。它其实由三段组成,顺序固定、各管一段:
- 降级链(前置环节):用户那句话先过本地硬匹配、再走正则、最后才交给大模型------这是成本与体验的权衡,本地能定的绝不上云;
- 分层门面(中段):业务层只认识一个门面对象,会话、流式、异常处理全被关在门面后面,界面只订阅事件、不碰网络;
- SSE 手工解析(末端) :
text/event-stream的分块到达怎么拼、事件边界怎么切、流被中途掐断怎么办。
三篇源笔记分别只讲了其中一段,但合起来,它们其实是同一次 AI 对话请求从入口到出口的三个切面。统一的判断是:流式 AI 对话真正难的不是调 API,而是"数据不是一次到齐的"------所以链路每一层都要能处理"半个东西"。下面按"先总览、再前置、再中段、再末端"的顺序展开。
一、先说链路:一条被"半个东西"贯穿的请求
把整条对话链路拉直,从用户张嘴(或敲字)到气泡里出现完整回答,数据流向是这样的:
这张图里有两个容易被忽略的设计决策,先把它们点破,后面三段才好理解:
第一,意图理解(降级链)和回答生成(门面+流式)不是两个独立功能,而是同一次请求的前后置。用户那句话,先经过降级链"定性"------这到底是"打开相机"还是"随便聊聊"?定性结果决定了后面要不要走大模型、走哪条分支。也就是说,降级链是链路的"入口守门员",它把 80% 的高频指令在本地秒回,只把真正模糊的少数交给云端。
第二,链路后半段的每一层,都在处理"流"而不是"结果"。门面拿到的是一串事件,不是一条回答;SSE 解析拿到的是一串分块字节,不是一段 JSON。于是就出现了开头说的"半个东西":
- 半句话:降级链前三秒可能拿不准------硬匹配没中、正则也没中,这句话到底是不是个指令?它必须"先放行、再兜底",把确定不了的丢给下一层,而不是卡死在这一级;
- 半个会话 :门面维护的
taskId↔msgId映射和累积文本,是边收边长的状态,不能假设"等全部到齐再处理"; - 半个 JSON :SSE 的一个
data:帧,底层 TCP 可能把它切成两半发,解析层必须把"没到齐的半帧"先缓存,等空行这个事件边界到了才拼成完整 JSON。
记住这个骨架:入口用降级链换成本,中段用门面换解耦,末端用 SSE 解析换可控。下面逐一拆开。
二、前置:本地优先的三级自然语言理解降级链
2.1 为什么意图识别不能一上来就上大模型
给硬件产品做语音助手或对话入口时,第一个纠结的就是"用户这句话到底想干嘛"(业内叫 NLU,自然语言理解)。直接上大模型?三个问题立刻冒出来:延迟高 (等模型推理几百毫秒到几秒)、要钱 (每次调用都计费)、断网就抓瞎(没网时整个功能废了)。可如果全用死规则匹配,表达能力又不够------"把音量调大一点"和"麻烦帮我调大音量"这种同义说法,你没法一个个写死。
一个很务实的解法是本地优先的三级降级链:先拿纯字符串硬匹配(最快最省),命中不了再上正则(能带参数),再不行才交给大模型(最灵活最兜底)。每一级都遵循"有结果就返回,没有就继续往下走"的短路原则。这样常见的指令本地毫秒级秒回、复杂的才花钱走 LLM,体验和账单都可控。
这套链条最反直觉的有两点,后面会专门讲:硬匹配要取"最长短语"防止误命中 ,以及正则要缓存 Pattern 防止反复编译。先看一下用户说法有多乱:
arduino
"拍照"
"帮我把相机打开"
"开始录像吧"
"相机"
需求是:把这些五花八门的说法,统一归到几个标准意图 (打开相机 / 拍照 / 开始录像 / 关闭录像)里,并且最好还能从说法里抽出参数 (比如"把音量调到 50"要抽出 50)。难点在于说法太多太随意,而且识别结果要足够确定------语音助手答错一次,用户就对整个产品失去信任。
2.2 根因:单一方案解决不了"又快又准又灵活"
三种候选方案各自的短板是互补的:
- 硬匹配:快、零成本、100% 确定,但只能认死说法,不会抽参数;
- 正则:能认"短语+参数"的组合,但要人工写规则,覆盖有限;
- 大模型:最灵活、最能理解各种说法,但慢、贵、依赖网络、还可能幻觉。
正因为短板互补,与其二选一,不如按"本地优先、由快到慢、逐级兜底"的顺序把它们串起来。这一串的本质,是在"成本、速度、灵活性"三角里找一个平衡点:让最便宜的方案先试,贵的方案只在必要时刻启用。
下面用一张表把三级的账算清楚------这是后面所有设计决策的根据。
| 级别 | 手段 | 单次成本 | 典型延迟 | 准确率/覆盖率 | 能否抽参数 | 适用说法 |
|---|---|---|---|---|---|---|
| 第一级 硬匹配 | 字符串全等或包含 | 零(纯内存) | 小于 1 毫秒 | 100% 确定,但仅限既定说法 | 否 | 固定指令、无参数 |
| 第二级 正则 | Pattern 编译后匹配 |
低(首次编译后缓存) | 1 至数毫秒 | 高,覆盖带参说法 | 是 | 短语加参数的组合 |
| 第三级 大模型 | LLM 推理 | 高(调用费加算力) | 数百毫秒到数秒 | 最高,理解自由说法 | 是 | 模糊、口语、规则覆盖不到 |
把"命中即返回"的短路逻辑画成流程图,后面读代码会更直观:
2.3 链条骨架:顺序执行,命中即返回
实现上,先定义"意图模板":每个标准意图,配一张"实体词 → 说法列表"的表。比如"拍照"意图的实体词 camera_action 下挂 ["拍照", "拍个照", "帮我拍张照"]。三级处理器都基于这套模板做匹配。调度层维护一个有序的处理器列表,逐个尝试,第一个返回非空结果的胜出:
kotlin
class IntentMatcher {
private val processors = mutableListOf<MatchProcessor>() // 有序
suspend fun match(command: String): NluResult? {
processors.forEach { processor ->
val result = processor.match(command)
if (result != null) {
return result // 本级命中,立即返回,不再往下
}
}
return null // 三级都没命中
}
}
装配时依次加入:硬匹配 → 正则匹配 → 大模型匹配。这样一个请求要么本地秒回,要么才走 LLM。这里有个边界情况值得说:如果某一级抛异常(比如大模型接口超时),理想做法是把它当"未命中"降级到下一级或兜底,而不是让整条链路崩溃------降级链的鲁棒性,恰恰体现在"上一级失败不等于整体失败"。
2.4 第一级 · 硬匹配:全等优先,且校验"周边只能空白/标点"
硬匹配只认两种情况:整句与某个说法全等 (忽略大小写);或者包含该说法,且说法以外的部分只有空白和标点(说明整句话就是来触发这个动作的,没有别的业务参数)。
kotlin
val matched = when {
// 情况1:整句全等
normalized.equals(phrase, ignoreCase = true) -> true
// 情况2:包含该说法,且说法外只有空白/标点(无业务参数)
normalized.contains(phrase, ignoreCase = true)
&& hasNoExtraPayload(normalized, phrase) -> true
else -> false
}
// 校验"说法之外"没有多余的业务内容
private fun hasNoExtraPayload(command: String, phrase: String): Boolean {
val start = command.indexOf(phrase, ignoreCase = true)
val end = start + phrase.length
val before = command.substring(0, start)
val after = command.substring(end)
return before.all { it.isWhitespace() || it in PUNCTUATION } // 两侧只能空白/标点
&& after.all { it.isWhitespace() || it in PUNCTUATION }
}
这个"周边只能空白/标点"的校验很关键------它保证"拍照"这个说法不会误命中"拍照发给小王"这种其实带参数的话(带参数的话应该交给正则级去抽参数)。这正是"半句话"难题的第一种形态:硬匹配必须对自己"吃不准"的话保持克制,宁可放过去让下一级处理,也不能强行认领一个带参数的指令,否则抽不出参数反而答非所问。
2.5 第一级 · 硬匹配的隐藏坑:取"最长匹配"防止短词误中
如果模板里既有"相机"又有"打开相机",用户说"打开相机",字符串匹配时"相机"和"打开相机"都可能命中同一个意图。如果取短的那个,结果不够精确。所以硬匹配在多个候选里要挑说法最长的那个:
kotlin
// 在所有命中候选中,取 phraseLength 最大的(最长短语更精确)
if (best == null || candidate.phraseLength > best!!.phraseLength) {
best = candidate
}
这也是一种防误命中策略:长短语的信息量更大,更值得信。短词很容易是长句的子串,取最长能显著降低把"打开相机"错判成"相机"的概率。工程上还要注意:规范化(去空格、统一全半角、大小写)必须在比较前完成,否则"打开相机 "尾巴带空格就会双双失配。
2.6 第二级 · 正则匹配:从"带参数的话"里抽参数
硬匹配没命中(多半是因为话里带了参数),就轮到正则。正则处理器会为每个说法生成三种模式,分别覆盖"说法在前、参数在后""参数在前、说法在后""说法在中间":
kotlin
// 例如说法 "调到音量":
// 1) "调到音量50" → 说法在头,参数在尾
// 2) "音量调到50" → 说法在中
// 3) "麻烦调到音量50" → 说法前有修饰词
private fun buildPatterns(phrase: String): List<Pattern> {
val quoted = Pattern.quote(phrase) // 转义,防说法里的正则元字符捣乱
val flags = Pattern.CASE_INSENSITIVE or Pattern.UNICODE_CASE
return listOf(
Pattern.compile("^$quoted(.+)$", flags), // 短语 + 尾部参数
Pattern.compile("^(.+?)$quoted$", flags), // 头部参数 + 短语
Pattern.compile("^.+$quoted(.+)$", flags), // 前有修饰 + 短语 + 尾部参数
)
}
命中的话,group(1) 就是抽出来的参数。这里有个边界细节:中文参数里可能含数字、单位、空格,(.+) 是贪婪匹配,在"说法在中间"的第三种模式里要确保只截到尾部参数,避免把后续冗余文本也吞进来------必要时用非贪婪或加结尾锚点。同样遵循"取最长匹配"的规则,保证说法被准确对齐。
2.7 第二级 · 正则的隐藏坑:缓存 Pattern,别反复编译
正则编译(Pattern.compile)是 CPU 密集型操作,对同一个说法反复编译纯属浪费。工程上用一张缓存表,同一个说法只编译一次:
kotlin
private val patternCache = mutableMapOf<String, List<Pattern>>()
val patterns = patternCache.getOrPut(phrase) { buildPatterns(phrase) }
这样在大量指令涌入时,正则匹配级的性能不会成为瓶颈。生产环境里这套缓存还要注意线程安全:mutableMapOf 不是并发安全的,高并发下要么用 ConcurrentHashMap,要么对缓存表加读写锁,否则可能出现重复编译甚至崩溃------这也是"本地优先"方案在设备端被高频调用时必须补的边界处理。
2.8 第三级 · 大模型匹配:把前两级解决不了的交给 LLM
前两级都没命中,说明说法太随意、规则覆盖不了,这时才调大模型。思路是把意图模板和说法一起塞进 prompt,让模型从给定的意图集合里挑一个,并抽出参数:
csharp
给模型的信息大致是:
- 已知意图:{打开相机/拍照/开始录像/关闭录像}
- 各自的触发说法:...
- 用户说:"麻烦帮我开一下摄像头好不"
模型输出:{ intent: "打开相机", entity: "...", params: [...] }
这一级兜住了所有"说人话但规则匹配不到"的情况。作为最后一级,即使它偶尔失败,前两级已经挡掉了绝大多数常见指令,总体体验依然可控。这里有个工程上的"半句话"兜底:大模型返回的结果也要做结构校验 ------intent 必须在已知意图集合内,否则宁可返回"未知"也不要瞎猜。降级链的精髓就是"宁可承认不知道,也不要自信地答错"。
2.9 升华:降级链是"成本与体验"的权衡架构
这套"本地优先、逐级兜底"的本质,是在成本、速度、灵活性之间找平衡:高频、确定的指令留在本地(硬/正则),零成本、毫秒级、100% 确定;低频、模糊的指令才交给大模型,控制成本与延迟;大模型作为"最后一根稻草",兜住规则的空白。
这种模式可迁移到很多场景:本地缓存优先 → 远端兜底 、精确匹配优先 → 模糊检索兜底 、内置规则优先 → AI 兜底 。原则相通:让最便宜的方案先尝试,贵的方案只在必要时启用。更妙的是链条本身可插拔------每个处理器都是统一的"入参命令、出参意图"接口,你可以随时在链里插入新处理器(比如加一个同义词扩展级),而不动其他层级。这也正是它能在"对话链路入口"稳稳当当做守门员的原因:无论后面接不接大模型,入口的契约不变。
三、中段:流式问答对话的分层门面架构
3.1 问题长什么样:流式回答"找不到家"
降级链定性完,进入回答生成。传统一问一答的接口是同步的:请求发出去,等一个完整的 JSON 回来,一次性渲染。可大模型聊天是流式的,服务器会像打字机一样,分多次把同一段回答的碎片推过来。于是出现一个尴尬局面:界面上有好几个正在回复的气泡(用户可能连发两条、或者打断重问),服务器推回来的碎片到底属于哪一条?
css
用户:帮我查一下设备状态 → 界面生成气泡 A(loading)
用户:算了,换个角度再问一次 → 界面生成气泡 B(loading)
服务器:流式碎片1(属于 A?)
服务器:流式碎片2(属于 B?)
如果只靠"按顺序对应",两次请求几乎同时发出、返回交错时,气泡 A 和 B 的内容就会互相串台。这是流式对话最反直觉、也最容易做错的地方------它对应的正是开头说的"半个会话":会话状态必须在碎片不断到达的过程中持续累积,而不能假设"等全部到齐"。
3.2 根因:缺少"流分片 ↔ 界面气泡"的映射
问题不在网络层,而在调度层缺少一张映射表。服务器其实给每个会话/每个回复都打了唯一标识(taskId),而界面上每个气泡也有自己的消息 id(msgId)。两头都有"身份证",但没人把这两个 id 对应起来,碎片到了就不知道往哪塞。
ini
服务器侧:taskId = t1 → 这一段回复属于哪个会话
界面侧: msgId = m1 → 这一条气泡是哪个
缺失:t1 ↔ m1 的对应关系
另一个附带问题:回复是流式增量的,界面不能等全部收完再显示,必须把累积的文本边收边刷新。这意味着状态必须是"可累积的",而不是"一锤子买卖"。这一节的门面架构,就是来解决这两件事的。
3.3 四层门面,各管一段
把整个对话能力拆成四层,越往上越接近用户,越往下越接近网络。每一层的边界用一张表固定下来,后面所有代码都守这个边界:
| 层级 | 职责 | 是否碰网络 | 对外暴露 |
|---|---|---|---|
| 界面层 | 展示气泡列表,订阅事件流,不做任何网络逻辑 | 否 | 事件流加方法调用 |
| 调度层(视图模型) | 编排业务,维护 taskId↔msgId 映射,决定事件刷新哪个气泡 | 否 | 只读 SharedFlow |
| 数据层(仓库) | 负责发起请求、解析流,把原始字节转成结构化事件 | 是(经网络层) | 挂起函数或 Flow |
| 网络层 | 封装 HTTP 客户端与接口定义 | 是 | 接口定义 |
界面层永远只看到两类东西:不可变的事件流 和方法调用。它不知道也不关心大模型是谁、走什么协议。想换一个问答服务商,只动数据层和网络层,界面一行不改------这就是门面存在的意义。
用一张类图把门面各角色的依赖关系钉死,后面所有代码都守这张图:
3.4 不可变事件流:让界面只订阅、不操办
界面需要的不是"命令",而是"通知"。所以调度层对外只暴露只读的 asSharedFlow(),内部才持有可写的 MutableSharedFlow:
kotlin
// 对外只读,界面只能订阅
val chatUIEvent = _chatUIEvent.asSharedFlow()
// 内部可写,业务逻辑往里推事件
private val _chatUIEvent = MutableSharedFlow<ChatUiEvent>(
extraBufferCapacity = 1, // 事件可能来不及消费,留一点缓冲
onBufferOverflow = BufferOverflow.DROP_OLDEST // 聊天气泡刷新允许丢最旧的
)
事件本身用密封结构描述,"开始流式回复 / 流式过程中 / 流式结束 / 出错"各是一种事件,携带这次回复属于哪个气泡 的 id。界面只需 when 分派。这里有个边界考量:extraBufferCapacity = 1 配 DROP_OLDEST,意味着极端情况下最旧的一次刷新可能被丢掉------对聊天气泡这种"以最新状态为准"的场景是可接受的,但如果你要的是"每帧都不能丢"(比如实时日志),就得改用 BufferOverflow.SUSPEND 或更大的缓冲。门面的缓冲策略要跟着业务语义走。
3.5 那张关键映射表:taskId ↔ msgId
这是整个方案的精髓。用户一发消息,界面立刻生成占位气泡并拿到 msgId;随后请求发出时,把这个 msgId 作为自定义输入一起带给服务器。服务器每次流式推送都会回带 taskId,调度层在"工作流开始"事件里抓到这个 taskId,把 taskId ↔ msgId 写进一张内存表:
kotlin
// 收到"工作流开始",建立映射
taskIdToMsgIdMap[taskId] = respMsgId
// 后续每个流式碎片/结束/错误事件,都靠 taskId 反查该刷哪个气泡
when (response.event) {
Message -> {
answerBuilder.append(response.answer) // 累积文本
_chatUIEvent.tryEmit(Streaming(
text = answerBuilder.toString(), // 推累积后的整段,界面直接覆盖
responseMsgId = taskIdToMsgIdMap[response.taskId]!!
))
}
MessageEnd -> {
// 流式结束,附带引用文档等信息
_chatUIEvent.tryEmit(StreamingEnd(
refDocs = ...,
responseMsgId = taskIdToMsgIdMap[response.taskId]!!
))
}
Error -> {
_chatUIEvent.tryEmit(Error(..., taskIdToMsgIdMap[response.taskId] ?: ""))
}
}
注意一个细节:推给界面的是"累积后的完整文本"而不是"增量片段" 。界面拿到的永远是"到目前为止的完整回答",直接覆盖气泡即可。这避免了界面自己拼字符串、拼错还要纠正的麻烦。这也正是"半个会话"的标准解法:累积状态由门面集中持有(answerBuilder),界面只认最终态,不维护自己的增量------把"会出错的状态"收拢到一层,是流式系统稳的关键。
3.6 停止响应也能精准定位
打断(停止生成)同样依赖映射表。用户点"停止",界面传 msgId,调度层反查 taskId,再调停止接口:
kotlin
// 用 msgId 反查 taskId,因为停止接口是按 taskId 调的
taskIdToMsgIdMap.forEach { (taskId, msgId) ->
if (msgId == responseMessageId) {
stopChatMessage(taskId, ...)
}
}
这里有个边界问题:停止操作本身也可能是并发的------用户连点两次停止,或者停止时流刚好结束。工程上要幂等处理:停止成功后从映射表移除该 taskId,重复停止直接忽略;同时流结束时也要清理映射,避免内存泄漏。映射表不是"建了就完事",而是随会话生命周期增删的活表。
3.7 升华:这种"双标识 + 映射表"的思路可以迁移
这套打法不限于大模型聊天。任何"异步多实例 + 回执需要归位"的场景都适用:
- 多设备并发指令:同一界面同时给多台设备发指令,回执靠设备 id 归位;
- 批量任务进度:多个上传/下载任务并行,进度回调靠任务 id 找对应条目;
- 多通道推送:同一消息从不同通道回来,靠通道标识去重、聚合。
核心要点就一个:上游给每个实例打唯一标识,下游用标识反查归属,而不是依赖"先来后到"的顺序假设。顺序会交错,id 不会。门面把这套逻辑封装在调度层,界面因此极干净------它只订阅、只渲染,永不直接触碰网络。
四、末端:SSE 流式响应的手工解析
4.1 现象:为什么"流式"变成了一次性输出
代码明明用的是流式接口,可表现却是:转圈转半天,然后"哐"一下整段回答同时冒出来------没有打字机效果。或者更糟:读到一半连接被中断,回答残缺。
排查方向往往被带偏到"是不是服务器没流式返回"。但多数时候,问题出在客户端:
css
现象A:整段一次性冒出(无流式)→ 客户端把流缓存了
现象B:读到一半断流 → 客户端把流超时了
两个现象,一个指向"日志拦截器",一个指向"读取超时",都不是协议问题。
4.2 根因:字节流被"截胡"或"掐断"
先说为什么日志拦截器会毁掉流式。在 HTTP 客户端里加一个"打印请求体/响应体"的日志拦截器很常见。但流式响应下,日志拦截器要打印完整响应体,就得先把整个响应读进内存。这一"读",就把流给吞了------等它打印完,原本逐帧到达的流已经全部读完,后面真正消费流的地方拿到的是一整块内存数据,流式效果荡然无存。
正常: 逐行读 → 逐行吐(打字机)
加日志:整段读进内存 → 打印 → 一次性吐(退化成非流式)
再说为什么读取超时等于"定时炸弹"。普通接口设个 10 秒读取超时很合理。但流式对话是长时间挂着的连接,两段事件之间可能间隔很久(大模型在思考、在检索知识库)。一旦设置了读取超时,等待下一帧时超时器到期,连接被强制关闭------回答就被"掐"在中间。
4.3 客户端配置:两条铁律
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 该留还得留------建连阶段卡死一样要守。另外如果用的是 HttpLoggingInterceptor,它的 Level.BODY 是罪魁祸首,降级到 Level.HEADERS 或 Level.BASIC 才能保住流式;最稳妥是干脆在流式客户端里不挂任何响应体日志拦截器。
4.4 SSE 协议长什么样:先看清原始报文
SSE 就是个极简单的文本协议:服务器把事件一行一行地发过来,客户端逐行读、逐行解析。一个真实的服务端报文大致是这样(每行以 \n 结尾,事件之间用空行分隔):
vbnet
event: message
data: {"taskId":"t1","answer":"你好"}
data: {"taskId":"t1","answer":",我是"}
: keep-alive
data: [DONE]
看懂这段报文,就能看清 SSE 的几个关键约定:每个事件由若干行构成 ,event: 是事件类型、data: 是数据载荷(我们关心的一行 JSON)、id: 是事件编号、以冒号开头的行(如 : keep-alive)是注释、两个事件之间用一个空行 分帧;当 data: 的值是 [DONE] 时,表示流结束。注意 data: 的值里可能本身就含换行(多段 data 拼接),所以"空行"才是真正的事件边界,而不是"读到一行就处理一行"------这正是下一节状态机要解决的"半个 JSON"问题。
4.5 逐行解析 SSE:核心就一个 while 循环
SSE 格式很简单,我们关心的只是 data: 行,取出来就是 JSON。最朴素的写法是一个 readUtf8Line() 循环:
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出去就是"流式的一次推进"。
4.6 更稳的写法:按"空行分帧"的状态机
上一节的朴素循环有个隐含前提:每个 data: 帧都完整落在单独一行里。但协议上 同一事件允许多个 data: 行拼接,且一个 data 值本身可能跨 TCP 分块到达 ------readUtf8Line() 虽然按行切,但"事件边界"是空行而非行尾。更正确的做法是用一个微型状态机:逐行累积字段,遇到空行才 dispatch 一个完整事件。这样无论半帧怎么切,只要没见到空行,就不会拿半个 JSON 去反序列化:
kotlin
// 增强版 SSE 解析:按"字段累积 + 空行分帧"的微型状态机
// 解决单个 data 值被 TCP 分块截断、跨多次 readUtf8Line 才到齐的问题
fun parseSse(source: BufferedSource): Flow<SseEvent> = flow {
val dataLines = StringBuilder()
var hasData = false
while (!source.exhausted()) {
val line = source.readUtf8Line() ?: continue
when {
line.startsWith("data:") -> {
// 同一事件可能多行 data,逐行追加,以换行连接
if (dataLines.isNotEmpty()) dataLines.append('\n')
dataLines.append(line.removePrefix("data:").trim())
hasData = true
}
line.startsWith("event:") -> { /* 类型暂忽略,按业务需要可记录 */ }
line.startsWith("id:") -> { /* 续传编号暂忽略,用业务 taskId 即可 */ }
line.isBlank() -> {
// 空行 = 一个事件结束,此刻才 dispatch
if (hasData) {
val payload = dataLines.toString()
when {
payload == "[DONE]" -> { emit(SseEvent.Done); return@flow }
payload == "event: ping" -> { /* 心跳,忽略 */ }
else -> emit(SseEvent.Data(payload))
}
}
dataLines.clear()
hasData = false
}
}
}
}
这个状态机的价值在于把"半个 JSON"彻底挡在反序列化之外:只要空行没到,payload 就一直在 StringBuilder 里攒着,绝不会拿半截字符串去 fromJson。它对应开头说的"半个 JSON"难题------底层字节可以碎,但事件边界(空行)之前,解析层一律先缓存。配合第三节门面的"累积文本"策略,整条链路对"不完整数据"的处理就闭环了:降级链对半句话保持克制、门面对半个会话持续累积、SSE 解析对半个 JSON 先缓存。
4.7 一个容易忽略的坑:流在 IO 线程读,界面在哪刷新
解析循环在 IO 线程逐个 emit。这些"流式推进"必须最终回到主线程刷新 UI。用协程流的话,就是在消费端用 withContext(Dispatchers.Main) 切回主线程后再更新界面;或者让调度层在订阅时把线程切好。核心是:读流在 IO,渲染在主线程,二者不能混。
kotlin
// 消费端:在 IO 读出帧,回到 Main 刷新气泡
viewModelScope.launch {
chatRepository.chatStream(request)
.flowOn(Dispatchers.IO) // 读流在 IO
.collect { chunk ->
withContext(Dispatchers.Main) { // 渲染回 Main
appendToBubble(chunk)
}
}
}
如果忘了切线程,直接在 IO 里更新 TextView,轻则崩溃(只有主线程能碰 View),重则界面无规律卡顿。流式链路的线程边界,和"数据边界"一样不能含糊。
4.8 升华:SSE 解析是"协议无关"的通用能力
很多人把"流式问答"和大模型强绑定,其实 SSE 就是个通用的服务器推送通道,跟具体内容无关:
- LLM 流式补全(大模型逐字生成);
- 实时日志推送(服务端把日志一行行推给前端);
- 知识库检索过程推送(把"开始检索 → 检索到文档 → 生成回答"每个阶段推出来)。
只要掌握了"逐行读 → 按前缀分拣 → 遇到哨兵收尾 → 空行分帧"这个套路,任何 SSE 服务都能吃下来。而且手工解析比引库更可控 :你能精确处理心跳、哨兵、脏数据,还能按业务定制。一个可选的工程化增强:把 while 循环里"按行切分、按空行分帧"的逻辑抽成一个可复用的 SSE 解析器,输入字节流、输出事件行序列,这样上层只管 when 分派事件类型,解析细节隔离。
下面用一张时序图,把"分块到达"和"事件边界"在末端怎么走完最后一程画清楚:
为了把 SSE 字段和前面踩的坑一次性收口,再补两张表。先是协议字段说明,明确我们到底处理哪些行:
| 字段行 | 含义 | 本实现是否处理 |
|---|---|---|
event: |
事件类型 | 忽略(业务按 data 区分) |
data: |
数据载荷,每帧一个 JSON | 提取并反序列化 |
id: |
事件编号,用于断线重连续传 | 忽略(用业务 taskId 即可) |
| 空行 | 事件分隔符,分帧边界 | 用作 dispatch 触发 |
: 注释 |
如 keep-alive 心跳注释 | 忽略 |
retry: |
建议重连毫秒数 | 忽略(客户端自管重连) |
然后是全链路的"现象、根因、解法"坑对照表------把前三节里散落的坑汇总成一张可截图的排查清单:
| 现象 | 根因 | 解法 |
|---|---|---|
| 气泡串台、张冠李戴 | 缺 taskId↔msgId 映射,靠顺序假设归位 | 双标识加内存映射表,事件携带 responseMsgId |
| 流式退化成一次性整段输出 | BODY 级日志拦截器先把整响应读进内存 | 去掉响应体日志,在消费端逐条打 |
| 读到一半断流、回答残缺 | readTimeout 设了正值,两帧间隔触发超时 | readTimeout(0) 关闭读取超时 |
| 界面卡死或主线程崩溃 | 读流跑在主线程、或未切回 Main 刷新 | 解析在 IO 线程,渲染回 Main |
| 半个 JSON 反序列化报错 | 按行而非按空行分帧,拿半帧去解析 | 用空行分帧的状态机先缓存 |
| 硬匹配误认带参指令 | 短词是长句子串,未校验周边内容 | 取最长短语,且校验周边仅空白标点 |
| 正则反复编译拖慢 | 每次匹配都 Pattern.compile |
用缓存表,同一说法只编译一次 |
4.9 断线重连与失败路径:手工解析的真正红利
流式中除了超时和日志这两个"自残"型坑,第三个现实问题是连接中途断开------地铁进隧道、Wi-Fi 切到蜂窝、服务端滚动重启,都会让那条长连接悄无声息地断掉。引第三方 SSE 库时,重连策略往往是黑盒;而手工解析的红利恰恰是重连逻辑完全握在自己手里 。SSE 协议本身预留了 id: 字段和 retry: 建议值做断点续传,但我们的业务侧已经有 taskId,重连时带着原 taskId 重新建流,就能让服务端续上同一段会话,而不必让用户把刚才那句话重说一遍。
重连本身要克制:重试次数必须设上限(比如 3 次),且每次退避间隔递增(如 1 秒、2 秒、4 秒),避免在网络彻底不可用时空转耗电、反复打连接。若达到上限仍失败,门面应当发一个 Error 事件,让对应的那条气泡显示"生成失败,点击重试",把失败路径也收进统一的事件流,而不是让界面各个角落各自 try-catch。所谓"链路每一层都能处理半个东西",最后一环也要能处理"半路断掉的半个连接"------这才是流式系统从能用到可靠的最后一道缝。
五、小结
- 流式 AI 对话真正难的不是调 API,而是数据从不在一次请求里到齐------链路每层都要能处理"半个东西":半句话、半个会话、半个 JSON。
- 入口用本地优先的三级降级链(硬匹配 → 正则 → 大模型)换成本:高频指令本地秒回,模糊说法才上云,且宁可承认"未知"也不瞎猜。
- 中段用分层门面 换解耦:界面只订阅不可变事件流、不碰网络;
taskId↔msgId映射表解决"碎片归哪个气泡",推累积文本让界面只覆盖不拼接。 - 末端用SSE 手工解析 换可控:
readTimeout(0)防掐断、禁 BODY 日志防退化,按"空行分帧"的状态机先把半帧 JSON 缓存住再解析。 - 这套"上游打 id、下游按 id 归位"与"逐步累积状态"的思路,可迁移到多设备指令、批量任务进度、多通道推送等一切异步流式场景。
你的流式对话里,断流时正在拼的那半个 JSON 是怎么处理的------是直接丢弃等用户重发,还是靠空行分帧的状态机缓存住、等重连后补齐?