SSE 流式响应多行文本编码方案

目录

一、概述

AI 聊天流式接口(SSE)推送的 thinking/reply 等文本事件常包含换行符(\n)。SSE 协议以 \n 作为帧分隔符,data: 字段中若出现裸换行,多行内容会被拆帧;非标准解析器(手写 fetch 按行解析、部分按行转发的代理)会把不以 data: 开头的行当作未知字段丢弃,导致返回信息丢失

二、候选方案

方案一:JSON 编码 data 字段

发送侧将文本/对象统一 JSON 序列化(如 JsonUtils.toJson),\n 被转义为字面量 \\n,帧层面永不出现裸换行;客户端统一 JSON.parse 还原。

text 复制代码
data: {"delta":"第一行\n第二行"}

后端(WebFlux):

java 复制代码
} else if (agentEvent instanceof TextBlockDeltaEvent delta) {
    // 文本增量统一 JSON 编码,\n 被转义为字面量,帧层面不出现裸换行
    return ServerSentEvent.builder()
            .event("reply")
            .data(JsonUtils.toJson(Map.of(
                    "replyId", delta.getReplyId(),
                    "delta", delta.getDelta())))
            .build();
}

前端(EventSource):

javascript 复制代码
source.addEventListener('reply', (e) => {
    const payload = JSON.parse(e.data);   // 统一 JSON.parse 还原
    appendToUI(payload.delta);             // "第一行\n第二行" 完整还原
});
  • ✅ 业界事实标准(OpenAI / Anthropic / Vercel AI SDK 均采用)
  • ✅ 无歧义、无碰撞风险,任意文本安全传输
  • ✅ 可顺带携带 replyIdindexfinish 等元数据,便于扩展
  • ❌ 每条事件多一层序列化/反序列化开销(对流式文本量级可忽略)

方案二:SSE 原生多 data:

利用规范允许的事件内多条 data: 行,标准客户端(EventSource)自动以 \n 拼接还原:

text 复制代码
event: reply
data: 第一行
data: 第二行

后端(Spring SseEmitter 传多行字符串时自动拆为多个 data: 行):

java 复制代码
// multiLineText = "第一行\n第二行"
sseEmitter.send(SseEmitter.event()
        .name("reply")
        .data(multiLineText));
// 实际发送帧:
// event:reply
// data:第一行
// data:第二行
//

前端(必须使用标准 EventSource,浏览器自动以 \n 拼接还原):

javascript 复制代码
source.addEventListener('reply', (e) => {
    appendToUI(e.data);   // 浏览器已还原为 "第一行\n第二行"
});
  • ✅ 协议原生,标准客户端零成本还原(Spring SseEmitter 发多行字符串时内部即此行为)
  • ❌ 仅对标准解析器有效,手写 fetch 解析或按行转发的代理仍会丢数据
  • ❌ 末尾换行语义易错(结尾 \n 会多出空行事件)
  • ❌ 空行(\n\n)语义难以可靠表达

方案三:自定义占位符转义

\n 替换为自定义占位符(如当前 AiChatController 使用的 #LR#),客户端反向还原。

后端:

java 复制代码
} else if (agentEvent instanceof TextBlockDeltaEvent delta) {
    // 换行符替换为占位符后再发送
    String escaped = delta.getDelta().replace("\n", "#LR#");
    return ServerSentEvent.builder()
            .event("reply")
            .data(escaped)
            .build();
}

前端:

javascript 复制代码
source.addEventListener('reply', (e) => {
    // 客户端需维护与服务端一致的转义表,反向还原
    appendToUI(e.data.replaceAll('#LR#', '\n'));
});
  • ✅ 实现简单,无需 JSON 依赖
  • ❌ 非标准协议,前后端须共同维护转义表
  • ❌ 占位符存在与真实内容碰撞的风险(AI 输出不可控)
  • ❌ 流式 token 粒度下,占位符可能被拆分到相邻增量中,还原逻辑更脆弱

方案四:按行拆分为多个事件

多行文本按 \n 拆行,每行发送一条独立 SSE 事件(当前 sseMultiLineSeparate 的做法)。

后端:

java 复制代码
private void sseMultiLineSeparate(SseEmitter sseEmitter, String eventName, String multiLines) throws IOException {
    if (!StringUtils.hasText(multiLines)) {
        return;
    }
    // 按换行拆分,每行一条独立事件;空行被 hasText 过滤,\n\n 语义丢失
    for (String line : multiLines.split("\n")) {
        sseEmitter.send(SseEmitter.event().name(eventName).data(line));
    }
}

前端:

javascript 复制代码
// 需自行约定拼接规则(如约定空行事件 = data 为 null)
source.addEventListener('reply', (e) => {
    appendToUI(e.data ?? '\n');
});
  • ✅ 每帧均为单行,无转义问题
  • ❌ 空行被过滤,\n\n 段落语义丢失
  • ❌ 事件数量膨胀,前端需自行拼接且无法区分行边界来源

三、方案对比总览

维度 方案一 JSON 编码 方案二 多 data: 方案三 占位符转义 方案四 按行拆事件
标准化程度 ✅ 业界事实标准 ✅ SSE 规范内 ❌ 自定义协议 ❌ 自定义协议
标准客户端(EventSource)可靠性 ✅ 高 ✅ 高 ⚠️ 需自行还原 ⚠️ 需自行拼接
非标准链路(手写 fetch 解析 / 代理转发)可靠性 ✅ 不丢数据 ❌ 会丢数据 ⚠️ 依赖转义约定 ✅ 不丢数据
空行 / 末尾换行语义 ✅ 完整保留 ❌ 易错 ✅ 完整保留 ❌ 空行丢失
内容碰撞 / 拆断风险 ✅ 无 ✅ 无 ❌ 占位符可碰撞、可被 token 拆断 ✅ 无
元数据扩展能力 ✅ 强(JSON 字段) ❌ 弱 ❌ 弱 ❌ 弱
前后端改造成本 低(序列化/反序列化一行代码) 最低 中(维护转义表) 中(拼接 + 行边界处理)
运行时开销 序列化开销,流式量级可忽略 字符串替换,低 事件数膨胀
业界使用 OpenAI / Anthropic / Vercel AI SDK 少用于业务文本 少数旧框架 / IM 推送 罕见

四、最终决策

采用方案一:SSE data 字段统一 JSON 编码,客户端 JSON.parse 解码。

理由:

  1. 与业界主流流式 AI 协议(OpenAI、Anthropic、Vercel AI SDK)对齐,前端可复用成熟解析模式;
  2. 唯一在"手写解析、代理转发"等非标准链路下也不丢数据的方案;
  3. 结构化载荷天然支持后续扩展(多事件元数据、错误码等)。

落地约定:

  • 后端:所有 SSE 事件 data 一律 JSON 字符串(文本增量经 JsonUtils.toJson 编码);
  • 前端:统一 JSON.parse 后取字段,不再做占位符替换或按行拼接;

五、备选方案(未采纳)

方案二仅适合纯浏览器原生 EventSource 且链路可控的场景;方案三、四为非标准过渡做法,维护成本与正确性风险均高于 JSON 编码,故弃用。

相关推荐
Lucifer三思而后行6 分钟前
转换率 90%,然后呢?
ai·aws
kaixin_啊啊17 分钟前
PandaWiki 本地 AI 知识库实战:文档导入、智能问答与远程访问
linux·服务器·人工智能·windows·ai
极小狐25 分钟前
用 Flow 编排 Agent 智能体:极狐GitLab Duo 自定义工作流实战
ai·gitlab·agent·devops·flow·duo
头茬韭菜1 小时前
第 3 篇:「Pydantic 即 Schema」—— 工具生态三层解剖
前端·chrome·ai·openmanus
LayZhangStrive2 小时前
Agent开发 - 实现人类与Manus智能体的终端窗口命令交互
ai·交互·agent·react·终端·manus
木圭的AI时代指南2 小时前
为了跑星火Spark-X2.5,我把 llama.cpp 重新编译了一遍
大数据·人工智能·ai·语言模型
Web极客码2 小时前
Pydantic 校验通过不等于答案正确:如何识别 LLM 的语义错误
服务器·人工智能·ai·llm
兜里只有三分钱~2 小时前
【SenseNova U1.5 Lite实战】AMD ROCm 192G显存部署全流程与性能调优
ai·日日新·sensenova
蒲公英eric11 小时前
从页面检查到功能验证:DVWA 授权绕过模块完整漏洞分析教程
web安全·ai·ctf·dvwa·ai安全·授权绕过模块
AI老陈说13 小时前
Nano Banana 2 AI 角色一致性怎么保持?Flux Art 同一角色换动作与版本管理
ai·ai工具·ai生图