A10第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

流式协议与事件

流式协议与事件 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

AI 聊天界面那个「打字机效果」不是特效,是工程。本课拆开它:数据从模型到浏览器经过哪些环节,事件有哪几种,怎么知道「流结束了」和「流断了」。

问题场景

一次模型回答可能要 20 秒。如果等完整响应再显示,用户盯着空白 20 秒,大概率以为坏了。流式输出让第一个字 1 秒内就出现,体感差距是数量级的。但流式也带来新问题:前端怎么消费一段「还没传完」的文本?中途出错了怎么办?用户点「停止」之后,管道里已经发出的半截内容算什么?

核心解释

主流方案是 SSE(Server-Sent Events):一个普通的 HTTP 响应,Content-Type: text/event-stream,服务器持续往里写文本行,浏览器按事件解析。它的好处是单向、简单、走标准 HTTP——对「模型 → 用户」这个方向的输出刚好够用。

SSE 的报文格式长这样:

data: {"type":"text","delta":"今天"}

data: {"type":"text","delta":"我们聊聊"}

data: {"type":"done"}

每个事件由 data: 行组成,空行分隔。本项目约定一套结构化事件协议(不要让前端猜字符串含义):

type 含义 载荷
start 开始生成 消息 id、模型名
text 文本增量 delta:这一小段新增文本
refs 课程引用 引用列表,可渲染为链接
tool 工具状态 工具名、running/done/error
done 正常结束 用量统计(tokens)
error 失败 错误类别 + 可读信息

两条铁律:

  1. 前端只信事件类型,不信文本内容。即使 delta 里出现"忽略之前的指令",它也只是要显示的文本,不是指令。
  2. 结束必须有明确信号。要么 done,要么 error。连接断了(服务器崩溃、断网)前端要在 catch 里显式标记「流中断」,保留已收到的部分并提示重试——绝不假装正常结束。

示例拆解

服务端(Route Handler)把一段静态文本假装成模型流,用于在没有密钥时调试管道:

// app/api/ai/demo-stream/route.ts
export async function GET() {
  const encoder = new TextEncoder();
  const chunks = ["流式", "输出的", "关键", "是事件协议"];
  const stream = new ReadableStream({
    async start(controller) {
      for (const c of chunks) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ type: "text", delta: c })}\n\n`)
        );
        await new Promise((r) => setTimeout(r, 300)); // 模拟模型逐字生成
      }
      controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "done" })}\n\n`));
      controller.close();
    },
  });
  return new Response(stream, {
    headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
  });
}

浏览器端消费(用 fetch 而不是 EventSource,因为聊天要 POST 请求体,EventSource 只支持 GET):

const res = await fetch("/api/ai/demo-stream");
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  // SSE 事件以空行分隔,逐个解析
  const parts = buf.split("\n\n");
  buf = parts.pop()!; // 最后一段可能不完整,留给下一轮
  for (const part of parts) {
    const line = part.trim();
    if (!line.startsWith("data:")) continue;
    const event = JSON.parse(line.slice(5));
    if (event.type === "text") appendDelta(event.delta);
    if (event.type === "done") finish();
  }
}

关键点:

  • buf 缓冲区是必须的:TCP 分包不保证一次拿到完整事件,按空行切分、余料留到下一轮。
  • decoder.decode(value, { stream: true }) 处理多字节中文字符被分包切断的情况。
  • 停止生成:AbortController + reader.cancel(),服务端监听 request.signal 提前退出循环(A12 课展开)。

动手任务

本课实验区里是一个纯前端的事件流模拟器:点击「开始」后,它逐个派发协议事件。你的任务:

  1. 观察事件序列,说出 done 出现之前至少需要哪几类事件。
  2. 点「制造断流」,观察界面如何表现——已收到的文本是否保留、有没有错误提示。
  3. (本机)把上面的 demo-stream 跑起来,用 curl -N http://localhost:3000/api/ai/demo-stream 肉眼看 SSE 报文。

常见错误

  • 把整个响应当一个字符串追加:没有按事件切分,中文被分包切断时界面出现乱码。
  • 用 EventSource 发聊天请求:它只支持 GET 且不能带自定义头,认证和请求体都受限,聊天场景用 fetch 流。
  • 流中断后不清理 UI 状态:光标还在闪、按钮还转圈。finally 块里必须复位状态。
  • 把 error 事件渲染成正文:错误是控制信号,要走错误 UI(红色提示 + 重试按钮),不是往消息里塞红字。

检查与复盘

完成下方检查题。自查清单:

  • 能画出「模型 → 服务端适配层 → SSE → 浏览器」的完整链路并标注每段的事件格式
  • 说出至少 4 种事件类型及各自用途
  • 知道为什么聊天用 fetch 流而不是 EventSource

参考资料

检查题共 3 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1单选简单

本站流式协议(SSE,data: 行事件)约定了 start/text/refs/tool/done/error 几类事件。关于「流怎么算结束」,符合协议铁律的是?

Q2读代码较难

阅读浏览器端消费 SSE 的代码。其中 buf 缓冲区与 parts.pop() 这两行的真正作用是什么?

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  const parts = buf.split("\n\n");
  buf = parts.pop(); // 最后一段可能不完整,留给下一轮
  for (const part of parts) {
    const line = part.trim();
    if (!line.startsWith("data:")) continue;
    const event = JSON.parse(line.slice(5));
    if (event.type === "text") appendDelta(event.delta);
    if (event.type === "done") finish();
  }
}
Q3单选简单

本站约定一套结构化事件协议(start/text/refs/tool/done/error),而不是让前端猜字符串含义。下列哪个事件序列是一次健康的、触发了课程检索的流式回答?(事件载荷从略)

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...