Data Persistence
AI 助理的資料持久化最佳實踐,聚焦對話、訊息 Part、記憶、Embedding 與工具狀態的儲存設計,以及每個設計決策背後的原因。
設計目標
AI 助理的持久化不是「把聊天紀錄存下來」而已,而是要同時滿足四件事:
- 能完整重建對話畫面
- 能追查這輪互動做過哪些事
- 能支援記憶、檢索與後處理
- 能在併發與失敗下保持一致性
最佳實踐的核心原則可以濃縮成一句話:
把 assistant 視為一個會產生結構化互動狀態的系統,而不是只會吐出文字回覆的對話介面。
這篇承接前面哪些內容?
只要前面的模組開始運作,你就得把它們留下來: 串流訊息、 記憶、 HITL 結果 與 embedding 基礎設施 都需要有清楚的落盤策略。
先分清楚哪些資料是主資料
不是所有資料都應該被同等對待。最佳實踐是先分出「主資料」與「衍生資料」。
| 類型 | 例子 | 建議定位 | 原因 |
|---|---|---|---|
| 對話與訊息 | chats、messages、parts | 主資料 | 這是產品真正的事實來源,不能只靠重算 |
| 記憶 | user preference、長期 facts | 主資料 | 有獨立生命週期,且要跨對話使用 |
| HITL / 工具決策 | approval、tool status | 主資料 | 涉及行為追蹤、安全與除錯 |
| 摘要 | chat summary、reflection | 主資料 | 後續搜尋與上下文組裝會依賴它 |
| Embedding | memory embedding、chat embedding | 衍生資料 | 可以從原始文字重新生成,不應是唯一真相 |
| 串流事件 | token、局部更新事件 | 視需求保留 | 主要用於 replay、觀測與除錯,不一定是業務主資料 |
這個切法很重要,因為它直接決定了你的備份、交易、索引與一致性策略。可重建的資料是衍生物;不可遺失的業務狀態才是主資料。
推薦的核心資料模型
如果你要做的是可長期維護的 AI Assistant,我會建議至少拆成下面幾個核心實體:
每個實體負責什麼?
| 實體 | 責任 | 為什麼這樣拆 |
|---|---|---|
chats | 定義對話邊界與擁有者 | 方便管理標題、最近活動時間、對話列表 |
messages | 定義角色、順序與時間線 | 方便追加、查詢、分頁與併發寫入 |
message_parts | 保存結構化內容 | 支援 text、reasoning、tool call、tool result、citation、data part |
memories | 保存跨對話知識 | 跟對話本身不同步長,必須獨立 CRUD |
chat_summaries | 保存壓縮後的對話視圖 | 用於搜尋、上下文重建與列表預覽 |
tool_runs | 保存工具執行狀態 | 用於除錯、重試、稽核與 side-effect 保護 |
最重要的設計決策
1. messages 應該獨立成表,不要整包塞進 chats
對話資料最常見的操作是:
- 追加新訊息
- 讀取最近 N 則訊息
- 依時間或順序重建對話
- 只查某一段對話,而不是整包讀回
把所有 messages 嵌在 chat 裡的問題是,每次更新都像在改一份大文件。對話一長,讀寫成本、併發衝突與局部查詢都會變差。
把 messages 拆成獨立列的好處是:
- 寫入模式自然符合 append-only
- 容易做分頁與局部查詢
- 可以用索引支撐常見讀取路徑
- 併發時不需要重寫整段對話
2. message_parts 要保留結構,不要只存單一 content
AI 助理的一則訊息通常不只有文字,還可能包含:
textreasoningtool calltool resultsource citation- 自訂
data part
如果你最後只存一段平面字串,等於把互動中的結構資訊全部抹掉。這會直接影響:
- UI 是否能正確重建
- 工具狀態是否能被追蹤
- 後續是否能針對特定 part 做分析
- 未來是否容易新增新的 part 類型
因此最佳實踐不是「訊息存文字」,而是「訊息存結構化 parts」。
3. UI model 與 DB model 不要硬綁在一起
前端需要的資料 shape,通常追求的是易用與易渲染;資料庫需要的 shape,通常追求的是約束、索引與查詢效率。這兩者不應該被迫完全一致。
最佳實踐是明確保留一層 mapping:
- UI / SDK message model:貼近前端與串流協議
- DB model:貼近查詢、約束與一致性
- Mapping layer:負責雙向轉換
這樣做的原因是,未來不管你調整 UI part、改資料表欄位、還是換掉 SDK,都不會讓三層一起被拖著改。
4. 記憶要獨立於對話生命週期
memory 跟 message 看起來都像文字,但它們是不同層級的資料:
message是某一輪互動的原始記錄memory是從多輪互動中抽取出的長期知識
memory 需要被更新、合併、刪除,也需要跨對話搜尋。若把它塞回 chat 內部,會讓記憶的生命週期綁死在某一個對話上,後續很難維護。
所以最佳實踐是:
- 記憶獨立成表
- 保留
sourceChatId或來源訊息關聯 - 將記憶檢索與對話儲存視為兩個不同責任
5. Embedding 是索引,不是主儲存
Embedding 很重要,但它不應該是唯一真相。真正的主資料仍然是原始文字、摘要與 metadata。
這樣設計的原因是:
- 向量可以重建,但原始內容不能靠向量反推出來
- 模型一換,embedding 可能要重算
- 索引策略會變,但業務資料不能跟著消失
最佳實踐是把 embedding 視為衍生層:
- 主資料存在關聯式資料庫
- embedding 依附在 memory、chat summary、chunk 等原始資料上
- 重建向量索引不應影響主系統正確性
6. 有副作用的工具執行要可追蹤
若 assistant 會呼叫外部工具,尤其是會改變外部世界的工具,最佳實踐不是只把最後輸出混在 message 裡,而是明確追蹤工具執行狀態。
至少要能回答這些問題:
- 呼叫了哪個工具?
- 使用了什麼輸入?
- 執行到哪個狀態?
- 有沒有失敗?
- 失敗發生在哪一步?
- 這次執行是否已被人類批准?
因此當系統開始有外部 side effect 時,tool_runs 與 approval 記錄應該成為正式資料模型的一部分,而不是只留在 log 裡。
寫入流程怎麼設計
最佳實踐不是讓所有事情在同一個大流程裡一起成功,而是先保住對用戶可見的主資料,再把後處理拆開。
推薦順序
- 驗證輸入,建立
requestId - 在 transaction 內寫入 user message、assistant message 與對應 parts
- 更新
chat.updatedAt與lastMessageAt - transaction 成功後,再啟動記憶提取、摘要更新等後處理
- 後處理用
allSettled之類的方式容錯執行
為什麼這樣設計?
- 對話主資料必須先落盤,否則 UI 與歷史記錄會失真
- 記憶與摘要依賴最新訊息,應該在主寫入完成後再跑
- 後處理失敗不應該讓用戶這輪對話消失
- 分開後比較容易做重試與觀測
額外建議
- 對外部請求保留
requestId,避免重送造成重複寫入 messages.sequence或等價欄位要明確定義順序- 重要寫入盡量保持 append-only,減少衝突面
索引與約束要跟讀取模式對齊
不要先想「哪些欄位看起來該加 index」,而是先想「系統每天最常怎麼查資料」。
常見查詢路徑
- 根據
chatId讀取訊息時間線 - 根據
messageId讀取所有 parts - 根據
userId搜尋 memories - 根據
requestId查一輪請求的完整痕跡 - 根據
toolRunId或status查異常工具執行
推薦索引
| 索引 | 用途 |
|---|---|
messages(chat_id, sequence) | 依順序重建對話 |
messages(chat_id, created_at) | 依時間讀最近訊息 |
message_parts(message_id, part_order) | 重建單則訊息內部順序 |
memories(user_id, updated_at) | 查用戶最新記憶 |
tool_runs(chat_id, started_at) | 查某段對話的工具歷史 |
messages(request_id) | 追查單次請求的寫入結果 |
推薦約束
| 約束 | 原因 |
|---|---|
message_parts(message_id, part_order) 唯一 | 避免 part 順序衝突 |
messages(chat_id, sequence) 唯一 | 保證對話時間線穩定 |
request_id 唯一或具冪等規則 | 防止重送造成重複資料 |
part_type 的 payload 驗證 | 避免 text part、tool part 混用錯欄位 |
這些約束的價值在於,把資料正確性的責任下沉到儲存層,而不是只靠應用程式「自己記得」。
Streaming 與工具狀態的持久化原則
串流與工具呼叫最容易讓資料層變得混亂,因為它們通常不是一次寫完,而是分階段更新。
最佳實踐
- 對話畫面需要的「最終狀態」一定要能直接重建
- 關鍵狀態轉換要能被追蹤
- 不要把每個 token 都當成永久主資料
建議做法
| 類型 | 建議 |
|---|---|
| Assistant final message | 正式落盤 |
| Tool input / output / error | 正式落盤 |
| Approval decision | 正式落盤 |
| 每個 streaming token | 通常不必永久保存 |
| 關鍵 streaming state transition | 若需要 replay / debug 再保留 |
原因很簡單:你真正要保住的是可重建性與可追查性,而不是把所有暫態事件無上限地存下來。
Persistence Layer 要作為明確邊界
不管你的入口是 API Route 還是 Server Actions,上層都不應該直接碰資料表細節。
最佳實踐是建立一層明確的 persistence interface,讓上層只表達意圖,例如:
- 建立 chat
- 追加 messages
- 讀取某段 chat history
- 更新 summary
- 寫入 memory
- 記錄 tool run
這樣設計的原因是:
- 業務邏輯不會綁死在某個資料庫細節上
- 比較容易加 transaction、重試與觀測
- 之後調整 schema 時,影響面可以被限制在 persistence layer
還有哪些欄位應該一開始就預留?
如果這個系統最終會上線,我會建議一開始就預留下面這些欄位:
| 欄位 | 原因 |
|---|---|
userId / workspaceId | 多用戶與租戶隔離 |
requestId | 冪等、追蹤、除錯 |
traceId | 串接 logs、traces、tool execution |
model / provider | 成本分析與問題定位 |
schemaVersion | 降低未來 schema 調整風險 |
deletedAt | 軟刪除、保留期與稽核 |
這些欄位平常看起來不起眼,但等系統開始有觀測、成本分析、資料治理需求時,它們會變成非常重要的基礎。
重點總結
| 設計決策 | 最佳實踐 | 為什麼 |
|---|---|---|
| 對話結構 | chat -> message -> part | 符合真實互動資料的層次 |
| 訊息儲存 | messages 獨立成表 | 方便追加、查詢、分頁與併發 |
| 內容儲存 | 保存結構化 parts | 支援 tool、reasoning、citation、data part |
| 記憶 | 獨立於 chat | 有自己的生命週期,且要跨對話使用 |
| Embedding | 視為衍生索引 | 可重建,不應成為唯一真相 |
| 工具追蹤 | 明確保存 tool run / approval | 方便除錯、重試與 side-effect 控制 |
| 一致性 | 先寫主資料,再做後處理 | 保住對話正確性,同時保留容錯 |
| 系統邊界 | 建立 persistence layer | 降低耦合,方便演進與維護 |
Last updated on