關掉重開就失憶、跑到一半就崩潰:agent 的記憶與韌性(AI Agent 架構全解析 4/8)
走到系列第四篇,我們的 agent 其實已經滿能打了。前三篇——拆解 harness、核心迴圈、扛住複雜任務的四招——把這台機器的骨架架好,讓它能規劃、拆解、扛下要好幾十步才做得完的複雜任務。但這台機器有兩個很尷尬的破口:你把視窗關掉再開,它完全不記得昨天聊過什麼、你交代過的偏好,每次都要從頭認識你一遍;更慘的是,跑到一半只要某個工具失敗、API 過載、或上下文塞爆,整條任務就當場崩掉,前面的努力全部白費。
這一篇要補的就是這兩塊:記憶(memory) 讓它跨執行不失憶;韌性(error recovery) 讓它在失敗裡活下來。中間再夾一塊很多人沒意識到、但天天在用的機制——system prompt 組裝,它決定了每一回合 agent 到底「知道」哪些事。這三件事湊在一起,才是把玩具迴圈變成能長期陪你工作的助手的關鍵。
記憶:讓 agent 關掉重開還記得你
先講一個你每天都在用的活例子。你用 Claude Code 時,那個 CLAUDE.md 和專案底下那堆 memory 檔案,就是這一節在講的東西。你交代過一次「不要用簡體字」,之後它每個 session 都記得——那不是模型變聰明了,是 harness 幫你把這條事實存到對話之外,下次開工再撈回來塞進去。
先分清楚它跟「單次執行的記憶」差在哪。迴圈裡那個 messages[] 是這次執行的短期記憶,執行一結束就消失,中途還可能因為太長被壓縮掉。長期記憶不一樣:它把持久事實寫在對話之外,之後再針對某一輪只撈相關的幾筆回來。
你可以把它想成一本工作筆記本,或一個專案的 README。你不會把每句話都抄進去——那本子很快會變成沒人想翻的流水帳;你抄的是「這客戶只收台幣報價」「這台機器的 DB 密碼放在 Keychain」這種之後推導不出來、一忘記就要重問的事實。這就是記憶的第一條紀律:只存無法靠 grep、git 或翻專案檔案推導出來的事實,程式碼裡查得到的東西不要存,存了只會製造雜訊。
整套記憶其實由四個操作撐起來,各司其職:
| 操作 | 時機 | 負責什麼 |
|---|---|---|
| Selection | 決定要不要存 | 判斷哪些事實值得留下 |
| Recall | 每一輪開始前 | 對現有記憶排序,撈出最相關的幾筆注入這一輪 |
| Extraction | 執行結束時 | 把這次學到的新事實寫成檔案 |
| Consolidation | 很少、背景跑 | 合併重複、清掉過時與矛盾的項目 |
這套機制有個漂亮的地方:Recall 只讀、Extraction 只寫,兩個方向徹底分開,儲存區才不會在迴圈中途莫名暴漲。實際接進迴圈長這樣:
1 | # 迴圈開始前:回想相關記憶,注入這一輪的訊息 |
效果:記憶只在「這一輪之前」讀一次、「執行結束時」寫一次。回想出的文字進了 messages[],所以之後上下文管理還能把它一起壓縮,不會硬佔位置。
提煉過的記憶檔案不是唯一的儲存區。你還可以把每次執行的原始對話文字整段記進第二個儲存區——用 SQLite 的 FTS5 全文檢索表存起來,之後靠關鍵字搜回來。提煉會漏,原始 log 卻保留所有內容,就算 extraction 漏抽了細節你也搜得到;而且它用 bm25 排序、完全不呼叫模型,便宜又快。
Claude Code 的記憶就放在 ~/.claude/projects/<專案根>/memory/ 底下,每筆是一個帶 YAML frontmatter 的 .md 檔,分成 user、feedback、project、reference 幾種類型,MEMORY.md 是索引。回想時由一個 Sonnet 側查詢最多挑 5 筆注入,還會附上新鮮度註記,讓模型知道「這條是三個月前記的,參考就好」;整併則交給一個叫「Dream」的背景任務定期合併清理。
記憶不是存越多越好。 什麼都存,回想就會變得又雜又舊,模型撈到一堆過時或不相關的事實,反而污染它這一輪的判斷。三個最常見的坑:Recall 撈太多筆把這一輪灌爆(限制注入數量、精準度優先)、過時記憶被當成現況事實(帶上存在時間或新鮮度中繼資料)、儲存區長期沒整併變成垃圾場(定期合併重複與矛盾項)。記憶的價值在「精準」,不在「量大」。
System prompt 組裝:每一回合的指令,都是臨時拼出來的
這一節要打破一個很多人的誤解:agent 的 system prompt 不是一段寫死的長字串。
system prompt 是 agent 的常駐指令集,描述它的身分、規則、有哪些工具可用、專案脈絡、開了哪些功能。問題是這些東西每個 session 都不一樣——這次你開了三個 MCP 伺服器、切了某個輸出風格,下次全都不同。prompt 若是寫死的,遲早會描述一堆「其實沒開」的功能,或漏掉「這次才啟用」的能力。
所以真實的 agent 是每一回合,從當下的即時狀態重新組裝出一份 prompt。你可以把它想成每次開會前臨時做簡報:你不會有一份用到底的萬用投影片,而是看今天要談什麼,臨時把相關的資料夾翻出來、把白板上的重點抄下來、把桌邊的便利貼收一收,現拼成這場剛好夠用的簡報;開完會狀態變了,下一場再拼一份新的。
做法意外地乾淨:把 prompt 定義成一組具名的段落(section)。有些是靜態的(永遠都在),有些會看即時狀態算出文字、不適用時回傳 None。組裝就是:逐段計算、把 None 丟掉、其餘接起來。
1 | # 每個段落有名字:static 段落固定,dynamic 段落看即時狀態決定要不要出現 |
效果:沒啟用的功能不會在 prompt 裡留下「殭屍指令」,每一回合都根據當下真的開了什麼,拼出一份剛好對得上現況的 system prompt——新功能有明確落腳段落、沒開的功能被自然略過、穩定段落還能吃到 prompt caching。
這裡有兩條紀律。第一,段落要不要放,靠狀態決定,不要靠關鍵字猜——「使用者好像提到檔案,那我把檔案工具說明加進去吧」這種猜測式組裝遲早出包。第二,把易變的內容放到後面,別放進穩定的 prompt 前綴。這牽涉 prompt caching:一份 session 裡大部分段落是穩定的,你可以在穩定前綴後面設一個 cache 斷點省下重複計費,但只要有一個會變動的值插在前面,後面的快取就全部失效。Claude Code 為此明確劃了一條 SYSTEM_PROMPT_DYNAMIC_BOUNDARY,用小小的動態尾段去換一大段靜態前綴的快取命中。
還有個關鍵分工:回想出來的記憶不屬於這份 prompt。上一節那些撈回來的記憶是包成 <system-reminder> 訊息注入對話的,不塞進 system prompt——因為記憶每輪都在變,塞進去會一直打壞前綴快取。同理,Claude Code 把 CLAUDE.md、日期、git 狀態這類常變動的脈絡也都用 context 訊息注入。哪些進 prompt、哪些進對話,界線很清楚。
組裝的順序會直接影響行為,也會影響荷包。 兩個最容易踩的雷:一是 prompt 裡提到「其實沒啟用的工具」,模型就會去呼叫一個不存在的東西——解法是工具說明一律從即時啟用的工具集生成,不要手寫死。二是把會變動的文字放在前綴,一整段本來能快取的內容因此天天失效,帳單默默變貴。另外,段落被快取後如果 session 狀態變了(例如你
/clear或/compact),記得把被記憶(memoize)的段落清掉,不然它會拿舊狀態餵你。
Error recovery:長任務怎麼在失敗中活下來
前面兩節讓 agent 記得事、也知道自己這回合能幹嘛。但任務一長就會撞到現實:一次執行橫跨很多次模型呼叫,任何一次都可能出事——網路抖一下、伺服器過載、撞到 rate limit、輸出超過上限、或上下文塞爆溢位。沒有復原機制,一次暫時性的 API 失敗就足以終結一項你等了半小時的長任務。
你可以把 error recovery 想成電玩的存檔點加上重試機制。魔王打到一半斷線,好的遊戲不會讓你從頭來過,而是從最近的存檔點續玩。agent 的復原也是同一套精神:失敗不可怕,可怕的是把一次可以救回來的失敗,當成整局結束。
不同的失敗要用不同的回應,所以第一步永遠是先分類,再採取有界限的行動:
- 暫時性的錯誤(過載、rate limit、5xx)→ 退避一下再重試。
- 問題出在 prompt 太長溢位 → 先壓縮一次,再重試。
- 反覆過載壓不下去 → 切換到 fallback model。
- 未知或根本不可重試的錯誤 → 停止,並把錯誤浮現出來。
分類與退避的骨架長這樣:
1 | RETRY_STATUS = {408, 409, 429} # 這些狀態碼 + 任何 5xx 才值得重試 |
效果:不是所有錯誤都重試,只有分類判定「暫時性」的才重試;退避時間指數成長還加上隨機抖動,避免一堆 client 在同一秒對著過載的伺服器一起撲上去,把它壓得更死。
有兩條路徑比較細膩。溢位(prompt_too_long)要在一般狀態碼之前先攔下來:只要壓縮能把 prompt 縮小它就可復原——遇到溢位就跑一次壓縮 callback(就地修剪 messages[])再重試,但只給一次機會,壓完還是爆就放棄,不然會無限壓下去。反覆的 529(過載)則累計次數,撞到一定次數就切換 fallback model,換一台比較不忙的頂上,而不是死守同一台被打回票。
看看 Claude Code 實際怎麼做:
| 失敗類型 | Claude Code 的處理 |
|---|---|
| 429 / 408 / 409 / 5xx | 由狀態決定、帶退避的重試;retry-after 優先於自己算的退避 |
| 輸出被截斷 | 用更高的輸出上限重試,或用續寫 prompt 救回 max_tokens 的停止 |
| prompt 太長溢位 | reactive compaction 壓縮一次再試 |
| 反覆 529 過載 | 次數到了拋出 FallbackTriggeredError,切換 fallback model |
還有一條最容易被忽略、卻最重要的原則:當所有復原手段都用盡、真的救不回來時,錯誤必須浮現出來,不能被吞掉。一個被默默吞掉的錯誤會讓你以為它做完了,其實它中途死了還裝沒事。誠實報出失敗,永遠比假裝成功好。
盲目重試會放大災情,不是解決災情。 最經典的是 retry storm:伺服器已經過載,結果一堆 client 同時瘋狂重試,等於補了一刀讓它更慘——所以一定要限制重試次數、尊重伺服器回的
retry-after。第二個坑是「無限復原」:提高輸出上限、續寫、壓縮這幾條路,任何一條沒設界限都可能變成死迴圈,每條路徑都要有明確的次數上限。原則很簡單:復原機制自己也要有煞車,救不動就停,別把一次小故障滾成一場雪崩。
下一步:從一場對話,走向一個系統
記憶讓 agent 跨執行不失憶,system prompt 組裝讓它每一回合都清楚自己當下能做什麼,error recovery 讓它摔倒了還爬得起來——這三塊湊齊,它才撐得夠久、記得夠多,久到值得你把真正的工作交給它。
不過到目前為止,我們談的都還是「一次對話」的尺度:你在鍵盤前面,它在你眼前跑完一輪。真正的挑戰在下一步——當任務長到要跑好幾個小時、甚至需要在你離線時自己非同步地推進,agent 要怎麼從「一場對話」升級成「一個能長時間自己運轉的系統」?下一篇(第五篇:從對話到系統,長時間與非同步)就來拆這件事:怎麼讓 agent 跑得久、跑得穩,還能在沒人盯著的時候自己把事情做完。我們下篇見。











