文章

【笔记】AI 事件流的传输、语义与状态同步

【笔记】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 还要表达:

  • 一段推理或回答从哪里开始、在哪里结束。
  • 某次工具调用的名称、参数增量、执行结果和错误。
  • 运行、步骤和消息分别处于什么生命周期。
  • 后端权威状态是完整快照,还是对旧状态的增量修改。
  • 执行是否需要用户审批,以及怎样从中断点继续。
  • 断线重连后,本地状态怎样与后端重新对齐。

这些信息不能靠字符串内容猜测。事件必须携带 messageIdtoolCallIdrunId 等关联键,以及明确的类型和阶段。

所以真正的数据流更接近:

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 能保证帧内格式,不自动保证业务事件幂等。
  • 连接按序到达,不代表跨重连后不会重复或缺失。
  • idLast-Event-ID 提供恢复机制的基础,但服务端仍需实现事件保留和续传。
  • 收到 EOF 只表示这次 HTTP 响应结束,不天然等于 Agent 成功完成。

4. 事件语义层:用生命周期和关联键消除猜测

4.1 三类生命周期不能混用

一个 Agent 流里常同时存在三层生命周期:

生命周期开始与结束表示什么典型关联键
Run一次 Agent 执行runIdthreadId
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 的实际事件名称会随主版本演进,但当前模型大致包含:

  • startfinish:消息流或响应生命周期。
  • text-starttext-deltatext-end:文本 Part。
  • reasoning-startreasoning-deltareasoning-end:推理 Part。
  • tool-input-starttool-input-deltatool-input-available:工具参数逐步形成。
  • tool-output-availabletool-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_STARTEDRUN_FINISHEDRUN_ERROR界定一次执行
Step 生命周期STEP_STARTEDSTEP_FINISHED暴露内部阶段
文本消息TEXT_MESSAGE_START/CONTENT/END增量构造消息
工具调用TOOL_CALL_START/ARGS/END/RESULT构造调用及结果
共享状态STATE_SNAPSHOTSTATE_DELTA同步 Agent 状态
消息对账MESSAGES_SNAPSHOT用权威消息集合校正本地状态
扩展RAWCUSTOM传递原始或自定义事件

AG-UI 比单纯聊天流多出的关键能力是显式状态同步。它不仅让前端展示 Agent 说了什么,还允许前端投影 Agent 当前维护的结构化状态。

6.2 消息和工具调用依靠 ID 关联

TEXT_MESSAGE_CONTENT 必须通过 messageId 找到对应消息后追加;TOOL_CALL_ARGSTOOL_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 StreamAG-UI
主要目标构造前端 UIMessage标准化 Agent Run 到 UI 的事件
核心状态消息及其 PartsRun、消息、工具调用和共享状态
状态对账以消息流和应用自定义数据为主明确提供 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

推荐的处理顺序是:

  1. 解析 SSE 帧,得到完整事件负载。
  2. 校验事件类型、关联 ID 和当前生命周期是否合法。
  3. 对同一 ID 的事件做幂等或重复检测。
  4. 用 reducer 更新规范化状态。
  5. 从状态派生 UI,而不是直接在网络回调里操作组件。
  6. 遇到未知状态或重连缺口时,请求权威快照重新对账。
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 的开始和结束不能混用。
  • 关联锚点runIdmessageIdtoolCallId 决定增量更新哪个对象。
  • 恢复模型:Delta 适合连续更新,Snapshot 适合初始化和重新对账。
  • Vercel AI SDK:事件最终归并为 UIMessage.parts,与 AI SDK 客户端紧密配合。
  • AG-UI:围绕 Agent Run,同步消息、工具调用和共享状态。
  • SSE 边界:自动重连只是连接能力,业务恢复仍需要事件 ID、日志或权威快照。
  • 实现原则:先规范化状态,再渲染 UI;不要在网络回调中直接拼组件状态。

11. 参考资料

本文由作者按照 CC BY 4.0 进行授权