Repository navigation
feat: 增加三级记忆召回 - #355
feat: 增加三级记忆召回#355
Conversation
raychen911
commented
Oct 9, 2026
- 修改例子的问题
- 增加多级召回,L0做兜底
- 修改例子的问题 - 增加多级召回,L0做兜底
AI Code Review审查结论不通过 审查范围:commit 02509b2..688537a("feat: 增加三级记忆召回"),共 8 个文件,均为手工编写的非生成代码/文档/测试。 计划符合性:核心功能(search_memory 并行召回 L1 原子记忆、L2 场景、L3 核心记忆,三层无内容时回退 L0 会话搜索)已实现并通过 MockTransport 单元测试覆盖;L2/L3 请求体、早退门、L0 兜底路径与文档描述一致。示例 run_agent.py 改为唯一标识验证流程,测试也相应更新。 主要风险(由本变更引入):
测试充分性:新增两个测试覆盖三层组合与部分层失败保留;但未覆盖 limit 超限、L2/L3 与 query 无关导致的兜底失效、纯空白 L3 内容、以及被删除的"空 content 条目被跳过"断言。 门禁结论:存在阻断合入的高置信缺陷(limit 契约破坏、L0 兜底失效),判定 FAILED。 发现的问题严重
问题: 重构后的 触发条件: 任意一次携带小 实际影响: 违反 修正方向: 在返回前对 严重
问题: L2 的 触发条件: 用户已有任何 L2/L3 记忆(示例第二次运行即满足,首次运行写入的"发版前必须跑一遍全量回归"会被提取为核心记忆),随后新写了尚未完成异步 L1 提取的重要信息——本应靠 L0 兜底召回,实际返回的只有与查询无关的 L2/L3 历史内容,L0 请求根本不发出。 实际影响: L0 兜底(正是为"L1 异步提取未完成"场景设计)对老用户失效,刚写入的对话信息在提取完成前不可召回,存在信息缺失的实际召回错误风险,与计划中"L0 做兜底"的意图相悖。 修正方向: 将回退条件恢复为"查询相关的 L1 无命中",或将 L2/L3 请求带上 中等
问题: 触发条件: 任一接口(如 实际影响: 每一次 修正方向: 对慢层设置较短独立超时(如 2-3s)并在 中等
问题: 触发条件: 网关对 实际影响: 产生一条空白核心记忆条目被转发给 LLM,且因第 212-213 行非空门同时抑制了 L0 兜底搜索;测试仅覆盖空串(""),未覆盖纯空白。 修正方向: 在 中等
问题: 触发条件: 用户按 README 第 4 节执行 实际影响: 示例按文档操作必然无法运行且无任何文档提示该新必需项,示例可用性被破坏。 修正方向: 在 较低
问题: 触发条件: httpx/事件循环实现变化或调度时机改变导致请求实际完成顺序与断言顺序不一致(同文件另一个新测试 实际影响: 测试可能间歇性误报失败(时序抖动),或反向掩盖真实回归(如 L2/L3 被改为串行时断言仍然通过)。 修正方向: 将 较低
问题: 本变更将示例内容从"喜欢的颜色"(chat 模式)改为"工程任务和发版评审"并要求服务端使用 触发条件: 用户按新要求以 code 模式部署后遇到 L1 提取为空,按 README 指示切换为 chat 模式——与新示例内容(工程任务)所需模式相反,提取依然为空或错误提取。 实际影响: 故障排查指引引导用户做出与示例要求矛盾的配置,误导排查方向;同类残留还出现在第 199 行引用块("MEMORY_PROMPT_MODE=chat 应配置在服务端")。 修正方向: 将故障排查段和相关引用同步为 code 模式语义(例如"确认服务端已使用 code 模式而非 chat")。 |
| layer_results = await asyncio.gather( | ||
| self._post(_ATOMIC_SEARCH_PATH, search_body), | ||
| self._post(_SCENARIO_LIST_PATH, isolation), | ||
| self._post(_CORE_READ_PATH, isolation), | ||
| return_exceptions=True, | ||
| ) | ||
|
|
||
| self._append_layer_items( | ||
| response, | ||
| layer_results[0], | ||
| field="items", | ||
| layer="L1", | ||
| ) | ||
| self._append_scenario_entries(response, layer_results[1]) | ||
| self._append_core_memory(response, layer_results[2]) |
There was a problem hiding this comment.
问题: 重构后的 search_memory 只把 limit 传给 /v3/atomic/search(L1),而 /v3/scenario/ls(L2)与 /v3/core/read(L3)的请求体只含隔离字段,_append_scenario_entries 和 _append_core_memory 又把全部合法条目无条件追加,没有任何按 limit 截断的逻辑,response.memories 可以确定性超过 limit。
触发条件: 任意一次携带小 limit(如 10)的召回,只要 L1 命中数加上 L2 场景条目加上 L3 核心记忆超过 limit 即触发;与查询无关的 L2/L3 内容会随用户长期使用不断累积,使超限成为常态。
实际影响: 违反 abc/_memory_service.py:84 的契约("limit: The maximum number of results to return"),且与仓库内其他实现不一致(_in_memory_memory_service.py:130、_sql_memory_service.py、mem0 均做截断/透传);_load_memory_tool.py:60 会把全部结果序列化转发给 LLM,导致上下文膨胀、注入无关记忆。新测试 test_search_memory_combines_v3_l1_l2_l3_across_sessions 用 limit=5 但三层合计仅 3 条,恰好未覆盖超限场景。
修正方向: 在返回前对 response.memories 统一截断到 limit(或对三层结果合并后按分数/层次优先级截断),并补充 limit 超限断言测试。
| if response.memories: | ||
| return response |
There was a problem hiding this comment.
问题: L2 的 /v3/scenario/ls 和 L3 的 /v3/core/read 请求体只含 team_id/agent_id/user_id(第 199-200 行),结果与查询词完全无关;只要任一历史场景条目或核心记忆存在,response.memories 即非空并触发第 212-213 行的提前返回,/v3/conversation/search(L0 兜底)对已积累过 L2/L3 内容的旧用户永远不可达。旧实现(02509b2)是"查询相关的 L1 无命中即回退 L0",新实现的兜底条件被放宽成了"三层全空"。
触发条件: 用户已有任何 L2/L3 记忆(示例第二次运行即满足,首次运行写入的"发版前必须跑一遍全量回归"会被提取为核心记忆),随后新写了尚未完成异步 L1 提取的重要信息——本应靠 L0 兜底召回,实际返回的只有与查询无关的 L2/L3 历史内容,L0 请求根本不发出。
实际影响: L0 兜底(正是为"L1 异步提取未完成"场景设计)对老用户失效,刚写入的对话信息在提取完成前不可召回,存在信息缺失的实际召回错误风险,与计划中"L0 做兜底"的意图相悖。
修正方向: 将回退条件恢复为"查询相关的 L1 无命中",或将 L2/L3 请求带上 query 做相关性过滤,使 L0 兜底在真实业务路径上可达。
| layer_results = await asyncio.gather( | ||
| self._post(_ATOMIC_SEARCH_PATH, search_body), | ||
| self._post(_SCENARIO_LIST_PATH, isolation), | ||
| self._post(_CORE_READ_PATH, isolation), | ||
| return_exceptions=True, | ||
| ) |
There was a problem hiding this comment.
问题: asyncio.gather 必须等待三个请求全部完成才开始 append(return_exceptions=True 只把异常收进结果列表),单层慢请求会把其他层的即时结果一并拖延,而每个请求都带 timeout=10.0。
触发条件: 任一接口(如 /v3/core/read 在部分网关版本上不支持、或网关负载高)耗时到接近超时,而 L1 本已快速返回命中结果。
实际影响: 每一次 load_memory 调用的延迟从"单次 L1 查询"变为"三个请求的最慢者"(最多 10 秒),agent 每轮对话的感知延迟显著增加;旧实现单请求即可返回。
修正方向: 对慢层设置较短独立超时(如 2-3s)并在 asyncio.wait/asyncio.timeout 下先完成先消费(L1 结果可用即返回),或将 L2/L3 改为惰性、超时即放弃继续等待。
| entry = cls._to_memory_entry({ | ||
| "type": "core", | ||
| "content": result.get("content"), | ||
| "created_at": result.get("created_at"), | ||
| "updated_at": result.get("updated_at"), | ||
| }) | ||
| if entry is not None: | ||
| response.memories.append(entry) |
There was a problem hiding this comment.
问题: _append_core_memory 把 result.get("content") 原样交给 _to_memory_entry,而 _to_memory_entry(第 440 行)只检查 not isinstance(memory_text, str) or not memory_text,纯空白字符串(如 " ")是 truthy,会通过检查生成 author="core" 的空白条目。对照 _append_scenario_entries 对 path 做了 path.strip() 校验,L3 分支缺少同样净化。
触发条件: 网关对 /v3/core/read 返回 {"content": " "}(或其他不可见字符内容)。
实际影响: 产生一条空白核心记忆条目被转发给 LLM,且因第 212-213 行非空门同时抑制了 L0 兜底搜索;测试仅覆盖空串(""),未覆盖纯空白。
修正方向: 在 _append_core_memory 中对 content 做 isinstance(content, str) and content.strip() 校验,空白内容视为无内容并继续 L0 兜底。
| memory_service=memory_service, | ||
| ) | ||
| user_id = "alice" | ||
| user_id = _required_env("TENCENTDB_MEMORY_USER_ID") |
There was a problem hiding this comment.
问题: run_agent.py 将 user_id 从硬编码 "alice" 改为 _required_env("TENCENTDB_MEMORY_USER_ID"),但同变更只同步了 .env(该文件并非 README 指示复制模板),README 配置流程引用的 .env.local/.env.remote 模板以及正文第 157 行(仍写 user_id="alice")均未包含该必需变量。
触发条件: 用户按 README 第 4 节执行 cp examples/memory_service_with_tencentdb/.env.local .env 并照模板填写后运行 python3 run_agent.py——环境变量缺失,_required_env 在第 158 行直接抛 ValueError: TENCENTDB_MEMORY_USER_ID must be configured 退出。
实际影响: 示例按文档操作必然无法运行且无任何文档提示该新必需项,示例可用性被破坏。
修正方向: 在 .env.local、.env.remote 模板与 README 正文(包括第 157 行 "user_id=alice" 段落)中补充 TENCENTDB_MEMORY_USER_ID 的填写说明。
| assert paths == [ | ||
| "/v3/atomic/search", | ||
| "/v3/scenario/ls", | ||
| "/v3/core/read", | ||
| ] | ||
| assert len(result.memories) == 1 | ||
| assert result.memories[0].author == "scenario" |
There was a problem hiding this comment.
问题: test_search_memory_falls_back_to_semantic_conversation_search 用 paths == [...] 断言三个并行请求的固定顺序(atomic/search → scenario/ls → core/read → conversation/search),而这三个请求由 asyncio.gather 并发发出,append 顺序取决于事件循环对 _post 协程的调度时机,测试绑定的是实现细节而非行为。
触发条件: httpx/事件循环实现变化或调度时机改变导致请求实际完成顺序与断言顺序不一致(同文件另一个新测试 test_search_memory_combines_v3_l1_l2_l3_across_sessions 已改用按 path 分键的 dict 规避此问题)。
实际影响: 测试可能间歇性误报失败(时序抖动),或反向掩盖真实回归(如 L2/L3 被改为串行时断言仍然通过)。
修正方向: 将 paths 断言改为无序集合断言(sorted(paths)),或像组合测试一样按键分发、单独断言每个 path 的请求体。
|
|
||
| ```dotenv | ||
| MEMORY_PROMPT_MODE=chat | ||
| MEMORY_PROMPT_MODE=code |
There was a problem hiding this comment.
问题: 本变更将示例内容从"喜欢的颜色"(chat 模式)改为"工程任务和发版评审"并要求服务端使用 MEMORY_PROMPT_MODE=code(第 86 行),但 README 故障排查段落仍残留旧结论:日志显示 promptMode=code 且提取数为 0 时"设置 MEMORY_PROMPT_MODE=chat 并重新执行"(第 312 行),与示例新要求直接矛盾。
触发条件: 用户按新要求以 code 模式部署后遇到 L1 提取为空,按 README 指示切换为 chat 模式——与新示例内容(工程任务)所需模式相反,提取依然为空或错误提取。
实际影响: 故障排查指引引导用户做出与示例要求矛盾的配置,误导排查方向;同类残留还出现在第 199 行引用块("MEMORY_PROMPT_MODE=chat 应配置在服务端")。
修正方向: 将故障排查段和相关引用同步为 code 模式语义(例如"确认服务端已使用 code 模式而非 chat")。
| **isolation, | ||
| } | ||
| layer_results = await asyncio.gather( | ||
| self._post(_ATOMIC_SEARCH_PATH, search_body), |
There was a problem hiding this comment.
这个搜索是不是可以让用户自己来配置要从哪个层级来搜索?是混合搜索效果要好一些吗?