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

3 阅读29分钟

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

做一个会"聊天"的 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 防止反复编译。先看一下用户说法有多乱:

"拍照"
"帮我把相机打开"
"开始录像吧"
"相机"

需求是:把这些五花八门的说法,统一归到几个标准意图(打开相机 / 拍照 / 开始录像 / 关闭录像)里,并且最好还能从说法里抽出参数(比如"把音量调到 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 下挂 ["拍照", "拍个照", "帮我拍张照"]。三级处理器都基于这套模板做匹配。调度层维护一个有序的处理器列表,逐个尝试,第一个返回非空结果的胜出:

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 第一级 · 硬匹配:全等优先,且校验"周边只能空白/标点"

硬匹配只认两种情况:整句与某个说法全等(忽略大小写);或者包含该说法,且说法以外的部分只有空白和标点(说明整句话就是来触发这个动作的,没有别的业务参数)。

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 第一级 · 硬匹配的隐藏坑:取"最长匹配"防止短词误中

如果模板里既有"相机"又有"打开相机",用户说"打开相机",字符串匹配时"相机"和"打开相机"都可能命中同一个意图。如果取短的那个,结果不够精确。所以硬匹配在多个候选里要挑说法最长的那个:

// 在所有命中候选中,取 phraseLength 最大的(最长短语更精确)
if (best == null || candidate.phraseLength > best!!.phraseLength) {
    best = candidate
}

这也是一种防误命中策略:长短语的信息量更大,更值得信。短词很容易是长句的子串,取最长能显著降低把"打开相机"错判成"相机"的概率。工程上还要注意:规范化(去空格、统一全半角、大小写)必须在比较前完成,否则"打开相机 "尾巴带空格就会双双失配。

2.6 第二级 · 正则匹配:从"带参数的话"里抽参数

硬匹配没命中(多半是因为话里带了参数),就轮到正则。正则处理器会为每个说法生成三种模式,分别覆盖"说法在前、参数在后""参数在前、说法在后""说法在中间":

// 例如说法 "调到音量":
// 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 密集型操作,对同一个说法反复编译纯属浪费。工程上用一张缓存表,同一个说法只编译一次:

private val patternCache = mutableMapOf<String, List<Pattern>>()

val patterns = patternCache.getOrPut(phrase) { buildPatterns(phrase) }

这样在大量指令涌入时,正则匹配级的性能不会成为瓶颈。生产环境里这套缓存还要注意线程安全:mutableMapOf 不是并发安全的,高并发下要么用 ConcurrentHashMap,要么对缓存表加读写锁,否则可能出现重复编译甚至崩溃——这也是"本地优先"方案在设备端被高频调用时必须补的边界处理。

2.8 第三级 · 大模型匹配:把前两级解决不了的交给 LLM

前两级都没命中,说明说法太随意、规则覆盖不了,这时才调大模型。思路是把意图模板和说法一起塞进 prompt,让模型从给定的意图集合里挑一个,并抽出参数:

给模型的信息大致是:
- 已知意图:{打开相机/拍照/开始录像/关闭录像}
- 各自的触发说法:...
- 用户说:"麻烦帮我开一下摄像头好不"
模型输出:{ intent: "打开相机", entity: "...", params: [...] }

这一级兜住了所有"说人话但规则匹配不到"的情况。作为最后一级,即使它偶尔失败,前两级已经挡掉了绝大多数常见指令,总体体验依然可控。这里有个工程上的"半句话"兜底:大模型返回的结果也要做结构校验——intent 必须在已知意图集合内,否则宁可返回"未知"也不要瞎猜。降级链的精髓就是"宁可承认不知道,也不要自信地答错"。

2.9 升华:降级链是"成本与体验"的权衡架构

这套"本地优先、逐级兜底"的本质,是在成本、速度、灵活性之间找平衡:高频、确定的指令留在本地(硬/正则),零成本、毫秒级、100% 确定;低频、模糊的指令才交给大模型,控制成本与延迟;大模型作为"最后一根稻草",兜住规则的空白。

这种模式可迁移到很多场景:本地缓存优先 → 远端兜底、精确匹配优先 → 模糊检索兜底、内置规则优先 → AI 兜底。原则相通:让最便宜的方案先尝试,贵的方案只在必要时启用。更妙的是链条本身可插拔——每个处理器都是统一的"入参命令、出参意图"接口,你可以随时在链里插入新处理器(比如加一个同义词扩展级),而不动其他层级。这也正是它能在"对话链路入口"稳稳当当做守门员的原因:无论后面接不接大模型,入口的契约不变。

三、中段:流式问答对话的分层门面架构

3.1 问题长什么样:流式回答"找不到家"

降级链定性完,进入回答生成。传统一问一答的接口是同步的:请求发出去,等一个完整的 JSON 回来,一次性渲染。可大模型聊天是流式的,服务器会像打字机一样,分多次把同一段回答的碎片推过来。于是出现一个尴尬局面:界面上有好几个正在回复的气泡(用户可能连发两条、或者打断重问),服务器推回来的碎片到底属于哪一条?

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

如果只靠"按顺序对应",两次请求几乎同时发出、返回交错时,气泡 A 和 B 的内容就会互相串台。这是流式对话最反直觉、也最容易做错的地方——它对应的正是开头说的"半个会话":会话状态必须在碎片不断到达的过程中持续累积,而不能假设"等全部到齐"。

3.2 根因:缺少"流分片 ↔ 界面气泡"的映射

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

服务器侧: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:

// 对外只读,界面只能订阅
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 写进一张内存表:

// 收到"工作流开始",建立映射
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,再调停止接口:

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

这里有个边界问题:停止操作本身也可能是并发的——用户连点两次停止,或者停止时流刚好结束。工程上要幂等处理:停止成功后从映射表移除该 taskId,重复停止直接忽略;同时流结束时也要清理映射,避免内存泄漏。映射表不是"建了就完事",而是随会话生命周期增删的活表。

3.7 升华:这种"双标识 + 映射表"的思路可以迁移

这套打法不限于大模型聊天。任何"异步多实例 + 回执需要归位"的场景都适用:

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

核心要点就一个:上游给每个实例打唯一标识,下游用标识反查归属,而不是依赖"先来后到"的顺序假设。顺序会交错,id 不会。门面把这套逻辑封装在调度层,界面因此极干净——它只订阅、只渲染,永不直接触碰网络。

四、末端:SSE 流式响应的手工解析

4.1 现象:为什么"流式"变成了一次性输出

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

排查方向往往被带偏到"是不是服务器没流式返回"。但多数时候,问题出在客户端:

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

两个现象,一个指向"日志拦截器",一个指向"读取超时",都不是协议问题。

4.2 根因:字节流被"截胡"或"掐断"

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

正常:  逐行读 → 逐行吐(打字机)
加日志:整段读进内存 → 打印 → 一次性吐(退化成非流式)

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

4.3 客户端配置:两条铁律

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 结尾,事件之间用空行分隔):

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() 循环:

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 去反序列化:

// 增强版 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,渲染在主线程,二者不能混。

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