第一个流式回答界面只处理一类消息:服务端每来一段文本就拼到字符串后面。接入工具调用后,页面开始收到“正在检索”“等待确认”“工具结果”和“继续生成”。网络重连又会重复收到部分 token,最终内容偶尔重复,按钮状态也停在 loading。

问题不在 React 渲染,而在协议把一个有状态任务伪装成文本管道。我们改成带 runId、sequence 和明确类型的事件流,客户端用 reducer 重建状态。

用户、UI、Agent 与工具之间包含审批和恢复的流式事件时序

图 1:文本只是事件之一;工具、审批、错误和结束都需要独立语义。

每个事件可排序、可去重

type RunEvent =
  | { runId: string; seq: number; type: "run.started"; at: string }
  | { runId: string; seq: number; type: "message.delta"; messageId: string; text: string }
  | { runId: string; seq: number; type: "tool.requested"; callId: string; tool: string; summary: string }
  | { runId: string; seq: number; type: "approval.required"; approvalId: string; planHash: string }
  | { runId: string; seq: number; type: "tool.completed"; callId: string; result: ToolSummary }
  | { runId: string; seq: number; type: "run.failed"; code: string; retryable: boolean }
  | { runId: string; seq: number; type: "run.completed"; usage: Usage };

服务端为每个 run 单调递增 seq,事件持久化后再推送。客户端保存 lastSeq,重连时请求 after=lastSeq;收到重复 seq 直接忽略,发现跳号则补拉,不能继续盲拼。

文本按 messageId 聚合

一个 run 可能产生多条 assistant message,也可能先写分析摘要再生成最终答案。delta 必须携带 messageId:

function reduce(state: RunState, event: RunEvent): RunState {
  if (event.seq <= state.lastSeq) return state;
 
  if (event.type === "message.delta") {
    const previous = state.messages[event.messageId] ?? "";
    return {
      ...state,
      lastSeq: event.seq,
      messages: { ...state.messages, [event.messageId]: previous + event.text },
    };
  }
  return transitionNonTextEvent(state, event);
}

渲染层按动画帧批量提交 delta,避免每个 token 都触发完整 Markdown 解析。代码块未闭合时使用增量友好的展示,完成后再做最终高亮。

断线不等于任务失败

浏览器连接断开,Agent 可能仍在运行。页面进入 reconnecting,先查询 run 状态和缺失事件;只有服务端明确 run.failed 才展示失败。用户刷新页面也能通过 URL 中的 runId 恢复,而不是丢掉整个任务。

状态 界面动作
running 展示当前阶段与停止按钮
waiting_approval 固定审批卡片,不继续显示假 loading
reconnecting 保留已有内容,显示连接恢复状态
failed_retryable 提供从检查点重试,不清空证据
completed 固化引用、用量和最终产物

停止也要有服务端确认

点击停止发送 run.cancel.requested,UI 不立即假装结束。Agent 在安全点取消工具和生成,服务端发 run.cancelled。已经发生的外部副作用不会因停止自动撤销,界面要列出已完成工具及补偿状态。

背压和大小限制必须进入协议

客户端消费慢或切到后台时,服务端不能无限缓冲。事件持久化与实时传输分离,WebSocket/SSE 只做通知;客户端按序补拉。单个工具结果不直接塞入事件流,只返回 artifactId、摘要和受权下载链接。

上线后我们观察重连成功率、序号缺口、重复事件、首 token、完成时间和取消耗时。一个流式界面的稳定性,不是动画是否顺滑,而是任何网络中断、工具等待和页面刷新后,用户仍能准确知道任务做到了哪一步。

事件协议与前端状态机的边界划分,和人工审批不是弹窗里审批协议的思路同源:把“需要人确认的阶段”变成协议里的一等事件,而不是 UI 上的一个临时状态。客户端如何组织这些事件的上下文,则属于上下文工程讨论的范围。