← 返回文章列表
所属专题:架构之道

让 AI 开口说话:从“整段等待”到“流式反馈”的架构演进

更新于 2025-12-03年份:2025字数:4,200阅读时长:12 分钟

以 global-xxxxx-h5 AI 对话为样本,复盘 SSE 流式输出的完整链路:为何选用 fetch-event-source + POST、AbortController 如何三层协作实现中断、停止后文案重复的三重根因与 session 隔离修复,以及协议标记拆片时的缓冲策略。

TL;DR · 核心结论

  • 1核心价值:流式输出本质是“感知延迟隐藏”,通过连续反馈稀释用户的等待焦虑。
  • 2技术选型:fetch-event-source + POST 兼顾 SSE 流式体验与 AI 对话所需的加密 body、FormData、Token 与中断能力。
  • 3工程落地:AbortController 三层中断;streamSessionId 隔离过期分片;协议标记拆片缓冲避免半截 marker 露出。

你是否曾有过这样的焦虑:在对话框输入指令后,盯着屏幕上那动也不动的“加载中”图标,每一秒的等待都显得漫长而枯燥,直到回复像整块砖头一样突然“啪”地弹出?

在 AI 时代,等待感(Perceived Latency)是用户流失的第一杀手。流式输出(Streaming)将交互从"等一屏字"进化为"边思边说"——不仅是技术跃迁,更是心理学在工程中的极致应用。

本文以 global-xxxxx-h5 项目的 AI 对话模块为样本,梳理 SSE 流式输出的代码结构、中断机制,以及一次真实的“停止后文案重复”踩坑与修复。


一、策略原点:感知速度 vs 物理速度

一个常被忽视的工程直觉是:同样耗时的任务,具备连续反馈的过程比"黑盒"等待更显快速。

流式输出的核心目标并非真正消除后端大模型的推理延迟(物理上限),而是通过掩盖延迟(Latency Hiding),将用户的注意力锚定在持续产出的内容上。这种"打字员"式的交互让用户感到系统正在"思考并即时响应",有效地稀释了等待焦虑。


二、技术选型:SSE 是 AI 场景的最佳搭子,但未必是 GET

在实时推送方案中,SSE 相较于 WebSocket 具备显著的“降维打击”优势:

1. 契合交互本质:AI 对话是典型的"一问一答"半双工通信。SSE 基于 HTTP,实现服务端向客户端的单向流式推送,无需维持复杂的双向 Socket 握手。

2. 基建兼容性:SSE 就是普通的 HTTP 请求,对负载均衡(Nginx)、CDN 以及各类企业级代理极为友好,避开了 WebSocket 常见的连接截断风险。

3. 协议轻量化:自带断线重连(Retry)机制,相比 WebSocket 冗长的协议升级过程,SSE 几乎是"零配置"接入。

为什么业界常说「SSE 用 GET」,本项目却用 POST?

这里需要区分协议能力浏览器 API 限制

SSE 协议本身不限定 HTTP 方法,响应类型为 `text/event-stream` 即可。
原生 `EventSource` API 仅支持 GET,不能携带 request body,自定义 Header 也受限——适合简单的订阅推送,因此给人“SSE = GET”的印象。
AI 对话场景需要:加密 body(如 `asg.strEnc`)、FormData 上传(图片/OCR/菜单识别)、Bearer Token 与签名 Header、以及 AbortController 中断。这些需求原生 EventSource 无法满足。

因此项目采用 `@microsoft/fetch-event-source`(^2.0.1),通过 POST 发起请求,响应仍按 SSE 格式解析。这是 AI 聊天场景的常见做法,与 REST 接口风格一致,同时保留流式体验。


三、代码结构:从 EventSource 到 fetch-event-source

项目未采用浏览器原生 `EventSource`,核心入口为 `index.vue` 内的 `fetchStreamData()`,通过 POST 监听 `onmessage`,对接以下接口:

| 接口 | 场景 |

| `/xxx/message/chat` | 普通对话 |

| `/xxx/recommend/chat` | 推荐流 |

| `/xxx/translation/chat` | 翻译 |

| `/xxx/image/chat` | 图片/OCR/菜单等 FormData 场景 |

周边模块分工清晰:

`streamContentFilter.js`:过滤协议标记 `*data_chunk*`,并在 marker 被拆分到多个 SSE 分片时做缓冲,避免半截标记直接展示给用户。
`composables2/useStreamData.js`:重构版 composable,逻辑更完整,但当前主页面尚未接入,可作为后续演进方向。
`request.js` 中的 `createSSEConnection()`:基于原生 EventSource 封装,未被引用,可视为遗留代码。

四、架构设计:流控三部曲

一套鲁棒的流式应用需要解决连接控制、数据解析与平滑渲染三大难题。

1. 竞态控制:AbortController 的“熔断”艺术

AI 正在逐字输出时,若用户因网络抖动失去耐心、连续发送新请求,UI 会瞬间沦为多个流数据混战的“修罗场”。

工程解法:在发起新请求前,强制调用前序请求的 `AbortController.abort()`。这种单路连接占座策略,确保状态机唯一性,规避内存泄露与 UI 竞态。推荐流使用独立的 `recommendController`,通过 `stopRecommendConnection()` 单独中断。

2. 字节流解析:从 Chunk 到有意义的片段

服务端推送的是碎片化的字节包。传输契约大致为:

事件类型(event):标记内容增量(message)、逻辑节点(node_process)或终止符(end)。
数据载体(data):包含 JSON 序列化的文本片段。

核心挑战在于处理非标准的 Unicode 转义,以及协议标记在分片边界被截断的情况——`streamContentFilter` 的缓冲机制正是为此而生,确保中文字符与 `*data_chunk*` 标记不会在随机切分处出现“半字乱码”或“半截标记”。

3. 平滑渲染:掩盖“跳帧”的艺术

直接将 Chunk 丢进 DOM 会引发频繁重排(Reflow)。针对不同后端,实施差异化策略:

真流式后端:累积缓冲区,按帧合并渲染,确保长文输出丝滑。
非流式(DB 检索)后端:前端引入模拟打字机队列(`requestAnimationFrame` 驱动),即便全量返回,也通过逻辑分发模拟“正在思考”的视觉节奏。

五、中断机制:三层协作

“随时停止生成”不仅是 UX 需求,还能节省昂贵的 Token 成本。中断由三层配合完成:

(1)用户层

生成过程中 `isPauseCreate === true` 时显示停止按钮,点击触发 `stopMessage()`。

(2)前端层

设置 `isStopMessage = true`,阻止 `onmessage` 继续追加内容;
调用 `AbortController.abort()` 断开 SSE 连接;
调用 `POST /xxx/message/stop`,传入最后一条消息的 `task_id`,通知服务端停止生成。

(3)回调层

`onmessage` 内多处检查 `isStopMessage`;连接关闭或出错时执行 `cleanupRequest()`,重置 `isAIReplying`、`isFinishedReplying`、`isPauseCreate` 等状态,并通过 `throw err` 阻止 fetch-event-source 自动重连。


六、踩坑实录:停止后文案重复

这是流式项目里最容易被低估的一类 bug:用户点击停止后再次提问,新回复开头重复出现上一次已输出的内容。

现象

停止 → 重新提问 → 新 AI 回复的前几行与上一次停止前的内容完全一致。

根因归纳

1. 缓冲未清理:`stopMessage()` 原先只 abort 连接,未清空 `streamingText` 与 `streamContentFilter` 缓冲;旧分片若在新请求开始后迟到,可能写入新回复。

2. 回调未隔离:新请求会将 `isStopMessage` 置回 `false`,但 abort 后迟到的 `onmessage` 缺少 session 校验,仍可能更新消息列表。

3. answer 格式处理不一致:`type === 'db'` 使用 `data.answer.slice(streamingText.length)`(按累积全文取增量);`type === 'input'` 原先直接 append 整段 `data.answer`(按纯增量处理)。若服务端返回累积全文,每次 append 会把旧内容再拼一遍。

修复措施

已在 `index.vue` 落地以下改动:

引入 `streamSessionId`:每次发起或中断流时递增;`onmessage` 校验 session,丢弃过期分片。
`stopMessage` 增加 `flushStreamingContent()`、`syncStreamingContentToLastAIMessage()`、`resetStreamingState()`。
`cleanupRequest` 增加 `resetStreamingState()`。
新增 `getAnswerDelta()`,兼容「纯增量」与「累积全文」两种 answer 格式。

若修复后仍出现重复,需排查服务端:`stop` 接口是否真正终止任务;新请求的 `message_id` 与首个 `answer` 是否携带旧会话残留内容。


七、从“能跑”到“好用”

在 AI 客服项目中,这套方案带来了显著收益:

TTFT(首字响应时间):从平均 3s 降至 200ms 以内(感知层)。
交互阻断:支持随时通过 UI 中断流式输出,节省 Token 成本。
架构复用:无论后端是翻译、搜索还是 OCR,均共用同一套流控逻辑;`useStreamData` composable 为后续统一接入预留了演进路径。

结语

流式输出的本质,是前端工程师作为"导演",对网络算力与人类感官偏差的一次精准博弈。当技术无法超越物理延迟时,设计巧思便是最后的生产力。

从 `fetch-event-source` 的 POST 选型,到 AbortController 的三层中断,再到 `streamSessionId` 对过期分片的隔离——每一个细节都在回答同一个问题:模型慢可以理解,界面装死不行。 让 AI 学会"一边思索一边倾谈",是我们赋予代码的一份人文温度。

本文属于 无魔法工程流派 的第 5 篇肉身实战。

阅读时长:12 分钟


文档信息

版权声明:自由转载-非商用-非衍生-保持署名(CC BY-NC-ND 3.0)

原文链接:https://yijinlee.com/articles/article-10

作者:李奕锦

商业用途或修改衍生请联系授权。


李奕锦
李奕锦

全栈工程师,业余马拉松选手。

TL;DR

  • 核心价值:流式输出本质是“感知延迟隐藏”,通过连续反馈稀释用户的等待焦虑。
  • 技术选型:fetch-event-source + POST 兼顾 SSE 流式体验与 AI 对话所需的加密 body、FormData、Token 与中断能力。
  • 工程落地:AbortController 三层中断;streamSessionId 隔离过期分片;协议标记拆片缓冲避免半截 marker 露出。
Tags:SSEServer-Sent Events流式输出AbortControllerfetch-event-sourceVue用户体验

该专题下的阅读路径

系统设计原则 → 工程化实践 → 技术选型与重构

常见问题 FAQ

Q1. 为什么选择 SSE 而不是 WebSocket?
AI 对话是典型的半双工通信。SSE 基于 HTTP,实现服务端单向推送,对现有负载均衡和 CDN 友好,无需维护复杂的双向连接状态,是该场景下的“奥卡姆剃刀”方案。
Q2. SSE 不是只能用 GET 吗?为什么项目用 POST?
协议本身不限定 HTTP 方法。原生 EventSource 仅支持 GET、不能带 body,才给人“SSE = GET”的印象。AI 对话需要加密 body、FormData 上传、自定义 Header 与 AbortController 中断,因此采用 fetchEventSource + POST,响应类型仍为 text/event-stream。
Q3. 点击停止后再次提问,新回复开头重复旧内容怎么办?
常见三重原因:缓冲未清理(streamingText 与 filter 缓冲残留)、回调未隔离(abort 后迟到的 onmessage 仍写入)、answer 格式处理不一致(累积全文被当作纯增量 append)。修复方向:streamSessionId 校验分片归属、stop/cleanup 时 resetStreamingState、getAnswerDelta 兼容两种 answer 格式。

发表评论

分享你的想法和反馈

支持 Markdown 格式

0/5000