Skip to content

消息数组够用吗?从 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 是否获准、订单是否真正修改属于业务状态,放到下一篇。
  • 本篇只记录“对话中出现了一个工具调用及其结果”,不把对话里的成功文案当成业务事实。

为什么第一版总是一个数组 ​

最小聊天实现通常从这里开始:

typescript
type Message = {
  id: string
  role: 'user' | 'assistant'
  content: string
}

const messages: Message[] = []

只要需求满足下面四个条件,数组完全够用:

  1. 只有一个会话;
  2. 消息只有纯文本;
  3. 一次请求成功或失败后不恢复;
  4. 不编辑历史消息,也不保留重新生成的旧答案。

问题在于,产品需求通常按这个顺序长出来:

text
纯文本
  → 流式文本
  → 停止 / 重试
  → 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:谁在什么时候说了什么 ​

typescript
type ConversationMessage = {
  id: string
  threadId: string
  role: 'system' | 'user' | 'assistant' | 'tool'
  parts: MessagePart[]
  parentId: string | null
  createdAt: string
}

消息需要稳定 ID。数组 index 不能承担身份:向上分页、插入系统消息、切分支后,index 都会变化。

2. Part:一条消息不再只是字符串 ​

流式 AI 消息里会交错出现:

typescript
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:

typescript
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:一组消息和运行的边界 ​

typescript
type ConversationThread = {
  id: string
  title: string
  activeBranchHeadId: string | null
  createdAt: string
  updatedAt: string
  version: number
}

thread 不只是侧边栏的一行标题。它决定加载哪组历史、当前可见分支、发请求时带哪个上下文,以及持久化和权限的边界。

5. Branch:编辑历史不是原地覆盖 ​

假设对话是:

text
U1 → A1 → U2 → A2

用户编辑 U1 并重新生成。如果直接覆盖 U1,A1、U2、A2 的上下文全部失真。更诚实的数据结构是:

text
U1
├── A1 → U2 → A2
└── U1' → A1'

用 parentId 可以表达:

typescript
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 图服务端数据库是对话事实,需要恢复和审计来源
当前进行中的 runruntime / durable execution store可能跨断线、审批和进程重启
草稿、折叠、滚动浏览器 UI 层不是对话事实

localStorage 可以让 demo 刷新不丢,但不能自动解决多设备同步、用户隔离、冲突、删除合规和运行恢复。


停止、失败和断线不是同一个状态 ​

很多实现只有 loading: boolean:

text
true  = 正在生成
false = 其他一切

这会把完全不同的结局压扁:

结局已生成内容能否重试服务端是否一定停止
completed完整可重新生成是
stopped保留半截可以继续或重试取决于取消是否传到服务端
failed可能有半截看错误类型run 已失败
disconnected客户端只看到半截应先尝试恢复流服务端可能仍在跑

AbortController.abort() 只能证明浏览器不再等待。如果服务端没有监听取消信号,模型和工具仍可能继续执行。对话层应该诚实记录“用户停止”“连接断开”和“服务端确认终止”的差异。

重试也有两种 ​

  • retry transport:同一个 run 因网络错误重连或继续;
  • regenerate answer:创建一个新 run 和新回答分支。

把两者都叫 retry,会让数据模型和用户预期一起混乱。


谁是最终真相:客户端、runtime 还是数据库 ​

一个稳妥的责任划分是:

text
浏览器
  - 乐观消息
  - 当前流式 part
  - 临时连接状态

对话 runtime
  - run 生命周期
  - 事件顺序
  - tool / approval 暂停点

服务端数据库
  - thread 元数据
  - 已确认消息与分支
  - 可恢复的 run / checkpoint 引用

服务端返回的 ID 要替换 client ID,但替换过程必须保持引用稳定;否则 UI 层用 message ID 保存的展开和复制状态会全部失效。常见做法是保留 clientId 作为关联键,服务端另发 canonical ID,或让服务端接受客户端生成的全局唯一 ID。


四种实现路线,只比较对话责任 ​

路线一:自己维护 Message[] ​

typescript
const [messages, setMessages] = useState<Message[]>([])
const [status, setStatus] = useState<ChatStatus>('ready')

优点: 最透明,没有框架迁移成本;单线程纯文本需求足够。

你必须拥有: 流事件解析、part 合并、稳定 ID、停止、错误恢复、线程隔离、分支、持久化、并发请求和乱序处理。

适用: 需求明确保持简单,或已有成熟内部对话协议和 store。

路线二:AI SDK useChat ​

当前官方 React 安装与导入:

bash
npm install ai @ai-sdk/react
tsx
import { 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 包和对话包是分开的:

bash
npm install @ant-design/x @ant-design/x-sdk
tsx
import { 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 的核心就在这里,不在上一篇的气泡外观:

bash
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 useChatX SDKassistant-ui runtime
流式消息合并自己写内置Provider/Hookruntime/adapter
part 模型自己定义UIMessage.parts按 X 消息模型适配message part runtime
停止 / 重新生成自己写stop/regenerateHook 请求状态与控制runtime action
输入草稿UI 自己管UI 自己管Sender/UI 管Composer runtime 同时含 UI 能力
多线程列表自己写自己写useXConversations 基础管理thread list runtime / adapter
回答分支自己写树业务自己扩展需自行核对/扩展runtime 原生语义
持久化自己写官方给模式,存储自己实现远程历史需业务接入Cloud 或自定义 adapter
现有外部 store天然由自己拥有可围绕 Hook 组合按 Provider 接入ExternalStoreRuntime

这不是成熟度排名。每向右一步,通常少写一些状态机,同时多接受一些框架数据模型和迁移责任。


最小验证:别只看“能发一条消息” ​

给所有候选实现同一组动作:

  1. 生成到一半停止,保留半截回答;
  2. 对同一问题重新生成,旧答案仍可访问;
  3. 编辑第一条用户消息,后续历史不能被无声篡改;
  4. 两个线程同时存在,切换时消息和运行状态不串;
  5. 流式中断后刷新,明确显示 failed、disconnected 或可恢复;
  6. 带 tool part 的消息保存后重载,类型与 ID 不丢;
  7. 删除线程后,数据库、缓存和当前选择一致收口。

建议打印的状态,而不是只看页面 ​

json
{
  "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 primitivesExternalStoreRuntime

不要因为 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 已经显示成功,但订单到底有没有真的修改。


📚 核对范围与官方资料

原创声明

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

👀 本文阅读 ··· 次

基于 VitePress 构建

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