踐)
作為長期折騰聊天機(jī)器人項(xiàng)目的開發(fā)者我一直在苦惱一個(gè)問題每次和AI助手重新開一個(gè)會話它就忘了我上個(gè)月交代過的偏好、項(xiàng)目背景和整理過的結(jié)論。這個(gè)痛點(diǎn)太普遍了所以我動手寫了一個(gè)叫claude-mem的小項(xiàng)目目標(biāo)是給對話程序加上“長期記憶”。它做的事很簡單把歷史對話埋進(jìn)本地存儲在下一次模型調(diào)用前自動把相關(guān)舊內(nèi)容撈出來拼進(jìn)上下文里。這篇文章老規(guī)矩把完整的設(shè)計(jì)思路、核心實(shí)現(xiàn)、踩過的坑和評估方法都攤開來講適合正在給AI應(yīng)用做記憶模塊、或者想了解“會話外記憶”該怎么落地的朋友參考。這個(gè)項(xiàng)目不是某一天突然想出來的而是從實(shí)際需求里長出來的。當(dāng)時(shí)我在維護(hù)一個(gè)內(nèi)部問答機(jī)器人它頻繁被問到“我之前讓你記的那個(gè)配置項(xiàng)是什么”。機(jī)器人每次都不記得只能讓用戶重新說一遍。我試過把整段歷史全塞進(jìn)上下文但模型窗口很快撐爆費(fèi)用也扛不住。反復(fù)折磨之后我確信需要一個(gè)獨(dú)立于會話之外的記憶模塊它負(fù)責(zé)三件事記住、想起來、送回去。claude-mem就是沖著這三件事去的。1. 記憶對AI助手意味著什么場景痛點(diǎn)與設(shè)計(jì)目標(biāo)先別急著看代碼得先說清楚“記憶”到底在解決什么問題。裸模型本身沒有跨會話狀態(tài)每次調(diào)用都是一個(gè)重開。要讓它“記得”本質(zhì)上是把記憶外置到調(diào)用流程里讓模型看到的不僅僅是一輪新問題還包括被篩選過的舊信息。1.1 沒有記憶的對話體驗(yàn)有多糟糕我在早期給某個(gè)群聊機(jī)器人做功能時(shí)用戶反復(fù)問“上次的結(jié)論是什么”。我最初的做法是把最近50輪對話原始文本存下來在每次請求時(shí)拼到系統(tǒng)提示詞后面。效果確實(shí)有一點(diǎn)但問題也很明顯無關(guān)信息太多模型容易抓錯(cuò)重點(diǎn)消息一長接口延遲肉眼可見更重要的是50輪之外的舊事它依然完全不記得。另一個(gè)麻煩是用戶說的“上次”可能已經(jīng)是三天前的事。對話文本里那叫長尾信息靠窮舉式拼接壓根撈不回來。真實(shí)用戶不會把關(guān)鍵信息重復(fù)說第二遍AI助手要是總裝著沒聽過信任感就沒了。1.2 記憶模塊的設(shè)計(jì)目標(biāo)與邊界我把項(xiàng)目目標(biāo)收斂成三條持久化所有值得記住的內(nèi)容都要落盤重啟服務(wù)不丟。語義召回不能只靠關(guān)鍵詞匹配要能根據(jù)當(dāng)前問題的意思找到相關(guān)舊記憶。低侵入接入現(xiàn)有調(diào)用鏈路時(shí)改動要小最好就是包一層調(diào)用。與此同時(shí)我也給自己劃了邊界不做全文對話備份不做知識庫不試圖理解情緒。claude-mem服務(wù)的是“事實(shí)性記憶”——用戶說過什么、結(jié)論是什么、偏好是什么。這類信息適合結(jié)構(gòu)化提取不該讓模型每次去猜。2. 技術(shù)選型的取舍為什么我選的是向量庫而不是數(shù)據(jù)庫entire architecture 的根基是存儲。我一開始很隨意后來發(fā)現(xiàn)存儲方式直接決定了“想起來”的效果上限。2.1 幾種存儲方案的對比方案優(yōu)點(diǎn)缺點(diǎn)適合場景純文本文件簡單、零依賴無法按語義檢索大文件低效臨時(shí)腳本、小規(guī)模調(diào)試SQLite事務(wù)可靠、支持SQL只能做精確匹配或正則結(jié)構(gòu)化管理元數(shù)據(jù)關(guān)系數(shù)據(jù)庫全文索引查詢靈活語義匹配弱中文分詞麻煩混合檢索向量數(shù)據(jù)庫或向量索引語義召回強(qiáng)、支持相似度檢索需要額外資源召回質(zhì)量依賴embedding記憶這種非結(jié)構(gòu)化內(nèi)容我最終選擇了SQLite 向量索引的混合方案。SQLite保存記憶條目本身和元信息時(shí)間、來源、類型向量索引負(fù)責(zé)相似度檢索。為什么不是直接用專門的向量數(shù)據(jù)庫因?yàn)轫?xiàng)目體量小不想引一個(gè)重服務(wù)進(jìn)來SQLite到處都有備份遷移都方便。向量部分我用了一個(gè)輕量級的本地索引庫幾百M(fèi)B數(shù)據(jù)量完全夠用不需要單獨(dú)部署。2.2 一個(gè)關(guān)鍵決策記憶條目不能是“整段對話”最開始我把“每輪問答”存成一條記憶。實(shí)踐下來發(fā)現(xiàn)不行一輪對話里既有事實(shí)信息又有廢話拿來當(dāng)一條記錄去匹配維度太雜。后來改成“先生成摘要再按語義拆分”。具體流程是模型完成一輪回答后由另一個(gè)提示詞把該輪內(nèi)容提煉成幾條獨(dú)立的“記憶片段”每片段盡量只包含一個(gè)事實(shí)或結(jié)論。這一步讓召回準(zhǔn)確率提升非常明顯。數(shù)據(jù)庫里的記錄越干凈后續(xù)檢索越精準(zhǔn)。這也算是我踩過的第一個(gè)坑——粒度不對后面全白費(fèi)。3. 實(shí)現(xiàn)claude-mem的核心模塊代碼層面我分成了四個(gè)模塊提取、存儲、召回、注入。下面我按實(shí)際運(yùn)行順序來講。3.1 記憶提取的提示詞設(shè)計(jì)記憶提取是重活直接決定了存進(jìn)去的東西值不值得留。我給提取環(huán)節(jié)設(shè)計(jì)了一個(gè)專門的prompt模板要求它返回JSON數(shù)組。數(shù)組里每個(gè)元素包含字段content記憶內(nèi)容、importance重要性0-1、category比如偏好、事實(shí)、結(jié)論。下面是提煉出一個(gè)具體的關(guān)鍵提示片段您需要從剛才的對話中提取值得長期記住的事實(shí)性內(nèi)容。 要求 1. 每條只包含一個(gè)事實(shí)或結(jié)論不要混入多件事。 2. 去掉臨時(shí)性信息比如“今天天氣不錯(cuò)”。 3. 如果一句話既包含事實(shí)也包含情緒只保留事實(shí)部分。 4. 輸出JSON數(shù)組格式為[{content: ..., importance: 0.9, category: fact}]這一步我當(dāng)時(shí)重復(fù)調(diào)了好幾版。最初提取出的條目經(jīng)常是“用戶說今天很忙”這種信息沒有任何記憶價(jià)值。解決辦法是加了一條“除非是約定或偏好不要記錄臨時(shí)狀態(tài)”。另外importance字段非常關(guān)鍵后面召回排序要靠它。3.2 存庫與向量化的協(xié)作邏輯提取后的每條記憶先進(jìn)SQLite拿到一個(gè)自增ID然后調(diào)用embedding接口生成向量把向量寫入本地索引庫和ID關(guān)聯(lián)。這里有個(gè)細(xì)節(jié)我在應(yīng)用層做了冪等控制用哈希比對判斷新記憶是否和已有記憶高度相似相似度超過閾值就直接跳過。一開始沒加這個(gè)第二天數(shù)據(jù)庫里全是意思相近的重復(fù)條目召回結(jié)果一團(tuán)糟。存儲表結(jié)構(gòu)也不復(fù)雜核心字段就是id、content、category、importance、created_at、source_session。source_session用來回溯是哪場對話產(chǎn)生的排除錯(cuò)誤記憶時(shí)非常有用。寫入順序也講究先存庫再生成向量。如果向量生成失敗至少SQLite里還有原始內(nèi)容后面可以補(bǔ)算不會丟失數(shù)據(jù)。3.3 語義召回與重排策略召回不是簡單的Top K相似度不然你會發(fā)現(xiàn)結(jié)果老帶著噪聲。我用的是“兩級策略”先取相似度最高的候選集比如20條再根據(jù)三個(gè)指標(biāo)重排。三個(gè)指標(biāo)分別是信息新鮮度創(chuàng)建時(shí)間越近權(quán)重越高但也不是絕對要和時(shí)間衰減函數(shù)配合。重要性importance字段越高的記憶更容易排前面。相似度語義相似度是基礎(chǔ)分。最終分?jǐn)?shù)可以按下面這個(gè)簡單公式算最終分 0.6 * 相似度 0.3 * 重要性 0.1 * 新鮮度這個(gè)比例我試過很多輪最后發(fā)現(xiàn)0.6/0.3/0.1最穩(wěn)。也提醒一句不要迷信這個(gè)數(shù)字不同業(yè)務(wù)場景比例不一樣。比如做客服系統(tǒng)“新用戶偏好”比“三周前的歷史結(jié)論”更該被召回做個(gè)人助手反而相反。建議你把參數(shù)暴露成可配置項(xiàng)方便按場景調(diào)。4. 把記憶送進(jìn)對話上下文接入流程與窗口控制模塊本身單純從庫里找記憶還不夠最難的是如何把它拼進(jìn)一次真實(shí)的模型調(diào)用。4.1 最小侵入的調(diào)用封裝我提供包裝函數(shù)記住原始請求和返回結(jié)果。原流程不變只在發(fā)送給模型之前做三件事讀當(dāng)前用戶輸入。用當(dāng)前輸入構(gòu)造查詢召回10條左右記憶。把記憶拼成一段“歷史背景說明”插入到系統(tǒng)提示詞和用戶消息之間。這里的插入方式有講究。我一開始直接塞在用戶消息前面模型容易被舊記憶帶偏。后來改成單列一個(gè)區(qū)塊明確告訴模型“以下是從長期記憶中提取的背景僅作參考。”這樣模型便不會把記憶當(dāng)成當(dāng)前必須執(zhí)行的命令。“僅作參考”這幾個(gè)字你聽起來輕飄飄的實(shí)際效果巨大。它既提供了上下文又不會讓模型盲目相信記憶畢竟記憶可能是舊的、錯(cuò)誤的。4.2 上下文窗口不夠用怎么辦再優(yōu)化的召回也會遇到窗口壓力。對話長起來后模型窗口就那么點(diǎn)空間。我的解決辦法是只保留“摘要 最近兩輪完整對話”。摘要由模型針對整段歷史生成并定期更新。在摘要中我會額外附上幾條關(guān)鍵召回記憶。窗口分配大致如下系統(tǒng)指令與固定prompt約占10%歷史摘要約占20%本輪召回記憶約占30%最近兩輪完整對話約占25%當(dāng)前用戶消息剩余空間這個(gè)配比不絕對但思路是務(wù)實(shí)的把最可能影響回答的信息放到顯眼位置其余能省則省。真的遇到超長對話我會把“最近兩輪完整對話”進(jìn)一步壓縮成“上一輪的用戶意圖”。提示窗口分配不是寫死的就管用建議先跑50條真實(shí)對話統(tǒng)計(jì)每次實(shí)際用了多少token再回頭調(diào)比例。5. 踩坑實(shí)錄第一版到穩(wěn)定版的血淚史再完美的設(shè)計(jì)圖落地都會遇坑。我挺樂意給claude-mem背這些鍋一是因?yàn)樗鼈兒苡写硇远沁@些坑文檔里一般不寫。5.1 向量維度與性能的平衡最早為了圖省事我選用了一個(gè)返回768維向量的embedding模型。準(zhǔn)確性不差但本地索引庫內(nèi)存和磁盤占用直線上升。后來換成256維的小模型召回質(zhì)量掉了一些整體資源占用掉了三分之二。對個(gè)人項(xiàng)目來說換取的內(nèi)存收益完全值得。做法是先跑一個(gè)小規(guī)模數(shù)據(jù)集同時(shí)測128維、256維、512維、768維的召回率畫個(gè)表格看看準(zhǔn)確性拐點(diǎn)在哪。我自己的測試結(jié)果里256維的準(zhǔn)確率比768維只低2到3個(gè)百分點(diǎn)資源少一半。如果你是給線上服務(wù)用可以考慮512維作為折中。5.2 歷史消息去重與更新這個(gè)坑出現(xiàn)得非常隱蔽。用戶說“把服務(wù)器時(shí)間改成8點(diǎn)”過一會又說“不對改成9點(diǎn)”。如果沒有更新策略兩條都進(jìn)記憶下次召回時(shí)會同時(shí)出現(xiàn)“8點(diǎn)”和“9點(diǎn)”模型就得猜。解決方式分兩步。第一步用高頻詞的embedding相似度做粗粒度碰撞新記憶和舊記憶內(nèi)容相似度超過0.85時(shí)不新增而是更新舊記錄。第二步引入“版本號”字段每次更新版本號加1而查詢時(shí)只返回最高版本。這種模式缺點(diǎn)也明顯如果模型提取時(shí)把兩條事實(shí)拼在一塊去重就撕不開它倆。所以我剛才一直在強(qiáng)調(diào)“每條記憶只保留一個(gè)事實(shí)”這個(gè)約束在去重時(shí)也會反哺你。5.3 異步寫入與數(shù)據(jù)一致性記憶提取要在主調(diào)用之后做不能阻塞用戶拿到響應(yīng)所以必須異步。但異步就帶來問題上一輪的記憶還沒落盤下一輪用戶就開始問了結(jié)果又沒召回。我開了兩層保險(xiǎn)在應(yīng)用啟動時(shí)把待寫入隊(duì)列移到“處理中”狀態(tài)重啟后可以續(xù)寫。在調(diào)用鏈路上加一個(gè)最短入庫等待時(shí)間如果用戶在同一會話內(nèi)連續(xù)提問直接優(yōu)先讀“剛提取但尚未入向量庫”的臨時(shí)緩存兼顧實(shí)時(shí)性和一致性。這種方法不算完美但實(shí)際使用很少出現(xiàn)召回失敗。核心思路是查詢路徑和數(shù)據(jù)寫入路徑分離加上一層臨時(shí)緩存兜底。6. 怎么驗(yàn)證記憶真的有用離線評測與線上觀察搞了個(gè)記憶模塊光說“能用”沒用得有數(shù)據(jù)支撐。我的驗(yàn)證分成兩階段。6.1 搭建離線召回測試集我準(zhǔn)備了一組模擬問題提前標(biāo)注好每個(gè)問題應(yīng)該匹配哪些記憶。很簡單像這樣test_cases [ { question: 上次說服務(wù)器遷移到哪個(gè)云平臺了, expected_memory: 用戶決定將服務(wù)器遷移到某云平臺原因是現(xiàn)有主機(jī)費(fèi)用過高, session_time: 2025-03-01 }, ... ]然后寫腳本自動注入記憶、調(diào)用召回、檢查top5是否包含expected_memory。這個(gè)環(huán)節(jié)主要調(diào)參閾值、重要度權(quán)重、新鮮度權(quán)重。我反復(fù)調(diào)整時(shí)把召回率從68%拉到了82%。離線測試有個(gè)陷阱它可能只是記住了測試集而不是真的泛化。所以還得去真實(shí)流量里觀察。6.2 線上觀察的三個(gè)指標(biāo)線上我沒法自動打標(biāo)就用幾個(gè)間接指標(biāo)來盯“問舊事”的復(fù)述率用戶再次提到上次內(nèi)容時(shí)不說全而說“老規(guī)矩”的比例。召回不到時(shí)的追問次數(shù)看機(jī)器人說“你之前說的我不太確定”的頻率。上下文命中率在調(diào)用日志中標(biāo)記哪些請求拼上了記憶再人工抽樣看回答是否用上了。我從后臺看了一個(gè)星期的日志發(fā)現(xiàn)“用戶重復(fù)解釋”的頻次確實(shí)下降了。比較典型的一幕是有人問我“內(nèi)存為何還得降”我直接回引用了他三天前說過的預(yù)算上限對方只回了一句“對就按這個(gè)來”。這說明記憶真的在起作用。7. claude-mem后續(xù)還能怎么玩說實(shí)話寫完第一版階后我最大的體會是記憶模塊的價(jià)值一半靠實(shí)現(xiàn)一半靠“召回策略”。你存得再好撈偏了一樣白搭。后面我的計(jì)劃是給記憶加一個(gè)“過期機(jī)制”比如某些臨時(shí)約定在兩周后自動降權(quán)另一個(gè)方向是讓用戶可以顯式標(biāo)記“這條必須永遠(yuǎn)記住”優(yōu)先級直接拉滿。如果你也在做類似的東西我的建議是先從最小閉環(huán)開始先手工提取、手工召回跑通流程再自動化。別急著上復(fù)雜架構(gòu)claude-mem這名字聽起來大但它就是從幾行SQLite代碼長出來的。最后說個(gè)實(shí)戰(zhàn)小技巧如果你在某個(gè)對話里發(fā)現(xiàn)某條記憶明確被用上了給它加一筆“命中次數(shù)”。這個(gè)數(shù)據(jù)能反過來幫你判斷哪些記憶類別真正有用哪些只是站位置的噪音。等模型越來越會用記憶之后再把那些一直沒被命中的記憶交給模型讓它自己決定要不要?jiǎng)h這就是“記憶整理”的方向了。