Agent memory poisoning:長期記憶如何遭污染
Prompt injection 只影響單次工作階段;memory poisoning 會把惡意文字寫成長期記憶。了解攻擊如何發生,以及如何隔離、設定到期時間與審查記錄。
Agent memory poisoning 是什麼
Agent memory poisoning 是一種攻擊。攻擊者將惡意文字寫入 agent 的長期記憶,讓 agent 在後續工作階段將其取出,並當成可信事實。Prompt injection 只存在於單一 context window 中,context window 關閉後攻擊就會結束。Memory poisoning 不會結束,因為 agent 已經記下攻擊者的文字,並會在隔天讀回。
這項差異會改變防禦重點。Prompt injection 是重新啟動即可清除的事件。遭污染的記憶則是系統狀態變更。它就像資料庫中的錯誤資料列:重新啟動後仍然存在,下一個查詢者仍會取得,而且後續工作階段對使用者而言看起來並無異常。
OWASP 在 2025 年 12 月發布的 Top 10 for Agentic Applications 中,將此問題列為 ASI06,Memory and Context Poisoning。最明確的公開測量來自 MINJA:Memory Injection Attacks on LLM Agents via Query-Only Interaction。該研究於 2025 年 3 月發布,並在 NeurIPS 2025 發表。以下對攻擊的說明來自這兩個來源。後續的防禦措施屬於實務作法,適用於任何由你自行代管的儲存體。
為什麼 prompt injection 會結束,而 memory poisoning 不會
context window 是每次執行期間的狀態。模型在不同呼叫之間沒有記憶,因此執行期間所掌握的一切資訊,都是由你的程式放入其中,包括 system prompt、tool output 及目前為止的對話內容。執行結束後,這些狀態就會被丟棄。嵌入在擷取網頁中的 injected instruction 也會隨之消失。針對 coding agent 的 prompt injection 在執行期間很危險,但執行結束後就不再有效,因此「開始新的工作階段」確實是有效的緩解措施。
長期記憶的存在,正是為了刻意打破這項特性。你可能希望 agent 記住這位使用者執行 Debian,或 production database 從這台主機只能讀取。因此,memory step 會寫入持久記錄,而 retrieval step 會在下一次執行時載入相符的記錄。如果你從未親自撰寫過這個 store-then-retrieve step,實作一次是了解它為何屬於 trust boundary、而不只是功能的最快方式;循序學習 agent 基礎會在你手動撰寫 loop 後,帶你進入 memory 階段。
攻擊者想要的也是相同特性,原因也相同。寫入一次,讀取多次。只要問題與遭污染的記錄足夠相似,每次都會擷取該記錄;只要該 store 持續提供服務,它就會影響每個 session 及每位使用者,直到有人將其刪除。
惡意文字如何變成已儲存的事實
寫入流程分為 4 個步驟。請逐一確認,因為防禦措施分別對應不同步驟。
- 代理程式讀取不受信任的內容:網頁、支援工單、電子郵件本文,或複製下來的儲存庫中的 README。
- 記憶步驟決定哪些內容值得保留。在多數設計中,這是另一次模型呼叫,會將工作階段摘要成簡短的事實句子。
- 儲存擷取出的句子。典型資料列會包含文字、embedding 向量、時間戳記,以及通常還有 user id。
- 後續工作階段執行相似度搜尋,將最相符的結果貼入 prompt,通常位於使用者訊息上方。
步驟 1 是程式碼掃描代理程式的日常工作:自行託管的 open-kritt 掃描器會讀取你指定的任何儲存庫,因此它可能記住的每項發現,都受到他人撰寫的文字影響。搜尋會進一步擴大同一個步驟,因為讓代理程式使用 SearXNG 搜尋後端表示送達擷取器的頁面,是由查詢結果的排名決定,而不是由你指定。
步驟 2 是跨越信任邊界的地方,因為擷取器的輸入包含不受信任的內容,但其輸出會被視為代理程式自己的結論。步驟 3 是損害變得持久的地方,因為擷取會捨棄文字來源。一句由使用者輸入的文字,與一句從惡意網頁擷取的文字,會以相同形式儲存:文字、向量、時間戳記。完成寫入後,資料列中沒有任何欄位能區分兩者。
接著,檢索會完全按照其設計運作。它依相似度而非信任程度排序,並將排名靠前的結果交給模型,放在 prompt 中用於既有上下文的區域。模型無法知道其中某一行是陌生人撰寫的。
MINJA 論文測量了什麼,以及沒有測量什麼
MINJA 的貢獻在於其威脅模型。攻擊者完全不會接觸資料庫。他們只向 agent 傳送查詢並讀取回答,這正是共用 agent 的一般使用者已具備的存取層級。惡意記錄則由 agent 自身的記憶步驟寫入。
The data behind this chart
[
{
"config": "EHRAgent GPT-4 MIMIC-III",
"injection_success_pct": 95.6,
"attack_success_pct": 57.0
},
{
"config": "EHRAgent GPT-4 eICU",
"injection_success_pct": 98.5,
"attack_success_pct": 90.0
},
{
"config": "RAP GPT-4 Webshop",
"injection_success_pct": 96.3,
"attack_success_pct": 77.4
},
{
"config": "RAP GPT-4o Webshop",
"injection_success_pct": 99.3,
"attack_success_pct": 98.9
},
{
"config": "QA GPT-4 MMLU",
"injection_success_pct": 100.0,
"attack_success_pct": 68.9
},
{
"config": "QA GPT-4o MMLU",
"injection_success_pct": 100.0,
"attack_success_pct": 68.9
},
{
"config": "Paper average",
"injection_success_pct": 98.2,
"attack_success_pct": 76.8
}
]請分開解讀這兩項指標。注入成功率表示惡意記錄是否確實進入記憶庫。攻擊成功率表示該記錄之後是否引導 agent 回答後續的不同查詢。論文報告的平均值中,前者為 98.2%,後者為 76.8%。將文字寫入記憶體接近可靠,但讓它改變後續回答則不然,而且不同 agent 之間的差異很大:採用 GPT-4o 的檢索增強購物 agent 達到 98.9%,而採用 MIMIC-III 的臨床記錄 agent 則為 57.0%。
這些已發表數據代表什麼,以及不代表什麼
這些數據是單篇論文自行實驗的平均值,實驗使用 GPT-4 和 GPT-4o,涵蓋 3 種 agent 設計與 4 個資料集。來源表格中的每個儲存格都附有標準差,而攻擊成功率的離散程度很大。這些數據測量的是那些系統,不是你的系統。應將其視為查詢專用的記憶注入可在實際 agent 設計上運作的證據,而不是你部署環境的發生機率。
為何一名使用者的輸入會成為另一名使用者的記憶
多租戶情境會讓原本的麻煩升級為資料外洩。許多自架環境只執行一個 agent,搭配一個記憶體儲存區,再以每筆記錄中的 metadata 欄位區分使用者,例如 tenant_id 或 user_id,並在查詢時套用篩選。
這種設計通常會以兩種方式失效。
第一種是讀取時遺漏篩選條件。Retrieval 可能由多個程式路徑呼叫:聊天處理常式、每晚執行的摘要工作,以及有人匆忙撰寫的評估腳本。只要其中一條路徑忘記套用篩選條件,就不會發生錯誤。系統會回傳更多資料列,並依相似度排序,而多出的資料列可能屬於其他租戶。遺漏篩選條件會導致系統以放行方式失效。
第二種是寫入錯誤。租戶 A 的工單文字會經過擷取器,產生的「事實」則以程式當時設定的租戶 ID 儲存。如果 summariser 在共用工作中執行,或因為內容看似一般知識而被寫成全域偏好設定,那麼 A 的文字就會成為 B 取得的內容。讀取篩選條件本身沒有錯。錯的是資料列被寫到了邊界的另一側。
因此,只要攻擊者能在任一租戶的 agent 中輸入內容,就能針對該儲存區服務的所有租戶。Retrieval 路徑沒有任何機制檢查記錄的寫入者。
防禦 1:每個信任邊界使用一個記憶體儲存區
先決定信任邊界的位置,再為每個邊界配置專屬儲存區。不要使用單一集合搭配篩選條件。應使用不同的資料庫,或至少使用以不同認證資訊連線的獨立 schema。
原因在於每種失敗的影響方向不同。遺漏篩選條件會回傳其他使用者的資料列,而且不會產生明顯錯誤。錯誤的連線字串則會完全取不到資料,通常在第一分鐘內就能發現。會以安全拒絕方式失敗的隔離設計,值得多執行一個 container。
多數部署環境中值得分開的邊界包括:
- 多租戶 agent 中的每位客戶或每個團隊。
- 任何衍生自公開內容的資料,都應與衍生自自有使用者輸入內容的資料分開。
- 共用同一台主機時的每個 agent 角色,例如具備特權的 agent 與對外服務的 agent。
- 開發環境與 production,避免測試執行寫入的資料列被實際工作階段讀取。
如果不得不使用共用資料表,應將檢查下放至資料庫,而不是信任每個呼叫端。PostgreSQL row level security 可透過三個陳述式完成:
ALTER TABLE agent_memory ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_memory FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON agent_memory
USING (tenant_id = current_setting('app.tenant_id', true));每個請求接著在讀取資料前,先在連線上指定其 tenant:
BEGIN;
SELECT set_config('app.tenant_id', 'acme', true);
SELECT content FROM agent_memory ORDER BY created_at DESC LIMIT 20;
COMMIT;第三個引數 true 會讓設定僅在目前交易中有效。這一點很重要,因為 pooled connection 之後會交給下一個請求使用。如果某段程式流程從未設定該值,就會回傳 0 個資料列,因為設定不存在時 current_setting('app.tenant_id', true) 是 NULL,而 tenant_id = NULL 永遠不會成立。這就是所需的安全拒絕行為。你可以不執行 set_config 那一行,直接執行第二個區塊來驗證。
實務上有兩件事會造成這項機制失效。FORCE ROW LEVEL SECURITY 並非裝飾用途:如果沒有它,資料表擁有者會略過所有 policy,因此使用擁有者身分連線的應用程式會讀取所有 tenant,讓人誤以為 policy 已失效。具備 BYPASSRLS 的 role 也會基於相同原因忽略 policy,而 superuser 具備此權限。請使用不擁有任何物件的普通 role 進行連線。
防禦 2:記錄來源,並將每筆記憶視為不受信任的輸入
為每筆資料補上擷取流程會捨棄的欄位。
CREATE TABLE agent_memory (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
content text NOT NULL,
source text NOT NULL, -- user_message, tool_output, web_page, operator
source_ref text, -- url, ticket id, session id
written_by text NOT NULL, -- which agent or job wrote the row
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
reviewed boolean NOT NULL DEFAULT false
);
CREATE INDEX agent_memory_tenant_idx ON agent_memory (tenant_id, created_at DESC);source 是最能發揮效益的欄位。發生疑似事件後,您真正需要回答的只有一個問題:哪些儲存的事實來自使用者未撰寫的內容?沒有這個欄位時,每筆資料看起來都同樣可信,您唯一剩下的選擇就是刪除整個儲存區,連同正確的記憶一起遺失。
來源資訊也能讓您用一行文字定義擷取政策。source 為 user_message 或 operator 的資料列,才可擷取至能呼叫工具的執行流程中。其他資料都必須先由人工設定 reviewed。這項做法具可攜性:它是 WHERE 條款,無論儲存區使用 Postgres、SQLite 或具有中繼資料篩選功能的向量資料庫,運作方式都相同。
接著,請勿將儲存的文字放入系統提示。擷取的記憶應放在獨立且清楚分隔的區塊中,並標示為回憶資料。標示並不能阻止模型遵循其中找到的指令;假設標示具有這種效果,正是人們最後信任無效控制措施的原因。標示有兩項可依賴的作用:提示中信任程度最高的區域不會包含攻擊者可控制的文字;當您追查模型看到了哪些內容時,也能在日誌中清楚看見這個界線。
防禦 3:讓寫入內容過期,檢視重要項目
永不過期的記憶會持續增加,直到無人能夠讀取。為每個擷取的資料列設定 TTL(存留時間),並依排程刪除:
DELETE FROM agent_memory
WHERE expires_at IS NOT NULL
AND expires_at < now();使用 systemd timer 執行即可:
[Unit]
Description=Delete expired agent memory rows
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target相符的 .service 單元只需包含一行呼叫 psql -d agentmem -f /etc/agent-memory/expire.sql 的 ExecStart,並以維護角色執行。使用 systemctl list-timers agent-memory-expire.timer 檢查它;輸出應顯示下次執行時間和上次執行時間。永不觸發的 timer 通常是因為建立後未完成 enable --now。
TTL 是遭污染資料列仍可被擷取的最長時間上限。因此,預設為 30 天時,曝露時間最多為 30 天的工作階段。對擷取的記憶採用較短的預設期限;只有使用者刻意提升的資料列,才給予較長的存留時間。相同的過期工作也應負責清理過期的代理程式記憶,因為正確性與安全性在這裡需要相同的處理方式。
請檢視可能改變行為的資料列,而不是逐一檢視全部資料列,因為無人閱讀的佇列只是裝飾。值得人工檢查的資料列,包括提到認證資料、工具名稱、URL,或含有「always」和「never」等詞的資料列。讓寫入日誌採用僅附加模式,並與記憶體資料表分開;如此刪除遭污染的資料列時,不會一併刪除其出現時間及寫入該資料列的工作階段記錄。
防禦 4:讓記憶儲存區遠離代理程式的攻擊範圍
儲存區是代理程式存取的資料庫,因此應套用資料庫的基本防護。使用獨立的容器或主機、不發布任何連接埠,並使用不同於代理程式其他用途的認證資訊:
docker network create agent-mem將資料庫與代理程式連接至該網路,且不發布任何連接埠。只能透過內部 Docker network 存取的儲存區,即使代理程式程序已遭完全入侵,也無法從網際網路讀取。
接著限制代理程式自身角色可執行的操作:
CREATE ROLE agent_rw LOGIN PASSWORD 'generate-this-do-not-type-it';
GRANT SELECT, INSERT ON agent_memory TO agent_rw;
GRANT USAGE ON SEQUENCE agent_memory_id_seq TO agent_rw;不得有 UPDATE,也不得有 DELETE。若有人誘導代理程式「修正這段記憶」,它就無法改寫歷史,因為該角色沒有這項授權,而到期計時器則由另一個角色執行。讀者測試時會在更新操作中遇到的錯誤是 permission denied for table agent_memory,這表示控制機制正在生效。這些分開的資料庫密碼必須儲存在某處。如果該處是自架的 vault,就必須另外強化安全性,因為 Vaultwarden 真正的暴露面在管理員 token 與備份檔案,而不是儲存的密文本身。
最後一點最容易被忽略。如果代理程式程序在與資料庫密碼相同的環境中持有 cloud key 或 deploy token,那麼遭污染的記憶只要引導一次工具呼叫,就能取得所有這些資訊。請依照 將 secret 排除在 AI 代理程式的環境之外 的方式分隔它們,並將重要操作置於 明確的核准步驟 之後。如果你執行專用的記憶服務,例如 VPS 上的自架 Mem0 server,這些原則仍然適用:它本質上是前方有模型的資料庫,而兩個部分都需要採用相同的防護方式。
如何判斷記憶是否已遭污染?
先說明實情:單靠閱讀文字無法判斷。MINJA 的作者也針對自身紀錄提出這項觀點。他們認為,注入的內容對輸入與輸出審核而言讀起來相當合理,因此預期內容篩選會漏掉這類內容。在這裡,掃描已儲存句子、尋找看似惡意內容的篩選器,控制效果有限。
採用帳務追蹤比檢查內容更有效:
- 依來源查詢。列出
source不等於user_message的資料列,並依最新項目排序。在正常的儲存庫中,這份清單通常短到可以直接閱讀。 - 監控增長速率。儲存庫每天增加 5 筆資料,卻突然增加 200 筆時,不論這些資料列的內容為何,都應進一步調查。
- 保留固定的一組評估提示,並在記憶變更後執行。如果程式碼沒有變更,行為卻發生變化,問題可能出在儲存庫。
- 每天建立儲存庫快照,並比較各快照的差異。文字差異可以直接閱讀,向量搜尋則無法提供同樣的可讀性。
以上方法都不是偵測器。它們的差別在於:是由你自行找出遭污染的資料列,還是等客戶告知你。
這些措施無法修正的問題
隔離、來源追蹤與到期機制,可以縮短遭污染紀錄的存留時間,並限制其可觸及的對象。這些措施無法阻止模型在該紀錄留在儲存區期間相信其內容。真正限制損害的因素,是代理程式獲准執行的操作:如果代理程式未經人工核准就無法部署或花費資金,即使持有錯誤信念,也無法造成太大影響。
如果代理程式會讀取不受信任的內容,而您目前還無法記錄來源,請在不使用長期記憶的情況下執行。無狀態代理程式會在工作階段結束時忘記攻擊內容。這確實會降低實用性,但在儲存區完成隔離且寫入路徑明確之前,這仍是正確的取捨。
FAQ
記憶中毒只是換了較長名稱的 prompt injection 嗎?
不是。兩者的入口相同,但生命週期不同。Prompt injection 只存在於單一 context window,因此結束工作階段就會清除。記憶中毒會將惡意文字寫入持久性儲存,因此下一個工作階段會將其當成既定事實取回;重新啟動也無法清除。OWASP 因此將兩者分開:prompt injection 列於 LLM Top 10,而持久性記憶損毀則列為 Agentic Applications Top 10 中的 ASI06, Memory and Context Poisoning。
沒有資料庫存取權的人,也能讓我的代理程式記憶中毒嗎?
可以,這正是 MINJA 論文的研究結果。攻擊者只需傳送查詢並讀取回答,無須存取 memory bank;代理程式本身的記憶步驟會執行寫入。該論文在不同設定中報告,平均 injection success 為 98.2 percent,平均 attack success 為 76.8 percent。只要不受信任一方的文字能抵達你的擷取步驟,該介面就是寫入路徑,包括 support inbox 或擷取的網頁。
為每個 tenant 建立專屬 collection,就能修正 multi-tenant 資料洩漏嗎?
只有在每條程式碼路徑都套用 filter 時,才能修正讀取面;但這正是薄弱環節:遺漏 filter 時,系統會回傳其他 tenant 的資料列,而不是產生錯誤,因此不會有任何提示。這無法解決寫入面問題:某個 tenant 的文字可能被擷取成記錄,並以全域資料或錯誤的 id 儲存。使用不同的 credential 連線到不同的 store,則會在錯誤時安全失效,因為錯誤的 connection string 完全不會回傳資料列。
代理程式記憶應保留多久?
應比直覺上可接受的時間更短。TTL 是中毒資料列持續被取回的最長時間,因此 30 day 的到期設定會將暴露時間限制在 30 day 的工作階段內。凡是由模型擷取的內容,都應採用較短的預設期限,並要求人員審查資料列後,才能使其永久保存。將 expires_at 儲存在資料列本身,讓到期工作只需一個 DELETE,而不是依賴腳本猜測。