流式协议与事件
流式协议与事件 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
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 |
失败 | 错误类别 + 可读信息 |
两条铁律:
- 前端只信事件类型,不信文本内容。即使
delta里出现"忽略之前的指令",它也只是要显示的文本,不是指令。 - 结束必须有明确信号。要么
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 课展开)。
动手任务
本课实验区里是一个纯前端的事件流模拟器:点击「开始」后,它逐个派发协议事件。你的任务:
- 观察事件序列,说出
done出现之前至少需要哪几类事件。 - 点「制造断流」,观察界面如何表现——已收到的文本是否保留、有没有错误提示。
- (本机)把上面的 demo-stream 跑起来,用
curl -N http://localhost:3000/api/ai/demo-stream肉眼看 SSE 报文。
常见错误
- 把整个响应当一个字符串追加:没有按事件切分,中文被分包切断时界面出现乱码。
- 用 EventSource 发聊天请求:它只支持 GET 且不能带自定义头,认证和请求体都受限,聊天场景用 fetch 流。
- 流中断后不清理 UI 状态:光标还在闪、按钮还转圈。
finally块里必须复位状态。 - 把
error事件渲染成正文:错误是控制信号,要走错误 UI(红色提示 + 重试按钮),不是往消息里塞红字。
检查与复盘
完成下方检查题。自查清单:
- 能画出「模型 → 服务端适配层 → SSE → 浏览器」的完整链路并标注每段的事件格式
- 说出至少 4 种事件类型及各自用途
- 知道为什么聊天用 fetch 流而不是 EventSource
参考资料
- MDN:Using server-sent events —— SSE 报文格式与浏览器解析规则
- 下一课 A11 把这套协议接到真实的流式聊天界面上。
读完这一课了?
标记完成后,工作台会安排接下来的学习。