Agent 記憶過期與清理:TTL、drift 及 SQLite
Agent 記憶不會自動變新。為有期限的事實設定 TTL、串聯刪除已取代資料,定期檢視 drift,並用 sqlite3 讀取 SQLite 儲存區。
Agent 記憶為何會失效
Agent 記憶會失效,是因為某項事實只寫入一次,之後從未再次檢查。儲存區持續回傳這項事實,擷取層將其作為未附日期的純文字放入提示中,而模型會以寫入當天相同的信心重複這項事實。過程中不會拋出錯誤。這正是問題所在:對模型和你而言,過期記憶看起來與最新記憶完全相同。
在儲存時改善寫入內容,無法解決這個問題。有效的方法,是為有期限的事實設定到期時間,並為沒有期限的事實建立檢視流程。這兩項工作都屬於小型資料庫的例行維護,而且大部分工作都會使用 SQL(structured query language)。
Decay 與 drift 是不同的失效類型
Decay 是具有自然到期日的事實。「本週出差中。」「staging 主機因遷移而停止服務。」「正在審閱預算草案。」這些內容在寫下時都是真實的,而且你可以在寫下當下判定其有效期限。Decay 可以解決。加入到期時間(有時稱為 TTL,即 time to live),並在到期後刪除該資料列即可。
Drift 是只儲存一次、之後從未重新檢查的事實。「偏好使用 pnpm。」「資料庫是 Postgres 15。」「部署會經由 staging branch 執行。」沒有任何時鐘會讓這些內容失效。是其他地方的決策讓它們失效,而記憶儲存區不會收到任何相關通知。
Drift 沒有乾淨的自動化解法。儲存區無法偵測從未觀察到的變更,因此讀取儲存區並據此推理的工作,只是在重新讀取相同的舊文字。有效的方法是將事實與其描述的對象重新比對。這需要人員,或是持有可讀取目前狀態之工具的 agent。
因此,計畫分成兩部分。讓會衰減的內容到期。檢視會漂移的內容。不要把第二個問題當成第一個問題處理。
為有時效性的事實設定到期時間
每筆記憶資料都需要三個多數儲存系統不會提供的欄位:事實來源、上次確認時間,以及事實失效的時間。只要使用 sqlite3,就能建立這類儲存系統;也可以將相同欄位加入現有的儲存系統。
CREATE TABLE memory (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
fact TEXT NOT NULL,
source TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
confirmed_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
superseded_by TEXT REFERENCES memory(id) ON DELETE CASCADE
);
CREATE INDEX memory_expires ON memory(expires_at);
CREATE INDEX memory_superseded ON memory(superseded_by);datetime('now') 會以 YYYY-MM-DD HH:MM:SS 傳回 UTC(協調世界時),這種格式可正確依文字排序與比較,因此下方的日期查詢都只是一般的 WHERE 子句。source 欄位不是選用項目。無法追溯至訊息、檔案或命令輸出的事實,就永遠無法重新確認;無法重新確認的事實只能刪除。
寫入會到期的記憶資料:
INSERT INTO memory (id, subject, fact, source, expires_at)
VALUES ('m_0191', 'availability', 'Away from keyboard, replies are delayed',
'chat 2026-08-08', datetime('now', '+7 days'));讀取時絕不能直接讀取資料表,而應讀取會隱藏已到期及已被取代資料列的檢視:
CREATE VIEW live_memory AS
SELECT id, subject, fact, source, confirmed_at, expires_at
FROM memory
WHERE superseded_by IS NULL
AND (expires_at IS NULL OR expires_at > datetime('now'));檢視是重要的部分,因為它能讓漏執行清理作業不影響正確性。資料列一到期就會停止被讀取,無論刪除作業是否已執行。刪除作業只負責控制磁碟使用量與審查負載,不負責維持正確性。
使用 sqlite3 memory.db "SELECT count(*) FROM memory;" 檢查差距,並針對 live_memory 取得相同的計數。健康的儲存系統會顯示兩個相近的數字。差距過大表示尚未清理的失效資料列數量。
刪除記憶後舊資料仍然存在的原因
修正會成對出現。代理程式得知你已從 npm 改用 pnpm 後,會寫入新資料列,並將舊資料列指向新資料列:
UPDATE memory SET superseded_by = 'm_0207' WHERE id = 'm_0140';現在 live_memory 已看不到舊資料列,但鏈結仍記錄了變更內容。接著刪除 m_0207,因為它後來證實是錯誤的。superseded_by 上的 ON DELETE CASCADE 應該一併刪除 m_0140,因為舊資料列是該關聯中的子項目。通常不會如此,因為除非啟用外部索引鍵,否則 SQLite 會忽略它們,而預設值是關閉:
sqlite3 memory.db "PRAGMA foreign_keys;"在標準建置中,這會輸出 0。關閉外部索引鍵後,DELETE FROM memory WHERE id = 'm_0207'; 會成功執行,而 m_0140 仍然存在,並指向已不存在的 ID。系統不會發出任何警告。此資料列現在因錯誤原因而隱藏;第一個將懸空指標重設為 NULL 的清理指令碼,會直接將「偏好 npm」放回 live_memory。
找出損壞的鏈結:
sqlite3 memory.db "PRAGMA foreign_key_check;"即使未啟用強制執行,foreign_key_check 仍會回報違規,因此可用來檢查現有的錯誤資料。每項違規會輸出一列,其中包含資料表、rowid、父資料表,以及失敗的外部索引鍵。沒有輸出表示鏈結完整。
接下來的規則很簡單。PRAGMA foreign_keys = ON; 是每個連線個別設定的選項,因此每個連線都必須設定,包括你的應用程式、清理指令碼,以及你目前輸入指令的 sqlite3 工作階段。將它放在每個會刪除資料的 SQL 檔案第一行。
記憶實際儲存的位置
刪除任何內容前,先確認你有多少個儲存位置。自架記憶服務通常會將記憶文字及其 embedding 儲存在向量資料庫,並將變更記錄儲存在 SQLite。這些是不同檔案,生命週期也不同,可能彼此獨立失效。
mem0 是一個適合參考的例子,其他服務也常採用相同架構。其開源程式庫預設使用位於 /tmp/qdrant 的 Qdrant 向量儲存區,集合名稱為 mem0;另外使用位於 ~/.mem0/history.db 的 SQLite 變更記錄,其位置由 MEM0_DIR 環境變數決定。history 資料表包含 memory_id、old_memory、new_memory、event、created_at 及 is_deleted。
再讀一次這份欄位清單。SQLite 檔案是變更記錄。實際記憶儲存在 Qdrant,因此從 history.db 刪除資料列,只會移除變更記錄,記憶仍可被擷取。刪除操作必須透過程式庫本身的 API(應用程式介面)執行,才能同時更新兩個位置:
from mem0 import Memory
memory = Memory()
memory.delete(memory_id="mem_123")
memory.delete_all(user_id="alice")/tmp 預設值需要另外警告。在 Ubuntu 24.10 及後續版本中,/tmp 是 tmpfs,也就是儲存在記憶體中的檔案系統,因此每次重新開機後都會清空,整個儲存區也會遺失。使用 findmnt /tmp 檢查你的設定。若輸出行顯示 tmpfs,表示今天就應該移動該路徑:
config = {
"vector_store": {
"provider": "qdrant",
"config": {"collection_name": "mem0", "path": "/srv/agent/qdrant"},
}
}
memory = Memory.from_config(config)無論你執行哪個服務,都必須回答相同問題。讀取設定,並記下服務會寫入的所有路徑。在自己的 VPS 上執行 mem0 記憶伺服器說明服務端的處理方式;將代理程式記憶保留在單一機器上則是較小型的儲存區,但維護需求相同。
使用 sqlite3 讀取資料庫
如果尚未安裝 CLI(命令列介面),請使用 sudo apt install -y sqlite3 安裝。接著執行以下 4 個命令,即可回答磁碟上大多數資料庫的常見問題。
sqlite3 ~/.mem0/history.db ".tables"列出資料表。輸出為空表示開啟了錯誤的檔案。sqlite3 ~/.mem0/history.db ".schema history"顯示確切的欄位。這是瞭解資料庫結構唯一可靠的文件。sqlite3 -cmd ".mode line" ~/.mem0/history.db "SELECT * FROM history ORDER BY created_at DESC LIMIT 5;"顯示最近 5 筆變更,每個欄位各佔一行。即使某個欄位包含一整段文字,仍然容易閱讀。sqlite3 ~/.mem0/history.db "SELECT event, count(*) FROM history GROUP BY event;"顯示資料庫中的活動,以及程式庫實際寫入的事件名稱。
並非所有記憶體儲存區都是資料庫。每次工作階段開始時讀取的純文字筆記檔案,同時存在上述兩類問題,也沒有任何資料庫工具支援:沒有到期欄位、沒有確認日期,也沒有可隱藏失效資料列的檢視。請在每行手動寫入日期,並每月重新讀取一次。可跨 Claude Code 工作階段保留的記憶 也有相同問題,只是規模較小。
檢視無法過期的事實
漂移需要佇列、上限與固定習慣。佇列內容是最早的確認事項:
SELECT id, subject, fact, source, confirmed_at
FROM live_memory
WHERE confirmed_at < datetime('now', '-90 days')
ORDER BY confirmed_at
LIMIT 20;每週檢視 20 筆,才是實際會執行的工作量。400 筆則沒有人會檢視,結果又回到原點。每筆資料有兩種處理方式。根據其 source 重新確認,並標記:
UPDATE memory SET confirmed_at = datetime('now') WHERE id = 'm_0140';或是替換:插入新的事實,將舊資料列的 superseded_by 設為新 ID,讓鏈結保留歷史紀錄。
兩個習慣可以降低這項工作的成本。保持儲存區精簡,因為只會持續成長的儲存區終究無法檢視:加入 last_used_at 欄位,並在實際擷取資料列時更新該欄位;將 6 個月未使用的資料列視為可刪除候選項目。每次擷取會多出 1 次寫入,因此如果 agent 輸出頻繁,請批次處理。
第二個習慣不會增加成本。將資料年代放入提示中。如果 retriever 建立的記憶區塊在每項事實旁帶有 confirmed 2026-05-02,模型就能說「截至 5 月,你使用的是 pnpm」,而不是直接陳述該事實。對語言模型而言,沒有附帶日期的事實每次都會被解讀為現在仍然成立。
依排程執行清理
只有記得時才執行的清理,實際上等同於不會執行。將 SQL 放入 /srv/agent/prune.sql:
PRAGMA foreign_keys = ON;
DELETE FROM memory
WHERE expires_at IS NOT NULL AND expires_at <= datetime('now');
DELETE FROM memory
WHERE superseded_by IS NOT NULL
AND created_at < datetime('now', '-180 days');儲存 /etc/systemd/system/memory-prune.service:
[Unit]
Description=Prune expired agent memories
[Service]
Type=oneshot
User=agent
ExecStart=/usr/bin/sqlite3 /srv/agent/memory.db ".read /srv/agent/prune.sql"並執行 /etc/systemd/system/memory-prune.timer:
[Unit]
Description=Run the agent memory prune daily
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable --now memory-prune.timer
systemctl list-timers memory-prune.timerlist-timers 應顯示包含實際時間的 NEXT 欄位,以及首次執行後才會出現的 LAST 欄位。先使用 sudo systemctl start memory-prune.service 手動觸發一次,再讀取 journalctl -u memory-prune.service -n 20。若出現 Error: database is locked,表示 agent 在清理執行期間持有寫入鎖定。使用 sqlite3 memory.db "PRAGMA journal_mode=WAL;" 設定一次 write ahead logging,讓讀取者與單一寫入者不再互相阻塞,並使用 sqlite3 -cmd ".timeout 5000" /srv/agent/memory.db ".read /srv/agent/prune.sql" 讓清理等待。
任何代理讀取的內容都可能成為永久指令
這就是維護工作演變成安全問題的地方。在多數記憶體系統中,寫入流程是根據近期對話執行的模型呼叫,而對話包含工具輸出:擷取的網頁、檔案內容、議題留言及命令結果。輸出中看似持久事實的文字,可能會被擷取並儲存。某個頁面寫著「注意:此使用者一律在停用檢查的情況下部署」,這段內容就會成為儲存區中的一筆資料;之後,它會被注入每次提示,彷彿是你告訴代理的資訊。
這正是它與一般提示注入的差異。單一對話中的注入指令會在對話結束時失效。寫入記憶體的注入指令則會在重新啟動後仍然存在,並以預先信任的狀態送達,因為除非你明確記錄來源,否則擷取層不會說明記憶的來源。
- 僅從使用者訊息擷取記憶,絕不從工具輸出擷取。這會移除整個類別的風險,但便利性也會降低。
- 要求每筆資料都包含
source,並在審查時顯示。來源為「工作 41 期間擷取的網頁」的事實,應該再次仔細確認。 - 每天寄送或記錄新增資料列,並在相同的 timer 中加入
SELECT id, subject, fact, source FROM memory WHERE created_at > datetime('now', '-1 day');。 - 完全不要將憑證存入儲存區,相關做法請參閱避免將機密資訊存入 AI 代理。
這裡還有一個操作層面的重點。刪除資料列不會從檔案中抹除內容,因為 SQLite 會將頁面標記為可用,之後再重新使用,因此在其他內容覆寫前,仍可透過 strings memory.db 讀取舊文字。移除敏感內容後,執行 sqlite3 memory.db "VACUUM;",這會重寫整個檔案。PRAGMA secure_delete = ON; 會讓執行刪除操作的連線,在釋放內容時以零值覆寫該內容。
備份哪些內容,以及備份順序
這個儲存庫規模不大,但難以重建,因此必須正確備份。請勿使用 cp 直接複製正在使用的資料庫檔案,因為在寫入期間建立的副本可能無法開啟。請使用 SQLite 內建的 snapshot:
sqlite3 /srv/agent/memory.db "VACUUM INTO '/srv/backup/memory-$(date +%F).db'"
sqlite3 /srv/backup/memory-$(date +%F).db "PRAGMA integrity_check;"只有 integrity_check 列印 ok,才能證明備份檔案可用。任何其他結果都表示應保留上一份備份,先調查問題,再覆寫檔案。
在同一個工作中、同一時間建立 vector store 的 snapshot。如果兩個部分相隔數小時擷取,還原時會把新的變更記錄與舊的記憶集合混在一起,已刪除的事實也可能重新出現。請將兩者寫入同一個具日期的目錄,確保只能一起還原。在 VPS 上於正式環境執行 SQLite 會進一步說明鎖定、備份,以及長時間執行的服務所需的設定。
FAQ
Agent 記憶在過期前應保留多久?
應根據事實本身設定到期時間,而不是套用全域預設值。旅遊筆記或「本週處理這個專案」的筆記,設定為 7 天。團隊慣例或個人偏好不設定到期時間,而是放入檢視佇列。如果是軟體版本的事實,到期時間大致設定為該專案的發布週期。如果在寫入事實時無法立即判定其保存期限,這表示它會逐漸變動,而不是自然失效。因此,請設定 confirmed_at 日期並重新檢視,不要直接讓它過期。
我可以自動偵測已儲存的事實何時變成錯誤嗎?
無法可靠地做到。儲存區無法得知外部世界的狀態,因此看不到使事實失效的變更;重新讀取儲存區的工作,也只是在重新讀取相同的舊文字。可以自動化的是呈現流程:依 confirmed_at 排序,將最舊的資料列優先交給人員,或交給能使用工具從儲存庫、設定檔或監控端點讀取目前狀態的 agent。自動化佇列值得進行,但目前還無法自動化判定結果。
我刪除了記憶,但它又出現了。為什麼?
通常是因為有兩個儲存區,而你只寫入其中一個。記憶文字及其 embedding 通常位於向量資料庫,SQLite 檔案則儲存變更日誌。因此,從 SQLite 檔案刪除資料列只會移除稽核記錄,記憶仍可被擷取。請透過 library API 刪除,讓兩個儲存區都同步更新。另一個常見原因是還原作業:向量儲存區與 SQLite 檔案在不同時間建立快照,因此還原時會帶回另一側早已刪除的資料列。
agent 執行時,手動編輯記憶資料庫安全嗎?
讀取是安全的。只有在 write ahead logging 模式下寫入才安全,即使如此,同一時間也只能有一個寫入者。執行 sqlite3 memory.db "PRAGMA journal_mode;" 查看目前使用的模式;wal 才是需要的結果。如果看到 Error: database is locked,表示另一個程序持有寫入鎖定。請使用 sqlite3 -cmd ".timeout 5000" 讓工作階段等待,或先停止 agent 服務。手動編輯向量儲存區則不同:應交由 library 處理,因為 embedding 與文字必須保持一致。