在开发 AI 聊天应用时,消息持久化 (Message Persistence) 是一个绕不开的核心功能。默认情况下,useChat 的状态是保存在内存中的,一旦用户刷新页面,聊天记录就会消失。
为了构建生产级别的应用,我们需要将聊天记录保存到数据库中,并在用户重新进入时加载它们。本文将基于 Vercel AI SDK 的最新文档,带你一步步实现这一功能。
核心思路
Vercel AI SDK 提供了一套优雅的机制来处理持久化,主要分为两部分:
- 服务端 (保存) :利用
streamText的onFinish回调,在 AI 回答完成后,将完整的对话历史(包含用户的新消息和 AI 的回答)保存到数据库。 - 客户端 (加载) :在页面加载时(通常在 Next.js 的 Server Component 中)从数据库获取历史记录,并通过
initialMessages属性传递给useChat。
1. 模拟数据库层 (Mock Database)
为了演示方便,我们先创建一个简单的文件存储工具。在实际生产环境中,请将其替换为你的数据库逻辑(如 PostgreSQL, MySQL, MongoDB 等)。
新建 util/chat-store.ts:
TypeScript
import { generateId } from 'ai';
import { UIMessage } from 'ai';
// 模拟数据库:保存聊天记录
// 在实际项目中,这里应该是数据库的 INSERT/UPDATE 操作
const chats: Record<string, UIMessage[]> = {};
export async function createChat(): Promise<string> {
const id = generateId();
chats[id] = [];
return id;
}
export async function loadChat(id: string): Promise<UIMessage[]> {
return chats[id] || [];
}
export async function saveChat({
chatId,
messages,
}: {
chatId: string;
messages: UIMessage[];
}): Promise<void> {
chats[chatId] = messages;
console.log(`Chat ${chatId} saved with ${messages.length} messages.`);
}
2. 服务端实现:生成流并保存 (Route Handler)
在 app/api/chat/route.ts 中,我们使用 streamText 生成流,并利用 onFinish 钩子进行存储。
关键点:
- 使用
toUIMessageStreamResponse返回响应。 onFinish会在流结束时触发,此时你可以拿到完整的messages数组。- 防止中断丢失:使用
result.consumeStream()确保即使客户端断开连接(如关闭标签页),服务端也能继续处理并完成保存。
TypeScript
import { openai } from '@ai-sdk/openai'; // 也可以替换为阿里通义千问等兼容 SDK 的 Provider
import { convertToModelMessages, streamText, UIMessage } from 'ai';
import { saveChat } from '@/util/chat-store'; // 引入上面的模拟数据库方法
export async function POST(req: Request) {
// 从请求体中获取消息列表和当前聊天 ID
const { messages, chatId }: { messages: UIMessage[]; chatId: string } = await req.json();
// 1. 生成流式文本
const result = streamText({
model: openai('gpt-4o'), // 选择你的模型
messages: convertToModelMessages(messages),
});
// 2. 关键:消费流以防止客户端断开导致处理中断
// 即使客户端关闭页面,服务端也会继续执行直到流结束并触发 onFinish
result.consumeStream();
// 3. 返回 UI 友好的流响应,并在结束时保存
return result.toUIMessageStreamResponse({
originalMessages: messages, // 传入原始消息以便 SDK 合并
onFinish: async ({ messages }) => {
// 这里的 messages 包含了:之前的历史 + 用户新消息 + AI 新生成的回答
await saveChat({ chatId, messages });
},
});
}
3. 客户端实现:加载历史记录 (Page & Component)
接下来我们在前端加载这些数据。
步骤 A: 创建聊天组件
在 ui/chat.tsx 中,我们从 props 接收 initialMessages 并传给 useChat。
TypeScript
'use client';
import { UIMessage, useChat } from '@ai-sdk/react';
export default function Chat({
id,
initialMessages,
}: {
id: string;
initialMessages: UIMessage[];
}) {
const { messages, input, handleInputChange, handleSubmit } = useChat({
id,
// 核心:使用从服务端加载的消息初始化状态
initialMessages,
// 传递 chatId 给后端,以便后端知道保存到哪条记录
body: { chatId: id },
});
return (
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
{messages.map(m => (
<div key={m.id} className="whitespace-pre-wrap">
{m.role === 'user' ? 'User: ' : 'AI: '}
{m.content}
</div>
))}
<form onSubmit={handleSubmit} className="fixed bottom-0 w-full max-w-md p-2 bg-white border-t">
<input
className="w-full p-2 border border-gray-300 rounded shadow-xl"
value={input}
placeholder="Say something..."
onChange={handleInputChange}
/>
</form>
</div>
);
}
步骤 B: 在 Server Component 中获取数据
在 Next.js 的页面文件 app/chat/[id]/page.tsx 中,我们在服务端直接读取数据库,避免了额外的 API 请求,提升首屏速度。
TypeScript
import { loadChat } from '@/util/chat-store';
import Chat from '@/ui/chat';
export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
// 从数据库加载历史消息
const messages = await loadChat(params.id);
// 将消息传递给客户端组件
return <Chat id={params.id} initialMessages={messages} />;
}
进阶优化:只发送最后一条消息
随着聊天记录变长,每次请求都把所有历史记录从客户端发给服务端会浪费带宽。我们可以优化为:客户端只发新消息,服务端负责拼接历史记录。
1. 客户端修改 (ui/chat.tsx)
使用 prepareSendMessagesRequest 自定义请求体:
TypeScript
import { DefaultChatTransport } from 'ai';
// ... inside Chat component
const { messages, input, handleInputChange, handleSubmit } = useChat({
// ... other props
transport: new DefaultChatTransport({
api: '/api/chat',
// 仅发送最后一条消息(即用户刚输入的那条)
prepareSendMessagesRequest({ messages, id }) {
return {
body: {
message: messages[messages.length - 1],
chatId: id
}
};
},
}),
});
2. 服务端修改 (app/api/chat/route.ts)
服务端需要先加载历史,再拼接新消息:
TypeScript
export async function POST(req: Request) {
// 此时接收到的只是单条 message
const { message, chatId } = await req.json();
// 1. 从数据库加载之前的历史记录
const previousMessages = await loadChat(chatId);
// 2. 拼接完整的消息列表
// 注意:这里可以加入 validateUIMessages 进行数据验证
const allMessages = [...previousMessages, message];
const result = streamText({
model: openai('gpt-4o'),
messages: convertToModelMessages(allMessages),
});
// ... 后续代码不变 (consumeStream, toUIMessageStreamResponse 等)
}
总结 📝
通过 Vercel AI SDK 的 onFinish 和 initialMessages,我们可以轻松实现聊天记录的持久化。
- 服务端:使用
onFinish捕获完整会话并存库,记得调用consumeStream()防止断连丢失数据。 - 客户端:使用 Server Component 预加载数据,并通过
initialMessages恢复会话状态。 - 优化:在生产环境中,建议仅传输增量消息,并在服务端进行历史记录的合并与校验。
希望这篇教程对你有帮助!如有疑问,欢迎在评论区交流。👏