Android 上的 AI 对话链路:流式响应、SSE 解析与三级降级

流式对话真正的难点,是数据从不在一次请求里到齐。

做一个会"聊天"的 Android 端,最容易让人低估的不是模型有多聪明,而是"一次对话请求"在工程上到底要拆成多少环节。你以为难点是调通大模型接口?其实接口本身十分钟就能接好。真正咬人的地方是:用户输入的一句话,要先被"听懂"------判断他到底想让设备干什么;听懂之后,答案又不会一次性砸回来,而是像打字机一样一个字一个字往外吐;你要在它吐到一半、断流、或者用户突然打断的时候,依然能把每段碎片准确地贴回对应的那一条气泡。

这三件事,任何一件单独看都不算难。但把它们串在一条链路上,就会发现一个贯穿始终的反常识事实:链路里没有任何一层能假设"数据一次到齐"。你说的那句话,可能本地规则吃不准(半个意图);流式回来的回答,可能这次只到了一半(半个会话状态);哪怕是最底层的网络字节,一个 JSON 也可能被 TCP 分块截成两半(半个 JSON)。

本文要讲的,就是把"理解用户说什么"到"一个字一个字吐出来"这条完整链路拆开看。它其实由三段组成,顺序固定、各管一段:

  • 降级链(前置环节):用户那句话先过本地硬匹配、再走正则、最后才交给大模型------这是成本与体验的权衡,本地能定的绝不上云;
  • 分层门面(中段):业务层只认识一个门面对象,会话、流式、异常处理全被关在门面后面,界面只订阅事件、不碰网络;
  • SSE 手工解析(末端) :text/event-stream 的分块到达怎么拼、事件边界怎么切、流被中途掐断怎么办。

三篇源笔记分别只讲了其中一段,但合起来,它们其实是同一次 AI 对话请求从入口到出口的三个切面。统一的判断是:流式 AI 对话真正难的不是调 API,而是"数据不是一次到齐的"------所以链路每一层都要能处理"半个东西"。下面按"先总览、再前置、再中段、再末端"的顺序展开。

一、先说链路:一条被"半个东西"贯穿的请求

把整条对话链路拉直,从用户张嘴(或敲字)到气泡里出现完整回答,数据流向是这样的:

flowchart TD U["用户语音或文本输入"] --> NLU["三级降级链 意图理解"] NLU -->|"硬匹配命中"| L1["本地直接返回 零成本"] NLU -->|"正则命中"| L2["本地抽参返回 低成本"] NLU -->|"两级都不中"| L3["大模型理解意图 高成本"] L1 --> F["分层门面 会话编排"] L2 --> F L3 --> F F --> S["SSE 手工解析"] S -->|"逐帧 data"| F F -->|"累积文本事件"| UI["界面气泡刷新"] L1 --> UI L2 --> UI

这张图里有两个容易被忽略的设计决策,先把它们点破,后面三段才好理解:

第一,意图理解(降级链)和回答生成(门面+流式)不是两个独立功能,而是同一次请求的前后置。用户那句话,先经过降级链"定性"------这到底是"打开相机"还是"随便聊聊"?定性结果决定了后面要不要走大模型、走哪条分支。也就是说,降级链是链路的"入口守门员",它把 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 推理 高(调用费加算力) 数百毫秒到数秒 最高,理解自由说法 是 模糊、口语、规则覆盖不到

把"命中即返回"的短路逻辑画成流程图,后面读代码会更直观:

flowchart TD Start["收到用户指令"] --> P1["第一级 硬匹配"] P1 -->|"全等或仅空白标点包含"| Hit1["直接返回意图 零成本"] P1 -->|"未命中"| P2["第二级 正则匹配"] P2 -->|"命中并抽出参数"| Hit2["返回意图加参数"] P2 -->|"未命中"| P3["第三级 大模型"] P3 -->|"模型返回"| Hit3["返回意图加参数"] P3 -->|"仍失败"| Miss["降级为未知意图"] Hit1 --> End["结果交给门面编排"] Hit2 --> End Hit3 --> End

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 客户端与接口定义 是 接口定义

界面层永远只看到两类东西:不可变的事件流 和方法调用。它不知道也不关心大模型是谁、走什么协议。想换一个问答服务商,只动数据层和网络层,界面一行不改------这就是门面存在的意义。

用一张类图把门面各角色的依赖关系钉死,后面所有代码都守这张图:

classDiagram class ChatViewModel { +asSharedFlow~ChatUiEvent~ chatUIEvent -MutableSharedFlow~ChatUiEvent~ _chatUIEvent -Map~String,String~ taskIdToMsgIdMap +sendMessage(msg) +stopMessage(msgId) } class ChatRepository { +chatStream(request) Flow~Chunk~ -OkHttpClient client } class SSEParser { +parse(source) Flow~SseEvent~ } class ChatUiEvent { <<sealed>> } ChatViewModel --> ChatRepository : 调用 ChatRepository --> SSEParser : 使用 ChatViewModel ..> ChatUiEvent : 发射

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)

几个要点:

  1. readUtf8Line() 是阻塞的 ,所以整体必须跑在 IO 线程(flowOn(Dispatchers.IO)),否则会卡主线程;
  2. 只看 data: 前缀 ,event:/id: 行直接忽略------我们的业务只关心数据;
  3. [DONE] 是结束哨兵,遇到就跳出循环;
  4. 心跳事件单独跳过,避免误当成业务数据去反序列化报错;
  5. 每个 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 分派事件类型,解析细节隔离。

下面用一张时序图,把"分块到达"和"事件边界"在末端怎么走完最后一程画清楚:

sequenceDiagram participant S as 服务端 participant C as OkHttp字节流 participant P as SSE解析器 participant V as 门面ViewModel participant U as 界面气泡 S->>C: 分块到达 data 帧 C->>P: readUtf8Line 取到一行 P->>V: emit Data 事件 V->>U: Streaming 累积文本 S->>C: 下一段 data 分块 C->>P: readUtf8Line 再取一行 P->>V: emit Data 事件 V->>U: Streaming 覆盖刷新 S->>C: 空行加 data 结束帧 C->>P: readUtf8Line 取到哨兵 P->>V: emit Done 事件 V->>U: StreamingEnd 收尾

为了把 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 是怎么处理的------是直接丢弃等用户重发,还是靠空行分帧的状态机缓存住、等重连后补齐?

相关推荐
阿俊-全栈开发2 小时前
LikeShop单商户高级版定时任务实现:订单超时关闭与自动确认收货
android·likeshop·likeshop开源商城
倔强的石头1063 小时前
【Transformer】Tokenization详解_BPE_WordPiece_SentencePiece
android·深度学习·transformer
haerapi3 小时前
仓库拣货路线不只靠最短边:S 形启发式的反例记录
android·开发语言·javascript
其实防守也摸鱼12 小时前
Fastjson 反序列化漏洞(JNDI注入)
android·数据库·安全·oracle·自动化·fastjson·反序列化
狗凯之家源码网14 小时前
微博图床 PHP 系统搭建与实战应用指南
android·开发语言·php
消失的旧时光-194314 小时前
(第一篇)JNI 多线程到底难在哪:从 Linux Thread 到 JNIEnv
android·jni·ndk
逗脑IDE17 小时前
零基础学ESP32:蓝牙控制舵机——手机一点,舵机就转!
android·智能手机
清霜之辰18 小时前
骁龙 8397 舱驾融合平台端侧大模型部署:7B 模型本地推理与 64GB 内存账本
android
烽火聊员18 小时前
Android屏幕卡顿traces.txt拉取查看
android·java·android studio