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

📷 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_CREATEDASSISTANT_MESSAGE_DELTASTEP_STATUS_CHANGEDTOOL_CALL_STARTEDTOOL_CALL_FINISHEDRUN_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.statusrun.steps(用于步骤条)run.tools(用于工具回执摘要)run.answerText(用于最终答案)
不要再额外存:
isStep3LoadinghasToolXErrorshowRetryButton
这些都应从派生状态计算出来,避免双写不同步。
四、并发与重试:事件模型如何避免“重复执行感”
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 与重放
- 更完整的事件日志在后端存(用于审计/排障)
- 前端只保留必要摘要