🏠 总目录📚 本教程 10 · 把流式接到界面
📑 本页目录(点开跳转)

10 · 把流式接到界面上

62 分钟 | ⭐⭐ 换任何前端框架,这一章的难点都还在


🎯 一句话

后端把字一个个吐出来了(第 5 章),这一章讲浏览器怎么把它们接住、拼起来、显示出去 —— 以及在这条路上会撞到的五个坑。

⭐ 上一章说过:流式相关的难点,换成 React 或 Vue 一个都不会消失, 因为它们是浏览器和 HTTP 层的问题。这一章就是那些问题。


🚧 一、第一个坑:EventSource 用不了

浏览器有个专门收 SSE 的内置 API,叫 EventSource。看起来完美:

const es = new EventSource('/api/chat');
es.onmessage = (e) => { /* 每帧一次 */ };

但它在真实应用里基本用不上,因为三个硬限制:

限制 后果
⚠️⚠️ 不能自定义 header 带不了 Authorization: Bearer ... —— 你的接口一加鉴权就废了
只能发 GET 用户的问题、历史消息只能塞 URL 里,⚠️ 长度有限、还会进日志
不能自定义 body 同上

⭐ 有人会说「那把 token 放 Cookie 或 query 参数」—— ⚠️ query 参数里的 token 会被记进服务器访问日志、代理日志、浏览器历史,这是个真实的泄露面。

⭐⭐ 所以实践中的标准做法是:fetch + ReadableStream 手动解析。 多写二十行,但 header、method、body 全都由你控制。


🔧 二、fetch + ReadableStream:手动收流

后端按 SSE 格式吐(第 1 章第 5 章定的全板块统一帧格式: 每帧一行 data: {JSON},帧间空一行,帧的类型放在 JSON 负载的 type 字段里——delta / usage / error / done)。 浏览器这边要做三件事:读字节 → 解码成文本 → 按空行切帧

async function streamChat(messages, onFrame, signal) {
  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + getToken(),   // ⭐ EventSource 做不到的就是这个
    },
    body: JSON.stringify({ messages }),
    signal,                                       // ⭐ 用于「停止生成」,见第五节
  });

  if (!res.ok) throw new Error('HTTP ' + res.status);

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    // ⚠️ stream: true —— 一个多字节字符可能被切在两个 chunk 中间
    buffer += decoder.decode(value, { stream: true });

    // ⚠️ 按【空行】切帧,不是按 chunk 切:一个 chunk 可能含半帧或三帧
    let idx;
    while ((idx = buffer.indexOf('\n\n')) !== -1) {
      const raw = buffer.slice(0, idx);
      buffer = buffer.slice(idx + 2);
      for (const line of raw.split('\n')) {
        // ⭐ 只认 data: 行;每帧都是合法 JSON,所以解析路径只有这一条
        if (line.startsWith('data: ')) onFrame(JSON.parse(line.slice(6)));
      }
    }
  }
}

⭐⭐ 注意这个解析器只认 data: 行——这正是帧格式那样定的原因。 SSE 协议里还有一个 event: 行可以给帧起名字,但它只对 EventSource 有意义,而 EventSource 在这一章第一节就被否掉了。手动解析器面对 event: 行只有两条路:要么多写一套状态机记住「上一行的 event 名是什么」再和下一行的 data: 配对,要么像这里一样把它整行丢掉——两条都是白费力气。

所以把类型放进 JSON 负载,是「跟着消费方走」的选择JSON.parse 一行就拿到全部信息。 ⚠️ 同理结束帧是 {"type":"done"} 而不是 [DONE]——[DONE] 不是合法 JSON,会让上面这行 JSON.parse 当场抛 SyntaxError,逼你在解析前加一次特判。⭐ 格式选择要看谁在消费它,这是本章和第 5 章共同的那条线。

⚠️⚠️ 这段代码里还有两个新手必栽的坑,都在注释里,值得展开:

坑 A:decoder.decode(value, { stream: true })stream: true 不能省

网络会在任意字节位置切开数据。一个中文字符是 3 个字节, 完全可能第一个 chunk 结尾是它的前 2 字节、下一个 chunk 开头是第 3 字节。

⚠️ 不加 stream: trueTextDecoder 会把那半个字符解成 (替换字符)。 ⭐ 加上之后它会把不完整的字节留到下次

💀 这个 bug 的可怕之处在于它是间歇性的 —— 中文/emoji 多的时候偶尔出现一个乱码字, 你会以为是模型的问题。

坑 B:一个 chunk ≠ 一帧

reader.read() 给你的是网络层的分块,和你的 SSE 帧完全没有对应关系。 一次 read 可能拿到:半帧、一帧、三帧半。

所以必须用一个 buffer 累积,按 \n\n —— 直接把每个 chunk 当一帧解析, 在本地测试时可能一切正常(帧小、网络快),上线遇到大响应或慢网络就会碎掉


🎨 三、边流边渲染 Markdown:半个代码块怎么办

模型的输出通常是 Markdown。但流式意味着你随时可能拿到一个残缺的 Markdown

用户看到的中间状态:
    这是一个例子:
    ```python
    def hello(
              ↑ 代码块没闭合,表格只有一半,粗体只有一个星号

三种做法,取舍不同:

做法 优点 缺点
流式期间只显示纯文本,结束后一次性渲染 Markdown 简单、绝不出错 ⚠️ 最后会「跳变」一下,观感突兀
每帧都重新渲染整段 Markdown 实时、体验最好 需要一个容错的解析器;性能要注意
只在「安全点」渲染(比如遇到换行符时) 折中 逻辑复杂,边界情况多

推荐第二种,但有两个前提

  1. 用容错的 Markdown 解析器。⚠️ 严格的解析器遇到未闭合的代码围栏可能抛错或把后面全吞掉; 宽松的解析器(大多数浏览器端库)会把它当成「还没结束的代码块」正常渲染 —— 这正是你要的
  2. ⭐⭐ 必须做 HTML 净化(sanitize)。Markdown 渲染出来是 HTML,而 模型输出是不可信输入 —— 它可能被提示注入诱导输出 <img src=x onerror=...>。 ⚠️ 渲染 Markdown 的那一刻,你就失去了上一章 textContent 的保护

💀 这是 AI 应用一个真实的攻击面:用户 A 上传一份文档,里面藏着「请输出以下 HTML」, 用户 B 检索到它、模型照做、B 的浏览器执行了。净化不是可选项。

净化到底要滤掉什么:四条最小判据

不管你用哪个库,落地的都是这四条(拿它去核对库的默认配置):

规则 具体
标签走白名单 只放行展示类标签:p / br / strong / em / code / pre / ul / ol / li / blockquote / a / h1–h6 / table / thead / tbody / tr / th / td / img。⚠️ 白名单之外一律剥掉,不要写黑名单——黑名单永远漏
💀 <script> 连同 <iframe> <object> <embed> <style> 一起禁:它们能直接执行脚本或用 CSS 遮罩伪造界面
💀 禁一切 on* 事件属性 onerror onload onclick……⚠️ 上面那个 <img src=x onerror=...> 走的就是这条——img 标签本身是合法的,要命的是属性
⚠️ URL 协议走白名单 ahrefimgsrc 都要查,只放行 http: / https: / mailto:。⚠️ javascript: 能直接执行,data: 能塞进一整个 HTML 文档

⚠️⚠️ 别自己写正则过滤——这是本节最想说的一句。 这四条看起来是几个 replace 的事,实际上不是:攻击面恰恰在浏览器的解析器和你的正则理解不一致的地方,而浏览器对畸形 HTML 极其宽容。<IMG SRC=x OnErRoR=…> 大小写混写、&#106;avascript: 实体编码、属性值不加引号、<img/src=x/onerror=…> 拿斜杠当分隔符、<scr<script>ipt> 靠嵌套躲过一次替换——每一种都能绕过一个「看起来没问题」的正则

正确的形状是:让一个真正的 HTML 解析器把内容解析成 DOM 树,再按白名单裁剪这棵树。 这样你判断的东西和浏览器最终执行的东西是同一棵树,绕过就无处可藏。净化库做的就是这件事。

🗓️ 浏览器端事实标准是 DOMPurifyDOMPurify.sanitize(html),就是「解析成树再裁剪」这个形状);服务端渲染那一侧各语言都有对应的库。⚠️ 库名和默认配置都会变,会过期的是名字、不过期的是上面那四条判据。

⚠️ 还有一个位置问题净化必须是写进 innerHTML 之前的最后一步。先净化、再拼字符串(比如再接一段「引用来源」)等于白做——拼接之后没人再检查过。

性能上的一个实用技巧:⭐ 不要每帧都重绘整个消息列表,只更新最后一条:

// ❌ 每帧全量重绘:一秒几十次,还会打断用户选中文本
function onFrame(f) { messages.at(-1).content += f.text; render(); }

// ✅ 只更新最后一个 DOM 节点
function onFrame(f) {
  messages.at(-1).content += f.text;                    // ⭐ 帧格式见第六节:delta 帧的正文在 f.text
  // ⭐ sanitize 由净化库提供(🗓️ 浏览器端常用 DOMPurify.sanitize)
  // ⚠️ 顺序不能反:先 renderMarkdown 再 sanitize,且 sanitize 是进 innerHTML 前的最后一步
  lastNode.innerHTML = sanitize(renderMarkdown(messages.at(-1).content));
  maybeScroll();
}

🛑 读到这里可以停 —— 前半章讲完了(约 25 分钟)。 后半章还有:滚动跟随:判断「用户还在跟读吗」 · 停止生成:前端 abort + 后端要能感知 · 流到一半失败:界面上已经有半句话了 · 断线重连:⚠️ 别自动重发 · 换个栈怎么对应 回来的时候不用重读,直接从下一节接着看就行。


📜 四、滚动跟随:判断「用户还在跟读吗」

上一章给了判据,这里是完整实现和一个额外的坑。

const NEAR_BOTTOM = 80;   // 像素
let following = true;     // ⭐ 用一个状态记住「用户是否在跟读」

box.addEventListener('scroll', () => {
  // ⚠️ 用户手动往上滚 → 停止跟随;滚回底部 → 恢复跟随
  following = box.scrollHeight - box.scrollTop - box.clientHeight < NEAR_BOTTOM;
});

function maybeScroll() {
  if (following) box.scrollTop = box.scrollHeight;
}

⚠️ 额外的坑:你自己的自动滚动也会触发 scroll 事件。 如果判断写得不对,会出现「自动滚一下 → 事件触发 → 误判用户在操作 → 停止跟随」的诡异现象。 ⭐ 上面这个写法之所以没事,是因为自动滚之后必然处于底部,判据仍然为 true

💡 加一个「回到底部」按钮:当 following === false 且有新内容时显示。 ⭐ 这比强行拽回去友好得多 —— 把控制权交还给用户。


⏹ 五、停止生成:前端 abort + 后端要能感知

用户点「停止」,两件事都要做到:

// 发起时
controller = new AbortController();
streamChat(messages, onFrame, controller.signal);

// 停止按钮
function stop() {
  controller?.abort();          // ⭐ 这会让 fetch 抛出 AbortError
  isStreaming = false;
}

⚠️ abort() 会让 await reader.read() 抛异常,所以收流的地方要接住:

try {
  await streamChat(...);
} catch (e) {
  if (e.name === 'AbortError') {
    // ⭐ 用户主动停止,不是错误 —— 保留已生成的内容,不显示错误提示
  } else {
    messages.at(-1).error = true;   // 真的出错了,见下一节
  }
}

⭐⭐ 但前端 abort 只解决了一半问题。 浏览器断开连接,后端如果不检测,还会继续调 LLM 把剩下几百个 token 生成完 —— 用户看不到,钱照付。第 5 章讲了后端怎么感知断连。 两边都做了,「停止」才是真的停止。


💔 六、流到一半失败:界面上已经有半句话了

⚠️ 这是 AI 应用独有的状态:既不是成功,也不是失败。

而且有个技术上的硬约束(第 5 章会讲): ⭐⭐ HTTP 头已经发出去了(200 OK),后端没法再改成 500 —— 所以错误只能作为流里的一帧传过来

这就是为什么第 1 章一开始就要求分帧而不是吐裸文本:

function onFrame(f) {
  switch (f.type) {
    case 'delta': messages.at(-1).content += f.text; break;
    case 'error':                                    // ⭐ 错误作为一帧
      messages.at(-1).error = f.message;
      messages.at(-1).traceId = f.trace_id;          // ⭐ 显示出来,用户才报得了
      break;
    case 'usage':                                    // 用量,用于成本显示
      messages.at(-1).usage = f;
      break;
    case 'done': isStreaming = false; break;
  }
  renderLast();
}

界面上三件事都要做到上一章提过,这里是落地):

做什么 为什么
保留已收到的内容 那几行字是有价值的,清空等于白花了钱
明确标出「这条不完整」 否则用户以为模型就说了这么多
重试要给两个选项 接着说」(把已有内容作为上文)和「重新说」—— 用户想要的往往是前者
⭐⭐ trace_id 显示出来 一小行灰字就够(出错了(a1b2c3d4e5f6))。⚠️ 不显示它,用户能告诉你的就只有「刚才崩了」

⭐⭐ trace_id 这条在流式里特别容易漏,值得单独说:非流式出错时它在那个 500 的 JSON 里,前端顺手就显示了;⚠️ 而流式的状态码在第一个字发出去时就定死成 200 了,那个 JSON 根本不会发出来 —— 所以它只能作为错误帧的一个字段送过来(05 章那一段),前端也得专门去接第 3 章立的原则是「每个错误带 trace_id 并同时写进日志」,第 16 章的验收项也是照着它写的 —— ⚠️ 漏在流式这条路上,那条验收项在你的主路径上就是空的。


🔌 七、断线重连:⚠️ 别自动重发

EventSource 有内置的自动重连。而你手写的 fetch 版本没有 —— ⭐ 这其实是好事

⚠️⚠️ AI 应用里自动重发是危险的:每次重发都是一次真金白银的新调用。 网络抖动时自动重发三次,你就付了三次钱,用户还可能看到三段重复的回答。

正确做法:失败后停下来,把重试按钮交给用户。 如果一定要自动重试,⚠️ 必须配合幂等键第 11 章讲), 让服务端能识别「这是同一次请求的重试」而不是新请求。


🔄 八、换个栈怎么对应

概念 原生 JS(本章) React Vue
收流 fetch + getReader() 一模一样 一模一样
拼帧与解码 手写 buffer + TextDecoder 一模一样 一模一样
只更新最后一条 手动改那个 DOM 节点 key 稳定时框架自动局部更新 同左
停止生成 AbortController 一模一样 一模一样
滚动跟随 手写 following 状态 ⚠️ 一样要手写 ⚠️ 一样要手写
Markdown 净化 自己接一个净化库 ⚠️ 一样要接 ⚠️ 一样要接

⭐⭐ 六行里只有一行是框架能帮上忙的。 这不是说框架没用 —— 而是说 AI 应用前端的难点不在 UI 层,在流式协议和不可信内容上。 换框架解决不了它们。


🔗 这一章连到哪里

去哪 为什么
⭐⭐ 05 · 流式输出 后端那一半。⭐ 本章的解析逻辑完全依赖那一章定的帧格式;「错误只能作为流里的一帧」和「后端怎么感知断连」也在那里
09 · 最小可用前端 本章的骨架、状态结构、textContent 的安全原则都来自那一章
01 · 第一天:两小时上线 ⭐ 那一章一开始就用 SSE 分帧而不是吐裸文本 —— 回头看会明白那个决定是为了本章的第六节
11 · 长任务与队列 第七节说的幂等键在那一章 —— 想做自动重试就必须先有它
12 · 限流配额与成本护栏 ⚠️ 前端自动重发是烧钱的一个典型来源,那一章讲怎么在服务端兜住
../智能体工程教程/15-安全-沙箱与提示注入.html ⭐ 第三节那个「文档里藏 HTML → 模型照做 → 别人浏览器执行」是间接提示注入的一个具体形态,那一章讲这类攻击的全貌

✅ 检查点

  1. EventSource 有哪三个硬限制?其中哪一个最致命?
  2. 「把 token 放 query 参数」为什么不行?
  3. decoder.decode(value, { stream: true }) 里的 stream: true 不加会怎样?为什么这个 bug 特别难查?
  4. 为什么必须用 buffer 按 \n\n 切帧,而不能把每个 chunk 当一帧?本章的解析器为什么只认 data: 行、不处理 event: 行?结束帧为什么不能写 [DONE]
  5. 边流边渲染 Markdown 有哪三种做法?推荐哪种、前提是什么?
  6. ⭐ 为什么渲染 Markdown 时必须做 HTML 净化?举一个具体的攻击路径。净化的四条最小判据是什么?为什么自己写正则不够?
  7. 滚动跟随有个「自己触发自己」的坑,是什么?本章的写法为什么没事?
  8. 前端 abort() 之后,为什么还不算真的停止?
  9. ⭐ 流到一半失败时,后端为什么不能返回 500?这个约束导致了什么设计?
  10. 为什么说「手写的 fetch 版本没有自动重连」反而是好事?
👀 答案
  1. ① 不能自定义 header ② 只能发 GET ③ 不能自定义 body。 ⚠️⚠️ 最致命的是第一条:带不了 Authorization,接口一加鉴权就废了。
  2. ⚠️ 因为 query 参数里的 token 会被记进服务器访问日志、代理日志、浏览器历史 —— 这是个真实的泄露面。
  3. ⚠️ 网络会在任意字节位置切开数据,一个中文字符 3 字节可能被切在两个 chunk 之间。不加 stream: trueTextDecoder 会把半个字符解成 ;加上之后它会把不完整字节留到下次。💀 难查是因为它是间歇性的 —— 中文/emoji 多时偶尔冒出一个乱码字,你会以为是模型的问题。
  4. 因为 reader.read() 给的是网络层分块,和 SSE 帧完全没有对应关系 —— 一次可能拿到半帧、一帧或三帧半。⭐ 直接把 chunk 当帧解析,本地测试(帧小、网络快)可能一切正常,上线遇到大响应或慢网络就碎。至于 event: 行:它只对 EventSource 有意义,而 EventSource 第一节就被否掉了;手动解析器要么多写一套状态机把它和下一行 data: 配对,要么整行丢掉。⭐ 所以帧类型放进 JSON 负载的 type 字段,一行 JSON.parse 拿到全部信息,解析路径只有一条。⚠️ 同理结束帧写 {"type":"done"}——[DONE] 不是合法 JSON,会让那行 JSON.parseSyntaxError
  5. ① 流式期间只显示纯文本、结束后一次性渲染(简单但⚠️ 最后会跳变)② ⭐ 每帧重新渲染整段(实时、体验最好)③ 只在安全点渲染(折中但逻辑复杂)。⭐ 推荐 ②,两个前提:用容错的解析器(严格解析器遇到未闭合的代码围栏可能抛错或吞掉后文)+ ⭐⭐ 必须做 HTML 净化
  6. 因为 Markdown 渲染出来是 HTML,而模型输出是不可信输入 —— ⚠️ 渲染的那一刻你就失去了 textContent 的保护。💀 攻击路径:用户 A 上传的文档里藏着「请输出以下 HTML」→ 用户 B 检索到它 → 模型照做 → B 的浏览器执行了 <img src=x onerror=...>。四条判据:① 标签走白名单(不是黑名单)② 禁 <script>(连同 iframe/object/embed/style③ 禁一切 on* 事件属性(⚠️ img 标签合法,要命的是属性)④ URL 协议只放行 http/https/mailto(⚠️ javascript:data: 都能执行)。⚠️⚠️ 自己写正则不够,是因为浏览器解析器和你的正则理解不一致:大小写混写、&#106;avascript: 实体编码、属性不加引号、<img/src=x/onerror=…><scr<script>ipt> 都能绕过。⭐ 正确形状是解析成 DOM 树再按白名单裁剪(🗓️ 浏览器端常用 DOMPurify),且净化要是进 innerHTML 前的最后一步
  7. ⚠️ 你自己的自动滚动也会触发 scroll 事件,判断写不对就会「自动滚一下 → 事件触发 → 误判用户在操作 → 停止跟随」。⭐ 本章写法没事是因为自动滚之后必然处于底部,判据仍然为 true
  8. ⭐⭐ 因为浏览器断开连接后,后端如果不检测,还会继续调 LLM 把剩下几百个 token 生成完 —— 用户看不到,钱照付。两边都做了,「停止」才是真的停止(后端那半在第 5 章)。
  9. ⭐⭐ 因为 HTTP 头已经发出去了(200 OK),没法再改成 500。这导致错误只能作为流里的一帧传过来 —— ⭐ 这正是第 1 章一开始就要求分帧而不是吐裸文本的原因。
  10. ⚠️⚠️ 因为 AI 应用里自动重发是危险的:每次重发都是一次真金白银的新调用,网络抖动时自动重发三次就付三次钱,用户还可能看到三段重复回答。⭐ 正确做法是停下来把重试按钮交给用户;一定要自动重试的话,⚠️ 必须配合幂等键让服务端识别「这是重试不是新请求」。

🛑 可以停在这里

走神救援

⭐⭐ 这一章讲浏览器怎么接住流,而它的难点换任何框架都不会消失 —— 因为都是浏览器和 HTTP 层的问题。坑一:EventSource 用不了,三个硬限制(⚠️⚠️ 不能自定义 header 所以带不了 Authorization、只能 GET、不能自定义 body);⚠️ 把 token 放 query 参数也不行 —— 会被记进服务器日志、代理日志、浏览器历史。⭐ 标准做法是 fetch + ReadableStream 手动解析,多写二十行换来完全的控制权。收流要做三件事:读字节 → 解码 → 按空行切帧,其中两个必栽的坑:⚠️ decoder.decode(value, {stream: true})stream: true 不能省 —— 网络在任意字节位置切开,一个中文 3 字节可能被切两半,不加就解成 ;💀 难查是因为它间歇性出现,你会以为是模型的问题。⚠️ 一个 chunk ≠ 一帧 —— read() 给的是网络分块,和 SSE 帧毫无对应关系,必须用 buffer 按 \n\n 切;直接把 chunk 当帧,本地测试正常、上线遇到大响应就碎。⭐⭐ 帧格式(全板块统一):每帧一行 data: {JSON},帧类型放在负载的 type 字段里delta / usage / error / done,错误帧字段是 message)——⚠️ 不用 SSE 的 event:,因为它只对 EventSource 有意义,手动解析器只能多写状态机或整行丢掉;⚠️ 结束帧写 {"type":"done"} 不写 [DONE],后者不是合法 JSON 会让 JSON.parseSyntaxError。⭐ 格式要跟着消费方走边流边渲染 Markdown 三种做法(结束后一次性渲染 / ⭐ 每帧重渲 / 只在安全点渲),推荐每帧重渲但有两个前提:用容错解析器(严格的遇到未闭合的代码围栏会抛错或吞掉后文)+ ⭐⭐ 必须做 HTML 净化 —— Markdown 渲染出来是 HTML,而模型输出是不可信输入,⚠️ 渲染那一刻你就失去了 textContent 的保护。💀 真实攻击路径:A 上传的文档里藏「请输出以下 HTML」→ B 检索到 → 模型照做 → B 的浏览器执行。⭐ 净化的四条最小判据标签走白名单(不是黑名单)、<script>(连同 iframe/object/embed/style)、禁一切 on* 事件属性(⚠️ img 标签合法,要命的是属性)、URL 只放行 http/https/mailto(⚠️ javascript:data: 都能执行)。⚠️⚠️ 别自己写正则 —— 大小写混写、实体编码、<img/src=x/onerror=…><scr<script>ipt> 都能绕过,⭐ 正确形状是解析成 DOM 树再按白名单裁剪(🗓️ 浏览器端常用 DOMPurify),且净化要是进 innerHTML 前的最后一步。性能上 ⭐ 别每帧重绘整个列表,只更新最后一条滚动跟随用一个 following 状态,⚠️ 注意「自己的自动滚也会触发 scroll 事件」这个自我干扰的坑(本章写法没事是因为自动滚后必然在底部);💡 加一个「回到底部」按钮比强行拽回去友好。停止生成AbortController + 接住 AbortError(⭐ 用户主动停止不是错误,要保留已生成内容);⭐⭐ 但前端 abort 只解决一半 —— 后端不检测断连的话还会继续把剩下几百个 token 生成完,用户看不到、钱照付流到一半失败是 AI 独有的状态,而且有个硬约束:⭐⭐ HTTP 头已经发出去了(200 OK),后端没法改成 500,所以错误只能作为流里的一帧 —— 这正是第 1 章一开始就要求分帧而不是吐裸文本的原因。界面上三件事:保留已收到的内容标出不完整、⭐ 重试给「接着说」和「重新说」两个选项断线重连 ⚠️ 别自动重发 —— 手写版本没有自动重连反而是好事,因为每次重发都是一次真金白银的新调用,抖动时重发三次就付三次钱;要自动重试就必须配幂等键。⭐⭐ 最后:换框架对应表里六行只有一行(自动局部更新)是框架能帮上忙的 —— AI 前端的难点不在 UI 层,在流式协议和不可信内容上

下一节 👉 11-长任务与队列.md

打卡记录保存在你的浏览器里,首页能看到总进度