消息数组够用吗?从 useChat 到对话运行时
前一篇《UI 状态怎么管》处理的是草稿、滚动和折叠。把页面全部拿掉,消息仍然要继续生成,停止后仍要知道停在哪,刷新后仍要找到线程,编辑历史问题后仍要保留两条回答。
这些不会随着组件卸载而消失的 AI 交互事实,就是对话状态。
🎯 本文目标
读完你应该能:分清 message、part、run、thread、branch 与 persistence;判断什么时候 Message[] 足够、什么时候已经在手写 runtime;理解停止、重试和刷新恢复的真实边界;最后按责任而不是 UI 外观,比较手写状态、AI SDK useChat、Ant Design X SDK 与 assistant-ui runtime。
本篇故意不讲什么
- 输入草稿、自动滚动、折叠状态属于 UI 状态,已经在上一篇处理。
- Tool Call 是否获准、订单是否真正修改属于业务状态,放到下一篇。
- 本篇只记录“对话中出现了一个工具调用及其结果”,不把对话里的成功文案当成业务事实。
为什么第一版总是一个数组
最小聊天实现通常从这里开始:
type Message = {
id: string
role: 'user' | 'assistant'
content: string
}
const messages: Message[] = []只要需求满足下面四个条件,数组完全够用:
- 只有一个会话;
- 消息只有纯文本;
- 一次请求成功或失败后不恢复;
- 不编辑历史消息,也不保留重新生成的旧答案。
问题在于,产品需求通常按这个顺序长出来:
纯文本
→ 流式文本
→ 停止 / 重试
→ reasoning / source / tool part
→ 多线程
→ 刷新恢复
→ 编辑历史问题
→ 回答分支数组不会突然失效,但你会不断给它补字段、回调和旁路 store。最后项目已经有了一套 runtime,只是没有承认。
一条边界:它描述交互过程,还是页面表现
| 状态 | 归属 | 原因 |
|---|---|---|
status = streaming | 对话 | 本轮交互尚未结束 |
| 是否显示闪烁光标 | UI | 只是如何呈现 streaming |
| reasoning part 内容 | 对话 | 消息的一部分 |
| reasoning 是否展开 | UI | 当前浏览器偏好 |
| 当前 thread ID | 对话 | 决定消息集合与后续上下文 |
| 线程侧栏是否展开 | UI | 只改变布局 |
| Tool Call 参数和结果 | 对话 | Agent 交互记录 |
| Tool 对应订单是否已删除 | 业务 | 数据库中的真实事实 |
对话状态可以驱动 UI,却不能由 UI 反推。例如按钮从“停止”变回“发送”,不能证明服务端 run 已经取消;绿色工具卡也不能证明数据库事务提交。
对话状态至少有六个对象
1. Message:谁在什么时候说了什么
type ConversationMessage = {
id: string
threadId: string
role: 'system' | 'user' | 'assistant' | 'tool'
parts: MessagePart[]
parentId: string | null
createdAt: string
}消息需要稳定 ID。数组 index 不能承担身份:向上分页、插入系统消息、切分支后,index 都会变化。
2. Part:一条消息不再只是字符串
流式 AI 消息里会交错出现:
type MessagePart =
| { id: string; type: 'text'; text: string }
| { id: string; type: 'reasoning'; text: string }
| { id: string; type: 'source'; url: string; title?: string }
| { id: string; type: 'file'; fileId: string; mime: string }
| {
id: string
type: 'tool-call'
toolCallId: string
toolName: string
args: unknown
result?: unknown
}如果所有东西都拼进 content: string,工具结果只能变成 JSON 文本,reasoning 无法单独折叠,来源也无法稳定渲染。part 是 AI 对话和普通 IM 消息的重要分叉。
3. Run:一次生成不是一条消息
用户消息创建后,会触发一次 run:
type ConversationRun = {
id: string
threadId: string
triggerMessageId: string
status:
| 'submitted'
| 'streaming'
| 'awaiting-tool'
| 'completed'
| 'stopped'
| 'failed'
| 'disconnected'
assistantMessageId?: string
error?: { code: string; retryable: boolean }
}消息回答“留下了什么”,run 回答“这次生成经历了什么”。同一条用户消息可以重新生成多次,因此 run 与 message 不是一对一。
4. Thread:一组消息和运行的边界
type ConversationThread = {
id: string
title: string
activeBranchHeadId: string | null
createdAt: string
updatedAt: string
version: number
}thread 不只是侧边栏的一行标题。它决定加载哪组历史、当前可见分支、发请求时带哪个上下文,以及持久化和权限的边界。
5. Branch:编辑历史不是原地覆盖
假设对话是:
U1 → A1 → U2 → A2用户编辑 U1 并重新生成。如果直接覆盖 U1,A1、U2、A2 的上下文全部失真。更诚实的数据结构是:
U1
├── A1 → U2 → A2
└── U1' → A1'用 parentId 可以表达:
const messages = [
{ id: 'u1', parentId: null, role: 'user' },
{ id: 'a1', parentId: 'u1', role: 'assistant' },
{ id: 'u2', parentId: 'a1', role: 'user' },
{ id: 'a2', parentId: 'u2', role: 'assistant' },
{ id: 'u1-edit', parentId: null, role: 'user' },
{ id: 'a1-edit', parentId: 'u1-edit', role: 'assistant' },
]真实实现还需要保存当前 branch head。仅持久化“当前可见数组”,旧分支就丢了;仅持久化整棵树却不保存 head,刷新后又不知道显示哪条路径。
6. Persistence:内存状态不等于会话历史
持久化至少分三份:
| 内容 | 谁更适合拥有 | 为什么 |
|---|---|---|
| thread 元数据 | 服务端数据库 | 要跨设备、鉴权、分页和搜索 |
| message / branch 图 | 服务端数据库 | 是对话事实,需要恢复和审计来源 |
| 当前进行中的 run | runtime / durable execution store | 可能跨断线、审批和进程重启 |
| 草稿、折叠、滚动 | 浏览器 UI 层 | 不是对话事实 |
localStorage 可以让 demo 刷新不丢,但不能自动解决多设备同步、用户隔离、冲突、删除合规和运行恢复。
停止、失败和断线不是同一个状态
很多实现只有 loading: boolean:
true = 正在生成
false = 其他一切这会把完全不同的结局压扁:
| 结局 | 已生成内容 | 能否重试 | 服务端是否一定停止 |
|---|---|---|---|
| completed | 完整 | 可重新生成 | 是 |
| stopped | 保留半截 | 可以继续或重试 | 取决于取消是否传到服务端 |
| failed | 可能有半截 | 看错误类型 | run 已失败 |
| disconnected | 客户端只看到半截 | 应先尝试恢复流 | 服务端可能仍在跑 |
AbortController.abort() 只能证明浏览器不再等待。如果服务端没有监听取消信号,模型和工具仍可能继续执行。对话层应该诚实记录“用户停止”“连接断开”和“服务端确认终止”的差异。
重试也有两种
- retry transport:同一个 run 因网络错误重连或继续;
- regenerate answer:创建一个新 run 和新回答分支。
把两者都叫 retry,会让数据模型和用户预期一起混乱。
谁是最终真相:客户端、runtime 还是数据库
一个稳妥的责任划分是:
浏览器
- 乐观消息
- 当前流式 part
- 临时连接状态
对话 runtime
- run 生命周期
- 事件顺序
- tool / approval 暂停点
服务端数据库
- thread 元数据
- 已确认消息与分支
- 可恢复的 run / checkpoint 引用服务端返回的 ID 要替换 client ID,但替换过程必须保持引用稳定;否则 UI 层用 message ID 保存的展开和复制状态会全部失效。常见做法是保留 clientId 作为关联键,服务端另发 canonical ID,或让服务端接受客户端生成的全局唯一 ID。
四种实现路线,只比较对话责任
路线一:自己维护 Message[]
const [messages, setMessages] = useState<Message[]>([])
const [status, setStatus] = useState<ChatStatus>('ready')优点: 最透明,没有框架迁移成本;单线程纯文本需求足够。
你必须拥有: 流事件解析、part 合并、稳定 ID、停止、错误恢复、线程隔离、分支、持久化、并发请求和乱序处理。
适用: 需求明确保持简单,或已有成熟内部对话协议和 store。
路线二:AI SDK useChat
当前官方 React 安装与导入:
npm install ai @ai-sdk/reactimport { useChat } from '@ai-sdk/react'
const {
messages,
status,
error,
sendMessage,
stop,
regenerate,
resumeStream,
} = useChat({
id: threadId,
transport,
})AI SDK 当前 status 包括 ready / submitted / streaming / error,消息使用 parts;stop() 中止当前流,regenerate({ messageId }) 重新生成,transport 决定怎样连接后端。
一个很重要的边界是:AI SDK 5 之后 useChat 不再内部管理输入框 state。也就是说,input 是 UI 状态,messages/status 是对话状态,官方 API 本身就在强化这个分界。
持久化也不是 Hook 自动完成。官方示例在服务端 toUIMessageStreamResponse({ onFinish }) 保存完整 UIMessage[],加载含工具、metadata 或自定义 data part 的历史时还建议先验证消息结构。
适合: 已在 AI SDK 生态,想获得无头消息与流状态,又希望 UI 和数据库自由设计。
边界: 多线程列表、完整分支树、业务权限仍要自己设计;stop() 是否终止服务端后续工作也取决于后端链路。
路线三:Ant Design X SDK
UI 包和对话包是分开的:
npm install @ant-design/x @ant-design/x-sdkimport { useXChat, useXConversations } from '@ant-design/x-sdk'useXChat 管单会话消息及请求状态,Provider / transform 适配后端流格式;useXConversations 管会话列表的增删改查,并能与 useXChat 组合加载远程历史。
优点: Ant Design 项目从 UI 到基础会话数据流路径一致;不必自己从裸 SSE 拼每条消息。
边界: “有多会话 Hook”不等于拥有生产持久化和复杂 branch repository。v1 → v2 曾把 runtime 工具迁到独立 X SDK 并重构 Hook,深度绑定时要把迁移成本算进来。
适合: React + Ant Design 项目,需要基础会话能力,复杂度尚未到完整 runtime。
路线四:assistant-ui runtime
assistant-ui 的核心就在这里,不在上一篇的气泡外观:
npm install @assistant-ui/react
# 使用 AI SDK adapter 时
npm install ai @assistant-ui/ai-sdk @ai-sdk/openai它把 thread、message、message part、composer、run、branch 和 adapter 组织成 runtime。UI primitives 读取这些上下文,因此编辑、reload、branch picker、tool UI 不需要各自维护一套旁路状态。
官方提供几类接法:
- LocalRuntime:runtime 拥有消息状态,你提供模型 adapter;
- framework adapter:连接 AI SDK、LangGraph、AG-UI 等;
- ExternalStoreRuntime:消息仍由 Redux/Zustand/自有 store 拥有,assistant-ui 适配它;
- Assistant Cloud 或自定义 thread/history adapter:负责多线程与持久化。
分支不是简单“多存一份数组”。assistant-ui 的 external store 可以导入/导出带 parentId 和 headId 的 message repository;显式切换分支时还要同步当前 head。
优点: 对话本身是产品时,状态模型、行为能力与 UI primitives 对齐;分支、编辑、重生成、多线程有统一语义。
代价: 要理解 runtime、adapter 和 capability;核心包仍处于 0.x,迁移文档记录过状态 API、事件名和 primitive API 调整。CLI codemod 能处理机械改名,不能替你验证数据库和 branch 映射。
适合: ChatGPT 类长期会话产品,或需要把多种 Agent 后端接进统一前端 runtime。
一张责任表看清四条路线
| 能力 | 手写数组 | AI SDK useChat | X SDK | assistant-ui runtime |
|---|---|---|---|---|
| 流式消息合并 | 自己写 | 内置 | Provider/Hook | runtime/adapter |
| part 模型 | 自己定义 | UIMessage.parts | 按 X 消息模型适配 | message part runtime |
| 停止 / 重新生成 | 自己写 | stop/regenerate | Hook 请求状态与控制 | runtime action |
| 输入草稿 | UI 自己管 | UI 自己管 | Sender/UI 管 | Composer runtime 同时含 UI 能力 |
| 多线程列表 | 自己写 | 自己写 | useXConversations 基础管理 | thread list runtime / adapter |
| 回答分支 | 自己写树 | 业务自己扩展 | 需自行核对/扩展 | runtime 原生语义 |
| 持久化 | 自己写 | 官方给模式,存储自己实现 | 远程历史需业务接入 | Cloud 或自定义 adapter |
| 现有外部 store | 天然由自己拥有 | 可围绕 Hook 组合 | 按 Provider 接入 | ExternalStoreRuntime |
这不是成熟度排名。每向右一步,通常少写一些状态机,同时多接受一些框架数据模型和迁移责任。
最小验证:别只看“能发一条消息”
给所有候选实现同一组动作:
- 生成到一半停止,保留半截回答;
- 对同一问题重新生成,旧答案仍可访问;
- 编辑第一条用户消息,后续历史不能被无声篡改;
- 两个线程同时存在,切换时消息和运行状态不串;
- 流式中断后刷新,明确显示 failed、disconnected 或可恢复;
- 带 tool part 的消息保存后重载,类型与 ID 不丢;
- 删除线程后,数据库、缓存和当前选择一致收口。
建议打印的状态,而不是只看页面
{
"threadId": "thread-42",
"activeHeadId": "assistant-9b",
"run": {
"id": "run-17",
"status": "stopped"
},
"visibleMessageIds": ["user-1", "assistant-2", "user-8", "assistant-9b"],
"branchCount": 2
}页面看起来相同,两套实现的状态结构可能完全不同。真正要评估的是:你是否愿意长期维护其中缺失的那部分。
怎么选
| 需求 | 起点 |
|---|---|
| 单线程、纯文本、短期工具 | 手写数组 |
| 已使用 AI SDK,UI 与数据库想完全自定义 | useChat |
| Ant Design 项目,需要 UI + 基础多会话 | Ant Design X + X SDK |
| 多线程、分支、编辑、工具 UI 是核心产品能力 | assistant-ui runtime |
| 已有成熟 Redux/Zustand 对话 store,又想用 assistant-ui primitives | ExternalStoreRuntime |
不要因为 assistant-ui 能画气泡就在 UI 篇选择它;也不要因为 useChat API 很短就认为持久化和分支已经完成。对话方案的选择,本质是在决定谁拥有 message graph、run lifecycle 和 storage adapter。
结语:数组没有错,隐形 runtime 才危险
Message[] 是很好的起点。问题不是数组“不高级”,而是需求已经出现 thread、run、part 和 branch 后,团队仍假装自己只在维护一个数组。
当你开始写:
pendingRequestByThread;regeneratedMessageMap;toolResultCache;editingMessageId;restoreAfterRefresh();
项目已经长出了 runtime。此时真正的选择不是“要不要 runtime”,而是:继续维护自己的 runtime,还是接受一个现成 runtime 的数据模型和迁移成本。
下一篇《AI 能改页面之后,谁为这次操作负责?》会越过聊天记录,讨论最危险的一步:Tool Call 已经显示成功,但订单到底有没有真的修改。
📚 核对范围与官方资料
- AI SDK
useChat、Chatbot、消息持久化 - Ant Design X
useXConversations、X SDK 介绍 - assistant-ui Architecture、Threads、Branching、External Store Runtime
- 本文是状态模型与官方能力核对,不声称对四套方案进行了同条件生产性能压测。