實(shí)現(xiàn)上下文壓縮免疫)
1. 從一次上下文崩潰說起為什么長會(huì)話代理總會(huì)“失憶”做過 AI 編碼代理的人都遇到過這個(gè)場(chǎng)景你花了兩個(gè)小時(shí)跟代理一起把一個(gè)模塊從零搭起來中間討論了接口設(shè)計(jì)、踩了三個(gè)依賴沖突的坑、定下了命名規(guī)范結(jié)果對(duì)話一長代理突然開始胡言亂語——它把你三小時(shí)前明確否掉的方案又提了一遍把已經(jīng)改好的函數(shù)名寫回舊版本甚至開始編造根本不存在的文件路徑。你往上翻記錄發(fā)現(xiàn)它并不是“忘了”而是上下文被壓縮之后那些關(guān)鍵決策被摘要成了幾句不痛不癢的廢話。這個(gè)現(xiàn)象我管它叫“腦葉切除術(shù)”。上下文窗口就像一個(gè)容量固定的工作臺(tái)當(dāng) token 數(shù)量逼近上限系統(tǒng)必須做壓縮要么截?cái)嘧钤绲膶?duì)話要么用一個(gè)小模型把歷史總結(jié)成摘要。無論哪種方式被切掉的部分往往是“為什么這么做”的推理鏈留下的只是“做了什么”的結(jié)論。而編碼這件事恰恰極度依賴推理鏈——一個(gè)變量為什么叫這個(gè)名字、一個(gè)邊界條件為什么這么處理這些信息一旦丟失代理就會(huì)退化成只會(huì)補(bǔ)全代碼的自動(dòng)機(jī)。標(biāo)題里提到的“20 萬 token”不是夸張。一個(gè)中等復(fù)雜度的重構(gòu)任務(wù)加上反復(fù)的報(bào)錯(cuò)日志、文件內(nèi)容、diff 輸出輕松就能堆到十幾萬 token。我實(shí)測(cè)過一個(gè)跨模塊的接口遷移任務(wù)光是來回粘貼的編譯錯(cuò)誤就有四萬多 token。在這種量級(jí)下任何依賴“把全部歷史塞進(jìn)上下文”的方案都是脆弱的。那怎么辦標(biāo)題給了兩個(gè)看似不相關(guān)的線索Unix init.d和《記憶碎片》。前者是操作系統(tǒng)里那套“開機(jī)自動(dòng)拉起服務(wù)、崩潰自動(dòng)重啟”的機(jī)制后者是那部倒敘順敘雙線交織的電影。把這兩個(gè)東西揉進(jìn) AI 代理的設(shè)計(jì)里就能得到一套對(duì)上下文壓縮免疫的架構(gòu)。下面我把這套思路完整拆開講。2. 核心思路拆解把代理當(dāng)成一個(gè)需要“開機(jī)自啟”的系統(tǒng)服務(wù)2.1 為什么是 init.d 而不是“更聰明的記憶”大多數(shù)人遇到代理失憶第一反應(yīng)是“換個(gè)更大的上下文窗口”或者“換個(gè)更強(qiáng)的摘要模型”。這兩個(gè)方向我都試過結(jié)論是治標(biāo)不治本。窗口再大也有上限摘要模型再強(qiáng)也會(huì)丟信息因?yàn)槟銦o法保證它每次都把“關(guān)鍵決策”判定為關(guān)鍵。init.d 的思路完全不同。它不關(guān)心“記憶”本身它關(guān)心的是狀態(tài)的可重建性。一個(gè) Unix 服務(wù)重啟之后它不需要記得自己上次運(yùn)行到哪它只需要讀取配置文件、檢查運(yùn)行時(shí)狀態(tài)、然后繼續(xù)工作。換句話說持久化的不是對(duì)話歷史而是對(duì)話產(chǎn)生的副作用。映射到 AI 編碼代理上代理不應(yīng)該依賴“我記得我們討論過用 snake_case”而應(yīng)該依賴“項(xiàng)目里有一個(gè).editorconfig文件寫著naming snake_case”。代理不需要記得“上次編譯報(bào)錯(cuò)是因?yàn)槿绷四硞€(gè)依賴”而應(yīng)該依賴“項(xiàng)目里有一個(gè)deps.lock文件記錄了完整依賴樹”。對(duì)話歷史可以丟但這些落盤的狀態(tài)不能丟。這就是 init.d 哲學(xué)的第一層把易失的上下文轉(zhuǎn)化為持久的、可被重新讀取的配置和狀態(tài)文件。2.2 《記憶碎片》結(jié)構(gòu)倒敘與順敘的雙軌記憶《記憶碎片》的主角有短期記憶障礙他的應(yīng)對(duì)方式是把關(guān)鍵信息紋在身上、拍成照片、寫成紙條。電影的結(jié)構(gòu)也是雙線——一條線倒敘一條線順敘兩條線在中間交匯。這套結(jié)構(gòu)用在代理上非常巧妙。傳統(tǒng)的對(duì)話歷史是純線性的從第一條消息到最后一條。壓縮的時(shí)候系統(tǒng)傾向于保留最近的、丟掉最早的。但編碼任務(wù)的關(guān)鍵決策往往發(fā)生在早期——架構(gòu)選型、接口定義、命名規(guī)范這些都在開頭幾輪就定下了。線性壓縮恰好把最重要的部分丟掉了。雙軌結(jié)構(gòu)是這樣設(shè)計(jì)的順敘軌正常的對(duì)話流記錄“我們正在做什么”。這條軌可以隨意壓縮、截?cái)嘁驗(yàn)樗怀休d過程。倒敘軌一個(gè)反向索引的“決策日志”記錄“我們?yōu)槭裁催@么做”。每做一次關(guān)鍵決策就往這個(gè)日志里追加一條帶時(shí)間戳和理由的記錄。這條軌永不壓縮因?yàn)樗旧砭褪歉叨葷饪s的。當(dāng)上下文被壓縮后代理重新加載時(shí)先讀倒敘軌決策日志再讀順敘軌最近幾輪對(duì)話就能快速重建出“我是誰、我在做什么、我為什么這么做”的完整認(rèn)知。這就像主角每次醒來先看自己身上的紋身。2.3 兩套機(jī)制如何咬合單有 init.d 的持久化還不夠因?yàn)闋顟B(tài)文件會(huì)越來越多代理重新加載時(shí)怎么知道該讀哪些單有《記憶碎片》的決策日志也不夠因?yàn)槿罩局挥涗洓Q策不記錄決策產(chǎn)生的實(shí)際文件變更。兩者咬合的方式是決策日志負(fù)責(zé)“指路”狀態(tài)文件負(fù)責(zé)“落地”。決策日志里的一條記錄長這樣[2024-XX-XX 14:32] 決策接口層采用適配器模式 理由上游有三個(gè)數(shù)據(jù)源格式不一致直接耦合會(huì)導(dǎo)致每次新增源都要改核心邏輯 影響文件src/adapters/*.ts, src/core/pipeline.ts 狀態(tài)快照snapshot-014代理重啟后讀到最后一條決策就知道要去src/adapters/目錄看當(dāng)前有哪些適配器去src/core/pipeline.ts看核心邏輯怎么調(diào)用的。它不需要記得討論過程只需要讀文件就能重建認(rèn)知。這就是“對(duì)上下文壓縮免疫”的本質(zhì)——壓縮掉的是對(duì)話不是認(rèn)知。3. 核心細(xì)節(jié)解析決策日志與狀態(tài)快照的具體設(shè)計(jì)3.1 決策日志的字段設(shè)計(jì)與寫入時(shí)機(jī)決策日志不是流水賬不能每輪對(duì)話都寫。寫得太密它自己就變成了需要壓縮的長文本寫得太疏關(guān)鍵信息又會(huì)丟。我的經(jīng)驗(yàn)是只在“不可逆決策”發(fā)生時(shí)寫入。什么叫不可逆決策滿足以下任一條件即可選定了某個(gè)方案且該方案會(huì)排除其他方案比如選了 REST 就不太可能同時(shí)用 GraphQL定義了會(huì)被多處引用的命名、接口、數(shù)據(jù)結(jié)構(gòu)確認(rèn)了某個(gè)約束條件比如“這個(gè)模塊不能引入新的第三方依賴”踩了一個(gè)坑并確定了規(guī)避方式比如“這個(gè)庫的某個(gè)版本有 bug鎖定到上一個(gè)版本”每條記錄的字段我固定為六個(gè)字段說明是否必填時(shí)間戳精確到分鐘用于排序是決策內(nèi)容一句話說清選了什么是理由為什么這么選這是最關(guān)鍵的是影響文件涉及哪些文件路徑是狀態(tài)快照對(duì)應(yīng)的快照編號(hào)是替代方案考慮過但放棄的方案及原因否“理由”字段是靈魂。我見過太多代理在壓縮后把決策內(nèi)容記住了但把理由忘了結(jié)果遇到一個(gè)邊界情況就不知道該怎么處理因?yàn)樗恢喇?dāng)初為什么這么設(shè)計(jì)。理由寫清楚了代理就能自己推導(dǎo)出邊界情況該怎么處理。寫入時(shí)機(jī)上我建議在每輪對(duì)話結(jié)束時(shí)由代理自己判斷是否觸發(fā)寫入而不是每輪都寫。判斷邏輯可以很簡(jiǎn)單如果這輪對(duì)話產(chǎn)生了文件變更且變更涉及接口或命名就寫一條。這個(gè)判斷本身可以用一個(gè)輕量規(guī)則實(shí)現(xiàn)不需要再調(diào)一次大模型。3.2 狀態(tài)快照的粒度與存儲(chǔ)策略狀態(tài)快照不是 git commit。git commit 記錄的是代碼的完整狀態(tài)而狀態(tài)快照記錄的是代理的認(rèn)知狀態(tài)。兩者互補(bǔ)但不能互相替代。快照的粒度我試過三種每輪快照太密存儲(chǔ)爆炸而且大部分快照沒有區(qū)別每小時(shí)快照太疏中間的關(guān)鍵變更可能丟失決策觸發(fā)快照每寫一條決策日志就拍一個(gè)快照這個(gè)粒度最合適快照的內(nèi)容包括當(dāng)前工作目錄的文件樹、關(guān)鍵文件的哈希值、當(dāng)前打開的“任務(wù)上下文”比如正在改哪個(gè)模塊、以及最近 N 輪對(duì)話的摘要。注意快照里不存完整對(duì)話歷史只存摘要。完整歷史交給順敘軌去管快照只負(fù)責(zé)“重建認(rèn)知所需的最小信息集”。存儲(chǔ)策略上我用的是“快照鏈 定期歸檔”。最近 20 個(gè)快照保留完整內(nèi)容更早的快照只保留決策日志和文件哈希具體文件內(nèi)容從 git 歷史里按哈希找回。這樣既控制了存儲(chǔ)又保證了可追溯性。3.3 重新加載時(shí)的“喚醒流程”這是整套機(jī)制最關(guān)鍵的環(huán)節(jié)。代理被壓縮后重新加載不能直接把所有決策日志和快照都讀一遍——那又變成大上下文了。喚醒流程必須是有序的、增量的。我的喚醒流程分四步讀最后一條決策日志知道當(dāng)前處于哪個(gè)決策節(jié)點(diǎn)讀對(duì)應(yīng)的狀態(tài)快照知道當(dāng)前文件樹和任務(wù)上下文讀最近 3-5 輪順敘對(duì)話知道剛才在做什么具體操作按需回溯如果當(dāng)前任務(wù)涉及某個(gè)早期決策再定向讀取那條決策日志這四步下來通常只需要 2000-4000 token 就能重建完整認(rèn)知相比原來十幾萬 token 的上下文壓縮比超過 95%。而且這個(gè)重建過程是確定性的——同樣的決策日志和快照每次重建出的認(rèn)知都一樣不會(huì)像摘要那樣每次都有細(xì)微差異。注意喚醒流程里第 4 步“按需回溯”需要代理有能力判斷“當(dāng)前任務(wù)涉及哪個(gè)早期決策”。這個(gè)判斷我建議用一個(gè)簡(jiǎn)單的關(guān)鍵詞匹配 文件路徑匹配來實(shí)現(xiàn)不要用大模型判斷否則又引入不確定性。4. 實(shí)操過程從零搭一套免疫壓縮的代理工作流4.1 目錄結(jié)構(gòu)約定先定目錄結(jié)構(gòu)這是整套機(jī)制的地基。我在項(xiàng)目根目錄下建一個(gè).agent/目錄里面放所有代理相關(guān)的狀態(tài)文件.agent/ decisions.log # 決策日志純文本一行一條 snapshots/ # 狀態(tài)快照目錄 snapshot-001.json snapshot-002.json ... current-task.md # 當(dāng)前任務(wù)描述代理每次喚醒先讀這個(gè) constraints.md # 硬約束清單比如“不許引入新依賴”decisions.log用純文本而不是 JSON是為了讓代理能快速 grep。JSON 雖然結(jié)構(gòu)化好但讀取時(shí)需要解析而且容易因?yàn)楦袷絾栴}導(dǎo)致整個(gè)文件不可讀。純文本一行一條即使某行格式壞了其他行還能用。current-task.md是代理的“當(dāng)前指令”。每次喚醒時(shí)代理先讀這個(gè)文件知道“我現(xiàn)在要干什么”。這個(gè)文件由人維護(hù)或者在任務(wù)切換時(shí)由代理自己更新。它不需要長三五句話即可。constraints.md是硬約束比如“這個(gè)項(xiàng)目用 pnpm 不用 npm”“所有異步操作必須用 async/await 不用回調(diào)”。這些約束不常變但一旦違反就會(huì)產(chǎn)生大量返工所以單獨(dú)拎出來每次喚醒必讀。4.2 決策日志的寫入腳本代理自己寫日志容易格式不統(tǒng)一我寫了一個(gè)簡(jiǎn)單的 shell 函數(shù)來規(guī)范化寫入# 用法log_decision 決策內(nèi)容 理由 影響文件 替代方案 log_decision() { local ts$(date %Y-%m-%d %H:%M) local snapshot_idsnapshot-$(printf %03d $(ls .agent/snapshots/ | wc -l | tr -d )) echo [$ts] 決策$1 | 理由$2 | 影響文件$3 | 快照$snapshot_id | 替代方案$4 .agent/decisions.log # 拍快照 mkdir -p .agent/snapshots { echo { echo \timestamp\: \$ts\, echo \decision\: \$1\, echo \files\: \$(find src -type f -newer .agent/snapshots/last_marker 2/dev/null | tr \n ,)\, echo \task\: \$(cat .agent/current-task.md | head -5 | tr \n )\ echo } } .agent/snapshots/$snapshot_id.json touch .agent/snapshots/last_marker }這個(gè)腳本做了三件事寫日志、拍快照、更新標(biāo)記文件。標(biāo)記文件用于下次拍快照時(shí)判斷哪些文件是新改的。實(shí)際使用時(shí)代理在每輪對(duì)話結(jié)束時(shí)調(diào)用這個(gè)函數(shù)參數(shù)由代理自己填。實(shí)操心得理由字段一定要讓代理用“因?yàn)椤浴钡木涫綄懖灰粚憽耙驗(yàn)椤薄N以囘^只寫原因不寫結(jié)論結(jié)果代理喚醒后看到原因自己推導(dǎo)出了另一個(gè)結(jié)論跟當(dāng)初的決策不一致。加上“所以……”能強(qiáng)制它把結(jié)論也寫清楚。4.3 喚醒流程的實(shí)現(xiàn)喚醒流程我封裝成一個(gè)腳本代理啟動(dòng)時(shí)先跑這個(gè)腳本把輸出作為第一條系統(tǒng)消息注入#!/bin/bash # wakeup.sh - 代理喚醒腳本 echo 當(dāng)前任務(wù) cat .agent/current-task.md echo echo 硬約束 cat .agent/constraints.md echo echo 最近決策 tail -5 .agent/decisions.log echo echo 當(dāng)前文件樹 find src -type f -name *.ts -o -name *.js -o -name *.py | head -30 echo echo 最近對(duì)話摘要 # 這里從順敘軌讀取最近幾輪摘要具體實(shí)現(xiàn)依賴你的對(duì)話存儲(chǔ)方式 cat .agent/recent-summary.md 2/dev/null || echo (無摘要)這個(gè)腳本的輸出通常只有 1500-2500 token但包含了重建認(rèn)知所需的全部信息。代理讀完這個(gè)輸出就知道自己是誰、在哪、要干什么、有什么約束、最近做了什么決策。4.4 與現(xiàn)有工具鏈的集成這套機(jī)制不需要替換你現(xiàn)有的代理框架它是在框架之上加一層“狀態(tài)管理層”。我用的是這樣的集成方式對(duì)話存儲(chǔ)仍然用框架自帶的對(duì)話歷史但設(shè)置一個(gè)較短的保留窗口比如最近 20 輪更早的自動(dòng)丟棄決策日志由代理在每輪結(jié)束時(shí)調(diào)用log_decision寫入喚醒注入在每次新會(huì)話開始時(shí)先跑wakeup.sh把輸出作為 system message 注入任務(wù)切換當(dāng)任務(wù)切換時(shí)更新current-task.md并寫一條“任務(wù)切換”決策日志這樣即使框架把對(duì)話歷史壓縮得面目全非代理也能通過喚醒流程重建認(rèn)知。我實(shí)測(cè)過一個(gè) 18 萬 token 的重構(gòu)任務(wù)中間經(jīng)歷了 7 次上下文壓縮代理沒有出現(xiàn)一次“失憶”導(dǎo)致的返工。5. 常見問題與排查技巧實(shí)錄5.1 決策日志膨脹了怎么辦這是最常見的問題。項(xiàng)目跑久了decisions.log可能積累幾百條記錄喚醒時(shí)tail -5只看最近 5 條但代理有時(shí)需要回溯更早的決策一 grep 就拉出一大堆。我的解法是分層歸檔。每 50 條決策為一個(gè)“紀(jì)元”歸檔到.agent/decisions-epoch-N.log主日志只保留當(dāng)前紀(jì)元的記錄。喚醒時(shí)只讀主日志需要回溯時(shí)再按紀(jì)元號(hào)去讀歸檔文件。紀(jì)元號(hào)可以從決策日志的時(shí)間戳推算或者直接在current-task.md里標(biāo)注“當(dāng)前紀(jì)元3”。另一個(gè)技巧是給決策打標(biāo)簽。在決策內(nèi)容前加一個(gè)方括號(hào)標(biāo)簽比如[架構(gòu)]、[命名]、[依賴]、[坑]?;厮輹r(shí)按標(biāo)簽 grep比全文搜索快得多也準(zhǔn)得多。5.2 快照和 git 沖突了怎么辦有人會(huì)問既然有 git為什么還要快照兩者不沖突但職責(zé)不同。git 記錄的是代碼狀態(tài)快照記錄的是認(rèn)知狀態(tài)。git 里看不到“我們?yōu)槭裁催x了這個(gè)方案”快照里能看到。但確實(shí)會(huì)有冗余。我的做法是快照里不存文件內(nèi)容只存文件路徑列表和哈希值。需要文件內(nèi)容時(shí)用哈希值去 git 歷史里找。這樣快照文件很小通常只有幾 KB不會(huì)和 git 搶存儲(chǔ)。注意快照里的哈希值要用 git 的 blob 哈希不要用 md5 或 sha256。git blob 哈??梢灾苯佑胓it cat-file找回內(nèi)容其他哈希還得自己維護(hù)映射表。5.3 代理不按格式寫決策日志怎么辦這是提示詞工程問題。我的經(jīng)驗(yàn)是不要指望代理自覺。在 system prompt 里明確寫“每輪結(jié)束必須調(diào)用 log_decision參數(shù)格式如下”并給一個(gè)完整示例。如果代理還是寫錯(cuò)就在喚醒腳本里加一個(gè)校驗(yàn)讀最近一條決策日志如果格式不對(duì)就在喚醒輸出里加一句“上一條決策日志格式異常請(qǐng)修正”。更狠的辦法是用工具調(diào)用強(qiáng)制格式。如果你的代理框架支持 function calling把log_decision定義成一個(gè)工具參數(shù)用 JSON Schema 約束。這樣代理不按格式寫就調(diào)不通自然會(huì)修正。5.4 喚醒后代理仍然“跑偏”怎么排查喚醒后跑偏通常是三個(gè)原因之一現(xiàn)象可能原因排查方法代理重復(fù)已否決的方案決策日志里“替代方案”字段沒寫清楚grep 決策日志看被否決方案是否記錄在案代理用了錯(cuò)誤的命名constraints.md里沒寫命名規(guī)范檢查 constraints 是否覆蓋了命名約定代理不知道當(dāng)前在改哪個(gè)文件current-task.md太模糊把任務(wù)描述細(xì)化到文件級(jí)別我踩過最坑的一次是current-task.md里寫“優(yōu)化數(shù)據(jù)處理流程”代理喚醒后把整個(gè)數(shù)據(jù)層重寫了一遍因?yàn)樗恢馈皟?yōu)化”具體指哪個(gè)環(huán)節(jié)。后來我把任務(wù)描述改成“優(yōu)化src/core/pipeline.ts里transformBatch函數(shù)的批處理邏輯目標(biāo)是把單批耗時(shí)從 200ms 降到 100ms 以內(nèi)”代理就精準(zhǔn)多了。5.5 多代理協(xié)作時(shí)怎么共享狀態(tài)如果多個(gè)代理同時(shí)在一個(gè)項(xiàng)目上工作決策日志會(huì)互相覆蓋。我的解法是按代理分文件.agent/decisions-agentA.log、.agent/decisions-agentB.log喚醒時(shí)讀所有代理的日志按時(shí)間戳合并排序。但更根本的解法是按模塊分文件.agent/decisions-frontend.log、.agent/decisions-backend.log。每個(gè)代理只負(fù)責(zé)一個(gè)模塊只寫自己模塊的日志??缒K的決策單獨(dú)放.agent/decisions-global.log由人工維護(hù)或由主代理寫入。這樣既避免了沖突又讓每個(gè)代理的喚醒上下文更聚焦。6. 一些踩坑之后的個(gè)人體會(huì)這套機(jī)制我用了大半年最大的體會(huì)是不要試圖讓代理“記住”要讓它“能查”。記憶是不可靠的尤其是經(jīng)過壓縮的記憶。但查詢是可靠的只要狀態(tài)文件寫得好代理每次都能查到準(zhǔn)確的信息。另一個(gè)體會(huì)是決策日志的質(zhì)量比數(shù)量重要得多。我一開始每輪都寫結(jié)果日志里全是“修改了某文件某行”這種廢話真正關(guān)鍵的架構(gòu)決策反而被淹沒了。后來改成只在不可逆決策時(shí)寫日志短了但每條都有分量喚醒效果反而更好。最后分享一個(gè)小技巧在current-task.md里加一行“上次中斷時(shí)的狀態(tài)”比如“正在等編譯結(jié)果”或“剛改完接口還沒跑測(cè)試”。代理喚醒后看到這行能更快接上中斷前的節(jié)奏不用從頭推理“我上次干到哪了”。這行由代理在每輪結(jié)束時(shí)自己更新成本很低但效果立竿見影。