长期记忆架构
Poco 基于 Mem0 v2、PGVector 与内置实体关联的长期记忆实现
1. 方案结论
Poco 的“记忆”只有一个业务概念:用户级长期记忆。
底层仍然同时使用向量、关键词和实体关系三种信号,但它们都由同一个 memory-service 和同一个 Mem0 实例管理:
- 主记忆保存在原表
public.poco_memories; - 实体关联保存在 Mem0 自动维护的
public.poco_memories_entities; - 实体关系参与召回加权,并在管理页汇总为主题热区,不再作为独立图谱页面或
relationsAPI 返回; - 独立 Neo4j 服务已废弃。
这意味着用户看到的是一套记忆,系统内部可以用多种方式找到它,不会再出现“向量记忆是一套、图记忆又是一套”的理解负担。
这里保留的“图能力”是实体与主记忆之间的关联和召回加权,不是任意实体关系建模或多跳图查询;若未来出现明确的多跳业务场景,应作为独立需求评估,而不应重新把运行日志自动灌入图数据库。
2. 通俗理解
可以把长期记忆理解成一个带索引的档案柜:
poco_memories是档案正文;- 向量是“意思相近”的目录;
text_lemmatized是“关键词相同”的目录;poco_memories_entities是“提到同一个人、产品或项目”的目录;- 最终返回的仍然是档案正文,而不是把目录本身展示给用户。
例如用户明确说:
后续给我的技术方案都使用简体中文,优先考虑易维护性;项目叫“Poco”。
写入层会保留稳定偏好,Mem0 可能形成两条长期事实:
- 用户偏好使用简体中文输出技术方案;
- 用户在“Poco”项目中优先考虑易维护性。
“Poco”会成为内部实体。以后用户问“这个项目的技术方案怎么写”,系统可同时利用语义相似度、关键词和 Poco 实体关联找到上述记忆,但对话链路只收到两条可读事实。
3. 总体架构
职责边界:
- Executor 决定何时检索,并把新增、更新、遗忘或不变表达为明确动作;
- 生命周期协调层校验动作和真实记忆 ID;前置层只负责凭据、日志、路径等硬安全清洗,不用正则替 Mem0 判断长期价值;
- Mem0 基于真实
user/assistant角色负责事实推理、向量化、关键词字段、实体关联和最终排序; - Backend 负责用户身份校验和记忆管理接口转发,不参与 Mem0 推理或排序;
- Frontend 展示同一套主记忆的概览、趋势、来源、主题、列表、详情、相关记忆和变更轨迹,并提供纠正与删除入口。
4. 写入与生命周期链路
/ingest 是自动任务结果的“只新增”入口,新请求传递真实角色消息,旧版 text 请求仍兼容;/manage 是对话侧统一生命周期入口。显式 add 使用 infer=false,保证一条输入只生成一条可追踪记忆;update 和 delete 必须携带检索得到的真实 memory_id,分别调用 Mem0 公共 update 和 delete 方法,不依赖 add(infer=true) 猜测旧记忆如何变化。
4.1 前置准入层
前置层是确定性规则,不调用 LLM,只负责在付出推理和 embedding 成本之前执行硬安全检查:
- 拒绝
pre_compact、session_end等会话临时快照; - 拒绝
session_state; - 整批拒绝 API Key、Token、密码、私钥等敏感内容;
- 按行剔除堆栈、日志、命令、代码块、压测回显和注入的会话摘要;
- 从混合文本中删除绝对路径和 URL,而不是因为一处技术内容否决整段自然语言;
task_completed必须同时保留有意义的user与assistant消息,避免把助手结果误记成用户偏好;- 将中文弯引号规范化为 ASCII 双引号;兼容层补充 Mem0 默认 NLP 容易漏掉的两个及以上汉字的引号实体,例如“林清”和“飞书”,并过滤“用户长期偏好通过”以及包含引号实体的整句伪专名。
前置层不会再根据“长期、偏好、本次、今天”等关键词做最终语义裁决。例如“这次生成 HTML 报告”会通过硬安全检查,但 Mem0 应根据内置正反例返回空结果;“以后所有报告统一使用 HTML”才会被提取为跨任务偏好。这样既避免正则误杀隐含的稳定事实,也避免让正则替代已经引入的 Mem0 推理能力。显式 manage add 则固定写入一条已由 Agent 确认的事实。
自动 task_completed 候选通过 Executor 队列异步提交且只投递一次。请求到达 Memory Service 后,自动提炼中的 Mem0 后端异常最多尝试 4 次,退避间隔为 1、2、4 秒;准入拒绝、no_effect 等确定性结果不会重试。任务结束时 Executor 的等待上限与记忆 HTTP 请求超时保持一致,并额外保留 5 秒收尾时间;当前默认请求超时 60 秒,因此默认最多等待 65 秒。所有自动写入仍是外挂式尽力而为:请求失败、超时或最终失败只记录日志,不改变主任务结果;Memory Service 重启时也不会恢复尚未完成的写入。显式 manage_user_memory 则同步返回记忆服务的真实处理结果。
同一个 Run 只允许一条记忆决策链路成为写入者。Agent 一旦合法调用 manage_user_memory(包括 none,以及后端最终返回失败),本 Run 的 task_completed 自动候选就会跳过。这样用户明确更新或遗忘旧事实后,任务总结不会再通过自动推理把旧事实重新新增回来;没有显式管理时,仍保持原来的任务完成自动沉淀。
4.2 为什么保持 infer=true
infer=true 是新增记忆的事实提取入口:
- LLM 将原始表达转换为可复用的独立事实;
- 新事实生成
text_lemmatized; - NLP 提取命名实体、引号实体和技术标识符;
- 实体与主记忆 ID 建立关联。
自动候选不能改为 infer=false,否则未经提炼的任务总结会原样入库。显式 manage add 的输入已经是 Agent 确认过的单条稳定事实,因此使用 infer=false 精确写入;适配层随后仍会执行实体提取与关联,不会失去实体索引能力。
LLM 的提示词不能严格保证输出语言。适配层因此会优先以用户消息判断主语言并检查 Mem0 提取结果;若发生翻译漂移,会先物理删除本次错误结果,再带着原持久记忆规则和强化语言约束执行一次 infer=true 重试。重试仍漂移时整次写入失败并清理结果,绝不会再用 infer=false 把整段对话原文当成记忆。
提取完成后还会逐条验证:正文不得包含凭据、路径、URL、堆栈或超长内容,持久化元数据中的 attributed_to 必须是 user 或 assistant。不合格事实会连同实体关联和 SQLite 轨迹一起删除;剩余合格事实才计入 inserted_count。这层只做可确定验证,不再二次使用关键词判断“值不值得记”。
Mem0 2.0.12 的 infer=true 默认会把最近消息保存在 SQLite,并在下一次提取时作为上下文。Poco 的每次自动候选都是独立任务结果,不应共享这段对话上下文,因此适配层在同一用户写入前后清理对应消息,并用分片锁避免并发请求交叉污染。主记忆和实体关联保存在 PostgreSQL;history() 返回的现存记忆操作轨迹以及隐式消息上下文保存在 SQLite history.db。删除记忆时会物理清理该 ID 的 SQLite 轨迹,两类存储仍由同一个 Mem0 实例管理。
但 infer=true 不是完整生命周期管理器。当前固定使用的 Mem0 2.0.12 在 add 链路中只产生新增事实,不能可靠地代替精确更新和遗忘。Poco 因此把 Agent 的语义判断与 Mem0 的存储动作分开:Agent 选择 add/update/delete/none,生命周期协调层使用真实 ID 执行公共 API,实体关联随同一个主记忆同步更新或清理。
5. 召回链路
Mem0 v2 的 PGVector 已返回“越大越相关”的相似度,并在内部组合关键词和实体加权。Poco 不再把该分数误当距离,也不再二次反转。
min_score 过滤的是融合前的纯向量语义分,候选通过后才参与 BM25 和实体加权。基于现有 M3E 模型、旧记忆抽样、受控正负查询和真实旧数据噪声校准,默认值为 0.45。Agent 主动查询的默认 top_k 保持 5,自动上下文注入单独收紧为 3。
Executor 自动上下文最多召回 3 条,去重后在 800 字符总预算内按排序注入 0~3 条纯事实;超出预算的事实整条跳过,不截断正文。上下文只包含低优先级、不可信背景声明和事实正文,不携带记忆 ID、分数、实体、来源或其他内部元数据。向量、关键词和实体信号只负责筛选与排序;需要更新或遗忘时,Agent 必须重新调用 search_user_memory 获取真实 ID。
“零命中”和“召回服务不可用”是两种不同状态。搜索响应额外返回 available 与可选 error_reason;Executor 遇到不可用时发送 memory.search.failed 并继续主任务,不再把网络或向量库故障伪装成正常的零条记忆。
6. SDK 精确调用契约
Poco 通过 Mem0Adapter 隔离 2.0.12 SDK 变化,业务层不直接调用 Mem0。
写入
memory.add(
messages=[
{"role": "user", "content": user_request},
{"role": "assistant", "content": confirmed_outcome},
],
user_id="tenant:user",
metadata=metadata,
infer=True,
)默认提取规则用正反例区分跨任务偏好、稳定资料、项目约定与一次性任务,保持事实的原始语言和实体拼写,并明确禁止把助手建议伪装成用户偏好。工具或服务“现在是否可用”、当前技能/插件/模型清单等会自然过期的运行快照固定按非长期记忆处理;已确认的架构决策,以及被明确提升为后续规则或技能的复盘经验仍可正常保存。
更新
memory.update(
memory_id="memory-id-from-search",
text="更新后的稳定事实",
metadata=metadata,
)更新前由 Poco 校验 memory_id 属于当前租户和用户。更新后 ID 保持不变,Mem0 重新生成正文相关字段和向量,适配器重建该记忆的实体关联。
删除
memory.delete(memory_id="memory-id-from-search")删除同样先做用户作用域校验,并在主记忆删除前验证 SQLite history 表兼容性。主记忆删除后会幂等清理派生实体关联,并物理删除该 ID 的 SQLite 变更轨迹;这是不可恢复的遗忘操作。批量删除仍使用分批 get_all + delete,每一批同步清理对应轨迹。
检索
memory.search(
query=query,
top_k=top_k,
threshold=min_score,
filters={"user_id": "tenant:user"},
)列表
memory.get_all(
filters={"user_id": "tenant:user"},
top_k=top_k,
)2.0.12 的 search/get_all 不接受顶层 user_id,必须放在 filters 中。删除全部用户记忆时,适配器使用分批 get_all + delete,规避 PGVector SDK 单次只处理 100 条的限制。主记忆删除成功后,Memory Service 会幂等清理 poco_memories_entities 中对应的关联和 history.db 中对应的历史行,避免孤立实体或已遗忘内容残留。
单条和批量删除都返回明确状态:deleted、not_found、forbidden、backend_error 或 partial_cleanup。partial_cleanup 表示主记忆已经删除了一部分或全部,但历史/实体清理未完整结束,此时 success=false,并保留已删除数量和原因;Backend 与页面不得再把它包装成成功。该结果模型只描述事实,不引入额外补偿表或独立状态机。
7. 对话开关与触发方式
memory_save_enabled
控制 Executor 是否允许提交任务完成候选,以及是否向 Agent 暴露 manage_user_memory。关闭后,对话链路不会新增、更新或遗忘长期记忆,但不会删除历史数据;用户仍可在记忆管理页人工纠正或清理。定时任务、定时任务补跑和夜间任务会无条件覆盖为关闭。
memory_reference_enabled
控制普通对话中长度不少于 10 个字符的用户输入是否自动检索并注入相关记忆。定时任务、定时任务补跑和夜间任务会强制关闭自动注入与相关提示;关闭后历史记忆仍保留,也不影响普通对话中的 Agent 主动查询。
search_user_memory
Agent 可主动调用的 SDK MCP 工具。适合任务中途需要历史偏好、稳定约束或既有决策时使用,是否调用由 Agent 根据当前任务判断,并非每轮必然执行。用户也可以直接要求:“先查一下我过去对技术方案的偏好。”工具返回 items + total + available + error_reason。
manage_user_memory
Agent 可主动管理持久记忆,支持 add/update/delete/none。工具会等待记忆服务处理完成,并返回真实的 action、success、memory_id、inserted_count 和拒绝原因。更新或删除只能使用 search_user_memory 返回的 ID,不能猜测;新增和更新内容仍要经过准入层。
当用户明确纠正旧事实或要求遗忘时,Executor 的能力提示要求 Agent 在同一轮先检索真实 ID,再调用 update 或 delete,不能等待任务完成自动提取。任务完成链路按 Mem0 当前 ADD-only 基线发现新事实;精确更新与物理遗忘始终走生命周期接口。
8. 数据兼容与升级
8.1 原表原地升级
升级继续使用 public.poco_memories 和原 embedding 模型/1536 维向量。旧行无需增加 text_lemmatized 即可继续进行语义召回,不需要破坏性重建主向量表。
索引脚本补充:
payload->>'user_id'表达式索引;text_lemmatizedGIN 索引;- HNSW 向量索引(不存在时)。
索引脚本不会更新或删除旧行。独立迁移工具 migrate_mem0_v2_data.py 默认只读预演;显式传入 --apply 后,可在原 payload 中补齐 hash、text_lemmatized、memory_schema_version 和迁移版本。该过程不改变记忆 ID,也不重新生成或覆盖向量。
8.2 新实体表
poco_memories_entities 由 Mem0 首次实体写入时延迟创建。旧记忆即使没有实体关联,仍可正常语义召回。
若需要清理已经积累的图噪声,可让迁移工具先备份原实体表,再清空并重建这个派生索引。重建只选择显式管理来源和当前准入策略产生的可信记忆,不把旧 task_completed 运行日志重新灌入图数据。主向量表全程保留,因此实体重建失败也可以从备份恢复,且不会让旧记忆失去基础召回能力。
8.3 Neo4j 下线
代码和 Compose 的运行链路不再依赖 Neo4j,旧 Neo4j 服务无需继续运行。升级期间只需保留旧数据卷或离线备份用于回滚,确认新链路达到验收标准后再人工删除。Neo4j 旧节点不会迁入新实体表,因为其大量内容正是本次治理要淘汰的技术噪声。
9. API 契约
检索返回
{
"items": [
{
"id": "memory-id",
"content": "用户偏好简体中文技术方案。",
"score": 0.91,
"metadata": {
"category": "preference"
}
}
],
"total": 1,
"available": true,
"error_reason": null
}不再返回 relations。
管理接口
Memory Service 内部接口:
POST /v1/memories/search:按用户作用域召回相关主记忆;POST /v1/memories/ingest:接收任务完成后的自动新增候选;POST /v1/memories/manage:统一执行add/update/delete/none;POST /v1/memories/list-user:分页查看主记忆;POST /v1/memories/overview:统计总量、近期变化、来源和时间趋势;POST /v1/memories/topics:读取实体索引形成的主题热区;POST /v1/memories/related:从同一用户主记忆中召回相关事实;POST /v1/memories/history:读取单条记忆的 Mem0 变更轨迹;POST /v1/memories/update-item:按 ID 更新并校验用户作用域;POST /v1/memories/delete-item:按 ID 删除并校验用户作用域;POST /v1/memories/delete-user:分批删除该用户全部记忆。
Backend 通过 /api/v1/user-customer-settings/memories* 用户接口转发管理请求,其中列表接口为 GET /api/v1/user-customer-settings/memories/vector,其余接口覆盖概览、主题、相关记忆、历史、更新、单条删除和全部删除。浏览器不直接访问 Memory Service;当前不再暴露 /memories/graph,任何接口也不返回 relations。
10. 故障与降级
memory-service未配置:Executor 退化为无记忆模式,不阻塞主任务;- 自动检索超时或失败:记录事件后继续执行当前对话;
- 硬安全拒绝:返回结构化原因,不视为系统异常;Mem0 正常提取零条只记为
no_effect,不按故障告警; - LLM 或 embedding 失败:写入返回
backend_error,不写入半成品; - 实体提取失败:Mem0 保留主向量记忆,图增强可降级;
- 删除后置清理失败:返回
partial_cleanup和已删除数量,不伪装成完整成功; - 管理列表 SDK 不可用:PGVector 只读仓储仍可分页读取原表。
11. 运维验证
升级顺序:
- 备份 PostgreSQL 与旧 Neo4j 数据卷;
- 核对原向量维度和 embedding 模型;
- 执行
memory_service/scripts/upgrade_mem0_v2.sql; - 构建包含 spaCy 模型的新镜像;
- 在新镜像中执行
/app/scripts/verify_mem0_v2_preflight.py,确认旧表、索引、embedding 维度和 NLP 全部通过; - 运行
migrate_mem0_v2_data.py只读预演并核对影响行数; - 确认数据库备份后,按需使用
--apply原地补齐 v2 payload; - 如需重建实体表,再额外传入
--rebuild-entities --confirm-entity-reset poco_memories_entities; - 启动
memory-service,验证旧记忆召回以及新记忆的新增、更新、遗忘和中文短实体关联; - 按部署文档完成连续稳定观察,确认召回基线、记忆 5xx、生命周期残留和回滚备份均达到清理条件后,再由负责人删除 Neo4j 资源。
重点监控:
memory_ingest_failed/memory_search_failed;memory_ingest_retry_scheduled、memory_ingest_drain_timeout;memory_delete_item_partial_cleanup/memory_delete_user_partial_cleanup;- 硬安全拒绝、提取后拒绝与正常
no_effect分布; - 写入
no_effect比例; - 搜索延迟与命中数;
- 主记忆表增长、更新/遗忘成功率和实体关联表增长;
history.db所在卷容量。
12. Mem0 SDK 升级门禁
以下检查适用于升级 mem0ai、切换 Mem0 provider,或调整 embedding 模型、维度和存储实现。/health 只证明 HTTP 服务能够响应,不能证明记忆数据仍然正确,禁止仅凭健康检查完成升级验收。
12.1 当前适配风险
Poco 的业务层只依赖 Mem0Adapter,但适配器为了保证原语言、上下文隔离和实体生命周期,使用了 Mem0 2.0.12 的部分内部能力。这些能力不属于稳定的公开兼容契约:
| 依赖点 | 当前用途 | 不兼容时的风险 |
|---|---|---|
_remove_memory_from_entity_store | 重建中文引号实体时清理已有实体关联 | 重建后出现重复或孤立实体 |
_upsert_entity | 重建中文引号实体和主记忆 ID 的关联 | “邮件”“飞书”等实体无法参与召回和可视化 |
client.db.connection、client.db._lock | 按用户清理隐式消息,并物理清理删除记忆的历史轨迹 | 旧事实污染后续写入、遗忘残留,或写入/删除失败 |
SQLite history.memory_id | 按记忆 ID 清理已遗忘事实的操作轨迹 | 删除后仍能从本地轨迹恢复旧内容 |
SQLite messages.session_scope | 将上下文清理限制在 tenant:user | 跨用户误删或上下文无法清理 |
results[].id/memory/event | 识别成功写入数量、语言漂移和提取后校验结果 | 新增数量或质量保护判断失真 |
get().attributed_to | 校验事实来自 user 或 assistant | 助手建议可能被误当成用户事实 |
delete() 先删向量再写历史的执行顺序 | 异常后用 get() 识别主记录是否已删并完成后置清理 | 已删除操作被误报为普通失败,留下历史或实体残留 |
| PGVector payload、分数和实体表结构 | 兼容旧数据、管理查询和召回阈值 | 旧记忆不可读、分数方向错误或实体页面失真 |
升级前必须核对上述接口、参数和数据结构在目标版本中的实现。若内部接口已经变化,应先修改适配器并补齐测试,不允许通过业务层散落兼容分支。
12.2 升级前准备
- 固定目标 Mem0 版本,阅读该版本发布说明,并对照源码核对上述依赖点;
- 备份
poco_memories、poco_memories_entities和history.db; - 记录旧数据行数、实体行数、embedding 模型与维度,以及一组正向、负向召回基线;
- 除非已有单独的全量向量迁移方案,否则保持原 embedding 模型和
1536维度; - 在独立测试环境或隔离用户作用域中验证,禁止直接使用真实用户数据试错。
12.3 必须执行的验证
先执行静态检查和全部 Memory Service 单元测试:
uv run ruff check memory_service/app memory_service/tests
uv run ruff format --check memory_service/app memory_service/tests
cd memory_service
.venv/bin/python -m unittest discover -s tests -p 'test_*.py' -v随后通过 Backend 与 Memory Service 的真实接口完成以下回归:
- 服务初始化:Mem0、NLP、LLM、embedding 和 PGVector 均可用;
- 自动写入:真实
user/assistant角色进入infer=true;一次性要求返回零条,稳定偏好和确认的项目状态可提取;模拟翻译时错误结果被删除并执行一次约束重试,链路中不得出现原文infer=false回退;Executor 只投递一次,Memory Service 内的临时 Mem0 后端异常按 1/2/4 秒退避,确定性拒绝不得重试;显式管理后同一 Run 不得再出现task_completed自动写入; - 上下文隔离:预先写入同用户的过期
messages后,新候选不携带旧事实,写入结束后该作用域消息数为零; - 前置与后置保护:敏感凭据整批拒绝;混合文本中的技术噪声被剔除而不是误杀自然语言;提取后的非法角色、路径、URL 和语言漂移不会残留;
- 生命周期:显式新增只产生一条记忆;更新保持原 ID;单条删除和全部遗忘同时清理主记忆、实体关联及该 ID 的 SQLite 历史轨迹;不兼容的
history表必须在删除主记忆前阻断操作;模拟 SDK 在主向量删除后抛错时,适配器必须识别记录已删除并完成后置清理;删除状态必须贯通 Backend; - 实体质量:引号中的中文实体可以写入,更新后旧实体消失,包含实体的整句伪专名不会进入实体表;
- 旧数据兼容:移除一条测试记忆的 v2 payload 字段后,列表与召回仍正常;原 ID 更新后重新补齐 v2 元数据;历史消息回放读取
text_preview,不要把可能含旧\u0000的 JSONcontent强制转换为jsonb; - 召回校准:正向查询得分方向仍为“越大越相关”,旧数据和新数据均能通过当前
0.45阈值,负向查询不会误召回;服务故障必须返回available=false,不能伪装成零命中; - 作用域隔离:不同租户或用户之间不能列表、召回、更新或删除对方记忆;
- 清理复核:验收结束后,测试用户对应的主向量、实体、SQLite 消息、SQLite 历史轨迹和临时 Backend 用户均为零。
测试报告必须记录目标版本、配置摘要、旧/新数据召回分数、实体结果、失败日志和清理结果。涉及真实模型的非确定性链路至少保留确定性单元测试,不能只依赖一次在线输出。
12.4 阻断升级与回滚条件
出现以下任一情况,不得继续发布:
- Mem0 初始化失败,或内部依赖点无法调用;
- 原语言保护失败、旧消息上下文复活,或发生跨用户数据访问;
- 更新改变记忆 ID,删除后仍残留主记忆、实体关联或 SQLite 历史轨迹;
- 旧数据不能列表、召回或原位更新;
- 召回分数方向变化,或基线样本在原阈值下明显退化;
- embedding 模型、维度或 PGVector schema 与现有数据不兼容;
- 全链路出现未解释的
backend_error、5xx 或测试数据无法完整清理。
回滚时恢复原 Mem0 版本与配置;如果升级过程修改过持久化数据,再使用升级前备份恢复 PostgreSQL 和 history.db。主表结构或向量模型变化必须有独立迁移与回滚方案,不能作为普通 SDK 升级处理。