SSD Nodes Learn 🎉 VPS từ $4.99/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Agent memory lỗi thời: đặt hạn dùng và prune

Agent memory có thể sai mà không báo lỗi. Đặt expiry cho dữ liệu có thời hạn, cascade delete, review phần còn lại và đọc SQLite store bằng sqlite3.

Vì sao memory của agent bị lỗi thời

Memory của agent bị lỗi thời vì một thông tin được ghi một lần rồi không bao giờ được kiểm tra lại. Store tiếp tục trả về thông tin đó, retrieval layer đưa nó vào prompt dưới dạng văn bản thuần túy mà không kèm ngày tháng, rồi model lặp lại với mức độ tự tin giống như tại thời điểm thông tin được ghi. Không có lỗi nào được báo. Đây chính là điểm khó: với model và với bạn, một memory lỗi thời trông không khác gì một memory mới.

Việc ghi dữ liệu tốt hơn tại thời điểm lưu không giải quyết được vấn đề này. Cách xử lý là đặt thời hạn hết hạn cho những thông tin có thể hết hiệu lực, đồng thời xây dựng quy trình review cho những thông tin không có thời hạn. Cả hai đều là công việc bảo trì thông thường trên một database nhỏ, và phần lớn công việc là SQL (structured query language).

Suy giảm và lệch trạng thái là hai lỗi khác nhau

Suy giảm là một thông tin có thời điểm hết hiệu lực tự nhiên. “Tuần này đang đi công tác.” “Máy staging đang tắt để migration.” “Đang xem xét bản dự thảo ngân sách.” Những thông tin này đúng tại thời điểm được ghi lại, và bạn có thể xác định thời hạn sử dụng ngay khi ghi chúng. Suy giảm có thể xử lý được. Hãy gắn thời hạn hết hiệu lực, đôi khi gọi là TTL (time to live), rồi xóa bản ghi khi thời hạn đó kết thúc.

Lệch trạng thái là một thông tin được lưu một lần rồi không bao giờ được kiểm tra lại. “Ưu tiên pnpm.” “Database là Postgres 15.” “Các lần deploy đi qua staging branch.” Không có đồng hồ nào khiến những thông tin này tự trở thành sai. Một quyết định ở nơi khác làm chúng thay đổi, nhưng không có cơ chế nào báo cho kho lưu trữ của bạn biết.

Lệch trạng thái không có cách tự động hóa hoàn toàn rõ ràng để xử lý. Kho lưu trữ không thể phát hiện một thay đổi mà nó chưa từng quan sát, vì vậy một job đọc kho rồi suy luận về dữ liệu đó chỉ đang đọc lại cùng một đoạn văn bản cũ. Cơ chế hiệu quả là kiểm tra lại thông tin với đối tượng mà nó mô tả. Việc này cần một người hoặc một agent có tool để đọc trạng thái hiện tại.

Vì vậy, kế hoạch phải tách thành hai phần. Đặt thời hạn cho những gì sẽ suy giảm. Kiểm tra lại những gì có thể lệch trạng thái. Không được xử lý vấn đề thứ hai như thể đó là vấn đề thứ nhất.

Đặt thời hạn cho các dữ kiện có thời hạn

Mỗi dòng memory cần 3 cột mà hầu hết store không cung cấp: nguồn của dữ kiện, thời điểm dữ kiện được xác nhận lần cuối và thời điểm dữ kiện không còn đúng. Bạn có thể xây dựng store này chỉ với sqlite3, đồng thời có thể thêm các cột tương tự vào store đang chạy.

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') trả về UTC (giờ phối hợp quốc tế) dưới dạng YYYY-MM-DD HH:MM:SS. Dạng này được sắp xếp và so sánh chính xác dưới dạng text, nên mọi truy vấn ngày tháng bên dưới chỉ cần một mệnh đề WHERE đơn giản. Cột source là bắt buộc. Dữ kiện không thể truy ngược về một message, file hoặc output của command thì không thể kiểm tra lại. Dữ kiện không thể kiểm tra lại chỉ có thể bị xóa.

Ghi memory có thời hạn:

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'));

Hoạt động retrieval không được đọc trực tiếp từ table. Nó đọc từ một view để ẩn các dòng đã hết hạn và bị thay thế:

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'));

View là phần quan trọng, vì nó khiến việc prune bị bỏ sót không gây lỗi. Dòng đã hết hạn sẽ không còn được retrieval ngay khi hết hạn, bất kể delete job đã chạy hay chưa. Delete job khi đó chỉ kiểm soát mức sử dụng disk và khối lượng cần review, không quyết định tính đúng đắn.

Kiểm tra khoảng chênh bằng sqlite3 memory.db "SELECT count(*) FROM memory;" và kiểm tra cùng số lượng đó đối với live_memory. Store hoạt động tốt sẽ có 2 số gần nhau. Khoảng chênh lớn là backlog của các dòng đã hết hiệu lực.

Vì sao xóa một memory vẫn để lại bản cũ

Các bản sửa luôn đi theo cặp. Agent biết bạn đã chuyển từ npm sang pnpm, ghi một row mới và trỏ row cũ vào row mới:

UPDATE memory SET superseded_by = 'm_0207' WHERE id = 'm_0140';

Row cũ giờ không còn hiển thị với live_memory, còn chain vẫn ghi lại nội dung đã thay đổi. Bây giờ hãy xóa m_0207 vì hóa ra nó không đúng. ON DELETE CASCADE trên superseded_by phải xóa luôn m_0140, vì row cũ là child trong quan hệ đó. Thông thường việc này không xảy ra vì SQLite bỏ qua foreign key nếu bạn không bật chúng, và mặc định là tắt:

sqlite3 memory.db "PRAGMA foreign_keys;"

Lệnh này in ra 0 trên bản build mặc định. Khi foreign key bị tắt, DELETE FROM memory WHERE id = 'm_0207'; vẫn thành công và m_0140 vẫn còn đó, trỏ đến một id không còn tồn tại. Không có cảnh báo nào. Row đó giờ bị ẩn vì lý do sai, và script dọn dẹp đầu tiên đặt lại các pointer bị treo về NULL sẽ đưa "ưu tiên npm" trở lại ngay trong live_memory.

Tìm các chain bị hỏng:

sqlite3 memory.db "PRAGMA foreign_key_check;"

foreign_key_check báo cáo các vi phạm ngay cả khi enforcement đang tắt, nên có thể dùng nó trên dữ liệu lộn xộn hiện có. Lệnh này in một row cho mỗi vi phạm: table, rowid, parent table và foreign key bị lỗi. Không có output nghĩa là các chain vẫn nguyên vẹn.

Quy tắc tiếp theo rất ngắn. PRAGMA foreign_keys = ON; là setting theo từng connection, nên mọi connection đều cần setting này: application, script prune và session sqlite3 mà bạn đang nhập lệnh vào. Đặt nó làm dòng đầu tiên trong mọi file SQL có thao tác xóa.

Nơi bộ nhớ thực sự được lưu

Trước khi xóa bất cứ thứ gì, hãy xác định bạn có bao nhiêu kho lưu trữ. Một memory service tự host thường lưu nội dung bộ nhớ và embedding của nó trong vector database, đồng thời lưu change log trong SQLite. Đây là các file khác nhau với vòng đời khác nhau, và chúng có thể lỗi độc lập với nhau.

mem0 là một ví dụ phù hợp, và mô hình tương tự cũng xuất hiện ở nơi khác. Thư viện mã nguồn mở này mặc định dùng Qdrant làm vector store tại /tmp/qdrant trong collection có tên mem0, đồng thời dùng SQLite làm change log tại ~/.mem0/history.db, với vị trí được xác định theo biến môi trường MEM0_DIR. Bảng history chứa memory_id, old_memory, new_memory, event, created_atis_deleted.

Hãy đọc lại danh sách cột đó. File SQLite là change log. Bản thân các memory nằm trong Qdrant, nên xóa các row khỏi history.db chỉ xóa bản ghi cho biết có thay đổi và vẫn để memory có thể được truy xuất. Phải thực hiện thao tác xóa thông qua API (application programming interface) riêng của thư viện để cập nhật cả hai nơi:

from mem0 import Memory

memory = Memory()
memory.delete(memory_id="mem_123")
memory.delete_all(user_id="alice")

Giá trị mặc định của /tmp cần được cảnh báo riêng. Trên Ubuntu 24.10 trở lên, /tmp là tmpfs, tức filesystem được giữ trong memory, nên nó trống sau mỗi lần reboot và toàn bộ store sẽ biến mất. Kiểm tra hệ thống của bạn bằng findmnt /tmp. Nếu có dòng hiển thị tmpfs, hãy chuyển path này ngay hôm nay:

config = {
    "vector_store": {
        "provider": "qdrant",
        "config": {"collection_name": "mem0", "path": "/srv/agent/qdrant"},
    }
}
memory = Memory.from_config(config)

Câu hỏi tương tự áp dụng cho mọi hệ thống bạn chạy. Đọc config và ghi lại mọi path mà service ghi dữ liệu vào. Chạy memory server mem0 trên VPS của bạn trình bày phần service, còn giữ agent memory cục bộ trên một máy là một store nhỏ hơn nhưng vẫn cần được bảo trì theo cách tương tự.

Đọc store bằng sqlite3

Nếu còn thiếu CLI (command line interface), hãy cài đặt bằng sudo apt install -y sqlite3. Sau đó, 4 lệnh này trả lời được hầu hết câu hỏi về bất kỳ store nào trên disk của bạn.

  • sqlite3 ~/.mem0/history.db ".tables" liệt kê các bảng. Kết quả trống nghĩa là bạn đã mở nhầm file.
  • sqlite3 ~/.mem0/history.db ".schema history" in ra chính xác các cột. Đây là tài liệu đáng tin cậy duy nhất về cấu trúc của store.
  • sqlite3 -cmd ".mode line" ~/.mem0/history.db "SELECT * FROM history ORDER BY created_at DESC LIMIT 5;" hiển thị 5 thay đổi gần đây nhất, mỗi field trên một dòng. Cách này vẫn dễ đọc khi một column chứa cả đoạn văn.
  • sqlite3 ~/.mem0/history.db "SELECT event, count(*) FROM history GROUP BY event;" cho biết store đã hoạt động như thế nào và những tên event nào thực sự được library của bạn ghi vào.

Không phải memory store nào cũng là database. Một file ghi chú đơn giản được đọc khi bắt đầu mỗi session có cả hai loại lỗi và không có công cụ hỗ trợ nào: không có cột expiry, không có ngày được xác nhận, không có view để ẩn các row đã lỗi thời. Hãy tự ghi ngày vào từng dòng bạn viết và đọc lại mỗi tháng. Memory được giữ lại qua các session Claude Code cũng gặp vấn đề tương tự, chỉ trong phạm vi nhỏ hơn.

Rà soát các facts không thể hết hạn

Drift cần có hàng đợi, giới hạn và thói quen. Hàng đợi là các lần xác nhận cũ nhất:

SELECT id, subject, fact, source, confirmed_at
FROM live_memory
WHERE confirmed_at < datetime('now', '-90 days')
ORDER BY confirmed_at
LIMIT 20;

Hai mươi dòng mỗi tuần là mức mà người thực sự sẽ rà soát. Bốn trăm dòng là mức không ai rà soát, khiến bạn quay lại đúng tình trạng ban đầu. Với mỗi dòng, có hai kết quả. Kiểm tra lại dòng đó dựa trên source của nó rồi đánh dấu:

UPDATE memory SET confirmed_at = datetime('now') WHERE id = 'm_0140';

Hoặc thay thế dòng đó: chèn fact mới, đặt superseded_by của dòng cũ thành id mới, rồi để chuỗi này lưu lại lịch sử.

Có hai thói quen giúp giảm chi phí này. Giữ store nhỏ, vì store chỉ tăng lên sẽ khiến việc rà soát trở nên bất khả thi: thêm cột last_used_at, cập nhật cột này khi một dòng thực sự được truy xuất, và coi các dòng không được dùng trong sáu tháng là ứng viên để xóa. Việc này tốn một lần ghi cho mỗi lần truy xuất, nên hãy batch lại nếu agent gửi nhiều yêu cầu.

Thói quen thứ hai không tốn chi phí. Đưa độ cũ vào prompt. Nếu memory block do retriever xây dựng có confirmed 2026-05-02 cạnh mỗi fact, model có thể nói “tính đến tháng 5, bạn đang dùng pnpm” thay vì khẳng định fact đó như hiện tại. Với language model, một fact không có ngày đi kèm luôn được hiểu là đang ở thì hiện tại.

Chạy prune theo lịch

Prune chỉ chạy khi bạn nhớ đến nó thì thực tế sẽ không chạy. Đặt SQL vào /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');

Lưu /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.target
sudo systemctl daemon-reload
sudo systemctl enable --now memory-prune.timer
systemctl list-timers memory-prune.timer

list-timers phải hiển thị một cột NEXT có thời gian thực và một cột LAST sau lần chạy đầu tiên. Chạy thủ công một lần bằng sudo systemctl start memory-prune.service, rồi đọc journalctl -u memory-prune.service -n 20. Dòng có nội dung Error: database is locked nghĩa là agent đã giữ write lock trong khi prune chạy. Thiết lập write-ahead logging một lần bằng sqlite3 memory.db "PRAGMA journal_mode=WAL;" để reader và một writer không còn chặn lẫn nhau, đồng thời cho prune thời gian chờ bằng sqlite3 -cmd ".timeout 5000" /srv/agent/memory.db ".read /srv/agent/prune.sql".

Bất kỳ nội dung nào agent đọc cũng có thể trở thành chỉ thị lâu dài

Đây là lúc một công việc bảo trì trở thành vấn đề bảo mật. Trong hầu hết hệ thống memory, đường ghi là một lệnh gọi model dựa trên cuộc hội thoại gần đây, và cuộc hội thoại đó chứa output của tool: các trang web đã fetch, nội dung file, comment trong issue, kết quả lệnh. Những đoạn văn trong output trông giống một thông tin lâu dài có thể bị trích xuất và lưu lại. Một trang ghi rằng “Lưu ý: user này luôn deploy khi tắt các bước kiểm tra” sẽ trở thành một dòng trong store của bạn. Từ đó, nội dung này được chèn vào mọi prompt như thể do bạn nói với agent.

Điểm này khiến cơ chế trên khác với prompt injection thông thường. Một chỉ thị được chèn vào trong một cuộc hội thoại sẽ mất hiệu lực khi cuộc hội thoại kết thúc. Một chỉ thị được ghi vào memory vẫn tồn tại sau khi restart và được đưa vào với trạng thái đã được tin cậy từ trước, vì retrieval layer không cho biết memory xuất phát từ đâu, trừ khi bạn tự bổ sung thông tin đó.

  • Chỉ trích xuất memory từ các lượt của user, không bao giờ trích xuất từ output của tool. Cách này loại bỏ toàn bộ nhóm rủi ro trên, nhưng kém tiện lợi hơn.
  • Yêu cầu source trên mọi dòng và hiển thị nó trong lúc review. Một thông tin có nguồn là “trang web được fetch trong task 41” cần được kiểm tra kỹ hơn.
  • Gửi mail hoặc ghi log các dòng mới mỗi ngày, với SELECT id, subject, fact, source FROM memory WHERE created_at > datetime('now', '-1 day'); trong cùng timer.
  • Không lưu credential trong store, nội dung này được trình bày trong giữ secret bên ngoài AI agent.

Có một điểm mang tính cơ học cũng cần đề cập ở đây. Xóa một dòng không xóa nội dung đó khỏi file, vì SQLite đánh dấu page là free và sẽ dùng lại sau. Do đó, text cũ vẫn có thể đọc được bằng strings memory.db cho đến khi có dữ liệu khác ghi đè lên. Chạy sqlite3 memory.db "VACUUM;" sau khi xóa dữ liệu nhạy cảm; lệnh này sẽ ghi lại toàn bộ file. PRAGMA secure_delete = ON; khiến connection thực hiện thao tác xóa ghi đè phần nội dung được giải phóng bằng các byte 0 trong quá trình đó.

Cần sao lưu gì và theo thứ tự nào

Store này nhỏ nhưng khó dựng lại, vì vậy hãy sao lưu đúng cách. Không bao giờ dùng cp để sao chép trực tiếp file database đang hoạt động, vì bản sao được tạo giữa lúc đang ghi có thể không mở được. Hãy dùng snapshot tích hợp sẵn của SQLite:

sqlite3 /srv/agent/memory.db "VACUUM INTO '/srv/backup/memory-$(date +%F).db'"
sqlite3 /srv/backup/memory-$(date +%F).db "PRAGMA integrity_check;"

Chỉ integrity_check rồi in ok mới chứng minh được file backup có thể sử dụng. Mọi kết quả khác đều có nghĩa là phải giữ lại backup trước đó và điều tra trước khi ghi đè.

Hãy snapshot vector store trong cùng một job và cùng thời điểm. Nếu hai phần được chụp cách nhau nhiều giờ, quá trình restore sẽ ghép một change log mới với một tập memory cũ, khiến các fact đã xóa xuất hiện trở lại. Ghi cả hai vào cùng một thư mục có tên theo ngày để chúng chỉ có thể được restore cùng nhau. Chạy SQLite trong môi trường production trên VPS giải thích sâu hơn về locking, backup và các thiết lập cần thiết cho một service chạy lâu dài.

FAQ

Bộ nhớ của agent nên tồn tại bao lâu trước khi hết hạn?

Đặt thời hạn dựa trên từng fact, không dựa trên một mặc định chung. Ghi chú du lịch hoặc ghi chú “đang làm dự án này trong tuần này” nên có thời hạn 7 ngày. Quy ước của team hoặc preference cá nhân không cần thời hạn; thay vào đó, đưa chúng vào hàng đợi review. Fact về một phiên bản phần mềm nên có thời hạn xấp xỉ chu kỳ release của project đó. Nếu lúc ghi fact, bạn không thể xác định thời gian lưu hữu ích của nó, đó là dấu hiệu cho thấy fact này nên được review thay vì tự hết hạn. Hãy đặt ngày confirmed_at cho nó và review sau.

Có thể tự động phát hiện khi một fact đã sai không?

Không đáng tin cậy. Store không biết những thay đổi bên ngoài nó, nên không thể phát hiện điều khiến một fact trở nên sai. Một job đọc lại store cũng chỉ đang đọc lại đúng nội dung cũ. Bạn có thể tự động hóa việc đưa các mục cần xử lý lên trước: sắp xếp theo confirmed_at rồi đưa các dòng cũ nhất cho một người, hoặc cho một agent có tool đọc trạng thái hiện tại từ repository, file cấu hình hoặc monitoring endpoint. Tự động hóa hàng đợi là việc đáng làm. Tự động hóa kết luận thì hiện chưa khả thi.

Tôi đã xóa một memory nhưng nó quay lại. Tại sao?

Thông thường là vì có 2 store và bạn đã ghi vào một store. Nội dung memory và embedding của nó thường nằm trong vector database, còn file SQLite lưu change log. Vì vậy, xóa các row trong file SQLite chỉ xóa audit record mà vẫn để memory có thể được truy xuất. Hãy xóa thông qua library API để cập nhật cả 2 nơi. Nguyên nhân phổ biến khác là restore. Vector store và file SQLite được snapshot ở các thời điểm khác nhau, nên khi restore, các row mà nửa còn lại đã xóa trước đó lại xuất hiện.

Có an toàn khi chỉnh sửa memory database thủ công trong lúc agent đang chạy không?

Đọc thì an toàn. Ghi chỉ an toàn khi dùng write ahead logging mode, và ngay cả khi đó cũng chỉ được có một writer tại một thời điểm. Chạy sqlite3 memory.db "PRAGMA journal_mode;" để xem bạn đang dùng mode nào; wal là giá trị bạn cần thấy. Nếu thấy Error: database is locked, một process khác đang giữ write lock. Hãy cho session chờ bằng sqlite3 -cmd ".timeout 5000" hoặc dừng agent service trước. Chỉnh sửa vector store thủ công là việc khác: hãy để library xử lý, vì embedding và nội dung text phải luôn nhất quán với nhau.