Skip to content
Vercel AI SDK UI 架构:AI Chat 组件与状态管理设计
概述
Vercel AI SDK 是一套面向大语言模型应用的前端工具链。它把对话消息的维护、流式响应的接收、请求状态的管理封装成 React Hook,应用层只需要提供界面,就能得到一个功能完整的聊天窗口。
以下依次说明这些主题:Message、Chat 与流式更新的基础抽象,useChat 的状态字段与异步状态流转,消息列表与输入框的组件拆分方式,将 AI SDK 状态同步到全局状态库的方法,Next.js App Router 的集成边界,以及 TypeScript 类型、错误处理、竞态与测试方面的注意点。
基本抽象:Message、Chat 与流式更新
Message 对象与 Chat/Completion 的区别
对话场景与补全(completion)场景的界面状态不同。补全场景只有一个输入与一个输出;对话场景包含一组交替出现的用户消息与助手消息。每条消息在 AI SDK 中被抽象为一个对象,渲染时读取它的 id、role 与 content 字段:
ts
// 消息数组的简化形态
[
{ id: "m1", role: "user", content: "什么是流式响应?" },
{ id: "m2", role: "assistant", content: "流式响应是服务端逐段返回内容的传输方式。" },
]id 是消息在列表中的稳定标识,渲染时作为列表 key;role 区分消息来源;content 是正文。流式更新过程中,assistant 消息的 id 保持不变,只有 content 逐渐变长。以 id 作为 key 可以避免列表项在每次增量更新时被重建。
SDK 同时提供 useChat 与 useCompletion 两个 Hook。useChat 面向对话场景,核心状态是一个消息数组;useCompletion 面向单轮补全场景,核心状态是一段持续增长的补全文本。补全界面只需要展示当前输出,对话界面需要按角色逐条渲染历史消息。界面需要什么状态结构,Hook 就提供什么状态结构。本文以对话场景的 useChat 为主线。
流式响应与增量更新
非流式请求下,界面必须等服务端生成完整内容后才能显示。流式请求把生成结果切成多个数据块,客户端每收到一块就更新一次界面,用户看到文字逐段出现。
AI SDK 在服务端与客户端之间定义了一套流式协议。服务端按协议返回数据块,客户端负责解析并更新状态;只要接口兼容该协议,useChat 就可以直接消费,不需要为不同模型编写不同的接入代码。
流式更新在状态层面的表现是:messages 中最后一条 assistant 消息的 content 随数据块到达持续增长。以 BAML React Hooks 中的 useChat 实现为例,可以直观地看到「流式增量」与「流结束」两个更新时机。BAML 的实现把 assistant 的流式内容放在 data 字段中,由调用方在 onStreamData 与 onFinalData 中自行拼装消息:
ts
// BAML React Hooks 的实现骨架(具体配置项略)
const [messages, setMessages] = useState<Message[]>([]);
const [streamingContent, setStreamingContent] = useState("");
const { data } = useChat({
onStreamData(content) {
// 阶段一:每个增量到达时,先显示中间内容
setStreamingContent(content);
},
onFinalData(finalContent) {
// 阶段二:流结束后,把完整内容写入消息数组
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: "assistant", content: finalContent },
]);
setStreamingContent("");
},
});这段代码展示了流式 UI 的两个关键时机:数据块到达时更新临时内容,流结束时落定最终消息。Vercel AI SDK 的 useChat 把这两个阶段内置在 Hook 内部,调用方只需要读取 messages。
useChat 的状态字段
useChat 用于在 React(以及 Svelte、Vue.js、Angular)中构建自定义聊天界面。使用前需要安装 ai 与 @ai-sdk/react 两个包。下面是一个最小示例:
tsx
"use client";
import { useState, type FormEvent } from "react";
import { useChat } from "@ai-sdk/react";
export function Chat() {
const { messages, sendMessage, isLoading, error } = useChat({
api: "/api/chat",
});
const [input, setInput] = useState("");
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
sendMessage(input);
setInput("");
}
return (
<section>
<ul>
{messages.map((message) => (
<li key={message.id}>
<b>{message.role}</b>:{message.content}
</li>
))}
</ul>
<form onSubmit={onSubmit}>
<input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="输入消息…"
/>
<button type="submit" disabled={isLoading || input.trim() === ""}>
发送
</button>
</form>
{isLoading && <p>正在生成…</p>}
{error && <p>请求出错:{error.message}</p>}
</section>
);
}部分服务的集成包会提供 transport 选项来替换默认请求实现,例如 Inkeep 的文档使用 transport: new DefaultChatTransport({ api, headers })。这种调用形态在后文「API 形态差异」中说明。
messages、isLoading、error 与 sendMessage
messages:消息数组,渲染消息列表的数据源。每次提交后新增一条 user 消息,assistant 消息随流式更新逐步填充。sendMessage(content):提交一条用户消息。参数是输入框的文本,调用后由组件自行决定何时清空输入。isLoading:布尔值,表示请求是否进行中。它同时覆盖「等待首包」与「流式接收中」两个阶段,通常用来切换加载提示与提交按钮的禁用状态。error:请求失败时的错误对象,请求正常时为null。展示时应读取error.message,不要把错误对象整体暴露给用户。
示例中,输入框的受控值由本地 useState 维护。输入状态与流式消息状态属于两种变化频率不同的数据:input 只在用户键入时变化,messages 在流式接收期间高频变化。把两者分开可以避免输入框因为消息更新而频繁重渲染。
消息追加、中止与重试
- 追加:
sendMessage先把用户消息追加进messages,然后发起请求。用户消息不需要等待服务端确认,界面因此可以立即反馈。 - 中止:
isLoading为true期间,调用stop()可以中断当前请求。界面上的「停止生成」按钮通常绑定这个方法。中止后已收到的内容保留在消息列表中。 - 重试:SDK 提供重新生成的方法,方法名随版本不同。自行实现重试时要注意重复追加的问题——直接再次调用
sendMessage会让同一条用户消息出现两次。可行的办法是先移除失败的 assistant 消息(如果 SDK 提供消息移除方法),再重新发送。
“停止生成”按钮可以这样实现:
tsx
function StopButton({ stop, isLoading }: { stop: () => void; isLoading: boolean }) {
if (!isLoading) return null;
return <button onClick={stop}>停止生成</button>;
}useChat 的异步状态流转
一次对话请求在 useChat 内部的状态变化可以概括为五个阶段:
| 阶段 | 触发 | messages | isLoading | error |
|---|---|---|---|---|
| 提交前 | — | 历史消息 | false | null |
| 提交 | sendMessage(text) | 追加一条 role: "user" 的消息 | true | null |
| 流式接收 | 数据块逐段到达 | assistant 消息出现,content 随数据块增长 | true | null |
| 完成 | 流正常结束 | assistant 消息内容完整 | false | null |
| 失败 | 连接中断或协议错误 | assistant 消息可能残缺 | false | Error 对象 |
提交这一步的要点是:用户消息先进入列表,再发出请求。isLoading 在请求发出时变为 true,界面切换到「生成中」状态。
流式接收这一步的要点是:每收到一个数据块,useChat 就更新一次 assistant 消息。由于消息 id 不变,React 的列表 diff 可以定位到具体消息节点,更新不会导致整个列表重新挂载。
流正常结束后 isLoading 回到 false。若中途出错,错误对象写入 error,已到达的半截内容保留;下一次 sendMessage 时 error 会被清空。
Chat 组件的拆分方式
消息列表、输入框与提交按钮
useChat 只提供状态,不提供界面。界面通常拆成消息列表、输入框、状态栏三个部分:
ChatScreen
├─ ChatProvider // 持有 useChat 状态,通过 Context 下发
│ ├─ MessageList // 只读 messages,渲染消息条
│ │ └─ MessageItem // 单条消息,按 role 区分样式
│ ├─ MessageInput // 本地 input state,提交时调用 sendMessage
│ └─ StatusBar // 读取 isLoading / error,渲染提示消息列表:
tsx
function MessageList({ messages }: { messages: ChatMessages }) {
return (
<ul>
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
</ul>
);
}
const MessageItem = memo(function MessageItem({ message }: { message: Message }) {
const align = message.role === "user" ? "text-right" : "text-left";
return <li className={align}>{message.content}</li>;
});MessageItem 用 memo 包一层后,流式更新时只有 content 发生变化的那条消息会重渲染,其余历史消息不会跟着更新。ChatMessages 的类型定义见后文「类型推导」。
输入框与提交按钮:
tsx
import { useState, type FormEvent } from "react";
function MessageInput({
onSend,
disabled,
}: {
onSend: (content: string) => void;
disabled: boolean;
}) {
const [input, setInput] = useState("");
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (input.trim() === "") return;
onSend(input);
setInput("");
}
return (
<form onSubmit={onSubmit}>
<input value={input} onChange={(event) => setInput(event.target.value)} />
<button type="submit" disabled={disabled || input.trim() === ""}>
发送
</button>
</form>
);
}MessageInput 的 onSend 由外层传入 sendMessage,disabled 通常由外层传入 isLoading。按钮禁用条件有两个:请求进行中禁用,防止重复提交;input.trim() 为空时禁用,避免发送空消息。如果产品需要「打断当前生成并立即提问」,可以先 stop() 再发送。
自定义 Hook 与 Context 共享
多个组件需要共享同一个会话的 messages、sendMessage、isLoading、error。把这些状态放进 Context,而不是逐层传递 props:
tsx
type ChatSession = {
messages: Message[];
sendMessage: (content: string) => void;
isLoading: boolean;
error: Error | null;
stop: () => void;
};
const ChatContext = createContext<ChatSession | null>(null);
export function ChatProvider({ children }: { children: React.ReactNode }) {
const { messages, sendMessage, isLoading, error, stop } = useChat({
api: "/api/chat",
});
const value = useMemo(
() => ({ messages, sendMessage, isLoading, error, stop }),
[messages, sendMessage, isLoading, error, stop],
);
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}
export function useChatSession() {
const ctx = useContext(ChatContext);
if (ctx === null) {
throw new Error("useChatSession 必须在 ChatProvider 内使用");
}
return ctx;
}这里 Context 中只放消息与会话操作方法,不放输入框的值。输入状态属于 MessageInput 的本地状态,与消息流解耦。
Context 的 value 需要 useMemo 收敛。useChat 在每次状态变化时返回新的对象,如果不收敛,消息列表的每次滚动、输入框的每次按键都会让所有消费者重渲染。
如果消息列表与输入框订阅同一个 Context,流式更新期间输入框也会频繁重渲染。可以把 Context 拆成两个:ChatMessagesContext 只放 messages,ChatActionsContext 放 sendMessage、stop、isLoading、error:
tsx
export function ChatProvider({ children }: { children: React.ReactNode }) {
const { messages, sendMessage, isLoading, error, stop } = useChat({
api: "/api/chat",
});
const actions = useMemo(
() => ({ sendMessage, stop, isLoading, error }),
[sendMessage, stop, isLoading, error],
);
return (
<ChatActionsContext.Provider value={actions}>
<ChatMessagesContext.Provider value={messages}>
{children}
</ChatMessagesContext.Provider>
</ChatActionsContext.Provider>
);
}流式更新时,只有订阅 ChatMessagesContext 的消息列表会重渲染,输入框与状态栏不受影响。
多会话隔离
每次调用 useChat 都会创建一组独立的消息状态。多会话界面的隔离因此天然成立:
tsx
function ChatPanel({ title }: { title: string }) {
// 每个 ChatPanel 实例拥有独立的 messages 状态
const { messages, sendMessage } = useChat({
api: "/api/chat",
headers: { "X-Chat-Session": title }, // 会话标识的携带方式由服务端协议决定
});
return (
<section>
<h2>{title}</h2>
<MessageList messages={messages} />
<MessageInput onSend={sendMessage} disabled={false} />
</section>
);
}
export function Dashboard({ sessions }: { sessions: { id: string; name: string }[] }) {
return (
<div>
{sessions.map((session) => (
<ChatPanel key={session.id} title={session.name} />
))}
</div>
);
}这里有两个要点。第一,每个 ChatPanel 实例拥有独立的 messages,互不串扰。第二,sessions.map 时需要给 ChatPanel 传 key,React 依靠 key 决定组件实例的挂载与卸载;切换会话时 key 变化会销毁旧实例,旧会话的状态也随之释放。如果需要在切换后保留历史,需要把消息持久化,见下一节。
将 AI SDK 状态同步到全局状态库
useChat 自己管理消息状态。全局状态库的必要性来自具体需求:会话持久化、日志上报,或者让非 React 模块读取当前消息。同步方向是单向的:useChat → 全局 store,而不是反过来。
同步到 Zustand
ts
import { create } from "zustand";
type ChatStore = {
messages: Message[];
streaming: boolean;
error: Error | null;
};
export const useChatStore = create<ChatStore>(() => ({
messages: [],
streaming: false,
error: null,
}));同步组件放在 ChatProvider 内部,用 effect 把 useChat 的状态镜像到 store:
tsx
function ChatStoreSync() {
const { messages, isLoading, error } = useChatSession();
useEffect(() => {
useChatStore.setState({ messages });
}, [messages]);
useEffect(() => {
useChatStore.setState({ streaming: isLoading, error });
}, [isLoading, error]);
return null;
}这个组件的职责只有一个:同步。它不需要渲染任何界面。Redux 的接入方式相同,在 effect 中 dispatch 一条包含 messages 的 action 即可。
高频更新与防抖合并
流式更新期间 messages 的变化频率很高,每次变化都触发 store 写入会带来大量计算。下面用防抖(debounce)把相邻的多次更新合并为一次:
tsx
useEffect(() => {
const timer = setTimeout(() => {
useChatStore.setState({ messages });
}, 200);
return () => clearTimeout(timer);
}, [messages]);原理:每次 messages 变化都先清除上一次的定时器,因此只有 messages 停止变化 200ms 后才会执行一次写入。这是防抖,即延时合并更新。如果需求是「每隔固定时间刷新一次」,那需要的是节流(throttle)——固定时间间隔内最多执行一次。两者名称与行为都不同,实现时需要注意区分。
轻量持久化
纯文本聊天记录可以用 localStorage 做轻量持久化。为了避免每次 token 更新都写一次,选择在流式结束后写入:
tsx
function usePersistMessages(storageKey: string) {
const { messages, isLoading } = useChatSession();
const prevLoading = useRef(isLoading);
useEffect(() => {
if (prevLoading.current === true && isLoading === false) {
// isLoading 由 true 变 false,说明一次流式响应结束
localStorage.setItem(storageKey, JSON.stringify(messages));
}
prevLoading.current = isLoading;
}, [isLoading, messages, storageKey]);
}「isLoading 由 true 变 false」是流式结束的可靠信号。写入的是完整快照;恢复时需要在页面加载阶段读取存档,并传给 useChat 的初始化参数,具体参数名随 SDK 版本不同。
localStorage 的容量有限,纯文本消息可以应付小规模会话。消息中如果包含工具调用结果、图片或其他结构化数据,应改用服务端存储。
与 Next.js App Router 的集成
"use client" 边界
useChat 是客户端 Hook,调用它的组件必须标注 "use client"。Next.js 中典型的做法是:页面入口保持服务端组件,聊天界面单独拆成一个客户端组件,接口由 Route Handler 提供。服务端预渲染阶段 useChat 返回空的消息列表,聊天请求在 hydration 之后才发起。
服务端 Route Handler
Route Handler 负责把模型输出转换成 AI SDK 流式协议。AI SDK 的服务端工具提供 streamText 来构造这种响应:
ts
// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai"; // 或项目选用的 Provider
export async function POST(request: Request) {
const { messages } = await request.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
});
return result.toDataStreamResponse();
}客户端发送的 messages 数组在这里被转发给模型。服务端返回的数据流按 AI SDK 流式协议组织,useChat 逐块解析后更新界面。model 参数由项目选用的 Provider 决定,openai 的导入路径取决于 Provider 包。
说明一点:Server Actions 适合一次性数据变更,不适合高频流式传输。流式响应需要在同一条 HTTP 连接上持续下发数据块,Route Handler 是更合适的载体。
TypeScript 类型、错误处理与注意点
类型推导
useChat 的返回类型由参数推导,不需要手动标注。消息列表组件的 props 可以从返回值中取出:
tsx
type ChatMessages = ReturnType<typeof useChat>["messages"];
function MessageList({ messages }: { messages: ChatMessages }) {
// ...
}这样写不依赖具体的类型导出名。如果所用版本的 useChat 是泛型重载,ReturnType 可能无法得到精确类型,这种情况下应查阅该版本的类型定义或官方文档,使用其导出的消息类型。
错误处理与重试
error 字段同时覆盖请求失败与流式中断两类错误。错误展示组件通常放在消息列表下方:
tsx
function ErrorBanner() {
const { error } = useChatSession();
if (error === null) return null;
return (
<div role="alert">
<p>生成失败:{error.message}</p>
</div>
);
}重试需要处理消息重复的问题。直接调用 sendMessage 会把最后一条用户消息再追加一次。SDK 在部分版本中提供「重新生成」的方法,可以实现不追加用户消息的重试;如果所用版本没有,则需要在重试前移除失败的 assistant 消息,或者接受重复追加并手动清理消息列表。具体 API 以所用版本的文档为准。
竞态条件
- 请求进行中再次提交:UI 应在
isLoading阶段禁用提交按钮。需要打断生成时,先stop()再提交新消息。 - 会话切换:如果通过 store 镜像消息,写入时必须带上会话标识,否则旧会话的迟到更新会写进新会话。下面这个同步 effect 把
sessionId作为 key,旧会话的更新只覆盖旧会话的存档:
tsx
function ChatStoreSync({ sessionId }: { sessionId: string }) {
const { messages } = useChatSession();
useEffect(() => {
useChatStore.setState((state) => ({
sessions: { ...state.sessions, [sessionId]: messages },
}));
}, [sessionId, messages]);
return null;
}- 陈旧闭包:在
useChat的回调选项(例如流结束回调)中读取外部状态时,可能拿到创建回调时的旧值。应使用函数式更新,或通过 ref 保存最新值。
API 形态差异
useChat 在不同版本和不同集成环境中存在 API 形态差异。阅读网上示例时,需要先判断它使用的是哪种形态。
官方文档的基本形态是 api 与 handleSubmit,输入状态由 Hook 内部管理:
tsx
const { messages, input, setInput, handleSubmit } = useChat({
api: "/api/chat",
});部分集成包(例如 Inkeep)提供的形态是 transport 与 sendMessage,输入状态由组件管理:
tsx
const [input, setInput] = useState("");
const { messages, sendMessage } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});transport 是用于替换底层请求实现的扩展点。服务方在集成包中提供专用的 Transport。判断当前代码使用哪种形态,可以看 useChat 的返回值里是否包含 sendMessage——若包含则用 sendMessage(content) 发送;若只有 setInput 与 handleSubmit,则用 handleSubmit 发送。
测试策略
聊天组件的测试重点在于状态流转:提交后用户消息出现、流式过程中 assistant 消息逐步增长、结束时 isLoading 回落、失败时展示错误。测试的关键是控制数据到达的时序,用 mock 替换底层请求,而不是等待真实模型响应。
如果 ChatProvider 支持注入 transport,测试中可以传入一个手动控制的替身,逐步 push 数据块:
tsx
const transport = createManualTransport(); // 按流式协议手动返回数据块
render(
<ChatProvider transport={transport}>
<Chat />
</ChatProvider>,
);
await act(async () => {
transport.push("你");
transport.push("你好");
transport.end();
});
expect(screen.getByText("你好")).toBeInTheDocument();createManualTransport 是测试替身,需要模拟流式协议的数据块。如果不想手动构造协议数据,可以直接 mock 全局 fetch 或替换为自定义请求层;具体实现细节以所用版本的类型定义为准。
主要限制
useChat是客户端状态。服务端渲染阶段消息列表为空,历史恢复发生在 hydration 之后。- 多个
useChat实例之间不共享状态,全局同步需要显式编写同步层。 - 流式协议由前后端共同遵守,前后端 SDK 版本不匹配时,消息可能无法更新。
- localStorage 持久化只适合小量纯文本,容量有限。
- 消息对象的字段在不同版本间有增删,不要依赖类型定义之外的字段。
从 Chat UI 到 Generative UI
以上讨论的是纯文本 Chat UI:用户发文本,模型回文本。AI SDK 生态正在把消息流扩展为混合内容流,文本之外还可以携带工具调用、结构化数据与界面片段。
SDK 提供两条自定义界面的路径:一是用 useChat 从零搭建,适合界面深度定制的场景;二是使用 AI Elements 这类聊天原语(形态类似 shadcn 组件集)快速组合标准聊天窗口。
工具调用是 Generative UI 的雏形。agent 的工具需要用户确认时,流式数据中会包含审批请求,客户端可以在消息流中渲染「允许/拒绝」按钮。工具执行的结果也以结构化数据的形式进入消息流,前端根据消息类型决定展示方式:
tsx
function AssistantMessage({ message }: { message: Message }) {
// 消息可能是普通文本,也可能是结构化内容
if (message.role === "tool") {
return <WeatherCard data={message.content} />;
}
return <p>{message.content}</p>;
}这种模式下,消息不再是「一段文本」,而是一组可渲染数据的集合。界面从「渲染文本流」演进为「渲染混合流」。目前不同版本的 SDK 对工具调用与组件渲染的支持程度不同;若只处理文本,useChat 的 messages 模型已经够用,生成式界面需要等工具协议成熟后再接入。
