目录
-
- 一、概述
- 二、候选方案
-
- [方案一:JSON 编码 data 字段](#方案一:JSON 编码 data 字段)
- [方案二:SSE 原生多 `data:` 行](#方案二:SSE 原生多
data:行) - 方案三:自定义占位符转义
- 方案四:按行拆分为多个事件
- 三、方案对比总览
- 四、最终决策
- 五、备选方案(未采纳)
一、概述
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 均采用)
- ✅ 无歧义、无碰撞风险,任意文本安全传输
- ✅ 可顺带携带
replyId、index、finish等元数据,便于扩展 - ❌ 每条事件多一层序列化/反序列化开销(对流式文本量级可忽略)
方案二: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 解码。
理由:
- 与业界主流流式 AI 协议(OpenAI、Anthropic、Vercel AI SDK)对齐,前端可复用成熟解析模式;
- 唯一在"手写解析、代理转发"等非标准链路下也不丢数据的方案;
- 结构化载荷天然支持后续扩展(多事件元数据、错误码等)。
落地约定:
- 后端:所有 SSE 事件
data一律 JSON 字符串(文本增量经JsonUtils.toJson编码); - 前端:统一
JSON.parse后取字段,不再做占位符替换或按行拼接;
五、备选方案(未采纳)
方案二仅适合纯浏览器原生 EventSource 且链路可控的场景;方案三、四为非标准过渡做法,维护成本与正确性风险均高于 JSON 编码,故弃用。