给 AI Agent 加记忆:从零到可用的完整路径
如果你的 Agent 每次对话都要用户重复交代背景,问题不在提示词,在于它没有记忆层。本文给出四种接入形态的完整代码、作用域怎么划、检索参数怎么调,以及上线后会遇到的那几个坑。所有代码在 mnemosyne-os 8.1.0.post1 上实测可运行。
先看结论:如果你只要「十分钟内跑起来」,跳到第一节装完直接复制第二段代码即可。如果你在选技术方案,直接看第四节(四种接入形态对比)。如果你已经接了但效果不好,看第七节(故障排查表)。
一、安装:零依赖意味着什么
pip install mnemosyne-os
实测(8.1.0.post1):核心包的 install_requires 是空列表。安装过程不拉取任何第三方包,不需要编译器,不需要 C 扩展,不需要预先装数据库。可选扩展才需要额外包:
| 扩展 | 装什么 | 什么时候需要 |
|---|---|---|
numpy | numpy | 向量打分想从纯 Python 换成矩阵运算时(性能优化,非正确性依赖) |
transformers | transformers | 想用 HuggingFace 系列嵌入模型时 |
tiktoken | tiktoken | 想用 OpenAI 的 tokenizer 精确计数时 |
这个特性在真实环境里很实用:气隙网络、受限容器、没有编译工具链的 Windows 机器、CI 里不想为装一个记忆库拉 2 GB 依赖——这些场景下「零依赖」不是营销词,是能不能装上的区别。
二、第一条记忆:写入与召回
from mnemosyne import Memory
from mnemosyne.api.config import MemoryConfig
import tempfile
# 指定记忆库位置(生产环境用固定路径)
cfg = MemoryConfig(brain_dir=tempfile.mkdtemp(), namespace="default")
m = Memory(cfg)
# 写入:一句话,指定作用域
r = m.add("Alice 偏好深色模式,用 vim 键位,时区 Asia/Shanghai",
user_id="alice")
print(r)
# {"results": [{"id": "00443a84648d1344", "memory": "Alice 偏好深色模式…",
# "event": "ADD", "categories": ["miscellaneous"],
# "confidence": 0.85}]}
# 召回
hits = m.search("alice 喜欢什么主题", filters={"user_id": "alice"}, top_k=5)
for h in hits["results"]:
print(h["score"], h["memory"])
关键点一:add() 返回的是 results 数组,不是单个对象。因为一次写入可能被抽取成多条记忆——实测中,一段 2400 字符、含 5 句话的文本,被切成 5 条独立记忆,每句一条。这是刻意的设计:记忆的检索粒度是「一条事实」,不是「一篇文章」。
关键点二:search() 的过滤条件必须写在 filters={} 里,不能作为顶层参数传 user_id。旧写法会抛 [VALIDATION_007] 错误——这是 8.x 的显式破坏性变更,目的是强制作用域显式化。
关键点三:默认配置下不需要任何 API Key,也不下载模型。用的是内置 128 维确定性嵌入 + 规则抽取器。这意味着 add() 在没有配置 LLM 的情况下也能工作——不是降级,是设计如此。
三、作用域:四个维度怎么划
作用域决定「谁看到谁的记忆」,划错了要么串数据,要么查不到。
| 维度 | 含义 | 典型取值 | 用错会怎样 |
|---|---|---|---|
user_id | 记忆属于谁 | 用户 ID、邮箱哈希 | 不传 → 所有人共享一个库,A 能看到 B 的偏好 |
agent_id | 哪个智能体写的 | support-bot、coder | 多 Agent 协作时无法区分来源,排查困难 |
run_id | 哪一次任务/会话 | 任务 UUID | 无法按任务回溯,也无法清理单次任务的中间产物 |
app_id | 哪个应用 | web、cli、mobile | 多端共用时统计混淆 |
Mnemosyne OS 的隔离是物理隔离:每个作用域对应一个独立的 SQLite 文件,而不是同一张表加过滤条件。区别在失败模式——逻辑隔离一旦某处漏写 WHERE,就是跨用户泄露;物理隔离下,查错库需要显式传错参数,且由文件系统兜底。
实践建议:user_id 必传。agent_id 在多 Agent 场景必传。run_id 用于短期任务记忆,配合 TTL 自动过期。app_id 按需。
四、四种接入形态,怎么选
| 形态 | 适合场景 | 需要写代码吗 | 跨语言 |
|---|---|---|---|
| Python 库 | 自己的 Python 服务、脚本、Notebook | 要 | 否 |
| CLI | 运维排查、批量导入、shell 脚本 | 不用 | 是(任何 shell) |
| REST API | 非 Python 技术栈、微服务 | 要(HTTP 调用) | 是 |
| MCP | Claude Desktop / Cursor 等已支持 MCP 的客户端 | 不用 | 是 |
形态 A:Python 库
见第二节。适合把记忆能力嵌进自己的进程,零网络开销。
形态 B:命令行
# 初始化记忆库
mnemosyne --dir ./mem init
# 写入与召回
mnemosyne --dir ./mem retain --content "Apple Inc. was founded in 1976"
mnemosyne --dir ./mem recall "Apple" --k 5
# 完整性校验(检测篡改并定位到具体记录)
mnemosyne --dir ./mem verify-integrity --json
# 图谱遍历
mnemosyne --dir ./mem graph-query "Steve Jobs" --depth 2 --json
形态 C:REST API
# 写入
curl -X POST https://ai-memory.net/v1/memories -H "Authorization: Bearer mn-your-key" -H "Content-Type: application/json" -d '{"content":"Alice 偏好深色模式","user_id":"alice"}'
# 召回
curl "https://ai-memory.net/v1/recall?query=alice&top_k=5" -H "Authorization: Bearer mn-your-key"
自托管时把域名换成自己的地址;使用托管版则在 /auth/keys 领取密钥。API 有 v1 / v2 / v3 三个版本,v1 覆盖面最广。
形态 D:MCP(推荐给已支持 MCP 的客户端)
# stdio 方式(本地进程)
export MNEMOSYNE_MCP_TOKEN="your-secret-token"
python -m mnemosyne.webui.mcp_server --brain-dir ./mem --namespace default
在 Claude Desktop 的配置文件里加:
{
"mcpServers": {
"mnemosyne": {
"command": "python",
"args": ["-m", "mnemosyne.webui.mcp_server",
"--brain-dir", "./mem", "--namespace", "default"],
"env": { "MNEMOSYNE_MCP_TOKEN": "your-secret-token" }
}
}
}
MCP 服务器暴露 52 个工具(41 个原生 + 11 个兼容 Mem0 命名的别名)。如果你已经在用 Mem0 的 MCP 工具,把客户端指向 Mnemosyne 即可,工具名一致,提示词不用改。
五、检索参数:三个真正需要调的
| 参数 | 默认 | 调它的时机 |
|---|---|---|
top_k | 20 | 注入上下文时通常 3–5 足够。太大等于用噪声淹没模型 |
threshold | 0.1 | 相关性下限。调高 = 宁缺毋滥;调低 = 宁多勿漏 |
rerank | false | 候选多且要求高精度时开,代价是多一次模型调用 |
关于 threshold 的实话:很多实现「永远填满 top_k」,导致最高分恒为 1.0,分数失去绝对意义。校准过的下限能让「0.85」真的意味着「比较相关」,而不是「这批里最相关的那个」。代价是有时候返回空——而返回空是特性,不是 bug:与其把不相关的内容塞进上下文污染推理,不如什么都不给。
六、把记忆接进对话循环
记忆不是「存了就会用」。要让它生效,需要在对话循环里加两个钩子:
def chat(user_input, user_id):
# 钩子 1:召回——在构造提示词之前
hits = m.search(user_input, filters={"user_id": user_id}, top_k=5)
context = "\n".join(h["memory"] for h in hits["results"])
prompt = (
"你是一个有记忆的助手。\n"
"已知关于用户的信息:\n" + context + "\n\n"
"用户说:" + user_input
)
reply = llm(prompt)
# 钩子 2:写入——在回复之后(异步,别阻塞用户)
m.add(f"用户说:{user_input}\n助手答:{reply}", user_id=user_id)
return reply
两个容易做错的地方:
- 写入不要同步阻塞。写一条记忆实测中位 11.91 ms,同步写会让每次回复都慢这么多。可以用异步写入(
async_mode=True)或放到后台队列。 - 不要每轮都写。闲聊「你好」「谢谢」不值得存。判断标准:这条信息在一周后还有用吗?没有就不写。存太多会让检索信噪比下降。
七、故障排查表
| 现象 | 原因 | 修法 |
|---|---|---|
[VALIDATION_007] get_all() no longer accepts user_id as top-level | 8.x 破坏性变更:作用域必须放进 filters | 改成 get_all(filters={"user_id": "x"}) |
| 召回总是返回空 | 作用域不匹配(写入用 A,查询用 B),或 threshold 过高 | 先 get_all(filters=...) 确认数据在,再放宽 threshold |
| 召回结果和查询无关 | 默认用内置 128 维嵌入,语义区分度有限 | 配置真实嵌入模型(如 BGE-M3),或加关键词检索兜底 |
| 写入返回多条结果 | 文本被按句切分,这是设计 | 属正常。需要整体时用 get_all 按批次取 |
| 数据在重启后消失 | brain_dir 指向了临时目录 | 改成固定路径,并确认进程对该目录有写权限 |
| 容器里写入很慢 | brain_dir 落在网络卷或 overlay 层 | 挂载本地卷;SQLite 对网络文件系统不友好 |
八、上线前检查清单
- 作用域必传:
user_id不允许为空。空作用域 = 所有人共享一个库。 - 记忆库在持久卷上:不在容器根文件系统,否则每次部署都丢。
- 定期跑完整性校验:
verify_integrity()实测 4.5 ms(120 条),可以放进每日任务。 - 日志不记记忆正文:记忆内容属用户隐私,日志只记 method / 工具名 / 作用域。
- 准备删除路径:用户要求删除时,要能真的删干净。
delete_all+ 级联删除派生结论。 - 备份:整个记忆库就是一个目录,直接打包即可,不需要数据库导出工具。
九、提示词怎么写才让记忆生效
接了记忆但效果不好,十次有八次是提示词的问题,不是检索的问题。三个常见错误:
错误一:把记忆当文档塞进提示词
# 差:一大段无结构文本
已知信息:Alice 喜欢深色模式。Alice 用 vim。Alice 在上海。Bob 是 PM…
# 好:分组 + 标注来源时间
【用户画像】
- 偏好:深色模式、vim 键位
- 时区:Asia/Shanghai
- 最近更新:2026-10-27
【相关历史】
- [2026-10-20] 提到正在迁移到上海
差别在于模型能否区分「稳定偏好」和「临时状态」。前者可以长期依赖,后者需要确认是否仍然成立。
错误二:不说「可以不知道」
如果提示词只给记忆内容,模型会倾向于把记忆当作完备信息,对没记住的事也硬答。加一句明确指令:
以下信息可能不完整。若用户问到的内容不在其中,
直接说「我这边没有相关记录」,不要推测。
错误三:把召回结果全部塞进去
top_k=20 意味着 20 条记忆进上下文。其中大部分与当前问题无关,反而稀释了真正相关的那几条。实践值:对话场景 top_k=3~5,加 threshold 下限。
一个反直觉的结论:召回返回空,比返回一堆弱相关内容更好。弱相关内容会诱导模型编造关联,而空结果会让模型老实说「不知道」。这也是为什么相关性下限(threshold)值得调。
十、多 Agent 场景:怎么划分记忆
一个系统里有多个 Agent 时,记忆归属容易乱。三种常见模式:
| 模式 | 作用域写法 | 适合 | 风险 |
|---|---|---|---|
| 共享用户记忆 | 只传 user_id | 客服 + 销售协作服务同一用户 | Agent 之间互相污染(一个的临时判断被另一个当事实) |
| 按 Agent 隔离 | user_id + agent_id | 各 Agent 职责差异大 | 用户要在多个 Agent 间重复交代背景 |
| 分层:用户层 + Agent 层 | 稳定事实写用户层,工作记忆写 Agent 层 | 推荐 | 需要判断「什么算稳定事实」 |
分层模式的具体做法:
# 用户层:跨 Agent 共享的稳定事实
m.add("用户是左撇子,偏好简洁回答", user_id="alice")
# Agent 层:某个 Agent 的工作记忆
m.add("本次排查已排除 DNS 问题", user_id="alice", agent_id="ops-bot",
run_id="task-2026-10-27-001")
好处是:ops-bot 的排查过程不会污染其他 Agent 对用户的认知,但它仍能读到用户的稳定偏好。
十一、成本与性能:什么时候该优化
| 症状 | 先查什么 | 优化手段 |
|---|---|---|
| 回复变慢 | 写入是否同步阻塞 | 改 async_mode=True,或后台队列 |
| 召回变慢 | 记忆总量到多少了 | 万级以上考虑装 numpy;十万级考虑外接向量库 |
| 召回不准 | 作用域是否混了、threshold 是否过高 | 先 get_all 确认数据在,再调参 |
| 上下文太长 | top_k 设了多少 | 降到 3–5,或改用目录式召回 |
| 存储增长过快 | 是否存了太多闲聊 | 写入前过滤;定期 dedup + consolidate |
实测基线(200 条记忆规模):写入中位 11.91 ms、召回中位 3.924 ms。低于这个数量级说明还有优化空间;远高于它,通常是作用域或存储位置的问题,不是引擎本身慢。
十二、实测性能参考
本机实测(Windows / Python 3.13.12 / 8.1.0.post1 / 默认零配置):
| 操作 | 耗时 |
|---|---|
| 引擎初始化 | 0.24 ms |
| 写入一条(中位) | 11.91 ms |
| 写入一条(P95) | 21.11 ms |
| 召回一次(中位) | 3.924 ms |
| 召回一次(P95) | 5.955 ms |
配置真实嵌入模型后延迟会上升,但语义召回质量明显提升。这是可调的取舍,不是固定值。