你是否曾有过这样的焦虑:在对话框输入指令后,盯着屏幕上那动也不动的“加载中”图标,每一秒的等待都显得漫长而枯燥,直到回复像整块砖头一样突然“啪”地弹出?
在 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 限制:
因此项目采用 `@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 场景 |
周边模块分工清晰:
四、架构设计:流控三部曲
一套鲁棒的流式应用需要解决连接控制、数据解析与平滑渲染三大难题。
1. 竞态控制:AbortController 的“熔断”艺术
AI 正在逐字输出时,若用户因网络抖动失去耐心、连续发送新请求,UI 会瞬间沦为多个流数据混战的“修罗场”。
工程解法:在发起新请求前,强制调用前序请求的 `AbortController.abort()`。这种单路连接占座策略,确保状态机唯一性,规避内存泄露与 UI 竞态。推荐流使用独立的 `recommendController`,通过 `stopRecommendConnection()` 单独中断。
2. 字节流解析:从 Chunk 到有意义的片段
服务端推送的是碎片化的字节包。传输契约大致为:
核心挑战在于处理非标准的 Unicode 转义,以及协议标记在分片边界被截断的情况——`streamContentFilter` 的缓冲机制正是为此而生,确保中文字符与 `*data_chunk*` 标记不会在随机切分处出现“半字乱码”或“半截标记”。
3. 平滑渲染:掩盖“跳帧”的艺术
直接将 Chunk 丢进 DOM 会引发频繁重排(Reflow)。针对不同后端,实施差异化策略:
五、中断机制:三层协作
“随时停止生成”不仅是 UX 需求,还能节省昂贵的 Token 成本。中断由三层配合完成:
(1)用户层
生成过程中 `isPauseCreate === true` 时显示停止按钮,点击触发 `stopMessage()`。
(2)前端层
(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` 落地以下改动:
若修复后仍出现重复,需排查服务端:`stop` 接口是否真正终止任务;新请求的 `message_id` 与首个 `answer` 是否携带旧会话残留内容。
七、从“能跑”到“好用”
在 AI 客服项目中,这套方案带来了显著收益:
结语
流式输出的本质,是前端工程师作为"导演",对网络算力与人类感官偏差的一次精准博弈。当技术无法超越物理延迟时,设计巧思便是最后的生产力。
从 `fetch-event-source` 的 POST 选型,到 AbortController 的三层中断,再到 `streamSessionId` 对过期分片的隔离——每一个细节都在回答同一个问题:模型慢可以理解,界面装死不行。 让 AI 学会"一边思索一边倾谈",是我们赋予代码的一份人文温度。
本文属于 无魔法工程流派 的第 5 篇肉身实战。
发表评论
分享你的想法和反馈