← 全部文章

技术笔记 · 2026-09-14

流式响应解析:一次网络读取不等于一条消息

从 AI 对话的流式输出出发,拆开字节解码、事件分帧和业务更新,分析中文截断、半包与连续事件为什么会让前端状态失真。

延伸分析 · 从项目场景展开的原理与方案讨论。

  • SSE
  • 流式响应
  • UTF-8
  • 协议解析

关联项目:AI Agent 应用平台。这是从项目的流式对话场景展开的技术分析,重点讨论协议解析边界,不代表项目曾出现下文所有问题。

一条事件可以跨过几次读取

流式接口看起来是“服务端产生一段文字,前端追加一段文字”,但浏览器读取到的是网络传输块。传输块的边界不由业务消息决定:一个块可能包含几条事件,也可能只包含一条事件的前半截。

如果对每次读取结果直接 JSON.parse,就把传输边界误当成了数据边界。偶尔正常,是因为测试时的数据恰好完整,不是协议保证。

事件 A:data: {"text":"你好"}\n\n
读取 1:data: {"te
读取 2:xt":"你好"}\n\ndata: {"text":"下一条"}\n\n

例子中的 \n 表示换行;实际发送的是换行字符,不是反斜杠和字母 n。

先解码字节,再识别事件

中文字符的 UTF-8 字节也可能被拆开。每收到一块就新建解码器,会丢掉尚未完整的字节上下文。一个响应应复用解码器,分块调用时使用流式模式,结束时完成解码。TextDecoder 文档说明了 stream 参数的作用。

接下来才是事件协议。标准 SSE 有自己的行、字段和空行规则,不能简单当作“每行一个 JSON”。例如多行 data: 会合并为一个事件数据,注释行也不该交给 JSON 解析器。HTML 的 SSE 解析规范定义了这些语义。

可以把处理链拆成三个边界:

字节块 → 连续文本 → 完整事件 → 业务状态
          解码层      协议层      应用层

原生 EventSource 已经负责前两层。如果因为请求方式或宿主限制使用 Fetch 自己读取流,就需要明确承担对应的解析责任。

缓冲区保存的是“尚不能解释”的内容

协议层应留下不完整的尾部,等待下一次输入;完整事件交给应用层后才从缓冲中移除。行结束符本身也可能跨块,不能只在单个块里替换换行格式。

应用层还要区分增量、全量快照、错误和结束事件。只有完整解析出业务事件,才决定是追加文本、替换结果还是结束加载;这些语义来自业务协议,SSE 不会替应用定义。

缓冲必须有上限。异常服务持续发送没有分隔符的文本时,无限拼接字符串会变成内存问题。超过约定的事件大小应进入明确的异常分支,不能无限等待一个不会到来的完整事件。

连接结束,不一定代表生成成功

网络断开可能发生在半条事件、半个字符,或者完整事件之后但业务完成信号之前。界面应分别表达“正常完成”和“连接中断,结果可能不完整”。不能只在读取循环退出时无条件标记成功。

标准 SSE 在流结束时也不会把缺少结束空行的残余事件自动派发。若服务采用不同的末尾规则,应把它当成独立协议约定,而不是悄悄改变标准解析器的语义。

这篇文章只处理流的解释方式。断线后的消息去重和恢复,需要另一层序号与重放约定。

用任意切分验证解析器

先准备一份完整字节序列和预期事件列表,再把它切成不同大小的块输入解析器。无论切在中文字符中间、字段名中间,还是两条事件之间,完整输入都应得到同一组事件。

再覆盖多行数据、注释、连续事件、异常 JSON、没有结束分隔符和超大事件。验证对象是“同一份数据在不同传输切分下是否语义一致”,不是某次网络恰好怎样分包。