設(shè)計與工程實踐)
我最近一直在折騰 claude-mem一個給 Claude 對話加長期記憶的小工具。如果你用過 Claude 的 API一定有過這種感覺單次對話里它聰明得像博士關(guān)掉窗口再打開就是金魚。claude-mem 要解決的就是這件事——把散落在多個會話里的關(guān)鍵信息自動攢起來下次開場時問一句“上次我們聊到哪了”它能立刻接上話。這篇文章我就把自己從零搭到實際使用的完整過程、踩過的坑和一些設(shè)計取舍攤開講適合正在給 AI 應(yīng)用做記憶層的開發(fā)者也適合只是好奇想抄作業(yè)的朋友。1. 整體設(shè)計為什么 Claude 需要一塊“外掛記憶”Claude 本身是有上下文窗口的在對話里它能記住前面說的內(nèi)容但這個記憶有兩個硬傷一是會話級一旦會話結(jié)束、或者在 API 場景下每次獨立請求狀態(tài)就丟了二是長度有限幾十頁資料塞進去前面的內(nèi)容就會被擠掉。做過聊天機器人的朋友都知道這種“金魚式記憶”在真實業(yè)務(wù)里非常難受。用戶昨天說過的偏好、項目的技術(shù)選型、幾天前討論過的 bug 原因第二天再問模型完全不記得。claude-mem 的思路不是去改模型的記憶能力而是給它在外面加一層持久化存儲讓“記得住”這件事不再依賴模型本身。1.1 記憶層到底放在哪里這里需要分清楚三個容易混淆的概念短期記憶、長期記憶和語義記憶。短期記憶就是我們每次請求里攜帶的對話歷史放在 prompt 里隨請求發(fā)送長期記憶是跨會話保存下來的事實、用戶偏好、決策記錄通常存進數(shù)據(jù)庫語義記憶則是對已有信息的理解和關(guān)聯(lián)比如“用戶提到過喜歡簡潔的回答風(fēng)格所以后續(xù)回復(fù)要控制篇幅”。claude-mem 主要做后兩層。它的核心結(jié)構(gòu)非常簡單每次對話結(jié)束后把這段對話的關(guān)鍵信息抽出來變成一條條結(jié)構(gòu)化的記憶記錄放進 SQLite下一次新會話開始時根據(jù)用戶當前問題把最相關(guān)的舊記憶撈出來拼接成系統(tǒng)提示詞和對話歷史一起發(fā)給 Claude。整個過程對上層業(yè)務(wù)透明模型拿到的仍然是正常的 prompt只是提示詞里多了一段“你之前了解過的背景”。1.2 方案選型為什么不是把全部歷史直接塞回去最樸素的做法是把所有歷史對話原封不動存下來下次提問時全部拼進 prompt。我在第一個版本就這么干過結(jié)果非常慘。一是 token 消耗巨大聊一天的內(nèi)容可能幾萬字全塞進去既貴又容易觸發(fā)窗口上限二是無效信息太多用戶只是問一句“我們上周說的部署方案是哪套”模型卻被幾千條閑聊淹沒回答質(zhì)量反而下降。所以 claude-mem 選擇了“摘要 檢索”的組合平時不存原始對話全文而是在每次輪次結(jié)束后生成一個濃縮的結(jié)構(gòu)化摘要到了使用階段先基于當前問題做相關(guān)性檢索只取最相關(guān)的若干條記憶注入。這樣既控制住了 token 數(shù)量又保證了信息的精準度代價是多了一次檢索的延遲但實際體驗下來基本可以忽略。1.3 三類數(shù)據(jù)對應(yīng)三種處理方式在設(shè)計存儲時我把數(shù)據(jù)分成了三類分別用不同策略處理。第一類是用戶偏好和基本事實比如“用戶在某互聯(lián)網(wǎng)公司做后端”“喜歡 Python 多于 Java”這類信息會常駐在系統(tǒng)提示里每次請求都帶上第二類是具體項目的階段性結(jié)論比如“訂單服務(wù)的超時時間最后定成 3 秒”“數(shù)據(jù)庫遷移用 Flyway”這類信息按時間衰減只在相關(guān)話題出現(xiàn)時檢索出來第三類是原始對話的審計日志完整保留但默認不注入只有在調(diào)試或用戶明確要求時才使用。這個分類讓 claude-mem 不會像無頭蒼蠅一樣什么都往 prompt 里塞也為后面的體積控制打下基礎(chǔ)。數(shù)據(jù)類型示例處理策略注入時機用戶偏好與事實偏好簡潔回答、常用語言長期固定每次請求項目結(jié)論超時時間 3 秒檢索注入相關(guān)話題出現(xiàn)時原始對話日志完整多輪對話存檔不注入調(diào)試或明確需求時我建議讀者在做類似記憶系統(tǒng)時先想清楚這三類數(shù)據(jù)分別落在哪里。很多人一開始把所有東西塞成一團后面檢索范圍、清理策略都很難做。2. 核心細節(jié)記憶存什么、怎么存、怎么用2.1 記憶表結(jié)構(gòu)設(shè)計claude-mem 的存儲層我用的是 SQLite沒上專門的向量數(shù)據(jù)庫原因很簡單個人工具和中小型應(yīng)用的數(shù)據(jù)量根本到不了需要 Milvus 或 Qdrant 的程度一個帶頭向量擴展的 SQLite 足夠壓住讀寫。表結(jié)構(gòu)上我設(shè)計了四張表conversations 記錄會話基本信息messages 按時間線保存每一輪的用戶輸入和助手輸出memory_items 保存抽取出來的結(jié)構(gòu)化記憶memory_tags 給記憶打標簽。這里最關(guān)鍵的是 memory_items它的字段包括 id、conversation_id、content、category、importance、source_message_id、created_at、last_accessed_at 和 embedding。importance 是一個 1 到 5 的整數(shù)由 Claude 在生成記憶時順便給出用于控制檢索權(quán)重last_accessed_at 則用于定期清理長期不用的冷記憶。具體建表語句如下實測用 Python 的 sqlite3 標準庫就能跑不需要額外 ORMCREATE TABLE conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, started_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER REFERENCES conversations(id), role TEXT CHECK(role IN (user, assistant)), content TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE memory_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER REFERENCES conversations(id), content TEXT NOT NULL, category TEXT, importance INTEGER DEFAULT 3, source_message_id INTEGER REFERENCES messages(id), created_at TEXT DEFAULT CURRENT_TIMESTAMP, last_accessed_at TEXT, embedding BLOB ); CREATE TABLE memory_tags ( memory_id INTEGER REFERENCES memory_items(id), tag TEXT );這個結(jié)構(gòu)是核心中的核心。剛開始我圖省事把記憶直接存在 JSON 文件里結(jié)果一旦上了多會話并發(fā)讀寫各種覆蓋和臟數(shù)據(jù)立刻出現(xiàn)。換成 SQLite 之后事務(wù)和索引都省心了而且數(shù)據(jù)存在單個文件里備份起來也方便。2.2 記憶抽取讓 Claude 自己整理自己的記憶很多記憶系統(tǒng)靠正則或關(guān)鍵詞提取關(guān)鍵信息效果一言難盡。claude-mem 直接利用 Claude 自身的語言理解能力每當一段對話結(jié)束我會把完整的對話內(nèi)容交給模型讓它按指定 JSON 格式輸出該留下的記憶條目。這段提示詞我調(diào)了很多次現(xiàn)在的版本長這樣你是 claude-mem 的記憶抽取器。閱讀下面的對話抽取值得長期保留的信息。要求 1. 只抽取明確陳述的事實、偏好、決策和待辦不要主觀推測。 2. 每條記憶控制在 40 字以內(nèi)動詞明確。 3. 無關(guān)的寒暄、重復(fù)內(nèi)容不要抽取。 4. 輸出 JSON 數(shù)組每項包含 text、category、importance。 category 取 user_fact、project_decision、task_todo、other 之一。 importance 為 1-5 整數(shù)5 表示下次對話必須知道。這里有個很重要的經(jīng)驗不要直接用聊天提示詞讓模型“記一下”而是給它一個獨立的、輸出格式嚴格的任務(wù)。我在前期經(jīng)常遇到模型把對話里的廢話也存成記憶或者把推理過程寫成長篇大論原因就是任務(wù)邊界不清晰。改成獨立 prompt 后抽取的準確率明顯提升而且 JSON 解析穩(wěn)定了很多。2.3 檢索與注入怎么在合適的時機想起合適的事記憶存進去只是開始真正決定體驗的是怎么把它取出來。claude-mem 采用兩階段策略先按關(guān)鍵詞和標簽做粗篩把候選集限定在幾百條以內(nèi)再在候選集里計算 embedding 相似度取 top_k 條。為什么不用純向量因為裸向量檢索在數(shù)據(jù)量小的時候反而容易找偏比如用戶問“上次說的超時時間”如果只靠語義相似度可能把“超時”相關(guān)的都拉出來但結(jié)合 SQL 里 LIKE 匹配“超時時間”這個標簽候選質(zhì)量會高很多。候選集縮小后再算相似度性能和精度都有保障。注入時機上我會把檢索到的記憶放在 system prompt 的固定位置并且顯式標記“以下是舊記憶如果與用戶當前信息沖突以當前信息為準”。這樣做的好處是避免記憶和當前對話發(fā)生沖突時模型被老信息帶偏。實際測試中這個標記能顯著降低“幻覺式引用”——模型一本正經(jīng)地引用了一個以前的、其實已經(jīng)被否定的方案。2.4 token 預(yù)算控制每次注入多少記憶我用三個參數(shù)控制max_recent_chars 控制在原始對話歷史中最多攜帶多少字符max_memory_chars 控制檢索到的舊記憶最多占多少字符max_total_chars 作為兜底上限。后面兩個參數(shù)是配合動態(tài)調(diào)整的。在一個長會話中如果最近幾輪已經(jīng)提到某個話題我會減少舊記憶的配額避免重復(fù)信息占用空間。這套規(guī)則寫成一個簡單的預(yù)算計算函數(shù)在構(gòu)造請求前調(diào)用比每次手工調(diào) prompt 要穩(wěn)定得多。max_memory_chars min(2000, max_total_chars - len(current_messages))當然這里的數(shù)字要按實際模型上下文窗口調(diào)整不要照搬。我一開始機械地把所有余量都塞給舊記憶結(jié)果模型連當前對話都處理不過來后來把舊記憶上限壓到總窗口的 20% 左右效果反而最好。3. 實操過程與核心環(huán)節(jié)實現(xiàn)3.1 環(huán)境準備與項目初始化實操部分我默認你已經(jīng)有一個可以正常調(diào)用 Claude API 的 Python 3.10 環(huán)境并且 ANTHROPIC_API_KEY 已經(jīng)寫進了環(huán)境變量。項目依賴盡量精簡我最終只用了三個包anthropic 官方 SDK、sqlite-vec以及一個本地 embedding 模型。這里我不推薦一上來就把系統(tǒng)做成微服務(wù)先寫成一個能在命令行里復(fù)現(xiàn)流程的腳本驗證思路后再拆模塊。初始化命令不多大概是這樣mkdir claude-mem cd claude-mem python3 -m venv venv source venv/bin/activate pip install anthropic sqlite-vec安裝好依賴后先建一個 config.py 統(tǒng)一管理參數(shù)包括模型名、窗口大小、記憶檢索條數(shù)等。把這些參數(shù)集中放一個文件里很重要后面調(diào)試時不用到處翻代碼。3.2 核心流程記錄對話并生成記憶第一個核心函數(shù)是 handle_turn。它接收用戶輸入先從 SQLite 里檢索相關(guān)記憶組裝 system prompt然后調(diào)用 Claude API 得到回復(fù)最后把用戶輸入和模型回復(fù)寫入 messages 表。這還沒完我會在每一輪結(jié)束后把這一輪的文本丟給記憶抽取器把生成的結(jié)構(gòu)化記憶寫入 memory_items。剛開始我以為摘要應(yīng)該在整段對話結(jié)束后做后來發(fā)現(xiàn)每一輪都即時抽取更合理因為很多關(guān)鍵信息在前面已經(jīng)出現(xiàn)等到最后再抽很容易遺漏而且長對話的 token 消耗也更大。核心代碼示意import sqlite3 import anthropic client anthropic.Anthropic() def handle_turn(user_input: str, conversation_id: int): memories retrieve_memories(user_input, top_k5) system build_system_prompt(memories) resp client.messages.create( modelclaude-3-5-sonnet-20241022, max_tokens1024, systemsystem, messages[{role: user, content: user_input}] ) assistant_text resp.content[0].text store_message(conversation_id, user, user_input) store_message(conversation_id, assistant, assistant_text) extract_and_store_memories(conversation_id) return assistant_text注意這里的 model 參數(shù)要按照你實際有權(quán)限的模型調(diào)整。我在開發(fā)時習(xí)慣把模型名也放到配置里避免每個函數(shù)都重復(fù)硬編碼。如果后續(xù)用 batch 處理歷史對話這一段代碼的輸入換成歷史消息數(shù)組即可復(fù)用性很高。3.3 記憶檢索實現(xiàn)的關(guān)鍵步驟retrieve_memories 函數(shù)內(nèi)部可以做得很簡單。我用的是兩層過濾先按關(guān)鍵詞粗篩再算 embedding 相似度。為了少一次 API 調(diào)用embedding 向量可以選擇在記憶寫入時就算好并緩存到 memory_items 表的 embedding 字段這樣檢索時只需要給當前用戶問題算一次向量。以下是檢索函數(shù)的骨架def retrieve_memories(query: str, top_k: int 5): query_embedding embed_text(query) rows db.execute( SELECT id, content, importance, embedding FROM memory_items ).fetchall() scored [] for row in rows: m_emb deserialize(row[embedding]) score cosine_similarity(query_embedding, m_emb) # 結(jié)合重要性和最后訪問時間加權(quán) score score * (0.6 0.1 * row[importance]) scored.append((score, row[content])) scored.sort(reverseTrue) return [s[1] for s in scored[:top_k]]這里有幾個小細節(jié)similarity 用余弦相似度比較穩(wěn)定importance 加權(quán)我控制了幅度只讓重要性最高的記憶能稍微提升排名否則用戶隨口統(tǒng)計一次“我不喜歡紅色”也能活很久。其實這一步可以在 SQL 里就近做一部分過濾比如只掃描最近 30 天的記憶能減少無謂計算。3.4 端到端測試模擬跨會話記憶全部代碼寫完我最先做的一個驗證場景是在會話 A 中告訴 Claude“我在做一個日志平臺日志保留期定成 30 天”然后結(jié)束會話。隔一段時間新開一個會話只問“日志平臺的數(shù)據(jù)保留策略是多少”如果模型能答出 30 天說明記憶鏈路通了。第一次跑的時候模型答不上來原因是檢索到的記憶里相關(guān)信息沒有被正確抽取。后來排查發(fā)現(xiàn)是記憶抽取提示詞里 category 只有 user_fact、project_decision、task_todo、other 四種而這條信息被歸到 other檢索時又沒有把 other 類型全部納入導(dǎo)致漏掉。調(diào)整檢索條件后終于跑通。這一輪踩坑讓我意識到實現(xiàn)跨會話記憶并不是把“存”和“取”做出來就結(jié)束中間的記憶類型規(guī)則、檢索覆蓋范圍、注入優(yōu)先級都需要逐項驗證。不需要一開始就追求完美先跑通一條最簡單的鏈路再逐步加入復(fù)雜功能比一次性搭巨系統(tǒng)要靠譜得多。4. 常見問題與排查技巧實錄4.1 上下文超長請求被拒我遇到最多的問題是 400 錯誤提示 prompt 超出 token 上限。大多數(shù)時候不是模型窗口不夠而是我的注入邏輯把舊記憶和對話歷史同時塞滿。解決辦法分三步先開 debug 日志把每次請求的 token 數(shù)量打出來再調(diào)整 max_recent_chars 和 max_memory_chars最后把長對話自動做一次滾動摘要替換最早的部分。滾動摘要這一塊我單獨寫了一個 summarize_old_messages 函數(shù)把超過窗口的部分先讓模型壓縮成幾百字的背景說明再保留最近幾輪的原文。這樣長對話也能穩(wěn)定續(xù)上。4.2 記憶混亂模型引用了過時或被推翻的信息這個問題在項目的第二個星期集中爆發(fā)明明用戶后來改了決定模型還是拿舊記憶回答。核心原因有兩個一是 memory_items 里沒有“棄用”狀態(tài)舊信息永遠有效二是注入提示詞里沒說明以當前對話為準。我給 memory_items 表加了 status 字段支持 active/deprecated當新記憶和舊記憶沖突時在抽取階段就把舊記憶標記為 deprecated并且在 system prompt 里明確寫上“如果舊記憶與當前對話有沖突一律以當前對話為準”。改完之后這種問題基本消失。4.3 本地存儲的隱私邊界因為所有對話和記憶都落在本地 SQLite隱私安全要提前想好。我在字段層面做了兩層處理第一層在代碼中過濾明顯敏感的輸入比如密碼、密鑰、手機號不寫入記憶第二層在寫入前用 AES-GCM 對整個 memory_items 表做可選加密密鑰存在系統(tǒng) keychain 或環(huán)境變量里。對于單機個人工具這已經(jīng)足夠。如果以后要提供多人服務(wù)還需要考慮權(quán)限隔離、脫敏和審計這些就超出本文范圍了但設(shè)計時一定要留出擴展位。4.4 調(diào)試時最有用的一招調(diào)試記憶系統(tǒng)最煩的就是 prompt 不可見。我后來把每次實際發(fā)送給 Claude 的 system prompt、檢索到的記憶列表、以及 token 統(tǒng)計全部落盤到 debug_log.jsonl出了任何問題都能回放。這個習(xí)慣幫我省了大量排查時間。比如之前提到的檢索遺漏就是打開 debug log 后發(fā)現(xiàn)檢索到的記憶里根本沒有 relevant 兩條才順藤摸瓜找到過濾邏輯的問題。強烈建議所有做類似工具的朋友都記一筆“現(xiàn)場快照”不要只在出錯時打印堆棧。5. 把 claude-mem 再往前推一步5.1 從命令行工具到輕量服務(wù)我現(xiàn)在把 claude-mem 從單一腳本拆成了三層CLI 交互層、記憶服務(wù)層、存儲層。CLI 層負責(zé)接收用戶輸入、展示回復(fù)記憶服務(wù)層封裝了抽取、存儲、檢索、注入的完整流程對外提供 add_turn 和 query_with_memory 兩個方法存儲層仍然是 SQLite。拆層之后寫單元測試方便了很多后續(xù)如果想接 Web 頁面只需要在 CLI 層之外再套一層 HTTP API記憶服務(wù)層可以原封不動復(fù)用。不過對多數(shù)場景保持單一腳本反而更好維護拆層要等復(fù)雜度到了再動手。5.2 幾個可以繼續(xù)擴展的方向我下一步想給它加上定時任務(wù)式的“大掃除”每隔一段時間把多輪對話中重復(fù)出現(xiàn)的結(jié)論合并成綜述同時清理長期未被訪問的記憶讓記憶庫保持整潔。另一個想法是支持多 Profile把工作記憶和個人記憶分開避免兩個語境互相污染。這些功能都不復(fù)雜但每一步都會讓工具離“真正的 AI 助手”更近一點。如果你也在做類似的事我的建議是先跑起來再去想“完美”記憶系統(tǒng)最忌一開始就陷入完美設(shè)計因為真正有價值的判斷標準只有一個一個新會話里它能不能在你需要的時候想起確實該想起的事。