返回文章列表
前端架构状态管理Event SourcingAgent可观测

聊天式产品的前端状态管理:从消息到工具事件(Event Sourcing 思路)

聊天式 AI 产品的“状态”远不止消息列表:还有工具调用、步骤状态、重试、取消、断线重连与可重放调试。本文给出一套可落地的前端事件模型:把消息、工具与运行状态统一成事件流,用 reducer 构建可重放 store,解决重复事件、并发子任务与 UI 派生视图的复杂度。

2026年3月4日
Synthly 团队
预计阅读 14 分钟
聊天式产品前端事件流:消息、工具事件与重放调试的结构示意图

📷 Photo by Jakub Zerdzicki via Pexels

你的 UI 之所以“越做越乱”,通常是因为状态模型错了

聊天式产品初期往往是:

  • 一个消息数组
  • 一个 isLoading
  • 一个 currentResponseText

但一旦进入 Agent 阶段(工具调用 + 多步骤 + 可取消/可重试),你会发现:

  • 同一条“回答”内部有多个阶段
  • 同一条“工具”会开始/结束/失败/重试
  • 同一任务可能断线重连,需要补流
  • 你需要把过程展示给用户(但不能泄密)

这时如果继续堆局部状态(local state),会出现典型事故:

  • UI 显示“完成”,但后端还在跑
  • UI 显示“失败”,但其实已成功写入(重复执行风险)
  • 重连后消息顺序错乱、重复渲染

解决思路:从“状态管理”升级为“事件管理”

如果你正在做流式 UI,建议配合阅读:


一、把聊天跑一次任务:从“消息”变成“Run”

建议先引入一个关键概念:Run(一次任务运行)。

  • 一次用户输入 → 对应一个 run
  • run 内部会产生多条事件:消息、步骤、工具、错误、完成

这样你就不会把“聊天消息”与“执行过程”混在一起。

最小数据结构:

  • threadId:会话
  • runId:一次运行
  • events[]:事件追加日志
  • derivedState:由 reducer 派生出的可渲染状态

二、事件模型:最小事件集合与字段规范

1)最小事件类型

  • USER_MESSAGE_CREATED
  • ASSISTANT_MESSAGE_DELTA
  • STEP_STATUS_CHANGED
  • TOOL_CALL_STARTED
  • TOOL_CALL_FINISHED
  • RUN_FAILED / RUN_SUCCEEDED

你可以先从这 7 种开始,后续再细化。

2)每条事件必须具备的“去重与排序字段”

  • eventId:全局唯一(或 runId + seq
  • seq:单 run 单调递增
  • ts:时间戳

为什么 seq 必须有?因为:

  • 网络会乱序
  • 事件可能重放
  • 你需要补流

3)事件载荷(payload)的安全原则

  • 前端事件:只放可展示不敏感的摘要
  • 调试需要的敏感细节:放后端日志(脱敏后)

这点与“流式 UI 不泄密”是同一套红线。


三、Store 设计:用 reducer 让状态可重放

1)为什么 reducer 是关键

如果你把事件“直接驱动 UI”,你会得到不可重现的 bug:

  • 某次乱序导致 UI 状态错了
  • 重连补事件导致重复
  • 并发子任务导致覆盖

而 reducer 的好处是:

  • 事件顺序可控(按 seq 排序)
  • 去重可控(按 eventId)
  • 状态可重放(给一串事件就能算出同样的 UI)

2)一个可落地的 TypeScript 形态(伪代码)

type EventBase = {
  eventId: string;
  runId: string;
  seq: number;
  ts: number;
};

type ChatEvent =
  | (EventBase & { type: 'USER_MESSAGE_CREATED'; text: string })
  | (EventBase & { type: 'ASSISTANT_MESSAGE_DELTA'; delta: string })
  | (EventBase & {
      type: 'STEP_STATUS_CHANGED';
      stepId: string;
      status: 'queued' | 'running' | 'succeeded' | 'failed';
    })
  | (EventBase & { type: 'TOOL_CALL_STARTED'; toolCallId: string; tool: string; summary?: string })
  | (EventBase & {
      type: 'TOOL_CALL_FINISHED';
      toolCallId: string;
      ok: boolean;
      summary?: string;
      errorType?: string;
    })
  | (EventBase & { type: 'RUN_SUCCEEDED' })
  | (EventBase & { type: 'RUN_FAILED'; errorType: string; userMessage: string });

type DerivedRunState = {
  status: 'running' | 'succeeded' | 'failed';
  answerText: string;
  steps: Record<string, { status: string }>;
  tools: Array<{ tool: string; ok?: boolean; summary?: string; errorType?: string }>;
  lastSeq: number;
};

function reduceRun(prev: DerivedRunState, e: ChatEvent): DerivedRunState {
  if (e.seq <= prev.lastSeq) return prev; // 最小保护:seq 回退直接忽略
  const next = { ...prev, lastSeq: e.seq };

  switch (e.type) {
    case 'ASSISTANT_MESSAGE_DELTA':
      next.answerText += e.delta;
      return next;
    case 'STEP_STATUS_CHANGED':
      next.steps = { ...next.steps, [e.stepId]: { status: e.status } };
      return next;
    case 'TOOL_CALL_STARTED':
      next.tools = [...next.tools, { tool: e.tool, summary: e.summary }];
      return next;
    case 'TOOL_CALL_FINISHED':
      next.tools = next.tools.map((t) =>
        t.tool === e.tool ? { ...t, ok: e.ok, summary: e.summary, errorType: e.errorType } : t,
      );
      return next;
    case 'RUN_SUCCEEDED':
      next.status = 'succeeded';
      return next;
    case 'RUN_FAILED':
      next.status = 'failed';
      return next;
    default:
      return next;
  }
}

说明:

  • 真实实现里应该按 eventId 做去重,而不只是 seq
  • TOOL_CALL_FINISHED 的关联应使用 toolCallId,这里简化

3)派生视图(Derived Views)而不是再存一堆 UI 状态

UI 组件应该读取:

  • run.status
  • run.steps(用于步骤条)
  • run.tools(用于工具回执摘要)
  • run.answerText(用于最终答案)

不要再额外存:

  • isStep3Loading
  • hasToolXError
  • showRetryButton

这些都应从派生状态计算出来,避免双写不同步。


四、并发与重试:事件模型如何避免“重复执行感”

1)并发:前端不“猜”,只“渲染事实”

并发时最容易出现 UI 的幻觉:你以为某步完成了,其实只是某个子任务完成。

建议把并发子任务显式建模:

  • stepId 下有多个 subtaskId
  • 或者每个工具调用都有独立 toolCallId

前端只是按事件展示“谁完成了”。不要在前端合并推理。

2)重试:事件里要包含“为什么重试”

用户不关心你重试了几次,但关心:

  • 是否会无限重试
  • 是否会重复写入

所以 UI 需要拿到两个信息:

  • errorType(例如 429/timeout/permission)
  • willRetry(是否自动重试,以及下一次退避)

这能显著降低用户焦虑与误操作。


五、断线重连与补流:可重放的真实价值

当用户刷新页面后,你希望:

  • 还原到同样的步骤状态
  • 已经输出的文本不丢
  • 工具回执摘要仍可见

事件流 + reducer 的方式天然支持:

  • 本地持久化 events(或派生状态快照)
  • 重连后从 lastSeq 开始补事件
  • 用同一个 reducer 重放得到一致状态

这就是“可重放调试”在产品体验上的直接价值。


六、上线 Checklist

  • 引入 Run 概念:一次输入对应一次 runId
  • 事件协议:类型最小集合 + eventId/seq/ts
  • Reducer:所有 UI 状态由事件派生,可重放
  • 去重/乱序:按 eventId 去重、按 seq 排序/忽略回退
  • 安全:事件载荷只含摘要,敏感参数留后端日志(脱敏)
  • 重连:lastSeq 补流,避免断线造成 UI 错乱
  • 埋点:首字延迟、完成时延、断线率、重试次数分布

常见问题

我已经用 Pinia/Vuex 了,还需要事件模型吗?

需要。Pinia 解决的是“存在哪里”,事件模型解决的是“存什么”。你可以用 Pinia 存事件与派生状态,但不要把它当成事件模型的替代。

事件都存前端会不会太大?

可以做分层:

  • 保留最近 N 条事件用于 UI 与重放
  • 更完整的事件日志在后端存(用于审计/排障)
  • 前端只保留必要摘要

想看更多工程化文章见 /articles,也可以在 /apps/new 体验 Agent 能力。