【笔记】AI 事件流的传输、语义与状态同步
1. 一句话心智模型
AI 应用里的“流式输出”不是把字符串切碎后不断发送,而是服务端持续发出一组有身份、有顺序、有生命周期的事件,客户端再把事件归并成可渲染状态。
完整链路可以分成三层:
flowchart LR
A[传输层<br/>HTTP + SSE] --> B[事件语义层<br/>text / tool / state / lifecycle]
B --> C[状态归并层<br/>Reducer / Client SDK]
C --> D[UI 投影<br/>消息 / 卡片 / 审批 / 进度]
- SSE 解决一条事件怎样划分、编码和送达。
- Vercel AI SDK、AG-UI 等事件协议定义事件表达什么。
- 客户端状态机决定收到事件后怎样更新消息、工具调用和共享状态。
只理解其中一层,很容易把“连接还活着”“某段文本结束了”和“整个 Agent 运行完成了”混为一谈。
2. 为什么普通文本流不够
纯文本聊天只需要不断追加字符:
1
"北" + "京" + "今天" + "晴"
Agent UI 还要表达:
- 一段推理或回答从哪里开始、在哪里结束。
- 某次工具调用的名称、参数增量、执行结果和错误。
- 运行、步骤和消息分别处于什么生命周期。
- 后端权威状态是完整快照,还是对旧状态的增量修改。
- 执行是否需要用户审批,以及怎样从中断点继续。
- 断线重连后,本地状态怎样与后端重新对齐。
这些信息不能靠字符串内容猜测。事件必须携带 messageId、toolCallId、runId 等关联键,以及明确的类型和阶段。
所以真正的数据流更接近:
1
2
3
4
5
6
7
8
9
RUN_STARTED
TEXT_MESSAGE_START(messageId=m1)
TEXT_MESSAGE_CONTENT(messageId=m1, delta="我来查询")
TOOL_CALL_START(toolCallId=t1, toolName="weather")
TOOL_CALL_ARGS(toolCallId=t1, delta="{...}")
TOOL_CALL_RESULT(toolCallId=t1, content="{...}")
TEXT_MESSAGE_CONTENT(messageId=m1, delta="北京今天晴")
TEXT_MESSAGE_END(messageId=m1)
RUN_FINISHED
3. 传输层:SSE 只负责传送事件
3.1 SSE 的帧格式
SSE 使用 UTF-8 文本。一个事件由若干字段行组成,以空行结束:
1
2
3
4
event: update
id: 42
data: {"type":"text-delta","delta":"你好"}
标准字段包括:
| 字段 | 作用 |
|---|---|
data | 事件负载;多个 data 行用换行连接 |
event | 浏览器分发的事件名;省略时默认为 message |
id | 更新 Last Event ID,为自动重连提供位置锚点 |
retry | 建议浏览器使用的重连等待时间 |
以 : 开头的行是注释,常被用作心跳。没有空行,事件就还没有完成分帧。
3.2 SSE 的能力边界
SSE 原生方向是服务端到客户端。AI 应用通常组合使用:
1
2
Client -- HTTP POST:用户消息和当前上下文 --> Server
Client <-- HTTP response body:SSE 事件流 ------ Server
这和浏览器 EventSource 的典型 GET 用法不同。EventSource 提供自动重连和 Last-Event-ID,但不适合直接发送带复杂请求体的 POST。Vercel AI SDK 等实现通常通过 fetch 读取 POST 响应中的 SSE 流,并由 SDK 自己负责解析与状态更新。
需要牢记:
- SSE 能保证帧内格式,不自动保证业务事件幂等。
- 连接按序到达,不代表跨重连后不会重复或缺失。
id和Last-Event-ID提供恢复机制的基础,但服务端仍需实现事件保留和续传。- 收到 EOF 只表示这次 HTTP 响应结束,不天然等于 Agent 成功完成。
4. 事件语义层:用生命周期和关联键消除猜测
4.1 三类生命周期不能混用
一个 Agent 流里常同时存在三层生命周期:
| 生命周期 | 开始与结束表示什么 | 典型关联键 |
|---|---|---|
| Run | 一次 Agent 执行 | runId、threadId |
| Message | 一条可展示消息 | messageId |
| Tool Call | 一次工具调用 | toolCallId |
TEXT_MESSAGE_END 只表示这条文本消息结束。后面仍可能执行工具或产生新消息。TOOL_CALL_END 通常表示参数流已经结束,也不必然表示工具执行已经返回结果。只有 Run 级终止事件才能说明本轮执行结束或失败。
4.2 Start / Delta / End 是增量对象的通用模型
对于文本和工具参数,常见归并方式是:
1
2
3
START(id) 创建空对象
DELTA(id, x) 按 id 找到对象并追加 x
END(id) 标记该对象不再接收增量
客户端不能简单地把增量加到“最后一条消息”。文本、工具调用和推理片段可能交错,正确目标必须由 ID 确定。
以文本为例:
1
2
3
messages[m1] = { role: "assistant", content: "" }
messages[m1].content += delta
messages[m1].complete = true
这个 reducer 才是流式打字效果的本质。UI 只是把不断变化的 messages[m1].content 重新渲染出来。
4.3 快照和增量解决不同问题
状态同步通常有两种事件:
- Snapshot:给出某一时刻的完整权威状态。
- Delta:描述怎样从旧状态变到新状态。
Delta 带宽更低,但依赖正确的前置状态;Snapshot 体积更大,却能在首次加载、重连或状态漂移时重新建立基线。
一个稳健模型是:
1
2
3
4
State(t+1) = Apply(State(t), Delta)
如果 State(t) 不可信:
State(t+1) = Snapshot
协议若只有增量事件,就需要额外定义断线后怎样恢复;否则客户端无法判断自己漏掉了哪一步。
5. Vercel AI SDK:围绕 UIMessage 归并事件
Vercel AI SDK 6 的 UI Message Stream Protocol 使用 SSE 传输 UIMessageChunk。自定义后端返回该协议时,需要使用对应的 UI message stream 响应格式,并设置协议识别头。
它的目标不是定义通用 Agent 网络协议,而是让服务端生成流与 useChat 等客户端能力直接协作。
5.1 UIMessage 是客户端的目标状态
当前 UIMessage 的核心结构是:
1
2
3
4
5
6
interface UIMessage<Metadata, DataParts, Tools> {
id: string;
role: 'system' | 'user' | 'assistant';
metadata?: Metadata;
parts: UIMessagePart<DataParts, Tools>[];
}
parts 可以承载文本、推理、工具调用、工具结果、文件以及自定义数据。事件流的职责,是逐步构造或更新这些 Part。
5.2 UI Message Stream 的典型事件
AI SDK 的实际事件名称会随主版本演进,但当前模型大致包含:
start、finish:消息流或响应生命周期。text-start、text-delta、text-end:文本 Part。reasoning-start、reasoning-delta、reasoning-end:推理 Part。tool-input-start、tool-input-delta、tool-input-available:工具参数逐步形成。tool-output-available、tool-output-error:工具执行结果。data-*:类型化的自定义数据 Part。error:流内错误信息。
这里的 finish 是 AI SDK 协议自己的生命周期信号,不能泛化成所有 SSE 流的标准结束事件。
5.3 客户端乐观写入不是传输协议保证
useChat 可以在发请求前把用户消息先写入本地状态,于是 UI 立即出现用户气泡,服务端只需流回助手侧变化。
1
2
3
4
sendMessage
├── 本地加入 user message
└── POST 当前消息上下文
└── response stream 逐步构造 assistant message
这是客户端 SDK 的状态管理策略,不是 SSE 规则,也不是所有 AI 事件协议必须采用的行为。自定义客户端若要支持失败回滚、重试或离线队列,还需要自己定义乐观消息的确认状态。
6. AG-UI:围绕 Agent Run 同步消息和共享状态
AG-UI 把 Agent 后端到用户界面的交互定义为一组开放事件。典型调用由客户端 POST RunAgentInput 开始,服务端通过 SSE 连续返回事件;其他传输方式也可以承载同样的事件模型。
6.1 事件按职责分组
| 事件组 | 代表事件 | 作用 |
|---|---|---|
| Run 生命周期 | RUN_STARTED、RUN_FINISHED、RUN_ERROR | 界定一次执行 |
| Step 生命周期 | STEP_STARTED、STEP_FINISHED | 暴露内部阶段 |
| 文本消息 | TEXT_MESSAGE_START/CONTENT/END | 增量构造消息 |
| 工具调用 | TOOL_CALL_START/ARGS/END/RESULT | 构造调用及结果 |
| 共享状态 | STATE_SNAPSHOT、STATE_DELTA | 同步 Agent 状态 |
| 消息对账 | MESSAGES_SNAPSHOT | 用权威消息集合校正本地状态 |
| 扩展 | RAW、CUSTOM | 传递原始或自定义事件 |
AG-UI 比单纯聊天流多出的关键能力是显式状态同步。它不仅让前端展示 Agent 说了什么,还允许前端投影 Agent 当前维护的结构化状态。
6.2 消息和工具调用依靠 ID 关联
TEXT_MESSAGE_CONTENT 必须通过 messageId 找到对应消息后追加;TOOL_CALL_ARGS 和 TOOL_CALL_RESULT 通过 toolCallId 关联同一次调用。工具调用还可以用 parentMessageId 归入某条 assistant message。
因此协议消费者需要维护索引,而不是只有一个字符串 buffer:
1
2
3
4
messagesById[messageId]
toolCallsById[toolCallId]
currentRun[runId]
sharedState
6.3 Human-in-the-Loop 是跨 Run 的恢复
当前 AG-UI 运行结果可以表达 interrupt。一个常见实现模型是:
sequenceDiagram
participant UI
participant Agent
UI->>Agent: Run 1
Agent-->>UI: events...
Agent-->>UI: RUN_FINISHED(interrupt)
Note over UI: 展示审批或输入界面
UI->>Agent: Run 2 + resume data
Agent-->>UI: events...
Agent-->>UI: RUN_FINISHED
中断不是保持原 HTTP 请求无限等待。前一条事件流结束,用户完成操作后,客户端发起新的 Run,并携带恢复所需的信息。
具体的 interrupt 数据结构、是否必须一次响应全部中断、幂等键和 checkpoint 约束,会受到 AG-UI 版本及后端框架适配器影响。除非使用的 SDK 明确保证,不能把某个框架的恢复规则当成协议的普遍事实。
7. 两种协议的核心差异
| 维度 | Vercel AI SDK UI Message Stream | AG-UI |
|---|---|---|
| 主要目标 | 构造前端 UIMessage | 标准化 Agent Run 到 UI 的事件 |
| 核心状态 | 消息及其 Parts | Run、消息、工具调用和共享状态 |
| 状态对账 | 以消息流和应用自定义数据为主 | 明确提供 state/messages snapshot 与 delta |
| 生态重心 | TypeScript 与 AI SDK 客户端 | 跨 Agent 框架和多语言 SDK |
| 传输 | 当前 UI stream 基于 HTTP + SSE | 事件模型可与传输解耦,常用 HTTP + SSE |
| 适用场景 | 已采用 AI SDK 的聊天或生成式 UI | 需要 Agent 与多种 UI/框架互操作 |
选择时不应只比较事件数量:
- 已经使用 AI SDK,目标是快速构造消息和工具 UI,优先使用它的原生 stream protocol。
- 后端 Agent 框架多样,需要显式同步 Agent 状态,或者希望前端不绑定某个生成 SDK,可以考虑 AG-UI。
- 只是简单文本补全,不需要工具、状态或跨框架互操作时,自定义的最小 SSE 事件可能更合适。
8. 客户端应该怎样归并事件
一个可恢复的消费者至少需要区分四种输入:
1
2
3
4
ClientWrite 用户本地操作,例如乐观消息
ServerDelta 文本、参数或状态增量
ServerSnapshot 后端权威快照
Lifecycle start / finish / error / interrupt
推荐的处理顺序是:
- 解析 SSE 帧,得到完整事件负载。
- 校验事件类型、关联 ID 和当前生命周期是否合法。
- 对同一 ID 的事件做幂等或重复检测。
- 用 reducer 更新规范化状态。
- 从状态派生 UI,而不是直接在网络回调里操作组件。
- 遇到未知状态或重连缺口时,请求权威快照重新对账。
flowchart TD
A[SSE Frame] --> B[Decode Event]
B --> C{关联键和顺序有效?}
C -->|否| D[记录错误 / 请求快照]
C -->|是| E[Reducer]
E --> F[Normalized State]
F --> G[UI Projection]
网络事件和 UI 组件之间增加状态层,才能处理重试、重复、交错事件和重连,而不是把问题隐藏在一组回调里。
9. 常见误区
9.1 把 SSE 当成完整协议
SSE 不知道什么是工具调用、任务状态或消息。data: 中放 JSON 只是承载方式,JSON 的字段和生命周期仍需上层协议定义。
9.2 收到 end 就清空所有 loading
Message end、Tool Call end、Step finish 和 Run finish 属于不同层级。只有和 UI loading 状态同层级的结束事件,才能关闭对应 loading。
9.3 把 delta 当成可以独立解释的消息
delta 通常缺少完整上下文,必须按 ID 和顺序归并。日志、队列或重试系统若只保存部分 delta,很可能无法重建状态。
9.4 默认断线重连能恢复业务状态
浏览器可以重新建立 SSE 连接,但服务端没有事件日志、游标或快照接口时,连接恢复不等于状态恢复。
9.5 把 SDK 实现细节写成协议保证
例如客户端乐观加入用户消息、interrupt 的恢复字段、某种工具 Part 状态名称,都可能随 SDK 版本变化。系统设计应先依赖协议稳定语义,再把具体版本适配隔离在编解码层。
10. 复习索引
- 三层模型:SSE 负责传输,事件协议负责语义,Reducer 负责把事件投影成状态。
- 三个生命周期:Run、Message、Tool Call 的开始和结束不能混用。
- 关联锚点:
runId、messageId、toolCallId决定增量更新哪个对象。 - 恢复模型:Delta 适合连续更新,Snapshot 适合初始化和重新对账。
- Vercel AI SDK:事件最终归并为
UIMessage.parts,与 AI SDK 客户端紧密配合。 - AG-UI:围绕 Agent Run,同步消息、工具调用和共享状态。
- SSE 边界:自动重连只是连接能力,业务恢复仍需要事件 ID、日志或权威快照。
- 实现原则:先规范化状态,再渲染 UI;不要在网络回调中直接拼组件状态。