AI 流式输出实战:SSE、WebSocket 与前端增量渲染
本文目录
聊天窗口里逐字出现的内容,通常不是模型把文字“直接推到页面”,而是模型服务、应用后端、网关和浏览器共同维护的一条有边界的数据流。选型时先问两个问题:客户端是否也要主动连续发消息?断线后要不要自动续传? 普通的一问一答式 AI 生成,通常用 HTTP fetch 读取 SSE 响应更简单;多人协作、实时双向控制或持续事件订阅,才更接近 WebSocket 的适用场景。
SSE、WebSocket、普通 HTTP 怎么选
| 方式 | 通信方向 | 常见用途 | 需要自己负责的部分 |
|---|---|---|---|
| 普通 HTTP | 请求后返回完整响应 | 短回答、一次性 JSON | 超时、重试和请求取消 |
fetch + SSE 格式响应 | 客户端发起请求,服务端持续返回 | AI 生成、导出进度、长任务状态 | 增量解析、取消、协议错误处理 |
EventSource | 服务端单向推送 | 公开事件流、通知订阅 | 浏览器自动重连、凭据与订阅生命周期 |
| WebSocket | 双向消息 | 在线协作、游戏、双向实时控制 | 心跳、重连、消息顺序、积压控制和服务端连接治理 |
SSE 是 UTF-8 文本协议,事件由空行分隔;一条事件可包含多个 data: 行。EventSource 提供浏览器管理的事件流和重连,但接口不提供通用自定义请求头或任意请求体。如果 AI API 需要 POST、JSON 请求体或 Bearer Authorization,常见做法是 fetch + ReadableStream,由应用自己解析 SSE 数据格式。SSE 在 HTTP/1.1 下还要留意浏览器对同一域名并发连接数的限制,HTTP/2 部署行为不同。
注意:fetch 读取 SSE 只是“用 Fetch API 消费流”,并不会获得 EventSource 的自动重连机制,也不会自动发送 Last-Event-ID。连接中断后先判断服务端是否保存了可恢复的事件游标;对于会启动新生成任务的 POST,不要无条件重放请求,否则可能重复执行甚至重复计费。需要自动续传时,应设计稳定的任务/请求 ID、服务端幂等语义、事件保留窗口和显式恢复接口;若服务端不支持,就把这次回答标为中断,而不是悄悄重新发起一次。
WebSocket 则保持双向连接,适合客户端和服务端都需要频繁主动发消息的交互。它不是“永远更实时”的默认选择:对只需服务端吐 token 的生成请求,连接认证、重连、心跳、消息队列和扩容成本往往没有收益。原生 WebSocket API 也没有可供应用直接等待的接收端背压控制;发送队列积压时要观察 bufferedAmount 并主动限速或丢弃可合并的旧状态。
fetch 读取流:先做好请求生命周期
下面的例子只展示按 UTF-8 字符增量读取。它把每个网络 chunk 当作文本展示,不是通用 SSE 解析器:一个 chunk 可能只包含一条事件的一部分,也可能合并多条事件。真实 SSE 应逐行缓冲、按空行分帧、合并 data: 字段、处理 event/id/retry 字段,或使用经过测试的解析器。
export async function streamText({ url, token, prompt, onText, signal }) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ prompt }),
signal,
});
if (!response.ok) {
throw new Error(`生成请求失败:HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type') ?? '';
const mediaType = contentType.split(';', 1)[0].trim().toLowerCase();
if (mediaType !== 'text/event-stream') {
throw new Error(`响应类型不是 SSE:${contentType || '缺失'}`);
}
if (!response.body) {
throw new Error('当前浏览器没有提供响应流');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
if (text) onText(text);
}
const finalText = decoder.decode();
if (finalText) onText(finalText);
} catch (error) {
try {
await reader.cancel(error);
} catch {
// The fetch may already have canceled the stream.
}
throw error;
} finally {
reader.releaseLock();
}
}
const controller = new AbortController();
const stopButton = document.querySelector('[data-stop-generation]');
stopButton.addEventListener('click', () => controller.abort(), { once: true });
try {
await streamText({ url: '/api/assistant/stream', token, prompt, signal: controller.signal, onText: appendPendingText });
} catch (error) {
if (error.name !== 'AbortError') showRequestError(error);
}
如果解析或 UI 回调本身抛错,示例会取消 reader,再释放锁;用户主动停止仍通过 AbortController 中断 fetch。取消浏览器响应不保证模型上游同步停止,服务端仍需把客户端断开与上游取消信号关联。
生产代码还应设置服务端生成时限、最大输出量和客户端超时;在主动停止时按协议通知后端取消上游模型任务。仅关闭页面的 fetch 不代表模型供应商的工作一定停止,后端必须把断开的客户端与上游取消令牌关联起来。
SSE 事件帧与结束协议
常见事件帧可以长这样:
event: token
data: {"delta":"你好"}
event: usage
data: {"inputTokens":12,"outputTokens":2}
event: done
data: {"finishReason":"stop"}
空行是事件帧的结束标记,不是可有可无的格式装饰。根据 HTML Living Standard,流在空行到来前结束时,这一帧仍不完整,必须丢弃;客户端不能把连接关闭自动解释为 done。服务端每条完整事件都应以 \\n\\n(或规范允许的 CRLF 组合)结束,再显式发送业务终态。
演示中的 tool_call 与 tool_result 只记录在事件日志里,只有 delta 会进入回答正文;这能把工具过程和模型输出分开观察。它是本地协议样例,不会真的调用检索工具。只有收到完整的 done 帧才显示成功;收到 error 会保留失败状态,流在终态事件前结束则提示回答可能不完整。
应用要定义自己的事件 schema,例如 token、status、usage、error、done。不要把“TCP 连接关闭”当成唯一成功标志:网关超时、模型错误和用户取消都会让流结束。结束事件应包含稳定的状态;如果要支持续传,需要设计事件 ID、服务端保留窗口、Last-Event-ID 或应用级游标、重复事件去重和过期游标的恢复行为。没有这些服务端能力时,应明确显示“连接中断,回答可能不完整”,不要默认为成功。
Agent 流式界面:运行状态与内容块分开
Agent 的输出不只有一段答案文本。一次运行可能先报告状态,再请求工具、等待执行结果,然后继续生成;有些操作还需要用户确认。若把所有事件都追加进同一个回答气泡,读者会把“正在搜索”误认为模型答案,也可能把尚未完成的工具参数当成可执行指令。
建议让每个事件都带稳定的 runId、单调递增的 seq 和明确的 type。例如:
{
"runId": "run-42",
"seq": 17,
"type": "tool.call.completed",
"callId": "call-8",
"name": "search_docs",
"summary": "找到 3 条匹配资料"
}
事件名是应用协议示例,不是模型供应商的通用标准。常见职责可以这样划分:
| 事件 | 界面表现 | 是否结束整次运行 |
|---|---|---|
run.started / step.started | 展示运行中状态和当前阶段 | 否 |
message.delta / message.completed | 只更新对应的助手消息 | 否,单条消息结束不一定代表 Agent 已结束 |
tool.call.started | 新建独立的工具活动条目 | 否 |
tool.call.arguments.delta | 可选地显示折叠后的参数草稿,不放进回答正文 | 否 |
tool.call.completed / tool.call.failed | 更新该工具条目的结果或错误 | 否,Agent 可能继续下一步 |
approval.required | 暂停运行并显示经服务端确认的授权操作 | 否 |
run.completed / run.failed / run.cancelled | 显示最终状态并释放运行资源 | 是 |
前端应以 runId 隔离连续任务,以 seq 丢弃重连后重复收到的旧事件。若序号出现缺口,不能假装状态完整:使用服务端支持的游标补取,或提示用户从当前状态重新同步。没有持久事件日志和恢复 API 时,自动重连只会恢复连接,不会自动恢复丢失的工具步骤。
渲染时把消息、工具活动和运行状态作为不同的数据类型维护:工具参数片段先作为不可信文本缓冲,等完整事件到达后再做 JSON/schema 校验;工具结果也只作为文本或受限 Markdown 展示。浏览器展示层不得执行工具调用、eval 模型内容或将未经净化的片段拼进 innerHTML。真正的工具授权、参数校验和执行必须留在服务端;需要人工确认的操作应等待一个有身份校验、可审计的确认请求,不能靠前端按钮本身授权。
思维链不应作为“Agent 进度”直接透传。界面只呈现对用户有用的阶段说明、已授权的工具活动和最终回答;如果服务端不打算公开某类推理内容,就不要通过调试事件或错误消息间接泄露。长答案可以按帧合并更新并使用温和的 aria-live 策略,避免屏幕阅读器逐 token 播报;工具条目则应能键盘展开、折叠和检查错误。
token 到达后,怎样让界面保持顺滑
网络 chunk 往往比屏幕刷新更频繁。不要每来几个字就立刻运行整篇 Markdown 解析、语法高亮和 DOM 替换。更稳的结构是把“传输缓冲”与“显示缓冲”分开:
- 网络层解析完整事件,只把有效的
delta追加到当前生成任务的缓冲区。 - 使用
requestAnimationFrame合并一帧内的多个 delta,再更新显示文本;慢设备可按字符数和时间片控制更新频率。 - 生成期间先以纯文本安全显示;若需要 Markdown,使用支持增量解析的实现,或降低完整解析频率,并在完成时做一次最终渲染。
- 代码块尚未闭合时不要反复触发昂贵的高亮;完成后再高亮或用 Worker 分摊工作。
- 每次新请求使用独立 generation id。旧请求迟到的 chunk 不得覆盖新回答;停止、切换会话或卸载页面时取消请求并清空对应的待渲染帧。
将模型返回内容直接拼进 innerHTML 会把模型输出当作 HTML 输入。应使用 textContent,或走已有严格标签、属性、URL 协议白名单的 Markdown 渲染路径;流式片段也必须遵守同样规则。服务端仍要校验用户权限、配额和请求体,前端隐藏按钮不构成访问控制。
WebSocket 的生命周期清单
下面的回声实验只有点击“连接”后才会打开外部 WSS 连接;发送的短文本会到达 Postman Echo 并原样返回。不要输入账号、令牌或任何个人信息。Postman 文档将 wss://ws.postman-echo.com/raw 列为其 WebSocket Echo 端点。
const socket = new WebSocket('wss://example.test/realtime');
socket.addEventListener('open', onOpen);
socket.addEventListener('message', onMessage);
socket.addEventListener('error', onError);
socket.addEventListener('close', onClose);
function sendWhenReady(message) {
if (socket.readyState !== WebSocket.OPEN) return false;
if (socket.bufferedAmount > 256 * 1024) return false;
socket.send(JSON.stringify(message));
return true;
}
function dispose() {
socket.removeEventListener('open', onOpen);
socket.removeEventListener('message', onMessage);
socket.removeEventListener('error', onError);
socket.removeEventListener('close', onClose);
if (socket.readyState < WebSocket.CLOSING) socket.close(1000, 'view disposed');
}
上限只是示例,应按消息大小、客户端能力和业务可丢弃性实测。浏览器端只能看到有限的连接状态和发送队列字节数,无法单独证明服务端已经处理消息。对必须送达的命令,要在协议层增加消息 ID、服务端确认、幂等处理和有上限的重试;重试时使用退避和抖动,避免大量客户端同时重连。
网关、缓存与部署检查
- 反向代理必须允许响应流持续传输,关闭或调整缓冲时要针对准确路由配置并监控,不能全站盲目关闭缓存/缓冲。
- 检查 CDN、API Gateway、应用服务器和上游模型 SDK 是否会聚合响应;浏览器 Network 面板里是否逐步看到事件,是端到端链路检查,不只是前端代码检查。
- SSE 响应使用正确的
Content-Type: text/event-stream,并明确缓存、连接保持和压缩策略;代理空闲超时要高于心跳间隔与预期任务时限。 - 连接建立后立即发送状态事件或心跳注释,避免中间代理把长时间无数据的流判为闲置;心跳不能替代业务事件和任务超时。
- 认证和 CORS 规则需要与实际凭据模式一致。不要把 token 放在 URL 查询参数里,以免进入代理日志、分析数据或浏览器历史。
- 对每用户并发任务、速率、最大输入/输出、总时长和上游花费设置服务端限制;记录 request id 和终态,不记录完整敏感提示词或凭据。
验收矩阵
| 场景 | 预期行为 |
|---|---|
| 服务器把一个中文字符拆到多个网络 chunk | TextDecoder 流式解码后字符不乱码 |
| HTTP 401/429/500 或错误 Content-Type | 进入错误态,不把错误 JSON 显示成模型答案 |
| 用户点击停止 | UI 立即进入停止态,前后端都尝试取消生成 |
| 网关中断流 | 标为中断/不完整,不伪装成正常完成 |
| 用户连续点击两次生成 | 旧 generation 的迟到数据不会写入新回答 |
| 服务端输出包含 HTML、脚本标签或危险链接 | 作为文本显示或被 Markdown 白名单拒绝 |
| 手机屏幕和低性能设备 | token 更新批处理,滚动不被每个 delta 强制拉到底 |
| WebSocket 断网后恢复 | 退避重连、恢复会话并处理重复消息,不无限排队 |
| 页面离开 | 取消流、撤销帧回调、移除监听并关闭连接 |
参考资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。