做 AI 聊天应用的人,迟早会碰上一个绕不开的问题:模型没有长期记忆。上一轮还在聊项目背景,下一轮它就能把你说过的关键约束忘得干干净净,用户只能一遍遍重复同样的话。我当时接过一个内部客服助手的迭代任务,痛点非常具体:会话一断开,历史信息全部归零,用户每次回来都要重新交代身份、偏好和上下文。我们折腾了挺久,最后决定不把“记忆”塞进模型本身,而是在外面套一层独立的记忆系统,这个项目就是后来我们内部一直叫的claude-mem。
这套方案做的事情很简单,但解决的是真问题:监听每次对话,把值得记住的信息抽取出来存进检索库,下次对话开始前再把相关记忆送回上下文。它适合正在做聊天机器人、Agent、或者垂直领域 AI 助手的人参考。它不是一门新技术,而是把抽取、存储、检索串成一条完整链路的设计实践,踩坑也不少。下面我把整个项目的思路、细节、代码和问题排查一次讲清楚。
1. 整体设计与思路拆解
1.1 为什么模型自己记不住,得加一层记忆库
首先要理顺一个概念:模型本身没有“记住”你的能力。它每次处理请求,能看到的只有一个固定长度的上下文窗口,窗口里的内容用完就丢。很多产品解决这个问题用的是“把历史消息全部带上”,但窗口有限,聊上几十轮就装不下,再说历史里大部分内容其实都是废话。
我当时考虑过几种方案:
- 微调模型,把用户偏好压进权重里。成本高、周期长,而且用户的偏好是动态变化的,今天喜欢简洁回复,明天可能就需要详细报告,微调根本跟不上。
- 用 RAG 做长期知识,但这解决的是“外部文档知识”的问题,不是“用户说了什么、喜欢什么、做过什么”的问题。
- 直接在系统提示词里写死规则。只能满足静态场景,遇到个性化需求就无能为力。
所以最后选了给模型加一个“外部记忆层”。它的定位是:在每次对话结束后,把该记的东西提炼成结构化条目;在每次对话开始前,把相关条目找回来拼进上下文。这正是claude-mem的核心工作方式——模型只负责推理,记忆由外部系统负责。
用生活类比就是:模型像一个特别健忘的顾问,你每次来都得重新讲一遍背景。我们给他配了一个助理,这个助理手里有一个记事本,记下你交代过的事情,下次见面之前先把上次的重点写在便签上递给顾问。顾问还是那个顾问,但看起来像是“认识你”了。
1.2 记忆内容分类:事实、偏好与任务状态
不是所有对话内容都值得记住。一开始我把用户说过的每句话都塞进记忆库,结果检索出来一堆“今天天气不错”这种垃圾,真正有价值的背景反而被冲淡。后来我把记忆分成了三类,抽取的时候按类型去判断:
| 类型 | 定义 | 典型例子 |
|---|---|---|
| 事实型记忆 | 用户的基本信息、客观情况 | 用户在上海,就职于某电商公司,负责数据团队 |
| 偏好型记忆 | 用户对输出方式的主观要求 | 喜欢结论先行,回复尽量控制在300字以内 |
| 任务状态型记忆 | 当前项目进行到哪一步 | 正在开发订单导出功能,卡在第2个接口联调 |
分类的价值在于后续检索时可以分别设置权重。偏好型记忆通常长期有效,重要性权重给高一些;任务状态型记忆时效性强,过了两周基本就过期了,权重要衰减。
1.3 三段式架构:采集、加工、检索
claude-mem的整体架构可以拆成三段,每一段职责单一,出了问题时也方便单独排查:
第一段是采集层。它挂钩在对话接口的回调里,每轮对话结束后,把这段对话的原文送到抽取模块。
第二段是加工层。用模型对这段文本做结构化抽取,生成若干条记忆条目,每条包含内容、类型、实体名、重要程度、过期时间等字段。这里有一个关键操作:新抽取出来的条目要和已有条目做冲突合并,避免同一个事实被重复存储成好几份。
第三段是检索层。新对话开始前,把当前会话的临时上下文临时拼成一句“查询语句”,去记忆库里做语义检索,取回来最相关的几条。把检索到的记忆和系统提示词、最近几轮对话一起拼成最终的请求。
这套设计的核心取舍在于:抽取异步做,不阻塞用户回复;检索同步做,必须在请求模型之前完成。异步抽取能大幅降低响应延迟,因为用户不需要等记忆写完就能收到回复;但检索必须快,整个耗时控制在一两百毫秒以内,否则对话开头会有明显卡顿。
1.4 为什么选用语义检索而不是关键词匹配
项目早期我试过最简单粗暴的“关键词标签”方案:给每条记忆打几个标签,匹配时按标签找人。比如用户提到“数据库”,就把所有带“数据库”标签的记忆捞出来。但很快露馅了。
用户说话的变体太多了。上午说“MySQL 查了个慢查询问题”,下午说“昨天那个 SQL 优化后来怎么处理了”,这两句里的关键词几乎没有交集,但语义是连贯的。关键词匹配完全联系不上。后来换成了向量检索,把记忆条目和当前对话内容都编码成向量,用余弦相似度去算相关性,才能真正做到“语义相近就能召回”。
当然向量检索也不是万能。纯向量容易忽略时效性,三个月前的记忆和三天前的记忆可能向量相似度相当,但前者很可能已过期。所以我在检索结果上又叠加了两个因子:重要程度权重和时间衰减权重。最终排序分值是向量相似度、重要性权重、时间新鲜度三者的加权和,这样结果才真正贴合实际使用场景。
2. 核心细节解析与实操要点
2.1 记忆条目的字段设计
记忆条目是整套系统的核心数据结构。最开始我设计得太简单,只存了内容和时间,结果后续更新、过期、隔离全都做不了。整理稳定后的字段如下:
{ "id": "mem_d1f8c2a4", "user_id": "user_42", "content": "用户偏好结论先行,回复控制在300字以内", "entity": "user_42.default", "type": "preference", "importance": 0.85, "created_at": 1713500000000, "last_accessed_at": 1713600000000, "access_count": 7, "expires_at": null }几个字段的作用:
user_id用于数据隔离。多人共用一个记忆库时,这个字段是硬隔离条件,检索时必须在 WHERE 里带上。importance是 0 到 1 的打分,用来表达这条记忆的长期价值。偏好型通常给 0.7 以上,事实型给 0.5 左右,临时任务状态给 0.3 以下。last_accessed_at和access_count用于频率召回。如果用户连着七次对话都在问同一件事,这条记忆虽然重要性不高,但短期活跃度很高,应该给一个临时加分。expires_at是可选的过期时间。比如“下周要交季度总结”这种记忆,到点就该清理掉。
这个结构并不复杂,但它在后续的合并、衰减和清理逻辑里处处要用,前期规划好能省后面非常多重构的功夫。
2.2 抽取策略:怎么从对话里提炼出“值得记的东西”
抽取是整个系统最依赖模型能力的环节,也是最容易出幺蛾子的环节。我用的是结构化抽取方案:把一段对话喂给模型,要求它输出一段 JSON 数组,每个元素是一条记忆候选。提示词模板大概长这样:
EXTRACT_PROMPT = """ 请从下面的对话中提取值得长期记住的信息。 只提取对后续对话有价值的稳定信息,忽略寒暄和临时内容。 区分类型:fact、preference、task_state。 输出格式(JSON数组): [ {{ "content": "记忆内容", "type": "fact|preference|task_state", "importance": 0.0-1.0, "expires_in_days": 天数或null }} ] 对话内容: {conversation_text} """这里有几个实操要点。第一,要求模型返回 JSON 而不是自然语言,因为这样能直接解析入库,下游处理和字段更新都方便。第二,importance必须让模型打分,并同时给它参考标准,比如“用户明确说出的偏好给 0.8 以上”。如果不给标准,模型会倾向于把什么东西都打 0.5 分,后续排序就失效了。第三,明确要求忽略寒暄,这个负向约束比正向约束管用得多。没有这条的时候,模型经常把“好的”“哈哈哈”“嗯嗯”也记录进去。
抽取频率也要控制。并不是每轮对话都必须抽取,如果用户只说了一句“好的”,就没有任何抽取价值。我的做法是先做一次粗筛:只有用户消息里包含新信息时,比如长度超过 15 个字,或者跟上一轮话题不一致,才触发抽取流程。这样能省掉大量无效调用。
2.3 记忆合并与冲突消解:避免同一个事实被存很多遍
这是实际项目里非常容易被忽略,又非常影响体验的一环。所谓冲突消解,是指同一条事实相关信息已经在记忆库里了,新抽取出来的内容不能直接再插一条,而是要做合并或者覆盖。
举个例子。用户第一次说“我在用 Python 写数据清洗脚本”,记忆库里存了一条“用户常用 Python 做数据清洗”。隔了两天,用户说“最近转向 Go 了,主要做后端服务”。如果只做简单插入,库里面就会出现两条互相矛盾的记录。检索的时候两条都查出来,模型就会困惑:这位用户到底用 Python 还是 Go?
我采用的策略是“事实型记忆优先合并,偏好型记忆允许保留多态”:
- 事实型记忆:按实体 + 属性名做唯一键。比如“用户.语言栈.Python”,如果新信息“用户.语言栈.Go”出现,就更新为 Go,并对旧的做历史标记。这能保证同一事实只有一个当前有效版本。
- 偏好型记忆:用户完全可能同时持有多个偏好,比如喜欢简洁回复,也喜欢用表格展示数据,这两条并存没问题,不需要覆盖。
合并操作放在抽取之后做。实体对齐用规则匹配,其实就是给每条记忆打一个entity标签,标签相同且类型相同就视为同一事实。这个小设计让后续删除和更新也变得容易,删除用户信息时直接按entity匹配即可。
2.4 上下文组装:怎么把记忆塞回对话里而不污染输出
记忆检索回来的结果不是越多越好。一开始我把 top_k 设成 8,结果系统提示词里堆了一大段历史信息,模型开始纠结于“这些记忆是否真实”,反而忽略了当前的用户问题。
后来我把记忆组装分成两个区域:一个是“用户画像区”,放在系统提示词靠前的位置,内容是用户的核心偏好和基本事实,属于长期稳定信息;另一个是“近期相关区”,放在最近对话的后面,内容是跟当前问题强相关的项目状态和临时信息。两个区域的作用完全不同,拼接顺序也不一样。
用量化标准来说:画像区通常控制在 300 字以内,只放 importance > 0.6 的偏好和事实;近期相关区控制 3 到 5 条,只放跟当前查询语义相似度超过阈值的记录。总量加起来不超过 800 字,这样才能保证模型接收到的记忆是有效增强,而不是噪声干扰。
3. 实操过程与核心环节实现
3.1 环境准备与基础依赖
claude-mem的技术栈并不复杂,我当时用的是 Python + SQLite + 向量检索插件。选择这套组合并不是因为它最强,而是因为它足够简单,方便快速验证逻辑,等跑通了再迁移到 Postgres 也不费劲。
基础依赖就三样:
- Python 3.10 以上
- 一个对话模型 API 的访问权限,用于抽取和最终回复
- 一个数据库,本地开发用 SQLite 加向量搜索扩展,生产环境可以换成支持向量类型的 PostgreSQL
数据库表结构也很直接,一张表存记忆条目,一张表存对话日志。对话日志用来做断点续传和问题回溯,因为对话内容本身并不需要长期保存,但最近一天的日志要留着。
CREATE TABLE memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, entity TEXT, content TEXT NOT NULL, type TEXT NOT NULL, importance REAL DEFAULT 0.5, created_at INTEGER NOT NULL, last_accessed_at INTEGER, access_count INTEGER DEFAULT 0, expires_at INTEGER ); CREATE INDEX idx_memories_user ON memories(user_id);顺带说一句,entity字段尽量别设计成可空,没有实体信息的记忆在合并和去重阶段会变成麻烦来源。宁可先设置成user_id + type + content拼接成的字符串,也别留空。
3.2 核心代码:存储、抽取与检索的完整实现
下面这段代码是我从当时项目里提炼出来的核心逻辑,包含了存储、抽取、检索三个最关键的环节。它没法直接复制就跑,因为模型 API 的接入方式不同项目不一样,但结构可以直接参考。
import json import sqlite3 import time import uuid class MemoryManager: def __init__(self, db_path: str, llm_client): self.conn = sqlite3.connect(db_path, check_same_thread=False) self.llm = llm_client self._init_db() def _init_db(self): self.conn.execute(""" CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, entity TEXT, content TEXT NOT NULL, type TEXT NOT NULL, importance REAL DEFAULT 0.5, created_at INTEGER NOT NULL, last_accessed_at INTEGER, access_count INTEGER DEFAULT 0, expires_at INTEGER ) """) def extract_and_save(self, user_id: str, conversation_text: str): """异步调用:从对话中抽取记忆候选,合并后写入库""" prompt = EXTRACT_PROMPT.format(conversation_text=conversation_text) result = self.llm.chat_completion(prompt, response_format="json") candidates = json.loads(result) now = int(time.time() * 1000) for item in candidates: content = item.get("content", "").strip() if len(content) < 4: continue mem_type = item.get("type", "fact") importance = float(item.get("importance", 0.5)) expires_in = item.get("expires_in_days") expires_at = now + int(expires_in * 86400000) if expires_in else None entity = f"{user_id}.{mem_type}.{self._normalize(content[:20])}" self._upsert_memory( user_id=user_id, entity=entity, content=content, mem_type=mem_type, importance=importance, expires_at=expires_at ) def _upsert_memory(self, user_id, entity, content, mem_type, importance, expires_at): row = self.conn.execute( "SELECT id, importance, content FROM memories WHERE entity=? AND user_id=?", (entity, user_id) ).fetchone() now = int(time.time() * 1000) if row is None: mem_id = "mem_" + uuid.uuid4().hex[:12] self.conn.execute( "INSERT INTO memories (id, user_id, entity, content, type, importance, created_at, last_accessed_at, access_count, expires_at) VALUES (?,?,?,?,?,?,?,?,?,?)", (mem_id, user_id, entity, content, mem_type, importance, now, now, 0, expires_at) ) else: # 新的重要性更高时覆盖旧内容 if importance >= row[2]: self.conn.execute( "UPDATE memories SET content=?, importance=?, type=?, last_accessed_at=?, expires_at=? WHERE id=?", (content, importance, mem_type, now, expires_at, row[0]) ) self.conn.commit() def retrieve(self, user_id: str, query: str, top_k: int = 5) -> list[dict]: """同步调用:在对话请求前取回相关记忆""" query_vec = self.llm.embedding(query) rows = self.conn.execute( "SELECT id, content, importance, last_accessed_at, access_count FROM memories WHERE user_id=? AND (expires_at IS NULL OR expires_at > ?)", (user_id, int(time.time() * 1000)) ).fetchall() scored = [] for row in rows: mem_vec = self.llm.embedding(row[1]) similarity = self._cosine_similarity(query_vec, mem_vec) time_freshness = self._freshness_score(row[3]) frequency_boost = min(row[4] / 10, 0.15) final_score = similarity * 0.6 + row[2] * 0.25 + time_freshness * 0.1 + frequency_boost scored.append((final_score, row[1])) scored.sort(key=lambda x: x[0], reverse=True) return [{"content": content} for score, content in scored[:top_k]] def _normalize(self, text: str) -> str: return "".join(text.split()).lower()[:50] @staticmethod def _cosine_similarity(vec_a, vec_b): dot = sum(a * b for a, b in zip(vec_a, vec_b)) norm_a = sum(a * a for a in vec_a) ** 0.5 norm_b = sum(b * b for b in vec_b) ** 0.5 if not norm_a or not norm_b: return 0.0 return dot / (norm_a * norm_b) @staticmethod def _freshness_score(last_accessed_at: int) -> float: hours = (int(time.time() * 1000) - last_accessed_at) / 3600000 return max(0.0, 1.0 - hours / (24 * 14))几个实现细节值得单独讲一下:
_upsert_memory里的合并条件我设置的是“新 importance 大于等于旧值才覆盖”。因为用户最新表达的意图往往比之前的更有参考价值,即使表述方式变了,也应该更新内容,但前提是新条目的重要性得分确实更高。如果模型给的重要性偏低,比如 0.3,那就不覆盖,保留原来更重要的记录。
retrieve方法里我把向量相似度权重设为 0.6,重要性 0.25,时间新鲜度 0.1,活跃度 0.15 封顶。这套权重并不是一次定死的,我调过好几版。早期我把重要性权重设到 0.4,结果发现高重要性的旧记忆会霸榜,每条结果都是“用户喜欢什么什么”,跟当前问题关联并不强。砍到 0.25 之后效果平衡多了。
另外,每一条记忆都现算一次向量,在数据量小的时候没问题,数据量过万之后会明显变慢,这就是需要换成真正的向量数据库的信号。
3.3 关键参数选择:top_k、importance 阈值和过期策略
top_k我最终固定为 5,理由很简单,超过 5 条之后模型开始出现“信息打架”的情况。比如同一轮对话里可能既有用户早期偏好、又有后期纠正信息,全塞进去模型反而不知所措。5 条既能保证关键背景不丢,又不至于让提示词里挤太多历史信息。
importance的入库阈值我设置在 0.35。低于这个值的候选大概率是寒暄、临时情绪或者不确定信息,存进来只会增加检索噪声。但要注意,阈值不能设太高,因为任务状态型记忆在刚出现的当下往往重要性不高,但短期价值很大,阈值过高会把它们全过滤掉。
过期策略有两种执行方式:被动过期和主动清理。被动过期就是检索时加条件expires_at IS NULL OR expires_at > 当前时间,不浪费时间单独删;主动清理是每天跑一次定时任务,把过期超过 30 天的记录彻底删除。两种配合,既能保证查询结果正确,又能控制存储膨胀。
3.4 一个完整的端到端调用示例
为了直观展示这套系统在工作场景中长什么样,我写了一个模拟流程。用户第一次会话说:“我在做订单导出功能,后端用 Python FastAPI,回复尽量简短一点。”
抽取阶段会生成三条记忆候选:
[ {"content": "用户在做订单导出功能", "type": "task_state", "importance": 0.7, "expires_in_days": 30}, {"content": "后端技术栈是 Python FastAPI", "type": "fact", "importance": 0.6, "expires_in_days": null}, {"content": "用户要求回复简短", "type": "preference", "importance": 0.8, "expires_in_days": null} ]这三条入库后,过了三天用户又打开会话,只输入了一句话:“上次那个导出接口联调好了吗?”这时候检索模块用这句话做查询,可以召回“在做订单导出功能”和“后端技术栈是 Python FastAPI”,系统把这批记忆拼进系统提示词,模型才能准确回答“上次那个界面”是指订单导出模块,而不是别的什么。
这就是整个系统的工作弧线:用户不需要重复背景,模型也不需要凭空猜测,记忆层把中间的桥梁搭好了。
4. 常见问题与排查技巧实录
实际跑起来之后,遇到的坑比我预想的多。整理几个典型的场景,算是给后来人排雷。
4.1 检索召回了无关记忆
表现:用户明明在问 A 项目的事,结果记忆库把 B 项目的状态也带出来了,模型回答就开始串味。
原因通常是 top_k 设置过大,或者相似度阈值过低。我当时排查时重点看了每个检索结果的余弦相似度,发现 0.7 以下的记录基本都没什么用,于是加了一个 0.65 的阈值,拦掉了大量低质召回。
另外还有个隐蔽因素:时间衰减权重太低。旧记忆一旦被频繁访问过,access_count 会给它一个持续的加分,导致它在很长时间内都能挤进召回列表。后来我把活跃度加分从 0.15 降到 0.1,并加了一条规则:超过 14 天未访问的记忆活跃度加分清零。这样热度高的旧记忆慢慢会让位给新鲜相关内容。
4.2 记忆条目越来越多,提示词被撑爆
表现:跑了一个月后,用户画像区的记忆内容越来越长,模型开始出现理解偏差和回复模糊。
根因是缺少“记忆合并压缩”机制。用户的同一个偏好可能被拆成好几条近似但表述不同的记录,比如“喜欢简短”“回复不要太长”“简洁一点”其实是同一个意思,却存了三份。
解决办法是加了一个周期性的压缩任务:把同一用户同类型记忆里向量相似度超过 0.9 的条目合并成一条,保留 importance 最高、内容信息量最大的那一条,同时把创建时间改成最早的,以保留历史脉络。这个任务每天跑一次,能有效控制记忆库膨胀。
4.3 抽取模型偶尔输出垃圾信息
表现:有时候一条“我今天吃了个苹果”也会被存入长期记忆库,因为模型把它当成了事实型信息。
这个问题靠提示词只能缓解,不能根治。我在后处理阶段加了两条规则:
- 内容里包含第一人称情绪化词(“开心”“烦死”“哈哈”)时,importance 直接降 0.2。
- 长度小于 4 个字的候选直接丢弃。
- 所有
expires_in_days为空的 task_state 类型条目,强制设置 7 天过期,因为任务状态往往变化很快。
此外,模型 API 偶尔会输出非法 JSON,导致整个抽取任务崩溃。应对做法是抽取逻辑外面包了重试降级:第一次解析失败,自动清理掉 JSON 中的前后缀再解析;第二次失败,就丢弃这批候选,记录日志,不让单次失败拖垮整个流程。
4.4 多用户共用环境下的数据串味
表现:一个工作区被两三个人同时用,用户 A 的信息跑到了用户 B 的记忆里。
排查发现是因为我在检索的时候漏掉了user_id过滤条件,早期代码里只有一条 SQL 查询,没带 WHERE 条件。后来修复很直接:所有读写操作的 SQL 都必须带上user_id,同时给(user_id, entity)加了唯一索引,从数据库层面挡住串味。
如果你做的产品要支持跨会话角色隔离,建议在表结构设计时就加上namespace字段,把用户、团队、会话三类维度分开。不要等到上线后才发现,再迁移数据是非常痛苦的。
4.5 问题排查速查表
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 召回结果和当前问题无关 | 相似度阈值过低、top_k 偏大 | 打印每次召回的向量相似度 | 设置 0.65 阈值,top_k 调回 5 |
| 记忆库增长过快 | 缺少合并压缩、过期策略失效 | 检查过滤条件是否生效 | 启用每日合并任务,设置强制过期 |
| 模型回复被旧信息带偏 | 画像区超过 300 字 | 查看实际拼入的提示词长度 | 收紧 importance 门槛到 0.6 |
| 抽取调用超时 | 抽取模型响应慢 | 看监控日志里的 P99 耗时 | 改为异步处理,不阻塞主回复链路 |
| 同事实被存多份 | 实体归一化失败 | 检查 entity 拼接规则 | 加唯一索引,优化 _normalize 逻辑 |
5. 扩展方向与边界思考
5.1 从“记忆用户”到“记忆项目知识”
claude-mem最初是服务单个用户的个性化记忆,但后来我发现它完全可以扩展到项目级知识记忆。比如一个团队在做某个跨平台系统,所有成员都会和 AI 助手讨论架构、接口设计和故障排查。这些内容本质上是项目知识,不是个人偏好,把它们存进同一个记忆库并标注为project_knowledge类型,就能实现“团队共享记忆”:任何人提到“登录服务”,助手都能接续之前别人讨论过的方案细节。
实现上只需要把user_id换成namespace概念,一个团队一个命名空间,检索时先按命名空间过滤,再按个人偏好加权。这比每人单独一套记忆库要节省大量重复成本。
5.2 把记忆升级成“人设系统”
记忆不只是用来提供背景知识,它还可以驱动 AI 的输出风格。我们当时做了一个实验:把记忆库里的偏好型记忆单独抽出来,拼成一句话作为“人设指令”,比如“用户偏好结论先行、用表格对比、避免堆砌术语”。这句话放在系统提示词的最前面,模型的回复风格立刻明显改变,用户反馈说“感觉这个助手开始懂我了”。
这说明记忆系统真正的价值不只是信息传递,更是行为对齐。同样的模型,同样的能力,通过记忆层的调度,能让不同用户拿到完全不同的交互体验。这个方向值得好好挖。
5.3 成本与性能预算
记忆系统是有成本的,主要来自两部分:抽取时的模型调用和检索时的向量计算。我的经验是,抽取调用可以只针对用户主动提供新信息的那些轮次触发,高频对话里大概只有四分之一需要真正抽取;向量计算在数据量小于一万条时用内存计算完全够,超过一万条再引入向量数据库索引。
还有一个容易忽略的点:不要每次回复完都马上调用抽取模型,可以在用户停止输入 5 秒后再触发一次异步抽取,减少无效调用。如果对话非常密集,每轮都做抽取会白烧不少 token。
5.4 小技巧:用记忆回放来调试
最后分享一个我实测非常管用的调试方法:在每次调用模型之前,把实际拼入提示词的记忆内容完整打印出来看一眼。日志里增加一行memory_replay,把检索回来的每条记忆的内容和得分都输出。这比看日志里的标准输出要直观得多,因为你马上就能发现哪些记忆被错误地带了进来、哪些真正重要的记忆被排在了后面。
踩过几次坑之后,我的体会是:记忆系统最容易翻车的不是存储和检索,而是“边界”。什么时候该记、什么时候该忘、哪些信息属于谁,这些边界问题想清楚了,系统就稳了一大半。技术细节都可以慢慢调,数据边界不清会导致后期所有层面都在返工。希望这份复盘能帮你少走几段弯路。