Eric TechBlog
AIAI Assistant

Data Persistence

AI 助理的資料持久化最佳實踐,聚焦對話、訊息 Part、記憶、Embedding 與工具狀態的儲存設計,以及每個設計決策背後的原因。

設計目標

AI 助理的持久化不是「把聊天紀錄存下來」而已,而是要同時滿足四件事:

  • 能完整重建對話畫面
  • 能追查這輪互動做過哪些事
  • 能支援記憶、檢索與後處理
  • 能在併發與失敗下保持一致性

最佳實踐的核心原則可以濃縮成一句話:

把 assistant 視為一個會產生結構化互動狀態的系統,而不是只會吐出文字回覆的對話介面。

這篇承接前面哪些內容?

只要前面的模組開始運作,你就得把它們留下來: 串流訊息記憶HITL 結果embedding 基礎設施 都需要有清楚的落盤策略。


先分清楚哪些資料是主資料

不是所有資料都應該被同等對待。最佳實踐是先分出「主資料」與「衍生資料」。

類型例子建議定位原因
對話與訊息chats、messages、parts主資料這是產品真正的事實來源,不能只靠重算
記憶user preference、長期 facts主資料有獨立生命週期,且要跨對話使用
HITL / 工具決策approval、tool status主資料涉及行為追蹤、安全與除錯
摘要chat summary、reflection主資料後續搜尋與上下文組裝會依賴它
Embeddingmemory 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 助理的一則訊息通常不只有文字,還可能包含:

  • text
  • reasoning
  • tool call
  • tool result
  • source 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. 記憶要獨立於對話生命週期

memorymessage 看起來都像文字,但它們是不同層級的資料:

  • 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 裡。


寫入流程怎麼設計

最佳實踐不是讓所有事情在同一個大流程裡一起成功,而是先保住對用戶可見的主資料,再把後處理拆開。

推薦順序

  1. 驗證輸入,建立 requestId
  2. 在 transaction 內寫入 user message、assistant message 與對應 parts
  3. 更新 chat.updatedAtlastMessageAt
  4. transaction 成功後,再啟動記憶提取、摘要更新等後處理
  5. 後處理用 allSettled 之類的方式容錯執行

為什麼這樣設計?

  • 對話主資料必須先落盤,否則 UI 與歷史記錄會失真
  • 記憶與摘要依賴最新訊息,應該在主寫入完成後再跑
  • 後處理失敗不應該讓用戶這輪對話消失
  • 分開後比較容易做重試與觀測

額外建議

  • 對外部請求保留 requestId,避免重送造成重複寫入
  • messages.sequence 或等價欄位要明確定義順序
  • 重要寫入盡量保持 append-only,減少衝突面

索引與約束要跟讀取模式對齊

不要先想「哪些欄位看起來該加 index」,而是先想「系統每天最常怎麼查資料」。

常見查詢路徑

  • 根據 chatId 讀取訊息時間線
  • 根據 messageId 讀取所有 parts
  • 根據 userId 搜尋 memories
  • 根據 requestId 查一輪請求的完整痕跡
  • 根據 toolRunIdstatus 查異常工具執行

推薦索引

索引用途
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

On this page