當 AI 把你的 codebase 當成記憶:三條路線與該付的代價
起因是讀一篇論文〈Code as Agent Harness〉(arXiv 2605.18747),看到一句話:semantic memory 會把外部的 codebase 變成一個「可查詢的證據空間」(queryable evidence space),讓 agent 隨時撈出來、塞進當下的 context。同一篇還有另一句,後來變成整串討論的暗線:沒治理的歷史紀錄會帶來語意雜訊、錯誤傳播跟假命中(semantic noise、error propagation、false retrievals),只有經過篩選、控管品質的經驗記憶才可能變成有用的資產。
我把這兩句丟給手邊的 coding agent 追問,聊著聊著,把「agent 到底怎麼記住一個專案」整套拆開了。記下來。
我的第一個問題很笨:要怎麼讓我手上這個專案,變成那種可查詢的證據空間?
先分清楚兩種「記憶」
很多人把 RAG 跟 agent memory 混在一起談。拆開其實是兩種東西。一種是證據索引:把原始碼切片、算 embedding、丟進向量庫,要用的時候靠語意相似度撈回來。另一種是蒸餾過的事實,像「這個 repo 的 lint 指令是 task lint」這種一句話結論。
把兩種記憶各自攝開,差別會更清楚。證據索引一筆長這樣(虛構例子):
{
"id": "src/billing/parser.py::parse_invoice",
"path": "src/billing/parser.py",
"span": [142, 168],
"symbol": "parse_invoice",
"kind": "function",
"text": "def parse_invoice(raw): ...",
"embedding": [0.013, -0.224, ...],
"git_blob_sha": "a1b2c3...",
"indexed_at_commit": "c6397c7"
}
那個 git_blob_sha 是後面講增量同步的關鍵。蒸餾事實則樸素得多:
subject: tooling
fact: "lint 跑 task lint、format 跑 task format"
citation: Taskfile.yaml
scope: repository
一個是「原始證據、可回滯行號、隨程式碼變動」;另一個是「人讀得懂的結論、變得慢但會慢慢 drift」。論文那句主要在講前者,但天天在用的,往往是後者。
三條路線,規模決定選哪條
我原本以為一定要建向量庫。聊完才發現,我這個專案才兩百多個檔案,建向量庫是浪費。
「全量塞」這條最土也最有效:把整個 codebase 連文件一次塞進 context window。召回率百分之百,零維護,永遠不會過時。只要塞得下,它就贏過所有花俏的做法。會破功只有三種情況——塞不下、每次都付整包的 token 太貴、或 context 太長導致模型「看到卻沒抓到」關鍵那幾行(lost in the middle)。
「即時查」這條靠的是現場檢索:需要什麼就 grep、glob、用 tree-sitter 掃當下的檔案。它最大的特點是不產生任何中間檔,查完就丟,沒有索引、沒有向量、沒有背景程序要顧。會落在磁碟上的只有你自己手寫的入口文件(AGENTS.md、架構文件那類),那是原始碼,不是衍生物。
「向量庫」這條才是論文那種向量 RAG:用 tree-sitter 按 function/class 切片(連 import 跟 docstring 一起留著保上下文)、用 code-aware 的 embedding 模型(text-embedding-3-large、voyage-code、jina-code 那類)算向量、塞進 sqlite-vec 或 LanceDB 這種單檔就能 gitignore 的本地向量庫、查的時候把 dense 向量跟 BM25 混在一起,最後包成一個 MCP server,暴露一個 search_code 給任何 agent 用。它的代價很明確:會生出一包 .index/,會過時,要持續同步。
聊到這我才想通一件事:沒有「即時的向量檢索」這種好康。向量一定要先建索引,這一步就是它所有維護成本的來源。「即時查」的即時,是用 grep 換來的;「向量庫」想要語意,就得接受索引會 stale。
程式一直改,記憶怎麼維護
這是我最在意的問題。我每天都在改 code,記憶不就天天爛掉?
關鍵答案是增量,不是重寫。向量索引那邊,每筆 chunk 存一個 git blob SHA,重建時拿來比對:沒變的跳過、變的重嵌、刪掉的移除。觸發點放在 pre-push 或 CI,別放 pre-commit,不然每次提交都卡著等 embedding。再加一個每晚的全量對帳當安全網。
蒸餾事實那邊更微妙,因為它 drift 得最快。我學到一個反直覺的點:抗 churn 的解藥是「把事實寫抽象一點」,而不是勤於重寫。「lint 跑 task lint」這種寫法,我改幾百行它都不會錯;「判斷邏輯在 parser.py 第 142 行」這種,行號一動就指錯人。前者活得久,後者三天就爛。背後其實是一條更通用的原則:寧可寫「去發現當下狀態」的指令,也別硬編路徑跟行號(prefer instructions that discover current state over hardcoded paths)。
還有一點值得記:跨 session 的記憶是累積的,新 session 該沿用、增量更新,不該清空重寫。這跟 多上下文 agent 工作流程 背後是同一套狀態管理紀律:可恢復的外部狀態勝過依賴 context window。重寫除了浪費 token,還會把 upvote/downvote 累積下來的品質訊號一起丟掉。
五層記憶會不會自己打架
我追問 agent,它自己的記憶存在哪。它列了五層,我把實際位置抽掉、只留性質:
- 蒸餾事實——store/vote 出來的短句知識,跨 session 長期保存。風險高。
- 結構地圖——背景程序維護的 codebase 地圖,會被主動推進 context。風險高。
- session 狀態——這次任務的 plan、checkpoint、產出,做完就丟。風險低。
- 可查詢的歷史庫——過去 session 的對話、事件、檔案改動,可以用 SQL 撈,長期可回滯。風險中。
- 暫存——todo、佇列那類用完即丟的東西。風險低。
五層聽起來很容易亂。但拆開看,風險只集中在前兩層。判斷標準有兩個:內容是「主張」還是「事實紀錄」,以及會不會被主動塞進 context。同時踩中這兩條的才危險,也就是那些「會自己冒出來、又可能講錯」的蒸餾事實跟結構地圖。第 3、5 層是即拋日誌,第 4 層是客觀紀錄、而且要我主動去查才會用到,污染不了正在做事的那塊。分層本身就是治理:原始流水帳不會跑去污染斷言空間,只有受治理的那一層會被自動取用。
用前驗證,會不會很燒 token
我提了一個自認為能戳破整套說法的問題:如果每條記憶用之前都要驗證,記憶一多,不就要驗到天荒地老、token 燒爆?
agent 的回答讓我重新理解這件事。成本不是看記憶總數,是看「這次任務真的拿來做決定的有幾條、錯了會多嚴重」。一次注入五條,我可能只靠其中一條下判斷,其餘四條根本沒用上,不驗。
而且大部分驗證是順便做的,幾乎不額外花 token。「lint 是 task lint」這條,我直接跑那個指令,錯了當場噴錯,就驗完了。「某函式在哪一行」這條,我本來就要讀那個檔,讀的時候順手確認。真正要另外開一輪、認真花 token 去驗的,只剩「後果嚴重、又容易過時、還沒辦法順手驗」的少數幾條,很少見。
那記憶數量本身怎麼控?重點在寫入端跟淘汰端,不在事後大掃除。寫入門檻設高:一條事實要能存,得對未來有用、跟當前這次改動無關、不太會變、而且沒辦法從一小段程式碼直接看出來。同時有一張明文黑名單擋著——ephemeral、任務專屬、「暫時這樣」、秘密、還有任何會變的東西,一律不準存。
再來一條我覺得最關鍵的規則:遇到已經存過的事實,正確動作是投票,不是再存一條。upvote 強化、downvote 降權,「又學到同一件事」只會讓既有那條的權重變高,不會多長一列。用投票收斂,而不是用新增膨脹。最後讀取端還有一道有界注入:每次只浮現 top-N 相關的,於是「實際被注入的量」跟「總庫多大」就脱鉤了。
Repo map:每個大任務開頭的那張地圖
聊到後面收斂出一個具體做法。每接到一個比較大的任務,先讓 agent 掃一遍相關檔案,產出一張「檔案 → class → 行號 → 誰呼叫誰」的地圖,接下來整個任務都靠這張地圖推進。
這張地圖該存多久,決定它會不會爛。當任務的暫存、做完就丟、每次重生,它就保有「永遠不 stale」的好處。一旦你存起來想跨任務重用,它立刻變成一個會過時的衍生檔,得套上跟向量索引一樣的維護紀律。我的選擇是前者:放進 session 的 plan 檔,不進版控,每個大任務重生一次。
生地圖會不會很燒 token、要叫一堆 tool?這也是我問過的。地圖本身其實很小,三百個 token 上下。真正的成本在掃描,但那些檔案你做任務本來就要讀,掃描只是把「反正要讀」的東西前置、壓縮。長任務算下來通常是省的。要壓掃描成本也有招:丟給一個獨立的 explore sub-agent 去跑,那一堆 grep 跟雜亂輸出留在它自己的 context 裡,你只收回一張乾淨的地圖。
還有一個我之前沒想到的角度:這種機械式的抽取,根本不用動到最強的模型。找符號、標行號、畫呼叫關係,是低推理、高召回的活,Haiku 或 mini 級的模型就做得很好,不少 agent 框架預設也把 explore 派給 Haiku 這種便宜模型。強模型留著做判斷:哪些檔跟這次任務有關、哪些關聯藏得比較深、該先動哪個。便宜模型掃,貴模型判,各司其職。
把這些拼起來,一個大任務開頭的流程大概長這樣:先判斷值不值得做地圖(小到只改一個檔就直接 grep,跳過);查有沒有現成的背景地圖能直接用;沒有就派一個 explore sub-agent 用便宜模型、平行地粗掃出一張全景地圖;接著換強模型(conductor)結合「任務+粗地圖」判斷哪些檔要讀、哪些要改;最後只對選中的少數檔放大深讀,收成一張聚焦的工作集地圖。最後這步是「篩+放大」——先廣掃全景,再 zoom-in 細看,不是單純砍出一個子集。session 內這張圖會一邊做一邊長大,跨 session 就丟掉重生。
它沒告訴你的那些洞
整套聽下來很順,但我請 agent 老實講限制,它給的幾條才是重點。
citation 的行號會「靜默」漂移。記憶寫著 parser.py 第 142 行,程式一改可能就指到別的地方,而且不會報錯,只有在我剛好又讀那個檔的時候才會發現。維護是反應式的:一條過時的事實,要等到下次有人用到、又剛好去驗,才會被修正,在那之前它一直掘著誤導人。沒有「程式一改、相關記憶自動失效」這種主動機制。
去重也只是 best-effort。近似重複如果沒被浮現出來比對,還是可能誤存成兩條。而 downvote 是降權不是刪除——真要彻底刪掉一條記憶,得自己進工具的記憶管理介面手動處理,光靠用的時候按倒讚收斂不掉。弱模型抽地圖也一樣,行號準度跟關聯遺漏率都比較高,高風險的改動別省那一輪複查。
一頁帶走
- 小型 repo:全量塞或 grep + 入口文件就夠,不需要向量庫,那是過度設計。
- semantic memory:跨 session 持久、增量維護、絕不每次重寫;靠寫入高門檻 + 去重改投票 + prune + 有界注入控量。
- 抗 churn:把記憶寫在抽象層級,指令型勝過硬編行號。
- repo map:即生即用、放 plan 檔、每任務重生才保證不 stale;委派便宜模型的 explore sub-agent 去掃,自己只判斷。
- 驗證紀律 > 存多少:記憶的價值在用前驗證跟可回滯 citation,不在數量。
所以重點不在記憶系統有多完美。它的價值押在驗證紀律跟可回溯的 citation 上,跟你存了幾條沒關係。整場對話的主軸也在這:從全量塞、到 grep、到向量,每往前一步,換來的能力都要拿一份維護責任去付。挑哪一條,看你的 codebase 現在多大、以及你願意付多少維護成本。