一条 SSE 怎么长成 AI 消息:text、reasoning、tool 与 data part
fetch()收到的只是字节。页面真正需要的,是“这段字属于正文”“这个对象是工具参数”“这条链接是来源”。如果服务端只不断返回delta: string,复杂度最终都会漏到组件里。
🎯 本文目标
把 bytes → event → message part → UI 四层拆开;设计稳定 ID、顺序号、去重和未知事件降级;最后看清 AI SDK UI Message Stream 与 AG-UI 提供了什么,以及什么时候值得定义自己的适配层。
本篇边界
delta: string 为什么很快不够用
纯文本 Demo 只需要:
data: {"delta":"预计"}
data: {"delta":"明天送达"}订单助手继续长出推理折叠、来源卡片和工具调用后,同一个字符串可能是五种东西:
| 内容 | 能否直接拼进正文 | UI 行为 | 是否应发给下一轮模型 |
|---|---|---|---|
text | 是 | Markdown / 富文本 | 是 |
reasoning | 否 | 可折叠、可能不展示 | 视模型与策略 |
source | 否 | 引用卡片 | 通常不作为正文 |
tool | 否 | 参数、审批、执行结果 | 作为工具消息或 part |
data | 否 | 进度、指标、领域组件 | 由应用决定 |
如果前端靠字符串前缀猜类型,例如 [TOOL]...,会立刻遇到转义、增量 JSON、错误归属和协议升级问题。传输可以是文本,语义不能只剩文本。
四层模型:不要让组件直接读网络包
HTTP / SSE bytes
↓ decoder
协议事件 AiUiEvent
↓ reducer
稳定的 UIMessage / parts
↓ selector
TextPart、ToolCard、SourceList、Progress每层只回答一个问题:
- 传输层:一帧是否完整,连接是否关闭;
- 协议层:这一帧是什么事件,属于哪个 run / message / part;
- 对话层:应用事件后,消息状态变成什么;
- 呈现层:当前 part 用哪个组件展示。
因此组件不应该保存“已收到多少 token”,SSE 解析器也不应该直接创建 React 节点。
一份够用的事件信封
先固定所有事件共享的标识,再扩展 payload:
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、恢复旧消息或切换分支后,下标都会变化。
一次订单回答,线上到底发生了什么
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 在中途不是合法对象,不能提前执行业务动作。安全的顺序是:
tool.input.start
→ tool.input.delta × N
→ tool.input.available(解析与 schema 校验完成)
→ awaiting approval / execute
→ tool.output.available | tool.output.error“参数开始出现”只适合展示;“参数可用”才适合校验。至于是否获准和是否执行成功,仍属于业务状态。
reducer:事件只能被应用一次
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 metadata | model、token usage、createdAt |
| 会随流更新、有自己的 UI | data part | 检索进度、计划步骤、图表数据 |
| 用户可阅读的回答 | text part | Markdown 正文 |
| 可追溯引用 | source part | URL、文档标题、页码 |
| 工具生命周期 | tool part | name、input、output、error |
不要把进度对象 JSON.stringify 后塞进正文,也不要用 metadata 承担逐 token 更新。AI SDK 的 UI Message 同样以 parts 表达文本、推理、来源、文件、工具和自定义数据,并把整条消息元信息与流式 data parts 区分开。
服务端做适配,业务代码不要绑死供应商事件
模型 SDK、Agent 框架和自有后端的事件名不会完全相同。建议增加一层窄适配器:
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:
type PublicRunError = {
code: 'NETWORK_LOST' | 'MODEL_FAILED' | 'TOOL_FAILED' | 'PROTOCOL_INVALID'
message: string
retryable: boolean
failedPartId?: string
}- 协议只发送可公开信息,堆栈和供应商原始响应留在服务端日志;
- 错误要绑定 run,必要时绑定 part / toolCall;
retryable是产品决策,不要由组件猜;- 未知事件类型应记录并忽略,未知协议主版本则应拒绝继续解析。
推荐响应头显式声明协议,例如:
Content-Type: text/event-stream
X-AI-UI-Protocol: product-ui-stream/1
Cache-Control: no-cache协议上线前的十项检查
- 每个 event、run、message、part、tool call 是否都有稳定 ID?
- 重放同一事件是否不会重复文本或重复动作?
- 缺一个 sequence 时是否停止合并并重新同步?
- 切换 thread 后,旧连接事件是否会被拒绝?
- 增量工具参数是否只在 complete / available 后解析?
- text、reasoning、source、tool、data 是否保持语义分离?
- 错误是否能归属到 run 或 part,并区分是否可重试?
- 未知事件与新字段是否有前向兼容策略?
- 持久化的是规范化消息 / 事件,还是某个组件的临时 props?
- 是否有完整事件夹具,可以脱离模型复现每种状态?
结语:协议是状态边界,不是换一种 JSON
SSE 只解决“怎样持续送达”,协议解决“送达的东西是什么意思”。一套可靠消息协议至少要表达稳定身份、part 类型、运行生命周期、顺序、去重和错误归属。
最值得保留的判断是:
组件消费稳定的消息 part,运行时消费有序事件,传输层只负责搬运字节。
下一篇继续处理更难的问题:事件已经定义清楚,但用户刷新了页面,正在生成的回答怎样接回来?
📚 核对范围与官方资料
- AI SDK:Stream Protocol、Streaming Custom Data、
UIMessage - AG-UI:Events、State Management
- 示例中的
product-ui-stream/1是文章自定义协议,用于说明设计方法,不是上述项目的官方字段。