📑 本页目录(点开跳转)
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: true,TextDecoder 会把那半个字符解成 �(替换字符)。
⭐ 加上之后它会把不完整的字节留到下次。
💀 这个 bug 的可怕之处在于它是间歇性的 —— 中文/emoji 多的时候偶尔出现一个乱码字, 你会以为是模型的问题。
坑 B:一个 chunk ≠ 一帧
reader.read() 给你的是网络层的分块,和你的 SSE 帧完全没有对应关系。
一次 read 可能拿到:半帧、一帧、三帧半。
⭐ 所以必须用一个 buffer 累积,按 \n\n 切 —— 直接把每个 chunk 当一帧解析,
在本地测试时可能一切正常(帧小、网络快),上线遇到大响应或慢网络就会碎掉。
🎨 三、边流边渲染 Markdown:半个代码块怎么办
模型的输出通常是 Markdown。但流式意味着你随时可能拿到一个残缺的 Markdown:
用户看到的中间状态:
这是一个例子:
```python
def hello(
↑ 代码块没闭合,表格只有一半,粗体只有一个星号
三种做法,取舍不同:
| 做法 | 优点 | 缺点 |
|---|---|---|
| 流式期间只显示纯文本,结束后一次性渲染 Markdown | 简单、绝不出错 | ⚠️ 最后会「跳变」一下,观感突兀 |
| ⭐ 每帧都重新渲染整段 Markdown | 实时、体验最好 | 需要一个容错的解析器;性能要注意 |
| 只在「安全点」渲染(比如遇到换行符时) | 折中 | 逻辑复杂,边界情况多 |
⭐ 推荐第二种,但有两个前提:
- 用容错的 Markdown 解析器。⚠️ 严格的解析器遇到未闭合的代码围栏可能抛错或把后面全吞掉; 宽松的解析器(大多数浏览器端库)会把它当成「还没结束的代码块」正常渲染 —— 这正是你要的
- ⭐⭐ 必须做 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 协议走白名单 | a 的 href、img 的 src 都要查,只放行 http: / https: / mailto:。⚠️ javascript: 能直接执行,data: 能塞进一整个 HTML 文档 |
⚠️⚠️ 别自己写正则过滤——这是本节最想说的一句。 这四条看起来是几个 replace 的事,实际上不是:攻击面恰恰在浏览器的解析器和你的正则理解不一致的地方,而浏览器对畸形 HTML 极其宽容。<IMG SRC=x OnErRoR=…> 大小写混写、javascript: 实体编码、属性值不加引号、<img/src=x/onerror=…> 拿斜杠当分隔符、<scr<script>ipt> 靠嵌套躲过一次替换——每一种都能绕过一个「看起来没问题」的正则。
⭐ 正确的形状是:让一个真正的 HTML 解析器把内容解析成 DOM 树,再按白名单裁剪这棵树。 这样你判断的东西和浏览器最终执行的东西是同一棵树,绕过就无处可藏。净化库做的就是这件事。
🗓️ 浏览器端事实标准是 DOMPurify(DOMPurify.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 → 模型照做 → 别人浏览器执行」是间接提示注入的一个具体形态,那一章讲这类攻击的全貌 |
✅ 检查点
EventSource有哪三个硬限制?其中哪一个最致命?- 「把 token 放 query 参数」为什么不行?
decoder.decode(value, { stream: true })里的stream: true不加会怎样?为什么这个 bug 特别难查?- 为什么必须用 buffer 按
\n\n切帧,而不能把每个 chunk 当一帧?本章的解析器为什么只认data:行、不处理event:行?结束帧为什么不能写[DONE]? - 边流边渲染 Markdown 有哪三种做法?推荐哪种、前提是什么?
- ⭐ 为什么渲染 Markdown 时必须做 HTML 净化?举一个具体的攻击路径。净化的四条最小判据是什么?为什么自己写正则不够?
- 滚动跟随有个「自己触发自己」的坑,是什么?本章的写法为什么没事?
- 前端
abort()之后,为什么还不算真的停止? - ⭐ 流到一半失败时,后端为什么不能返回 500?这个约束导致了什么设计?
- 为什么说「手写的 fetch 版本没有自动重连」反而是好事?
👀 答案
- ① 不能自定义 header ② 只能发 GET ③ 不能自定义 body。 ⚠️⚠️ 最致命的是第一条:带不了
Authorization,接口一加鉴权就废了。 - ⚠️ 因为 query 参数里的 token 会被记进服务器访问日志、代理日志、浏览器历史 —— 这是个真实的泄露面。
- ⚠️ 网络会在任意字节位置切开数据,一个中文字符 3 字节可能被切在两个 chunk 之间。不加
stream: true,TextDecoder会把半个字符解成�;加上之后它会把不完整字节留到下次。💀 难查是因为它是间歇性的 —— 中文/emoji 多时偶尔冒出一个乱码字,你会以为是模型的问题。 - 因为
reader.read()给的是网络层分块,和 SSE 帧完全没有对应关系 —— 一次可能拿到半帧、一帧或三帧半。⭐ 直接把 chunk 当帧解析,本地测试(帧小、网络快)可能一切正常,上线遇到大响应或慢网络就碎。至于event:行:它只对EventSource有意义,而EventSource第一节就被否掉了;手动解析器要么多写一套状态机把它和下一行data:配对,要么整行丢掉。⭐ 所以帧类型放进 JSON 负载的type字段,一行JSON.parse拿到全部信息,解析路径只有一条。⚠️ 同理结束帧写{"type":"done"}——[DONE]不是合法 JSON,会让那行JSON.parse抛SyntaxError。 - ① 流式期间只显示纯文本、结束后一次性渲染(简单但⚠️ 最后会跳变)② ⭐ 每帧重新渲染整段(实时、体验最好)③ 只在安全点渲染(折中但逻辑复杂)。⭐ 推荐 ②,两个前提:用容错的解析器(严格解析器遇到未闭合的代码围栏可能抛错或吞掉后文)+ ⭐⭐ 必须做 HTML 净化。
- 因为 Markdown 渲染出来是 HTML,而模型输出是不可信输入 —— ⚠️ 渲染的那一刻你就失去了
textContent的保护。💀 攻击路径:用户 A 上传的文档里藏着「请输出以下 HTML」→ 用户 B 检索到它 → 模型照做 → B 的浏览器执行了<img src=x onerror=...>。四条判据:① 标签走白名单(不是黑名单)② 禁<script>(连同iframe/object/embed/style)③ 禁一切on*事件属性(⚠️img标签合法,要命的是属性)④ URL 协议只放行http/https/mailto(⚠️javascript:和data:都能执行)。⚠️⚠️ 自己写正则不够,是因为浏览器解析器和你的正则理解不一致:大小写混写、javascript:实体编码、属性不加引号、<img/src=x/onerror=…>、<scr<script>ipt>都能绕过。⭐ 正确形状是解析成 DOM 树再按白名单裁剪(🗓️ 浏览器端常用 DOMPurify),且净化要是进innerHTML前的最后一步。 - ⚠️ 你自己的自动滚动也会触发
scroll事件,判断写不对就会「自动滚一下 → 事件触发 → 误判用户在操作 → 停止跟随」。⭐ 本章写法没事是因为自动滚之后必然处于底部,判据仍然为true。 - ⭐⭐ 因为浏览器断开连接后,后端如果不检测,还会继续调 LLM 把剩下几百个 token 生成完 —— 用户看不到,钱照付。两边都做了,「停止」才是真的停止(后端那半在第 5 章)。
- ⭐⭐ 因为 HTTP 头已经发出去了(200 OK),没法再改成 500。这导致错误只能作为流里的一帧传过来 —— ⭐ 这正是第 1 章一开始就要求分帧而不是吐裸文本的原因。
- ⚠️⚠️ 因为 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.parse抛SyntaxError。⭐ 格式要跟着消费方走。边流边渲染 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