对话不丢、刷新即续:IndexedDB 持久化 AI 会话历史的工程实践
对话不丢、刷新即续IndexedDB 持久化 AI 会话历史的工程实践一、刷新即丢历史AI 会话为何必须前端持久化某 AI 助手产品上线两周客服收到一类高频反馈用户聊到一半刷新页面整段对话没了。排查发现前端把会话历史存在内存里刷新即清空服务端又没做全量持久化。这事我见过太多团队栽进去——把 AI 对话当成一次性请求忽略历史本身就是用户资产。AI 对话的特殊性在于上下文连续。多轮对话依赖前面几轮的提问与回答丢一段历史意味着模型失去上下文回答质量断崖式下降。用户切换标签页、断网恢复、跨设备继续聊都需要历史可重建。服务端持久化是标配但只靠服务端不够。第一网络抖动时前端拿不到历史刷新就空白。第二离线场景下用户仍想查阅旧对话。第三频繁拉取历史增加服务端压力与首屏延迟。把会话历史在前端也持久化一份能解决离线可读、快速恢复、降低请求量三个问题。IndexedDB 是浏览器里唯一支持大容量结构化存储的方案。localStorage 容量上限 5MB 上下存不下几百轮带富文本的对话。IndexedDB 容量可达数百 MB 甚至更高支持索引与事务适合做会话仓库。二、事务模型与索引设计会话存储的底层机制IndexedDB 的核心是数据库—对象仓库—索引—记录四层结构。一个数据库下可有多个对象仓库类似表每个仓库可建多条索引。所有读写必须在事务中完成事务提交后才落盘。事务模型的关键是作用域。开启事务时指定涉及的仓库列表浏览器据此加锁。同一仓库上多个事务默认串行避免并发写冲突。写操作要么全部成功要么全部回滚——这对会话这种多消息原子写入场景是天然契合。索引设计决定查询效率。会话历史的典型查询有三种按会话 ID 拉取列表会话概览、按会话 ID 分页加载消息聊天窗口滚动、按时间范围检索全局搜索。对应索引sessionId建索引支持会话内消息分页updatedAt建索引支持会话列表按最近更新排序createdAt建索引支持时间范围检索。大对象分片存储是另一关键。单条消息若包含长文本、附件引用、工具调用结果体积可能达数十 KB。若整体写入一条记录更新单字段也要重写整条。把消息内容拆成messages与message_parts两个仓库消息元数据与内容分片分离更新更轻量。版本迁移是持久化的必经之路。需求演进会改 schemaIndexedDB 通过onupgradeneeded回调处理。迁移必须幂等——同一版本重复执行不破坏数据且要处理从旧版到新版的多步跳转。综上事务把多消息写入原子化索引让分页查询走 O(log n)。从四层结构、事务作用域到索引设计与版本迁移这套机制共同构成会话持久化的工程基础。三、生产级会话持久化仓库事务兜底与版本迁移下面给出一个可复用的会话仓库。它支持分页加载、批量写入、版本迁移与事务错误兜底。/** * AI 会话持久化仓库 * 仓库结构sessions会话元数据、messages消息元数据、message_parts消息内容分片 * 索引messages.sessionId、sessions.updatedAt、messages.createdAt */ const DB_NAME ai-chat; const DB_VERSION 2; export interface ChatMessage { id: string; sessionId: string; role: user | assistant | system | tool; createdAt: number; // 内容分片单独存更新单字段不必重写整条 parts: Array{ type: text | image | tool_call; payload: unknown }; } export interface ChatSession { id: string; title: string; createdAt: number; updatedAt: number; } export class ChatHistoryRepo { private dbPromise: PromiseIDBDatabase | null null; /** 懒加载打开数据库迁移逻辑集中在 onupgradeneeded */ private open(): PromiseIDBDatabase { if (this.dbPromise) return this.dbPromise; this.dbPromise new Promise((resolve, reject) { const req indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded (e) { const db req.result; // 老版本事件对象里没有 transaction用 req.transaction 兜底 const tx req.transaction!; this.migrate(db, tx, e.oldVersion, e.newVersion ?? DB_VERSION); }; req.onsuccess () resolve(req.result); req.onerror () reject(req.error); // 多标签升级冲突时明确报错避免静默卡死 req.onblocked () reject(new Error(DB upgrade blocked by another tab)); }); return this.dbPromise; } /** * 版本迁移必须幂等按 oldVersion 分段处理 * v1建 sessions 与 messages 仓库 * v2新增 message_parts 仓库与 messages.sessionId 索引 */ private migrate(db: IDBDatabase, tx: IDBTransaction, oldV: number, newV: number) { if (oldV 1) { const sessions db.createObjectStore(sessions, { keyPath: id }); sessions.createIndex(updatedAt, updatedAt); const messages db.createObjectStore(messages, { keyPath: id }); messages.createIndex(sessionId, sessionId); messages.createIndex(createdAt, createdAt); } if (oldV 2) { // v2 拆出 message_parts存大体积内容分片 if (!db.objectStoreNames.contains(message_parts)) { const parts db.createObjectStore(message_parts, { keyPath: [messageId, idx] }); parts.createIndex(messageId, messageId); } } } /** 追加一条消息消息元数据与分片在同一事务内写入保证原子性 */ async appendMessage(msg: ChatMessage): Promisevoid { const db await this.open(); return new Promise((resolve, reject) { const tx db.transaction([messages, message_parts, sessions], readwrite); tx.onerror () reject(tx.error); tx.onabort () reject(tx.error ?? new Error(tx aborted)); tx.oncomplete () resolve(); const msgStore tx.objectStore(messages); // 元数据只存轻量字段重内容下沉到 parts msgStore.put({ id: msg.id, sessionId: msg.sessionId, role: msg.role, createdAt: msg.createdAt, }); const partStore tx.objectStore(message_parts); msg.parts.forEach((part, idx) { partStore.put({ messageId: msg.id, idx, type: part.type, payload: part.payload }); }); // 同步更新会话的 updatedAt用于会话列表排序 tx.objectStore(sessions).put({ id: msg.sessionId, title: msg.role user ? String(msg.parts[0]?.payload ?? ).slice(0, 30) : , createdAt: msg.createdAt, updatedAt: msg.createdAt, } as ChatSession); }); } /** * 分页加载会话消息走 sessionId 索引按 createdAt 升序 * 用 cursor advance 跳过已加载页避免全量遍历 */ async loadMessages(sessionId: string, page 0, pageSize 20): PromiseChatMessage[] { const db await this.open(); return new Promise((resolve, reject) { const tx db.transaction([messages, message_parts], readonly); const idx tx.objectStore(messages).index(sessionId); const result: ChatMessage[] []; const skip page * pageSize; let advanced false; const req idx.openCursor(IDBKeyRange.only(sessionId), next); req.onerror () reject(req.error); req.onsuccess () { const cursor req.result; if (!cursor) { // 元数据加载完再批量拉分片 this.fillParts(tx, result).then(() resolve(result)).catch(reject); return; } if (!advanced skip 0) { cursor.advance(skip); advanced true; return; } const val cursor.value; result.push({ ...val, parts: [] }); if (result.length pageSize) { this.fillParts(tx, result).then(() resolve(result)).catch(reject); return; } cursor.continue(); }; }); } /** 批量填充消息分片减少往返 */ private fillParts(tx: IDBTransaction, msgs: ChatMessage[]): Promisevoid { const store tx.objectStore(message_parts); return Promise.all(msgs.map(m new Promisevoid((res, rej) { const r store.index(messageId).getAll(IDBKeyRange.only(m.id)); r.onsuccess () { m.parts r.result.map((p: any) ({ type: p.type, payload: p.payload })); res(); }; r.onerror () rej(r.error); }))).then(() undefined); } /** 过期清理按 createdAt 删除早于 cutoff 的消息级联删 parts */ async pruneBefore(cutoff: number): Promisenumber { const db await this.open(); return new Promise((resolve, reject) { const tx db.transaction([messages, message_parts], readwrite); const idx tx.objectStore(messages).index(createdAt); const range IDBKeyRange.upperBound(cutoff, true); let count 0; const req idx.openCursor(range); req.onerror () reject(req.error); req.onsuccess () { const cursor req.result; if (!cursor) return; // 遍历完毕等 tx.oncomplete const msgId cursor.value.id; // 级联删分片用 messageId 索引定位 const partReq tx.objectStore(message_parts).index(messageId) .openKeyCursor(IDBKeyRange.only(msgId)); partReq.onsuccess (e) { const c (e.target as IDBRequest).result; if (!c) return; tx.objectStore(message_parts).delete(c.primaryKey); c.continue(); }; cursor.delete(); count; cursor.continue(); }; tx.oncomplete () resolve(count); tx.onerror () reject(tx.error); tx.onabort () reject(tx.error ?? new Error(prune aborted)); }); } }关键点有三处。其一迁移按oldVersion分段幂等可重入。其二消息元数据与分片分仓库更新单字段不重写整条。其三分页用cursor.advance跳过已加载页避免全量遍历。事务失败统一走onerror与onabort兜底调用方捕获即可重试。四、持久化的代价存储膨胀、迁移风险与隐私边界IndexedDB 持久化并非无损。第一类代价是存储膨胀。AI 对话富文本、工具调用结果、附件引用体积大几个月积累下来可达数百 MB。浏览器配额超限后写入直接失败。必须有过期清理策略——按时间或按条数裁剪并把清理放到requestIdleCallback里避免抢主线程。某产品曾因无清理半年后用户首屏加载 IndexedDB 耗时 1.2 秒。第二类代价是迁移风险。版本迁移若写错可能丢历史数据。迁移代码必须幂等且上线前在本地全量回归旧版本库。生产环境建议加 schema 版本埋点发现用户卡在旧版时主动提示刷新。第三类代价是隐私边界。对话历史常含敏感信息前端持久化意味着数据留在用户设备。企业版与合规场景需提供一键清除全部入口并在用户登出时按策略清理。多设备同步要避免把 A 设备的私聊历史泄露到 B 设备。第四类代价是多标签并发。同一站点在多标签打开时IndexedDB 升级会互相阻塞触发onblocked。处理方式是提示用户关闭其他标签或用BroadcastChannel协调单写多读。适用边界需要离线可读、快速恢复、跨会话检索的 AI 产品收益最高。一次性问答、强匿名场景则不必持久化避免引入存储与隐私负担。五、总结IndexedDB 把 AI 会话历史留在前端解决离线可读、快速恢复与请求量三个问题。落地建议第一按会话、消息、分片三仓库拆分元数据轻、内容下沉。第二索引按 sessionId、updatedAt、createdAt 设计覆盖分页、列表、检索三类查询。第三版本迁移集中在 onupgradeneeded按 oldVersion 分段且幂等。第四写入用读写事务原子化失败走 onerror 与 onabort 兜底。第五过期清理配合 requestIdleCallback避免配额超限与首屏卡顿。这条路在万轮对话与离线恢复场景下能跑通回报是值得的。