流式聊天不只是在字符串后面追加 token
如果后端只返回一个不断变长的字符串,前端很快就会遇到边界问题:工具执行显示在哪里,断线之后从哪接上,连接关闭是否等于生成结束?
按事件建协议,别让 UI 猜状态
可以将一次任务的输出定义成带序号的事件流。下面是自定义业务协议,不是某个模型供应商的原始响应格式;后端负责将上游事件转换成统一结构。
id: 42
event: text.delta
data: {"runId":"run_demo","messageId":"m_1","delta":"你好"}
id: 43
event: tool.started
data: {"runId":"run_demo","callId":"tc_1","name":"search_docs"}
id: 44
event: run.completed
data: {"runId":"run_demo"}
SSE 以空行分隔事件。网络读取到的一个 chunk 不一定是一条事件:它可能只有半个 JSON,也可能包含几条事件。使用 fetch 读取响应流时,要做增量 UTF-8 解码,并按 SSE 规则处理缓冲区,不能对每个 chunk 直接 JSON.parse。
原生 EventSource 与 fetch 怎么选
原生 EventSource 适合 GET 订阅,能够自动重连,但不能像 fetch 一样任意设置 Authorization 请求头。如果认证采用同源 Cookie,可以先 POST 创建 run,再用 GET 订阅它的事件。
POST /api/runs → { "runId": "run_demo" }
GET /api/runs/run_demo/events → text/event-stream
POST /api/runs/run_demo/cancel → { "status": "cancelling" }需要请求体或自定义请求头时,可以使用 fetch 读流,但重连、解析和游标管理都要自己实现。不要为了省事把长期访问令牌放进 URL。
断线不等于完成
只有收到 run.completed、run.failed 或 run.cancelled 这类终态事件,界面才进入最终状态。连接意外关闭时应显示“连接中断,正在恢复”,而不是直接把最后一段文本当成完整答案。
服务端需要保存可回放的事件序号,并支持从 Last-Event-ID 或业务游标继续发送。前端按 runId 和事件 id 去重。若游标已超出保留范围,重新获取任务快照,再继续订阅。
“停止生成”需要两条链路
AbortController 只会断开当前浏览器请求,不保证服务端停止推理。点击停止时应调用取消接口,同时按产品需要关闭本地订阅;之后再通过任务快照确认最终状态。
高频文本更新还可以合并到每帧或短时间窗口内渲染,避免每个小增量都触发整条消息的 Markdown 解析。代码块没闭合时也要能展示,不能等所有 Markdown 都合法才更新。