Skip to content

一条 SSE 怎么长成 AI 消息:text、reasoning、tool 与 data part ​

fetch() 收到的只是字节。页面真正需要的,是“这段字属于正文”“这个对象是工具参数”“这条链接是来源”。如果服务端只不断返回 delta: string,复杂度最终都会漏到组件里。

🎯 本文目标

把 bytes → event → message part → UI 四层拆开;设计稳定 ID、顺序号、去重和未知事件降级;最后看清 AI SDK UI Message Stream 与 AG-UI 提供了什么,以及什么时候值得定义自己的适配层。

本篇边界

  • 流式输出篇讲 SSE、ReadableStream 和打字机如何工作。
  • 对话状态篇讲 thread、message、run、branch 由谁管理。
  • 本篇只讲:字节抵达后,怎样表达为有语义、可重放的事件。

delta: string 为什么很快不够用 ​

纯文本 Demo 只需要:

text
data: {"delta":"预计"}
data: {"delta":"明天送达"}

订单助手继续长出推理折叠、来源卡片和工具调用后,同一个字符串可能是五种东西:

内容能否直接拼进正文UI 行为是否应发给下一轮模型
text是Markdown / 富文本是
reasoning否可折叠、可能不展示视模型与策略
source否引用卡片通常不作为正文
tool否参数、审批、执行结果作为工具消息或 part
data否进度、指标、领域组件由应用决定

如果前端靠字符串前缀猜类型,例如 [TOOL]...,会立刻遇到转义、增量 JSON、错误归属和协议升级问题。传输可以是文本,语义不能只剩文本。


四层模型:不要让组件直接读网络包 ​

text
HTTP / SSE bytes
      ↓ decoder
协议事件 AiUiEvent
      ↓ reducer
稳定的 UIMessage / parts
      ↓ selector
TextPart、ToolCard、SourceList、Progress

每层只回答一个问题:

  1. 传输层:一帧是否完整,连接是否关闭;
  2. 协议层:这一帧是什么事件,属于哪个 run / message / part;
  3. 对话层:应用事件后,消息状态变成什么;
  4. 呈现层:当前 part 用哪个组件展示。

因此组件不应该保存“已收到多少 token”,SSE 解析器也不应该直接创建 React 节点。


一份够用的事件信封 ​

先固定所有事件共享的标识,再扩展 payload:

typescript
type EventEnvelope<T extends string, P> = {
  version: 1
  type: T
  eventId: string
  threadId: string
  runId: string
  messageId: string
  sequence: number
  at: string
  payload: P
}

type AiUiEvent =
  | EventEnvelope<'message.start', { role: 'assistant' }>
  | EventEnvelope<'part.start', { partId: string; kind: PartKind }>
  | EventEnvelope<'part.delta', { partId: string; delta: string }>
  | EventEnvelope<'part.complete', { partId: string }>
  | EventEnvelope<'tool.input.available', ToolInput>
  | EventEnvelope<'tool.output.available', ToolOutput>
  | EventEnvelope<'source.available', SourcePart>
  | EventEnvelope<'data.available', DataPart>
  | EventEnvelope<'run.error', { code: string; message: string; retryable: boolean }>
  | EventEnvelope<'run.complete', { finishReason: string }>

type PartKind = 'text' | 'reasoning' | 'tool' | 'source' | 'file' | 'data'

这些标识各有用途:

字段解决的问题
eventId重连重放时去重
threadId防止切线程后旧流写进新页面
runId区分重试、重新生成和并发运行
messageId把事件归到稳定消息,而不是“最后一条”
partId多个文本 / 工具 part 并行更新
sequence检测丢包、重复和乱序
version允许协议演进和适配

不要把数组下标当 ID。插入 tool result、恢复旧消息或切换分支后,下标都会变化。


一次订单回答,线上到底发生了什么 ​

text
seq 1  message.start             message=m_9
seq 2  part.start(text)          part=p_text
seq 3  part.delta                "我来查询"
seq 4  part.complete             part=p_text
seq 5  tool.input.available      toolCall=tc_1 queryOrder({id:"A42"})
seq 6  tool.output.available     toolCall=tc_1 {status:"shipped"}
seq 7  source.available          part=p_source /orders/A42
seq 8  part.start(text)          part=p_answer
seq 9  part.delta                "订单已发货"
seq 10 part.complete             part=p_answer
seq 11 run.complete              reason=stop

这不是十一条消息,而是一条 assistant message 的多个 part,以及一个 run 的生命周期。把每个 chunk 都 push 成消息,会造成头像重复、Markdown 重解析和错误的持久化结构。

工具参数为什么需要独立生命周期 ​

工具参数通常是增量 JSON。{"orderId":"A 在中途不是合法对象,不能提前执行业务动作。安全的顺序是:

text
tool.input.start
  → tool.input.delta × N
  → tool.input.available(解析与 schema 校验完成)
  → awaiting approval / execute
  → tool.output.available | tool.output.error

“参数开始出现”只适合展示;“参数可用”才适合校验。至于是否获准和是否执行成功,仍属于业务状态。


reducer:事件只能被应用一次 ​

typescript
type StreamState = {
  runId: string
  lastSequence: number
  appliedEventIds: Set<string>
  messages: Record<string, UiMessage>
}

function applyEvent(state: StreamState, event: AiUiEvent): StreamState {
  if (event.runId !== state.runId) return state
  if (state.appliedEventIds.has(event.eventId)) return state

  if (event.sequence !== state.lastSequence + 1) {
    throw new Error(`event gap: expected ${state.lastSequence + 1}`)
  }

  const next = structuredClone(state)
  next.appliedEventIds.add(event.eventId)
  next.lastSequence = event.sequence

  // 按 messageId + partId 定位并更新;不要默认修改最后一项。
  return reduceKnownEvent(next, event)
}

生产实现还要决定 Set 如何裁剪、gap 是暂停等待还是请求 snapshot。但核心不变量不变:

同一个事件最多应用一次;只有连续事件才能推进游标。

如果传输层可能乱序,可以先按 sequence 放进缓冲区;如果服务端保证顺序,发现 gap 就停止合并并重新同步,通常更简单。


metadata、data part 与消息正文不要混用 ​

一个实用判断:

数据推荐位置例子
整条消息共有且不会高频追加message metadatamodel、token usage、createdAt
会随流更新、有自己的 UIdata part检索进度、计划步骤、图表数据
用户可阅读的回答text partMarkdown 正文
可追溯引用source partURL、文档标题、页码
工具生命周期tool partname、input、output、error

不要把进度对象 JSON.stringify 后塞进正文,也不要用 metadata 承担逐 token 更新。AI SDK 的 UI Message 同样以 parts 表达文本、推理、来源、文件、工具和自定义数据,并把整条消息元信息与流式 data parts 区分开。


服务端做适配,业务代码不要绑死供应商事件 ​

模型 SDK、Agent 框架和自有后端的事件名不会完全相同。建议增加一层窄适配器:

typescript
async function* toProductEvents(
  source: AsyncIterable<ProviderEvent>,
  context: RunContext,
): AsyncIterable<AiUiEvent> {
  for await (const event of source) {
    if (event.kind === 'text-delta') yield mapTextDelta(event, context)
    else if (event.kind === 'tool-call') yield mapToolInput(event, context)
    else if (event.kind === 'source') yield mapSource(event, context)
    // 未支持的 provider event 记日志,不直接泄漏给组件。
  }
}

这样更换模型供应商只改适配层;浏览器、持久化和测试继续依赖产品协议。若前后端都已经采用 AI SDK,则直接使用其 UI Message Stream 往往更省事;跨多种 Agent 和客户端时,可以评估 AG-UI 这类标准事件协议。

两类官方协议怎样理解 ​

方案强项要注意
AI SDK UI Message Stream与 useChat、UIMessage.parts、自定义 data parts 衔接紧密更适合 AI SDK 技术栈;持久化模型仍由应用设计
AG-UI events文本、工具、生命周期、消息 snapshot、state snapshot / delta 都有显式事件覆盖面更宽,不代表你的权限、事务和数据库模型已经解决
自有协议 + adapters字段与领域约束最稳定,可屏蔽多供应商差异版本、SDK、兼容性和测试成本由团队承担

协议不是越通用越好。先确认消费端数量、跨语言需要和已有运行时,再决定是否引入标准。


错误也必须有语义 ​

网络断开、模型拒答、工具失败和业务冲突不能都变成红色 Error:

typescript
type PublicRunError = {
  code: 'NETWORK_LOST' | 'MODEL_FAILED' | 'TOOL_FAILED' | 'PROTOCOL_INVALID'
  message: string
  retryable: boolean
  failedPartId?: string
}
  • 协议只发送可公开信息,堆栈和供应商原始响应留在服务端日志;
  • 错误要绑定 run,必要时绑定 part / toolCall;
  • retryable 是产品决策,不要由组件猜;
  • 未知事件类型应记录并忽略,未知协议主版本则应拒绝继续解析。

推荐响应头显式声明协议,例如:

http
Content-Type: text/event-stream
X-AI-UI-Protocol: product-ui-stream/1
Cache-Control: no-cache

协议上线前的十项检查 ​

  1. 每个 event、run、message、part、tool call 是否都有稳定 ID?
  2. 重放同一事件是否不会重复文本或重复动作?
  3. 缺一个 sequence 时是否停止合并并重新同步?
  4. 切换 thread 后,旧连接事件是否会被拒绝?
  5. 增量工具参数是否只在 complete / available 后解析?
  6. text、reasoning、source、tool、data 是否保持语义分离?
  7. 错误是否能归属到 run 或 part,并区分是否可重试?
  8. 未知事件与新字段是否有前向兼容策略?
  9. 持久化的是规范化消息 / 事件,还是某个组件的临时 props?
  10. 是否有完整事件夹具,可以脱离模型复现每种状态?

结语:协议是状态边界,不是换一种 JSON ​

SSE 只解决“怎样持续送达”,协议解决“送达的东西是什么意思”。一套可靠消息协议至少要表达稳定身份、part 类型、运行生命周期、顺序、去重和错误归属。

最值得保留的判断是:

组件消费稳定的消息 part,运行时消费有序事件,传输层只负责搬运字节。

下一篇继续处理更难的问题:事件已经定义清楚,但用户刷新了页面,正在生成的回答怎样接回来?


📚 核对范围与官方资料

原创声明

本文首发于我的个人博客。如需转载请注明作者及 原文链接。

👀 本文阅读 ··· 次

基于 VitePress 构建

本站总访问量 ··· 次·访客数 ··· 人