专栏:AI 全栈开发|19
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 上一篇 18《Message 和对话》把完整历史、会话资源和本轮模型上下文分开了。现在请求结构已经正确,新的问题变成用户体验:模型还在生成时,浏览器是不是必须一直白屏等到完整答案?F19 要打通 Provider → Backend → Browser 的增量链路,让"第一段内容尽快可见",同时把缓冲、取消和流中错误纳入统一状态。 |
一、流式不是"模型开了 stream=true"就结束了
流式体验只有在整条链路都保留增量时才成立。Provider 可以逐段产生 delta,但后端如果先把整个 body 读完再返回,或者代理层把响应缓冲到一定大小,浏览器看到的仍然是一次性整包。
所以这一篇不把"流式"理解成某个 SDK 参数,而理解成三层接力:上游持续生成;后端来一段转一段;浏览器收到一段渲染一段。任何一跳从"增量"退回"全量",前面所有优化都会被抵消。
图 1 Provider、Backend、Browser 三层都必须保留增量,流式才真正成立
二、先把三层职责钉死
|----------|-------------------|----------------------|---------------|
| 层 | 输入 | 这一层要做什么 | 最常见故障 |
| Provider | messages + stream | 持续输出 delta,最终正常结束或失败 | 上游继续生成但下游已取消 |
| Backend | 上游字节/SSE | 增量读取、转发、保序、传播取消 | 全量读取或代理缓冲 |
| Browser | HTTP 响应流 | 增量解码、按事件解析、更新 UI | 等 done 后一次性渲染 |
在聊天产品里,客户端通常还要 POST 多轮 history,因此这一链路更适合 `fetch()` 读取 streaming response。SSE 是事件格式;EventSource 是消费 SSE 的一种浏览器 API,但它不能直接承担带 JSON body 的 POST 聊天请求。
三、SSE 的关键不是"长连接",而是事件边界
最小 SSE:每个事件以空行结束,data: 携带载荷
|-------------------------------------------------------------------------------------------|
| data: {"type":"delta","text":"你"} data: {"type":"delta","text":"好"} data: {"type":"done"} |
真正重要的是"完整事件"这个概念。HTTP/TCP 给浏览器的是任意大小的字节块,一个 `reader.read()` 可能只拿到半个 UTF-8 字符,也可能拿到半个 SSE 事件。因此不能把"网络块"直接当成"应用消息"。
四、字节块、字符和 SSE 事件是三个不同边界 
图 2 正确消费顺序:bytes → 增量解码 → 完整 SSE event → delta
浏览器端最小骨架:TextDecoder 必须保持跨 chunk 状态
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let end;
while ((end = buffer.indexOf("\n\n")) >= 0) {
const event = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
handleSseEvent(event); // 这里只接收完整事件
}
}
stream:true` 的作用不是"让请求变流式",而是告诉 TextDecoder:这次 chunk 末尾可能还剩半个字符,先保留状态,下一块来了再拼。SSE 解析器则做第二层缓存:直到遇到空行,才把一段内容交给 JSON 解析。
五、后端转发层最怕"无意中的缓冲"
原稿指出的排查思路非常实用:从 Provider 到 Browser 逐跳问一句"这一层是不是在攒包"。常见的缓冲点包括后端先读取完整 body、反向代理默认 buffering、压缩层迟迟不 flush,以及应用框架把 generator 结果先聚合后输出。
• 后端:上游一到增量就尽快 yield / write,不先拼完整 assistant 文本再返回。
• 代理:流式路由单独检查 buffering、idle timeout、compression 等配置。
• 浏览器:收到事件就更新状态;不要等 done 后才把 accumulatedText 一次性塞进 DOM。
六、心跳和"结束"是两件不同的事
长连接在模型低频输出或工具调用阶段可能暂时没有业务 delta,中间网关却可能把它判成空闲连接。SSE 可以发送注释帧作为 keep-alive;客户端忽略这类帧,它们只负责维持链路。
但心跳不能替代明确终态。前端不能靠"很久没收到新字"猜模型已经说完;正常结束、生成中断和用户取消必须有明确状态,才能决定 UI 是显示完成、错误提示还是"已停止"。
七、停止按钮必须把取消一路传到 Provider 
图 3 用户停止生成时,取消必须从浏览器一路传播到 Provider
如果用户点击 Stop 只让前端停止渲染,而后端还继续读 Provider,体验上看似结束,计算与 token 消耗却仍在继续。因此"停止生成"不是一个前端动画,而是一条取消链。
• Browser:用 AbortController 终止本次 fetch。
• Backend:发现请求取消或下游连接断开后,停止转发并关闭/取消上游流。
• Provider:上游连接被关闭后停止继续生成,整个 run 才真正结束。
八、流中出错后,不要把已经显示的半段答案当成"没发生"
|-----------|--------------------|-------------------|
| 状态 | 前端应该怎么显示 | 后端要做什么 |
| streaming | 继续追加 delta | 保持转发与心跳 |
| done | 固定最终文本,结束 loading | 提交完整 assistant 消息 |
| error | 保留已显示内容并标记"生成中断" | 发送/记录错误终态 |
| cancelled | 保留用户已看到的部分,显示"已停止" | 向上游传播取消 |
这一点与 F18 的 Conversation 直接相关:存储层要知道本轮 assistant 是完整完成、异常中断还是用户取消。否则下次组装 Context 时,会把半截内容当成一条正常历史继续喂给模型。
九、完整链路:前端只需要消费你自己的后端流
POST /chat + fetch Streaming:浏览器永远不直接携带 Provider API Key
const ctrl = new AbortController();
const resp = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ conversation_id, message }),
signal: ctrl.signal,
});
await consumeSse(resp.body, {
onDelta: text => answerEl.textContent += text,
onDone: () => setState("done"),
onError: err => setState("error", err),
});
// Stop 按钮:
// ctrl.abort();
这种拓扑把密钥、限流、会话归属、成本统计都留在后端;浏览器只面对自己的 `/api/chat`。它也与 F14 的结论一致:聊天并不因为"用户也会发消息"就必须切成 WebSocket,一次 POST + 持续下行完全可以保持在 HTTP Streaming / SSE 这条路径上。
十、最容易让流式失效的 7 个错误
• Provider 开了 stream,但后端使用全量响应 API,等全部读完才返回。
• 把 reader.read() 得到的每个 chunk 独立 decode / JSON.parse,把网络边界误当成消息边界。
• POST 聊天却硬套 EventSource,最终又绕出一套任务创建与订阅协议。
• 代理层仍在 buffering / 长响应超时,开发环境流畅,上线后又变成整包。
• 没有心跳,低频阶段被网关当成 idle connection 关闭。
• Stop 只停前端动画,没有取消后端与 Provider。
• 把 Provider API Key 放进浏览器,为了少一层代理直接让前端连模型服务。
十一、一张表:前后端至少要约定这 4 种事件
|-----------|-------------------|--------------------|
| 事件 | 最小载荷 | 前端动作 |
| delta | text | 追加到当前 assistant 文本 |
| heartbeat | 可为空 | 忽略业务内容,只刷新连接存活 |
| done | finish / usage 可选 | 固定最终状态,停止 loading |
| error | code + message | 保留已显示部分,标记生成中断 |
事件名不一定非要按这四个字符串实现,但"增量、保活、正常终止、错误终止"这四种语义最好明确分开。这样前端状态机才不会靠连接是否突然关闭来猜发生了什么。
十二、几个最容易混淆的结论
• SSE 是事件格式,EventSource 是一种浏览器 API;POST 聊天完全可以用 fetch 消费 text/event-stream。
• 流式降低的是等待完整答案的体感,不代表模型本身生成速度自动变快。
• 网络 chunk、UTF-8 字符和 SSE event 有三套不同边界;解析时必须逐层恢复,不能混成一层。
• 断开连接与取消业务生成不是同一句话;只有取消真正传播到 Provider,才算停止了上游工作。
十三、下一篇
到这里,我们已经把"文本如何持续来到浏览器"解决了。但程序真正想要的通常不是一段自由文本,而是稳定、可校验的数据结构。下一篇 20《Structured Output》会从 JSON 与 Schema 出发,继续解决"怎么让模型输出能直接被代码消费"。