Skip to content

feat: 增加三级记忆召回 - #355

Merged
weimch merged 1 commit into
mainfrom
feat/tencentdb_recall
Oct 10, 2026
Merged

weimch merged 1 commit into
mainfrom
feat/tencentdb_recall

Conversation

@raychen911

Copy link
Copy Markdown
Contributor
  • 修改例子的问题
  • 增加多级召回,L0做兜底

- 修改例子的问题
- 增加多级召回,L0做兜底
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

不通过

审查范围:commit 02509b2..688537a("feat: 增加三级记忆召回"),共 8 个文件,均为手工编写的非生成代码/文档/测试。

计划符合性:核心功能(search_memory 并行召回 L1 原子记忆、L2 场景、L3 核心记忆,三层无内容时回退 L0 会话搜索)已实现并通过 MockTransport 单元测试覆盖;L2/L3 请求体、早退门、L0 兜底路径与文档描述一致。示例 run_agent.py 改为唯一标识验证流程,测试也相应更新。

主要风险(由本变更引入):

  1. Limit 契约破坏(SEVERE):只有 L1 请求携带 limit,L2/L3 结果全量追加且不截断,response.memories 可超过 ABC 契约"limit: The maximum number of results to return"(abc/_memory_service.py:84),且与其他实现(in-memory、SQL、mem0 均截断)不一致;load_memory 工具会把超限结果全量转发给 LLM。新测试 limit=5 时三层合计仅 3 条,未覆盖超限场景。
  2. L0 兜底被与 query 无关的 L2/L3 内容短路(SEVERE):/v3/scenario/ls 与 /v3/core/read 的请求体只含隔离字段、不含 query,任何历史存在的场景条目或核心记忆都会使 response.memories 非空并提前返回,/v3/conversation/search 在用户已积累 L2/L3 内容后实际不可达;而 L0 兜底的存在意义正是"L1 异步提取未完成时召回刚写入的对话",对老用户该兜底失效。
  3. 慢层拖累(MODERATE):asyncio.gather 需等待三个请求全部完成,任一接口超时(默认 10s)会把 L1 的即时结果拖延到最慢层结束。
  4. L3 纯空白 content 生成空白记忆条目并抑制 L0(MODERATE):_append_core_memory 未像 scenario 分支那样做 strip 校验。
  5. 示例文档与代码契约断裂(MODERATE):run_agent.py 改为 _required_env("TENCENTDB_MEMORY_USER_ID"),但 README 配置流程引用的 .env.local/.env.remote 模板与正文(仍写 user_id="alice")均未提供该变量,按文档操作必然抛 ValueError 退出。
  6. 测试断言时序脆弱(LOW):并行 gather 的三个请求路径被断言为固定顺序,绑定事件循环调度实现细节。
  7. README 故障排查与新模式自相矛盾(LOW):示例改为 code 模式后,残留的"日志显示 code 模式则切换 chat"建议会引导用户做出与示例要求相反的配置。

测试充分性:新增两个测试覆盖三层组合与部分层失败保留;但未覆盖 limit 超限、L2/L3 与 query 无关导致的兜底失效、纯空白 L3 内容、以及被删除的"空 content 条目被跳过"断言。

门禁结论:存在阻断合入的高置信缺陷(limit 契约破坏、L0 兜底失效),判定 FAILED。

发现的问题

严重

trpc_agent_sdk/memory/tencentdb_memory_service.py:197-211

问题: 重构后的 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 超限断言测试。

严重

trpc_agent_sdk/memory/tencentdb_memory_service.py:212-213

问题: 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 兜底在真实业务路径上可达。

中等

trpc_agent_sdk/memory/tencentdb_memory_service.py:197-202

问题: 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 改为惰性、超时即放弃继续等待。

中等

trpc_agent_sdk/memory/tencentdb_memory_service.py:304-311

问题: _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 兜底。

中等

examples/memory_service_with_tencentdb/run_agent.py:158

问题: 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 的填写说明。

较低

tests/memory/test_tencentdb_memory_service.py:352-358

问题: 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 的请求体。

较低

examples/memory_service_with_tencentdb/README.md:86

问题: 本变更将示例内容从"喜欢的颜色"(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")。

Comment on lines +197 to +211
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])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 重构后的 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 超限断言测试。

Comment on lines +212 to +213
if response.memories:
return response

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 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 兜底在真实业务路径上可达。

Comment on lines +197 to +202
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,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 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 改为惰性、超时即放弃继续等待。

Comment on lines +304 to +311
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 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 的填写说明。

Comment on lines +352 to +358
assert paths == [
"/v3/atomic/search",
"/v3/scenario/ls",
"/v3/core/read",
]
assert len(result.memories) == 1
assert result.memories[0].author == "scenario"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 本变更将示例内容从"喜欢的颜色"(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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个搜索是不是可以让用户自己来配置要从哪个层级来搜索?是混合搜索效果要好一些吗?

@weimch weimch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve

@weimch
weimch merged commit 64a4625 into main Oct 10, 2026
5 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants