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