計(jì):從抽取到檢索的工程實(shí)踐)
1. 從claude-mem這個(gè)名字說起它到底想解決什么第一次看到claude-mem這個(gè)命名我的直覺是這是一個(gè)圍繞 Claude 生態(tài)做記憶層的項(xiàng)目。名字拆開看很直白——claude指向模型側(cè)mem是 memory 的縮寫。合在一起它要處理的核心命題就一句話讓模型在多輪、跨會(huì)話的交互中記住該記住的東西忘掉該忘掉的東西。這件事聽起來簡單做起來極其麻煩。我接觸過不少做對(duì)話系統(tǒng)的團(tuán)隊(duì)大家一開始都覺得記憶不就是把歷史消息拼進(jìn)上下文嗎真上手才發(fā)現(xiàn)上下文窗口是有限的token 是要花錢的歷史越長噪聲越大模型反而越容易跑偏。更別提跨會(huì)話場景——今天用戶說了一句關(guān)鍵偏好明天開新會(huì)話模型完全不記得體驗(yàn)直接斷裂。claude-mem這類項(xiàng)目瞄準(zhǔn)的就是這個(gè)斷層。它本質(zhì)上是一套記憶管理中間層在模型和用戶之間架一層負(fù)責(zé)把對(duì)話中產(chǎn)生的信息抽取、壓縮、存儲(chǔ)、檢索再在合適的時(shí)機(jī)把最相關(guān)的片段喂回給模型。它解決的不是模型聰不聰明的問題而是模型記不記得住的問題。適合誰來讀這篇內(nèi)容三類人。第一類是想給自己的 AI 應(yīng)用加記憶能力的開發(fā)者你需要知道記憶層怎么設(shè)計(jì)、坑在哪第二類是在做 Agent、客服機(jī)器人、個(gè)人助理這類長周期交互產(chǎn)品的同學(xué)記憶直接決定產(chǎn)品能不能用第三類是對(duì) RAG、上下文工程感興趣的技術(shù)愛好者記憶管理其實(shí)是 RAG 思路在對(duì)話歷史這個(gè)特殊語料上的變體。我下面會(huì)按為什么需要獨(dú)立記憶層 → 記憶怎么存 → 怎么取 → 怎么防止它變味 → 實(shí)測中的坑這條線展開。所有涉及具體實(shí)現(xiàn)的地方我會(huì)說明這是基于常見工程實(shí)踐的合理方案因?yàn)樵柬?xiàng)目正文是空的我補(bǔ)的是一個(gè)合格從業(yè)者在這個(gè)場景下最可能怎么做。2. 為什么不能只靠把歷史全塞進(jìn)上下文2.1 上下文窗口不是免費(fèi)的午餐很多人對(duì)上下文窗口有個(gè)誤解覺得窗口越大越好那就把所有歷史都塞進(jìn)去唄。我實(shí)測過這條路走不通原因有三層。第一層是成本。上下文是按 token 計(jì)費(fèi)的而且是雙向的——你塞進(jìn)去的每一輪歷史在后續(xù)每一次請(qǐng)求里都要重新計(jì)費(fèi)。一個(gè)跑了 50 輪的會(huì)話如果每輪都帶全量歷史token 消耗是平方級(jí)增長的。我見過一個(gè)客服場景單次會(huì)話成本從幾分錢漲到幾塊錢就是因?yàn)闅v史沒做裁剪。第二層是注意力稀釋。模型對(duì)上下文的注意力不是均勻分布的中間部分容易被忽略這就是常說的lost in the middle現(xiàn)象。你塞 100 輪歷史進(jìn)去真正關(guān)鍵的那句我對(duì)花生過敏可能被淹沒在中間模型該犯錯(cuò)還是犯錯(cuò)。信息多不等于信息有效。第三層是噪聲污染。歷史里大量是寒暄、確認(rèn)、重復(fù)表達(dá)這些對(duì)當(dāng)前任務(wù)毫無價(jià)值卻占著寶貴的窗口位置。把噪聲和信號(hào)一起喂進(jìn)去等于讓模型做一道從垃圾里挑金子的題正確率自然下降。2.2 記憶層要做的三件事所以claude-mem這類項(xiàng)目的價(jià)值就體現(xiàn)在它替模型做了三件模型自己不方便做的事抽取從原始對(duì)話流里識(shí)別出值得記的信息。不是每句話都值得記今天天氣不錯(cuò)不值得我下周要去上海出差值得。壓縮把抽取出來的信息用更緊湊的形式表達(dá)。原始對(duì)話可能是五句話壓縮后是一條結(jié)構(gòu)化記錄。檢索在需要的時(shí)候把最相關(guān)的記憶片段找出來而不是把整個(gè)記憶庫倒給模型。這三件事對(duì)應(yīng)三個(gè)技術(shù)模塊信息抽取、記憶存儲(chǔ)、相關(guān)性檢索。下面逐個(gè)拆。2.3 一個(gè)容易踩的認(rèn)知坑我特別想提醒一點(diǎn)記憶不等于歷史。很多團(tuán)隊(duì)做記憶層做著做著就退化成了歷史消息的數(shù)據(jù)庫只是把消息存起來再取出來這跟直接拼上下文沒本質(zhì)區(qū)別只是換了個(gè)存儲(chǔ)位置。真正的記憶層輸出給模型的東西應(yīng)該和原始?xì)v史長得不一樣。它應(yīng)該是提煉過的、結(jié)構(gòu)化的、帶時(shí)間戳和置信度的。如果你做出來的記憶層取出來的內(nèi)容和原始對(duì)話一模一樣那這個(gè)層就是白做的。判斷標(biāo)準(zhǔn)很簡單記憶層取出的內(nèi)容token 數(shù)應(yīng)該顯著少于原始?xì)v史但關(guān)鍵信息保留率要高。3. 記憶怎么存從流水賬到結(jié)構(gòu)化檔案3.1 存儲(chǔ)粒度的選擇記憶存什么粒度是個(gè)關(guān)鍵決策。我見過三種主流做法各有取舍。按輪次存每一輪對(duì)話存一條記錄。優(yōu)點(diǎn)是實(shí)現(xiàn)簡單缺點(diǎn)是粒度太細(xì)檢索時(shí)容易召回一堆無關(guān)的輪次而且單輪信息往往不完整。按會(huì)話存整個(gè)會(huì)話壓縮成一條摘要。優(yōu)點(diǎn)是緊湊缺點(diǎn)是粒度太粗一個(gè)長會(huì)話里可能包含多個(gè)主題壓成一條會(huì)丟失細(xì)節(jié)。按記憶單元存這是我認(rèn)為最合理的做法。把對(duì)話中識(shí)別出的獨(dú)立事實(shí)、偏好、事件各自存成一條記憶單元。比如用戶偏好簡潔回復(fù)是一條用戶下周三有會(huì)議是另一條。粒度適中檢索精準(zhǔn)。claude-mem這類項(xiàng)目我推測它采用的是第三種因?yàn)橹挥羞@種粒度才能支撐起精準(zhǔn)檢索這個(gè)核心價(jià)值。下面我按記憶單元的思路展開。3.2 一條記憶單元應(yīng)該長什么樣我實(shí)際設(shè)計(jì)過記憶單元的結(jié)構(gòu)分享一個(gè)我認(rèn)為比較實(shí)用的字段設(shè)計(jì)字段類型說明idstring唯一標(biāo)識(shí)建議用內(nèi)容哈希天然去重contentstring記憶正文壓縮后的自然語言typeenum事實(shí)/偏好/事件/指令分類影響檢索權(quán)重embeddingvector向量表示用于語義檢索keywordslist關(guān)鍵詞用于混合檢索created_attimestamp創(chuàng)建時(shí)間用于時(shí)效性衰減last_accessedtimestamp最近訪問時(shí)間用于熱度排序confidencefloat置信度抽取不確定時(shí)降低權(quán)重sourcestring來源會(huì)話標(biāo)識(shí)便于溯源這個(gè)結(jié)構(gòu)里type和confidence是兩個(gè)容易被忽略但很重要的字段。type讓檢索時(shí)可以按類型加權(quán)——比如當(dāng)前是任務(wù)執(zhí)行場景事件和指令類記憶權(quán)重調(diào)高當(dāng)前是閑聊偏好類權(quán)重調(diào)高。confidence則處理模型不確定這句話是不是重要信息的情況抽取時(shí)給個(gè)低置信度檢索時(shí)自然降權(quán)避免誤記污染。3.3 抽取環(huán)節(jié)怎么判斷值得記抽取是記憶層最考驗(yàn)功力的地方。我的經(jīng)驗(yàn)是不要指望一次 LLM 調(diào)用就能完美抽取要用規(guī)則 模型的組合。規(guī)則層負(fù)責(zé)快速過濾明顯不值得記的內(nèi)容純寒暄、純確認(rèn)好的收到、純情緒表達(dá)。這些用關(guān)鍵詞和長度規(guī)則就能篩掉一大半省下模型調(diào)用成本。模型層負(fù)責(zé)從剩下的內(nèi)容里抽取結(jié)構(gòu)化記憶。這里 prompt 設(shè)計(jì)很關(guān)鍵。我常用的抽取 prompt 結(jié)構(gòu)是這樣的從以下對(duì)話片段中抽取值得長期記憶的信息。 只抽取以下類型 1. 用戶的穩(wěn)定偏好如喜歡簡潔回復(fù) 2. 用戶的事實(shí)信息如住在杭州 3. 未來的事件安排如下周三開會(huì) 4. 明確的指令要求如以后都用中文回復(fù) 不要抽取寒暄、確認(rèn)、情緒表達(dá)、臨時(shí)性內(nèi)容。 輸出 JSON 數(shù)組每條包含 content、type、confidence。 如果沒有任何值得記的內(nèi)容輸出空數(shù)組。這個(gè) prompt 的關(guān)鍵在于給了明確的不要抽取清單。只告訴模型抽什么它容易過度抽取加上不抽什么準(zhǔn)確率明顯提升。這是我踩過坑之后總結(jié)的——早期版本只寫正向指令結(jié)果模型把用戶說了句謝謝都記下來了。3.4 壓縮環(huán)節(jié)怎么讓記憶更緊湊抽取出來的記憶單元往往還是偏長。比如原始對(duì)話是我最近在做一個(gè)數(shù)據(jù)分析的項(xiàng)目用的是 Python主要處理一些銷售數(shù)據(jù)數(shù)據(jù)量大概幾百萬行抽取出來可能還是這么長。壓縮的目標(biāo)是變成用戶在做 Python 數(shù)據(jù)分析項(xiàng)目處理百萬級(jí)銷售數(shù)據(jù)。壓縮我一般用兩種手段結(jié)合。一是模型改寫讓模型用最簡潔的語言重述明確要求不超過 30 字。二是去重合并如果新記憶和已有記憶語義高度相似就合并而不是新增。合并時(shí)保留信息更全的那條更新時(shí)間戳。去重這一步特別重要。我見過沒做去重的記憶庫用戶提了三次我喜歡喝咖啡庫里就有三條幾乎一樣的記錄檢索時(shí)全被召回白白占窗口。去重的閾值我一般設(shè)在余弦相似度 0.9 左右太高會(huì)漏合并太低會(huì)誤合并。4. 記憶怎么取檢索策略決定體驗(yàn)上限4.1 純向量檢索的局限很多人一提檢索就想到向量相似度覺得 embedding 一算top-k 一取就完事。我實(shí)測下來純向量檢索在記憶場景有幾個(gè)明顯問題。問題一時(shí)效性無法體現(xiàn)。用戶三個(gè)月前說我在學(xué)吉他和昨天說我在學(xué)吉他向量相似度幾乎一樣但后者顯然更該被召回。純向量檢索對(duì)時(shí)間不敏感。問題二類型無法區(qū)分。當(dāng)前是任務(wù)場景應(yīng)該優(yōu)先召回指令類記憶但向量檢索不管類型可能召回一堆偏好類記憶。問題三精確匹配失效。用戶問我上次說的那個(gè)項(xiàng)目叫什么這需要精確匹配項(xiàng)目名向量檢索對(duì)專有名詞的匹配反而不如關(guān)鍵詞檢索。4.2 混合檢索向量 關(guān)鍵詞 規(guī)則我的方案是混合檢索三路并行再融合排序。第一路是向量檢索負(fù)責(zé)語義相關(guān)性。用記憶單元的 embedding 和當(dāng)前 query 的 embedding 算相似度取 top-N。第二路是關(guān)鍵詞檢索負(fù)責(zé)精確匹配。用 BM25 或簡單的關(guān)鍵詞倒排匹配專有名詞、數(shù)字、日期這類向量不擅長的內(nèi)容。第三路是規(guī)則加權(quán)負(fù)責(zé)業(yè)務(wù)邏輯。根據(jù)記憶的 type、created_at、last_accessed、confidence 做加權(quán)。比如事件類記憶按時(shí)間衰減越近權(quán)重越高指令類記憶不衰減長期有效。三路結(jié)果用加權(quán)融合比如 RRF 倒數(shù)排名融合合并再取最終 top-k。這套組合拳下來召回質(zhì)量比純向量高一個(gè)檔次。我做過對(duì)比測試在記憶檢索場景混合檢索的命中率比純向量檢索高出約 20 到 30 個(gè)百分點(diǎn)。4.3 檢索結(jié)果怎么喂回模型檢索出記憶后怎么組織進(jìn) prompt 也有講究。我一般用這樣的結(jié)構(gòu)以下是關(guān)于當(dāng)前用戶的歷史記憶供你參考 [偏好] 用戶喜歡簡潔回復(fù) [事實(shí)] 用戶在做 Python 數(shù)據(jù)分析項(xiàng)目 [事件] 用戶下周三有會(huì)議記錄于 3 天前 請(qǐng)結(jié)合以上記憶回答用戶問題。如果記憶與當(dāng)前問題無關(guān)忽略即可。這里有兩個(gè)細(xì)節(jié)。一是給每條記憶打上類型標(biāo)簽讓模型知道這條記憶的性質(zhì)便于它判斷如何使用。二是明確告訴模型無關(guān)可忽略否則模型容易強(qiáng)行把不相關(guān)的記憶往回答里塞反而答非所問。還有一個(gè)我踩過的坑記憶不要放在 system prompt 里。早期我把記憶塞進(jìn) system prompt結(jié)果發(fā)現(xiàn)模型對(duì) system 里的內(nèi)容有一種必須遵守的傾向?qū)е滤堰^時(shí)的記憶也當(dāng)鐵律執(zhí)行。后來改成放在 user 消息前部作為參考信息而非指令模型的使用就自然多了。4.4 檢索數(shù)量不是越多越好top-k 取多少我的經(jīng)驗(yàn)是 3 到 5 條。取太少可能漏關(guān)鍵信息取太多會(huì)引入噪聲。而且要注意記憶檢索的 k 值和普通 RAG 不一樣——RAG 的文檔塊信息密度高多取幾條問題不大記憶單元往往很短取太多條會(huì)顯得零碎反而干擾模型。如果確實(shí)需要更多記憶我建議先做二次壓縮把召回的 10 條記憶再讓模型壓成一段連貫的摘要再喂給模型。這樣既保留了信息又控制了長度。5. 記憶的保鮮衰減、更新與遺忘5.1 為什么記憶需要遺忘一個(gè)不會(huì)遺忘的記憶系統(tǒng)最終會(huì)變成垃圾場。用戶三個(gè)月前隨口說的一句話和昨天明確表達(dá)的偏好如果權(quán)重一樣檢索時(shí)就會(huì)互相干擾。遺忘不是缺陷是特性。我設(shè)計(jì)的遺忘機(jī)制分兩種時(shí)間衰減和主動(dòng)清理。時(shí)間衰減針對(duì)事件類記憶。每條事件記憶有個(gè)有效期概念過了有效期權(quán)重快速下降。比如下周三開會(huì)過了下周三這條記憶基本就沒用了權(quán)重降到接近零。實(shí)現(xiàn)上可以用指數(shù)衰減函數(shù)weight base * exp(-λ * days_since_created)λ 根據(jù)記憶類型調(diào)整事件類 λ 大衰減快偏好類 λ 接近零幾乎不衰減。主動(dòng)清理針對(duì)低質(zhì)量記憶。定期掃描記憶庫把 confidence 低、長期未被訪問、且內(nèi)容與其他記憶重復(fù)的記錄刪掉。清理頻率我一般設(shè)成每周一次太頻繁浪費(fèi)資源太稀疏垃圾積累太多。5.2 記憶沖突怎么處理記憶沖突是個(gè)繞不開的問題。用戶上周說我喜歡喝咖啡這周說我戒咖啡了兩條記憶直接矛盾。怎么處理我的做法是新記憶覆蓋舊記憶但保留舊記憶的痕跡。具體來說檢測到?jīng)_突時(shí)把舊記憶標(biāo)記為已失效新記憶正常入庫。檢索時(shí)失效記憶默認(rèn)不召回但如果用戶明確問我以前是不是說過喜歡咖啡可以臨時(shí)召回失效記憶來回答。沖突檢測用向量相似度加類型判斷。兩條同類型記憶語義相似度高但內(nèi)容矛盾可以用模型判斷是否矛盾就觸發(fā)沖突處理。這一步會(huì)增加一些模型調(diào)用成本但值得因?yàn)槊苡洃浳菇o模型模型會(huì)精神分裂。5.3 記憶的更新而非新增很多場景下新信息不是新增記憶而是更新已有記憶。比如用戶之前說我在學(xué)吉他后來說我吉他學(xué)完了開始學(xué)鋼琴。這不是兩條獨(dú)立記憶而是一條記憶的狀態(tài)更新。我的處理方式是抽取新記憶時(shí)先檢索是否有相關(guān)舊記憶如果有讓模型判斷是新增還是更新。更新時(shí)保留原記憶 id更新 content 和 timestamp。這樣記憶庫不會(huì)因?yàn)橥恢黝}的多次提及而膨脹。這個(gè)判斷邏輯我寫在抽取 prompt 里讓模型在輸出時(shí)帶上action: create/update和target_id如果是 update。實(shí)測下來模型對(duì)這個(gè)判斷的準(zhǔn)確率還不錯(cuò)偶爾出錯(cuò)也能靠后續(xù)的去重兜底。6. 實(shí)測中的坑那些文檔不會(huì)告訴你的細(xì)節(jié)6.1 embedding 模型的選擇陷阱記憶檢索的 embedding 模型不要盲目用最大的那個(gè)。我踩過的坑是用了一個(gè)維度很高、效果很好的通用 embedding 模型結(jié)果發(fā)現(xiàn)它對(duì)短文本的區(qū)分度反而不如小模型。記憶單元往往就十幾個(gè)字大模型容易把語義相近的短文本壓到幾乎相同的向量上導(dǎo)致檢索區(qū)分不開。我的建議是記憶場景優(yōu)先選針對(duì)短文本優(yōu)化的 embedding 模型或者至少做一次實(shí)測對(duì)比。測試方法很簡單準(zhǔn)備 50 條記憶和 20 個(gè) query看不同模型的 top-3 命中率。別嫌麻煩這一步省不得。6.2 抽取的過度記憶傾向模型有過度抽取的傾向尤其是能力強(qiáng)的模型它總覺得這句話可能有用。我早期版本沒做限制結(jié)果記憶庫里塞滿了用戶說了你好用戶表示同意這種垃圾。解決辦法除了前面說的不要抽取清單還有一個(gè)技巧給抽取結(jié)果加長度下限。比如 content 少于 8 個(gè)字的記憶直接丟棄。這條簡單規(guī)則能過濾掉大量無意義記憶。另外定期人工抽查記憶庫看看抽出來的都是什么根據(jù)實(shí)際情況調(diào)整 prompt這個(gè)習(xí)慣我保持了很長時(shí)間收益很大。6.3 檢索的最近偏好偏差有個(gè)隱蔽的坑如果檢索時(shí)過度加權(quán)最近訪問或最近創(chuàng)建會(huì)導(dǎo)致模型只記得最近的事早期的重要記憶永遠(yuǎn)召不回。我遇到過用戶抱怨它忘了我最開始說的核心需求一查發(fā)現(xiàn)是最近性權(quán)重給太高了。修正方法是分類型設(shè)置最近性權(quán)重。偏好類和指令類記憶最近性權(quán)重應(yīng)該低因?yàn)樗鼈冮L期有效事件類記憶最近性權(quán)重可以高。別用一個(gè)統(tǒng)一的衰減函數(shù)套所有類型。6.4 并發(fā)寫入的記憶重復(fù)如果應(yīng)用是多用戶并發(fā)的記憶寫入要做冪等。我遇到過同一句話因?yàn)橹卦嚈C(jī)制被寫入兩次的情況導(dǎo)致記憶庫出現(xiàn)重復(fù)。解決辦法是用 content 哈希做唯一鍵寫入前先查重存在就跳過或更新。這個(gè)細(xì)節(jié)在單機(jī)測試時(shí)發(fā)現(xiàn)不了一上并發(fā)就暴露。6.5 記憶的隱私邊界最后說個(gè)非技術(shù)但極其重要的點(diǎn)記憶層是隱私敏感區(qū)。用戶的偏好、事實(shí)、事件很多是私人信息。設(shè)計(jì)記憶層時(shí)刪除能力必須是一等公民——用戶要能查看自己的記憶、刪除單條記憶、清空全部記憶。而且刪除要徹底不能只是標(biāo)記失效向量庫、關(guān)鍵詞索引、緩存都要同步清理。這一點(diǎn)在項(xiàng)目設(shè)計(jì)初期就要考慮后期補(bǔ)很痛苦。7. 如果要自己動(dòng)手一個(gè)最小可行實(shí)現(xiàn)路徑如果你看完想自己搭一個(gè)類似claude-mem的記憶層我給一條最小可行路徑避免一上來就過度設(shè)計(jì)。第一步先做存儲(chǔ)和檢索不做抽取。手動(dòng)往記憶庫里塞幾條記憶把向量檢索跑通驗(yàn)證檢索質(zhì)量。這一步能幫你快速確認(rèn) embedding 模型選得對(duì)不對(duì)。第二步加抽取但先用規(guī)則。用關(guān)鍵詞規(guī)則抽取明顯值得記的內(nèi)容比如包含我喜歡我要記住的句子先跑起來觀察效果。規(guī)則抽取準(zhǔn)確率不高但可控適合驗(yàn)證流程。第三步把規(guī)則換成模型抽取。用前面給的 prompt 結(jié)構(gòu)接上模型做抽取和規(guī)則抽取的結(jié)果對(duì)比逐步替換。第四步加混合檢索和衰減。向量檢索跑順了再加關(guān)鍵詞檢索和規(guī)則加權(quán)加時(shí)間衰減。每加一個(gè)模塊都做 A/B 對(duì)比確認(rèn)有正向收益再加下一個(gè)。第五步加遺忘和沖突處理。這是最后一步也是最復(fù)雜的一步。前面都穩(wěn)了再動(dòng)這里。這條路徑的核心思想是每一步都可獨(dú)立驗(yàn)證不要一次性把所有模塊堆上去。我見過太多項(xiàng)目一上來就設(shè)計(jì)一個(gè)龐大的記憶架構(gòu)結(jié)果每個(gè)模塊都沒調(diào)好整體效果一塌糊涂。記憶層是個(gè)精細(xì)活慢就是快。關(guān)于技術(shù)選型向量庫用輕量的就夠記憶量級(jí)通常不大幾萬到幾十萬條很多嵌入式向量庫完全能扛。別一上來就上分布式向量數(shù)據(jù)庫運(yùn)維成本遠(yuǎn)大于收益。存儲(chǔ)用關(guān)系庫加向量擴(kuò)展也行簡單直接。我在實(shí)際做記憶層的過程中最大的體會(huì)是記憶系統(tǒng)的質(zhì)量八成取決于抽取和檢索這兩個(gè)環(huán)節(jié)的調(diào)優(yōu)而不是架構(gòu)有多花哨。把抽取 prompt 打磨好把檢索的加權(quán)邏輯調(diào)對(duì)比換任何 fancy 的框架都管用。這個(gè)領(lǐng)域沒有銀彈就是一遍遍實(shí)測、對(duì)比、調(diào)整。