前端 Agent 流式展示:产品状态机、Fetch 与 SSE 分帧
本文目录
“流式”不是把完整答案每隔几十毫秒拆开显示。网络分块、UTF-8 字符、SSE 事件和模型语义 token 是四种不同边界;任一层处理错误,都会出现乱码、半截 JSON、Markdown 闪烁、重复生成或用户停止后仍继续计费。先定义服务端事件契约,再设计前端读取、解析、呈现和取消。
让用户看懂任务状态
传输状态、Agent 任务状态和答案内容是不同维度。网络连接断开不一定说明服务端任务失败,收到一段文本也不代表任务完成;界面只展示服务端明确发来的可验证状态,不能从 token 猜测“正在思考”。
| 状态 | 含义 | 界面反馈 |
|---|---|---|
| idle | 尚未提交 | 输入框可编辑 |
| connecting | 已提交,尚未收到有效事件 | 显示已提交,允许停止 |
| streaming | 正在收到可见答案片段 | 增量呈现正文 |
| tool-running | 一个或多个公开工具步骤在运行 | 展示经过筛选的工具状态 |
| interrupted | 用户停止或连接中断 | 保留已收到内容,明确恢复方式 |
| completed | 收到协议定义的 done 终态 | 提供复制与后续操作 |
| failed | 请求或协议失败 | 显示可恢复性和安全重试入口 |
答案、工具活动和任务状态分区呈现。工具活动可以说明公开工具名、开始/完成状态和经筛选的摘要;不要暴露隐藏推理链、内部 prompt、凭据、堆栈或未经授权的原始工具结果。
选择传输方式
| 方式 | 适合场景 | 需要自己承担的边界 |
|---|---|---|
| 原生 EventSource | 单向推送、简单 URL、浏览器自动重连 | 主要是 GET;不能任意设置请求头或 POST body;连接重试与游标语义要由服务端配合 |
| Fetch + ReadableStream | 带请求体的 Agent/生成请求、需要自定义请求头和取消 | 自己检查 HTTP 错误、解析事件帧、处理结束/错误与重连 |
| WebSocket | 需要双向低延迟控制或持续协作 | 连接状态、消息背压、重连、顺序和授权复杂度更高;单向生成通常没必要 |
浏览器原生 EventSource 的自动重连和 Last-Event-ID 很方便,但它不适合需要 POST prompt 或任意 Authorization header 的接口。此时常见做法是 Fetch POST 接收 text/event-stream。如果使用 Cookie 身份,仍需按站点架构配置 CSRF 与 Origin 校验;若采用 Bearer 凭据,只放授权头,不把凭据拼进 URL。不要让浏览器持有模型供应商密钥。
SSE 不是“每个网络 chunk 就是一条 JSON”。一条事件用空行结束,支持 LF、CRLF 或 CR;一个事件可以有多行 data:,空格规则、注释行和字段顺序也有定义。代理可能合并多个事件,甚至把 UTF-8 字符拆在两个字节块之间。先增量解码,再按协议分帧,最后才解析 JSON。
一个服务端事件契约
接口应固定状态与数据 schema,避免前端猜测字符串。下面的 delta 只代表可以展示的用户可见文本,不代表暴露模型隐藏推理;工具调用应展示经过筛选的阶段状态,不应把内部 prompt、凭据或原始工具输出直接发给读者。
event: message
id: turn-42:17
data: {"type":"delta","text":"你好"}
event: message
id: turn-42:18
data: {"type":"usage","outputTokens":24}
event: done
data: {"finishReason":"stop"}
明确终止、错误、取消、用量和恢复游标的语义。每个事件应带稳定 requestId 和递增 sequence;delta 可承载文本,工具事件只放经过筛选的状态,error 提供稳定错误码与 retryable。done 是唯一正常终态,EOF 未收到 done 应标为 interrupted 或协议错误。
Event ID 可帮助重连定位,但不自动保证 exactly-once:服务端必须定义事件是否可重放,客户端也要校验 requestId、sequence 并按稳定 ID 去重。没有幂等键和可恢复游标时,不要在网络错误后静默重新提交生成请求,否则可能重复扣费、创建重复任务或把两次回答拼在一起。错误响应不要原样携带数据库异常、工具堆栈或服务密钥。
增量解码与 SSE 解析
以下解析器只处理 SSE 的换行、字段和事件边界,不把网络块当作消息。它支持首行 BOM、跨块 CRLF、重复 data 行、注释、事件名与持久的 last event ID;为避免误导,这个小解析器不实现 retry 自动重连。生产版本还应限制单行、单事件和总响应大小,并对解码后的 JSON 做 schema 校验;重试策略由调用层按接口契约实现。
function createSseParser(onEvent) {
let buffer = '';
let data = [];
let eventType = '';
let lastEventId = '';
let firstLine = true;
function dispatch() {
if (data.length > 0) {
onEvent({ event: eventType || 'message', data: data.join('\n'), id: lastEventId });
}
data = [];
eventType = '';
}
function processLine(line) {
if (firstLine) {
firstLine = false;
if (line.charCodeAt(0) === 0xfeff) line = line.slice(1);
}
if (line === '') {
dispatch();
return;
}
if (line.startsWith(':')) return;
const colon = line.indexOf(':');
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'data') data.push(value);
else if (field === 'event') eventType = value;
else if (field === 'id' && !value.includes('\0')) lastEventId = value;
}
function consumeLines() {
while (true) {
const match = /[\r\n]/.exec(buffer);
if (!match) return;
if (match[0] === '\r' && match.index === buffer.length - 1) return;
const delimiter = match[0] === '\r' && buffer[match.index + 1] === '\n' ? 2 : 1;
processLine(buffer.slice(0, match.index));
buffer = buffer.slice(match.index + delimiter);
}
}
return {
feed(chunk) {
buffer += chunk;
consumeLines();
},
finish() {
if (buffer.endsWith('\r')) processLine(buffer.slice(0, -1));
buffer = '';
data = [];
eventType = '';
},
};
}
消费 Fetch 响应时,检查状态码、MIME 和 body;TextDecoder.decode(..., { stream: true }) 会保留跨字节块的 UTF-8 状态,直到最后再 flush。组件卸载、用户点停止或新请求开始时,要 AbortController 中止网络并阻止旧 generation 的迟到事件污染新回答。
async function readAgentEvents({ url, requestId, prompt, signal, onEvent }) {
const response = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'text/event-stream', 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, prompt }),
signal,
});
if (!response.ok) throw new Error('生成请求失败:HTTP ' + response.status);
if (!(response.headers.get('content-type') || '').includes('text/event-stream')) {
throw new Error('服务端没有返回 SSE');
}
if (!response.body) throw new Error('浏览器没有提供可读响应流');
const parser = createSseParser(onEvent);
const decoder = new TextDecoder('utf-8');
const reader = response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
parser.feed(decoder.decode(value, { stream: true }));
}
parser.feed(decoder.decode());
parser.finish();
} finally {
if (signal?.aborted) await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
这里的 parser 示例是协议教学代码;正式 SDK 还应处理 reader 抛错、超时、服务端 error 事件、最大行/事件/响应大小、回调异常、重复 event ID 和取消后的状态回收。SSE 事件必须以空行结束;连接 EOF 时没有完整终止符的残缺事件不应被当成已完成回答。完成状态必须来自完整的 done 业务事件,而不只是 reader 返回 done。
增量状态与 Markdown 呈现
把传输、事件协议、业务状态和视觉渲染拆开:传输层只产出完整事件;协议适配器把 JSON 校验为有限的 delta/status/tool/done/error 联合类型;UI 再更新回答状态。对未知事件应安全忽略或记录不含正文的诊断,不要把任意对象直接渲染。
- 高频 delta 先追加到受控缓冲,再每帧或约 30–80ms 批量提交 UI;避免每个字符都触发昂贵的 React/Vue 全树更新。
- 对很长的回答使用分段追加或合适的虚拟化;滚动只在用户仍接近底部时跟随,用户向上阅读时暂停自动滚动,并提供“回到底部”。
- 不必每个增量都重新解析整篇 Markdown。可在增量期间显示纯文本或稳定的进行中段落,节流解析;代码围栏未闭合时不要抖动重排整个页面。
- 将模型文本当作不可信输入。首选 textContent/框架文本节点;Markdown 链接、HTML、图片和代码高亮经允许列表与安全净化后再呈现,禁止直接把原始文本塞进 innerHTML 或执行代码块。
- 区分“正在连接、生成中、调用工具、已完成、用户取消、网络中断和服务端错误”。错误状态保留已生成内容,并给出明确的重试/复制/继续入口。
读屏体验不能逐 token 播报。使用有边界的 aria-live="polite" 状态摘要,例如阶段变化或每段完成后更新;正文容器保持可读,停止按钮有明确名称且键盘可达。尊重减少动态效果设置,不用持续闪烁光标或强制滚到底部。
代理、超时与服务端协作
若服务端已经逐事件 flush,反向代理的 buffering、压缩、中间层空闲超时和 CDN 规则仍可能让用户只在结尾看到整段。浏览器应检查真实到达时间与事件间隔;服务端按 SSE 格式发送心跳注释,设置合适的 no-store/缓存策略,并只在确认部署拓扑支持时关闭该路由的代理缓冲。不要为流式路由随意关闭整站代理保护或扩大超时。
移动网络切换、浏览器进入后台、省电策略、请求限流和服务端队列都会造成停顿。记录首事件时间、事件间隔、取消率、失败阶段和响应大小;不要记录 prompt、生成正文、Cookie、Authorization 或内部工具参数。用户停止后需让服务端取消模型/工具任务,单纯关闭浏览器读取并不保证上游任务停止计费。
回归矩阵
测试 UTF-8 多字节字符被拆分、CRLF 在块边界拆分、多行 data、注释、未知字段、末尾缺失空行、非 2xx、错误 MIME、空 body、用户取消、组件快速重建、超长输出、服务端限流、代理缓冲、移动网络切换和屏幕阅读器。验证 EOF 未收到 done 时不会标为成功,requestId/sequence 拒绝旧任务和重复事件,AbortError 会显示为 interrupted,重试不会重复生成。对模型与工具调用日志做脱敏检查;观测首事件、首个可见字符、工具阶段耗时、取消率和最终状态。上线前在桌面 Chromium、Safari/iOS 与目标 WebView 上测首事件延迟,而不仅看浏览器 Network 面板是否显示 200。
参考:MDN 使用 Fetch API 的响应流、MDN ReadableStream、MDN TextDecoderStream、WHATWG Server-sent events、MDN AbortController。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。