资讯动态

用 AI SDK 在 Next.js 中实现消息持久化、SSR 与可恢复流式响应:Chat Example 实战解析

发布时间:2026/9/12 2:23:54 来源:尧图企业网站定制
用 AI SDK 在 Next.js 中实现消息持久化、SSR 与可恢复流式响应Chat Example 实战解析【免费下载链接】aiThe AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications and agents项目地址: https://gitcode.com/GitHub_Trending/ai/ai这篇指南以 AI SDK 官方仓库中的examples/next示例一个最小化的 Next.js 聊天应用为蓝本完整讲解如何基于 AI SDK 的streamText、createUIMessageStreamResponse与DefaultChatTransport搭建具备消息持久化Message Persistence、服务端渲染SSR和可恢复流式响应Resumable Streams能力的聊天应用。读完本文你将掌握该示例从环境准备、本地运行到核心路由与存储实现的全链路细节并能在自己的 Next.js 项目中复刻同样的架构模式。示例概览为什么需要持久化、SSR 与可恢复流examples/next定位为一个minimal Next.js chat application它专门用于验证三个容易被忽略、但在真实聊天产品中至关重要的能力消息持久化Message Persistence聊天记录以 JSON 文件形式落在examples/next/.chats目录刷新页面后历史消息不丢失服务端渲染SSR直接访问/chat/[chatId]URL 时服务端先读取已保存的聊天数据再把消息渲染进首屏 HTML避免“先空白后加载”可恢复流式响应Resumable Streams当模型仍在流式输出时刷新页面客户端可通过 Redis 重新连接到正在进行的流继续接收剩余内容。示例本身刻意保持最小化没有数据库、没有认证、没有 UI 框架以外的复杂业务逻辑正好适合作为理解 AI SDK 在 Next.js 中落地的“最小可运行样本”。它使用openai/gpt-5-mini模型通过 AI Gateway 网关鉴权后发起流式请求。环境准备前置条件Node.js 22.13、24 或 26pnpm 11 或更高版本仓库采用 pnpm workspace 管理多包一个 AI Gateway API key用于本地运行时对网关请求鉴权一个支持 pub/sub 的 Redis 数据库用于可恢复流的桥接resumable-stream依赖其发布/订阅能力。安装依赖并构建工作区本仓库是 monorepo示例依赖工作区内的ai、ai-sdk/react等包因此需要先从仓库根目录安装并构建pnpm install pnpm build构建会产出工作区各包如packages/ai、packages/react的产物供examples/next以workspace:*协议引用见 examples/next/package.json其中ai、ai-sdk/react均声明为workspace:*。配置环境变量复制环境变量示例文件cp examples/next/.env.local.example examples/next/.env.local在examples/next/.env.local中填入两个核心值参考 .env.local.example变量说明AI_GATEWAY_API_KEY向 AI Gateway 鉴权所需的 API Key也可改用VERCEL_OIDC_TOKEN通过 Vercel OIDC 令牌完成鉴权二选一REDIS_URL连接 Redis 的 URLresumable-stream用它建立可恢复流Redis 必须启用 pub/sub 能力本地运行cd examples/next pnpm dev启动后打开 http://localhost:3000 即可使用。示例的可用脚本见 package.jsonpnpm devNext.js 开发模式pnpm build/pnpm start构建并启动生产版本pnpm lint代码检查。测试示例的三个关键场景1. 发送消息并验证持久化发送一条消息即可创建一个聊天。示例把聊天数据以 JSON 文件写入examples/next/.chats因此重新加载聊天 URL 时服务端会把已保存的消息渲染出来。这正是“持久化 SSR”组合的效果聊天历史由服务端读取文件后渲染而不是依赖客户端 localStorage 之类的方案。2. 验证流恢复Resumption请求一个较长回复在流式输出尚未结束时刷新页面。客户端会通过 Redis 重新连接到仍在进行的流继续接收剩余 token。如果刷新时流已结束activeStreamId为 null则流恢复接口返回 204 空响应页面直接展示已保存的完整消息。3. 使用限制文件型聊天存储examples/next/.chats仅用于本地演示不适用于生产环境生产场景应替换为真正的数据库。源码中 chat-store.ts 的注释也明确说明真实应用中应当把聊天保存到数据库并直接使用数据库条目中的 id。深入源码聊天数据结构与文件存储数据模型chat-schema.tsutil/chat-schema.ts 定义了聊天的类型结构import type { UIDataTypes, UIMessage } from ai; import { z } from zod; export const myMessageMetadataSchema z.object({ createdAt: z.number(), }); export type MyMessageMetadata z.infertypeof myMessageMetadataSchema; export type MyUIMessage UIMessageMyMessageMetadata, UIDataTypes; export type ChatData { id: string; messages: MyUIMessage[]; createdAt: number; activeStreamId: string | null; canceledAt: number | null; };要点MyUIMessage是基于 AI SDKUIMessage泛型扩展出的带createdAt元数据的消息类型ChatData中activeStreamId记录当前进行中的可恢复流 IDcanceledAt记录用户主动取消的时间戳——两者共同驱动“流恢复”与“停止生成”两个能力消息元数据用 zod 的myMessageMetadataSchema校验保证写入存储的数据结构一致。文件存储实现chat-store.tsutil/chat-store.ts 是一个“演示级”文件存储暴露以下 APIcreateChat()用generateId()生成聊天 id 并初始化空聊天文件saveChat({ id, activeStreamId, messages, canceledAt })按需合并更新字段后整体写回文件appendMessageToChat({ id, message })向已有聊天追加一条消息readChat(id)读取单个聊天readAllChats()读取目录下全部聊天按文件名正则^([A-Za-z0-9_-])\.json$过滤合法 id。实现上有两个值得借鉴的安全细节id 白名单正则chatIdRegex /^[A-Za-z0-9_-]$/并配合path.resolve后的startsWith二次校验防止路径穿越Path Traversal惰性初始化getChatFile在文件不存在时写入一个空ChatData骨架messages: []、activeStreamId: null、canceledAt: null避免后续读取时报错。存储目录通过path.resolve(process.cwd(), .chats)定位即示例运行目录下的.chats文件夹。深入源码API 路由的三层协作聊天相关的 API 全部集中在app/api/chat下路由结构与职责如下app/api/chat/ ├── route.ts # POST处理消息提交/重新生成启动流式响应 └── [id]/stream/ └── route.ts # GET恢复进行中的流DELETE取消流POST提交消息并启动流app/api/chat/route.ts 是核心入口。请求体携带message、id、trigger、messageId四个字段其中trigger取值submit-message提交新消息或regenerate-message重新生成。逻辑分四步读取并裁剪消息历史根据trigger不同追加新消息submit-message或截断到指定 assistant 消息之前regenerate-message保存用户消息saveChat({ id, messages, activeStreamId: null })发起流式生成const result streamText({ model: openai/gpt-5-mini, messages: await convertToModelMessages(messages), abortSignal: userStopSignal.signal, // throttle reading from chat store to max once per second onChunk: throttle(async () { const { canceledAt } await readChat(id); if (canceledAt) { userStopSignal.abort(); } }, 1000), onAbort: () { console.log(aborted); }, });这里有几个 AI SDK 的进阶用法值得展开convertToModelMessages(messages)把 UI 层消息转换为模型可消费的消息格式是 AI SDK 统一前后端消息结构的关键转换函数onChunk结合throttlethrottleit包最多每秒轮询一次聊天文件检测到canceledAt后通过AbortController中止流——这是“停止生成”功能的服务端实现abortSignal让流式请求可以被编程方式取消。返回 UI 消息流并注册可恢复流return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream, originalMessages: messages, generateMessageId: generateId, messageMetadata: ({ part }) { if (part.type start) { return { createdAt: Date.now() }; } }, onFinish: ({ messages }) { saveChat({ id, messages, activeStreamId: null }); }, }), async consumeSseStream({ stream }) { const streamId generateId(); // send the sse stream into a resumable stream sink as well: const streamContext createResumableStreamContext({ waitUntil: after }); await streamContext.createNewResumableStream(streamId, () stream); // update the chat with the streamId saveChat({ id, activeStreamId: streamId }); }, });要点createUIMessageStreamResponse是 AI SDK 面向 UI 层消息流的标准响应包装toUIMessageStream将底层streamText流转换为 UI 消息流并通过messageMetadata在startpart 上附加createdAt元数据onFinish在流结束时把最终完整消息回写到存储并将activeStreamId置空consumeSseStream里createResumableStreamContext({ waitUntil: after })把 SSE 流同时灌入resumable-stream使用 Next.js 的after让流在后台继续再把生成的streamId写回聊天记录——这一步就是“可恢复流”的注册动作。GET恢复进行中的流app/api/chat/[id]/stream/route.ts 的GET负责流恢复export async function GET( request: Request, { params }: { params: Promise{ id: string } }, ) { const { id } await params; const chat await readChat(id); if (chat.activeStreamId null) { // no content response when there is no active stream return new Response(null, { status: 204 }); } const streamContext createResumableStreamContext({ waitUntil: after }); return new Response( await streamContext.resumeExistingStream(chat.activeStreamId), { headers: UI_MESSAGE_STREAM_HEADERS }, ); }若activeStreamId为 null没有进行中的流返回204 空响应否则通过resumeExistingStream(chat.activeStreamId)从 Redis 恢复同一流的剩余内容响应头使用 AI SDK 导出的UI_MESSAGE_STREAM_HEADERS保证客户端能按 UI 消息流协议解析。DELETE停止生成同文件中的DELETE实现“停止”动作把当前时间写入chat.canceledAt并保存。前端停止按钮只是发送fetch(..., { method: DELETE })真正的中止由 POST 路由中轮询canceledAt的onChunk逻辑触发userStopSignal.abort()完成——一个“客户端请求、服务端协作取消”的典型实现。深入源码客户端 useChat 与 DefaultChatTransport页面与会话恢复首页 app/page.tsx 用generateId()生成一个新聊天 id 并渲染Chat组件isNewChat标记新会话。会话页 app/chat/[chatId]/page.tsx 是一个async Server ComponentreadChat(chatId)在服务端读取聊天数据readAllChats()读取全部聊天并按createdAt排序渲染最近 5 个聊天的链接列表通过resume{chatData.activeStreamId ! null}告诉客户端“是否存在需要恢复的流”。这就是 SSR 的核心首屏消息来自服务端文件读取结果而不是客户端二次请求。客户端聊天逻辑app/chat/[chatId]/chat.tsx 使用ai-sdk/react的useChatHook并传入自定义DefaultChatTransportconst { status, sendMessage, messages, regenerate, stop } useChat({ id: chatData.id, messages: chatData.messages, resume, transport: new DefaultChatTransport({ prepareSendMessagesRequest: ({ id, messages, trigger, messageId }) { switch (trigger) { case regenerate-message: // omit messages data transfer, only send the messageId: return { body: { trigger: regenerate-message, id, messageId } }; case submit-message: // only send the last message to the server to limit the request size: return { body: { trigger: submit-message, id, message: messages[messages.length - 1], messageId } }; } }, }), onFinish(options) { if (isNewChat) invalidateRouterCache(); // 使新聊天的路由缓存失效触发正确的 SSR requestAnimationFrame(() inputRef.current?.focus()); }, });三个值得注意的设计resume参数直接复用服务端判断出的activeStreamId ! null让useChat在挂载时自动尝试恢复流prepareSendMessagesRequest做请求瘦身提交新消息时只发送最后一条消息重新生成时甚至不发送消息体、只传messageId——避免整段历史随每个请求反复传输onFinish中的invalidateRouterCache调用 app/actions.ts 里的revalidatePath(/just-trigger-client-reload)一个故意不存在的路径仅用于触发客户端重新加载确保新建聊天后返回首页/列表时能正确触发 SSR 刷新。交互细节app/chat/[chatId]/chat-input.tsx 维护输入框与 Stop 按钮status streaming || status submitted时展示 Stop点击后发送DELETE /api/chat/${id}/stream输入框在非ready状态下禁用。提交消息时sendMessage({ text, metadata: { createdAt: Date.now() } })新聊天还会用window.history.pushState把 URL 从/更新为/chat/${chatId}保证刷新后停留在可恢复的会话页。关键机制串讲一次完整请求的生命周期把上面所有环节串起来一次“发送消息 → 中途刷新 → 流恢复”的完整流程是用户在首页输入文字useChat.sendMessage通过DefaultChatTransport仅携带最后一条消息 POST 到/api/chat服务端route.ts读取聊天文件、追加用户消息、调用streamText发起openai/gpt-5-mini流式生成同时把 SSE 流注册进resumable-streamRedis并把streamId写回存储浏览器持续消费createUIMessageStreamResponse返回的 UI 消息流逐 token 渲染若此时刷新页面/chat/[chatId]的 Server Component 从文件读取已保存消息完成 SSR 渲染同时useChat因resumetrue请求GET /api/chat/[id]/stream从 Redis 恢复剩余流继续输出若用户点击 Stop客户端发DELETE服务端写入canceledAtPOST 路由的onChunk轮询检测到后abort()终止生成onFinish把最终消息落盘、activeStreamId置空此后刷新页面不再尝试恢复GET 返回 204。整个链路中文件存储负责“消息持久化 SSR 数据源”Redis/resumable-stream 负责“流恢复”AI SDK 的streamText/toUIMessageStream/useChat负责“前后端消息协议对齐”三者职责清晰、各司其职是值得在实际项目中借鉴的分层模式。迁移到生产环境的注意点示例在架构上刻意留出替换点生产化时主要做三件事存储替换把 util/chat-store.ts 的文件读写替换为数据库读写接口签名可保持readChat/saveChat/readAllChats不变.chats目录与路径穿越防护仅在文件实现中存在鉴权升级本地使用AI_GATEWAY_API_KEY或VERCEL_OIDC_TOKEN生产环境应按网关规范配置密钥管理与环境注入流恢复基础设施resumable-stream依赖的 Redis 需保持 pub/sub 能力与足够的连接配额同时注意waitUntil: after依赖 Next.js 运行时支持部署平台需与之兼容。相关参考示例目录请结合 examples/next 下的 package.json、.env.local.example 查看完整配置AI SDK 核心包源码packages/aistreamText、convertToModelMessages、createUIMessageStreamResponse、UI_MESSAGE_STREAM_HEADERS等均出自该包React 集成包packages/reactuseChat与DefaultChatTransport的底层实现可恢复流协议resumable-stream包本示例中通过createResumableStreamContext使用。【免费下载链接】aiThe AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications and agents项目地址: https://gitcode.com/GitHub_Trending/ai/ai创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价