第六话结尾,留下了一个没解决的问题。
那一篇我们让循环「断了能接回来」:进度落盘、崩溃恢复、幂等防重做。但回头想,我们忙活的始终是「这一次任务」从头到尾的进度。真实世界里,Agent 处理的不止一次任务——它今天处理一百笔、明天再处理一百笔。于是问题冒出来了:
用户在第一场对话里告诉 Agent「我最近在戒咖啡,想少喝一点」。三天后用户新开一场对话,Agent 还记得吗?还是像第一次见面的陌生人一样,把这件事从头问一遍?
「记住这一次任务走到哪了」,和「记住过去那些对话里攒下的信息」,是两码事。前者第六话解决了;后者——让 Agent 跨任务、跨会话,还记得以前的事——正是这一篇要讲的:记忆。
一、先看清:模型到底「记得」什么
要讲记忆,得先拆穿一个最常见的误解——以为模型有记忆。
模型没有跨会话的记忆。每一次调用都是独立的:你发给它的,只有当前这一批 messages。它不记得上一轮说过什么,不记得昨天聊过什么,更不记得半年前你说过要戒咖啡。它「看起来记得」,是因为你把该记住的信息,在这一次调用里重新提供给了它。
把这一点说透,用一张表:
| 你以为的 | 实际发生的 |
|---|---|
| 模型记得上一轮 | 你把上一轮对话放进了这一次的 messages |
| 模型记得你的名字 | 你把「用户叫张三」这条信息,在需要时重新提供给了它 |
| 模型记得你要戒咖啡 | 你把「偏好:戒咖啡」这条信息,检索出来放进了上下文 |
所以「记忆」这件事,责任从来不在模型,而在运行它的那层代码。模型只是个无状态的文本补全器;「记不记得住」,取决于你用什么方式存、存到哪里、什么时候检索出来喂给它。
这里先纠正三个容易走偏的简化说法:
- 上下文不必是完整历史。 你可以塞全文,也可以塞摘要,或者只塞检索出来的、与当前问题相关的片段。塞什么,是设计选择,不是唯一解。
- 超出窗口后,不一定会「自动挤掉最早的内容」。 有的实现会报错拒绝,有的会由应用在调用前主动压缩或裁剪。把「超窗即遗忘最早」当成必然,会掩盖真实的工程分支。
- 用服务端会话管理时,调用方不一定每次重传全文。 历史可能存在服务端,客户端只带一个会话 ID。所以「每次把历史原样塞回去」描述的只是一种实现方式,不是全部。
这不是杞人忧天。MemGPT 那篇论文(arXiv 2310.08560)做过一个「Deep Memory Retrieval」实验:在多会话对话数据集 MSC 上,问一个明确指向此前第 1–5 次会话讨论过的话题,裸的 GPT-4(gpt-4-0613,8K 上下文)只有 32.1% 答对——因为基线只能看到这几段对话的有损摘要;而 MemGPT 靠「把相关记忆检索进主上下文」这套机制,做到了 92.5%。同样的模型,差别在有没有一层可靠的记忆系统。(具体实验设置和局限,见文末「参考」。)
这一篇要做的,就是把「在需要时把该记住的重新提供给模型」这件事,从临时的、随手的、手忙脚乱的,变成分层的、持久的、可检索的——这才是「记忆系统」四个字的真正含义。
二、四层记忆,各管一段
人的记忆不是一坨,是分层的。这个分层不是拍脑袋——在 Agent 领域,有一篇被广泛引用的论文把它正式化了:CoALA(Cognitive Architectures for Language Agents,arXiv 2309.02427)。它借鉴经典的认知架构(Soar 等),把语言 Agent 的记忆拆成一层工作记忆 + 三种长期记忆,正好对应四个名字:工作、情节、语义、程序。
关键的一点:这四层是按「记的是什么」来区分的,不是按「存到哪里」来区分的。
| 类型 | 区分依据 | 本篇用的例子 |
|---|---|---|
| 工作记忆 Working | 当前决策正在使用的信息 | 当前问题、相关历史、工具结果 |
| 情节记忆 Episodic | 具体经历及其发生背景 | 某次对话中,用户说想减少咖啡摄入 |
| 语义记忆 Semantic | 提炼后的事实、知识或偏好 | 用户目前倾向少喝咖啡 |
| 程序记忆 Procedural | 完成任务的方法或行为规则 | 如何根据饮食限制给出建议 |
SQLite、文件、向量索引这些是「实现选择」,要另说,不能和记忆类型混为一谈。 情节记忆同样能做向量检索(把「某次对话里用户说了什么」向量化后召回),语义记忆同样能走 SQL 精确查询(按用户 ID 直接取一条偏好)。CoALA 原文就明确讨论了「从情节记忆中检索经历来支持推理」——情节记忆不是只能原样读回。
分清楚每层「记的是什么」,是设计记忆系统的第一步。存储介质是第二步才轮到的事。
四层各记各的,但最终殊途同归——都要在模型需要时,被检索出来、放进上下文:
逐层说。
工作记忆就是第四、五话里那个 messages 列表——模型现在「正在看」的东西。它最直接,也最脆弱:窗口一满,最早的东西可能被挤掉;进程一重启,全没了。
情节记忆是「某一次对话里发生过什么」:用户在第三场对话里说过「最近在戒咖啡」。把它显式提取出来,存成结构化条目,比全量历史更省 token、更抗干扰,而且落盘后能活过重启。它记的是「经历及其发生背景」,不是「结论」。
语义记忆是「跨会话你还知道什么」:不管哪一场对话,Agent 都「知道」这个用户现在倾向少喝咖啡。它记的是提炼后的事实或偏好——是情节记忆经过归纳之后的产物。
程序记忆是「怎么做一件事」:不是「记住了什么事实」,而是「记住了怎么做」——比如「如何根据用户的饮食限制给出建议」。注意别用「事实会过期、方法可复用」来划这条界:方法同样可能过时、需要维护,事实也可以被反复复用。 真正的区别是,前三层记的是「关于世界与经历的信息」,程序记忆记的是「完成任务的方法或行为规则」。而且程序记忆里的代码,也不必全文塞进提示词才生效——它可以被检索、被调用。这是第九话 Skill 系统的引子,这里只点一句。
关键洞察:分层不是为了显得高级,而是因为不同类型的记忆,其生命周期和「进入上下文的方式」不同——工作记忆随窗口和进程起落,情节记忆按经历归档,语义记忆靠归纳沉淀,程序记忆靠固化调用。分不清层,就不知道该把什么存进哪一层,也不知道什么时候该去检索什么。
三、情节记忆:把经历提取成结构化事实,落进 SQLite
先从最容易落地的一层下手:情节记忆。这里说明一下:本篇 demo 里的关键事实是「预设写入」的(代码直接写了几条),并没有演示「从对话里自动提取关键事实」那道工序。 自动提取需要另外一套流程(让模型读对话、抽出事实再入库),那是另一个话题;本篇先聚焦「存下来、能读回、能被检索」这条主线。
第六话我们已经建立了一个认知——进度要显式化,不和消息耦合。情节记忆同理:与其每次把整段对话塞回去,不如把它缩成几条结构化事实。
class EpisodicMemory:
def __init__(self, db_path: str):
self.conn = sqlite3.connect(db_path)
self.conn.execute(
"""CREATE TABLE IF NOT EXISTS facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject TEXT NOT NULL, -- 事实主体
fact TEXT NOT NULL, -- 事实内容
session_id TEXT NOT NULL, -- 产生于哪个会话
created_at TEXT NOT NULL
)"""
)
def add(self, subject, fact, session_id):
self.conn.execute(
"INSERT INTO facts(subject, fact, session_id, created_at) VALUES (?,?,?,?)",
(subject, fact, session_id, datetime.now(TZ).isoformat()),
)
self.conn.commit()
注意这里多了一个字段 session_id。它记录「这条事实产生于哪一场对话」,让每一条事实都可追溯归属。至于「情节记忆里的哪些事实,该升格为跨会话的语义记忆」——那是另一道工序,本篇 demo 还没做,但 session_id 是它将来成立的地基:没有归属,就谈不上「把某个会话学到的、沉淀成全局长效的」。
情节记忆怎么召回:SQL 精确查询
存进去只是第一步。情节记忆的价值,在于当用户问到一个具体问题、或进入某一场对话时,能把相关的那几条事实精确取出来——而不是每次把整张表全读回来。
因为是结构化条目(subject / fact / session_id 各司其职),召回可以走 SQL 精确查询,三条路:
def by_subject(self, subject): # 按事实主体查:取出所有「偏好」条目
return self._query("WHERE subject = ?", (subject,))
def by_session(self, session_id): # 按会话查:取回「这场对话都发生了什么」
return self._query("WHERE session_id = ?", (session_id,))
def search(self, keyword): # 按关键词模糊查:事实里含「咖啡」的条目
return self._query("WHERE fact LIKE ?", (f"%{keyword}%",))
(_query 就是把三条 SELECT 里重复的「取字段、拼 dict」抽出来的公共方法,完整代码见仓库。)
跑起来会看到两件事,正好是这一层要解决的两个问题。进程重启后事实还在:落进 SQLite 的三条事实(用户名、偏好「最近在戒咖啡,想少喝一点」、职业)在新开的连接里原样读回,每条还带着自己来自哪场会话。三种召回各命中什么:按 subject 精确查「偏好」命中 1 条;按会话查 session-A 命中 3 条,也就是这场对话里发生过的所有事;按关键词模糊查「咖啡」命中 1 条(脚本 memory_demo.py)。
「进程重启」这一下,是第六话「断点恢复」的自然延伸:第六话让这一次任务的进度活过崩溃,这一篇让过去对话的事实活过重启。而「SQL 召回」这三条路,分别对应三种真实场景——「用户有什么偏好」(by_subject)、「上次那场对话聊了什么」(by_session)、「提到咖啡的那些事」(search)。
到这里,情节记忆记的是「发生了什么、在什么背景下发生」,并且能靠结构化字段精确召回。但它的召回有个边界:它只能按你存进去时定好的字段来查。 当用户问出一句你没存过原句、也没有对应字段的话——比如「我最近饮食上有什么要注意的吗?」——SQL 里既没有「饮食」这个 subject,fact 里也没有「饮食」这个字面词,精确查询就落空了。结构化查得到「存过的」,查不到「没存过但语义相关」的。 这就引出下一层:语义检索。
四、语义记忆:不检索,等于不存在
语义记忆要解决的,是「从哪里找」的问题。
存了一万条事实,用户问一句新的话,你不可能把一万条全塞给模型(塞不下,也污染)。你得按语义相似度,挑最相关的几条。这就是「检索」。
要说明白的是:检索不是语义记忆的专利。 上一节情节记忆已经用 SQL 精确召回(by_subject/by_session/search),情节记忆同样能向量化后召回,语义记忆也能用 SQL 精确查。这一节拿「跨会话偏好」这个典型场景,演示语义检索这一种检索方式——写向量、算相似度、召回。
两者真正的区别是靠什么匹配:情节记忆的 SQL 召回靠字面/字段精确匹配——你存了「偏好」这个 subject、fact 里写了「咖啡」,才查得到;换个说法「饮食上要注意什么」就落空。语义检索靠向量相似度——「想少喝一点」和「想戒掉什么」字面不同,但向量相近,照样命中。至于「情节 vs 语义」这两层记的东西不同(经历及背景 vs 提炼后的结论),那是另一个维度,别和「怎么召回」混在一起。
检索靠的是向量:把每段文字映射成一个高维向量,语义相近的文字,向量在空间里挨得近。这里的关键,是用真实 Embedding 模型(bge-m3,1024 维)把文字变向量,而不是手动分词去凑:
def embed_one(text: str) -> list[float]:
payload = {"model": "bge-m3", "prompt": text}
req = urllib.request.Request(
f"{EMBEDDING_URL}/api/embeddings",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read())["embedding"]
然后写入 Qdrant、检索时算余弦相似度召回,两条核心方法长这样:
class SemanticMemory:
def store(self, text: str) -> None:
"""把一条记忆向量化后写入 Qdrant。"""
vector = embed_one(text)
point_id = abs(hash(text)) % (10 ** 12) # Qdrant point id 只认整数/UUID
body = {"points": [{"id": point_id, "vector": vector, "payload": {"text": text}}]}
# PUT /collections/{COLLECTION}/points?wait=true
def retrieve(self, query: str, top_k: int = 2, min_score: float = 0.0):
"""把 query 向量化,算相似度,召回最相关的 top_k 条。"""
vector = embed_one(query)
body = {"vector": vector, "limit": top_k, "with_payload": True}
# POST /collections/{COLLECTION}/points/search
# 返回的每条里,score 低于 min_score 的丢弃(见下一节「候选筛选」)
注意两点:写入时,每条记忆先经 embed_one 变成 1024 维向量,再连同原文一起存进 Qdrant(原文放在 payload 里,召回时原样取出);检索时,把问题也向量化,交给 Qdrant 算余弦相似度、返回最像的前 top_k 条。
跑起来看真实的检索结果,四个问题的得分依次是 0.753、0.783、0.704、0.604:问「用户喝咖啡吗?」命中的是「最近在戒咖啡,想少喝一点」,换一句更泛的「用户想戒掉什么?」命中的还是同一条、得分还略高;问职业、问博客主题则各自命中对应的那条记忆。
盯住前两个问题。一个问「喝咖啡吗」,一个问「想戒掉什么」——两句话字面上几乎没有重叠,但都命中了同一条「戒咖啡」记忆。尤其后一个:记忆里写的是「戒咖啡、想少喝一点」,问句用的是「戒掉」这个更泛的词,字面对不上,但向量空间里挨得很近。
这就是真实 Embedding 的价值,也是它区别于「土法分词」的地方。用纯 bigram 分词做检索,「戒掉」和「戒咖啡」未必能靠字面重叠对上;而 bge-m3 把同义表达投影到相近向量,「想戒掉什么」照样能命中「想少喝一点」。语义检索的「语义」二字,靠的就是这一步。
但检索命中,故事才讲了一半——找出来的记忆,怎样真正进入本轮上下文、影响模型的回答? 下一节说。
五、检索结果,怎样真正进入本轮上下文
语义检索把最相关的东西找出来了,但故事还没完——「查到了」和「模型用上了」之间,还隔着一串工程问题。
别小看这一截。很多人把记忆系统做成「存得进去、查得出来」就收工了,结果模型依然答不对。缺的就是把检索结果正确地装回上下文这一步。要回答四个问题:
- 谁触发检索? 是程序在每次调用前自动查,还是模型自己决定「我需要记忆」并发起检索?前者简单可控,后者更灵活但需要模型配合。
- 候选怎么筛选、预算怎么限制? 检索返回的候选可能十几条,全塞进上下文既浪费又干扰。要按相似度阈值、top-k、token 预算来裁。
- 结果放进哪条消息? 是 system 消息,还是用户消息,还是一段专门的「记忆」字段?放的位置决定了模型把它当「背景知识」还是「对话内容」来对待。
- 没命中、或记忆冲突时怎么办? 检索空手而归,是如实告知模型「没有相关信息」,还是硬塞一个不相关的?多条记忆互相矛盾时,信哪条?
这一篇选一种加载方式讲完整,不追求面面俱到。延续前几话已经建立的「工具调用」套路,最自然的一条链是:
模型发起记忆检索(一次工具调用)
→ 程序按相似度取 top-k、过滤低分候选
→ 把命中条目作为「工具结果」回传给模型
→ 模型据此回答
这正好对应我们 demo 里 [3] 那一段的流程:提问 → 检索 → 组装进 system 消息 → 调用模型。其中「过滤低分候选」这一步,代码里用一个相似度阈值 min_score=0.5 落到了实处——检索总会返回 top-k 条,但分数太低的候选(比如问「去过哪里」时误召回的「博客主题」0.373 分)会被阈值挡掉,视为「无命中」,而不是硬塞给模型:
# 检索时带阈值,低于 0.5 的候选丢弃
hits = sem.retrieve(q, top_k=2, min_score=0.5)
然后才是「组装」。我们把命中条目连同相似度分数一起写进 system 消息,让模型既知道「有这些相关信息」,也看得见「它们来自检索、有分数可循」:
def build_messages(question, hits):
messages = []
if hits:
lines = "\n".join(f"- {text}(相似度 {score:.3f})" for score, text in hits)
messages.append({
"role": "system",
"content": f"你可以参考下面这些关于当前用户、检索自记忆库的信息:\n{lines}",
})
messages.append({"role": "user", "content": question})
return messages
这个「模型发起检索、程序筛选(阈值 + top-k)、按工具结果回传」的方案,是一种合理选择,不是标准答案。实际工程里还有别的做法——比如程序在每次调用前自动检索、把小模型当「筛选器」给候选打分排序。那些都是仍待验证的设计,这里不展开成定论。
回到「放进哪条消息」这个更细的问题,有一个被反复验证的位置效应值得提一句。2023 年的论文《Lost in the Middle》(arXiv 2307.03172)测过:在它实验的模型和设置下,模型对长上下文两头的关注强于中间——把关键信息放在开头或结尾,比埋在中间更容易被用上(该文在多文档问答里观察到 GPT-3.5-Turbo 的准确率从最好位置的约 76.8% 掉到中间位置的约 53.8%)。这个效应不是所有模型的铁律——同一篇论文里,有些模型(如 Claude-1.3 在键值检索上)几乎不受影响,而「把查询放在数据前后」的改写还能显著改善键值检索。
所以对记忆系统的启示,一句话就够:
检索回来之后,别随手一塞。 把最重要的几条放到上下文里显眼、靠前的位置;如果预算紧张,宁可少而精,也不要多而杂。检索只是第一步,装进上下文的方式同样决定模型「用没用上」你找到的记忆。
这一篇的 demo 检索结果只有几条、短文不触发这个效应,所以这里只作提醒——真正做长上下文记忆时,位置问题会和检索问题一起蹦出来。
六、程序记忆:怎么做事,是另一类记忆
四层讲了三层,最后一层只点一句,因为它是独立的一篇。
前三层记的都是信息:你叫什么、有什么偏好、是做什么的。但有一类「记忆」被漏掉了——怎么把事情做对的方法。
「用户最近在戒咖啡」是信息。「处理订单要先查库存、再扣库存、扣完发通知、全程落盘」是方法。方法不存成事实,方法要存成可重复调用的流程——这就是第九话要讲的 Skill。
程序记忆和前三层的真正区别,是记的东西不同:前三层记「关于世界与经历的信息」,程序记忆记「完成任务的方法或行为规则」。别用「事实会过期、方法可复用」来划这条界——方法同样会过时、需要维护,事实也能反复复用。而且程序记忆里的代码,不必全文塞进提示词才生效,它会被检索、被调用。
这里先埋下这个钩子,到此为止。
七、案例:一个真实的 Agent 是怎么落地这四层的
讲完四层和检索,你可能想问:真实世界里有哪个 Agent 真的这么做了?有——Hermes Agent。它不是把「工作/情节/语义/程序」当学术名词挂在嘴上,而是用几个文件和一套检索机制,把同样的分层落到了工程里。拿它当镜子照一遍,能看出两件事:哪几层它做得很实,哪一层它其实「对不齐」。
先说结论,一张表:
| 四层记忆 | Hermes 的落地 | 官方怎么说 |
|---|---|---|
| 工作记忆 | 本次会话的 messages | 会话开始时的「冻结快照」注入系统 prompt,会话中途不变 |
| 情节记忆 | session_search(SQLite + FTS5 全文检索原始消息) | 「所有会话存 SQLite,返回实际消息,无摘要、无截断」 |
| 语义记忆 | MEMORY.md + USER.md | 「有界、经过整理的记忆,跨会话持久保存」 |
| 程序记忆 | (无直接对应物) | 官方最接近的是 SOUL.md,但它是「身份标识」,不是「方法」 |
工作记忆,官方叫「冻结快照」。每次会话开始时,把 MEMORY.md、USER.md 的内容从磁盘读出来、以冻结块的形式渲染进系统 prompt,之后这一整场会话就不再变。官方还特意解释了为什么「冻结」——为了保住 LLM 的前缀缓存、省 token。这跟我们后面 demo 里「把命中条目装进 system 消息再调模型」是同一件事:工作记忆就是「这一次喂给模型的东西」,喂进去就定型了。
语义记忆,官方拆成了两个文件,而且按「记谁的」做了区分:
MEMORY.md(2200 字符上限):Agent 的个人笔记——环境事实、项目约定、踩过的坑;USER.md(1375 字符上限):用户档案——偏好、沟通风格、期望。
这两个文件正是 C07 第四节说的语义记忆:提炼后的、跨会话长期有效的结论。官方文档里「什么该存、什么该跳过」那一段,几乎是语义记忆的判据——「用户喜欢 TypeScript 而非 JS」存下来,「用户问了 Python」这种琐碎信息跳过;「容易重新发现的常识」跳过,「原始数据转储」跳过。而且两个文件都有硬性字符上限,满了要「整合或替换」,这就是语义记忆「归纳沉淀、有界整理」的特征,而不是情节记忆「原样记录」。
到这里,Hermes 的语义记忆还只是「有界文件 + 手动 add/replace/remove」。但官方其实给了一条更强的腿——外部记忆提供商(Memory Provider)插件。Hermes 内置了 8 个:Honcho、OpenViking、Mem0、Hindsight、Holographic、RetainDB、ByteRover、Supermemory。它们与内置记忆并行工作(不是替代),同一时间激活一个,额外提供知识图谱、语义搜索、自动事实提取、跨会话用户建模这些能力。以 Mem0 为例,官方定位是「服务端 LLM 事实提取 + 语义搜索 + 重排序 + 自动去重」,给 Agent 加了 mem0_search(语义搜索)这类工具。
这一层特别值得琢磨,因为它正好印证了 C07 第二节那句话:存储介质是实现选择,不是记忆类型。 接不接 Mem0,语义记忆都还是「语义记忆」——记的东西没变,变的只是「用文件手动整理」换成了「用向量库 + LLM 自动提取」。Hermes 把内置文件当兜底、把外部 provider 当增强,而不是把它们当成两种不同的记忆类型,这个设计分寸拿捏得刚好。
情节记忆,是 Hermes 做得最漂亮、也最值得记住的一环
情节记忆,是 Hermes 做得最漂亮、也最值得记住的一环——它没有塞进 MEMORY.md,而是单独用了一套 session_search 机制:所有会话存进 SQLite(~/.hermes/state.db),用 FTS5 全文搜索,查出来的是原始消息本身——没有 LLM 摘要、没有截断。官方还给了一张对比表,直接点破了语义记忆和情节记忆的分野:
「记忆」用于应始终在上下文中的关键事实;「会话搜索」用于按需回忆,比如「我们上周讨论过 X 吗?」这类问法。
这跟 CoALA 里「从情节记忆里检索具体经历来支持推理」完全对上了:语义记忆是「你一直该知道的」(提炼后的偏好),情节记忆是「某次具体对话里发生过什么」(原始经历),一个常驻、一个按需检索。
程序记忆,这里有个诚实的观察——Hermes 官方没有直接对应物。官方文档里最接近的是 SOUL.md,但官方把它定义为「主要身份标识」,占系统 prompt 第 1 槽位,内容是「语气、沟通风格、直接程度、默认交互方式」——这是「这个 agent 是谁、该怎么说话」,不是 C07 里程序记忆说的「怎么把一件事做对的方法/流程」。官方还特意划了边界:一次性任务说明、文件路径、代码库规范都不该写进 SOUL.md,那些归 AGENTS.md。换句话说,SOUL 是「身份」,不是「技能」;真正对应程序记忆的「怎么做」那部分,Hermes 是交给另一个机制(Skill)去承载的——而那正是我们这个系列第九话要展开的东西。
把这一节照完镜子,能沉淀下一句实用的判断:一个记忆系统成不成立,看它有没有把「提炼后的结论」和「原始的经历」分开存、分开检索。 Hermes 用「两个文件 + 一套会话搜索」做到了这一点,剩下的「程序记忆该放哪」它选择交给 Skill,留给了我们下一篇的钩子。
八、动手实验 + 失败路径
看完案例,跑一遍真的。任务是「让 Agent 跨会话认得你」:先落盘一批记忆;再模拟重启、换新会话;最后走一遍完整闭环——保存记忆 → 新会话提问 → 检索候选 → 组装上下文 → 调用模型 → 检查回答依据。
配套代码在 GYA 仓库(Python)与 GYA-Java 仓库(Java 21):离线回归 10 项不依赖 Embedding / Qdrant / LLM,完整演示要真实 bge-m3 + Qdrant + SQLite。两条命令见仓库该目录的 README。
三段输出的读数已经写在第三、四节了。这里补闭环那一段,也就是有记忆与无记忆的对照,以及检索无命中时的表现。同一个问题「我最近在戒咖啡,聚餐时该注意什么?」问三遍:给了检索到的记忆时(相似度 0.753),模型答「你最近在戒咖啡,聚餐时留意一下含咖啡因的饮品,比如咖啡、浓茶」;不给任何记忆时,它说无法从这句话判断你的饮食偏好,不知道该怎么提醒;问一个记忆里根本没有的问题时,它如实说没有相关信息。
三种情况对应记忆系统的三个基本要求,也是验收标准:
- 无记忆 → 不能编造。 没有检索结果时,模型不该硬编一个用户信息出来。
- 有相关记忆 → 能回答,且可追溯。 答得出「你在戒咖啡」,并且你能顺着 system 消息里的命中条目,指出「这条回答来自哪条记忆、相似度多少」。
- 无相关命中 → 承认信息不足。 检索空手而归时,模型如实说「不知道」,而不是拿不相关的东西凑数。
这一对照,把「记忆系统」真正立住了——差别不在模型,而在于:检索有没有命中、命中的条目有没有被装进上下文、模型有没有据此回答。 读者应该能指着一次真实请求,说清楚「哪条记忆被选中了、装进哪条消息、怎样影响了回答」。
跑不通时,最常见的三个坑:
- Embedding/Qdrant 没起:语义部分会降级到离线 TF-IDF(会打 ⚠️),而且降级覆盖「写入 + 检索」全程,不只是初始化——Embedding 服务即使在初始化后、写入时突然不可达,也会整体回退。真实检索需要 Ollama bge-m3 在线、Qdrant 可访问、
QDRANT_API_KEY正确。离线 TF-IDF 只用于回归验证「检索」这个机制本身,不冒充真实 Embedding 的同义改写能力(「戒掉什么」vs「戒咖啡」这种改写,只有真实 Embedding 才能对上)。 - Qdrant 写入报 400「not a valid point ID」:Qdrant 的 point id 只认无符号整数或 UUID,不认数字字符串。demo 里已用整数。
- Java 版报
SQLITE_IOERR_DELETE:受限沙箱环境(CI/容器化 shell)会拦 SQLite 默认 journal 的 unlink 调用。默认不关闭 journal(保留崩溃安全与回滚能力);只有在受限环境里,才通过环境变量MEMORY_SQLITE_UNSAFE_NO_JOURNAL=1显式关闭,仅限一次性实验。正常终端切勿开启——关闭 journal 意味着崩溃时可能损坏数据库,对讲持久化记忆的示例尤其不合适。
九、记忆让 Agent 记住「你是谁」,工具怎么接进来还差一环
把这条数据流从头到尾捋一遍,就是这一篇的完整答案:
用户在一场对话里说了「最近在戒咖啡」
→ 情节记忆:把这条经历(连同背景)落盘
→ 提炼:沉淀成语义记忆「用户倾向少喝咖啡」
→ 新会话里用户问「今晚聚餐我该注意什么?」
→ 检索:按语义相似度召回「少喝咖啡」这条
→ 组装:把命中条目装进 system 消息
→ 模型据此回答「你最近在戒咖啡,聚餐时留意一下含咖啡因的饮品」
四层记忆各管一段:工作记忆管「现在看什么」,情节记忆管「这次经历过什么」,语义记忆管「跨会话知道什么」,程序记忆管「怎么做」。它们共同的病根只有一个——模型单次调用不会自动记住历史,跨会话的信息需要由应用这层保存、检索、再喂回。 而「喂回」这一步本身,又藏着检索、筛选、组装、位置这些工程细节。
这一篇让 Agent 记住了「你是谁、你要什么」。但记忆要真正跨会话、跨进程地派上用场,还差一环:工具怎么被标准化地接入和调用——查天气、查库存、调别人家的服务,谁来定义接口、谁来负责发现和调度?前几话我们已经能调工具了,但那是「手写一个函数」;下一篇要讲的,是把它变成跨进程、可发现、可组合的标准化协议:MCP。
演示代码:Python 版在 GYA 仓库
c07-memory,Java 21 等价实现在 GYA-Java 仓库c07-memory。按各自 README 的命令跑,不要复制这篇文章里零散的代码块。 参考:Sumers 等, CoALA: Cognitive Architectures for Language Agents(工作/情节/语义/程序四层记忆的分类出处,以及「从情节记忆检索经历支持推理」);Packer 等, MemGPT: Towards LLMs as Operating Systems(分层记忆与 Deep Memory Retrieval:MSC 数据集上 GPT-4 基线 32.1% vs +MemGPT 92.5%,基线仅见五段对话的有损摘要);Liu 等, Lost in the Middle(上下文位置效应——注意该结论限定于论文所测的模型与设置,多文档问答中 GPT-3.5-Turbo 约 76.8%→53.8%,且部分模型不受影响)。Embedding 模型 BAAIbge-m3(1024 维)。检索与闭环对照 Trace 为 2026-09 本机实测(Python 与 Java 双跑,分数一致)。Hermes Agent 官方文档(案例节):持久化记忆(MEMORY.md / USER.md / session_search 与「记忆 vs 会话搜索」对比)、个性与 SOUL.md(SOUL.md 作为「主要身份标识」占系统 prompt 第 1 槽位)、Memory Providers(8 个外部记忆提供商插件,含 Mem0,与内置记忆并行)。

留言
欢迎分享你的想法。评论提交后会在审核通过后显示。