"刚才明明告诉过你了,怎么又忘了?"
用过 AI 智能体做复杂任务的开发者,大多经历过这种哭笑不得的时刻。上午刚和 Agent 对齐了项目背景——"我在做东南亚跨境电商,主营家居类目,目标客户是 25-40 岁女性"——下午让它帮忙生成一份选品方案,它却像一个刚入职的实习生,连你的业务方向都没记住。
问题从来不在于模型不够聪明,而在于绝大多数 Agent 天生没有"长期记忆"。
我的判断很直接:记忆能力,是 Agent 从"能用"到"好用"的分水岭。没有记忆的 Agent 只能完成一次性的问答和指令执行;有了记忆的 Agent,才能真正承担"持续陪跑型"的工作——比如长期项目助理、持续优化的数据分析师、和团队协作的开发助手。这篇文章不做概念空谈,以 Hermes 智能体为例,给出一套可落地的"记忆外挂"方案:从架构设计到核心代码,从接入 Hermes 主流程到生产环境的坑,一次讲全。
读完你会得到:一套能跨会话记住用户偏好和历史结论的记忆模块;一份可以在 Hermes 或其他 Agent 框架里复用的接入范式;以及一堆真实项目中才遇得到的避坑经验。下面进入正题。
1. 为什么说"记忆"是智能体的最大短板
1.1 没有记忆的 Agent 有多尴尬
先把场景铺开。假设你正在用 Hermes 做一个"研发周报助手":每周一它读取 Git 提交记录、聚合需求进展、生成周报。第一周一切正常,因为模型上下文里塞进了当周的提交数据。但到了第五周,问题开始密集出现:
- 你告诉过它团队的项目代号、模块命名规范,它每次都当新知识重新理解,从不沉淀。
- 上周它已经总结过某个需求的阻塞原因,这周它又在重复分析同一条阻塞,浪费 token 还拖延进度。
- 你希望周报保持统一的结构和措辞语气,但它每次生成的风格都在"漂移",你不得不花时间调整 prompt。
这些问题的本质是同一个:Agent 是"无状态"的。每次调用,模型面对的都是一个清空的大脑,唯一的信息来源只有当前这次请求里塞进去的 prompt。用户以为自己在和同一个助手对话,实际上每次都在和一个"失忆的陌生人"交流。
1.2 根因:无状态架构与有限上下文窗口
为什么 Agent 会这么"健忘"?从架构层面看有三个原因。
第一,大模型 API 本身不维护状态。你调用一次模型,它给你一个回复,这次调用结束,上下文就消失了。Agent 框架通常会把多轮对话保存在内存列表里,但进程一重启,什么都没了。这不是框架的缺陷,而是无状态设计带来的天然结果。
第二,上下文窗口是有限的。即便当前主流大模型已经支持几十万 token 的上下文,把历史对话全部塞进去也不现实:成本高、响应慢,而且超出一定长度后,模型对"中间段"信息的注意力会明显衰减。学术上这叫 lost in the middle 现象,你真正想让 Agent 记住的那个关键结论,可能恰好落在它最容易忽略的位置。
第三,历史对话里的信息密度太低。十轮互动式的对话,真正有价值的可能只有一条:"用户偏好使用 PostgreSQL"。把所有原始对话都存下来再全量拼接,是在用存储成本换检索效率,怎么算都不划算。
所以结论很清楚:Agent 记忆不是一个"聊天记录文件",而是一个有写入、有检索、有更新、有遗忘的信息系统。只有把这个系统做对了,Agent 才能展现出稳定的"成长感"。
2. "记忆外挂"到底在解决什么问题
2.1 智能体记忆的三种模型
在设计记忆系统之前,先分清三种记忆类型。这个分类借鉴了认知科学的框架,但在工程上非常实用:
| 记忆类型 | 对应人类概念 | 技术实现载体 | 生命周期 |
|---|---|---|---|
| 工作记忆 | 当前正在做的事 | 上下文窗口 + 最近对话列表 | 单次任务或单轮会话 |
| 情景记忆 | 发生过的事件 | 结构化记录 + 时间戳 | 长期保留,按事件读取 |
| 语义记忆 | 学到的知识和偏好 | 向量化存储 + 语义检索 | 长期保留,反复更新 |
在文章里要做的"记忆外挂",主要解决的是情景记忆和语义记忆的持久化问题。工作记忆是 Agent 框架自带的能力,不需要额外开发;真正的增量在于:让 Agent 在跨会话、跨天、跨周之后,依然记得"你是一个什么样的人、你的项目走到哪一步、你做过什么决定"。
2.2 两个典型的错误方案
在想到"给 Agent 加记忆"时,很多人会走两条弯路。
第一条弯路:把全部聊天记录塞进 prompt。这种做法在小规模 demo 里能跑通,但最多几十轮对话后就会撞上上下文窗口的天花板。而且原始对话里大量内容是寒暄、重复和纠错,直接喂养给模型,反而会稀释真正重要的信息,让模型"记了等于没记"。
第二条弯路:用规则关键词匹配做记忆召回。比如把历史对话存在数据库里,用户提问时用简单关键词过滤出"相关"记录。问题是自然语言表达极其多样,"PostgreSQL"和"PG"是同一个概念,用户今天说"我要用 PG",明天说"数据库选型有什么建议",关键词匹配根本关联不上,召回结果自然惨不忍睹。
这两条弯路指向同一个正确方向:记忆的写入要有筛选和压缩,记忆的读取要靠语义相似度,而不是字面匹配。关键词匹配是幼儿园级别的记忆,语义检索才是真正能用的长期记忆。
2.3 记忆系统的三个设计原则
基于上面的分析,我提炼出三个设计原则,后续代码全部围绕它们展开。
第一个原则:写入时先压缩。不是把原始对话原封不动存进去,而是抽取"用户画像、项目状态、关键决定、待办事项"等结构化信息后再存储。这样做的好处有两个层面:存储空间更小,检索时命中的信息更精炼,不会把一堆寒暄话喂给模型。
第二个原则:读取时做语义检索。用向量化加相似度检索召回相关内容,再拼接到 prompt 中。语义检索的核心优势是"意思相近就能命中",这符合人类记忆的真实工作方式——你想起一段经历,往往不是因为关键词,而是因为"这件事和当前场景很像"。
第三个原则:更新时要会"遗忘"。记忆不能只增不减,要支持覆盖旧结论、合并重复信息,否则系统会越来越臃肿,检索质量会持续下降。好的记忆系统应该像一个人的长期记忆一样,不断强化重要信息、淡化无关信息。
3. 记忆系统总体架构设计
3.1 架构分层
落地一个记忆系统,需要把它拆成四个清晰的层次,每一层的边界必须干净。
| 层次 | 模块 | 职责 | 关键技术 |
|---|---|---|---|
| 接入层 | MemoryWrapper | 对接 Hermes 主流程,在对话前读记忆、对话后写记忆 | Python 包装器 / 钩子 |
| 检索层 | Retriever | 把用户输入转成向量,从记忆库召回相关内容 | Embedding + 向量相似度 |
| 存储层 | VectorStore | 持久化记忆向量和元数据 | ChromaDB / FAISS / Milvus |
| 压缩层 | Compactor | 定期把零散记忆汇总成更高层的摘要 | LLM 对话摘要 |
四个层次之间是单向依赖:接入层调用检索层和压缩层,检索层依赖存储层,存储层不感知上层的业务逻辑。这样设计最大的好处是每一层都能独立替换——今天用 ChromaDB,明天换 Milvus,只需要改存储层一个文件;今天用 DeepSeek 做压缩,明天换本地模型,压缩层接口不变。架构的灵活度来自接口的稳定,而不是代码的复杂。
3.2 一次带记忆的对话,数据是怎么流动的
以 Hermes 处理用户提问为例,完整流程是这样的:
- 用户输入进入 MemoryWrapper;
- Wrapper 调用 Retriever,把用户输入编码成向量,在记忆库中做 top-k 召回;
- 召回结果与最近几轮对话一起拼装成增强后的 prompt;
- Hermes 调用大模型生成回答;
- 回答完成后,Wrapper 把"用户提问 + 助手回答"写入临时缓冲;
- 当缓冲达到一定长度或满足触发条件时,调用 Compactor 压缩成结构化摘要,再写入存储层。
这个流程里有几个关键决策点:召回多少条记忆、最近对话保留几轮、什么时候触发压缩。这些参数没有标准答案,需要结合实际任务调优。下面给出合理的默认值,并说明每个参数的含义,方便你在自己的项目里做调整。
4. 环境准备与前置条件
4.1 基础环境
本文示例使用 Python 3.10 及以上版本,操作系统不限(Windows / macOS / Linux 均可)。示例代码在 Windows 和 Linux 上都能直接运行,如果你使用的是 Windows,建议在命令行中确认 Python 已加入 PATH 环境变量。
python --version如果输出Python 3.10.x或更高版本,环境就满足要求。如果你使用的是 Conda,可以创建一个干净的虚拟环境再继续,避免和系统 Python 的包产生冲突。
4.2 安装依赖
pip install chromadb sentence-transformers两个核心依赖的用途要搞清楚:
chromadb:嵌入式向量数据库,负责记忆的持久化和语义检索,开箱即用,不需要单独启动服务。它是这个方案里最省心的组件,数据默认存在本地目录,迁移和备份都只需要复制一个文件夹。sentence-transformers:用来把文本转换成向量,我们使用国产开源模型BAAI/bge-small-zh-v1.5,它在中文语义匹配上表现稳定,模型体积小(约 100MB),普通 CPU 环境也能流畅运行。
如果你使用的是 Mac 或 Linux,且机器有 NVIDIA GPU,可以安装 GPU 版 PyTorch 加速 embedding 计算;没有 GPU 也不影响功能演示,只是向量化速度会慢一些,对个人项目完全够用。
4.3 向量数据库选型对比
很多读者会问:为什么不用传统数据库存记忆?答案是传统数据库擅长等值查询和范围查询,不擅长"语义相似"查询。比如你想找到历史上所有"关于数据库选型"的讨论,用 SQL 的LIKE只能匹配字面关键词,而向量数据库能根据语义找出"数据库选型""DBMS 对比""存储引擎怎么选"这些表达不同但意思相近的记录。这是量级的差异。
| 方案 | 部署复杂度 | 适合场景 | 说明 |
|---|---|---|---|
| ChromaDB | 低(嵌入式) | 个人项目、中小型应用 | 本文使用,零配置 |
| FAISS | 低(需自行管理索引) | 对查询性能有极致要求 | Meta 开源,纯向量检索 |
| Milvus | 高(需部署服务) | 团队级、海量记忆数据 | 分布式,运维成本高 |
| 传统数据库 + 向量插件 | 中 | 已有数据库体系 | 如 PostgreSQL + pgvector |
对大多数 Hermes 用户来说,ChromaDB 是最合适的起点:一条命令装好,数据落在本地文件,后续迁移也简单。如果未来数据量增长到百万条以上,再考虑迁移到 Milvus 也不迟,因为存储层的接口设计已经为替换留好了空间。
5. 核心代码实现:给 Hermes 装上记忆外挂
现在进入正题。下面的代码都基于一个清晰的目录结构,建议你按同样的方式组织项目:
hermes-memory/ ├── memory_store.py # 存储层 ├── memory_retriever.py # 检索层 ├── memory_compactor.py # 压缩层 ├── memory_wrapper.py # 接入层 ├── requirements.txt # 依赖清单 └── test_dialogue.py # 验证脚本5.1 存储层:基于 ChromaDB 的长期记忆仓库
memory_store.py负责最底层的"写入和查询"。它对外提供两个方法:一个是写入一条记忆,另一个是按语义检索记忆。整个类的设计目标是"上层不关心向量怎么算、数据存哪里,只需要调用两个方法"。
# 文件路径:hermes-memory/memory_store.py import uuid from datetime import datetime import chromadb from sentence_transformers import SentenceTransformer class MemoryStore: """向量记忆存储:负责把文本记忆向量化并持久化到 ChromaDB。""" def __init__(self, persist_dir: str = "./hermes_memory"): # PersistentClient 会把数据落盘到本地目录,进程重启后记忆仍在 self.client = chromadb.PersistentClient(path=persist_dir) self.collection = self.client.get_or_create_collection( name="hermes_long_term_memory", metadata={"hnsw:space": "cosine"}, # 使用余弦距离衡量语义相似度 ) # BGE 中文向量模型,首次运行会自动从 HuggingFace 下载 self.embedder = SentenceTransformer("BAAI/bge-small-zh-v1.5") def add_memory(self, text: str, memory_type: str = "dialogue", metadata: dict | None = None) -> str: """写入一条记忆,返回记忆 ID。""" memory_id = str(uuid.uuid4()) embedding = self.embedder.encode(text).tolist() meta = { "memory_type": memory_type, "timestamp": datetime.now().isoformat(), } if metadata: meta.update(metadata) self.collection.add( ids=[memory_id], embeddings=[embedding], documents=[text], metadatas=[meta], ) return memory_id def search(self, query: str, top_k: int = 5) -> list[dict]: """按语义相似度检索记忆,返回最相关的 top_k 条。""" query_embedding = self.embedder.encode(query).tolist() results = self.collection.query( query_embeddings=[query_embedding], n_results=top_k, include=["documents", "metadatas", "distances"], ) memories = [] if results["documents"] and results["documents"][0]: for doc, meta, dist in zip( results["documents"][0], results["metadatas"][0], results["distances"][0], ): memories.append({ "text": doc, "metadata": meta, "distance": dist, }) return memories这段代码有三个关键点值得单独说明。
第一,PersistentClient是 ChromaDB 的持久化模式,数据会写入./hermes_memory目录。这个目录就是你的"记忆文件",备份、迁移都只需要复制它。进程重启后,新创建的MemoryStore实例能读到之前写入的所有记忆,这正是跨会话记忆的基础。
第二,hnsw:space设置为cosine,意味着我们用余弦距离衡量两条记忆的语义相关度。余弦距离关注的是向量方向的相似性,而不是绝对距离,这在文本向量场景下比欧氏距离更稳定。检索结果中的distance字段越小,代表与查询越相关。
第三,SentenceTransformer("BAAI/bge-small-zh-v1.5")会在第一次运行时从 HuggingFace 下载模型。如果你的网络无法访问 HuggingFace,可以提前把模型下载后放到本地目录,用本地路径初始化即可,代码其余部分完全不用改。这一点在第七章会详细说明。
5.2 检索层:把用户的问题变成"记忆探针"
有了存储层,下一步是写检索逻辑。memory_retriever.py做的事情看起来很简单——调store.search()——但实际上它还承担了一个重要职责:把召回结果整理成适合拼进 prompt 的文本块。这个"格式化"的步骤容易被忽略,但它在实际使用中很关键,因为大模型对 prompt 结构的敏感度远比我们想象的高。
# 文件路径:hermes-memory/memory_retriever.py from memory_store import MemoryStore class MemoryRetriever: """检索层:负责从长期记忆中召回相关内容,并加工成 prompt 片段。""" def __init__(self, store: MemoryStore, top_k: int = 5): self.store = store self.top_k = top_k def retrieve_context(self, user_input: str) -> str: """召回与当前问题最相关的记忆,返回可直接拼进 prompt 的文本。""" memories = self.store.search(user_input, top_k=self.top_k) if not memories: return "" lines = [] for idx, mem in enumerate(memories, 1): # distance 越小代表越相关 lines.append(f"{idx}. [{mem['metadata'].get('memory_type', 'dialogue')}] {mem['text']}") return "\n".join(lines) def retrieve_as_list(self, user_input: str) -> list[dict]: """供程序内部使用的检索接口,返回原始结构化数据。""" return self.store.search(user_input, top_k=self.top_k)这里我把"检索"和"格式化"拆开了:retrieve_context给 prompt 用,retrieve_as_list给程序逻辑用。实际项目中你可能会需要按时间过滤、按记忆类型过滤,这些扩展都可以加在MemoryRetriever里,不需要改动存储层。比如用户可以限定只检索"user_profile"类型的记忆,这在多类型记忆混合存储时非常实用。
5.3 压缩层:让记忆"越存越精"
存储层和检索层解决了"怎么存、怎么取"的问题,但还有一个隐患:如果每轮对话都往记忆库里写一条,几天之后库里会堆满大量低价值的碎片记录——"用户早上打了个招呼""用户问了天气"都成了记忆,真正重要的决策反而被噪声淹没。压缩层负责把零散记忆提炼成更精炼的高层摘要。
# 文件路径:hermes-memory/memory_compactor.py import json from datetime import datetime class MemoryCompactor: """压缩层:把多条零散记忆汇总成一条结构化摘要。""" def __init__(self, llm_chat_func, max_batch: int = 10): """ llm_chat_func: 一个接受 prompt 字符串并返回回答字符串的函数。 你可以对接任意大模型 API,例如 DeepSeek、通义千问等。 """ self.llm_chat = llm_chat_func self.max_batch = max_batch def compact(self, memory_texts: list[str]) -> dict: """输入一组记忆文本,输出压缩后的结构化摘要。""" if not memory_texts: return {} joined = "\n".join(f"- {t}" for t in memory_texts) prompt = f"""请阅读以下 AI 助手与用户的对话记忆碎片,提取并输出 JSON 格式的结构化摘要。 要求: 1. 提取用户的核心偏好、项目背景、关键决策、待办事项。 2. 去除寒暄、重复、临时性内容。 3. 输出格式固定为: {{"user_profile": "...", "project_status": "...", "key_decisions": ["...", "..."], "todos": ["...", "..."]}} 对话记忆碎片: {joined} """ raw = self.llm_chat(prompt) try: return json.loads(raw) except json.JSONDecodeError: # 如果大模型没有严格输出 JSON,退化为原文返回 return {"user_profile": "", "project_status": joined, "key_decisions": [], "todos": []}这里有一个容易踩的坑:大模型并不总是严格按 JSON 格式输出,有时会在 JSON 前后加解释性文字,有时会用中文引号或单引号。代码里用json.loads直接解析,如果失败就退化为把原文塞进project_status字段。这种"降级策略"保证了即使压缩失败,信息也不会丢失,只是结构没那么优雅而已。生产环境中可以在这个降级分支里加日志告警,提醒你调整压缩 prompt 的质量。
5.4 接入层:把记忆模块挂到 Hermes 主流程上
前三层的代码都不感知 Hermes 的存在,它们只是通用的记忆基础设施。接入层才是真正和 Hermes 交互的地方。HermesMemoryWrapper把自己包装成 Hermes 的一个"外挂":在对话前增强上下文,在对话后沉淀记忆。
# 文件路径:hermes-memory/memory_wrapper.py from datetime import datetime from typing import Any from memory_compactor import MemoryCompactor from memory_retriever import MemoryRetriever from memory_store import MemoryStore class HermesMemoryWrapper: """把记忆模块包装成 Hermes 的外挂层。 在 Hermes 处理用户请求之前先检索记忆、增强 prompt; 在 Hermes 返回回答之后把这轮对话写入记忆缓冲。 """ def __init__(self, hermes_agent: Any, store: MemoryStore, retriever: MemoryRetriever, compactor: MemoryCompactor, recent_window: int = 6, compact_threshold: int = 20): """ hermes_agent: Hermes 智能体实例,需要提供 chat(text) 方法。 recent_window: 直接拼进 prompt 的最近对话轮数。 compact_threshold: 缓冲记忆达到多少条后触发压缩。 """ self.agent = hermes_agent self.store = store self.retriever = retriever self.compactor = compactor self.recent_window = recent_window self.compact_threshold = compact_threshold # 短期记忆缓冲,存放尚未压缩的最近对话 self._dialogue_buffer: list[str] = [] # 最近对话历史(原始文本),用于拼接 prompt self._recent_history: list[dict] = [] def chat(self, user_input: str) -> str: """带记忆的对话入口,替代直接调用 hermes_agent.chat()。""" # 1. 从长期记忆中检索相关内容 memory_context = self.retriever.retrieve_context(user_input) # 2. 构造最近对话片段 recent_lines = [] for item in self._recent_history[-self.recent_window:]: role = "用户" if item["role"] == "user" else "助手" recent_lines.append(f"{role}: {item['content']}") recent_block = "\n".join(recent_lines) # 3. 组装增强后的 prompt enhanced_input = f"""[长期记忆] {memory_context if memory_context else '(暂无相关记忆)'} [最近对话] {recent_block if recent_block else '(暂无最近对话)'} [当前问题] {user_input} """ # 4. 调用 Hermes 原始能力 response = self.agent.chat(enhanced_input) # 5. 记录到短期缓冲 self._recent_history.append({"role": "user", "content": user_input}) self._recent_history.append({"role": "assistant", "content": response}) self._dialogue_buffer.append(f"用户:{user_input}") self._dialogue_buffer.append(f"助手:{response}") # 6. 缓冲达到阈值时触发压缩,写入长期记忆 if len(self._dialogue_buffer) >= self.compact_threshold: self._compact_dialogue_buffer() return response def _compact_dialogue_buffer(self): """压缩缓冲对话为结构化记忆,写入存储层。""" summary = self.compactor.compact(self._dialogue_buffer) if summary.get("user_profile"): self.store.add_memory( f"用户画像:{summary['user_profile']}", memory_type="user_profile", metadata={"source": "compactor"}, ) if summary.get("project_status"): self.store.add_memory( f"项目状态:{summary['project_status']}", memory_type="project_status", metadata={"source": "compactor"}, ) for decision in summary.get("key_decisions", []): self.store.add_memory( f"关键决策:{decision}", memory_type="key_decision", metadata={"source": "compactor"}, ) for todo in summary.get("todos", []): self.store.add_memory( f"待办事项:{todo}", memory_type="todo", metadata={"source": "compactor"}, ) # 压缩完成后清空缓冲 self._dialogue_buffer.clear()这段代码是整个"记忆外挂"的核心。它做的事情本质上是把一次普通的agent.chat()调用,包装成"检索记忆 -> 增强上下文 -> 执行对话 -> 沉淀新记忆"的完整闭环。你不需要改动 Hermes 源码,只需要在创建智能体时,把原来的agent.chat()替换成wrapper.chat(),记忆能力就自动生效了。
一个值得注意的设计细节:我同时维护了_recent_history和_dialogue_buffer两个数据。前者保留原始文本,用来拼进 prompt 让模型看到最近的对话上下文;后者是待压缩的素材,达到阈值后交给压缩层处理。这样设计的原因是防止重复存储——原始对话不应该直接写进长期记忆,它们只是压缩的输入。长期记忆库里存的应该是提炼后的结论,而不是流水账。
6. 运行与验证
6.1 一个最小的验证脚本
为了让上面的代码可以直接跑起来,我写了一个测试脚本。这里用一个假的hermes_agent来模拟 Hermes 的行为——它只是简单地把输入原样返回,重点是验证记忆模块的检索和压缩逻辑是否正常工作。真实接入时,把这个假 Agent 替换成你的真实 Hermes 实例即可。
# 文件路径:hermes-memory/test_dialogue.py from memory_compactor import MemoryCompactor from memory_retriever import MemoryRetriever from memory_store import MemoryStore from memory_wrapper import HermesMemoryWrapper class FakeHermesAgent: """测试用假 Agent:真实项目中替换为 Hermes 实例。""" def chat(self, text: str) -> str: # 简单模拟:返回输入的最后一行作为"回答" lines = text.strip().split("\n") return f"[Hermes] 收到问题:{lines[-1]}" def fake_llm(prompt: str) -> str: """测试用假 LLM:真实项目中替换为 DeepSeek / 通义千问等 API 调用。""" return '{"user_profile": "跨境电商从业者,热爱效率工具", "project_status": "研发周报助手项目进行中", "key_decisions": ["使用 PostgreSQL 作为数据库"], "todos": ["调研记忆压缩策略"]}' def run_test(): store = MemoryStore(persist_dir="./hermes_memory_test") retriever = MemoryRetriever(store, top_k=3) compactor = MemoryCompactor(fake_llm) agent = FakeHermesAgent() wrapper = HermesMemoryWrapper( hermes_agent=agent, store=store, retriever=retriever, compactor=compactor, recent_window=2, compact_threshold=4, # 测试时调低阈值,快速触发压缩 ) # 第一轮:模拟用户告诉 Agent 自己的偏好 wrapper.chat("我是做跨境电商的,主攻东南亚市场") wrapper.chat("我决定用 PostgreSQL 作为数据库") # 第二轮:换一个进程重新加载 memory store,模拟"跨会话" store2 = MemoryStore(persist_dir="./hermes_memory_test") retriever2 = MemoryRetriever(store2, top_k=3) print("=== 第一次对话结束,重新加载记忆库 ===") print("检索'数据库选型'的结果:") for mem in retriever2.retrieve_as_list("数据库选型"): print(f" - {mem['text']}") if __name__ == "__main__": run_test()运行方式:
cd hermes-memory python test_dialogue.py6.2 预期输出与判断标准
正常情况下,你会看到类似这样的输出:
=== 第一次对话结束,重新加载记忆库 === 检索'数据库选型'的结果: - 用户画像:跨境电商从业者,热爱效率工具 - 项目状态:研发周报助手项目进行中 - 关键决策:使用 PostgreSQL 作为数据库判断标准有两条,缺一不可。
第一条:输入"数据库选型"这个与原文不完全一致的问题,能检索到"使用 PostgreSQL"这条历史决策,说明语义检索生效了。如果这一步失败,问题通常出在 embedding 模型没有正确加载,或者 ChromaDB 的集合配置有误。
第二条:第二次加载时记忆库数据仍在,说明持久化生效。也就是说,即使模拟"进程重启",记忆也没有丢失。如果这一步失败,检查你是否有多个persist_dir路径,或者是否误用了 ChromaDB 的内存模式。
两个条件都满足,说明你的记忆外挂已经能实现跨会话的信息保持。真实接入 Hermes 时,只需要把FakeHermesAgent替换成真正的 Hermes 智能体实例,并保证它暴露chat(text)方法即可。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 第一次运行下载模型失败 | HuggingFace 网络不可达 | 查看模型下载日志 | 提前下载模型到本地,使用SentenceTransformer("本地路径") |
| 检索结果与问题无关 | embedding 模型与领域不匹配 | 打印召回日志,查看 distance 分数 | 换更大的中文向量模型,或对记忆文本做预处理 |
| 记忆库文件越来越大 | 写入过多碎片记忆,压缩未触发 | 检查_dialogue_buffer长度和压缩逻辑 | 调低compact_threshold,或增加定期清理任务 |
| 压缩层返回的 JSON 解析失败 | 大模型没有严格按 JSON 格式输出 | 打印llm_chat的原始返回值 | 在 prompt 中加 few-shot 示例,或使用 JSON Mode |
| 接入真实 Hermes 后原有功能异常 | wrapper 修改了 prompt 结构 | 对比增强前后的 prompt 差异 | 先关闭长期记忆,确认原始功能正常后再逐步开启 |
| 多用户共用一套记忆库 | 没有区分用户维度 | 检查 metadata 中是否包含 user_id | 在add_memory时写入用户标识,检索时按 metadata 过滤 |
最值得强调的坑是第一个。由于向量模型需要从网上下载,很多开发者在公司内网环境中会在第一步就卡住。解决办法是预下载模型。你可以在可联网的机器上下载模型目录,复制到目标机器,然后用本地路径初始化SentenceTransformer:
# 本地路径初始化示例 self.embedder = SentenceTransformer("./models/bge-small-zh-v1.5")第二个高频问题也值得展开:检索质量差。很多人遇到"检索出来的记忆和当前问题完全无关"时,第一反应是换更大的向量模型,但实际上更常见的原因是写入时的文本质量太低——对话碎片太短、没有上下文、噪声太多。建议在写入前先做一轮清洗,把"用户问了什么 + 助手答了什么"合并成一句完整的语义单元,再向量化存储。这个改动往往比换模型更有效。
8. 最佳实践与工程建议
8.1 记忆的更新策略:既要记住,也要会忘
真实项目的记忆不是无限累积的。一个有用的记忆系统必须支持"更新"和"遗忘"。如果只写不删,系统会在两周内被低价值信息淹没,检索质量急剧下降。
具体建议有四点。第一,同一主题的新结论出现时,需要覆盖旧结论。比如用户先说"用 MySQL",两周后改口"改用 PostgreSQL",旧的决策记忆如果不更新,检索出来反而会误导 Agent 给出过期建议。第二,给每条记忆加source_time字段,检索时按时间做衰减加权——越新的记忆权重越高。第三,定期执行"记忆整理"任务,每周触发一次全量压缩,把零散碎片合并为高层摘要。第四,设置记忆过期策略,超过 90 天且从未被检索的低价值记忆,直接清理。
8.2 权限与隐私边界
记忆系统保存的是用户的对话内容,属于敏感数据。接入生产环境时必须做到三点,缺一不可。
最小权限原则:记忆库目录的读写权限只授予运行 Agent 的服务账户,不要让普通用户直接访问底层数据文件。数据隔离:多租户场景下,每条记忆的 metadata 必须包含user_id,检索时强制按这个字段过滤,防止 A 用户的记忆被 B 用户查询到。这一点实现起来很简单,但漏掉的后果很严重。可删除性:提供delete_memory(memory_id)和clear_user_memory(user_id)接口,当用户要求删除数据时,能够完整清除关联记忆。没有这个能力,产品合规层面会非常被动。
8.3 成本控制与性能优化
记忆系统最大的成本来自两处:向量化计算和压缩时的 LLM 调用。这两块如果不做控制,月度成本会随使用量线性增长。
向量化方面,BGE small 模型在 CPU 上对一条短文本的编码耗时约几十毫秒,对个人项目来说完全够用;如果对话量很大,可以把 embedding 计算放到独立的异步任务中,避免阻塞对话主链路。压缩方面,不要每轮对话都调用 LLM 压缩。建议在对话缓冲达到 20 到 50 条时才触发一次;压缩时只对新增部分做增量摘要,避免每次都全量重算。如果接入的是商业化大模型 API,这一步优化能把成本降低一个量级。
8.4 如何评估记忆系统好不好
最后聊一个很容易被忽略的问题:怎么知道记忆系统真的有效?很多人的验证方式停留在"好像记住了",这是不够的。建议建立两组评测。
第一组是召回质量测试。准备 20 到 50 个问题,每个问题对应一条已知的记忆,跑一遍检索,统计"正确的记忆是否出现在 top-5 结果中"。这个指标叫 Recall@5,能直观反映检索层的好坏。如果召回率低于 80%,先检查写入质量,再考虑换模型。第二组是端到端对话测试。把同样的问题分别发给"无记忆的 Hermes"和"带记忆外挂的 Hermes",对比回答中是否准确引用了历史信息。建议把这两组回答存成样例集,在每次修改记忆模块后回放一遍,防止回归。这两组评测加在一起,就是记忆系统的"单元测试"和"集成测试"。
9. 小结与后续学习方向
这篇文章的核心就一句话:给 Hermes 这样的智能体装记忆,本质不是存储聊天记录,而是建立一套"写入时压缩、读取时语义检索、定期更新与遗忘"的记忆系统。文中给出的四个层次——存储层、检索层、压缩层、接入层——可以原样移植到任何以对话为核心的 AI 应用里,不限于 Hermes。
建议你下一步按这样的顺序实践:先跑通第 5 节的完整代码,确认语义检索和跨会话持久化生效;然后把FakeHermesAgent替换成你的真实智能体,调整top_k、recent_window、compact_threshold三个参数;最后再考虑接入 Milvus 或 pgvector 做大规模部署。
值得继续深入的方向有三个:用图数据库存记忆之间的关联关系,让 Agent 不仅"记得"还能"推理";实现基于时间衰减的记忆评分机制,让重要记忆自然浮上来、无关记忆沉下去;以及把记忆模块改造成独立服务,让多个 Agent 共享同一套记忆基础设施,实现跨 Agent 的知识复用。希望这篇文章能帮你把 Agent 从"金鱼"养成"大象"——记忆的积累,才是智能体进化的第一步。