Streaming Model Output

专栏: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 出发,继续解决"怎么让模型输出能直接被代码消费"。

相关推荐
paopaokaka_luck2 小时前
基于springboot+vue的益暖微光公益服务平台(DeepSeek公益助手、善款信息追溯、志愿服务积分闭环、ECharts运营分析、活动状态自动流转)
java·前端·javascript·spring boot·mysql·架构·echarts
栀椩2 小时前
Electron 内嵌页面跨域问题(CORS/PNA)解决全记录
前端·javascript·electron
asaotomo2 小时前
截图识题、AI 讲解、笔记沉淀:Chrome 扩展体验
前端·chrome·笔记
前端架构师-老李2 小时前
HTML与CSS基础第五课—HTML的空格、换行、横线相关知识
前端·css·html
SEO_juper2 小时前
Chrome 扩展一键查看当前页面 SEO 信息:不用切页面,点一下全出来
前端·javascript·chrome
Nymph_Zhu2 小时前
vue3+element-plus 文件上传
前端·typescript·vue3·element-plus
熊猫钓鱼>_>2 小时前
鸿蒙PC高分屏与键鼠交互适配:DPI缩放、右键菜单一次讲清
开发语言·笔记·华为·ai编程·harmonyos·鸿蒙·鸿蒙心迹
echome8882 小时前
CSS 布局:Flex 和 Grid 怎么选,附常见坑
前端·css·html
计算机魔术师2 小时前
科技巨头砸7400亿押注AI,为什么苹果成了唯一赢家?
前端