TanStack AI Persistence 是 @tanstack/ai 生态中负责状态持久化的技能包,构建在 ai-core 之上,通常与 ai-core/chat-experience 配合使用。它的核心定位是把「传输可恢复性」和「状态持久化」拆成两个互不共享代码、解决不同问题的层面。
用途 传输可恢复性回答的是「如何重连到仍在运行的流」,由 memoryStream / @tanstack/ai-durable-stream 在响应侧处理,属于可恢复流文档的范畴。状态持久化回答的是「这段对话之后是什么」,由客户端 useChat 的持久化选项与服务端 @tanstack/ai-persistence 的 withPersistence 处理。可重放的流不等于已保存的对话,已保存的对话也不等于实时流,生产应用常常两者并用。
主要能力 该包把持久化定义为一种契约而非数据库。它提供四类聊天状态契约:MessageStore、RunStore、InterruptStore、MetadataStore;三类生成契约:GenerationRunStore、ArtifactStore、BlobStore(分别对应任务生命周期与字节数据)。配套提供 withPersistence / withGenerationPersistence 中间件、进程内参考后端 memoryPersistence()(覆盖全部七个存储,用于开发与测试)、服务端水合路由辅助函数 reconstructChat / reconstructGeneration,以及用于回传已持久化生成媒体字节的 retrieveArtifact / retrieveBlob / resolveArtifactBlobKey。一致性校验由 @tanstack/ai-persistence/testkit 的 runPersistenceConformance 负责,针对聊天状态存储。锁相关能力(LockStore / withLocks / InMemoryLockStore)来自 @tanstack/ai/locks,属于协调机制而非本包的状态存储。
聊天存储与生成存储 聊天持久化以 threadId 为键,使用 messages 以及可选的 runs / interrupts / metadata。生成持久化以自己的 runId 为键,使用 generationRuns(withGenerationPersistence 必需),外加可选的 artifacts + blobs 组合(两者要么都提供,要么都不提供),用于把生成的媒体字节存到 blob key artifacts/<runId>/<artifactId>。生成任务的标识是它自己的 runId,但记录上必须带 threadId:它是后续多次运行填充的稳定槽位,也是唯一用于水合运行记录的查询 findLatestForThread 的键。为 Worker 构建 R2/D1 字节存储可参考 ai-persistence/build-cloudflare-artifact-store。
字节存储的规则 默认 blob key 为 artifacts/<runId>/<artifactId>。可通过 withGenerationPersistence 的 storageKey 自定义目录结构,它接收 { artifactId, runId, threadId, role, activity, path, mimeType, name } 并返回 key,且仅限服务端使用(浏览器提供的 key 会带来路径穿越与跨租户写入风险)。解析后的 key 记录在 ArtifactRecord.blobKey 上,因为之后无法再推导,读取应通过 resolveArtifactBlobKey(record),不要重新计算。早于 blobKey 的记录回退到默认约定,因此该约定不能事后更改。key 不唯一会覆盖,除非有意为之,否则应包含 artifactId。
字节存储保存的是生成输出,而不是提示词 URL。提供方结果 URL 会过期,因此会被下载并保留。以 base64 发送的提示媒体(source: { type: 'data' })也会被存储。以 URL 发送的提示媒体不会被抓取——该 URL 由调用方提供,服务端下载它构成 SSRF 风险,且字节是冗余的。确实需要持久副本的应用可通过 allowInputUrl 谓词显式开启,例如 allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com'),不应使用 () => true。所有产物抓取仅限 http/https,有超时(artifactFetchTimeoutMs)与大小上限(maxArtifactBytes);输入抓取还会阻止回环/私有/链路本地主机并拒绝重定向。artifactFetch 可注入 fetch,以便经由受限出口代理路由。
两条相关的路由级规则:按 id 提供产物字节的 GET 必须在提供前针对 ArtifactRecord.threadId 对调用方授权(返回 404 而非 403,以免确认有效 id);在多用户路由上必须为 reconstructGeneration 提供 authorize。两者都直接接收来自调用方的 id。
可移植沙箱快照使用相同的 messages、artifacts 和 blobs 存储。其产物读取器会检查检查点线程,但不会认证调用方,因此任何读取快照产物的路由都应先对线程授权。快照检查点存储还需要原子追加与分叉操作,例如 SQLite 适配器必须在一个事务中写入检查点、其头部与 blob 引用计数。
子技能导航 需要接入服务端聊天历史、运行与中断时,阅读 ai-persistence/server/SKILL.md;需要在浏览器中跨刷新存活时,阅读 @tanstack/ai 中的 ai-core/client-persistence/SKILL.md;需要为自己的数据库实现存储接口时,阅读 ai-persistence/stores/SKILL.md;需要多实例锁(与状态分离)时,阅读 @tanstack/ai 中的 ai-core/locks/SKILL.md。
为应用添加持久化时,可按应用现有技术栈选择配方,每个配方针对应用已有的数据库客户端与 schema 编写单个 chat-persistence.ts:Drizzle ORM(SQLite / Postgres / MySQL)对应 ai-persistence/build-drizzle-adapter/SKILL.md;Prisma 对应 ai-persistence/build-prisma-adapter/SKILL.md;Cloudflare Workers + D1(± Durable Object 锁)对应 ai-persistence/build-cloudflare-adapter/SKILL.md;Cloudflare Workers + R2/D1 用于生成媒体字节对应 ai-persistence/build-cloudflare-artifact-store/SKILL.md;其他情况(原生 pg、Kysely、SQLite、Mongo)对应 ai-persistence/build-custom-adapter/SKILL.md。
状态持久化的两半 客户端半部分把 transcript ± resume 指针存在浏览器存储中,可跨刷新/关闭标签页存活(按浏览器),典型用于 SPA 恢复与离线优先。服务端半部分把 messages、runs、interrupts、metadata 存在你的数据库中,可跨重启与多设备存活,典型用于权威历史与持久审批。两者相互独立,可单独或同时使用。
身份与作用域 服务端存储以 threadId 为键(与 chat({ threadId }) / ChatMiddlewareContext.threadId / @tanstack/ai 的 Scope.threadId 一致)。存储方法接收裸 threadId 字符串以简化适配器。多用户隔离由你负责:在服务端从会话推导 userId / tenantId,并在 load/save / reconstructChat 之前授权。绝不能仅凭客户端提供的 thread id 判定归属——id 是可猜测的。
权威历史契约 当两半同时运行时,每轮的归属由请求中的 messages 决定:客户端发送非空 messages 表示完整 transcript(事实来源),结束时服务端覆盖已存储线程;客户端发送空 messages 表示从服务端副本继续,服务端加载已存储线程。绝不能把增量作为 messages 提交,否则会把历史清空到只剩增量。客户端权威模式下始终发送完整 transcript,浏览器是事实、服务端镜像;服务端权威模式下发送空 messages(或通过服务端加载水合),服务端是事实、多设备可用。
推荐生产栈 客户端使用 persistence: true,即服务端权威、无客户端缓存。服务端使用 withPersistence(backend),覆盖 messages + runs + interrupts。若关心中途流重连,路由层使用传输可恢复性。当其他中间件需要多实例协调时,可选使用 @tanstack/ai/locks 的 withLocks(distributedLockStore)(不属于状态包)。
最小端到端示例 服务端:从 @tanstack/ai 导入 chat、chatParamsFromRequest、toServerSentEventsResponse,从 @tanstack/ai-openai 导入 openaiText,从 @tanstack/ai-persistence 导入 withPersistence,并导入自定义适配器 persistence。在 POST 中解析参数,调用 chat 并传入 adapter、messages、threadId、runId、可选 resume 以及 middleware: [withPersistence(persistence)],最后返回 toServerSentEventsResponse(stream)。
客户端(服务端权威):从 @tanstack/ai-react 导入 useChat、fetchServerSentEvents,在组件中以 threadId、connection: fetchServerSentEvents('/api/chat')、persistence: true 调用 useChat。使用 persistence: true 时客户端不缓存任何内容,挂载时以 thread id 为键从服务端水合 transcript,并配合 reconstructChat 之类的服务端加载路径处理 GET。
关键规则 这不是 Vercel AI SDK,持久化是 @tanstack/ai-persistence + 中间件,而非 Vercel useChat 的存储技巧。saveThread 是全量覆盖,绝不追加。createOrResume 对同一 runId 是「不存在则插入」。中断创建是「不存在则插入」,绝不把已解决状态覆盖回 pending。锁不等于状态,withLocks 从 @tanstack/ai/locks 导入。沙箱恢复属于沙箱包范畴,不是 stores 的键;stores 只接受 messages、runs、interrupts、metadata。schema 由你拥有,包不会替你生成迁移。对你编写的任何适配器都应运行一致性测试工具。在路由边界对线程访问授权。
交叉引用 ai-core/chat-experience(@tanstack/ai)涵盖 useChat、SSE 与客户端持久化选项概览;ai-core/middleware(@tanstack/ai)涵盖中间件钩子,withPersistence 是一个 ChatMiddleware;可恢复流文档仅涉及传输可恢复性。