---
title: "让 AI 开口说话：从“整段等待”到“流式反馈”的架构演进"
date: 2026-03-02
description: "以 chat-h5-demo AI 对话为样本，复盘 SSE 流式输出的完整链路：为何选用 fetch-event-source + POST、AbortController 如何三层协作实现中断、停止后文案重复的三重根因与 session 隔离修复，以及协议标记拆片时的缓冲策略。"
canonical_url: "https://yijinlee.com/articles/article-10"
tags:
  - SSE
  - "Server-Sent Events"
  - 流式输出
  - AbortController
  - fetch-event-source
  - Vue
  - 用户体验
topic: "架构之道"
---

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



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



本文以 **chat-h5-demo** 项目的 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**（如 `payload.encryptedBody`）、**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`，对接以下接口：



| 接口 | 场景 |

| `/api/v1/demo/chat/message` | 普通对话 |

| `/api/v1/demo/chat/recommend` | 推荐流 |

| `/api/v1/demo/chat/translate` | 翻译 |

| `/api/v1/demo/chat/vision` | 图片/OCR/菜单等 FormData 场景 |



周边模块分工清晰：



- **`streamFilter.ts`**：过滤协议标记 `__STREAM_MARKER__`，并在 marker 被拆分到多个 SSE 分片时做缓冲，避免半截标记直接展示给用户。

- **`composables/useStreamData.ts`**：重构版 composable，逻辑更完整，但当前主页面尚未接入，可作为后续演进方向。

- **`request.js` 中的 `createSSEConnection()`**：基于原生 EventSource 封装，**未被引用**，可视为遗留代码。



---



### 四、架构设计：流控三部曲



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



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



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



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



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



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



- **事件类型（event）**：标记内容增量（message）、逻辑节点（node_process）或终止符（end）。

- **数据载体（data）**：包含 JSON 序列化的文本片段。



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



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



直接将 Chunk 丢进 DOM 会引发频繁重排（Reflow）。针对不同后端，实施差异化策略：



- **真流式后端**：累积缓冲区，按帧合并渲染，确保长文输出丝滑。

- **非流式（DB 检索）后端**：前端引入模拟打字机队列（`requestAnimationFrame` 驱动），即便全量返回，也通过逻辑分发模拟“正在思考”的视觉节奏。



---



### 五、中断机制：三层协作



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



**（1）用户层**



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



**（2）前端层**



- 设置 `isStopMessage = true`，阻止 `onmessage` 继续追加内容；

- 调用 `AbortController.abort()` 断开 SSE 连接；

- 调用 `POST /api/v1/demo/chat/stop`，传入最后一条消息的 `task_id`，通知服务端停止生成。



**（3）回调层**



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



---



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



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



#### 现象



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



#### 根因归纳



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

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 学会"一边思索一边倾谈"，是我们赋予代码的一份人文温度。

## 参考文章：相关链接

- [MDN · Server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)（官方文档）— SSE 使用指南：事件流、重连与缓冲，支撑从整段等待到流式反馈。
- [WHATWG · Fetch Living Standard (streams)](https://fetch.spec.whatwg.org/#fetching)（权威引文）— Fetch / Stream 标准语境，理解流式传输的浏览器契约。
- [线上流式 FAQ"首尾截断"根因剖析与架构重构实践](https://yijinlee.com/articles/article-56)（站内深度）— 流式反馈落地后的截断故障与分层重构。
- [破局流式传输占位符泄露：基于多模型协作的前端重构深度复盘](https://yijinlee.com/articles/article-49)（站内深度）— 流式协议与 UI 占位符泄露的协同排障。

