Version: 0.9.22.dev.260416
后端: 1. 品牌文案与聊天定位统一切到 SmartMate,并放宽非排程问答能力 - 系统人设、路由、排程、查询、交付提示统一从 SmartFlow 改为 SmartMate - 明确普通问答/生活建议/开放讨论可正常回答,deep_answer 不再输出“让我想想”等占位话术 - thinkingMode=auto 时,deep_answer 默认开启 thinking,execute 继续跟随路由决策,其余路由默认关闭 2. Memory 读取链路升级为“结构化强约束 + 语义候选”hybrid 模式,并补齐注入渲染 / Execute 消费 - 新增 read.mode、四类记忆预算、inject.renderMode 等配置及默认值 - 落地 HybridRetrieve,统一 MySQL/RAG 读侧作用域、三级去重(ID/hash/text)、统一重排与按类型预算裁剪 - 新增 FindPinnedByUser、content_hash DTO/兜底补算、legacy/RAG 共用读侧查询口径与 fallback 逻辑 - 记忆注入支持 flat/typed_v2 两种渲染,execute msg3 正式消费 memory_context,主链路注入 MemoryReader 时同步透传 memory 配置 3. Memory 第二步/第三步 handoff 与治理文档补齐 - HANDOFF_Memory向Mem0靠拢三步冲刺计划.md 从 newAgent 迁到 memory 目录,并补充“我的记忆”增删改查与最小留痕口径 - 新增 backend/memory/记忆模块第二步计划.md、backend/memory/第三步治理与观测落地计划.md,分别拆解 hybrid 读取注入闭环与治理/观测/清理路线 - 同步更新 backend/memory/Log.txt 调试日志 前端: 1. 助手输入区新增“智能编排”任务类选择器,并把 task_class_ids 作为请求 extra 透传 - 新建 frontend/src/components/assistant/TaskClassPlanningPicker.vue,支持拉取任务类列表、临时勾选、已选标签回显与清空 - 更新 frontend/src/components/dashboard/AssistantPanel.vue、frontend/src/types/dashboard.ts:Chat extra 正式建模 task_class_ids / retry 字段;当本轮带编排任务类时强制新起会话,避免把现有会话历史误混入新编排 2. 会话上下文窗口统计接入前端展示 - 更新 frontend/src/api/agent.ts、新建 frontend/src/components/assistant/ContextWindowMeter.vue、更新 frontend/src/components/dashboard/AssistantPanel.vue、frontend/src/types/dashboard.ts:接入 /agent/context-stats,兼容 object/string/null 三种返回;在输入工具栏展示 msg0~msg3 占比与预算使用率 3. 助手面板交互细节优化 - 更新 frontend/src/components/dashboard/AssistantPanel.vue:thinking 开关改为 auto/true/false 三态选择;切会话与重试后同步刷新 context stats;历史列表首屏不足时自动继续分页直到形成滚动区 仓库:无
This commit is contained in:
639
backend/memory/HANDOFF_Memory向Mem0靠拢三步冲刺计划.md
Normal file
639
backend/memory/HANDOFF_Memory向Mem0靠拢三步冲刺计划.md
Normal file
@@ -0,0 +1,639 @@
|
||||
# Memory 向 Mem0 靠拢三步冲刺计划(newAgent)
|
||||
|
||||
## 1. 一句话结论
|
||||
|
||||
当前 `memory` 已经具备了“可异步写入、可基础抽取、可基础检索、可注入 newAgent”的骨架,但距离真正有 Mem0 味道的记忆系统,还差三块核心能力:
|
||||
|
||||
1. 写入侧没有“先召回旧记忆,再做 `ADD/UPDATE/DELETE/NONE` 决策”的治理层。
|
||||
2. 读侧没有把“硬约束优先、语义召回补充、结果去重、注入预算”做成稳定链路。
|
||||
3. 系统层没有形成“可灰度、可解释、可清理、可回滚”的治理闭环。
|
||||
|
||||
因此建议按三步走推进,并严格遵守一个原则:
|
||||
|
||||
1. 每一轮只处理一个能力域。
|
||||
2. 第一步只动写入决策层。
|
||||
3. 第二步只动读链路与注入质量。
|
||||
4. 第三步只动治理、清理、指标与切流收口。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本文档给谁看
|
||||
|
||||
本文档面向三类读者:
|
||||
|
||||
1. 需要继续实现 `memory/newAgent` 的 agent。
|
||||
2. 需要拆任务、排优先级的人。
|
||||
3. 需要快速判断“本轮该改什么、不该改什么”的维护者。
|
||||
|
||||
本文档不是背景介绍文档,而是“可直接拿去拆工单和接力开发”的冲刺说明。
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前现状与目标差距
|
||||
|
||||
### 3.1 当前已完成的部分
|
||||
|
||||
当前已经有的能力:
|
||||
|
||||
1. 聊天消息可通过 `outbox -> memory.extract.requested -> memory_jobs -> worker` 进入异步记忆链路。
|
||||
2. Worker 可调用 LLM 做事实抽取,并通过 `NormalizeFacts` 做批内标准化和批内去重。
|
||||
3. `memory_items / memory_jobs / memory_audit_logs / memory_user_settings` 四张核心表已经建立并接线。
|
||||
4. `ReadService` 已可做基础查询与轻量排序。
|
||||
5. `newAgent` 已通过 `injectMemoryContext` 把记忆写入 pinned block。
|
||||
6. 用户设置、删除、审计已经具备基础治理能力。
|
||||
|
||||
### 3.2 当前离 Mem0 还差什么
|
||||
|
||||
最关键的差距如下:
|
||||
|
||||
| 能力 | 当前状态 | 与 Mem0 的差距 |
|
||||
| --- | --- | --- |
|
||||
| 异步入队 | 已完成 | 基本到位 |
|
||||
| 抽取候选事实 | 已完成 | 缺少更强的抽取后治理 |
|
||||
| 批内去重 | 已完成 | 仅限单批,不处理历史记忆 |
|
||||
| 历史去重 | 未完成 | 需要按旧记忆召回后做决策 |
|
||||
| `ADD/UPDATE/DELETE/NONE` 决策 | 未完成 | 这是最关键差距 |
|
||||
| 语义召回 | 部分完成 | 接口有了,质量与稳定性未形成闭环 |
|
||||
| 读侧去重 | 未完成 | 现在更多是展示层弱去重 |
|
||||
| Prompt 注入 | 基础版已接 | 还没有类型分层与预算控制 |
|
||||
| 管理治理 | 部分完成 | 还缺更新、恢复、历史清理、指标闭环 |
|
||||
| 灰度/回滚 | 较弱 | 需要细粒度 feature flag 与分阶段切流 |
|
||||
|
||||
### 3.3 本次冲刺的目标定义
|
||||
|
||||
本轮不是要把项目做成完整 Mem0,也不是做图记忆或多 Provider 平台,而是要做到一个“Mem0-lite 可自信上线”的状态。满足以下条件,就可以认为基本靠近目标:
|
||||
|
||||
1. 相同或同义记忆不会无脑越写越多。
|
||||
2. 用户纠正一条旧记忆时,系统更倾向于更新旧值,而不是新增一条冲突值。
|
||||
3. 读侧能优先拿到“硬约束 + 偏好 + 当前话题相关事实”,而不是仅按最近更新时间胡乱注入。
|
||||
4. Prompt 注入是稳定、可控、可解释的,而不是纯拼接。
|
||||
5. 出问题时可以快速关掉某一层能力,而不是整条 memory 链路一起陪葬。
|
||||
|
||||
---
|
||||
|
||||
## 4. 设计原则与边界
|
||||
|
||||
### 4.1 每轮只处理一个能力域
|
||||
|
||||
为避免回归问题无法定位,本计划明确规定:
|
||||
|
||||
1. 第一步只处理“写入决策层”。
|
||||
2. 第二步只处理“读取与注入层”。
|
||||
3. 第三步只处理“治理、清理与切流层”。
|
||||
|
||||
禁止在同一轮里同时大改:
|
||||
|
||||
1. `memory` 写入逻辑。
|
||||
2. `newAgent` 图节点结构。
|
||||
3. WebSearch / 其他 RAG 语料。
|
||||
4. 多个 prompt 体系。
|
||||
|
||||
### 4.2 保留旧实现,走并行迁移
|
||||
|
||||
整个冲刺必须遵守并行迁移策略:
|
||||
|
||||
1. 旧的“抽取后直接 `Create`”路径先保留。
|
||||
2. 新的“决策后 ApplyAction”路径并行落地。
|
||||
3. 用 feature flag 灰度切流。
|
||||
4. 验证通过后,再决定是否删除旧路径。
|
||||
|
||||
### 4.3 不新增“memory 工具化”这条支线
|
||||
|
||||
本轮不建议把 `memory` 改成一个显式工具让 `newAgent` 主动调用,原因如下:
|
||||
|
||||
1. 当前 `pinned block` 已经接入主链路,切点稳定。
|
||||
2. 本轮目标是让记忆“更准”,不是让图结构更复杂。
|
||||
3. 若同时引入工具化调用,会把“写入决策层”和“图编排层”耦到一起。
|
||||
|
||||
因此本轮默认继续沿用:
|
||||
|
||||
1. `backend/memory/service/read_service.go`
|
||||
2. `backend/service/agentsvc/agent_memory.go`
|
||||
3. `pinned block` 注入
|
||||
|
||||
---
|
||||
|
||||
## 5. 三步走总览
|
||||
|
||||
| 步骤 | 只处理的能力域 | 核心目标 | 旧实现是否保留 |
|
||||
| --- | --- | --- | --- |
|
||||
| 第一步 | 写入决策层 | 把“抽取即新增”升级为“召回旧记忆 + 决策动作” | 保留 |
|
||||
| 第二步 | 读链路与注入层 | 把“查到就拼”升级为“硬约束优先 + 语义补充 + 注入预算” | 保留 |
|
||||
| 第三步 | 治理与切流层 | 把“能跑”升级为“可灰度、可观测、可清理、可回滚” | 收口 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 第一步:先把写入侧做成 Mem0-lite
|
||||
|
||||
### 6.1 这一步解决什么问题
|
||||
|
||||
当前写入链路本质上还是:
|
||||
|
||||
`抽取 -> 标准化 -> 直接写 memory_items`
|
||||
|
||||
这会带来三个直接问题:
|
||||
|
||||
1. 历史同义记忆不会合并。
|
||||
2. 用户纠正旧记忆时,系统更可能新增一条相反记忆。
|
||||
3. `content_hash` 现在更多只是存了个字段,没有真正承担“历史治理”的职责。
|
||||
|
||||
第一步的目标是把写入链路升级为:
|
||||
|
||||
`抽取 -> 召回旧记忆候选 -> 临时 ID 映射 -> LLM 决策 -> ApplyAction`
|
||||
|
||||
### 6.2 本轮要落的能力
|
||||
|
||||
第一步必须落地以下能力:
|
||||
|
||||
1. 为每条新候选 fact 召回有限个旧记忆候选。
|
||||
2. 用临时整数 ID 或候选序号喂给决策模型,避免模型直接编造真实 `memory_id`。
|
||||
3. 让模型只输出结构化 JSON 决策:`ADD/UPDATE/DELETE/NONE`。
|
||||
4. 后端严格校验决策合法性,再执行数据库动作。
|
||||
5. `UPDATE/DELETE` 也必须补齐审计日志,而不是只有 `create/delete`。
|
||||
|
||||
### 6.3 推荐的文件落点
|
||||
|
||||
建议新增文件:
|
||||
|
||||
1. `backend/memory/model/decision.go`
|
||||
- 定义决策 DTO、候选旧记忆 DTO、ApplyAction DTO。
|
||||
2. `backend/memory/orchestrator/llm_decision_orchestrator.go`
|
||||
- 负责“给定新 fact + 旧候选 -> 输出结构化动作决策”。
|
||||
3. `backend/memory/utils/decision_id_map.go`
|
||||
- 负责“真实 memory_id <-> 临时决策 ID”的映射。
|
||||
4. `backend/memory/utils/decision_validate.go`
|
||||
- 负责校验动作是否合法、目标 ID 是否存在、动作字段是否完整。
|
||||
5. `backend/memory/worker/decision_flow.go`
|
||||
- 负责 worker 内的“候选召回 -> 决策 -> 动作执行编排”。
|
||||
6. `backend/memory/worker/apply_actions.go`
|
||||
- 负责把 `ADD/UPDATE/DELETE/NONE` 落为数据库动作与审计。
|
||||
|
||||
建议修改文件:
|
||||
|
||||
1. `backend/memory/model/config.go`
|
||||
2. `backend/memory/service/config_loader.go`
|
||||
3. `backend/memory/repo/item_repo.go`
|
||||
4. `backend/memory/worker/runner.go`
|
||||
5. `backend/memory/utils/audit.go`
|
||||
|
||||
### 6.4 推荐新增配置
|
||||
|
||||
建议新增配置项,全部走 `memory` 命名空间:
|
||||
|
||||
1. `memory.decision.enabled`
|
||||
- 是否启用决策层。
|
||||
2. `memory.decision.candidateTopK`
|
||||
- 每个新 fact 召回多少个旧记忆候选。
|
||||
3. `memory.decision.fallbackMode`
|
||||
- 建议支持 `legacy_add` / `drop` 两种模式。
|
||||
4. `memory.write.mode`
|
||||
- 建议支持 `legacy` / `decision` 两种模式。
|
||||
|
||||
建议默认值:
|
||||
|
||||
1. `memory.decision.enabled=false`
|
||||
2. `memory.write.mode=legacy`
|
||||
3. `memory.decision.candidateTopK=5`
|
||||
4. `memory.decision.fallbackMode=legacy_add`
|
||||
|
||||
### 6.5 `ItemRepo` 需要补的能力
|
||||
|
||||
当前 `ItemRepo` 只有“查、建、删状态、刷访问时间、刷向量状态”,还不够支撑决策动作。第一步至少要补以下能力:
|
||||
|
||||
1. `FindDecisionCandidates(...)`
|
||||
- 按 `user_id + assistant_id + conversation_id + run_id + memory_type` 查候选。
|
||||
- 当 RAG 可用时,可优先用向量召回补候选。
|
||||
2. `UpdateContentByID(...)`
|
||||
- 用于 `UPDATE`。
|
||||
- 至少要更新:`title/content/normalized_content/content_hash/confidence/importance/sensitivity_level/is_explicit/updated_at`。
|
||||
3. `SoftDeleteByID(...)`
|
||||
- 用于决策型 `DELETE`。
|
||||
4. `FindActiveByHash(...)`
|
||||
- 给兜底幂等或低成本重复检测预留接口。
|
||||
|
||||
注意:
|
||||
|
||||
1. 不要把这些逻辑继续堆进 `UpsertItems`。
|
||||
2. `UpsertItems` 可以暂时保留给 legacy 路径使用。
|
||||
3. 新路径应尽量使用显式动作函数,而不是一个“万能 Upsert”。
|
||||
|
||||
### 6.6 Worker 内推荐的执行顺序
|
||||
|
||||
对每个 job,建议执行以下顺序:
|
||||
|
||||
1. 先抽取新事实。
|
||||
2. 对抽取结果做 `NormalizeFacts`。
|
||||
3. 按用户设置过滤。
|
||||
4. 若 `memory.decision.enabled=false`,直接走旧路径并返回。
|
||||
5. 对每条新 fact 召回旧候选:
|
||||
- 先查强约束域内候选。
|
||||
- 若 `memory.rag.enabled=true`,再用 RAG 补充语义候选。
|
||||
6. 对候选做临时 ID 映射。
|
||||
7. 调 `LLMDecisionOrchestrator` 输出动作。
|
||||
8. 后端校验动作合法性。
|
||||
9. 执行动作:
|
||||
- `ADD`:创建 item + `create` audit
|
||||
- `UPDATE`:更新旧 item + `update` audit
|
||||
- `DELETE`:软删除旧 item + `delete` audit
|
||||
- `NONE`:只记日志,不动表
|
||||
10. 根据动作决定是否做向量同步:
|
||||
- `ADD`:新增向量
|
||||
- `UPDATE`:重写向量
|
||||
- `DELETE`:删向量或打 pending 删除标记
|
||||
|
||||
### 6.7 决策 Prompt 的建议约束
|
||||
|
||||
决策 prompt 需要非常收敛,建议只允许模型做一件事:
|
||||
|
||||
1. 给定一条新 fact。
|
||||
2. 给定少量旧候选。
|
||||
3. 在 `ADD/UPDATE/DELETE/NONE` 中选一个动作。
|
||||
|
||||
不建议第一版就让模型:
|
||||
|
||||
1. 一次同时处理多条新 fact 与多条旧事实的复杂批量决策。
|
||||
2. 自己生成复杂的替代文案策略。
|
||||
3. 自己修改 scope 或元数据。
|
||||
|
||||
推荐第一版输出结构大致为:
|
||||
|
||||
```json
|
||||
{
|
||||
"decisions": [
|
||||
{
|
||||
"candidate_index": 0,
|
||||
"action": "UPDATE",
|
||||
"target_temp_id": 2,
|
||||
"title": "更新后的标题",
|
||||
"content": "更新后的内容",
|
||||
"reason": "新事实是在纠正旧事实"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.8 这一步的验收标准
|
||||
|
||||
满足以下条件,可认为第一步完成:
|
||||
|
||||
1. 重复表达同一偏好,不会连续生成多条 `active` 记忆。
|
||||
2. 用户显式纠正旧偏好时,会更倾向触发 `UPDATE`,而不是再新增一条冲突记忆。
|
||||
3. `memory_audit_logs` 能明确区分 `create/update/delete`。
|
||||
4. 决策层失败时,不会阻断原有 legacy 链路。
|
||||
5. 关闭 `memory.decision.enabled` 后,系统行为可完全回到当前实现。
|
||||
|
||||
### 6.9 这一步的回滚点
|
||||
|
||||
第一步必须保留明确回滚点:
|
||||
|
||||
1. 关闭 `memory.decision.enabled`
|
||||
2. `memory.write.mode` 切回 `legacy`
|
||||
|
||||
回滚后仍然使用:
|
||||
|
||||
1. `LLMWriteOrchestrator.ExtractFacts`
|
||||
2. `NormalizeFacts`
|
||||
3. `buildMemoryItems`
|
||||
4. `ItemRepo.UpsertItems`
|
||||
|
||||
### 6.10 这一步明确不做什么
|
||||
|
||||
第一步不要顺手做以下事情:
|
||||
|
||||
1. 不重构 `newAgent` 图节点。
|
||||
2. 不引入 memory 工具调用。
|
||||
3. 不做图记忆。
|
||||
4. 不做用户侧“编辑记忆内容”的管理 API。
|
||||
5. 不同时改 WebSearch 的 RAG 链路。
|
||||
|
||||
---
|
||||
|
||||
## 7. 第二步:把读取与注入做成真正可用的记忆链路
|
||||
|
||||
### 7.1 这一步解决什么问题
|
||||
|
||||
写入侧即使更聪明,如果读出来的还是“按分数凑五条,再平铺给 prompt”,整体体验依然不会像 Mem0。
|
||||
|
||||
第二步要解决的问题是:
|
||||
|
||||
1. 硬约束和偏好不能被普通事实挤掉。
|
||||
2. 历史重复项不能继续在读侧污染 TopK。
|
||||
3. 注入给模型的文本需要可控,而不是简单平铺。
|
||||
4. RAG 可用时要真正成为加分项,不可用时要稳定降级。
|
||||
|
||||
### 7.2 本轮要落的能力
|
||||
|
||||
第二步必须落地以下能力:
|
||||
|
||||
1. 读侧合并“结构化强约束召回”和“语义候选召回”。
|
||||
2. 读侧在服务层做真正的去重,而不是只在渲染字符串时弱去重。
|
||||
3. 注入文本按类型分组,而不是所有内容同一层级平铺。
|
||||
4. 给每一类记忆设置注入预算,避免事实类把 prompt 撑爆。
|
||||
|
||||
### 7.3 推荐的文件落点
|
||||
|
||||
建议优先修改文件:
|
||||
|
||||
1. `backend/memory/service/read_service.go`
|
||||
2. `backend/memory/repo/item_repo.go`
|
||||
3. `backend/service/agentsvc/agent_memory.go`
|
||||
|
||||
如需补辅助文件,建议新增:
|
||||
|
||||
1. `backend/memory/service/retrieve_merge.go`
|
||||
- 负责多路召回的结果合并、去重、预算裁剪。
|
||||
2. `backend/memory/service/retrieve_rank.go`
|
||||
- 负责重排与门控。
|
||||
3. `backend/service/agentsvc/agent_memory_render.go`
|
||||
- 负责把 memory DTO 渲染成稳定的注入 block。
|
||||
|
||||
说明:
|
||||
|
||||
1. 当前 `agent_memory.go` 已经不算小。
|
||||
2. 第二步不要继续往单文件里堆“召回策略 + 去重 + 渲染模板”。
|
||||
3. 这一轮拆开渲染层是合理的职责拆分,不属于跨能力域大重构。
|
||||
|
||||
### 7.4 读取侧推荐的新流程
|
||||
|
||||
建议读侧升级为以下顺序:
|
||||
|
||||
1. 先从 MySQL 拉“必守约束”:
|
||||
- `constraint`
|
||||
- 高置信度 `preference`
|
||||
2. 再按当前 query 做相关召回:
|
||||
- 若 `memory.rag.enabled=true`,优先走 RAG
|
||||
- 否则走 legacy DB 排序
|
||||
3. 合并两路结果。
|
||||
4. 先按 `memory_id` 去重。
|
||||
5. 再按 `content_hash` 去重。
|
||||
6. 最后才按渲染文本兜底去重。
|
||||
7. 对结果做类型预算:
|
||||
- `constraint`:优先保留
|
||||
- `preference`:次优先
|
||||
- `todo_hint`:控制数量
|
||||
- `fact`:最容易膨胀,要严格限额
|
||||
|
||||
### 7.5 注入层推荐的渲染方式
|
||||
|
||||
当前渲染方式更像“扁平清单”。第二步建议升级成“分段注入”,例如:
|
||||
|
||||
1. 必守约束
|
||||
2. 用户偏好
|
||||
3. 当前话题相关事实
|
||||
4. 近期线索
|
||||
|
||||
推荐生成类似文本:
|
||||
|
||||
```text
|
||||
以下是与当前对话相关的用户记忆,仅在确实有帮助时参考,不要机械复述。
|
||||
|
||||
【必守约束】
|
||||
- 用户点外卖不要香菜。
|
||||
|
||||
【用户偏好】
|
||||
- 用户偏爱黑咖啡。
|
||||
|
||||
【当前话题相关事实】
|
||||
- 用户最近在准备周四的程序设计作业。
|
||||
```
|
||||
|
||||
这样做的好处:
|
||||
|
||||
1. 模型更容易区分“必须遵守”和“仅可参考”。
|
||||
2. 日后更容易按类型做 budget。
|
||||
3. 若发生错误注入,也更容易解释是哪一层出错。
|
||||
|
||||
### 7.6 第二步建议新增配置
|
||||
|
||||
建议新增:
|
||||
|
||||
1. `memory.read.mode`
|
||||
- 建议支持 `legacy` / `hybrid`
|
||||
2. `memory.read.factLimit`
|
||||
3. `memory.read.preferenceLimit`
|
||||
4. `memory.read.constraintLimit`
|
||||
5. `memory.inject.renderMode`
|
||||
- 建议支持 `flat` / `typed_v2`
|
||||
|
||||
建议默认值:
|
||||
|
||||
1. `memory.read.mode=legacy`
|
||||
2. `memory.inject.renderMode=flat`
|
||||
|
||||
灰度时再逐步切到:
|
||||
|
||||
1. `memory.read.mode=hybrid`
|
||||
2. `memory.inject.renderMode=typed_v2`
|
||||
|
||||
### 7.7 这一步的验收标准
|
||||
|
||||
满足以下条件,可认为第二步完成:
|
||||
|
||||
1. 同一条重复记忆即使数据库里有多条,最终注入给 prompt 也只保留一条。
|
||||
2. `constraint` 类记忆不会轻易被 `fact` 类挤出注入集合。
|
||||
3. RAG 异常时,系统仍能稳定退回 legacy 读取逻辑。
|
||||
4. 注入文本结构清晰,且总长度稳定,不会一轮长一轮短。
|
||||
5. newAgent 的 `pinned block` 内容更可读、更可解释。
|
||||
|
||||
### 7.8 这一步的回滚点
|
||||
|
||||
第二步必须支持快速回滚:
|
||||
|
||||
1. `memory.read.mode=legacy`
|
||||
2. `memory.inject.renderMode=flat`
|
||||
3. `memory.rag.enabled=false`
|
||||
|
||||
回滚后保留:
|
||||
|
||||
1. 旧的 `ReadService.retrieveByLegacy`
|
||||
2. 当前 `agent_memory.go` 扁平渲染逻辑
|
||||
|
||||
### 7.9 这一步明确不做什么
|
||||
|
||||
第二步不要顺手做以下事情:
|
||||
|
||||
1. 不把 memory 改造成工具调用。
|
||||
2. 不改 `newAgent` 的图路由结构。
|
||||
3. 不把 WebSearch 一起并进统一召回。
|
||||
4. 不在这一轮清理历史重复脏数据。
|
||||
|
||||
---
|
||||
|
||||
## 8. 第三步:做治理、清理、指标与切流收口
|
||||
|
||||
### 8.1 这一步解决什么问题
|
||||
|
||||
前两步做完后,系统可能“效果已经不错”,但仍缺三个上线必须项:
|
||||
|
||||
1. 出问题时怎么知道错在哪一层。
|
||||
2. 历史已经写进去的重复脏数据怎么治理。
|
||||
3. 什么时候能把 legacy 路径关掉。
|
||||
|
||||
第三步就是收口这一层。
|
||||
|
||||
### 8.2 本轮要落的能力
|
||||
|
||||
第三步建议至少做以下能力:
|
||||
|
||||
1. 为写入决策、读取召回、注入渲染补齐结构化日志和指标。
|
||||
2. 增加历史重复清理能力。
|
||||
3. 补齐“我的记忆”增删改查语义,以及必要的最小变更留痕。
|
||||
4. 明确 feature flag 切流策略与回滚手册。
|
||||
5. 更新文档,避免后续维护者只看到旧 README。
|
||||
|
||||
### 8.3 推荐的文件落点
|
||||
|
||||
建议修改文件:
|
||||
|
||||
1. `backend/memory/utils/audit.go`
|
||||
2. `backend/memory/service/manage_service.go`
|
||||
3. `backend/memory/repo/item_repo.go`
|
||||
4. `backend/memory/README.md`
|
||||
5. `backend/memory/记忆模块实施计划.md`
|
||||
|
||||
建议新增文件:
|
||||
|
||||
1. `backend/memory/cleanup/dedup_runner.go`
|
||||
- 用于历史重复治理。
|
||||
2. `backend/memory/cleanup/dedup_policy.go`
|
||||
- 负责定义“保留哪条、归档哪条”。
|
||||
3. `backend/memory/observe/log_fields.go`
|
||||
- 统一日志字段,避免不同文件各写各的。
|
||||
|
||||
### 8.4 历史数据清理建议
|
||||
|
||||
建议不要直接写危险 SQL 一把梭清表,而是通过可留痕的治理流程清理历史脏数据:
|
||||
|
||||
1. 按 `user_id + memory_type + content_hash + status=active` 扫描重复组。
|
||||
2. 为每组挑一个保留主记录:
|
||||
- 优先保留最近更新
|
||||
- 或优先保留置信度更高
|
||||
3. 其余重复项改为 `archived` 或 `deleted`。
|
||||
4. 对每次治理动作写最小变更留痕。
|
||||
|
||||
建议第一版优先做“离线治理工具”或“手动触发 job”,不要直接绑到主 worker 周期任务里。
|
||||
|
||||
### 8.5 建议补的指标
|
||||
|
||||
第三步建议至少打这些指标:
|
||||
|
||||
1. `memory_job_success_rate`
|
||||
2. `memory_job_retry_rate`
|
||||
3. `memory_decision_distribution`
|
||||
4. `memory_decision_fallback_rate`
|
||||
5. `memory_retrieve_hit_count`
|
||||
6. `memory_retrieve_dedup_drop_count`
|
||||
7. `memory_inject_item_count`
|
||||
8. `memory_rag_fallback_rate`
|
||||
9. `memory_wrong_mention_rate`
|
||||
10. `memory_user_correction_rate`
|
||||
|
||||
其中前八项可以本轮先落,后两项可通过后续用户纠正入口接入。
|
||||
|
||||
### 8.6 建议的切流顺序
|
||||
|
||||
第三步不要“一刀切”。建议按以下顺序灰度:
|
||||
|
||||
1. 阶段 A:决策层 shadow 模式
|
||||
- 真正写库仍走 legacy
|
||||
- 新决策层只做日志,不生效
|
||||
2. 阶段 B:决策层仅对显式记忆生效
|
||||
3. 阶段 C:决策层对全部写入生效
|
||||
4. 阶段 D:读侧切到 hybrid
|
||||
5. 阶段 E:注入切到 typed_v2
|
||||
6. 阶段 F:历史清理跑完,再考虑关闭 legacy 默认路径
|
||||
|
||||
### 8.7 这一步的验收标准
|
||||
|
||||
满足以下条件,可认为第三步完成:
|
||||
|
||||
1. 能从日志看清某条记忆为何被 `ADD/UPDATE/DELETE/NONE`。
|
||||
2. 能从指标看清读侧命中、去重、降级、回滚情况。
|
||||
3. 能对历史重复数据做可留痕清理。
|
||||
4. 出现异常时可在分钟级通过开关退回 legacy。
|
||||
5. 文档与代码现状一致,不再依赖口头传递。
|
||||
|
||||
### 8.8 这一步的回滚点
|
||||
|
||||
第三步的回滚不应影响前两步代码保留,只需回切开关:
|
||||
|
||||
1. 决策层回到 `legacy`
|
||||
2. 读侧回到 `legacy`
|
||||
3. 注入渲染回到 `flat`
|
||||
4. 停掉清理任务
|
||||
|
||||
### 8.9 这一步明确不做什么
|
||||
|
||||
第三步仍然不建议同时做以下事情:
|
||||
|
||||
1. 不做图记忆。
|
||||
2. 不做多 Provider 工厂化。
|
||||
3. 不拆独立 memory 服务。
|
||||
4. 不把 WebSearch 与 Memory 强行合并到同一轮上线。
|
||||
|
||||
---
|
||||
|
||||
## 9. 推荐的三轮交付顺序
|
||||
|
||||
如果资源有限,建议严格按下面顺序推进:
|
||||
|
||||
1. 先做第一步。
|
||||
- 原因:写侧如果还是“抽取即新增”,读侧再怎么优化也会越来越脏。
|
||||
2. 再做第二步。
|
||||
- 原因:写侧稳定后,读侧才能真正体现效果。
|
||||
3. 最后做第三步。
|
||||
- 原因:治理、指标、清理要建立在前两步能力已经基本成形的前提下。
|
||||
|
||||
一句话总结:
|
||||
|
||||
1. 先让系统“会整理记忆”。
|
||||
2. 再让系统“会正确读记忆”。
|
||||
3. 最后让系统“可稳定上线和维护”。
|
||||
|
||||
---
|
||||
|
||||
## 10. 建议的任务拆分方式
|
||||
|
||||
如果后续要多人并行,建议按职责边界拆,而不是按文件随意拆:
|
||||
|
||||
### 10.1 第一步可拆为两块
|
||||
|
||||
1. 决策模型与编排
|
||||
- `decision.go`
|
||||
- `llm_decision_orchestrator.go`
|
||||
- `decision_validate.go`
|
||||
2. Repo 与动作执行
|
||||
- `item_repo.go`
|
||||
- `apply_actions.go`
|
||||
- `audit.go`
|
||||
|
||||
### 10.2 第二步可拆为两块
|
||||
|
||||
1. 读侧召回与合并
|
||||
- `read_service.go`
|
||||
- `retrieve_merge.go`
|
||||
- `retrieve_rank.go`
|
||||
2. newAgent 注入渲染
|
||||
- `agent_memory.go`
|
||||
- `agent_memory_render.go`
|
||||
|
||||
### 10.3 第三步可拆为两块
|
||||
|
||||
1. 治理与清理
|
||||
- `dedup_runner.go`
|
||||
- `manage_service.go`
|
||||
2. 观测与文档
|
||||
- 指标日志
|
||||
- README / 计划文档更新
|
||||
|
||||
---
|
||||
|
||||
## 11. 如果只看一个结论,请看这里
|
||||
|
||||
要让当前 memory 真正靠近 Mem0,不是再加一张表,也不是再加一个 prompt,而是要完成以下收敛:
|
||||
|
||||
1. 写入侧从“抽到就加”升级为“先回看旧记忆,再决定加改删不做”。
|
||||
2. 读侧从“查到就拼”升级为“硬约束优先、语义补充、结果去重、预算注入”。
|
||||
3. 系统侧从“能跑”升级为“有灰度、有指标、有清理、有回滚”。
|
||||
|
||||
只要三步按这个顺序推进,最终得到的就不是一个“会不断积灰的记忆表”,而是一套真正能为 `newAgent` 服务的记忆系统。
|
||||
@@ -1,43 +1,78 @@
|
||||
2026/04/16 11:24:55 D:/SmartFlow-Agent/backend/dao/agent.go:306 record not found
|
||||
[44.328ms] [rows:0] SELECT * FROM `agent_chats` WHERE user_id = 1 AND chat_id = 'df7ce26d-6952-493d-ac7f-3bfe98cbc338' ORDER BY `agent_chats`.`id` LIMIT 1
|
||||
2026/04/16 11:24:55 [DEBUG] loadOrCreateRuntimeState chatID=df7ce26d-6952-493d-ac7f-3bfe98cbc338 ok=false err=<nil> hasRuntime=false hasPending=false hasCtx=false hasSchedule=false hasOriginal=false
|
||||
2026/04/16 11:24:55 [GORM-Cache] Invalidated conversation history cache for user 1 conversation df7ce26d-6952-493d-ac7f-3bfe98cbc338
|
||||
2026/04/16 11:24:56 rag level=info component=store operation=ensure_collection action=search collection=smartflow_rag_chunks corpus=memory latency_ms=40 metric_type=COSINE status=created store=milvus vector_dim=1024
|
||||
2026/04/16 11:24:57 rag level=error component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory error=Post "http://localhost:19530/v2/vectordb/entities/search": context deadline exceeded error_code=DEADLINE_EXCEEDED filter_count=3 latency_ms=1304 status=failed store=milvus top_k=5 vector_dim=1024
|
||||
2026/04/16 11:24:57 rag level=error component=runtime operation=retrieve action=search corpus=memory error=Post "http://localhost:19530/v2/vectordb/entities/search": context deadline exceeded error_code=DEADLINE_EXCEEDED latency_ms=1500 query_len=48 status=failed threshold=0.55 top_k=5
|
||||
2026/04/16 11:25:03 [DEBUG] chat routing chat=df7ce26d-6952-493d-ac7f-3bfe98cbc338 route=direct_reply needs_rough_build=false needs_refine_after_rough_build=false allow_reorder=false thinking=false has_rough_build_done=false task_class_count=0 raw=<SMARTFLOW_ROUTE nonce="84656bca-1aa3-4308-bb7d-5127badf9d47" route="direct_reply"/>
|
||||
[GIN] 2026/04/16 - 11:25:04 | 200 | 9.3560115s | 127.0.0.1 | POST "/api/v1/agent/chat"
|
||||
2026/04/16 11:25:05 outbox due messages=3, start dispatch
|
||||
2026/04/16 11:25:06 [GORM-Cache] Invalidated conversation history cache for user 1 conversation df7ce26d-6952-493d-ac7f-3bfe98cbc338
|
||||
2026/04/16 11:25:07 [GORM-Cache] Invalidated conversation history cache for user 1 conversation df7ce26d-6952-493d-ac7f-3bfe98cbc338
|
||||
2026/04/16 11:25:08 outbox due messages=1, start dispatch
|
||||
2026/04/16 11:25:09 outbox due messages=1, start dispatch
|
||||
2026/04/16 11:25:18 rag level=info component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory filter_count=3 latency_ms=7 result_count=0 status=success store=milvus top_k=5 vector_dim=1024
|
||||
2026/04/16 11:25:18 rag level=info component=runtime operation=retrieve action=search corpus=memory fallback_used=false hit_count=0 latency_ms=100 query_len=21 raw_count=0 status=success threshold=0.6 top_k=5
|
||||
2026/04/16 11:25:18 [DEBUG][去重] 语义召回候选: job_id=18 user_id=1 memory_type=preference candidate_count=0
|
||||
2026/04/16 11:25:18 [DEBUG][去重] 汇总决策: job_id=18 action=ADD target_id=0 reason="无相关旧记忆,直接新增"
|
||||
2026/04/16 11:25:19 rag level=info component=store operation=upsert action=add collection=smartflow_rag_chunks corpus=memory latency_ms=53 row_count=1 status=success store=milvus vector_dim=1024
|
||||
2026/04/16 11:25:19 rag level=info component=runtime operation=ingest action=add chunk_count=1 corpus=memory document_count=1 latency_ms=158 status=success
|
||||
2026/04/16 11:25:19 [去重] 决策流程完成: job_id=18 user_id=1 新增=1 更新=0 删除=0 跳过=0
|
||||
GOROOT=C:\Program Files\Go #gosetup
|
||||
GOPATH=C:\Users\Dev\go #gosetup
|
||||
"C:\Program Files\Go\bin\go.exe" build -o C:\Users\Dev\AppData\Local\JetBrains\GoLand2025.3\tmp\GoLand\___7go_build_main_go.exe D:\SmartFlow-Agent\backend\main.go #gosetup
|
||||
C:\Users\Dev\AppData\Local\JetBrains\GoLand2025.3\tmp\GoLand\___7go_build_main_go.exe #gosetup
|
||||
2026/04/16 16:00:27 Config loaded successfully
|
||||
2026/04/16 16:00:36 Database connected successfully
|
||||
2026/04/16 16:00:36 Database auto migration completed
|
||||
2026/04/16 16:00:36 RAG runtime initialized: store=milvus embed=eino reranker=noop
|
||||
2026/04/16 16:00:36 outbox engine starting: topic=smartflow.agent.outbox brokers=[localhost:9092] retry_scan=1s batch=100
|
||||
2026/04/16 16:00:36 Kafka topic is ready: smartflow.agent.outbox
|
||||
2026/04/16 16:00:36 Outbox event bus started
|
||||
2026/04/16 16:00:36 Memory worker started
|
||||
2026/04/16 16:00:36 WebSearch provider: bocha
|
||||
2026/04/16 16:00:36 Routes setup completed
|
||||
2026/04/16 16:00:36 Server starting on port 8080...
|
||||
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
|
||||
|
||||
2026/04/16 11:25:44 D:/SmartFlow-Agent/backend/dao/agent.go:306 record not found
|
||||
[2.018ms] [rows:0] SELECT * FROM `agent_chats` WHERE user_id = 1 AND chat_id = '6279c9f0-0685-4484-bb33-d4216ef6107c' ORDER BY `agent_chats`.`id` LIMIT 1
|
||||
2026/04/16 11:25:44 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 6279c9f0-0685-4484-bb33-d4216ef6107c
|
||||
2026/04/16 11:25:44 [DEBUG] loadOrCreateRuntimeState chatID=6279c9f0-0685-4484-bb33-d4216ef6107c ok=false err=<nil> hasRuntime=false hasPending=false hasCtx=false hasSchedule=false hasOriginal=false
|
||||
2026/04/16 11:25:44 rag level=info component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory filter_count=3 latency_ms=46 result_count=0 status=success store=milvus top_k=5 vector_dim=1024
|
||||
2026/04/16 11:25:44 rag level=info component=runtime operation=retrieve action=search corpus=memory fallback_used=false hit_count=0 latency_ms=145 query_len=45 raw_count=0 status=success threshold=0.55 top_k=5
|
||||
2026/04/16 11:25:48 [DEBUG] chat routing chat=6279c9f0-0685-4484-bb33-d4216ef6107c route=direct_reply needs_rough_build=false needs_refine_after_rough_build=false allow_reorder=false thinking=false has_rough_build_done=false task_class_count=0 raw=<SMARTFLOW_ROUTE nonce="a868c365-4f8c-4d56-ac90-a8504842f81c" route="direct_reply"/>
|
||||
[GIN] 2026/04/16 - 11:25:49 | 200 | 5.3825319s | 127.0.0.1 | POST "/api/v1/agent/chat"
|
||||
2026/04/16 11:25:50 outbox due messages=3, start dispatch
|
||||
2026/04/16 11:25:51 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 6279c9f0-0685-4484-bb33-d4216ef6107c
|
||||
2026/04/16 11:25:52 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 6279c9f0-0685-4484-bb33-d4216ef6107c
|
||||
2026/04/16 11:25:53 outbox due messages=2, start dispatch
|
||||
2026/04/16 11:25:58 rag level=info component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory filter_count=3 latency_ms=53 result_count=1 status=success store=milvus top_k=5 vector_dim=1024
|
||||
2026/04/16 11:25:58 rag level=info component=runtime operation=retrieve action=search corpus=memory fallback_used=false hit_count=1 latency_ms=143 query_len=18 raw_count=1 status=success threshold=0.6 top_k=5
|
||||
2026/04/16 11:25:58 [WARN][去重] DocumentID 解析失败,跳过候选: document_id="memory:uid:1:6bf14130e4dfc8bd"
|
||||
2026/04/16 11:25:58 [WARN][去重] Milvus 返回 1 条结果但 DocumentID 全部解析失败,降级到 MySQL: user_id=1 memory_type=preference
|
||||
2026/04/16 11:25:58 [DEBUG][去重] 语义召回候选: job_id=19 user_id=1 memory_type=preference candidate_count=1
|
||||
2026/04/16 11:25:58 [DEBUG][去重] 候选详情: memory_id=17 score=0.0000 content="用户喜欢听音乐"
|
||||
2026/04/16 11:26:04 [DEBUG][去重] LLM 比对结果: candidate_id=17 score=0.0000 relation=duplicate reason="听歌和听音乐表达相同意思" candidate_content="用户喜欢听音乐"
|
||||
2026/04/16 11:26:04 [DEBUG][去重] 汇总决策: job_id=19 action=NONE target_id=0 reason="存在完全重复的旧记忆,跳过写入"
|
||||
2026/04/16 11:26:04 [去重] 决策流程完成: job_id=19 user_id=1 新增=0 更新=0 删除=0 跳过=1
|
||||
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
|
||||
- using env: export GIN_MODE=release
|
||||
- using code: gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
[GIN-debug] GET /api/v1/health --> github.com/LoveLosita/smartflow/backend/routers.RegisterRouters.func1 (3 handlers)
|
||||
[GIN-debug] POST /api/v1/user/register --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserRegister-fm (3 handlers)
|
||||
[GIN-debug] POST /api/v1/user/login --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserLogin-fm (3 handlers)
|
||||
[GIN-debug] POST /api/v1/user/refresh-token --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).RefreshTokenHandler-fm (3 handlers)
|
||||
[GIN-debug] POST /api/v1/user/logout --> github.com/LoveLosita/smartflow/backend/api.(*UserHandler).UserLogout-fm (5 handlers)
|
||||
[GIN-debug] POST /api/v1/task/create --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).AddTask-fm (6 handlers)
|
||||
[GIN-debug] PUT /api/v1/task/complete --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).CompleteTask-fm (6 handlers)
|
||||
[GIN-debug] PUT /api/v1/task/undo-complete --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).UndoCompleteTask-fm (6 handlers)
|
||||
[GIN-debug] GET /api/v1/task/get --> github.com/LoveLosita/smartflow/backend/api.(*TaskHandler).GetUserTasks-fm (5 handlers)
|
||||
[GIN-debug] POST /api/v1/course/validate --> github.com/LoveLosita/smartflow/backend/api.(*CourseHandler).CheckUserCourse-fm (5 handlers)
|
||||
[GIN-debug] POST /api/v1/course/import --> github.com/LoveLosita/smartflow/backend/api.(*CourseHandler).AddUserCourses-fm (6 handlers)
|
||||
[GIN-debug] POST /api/v1/task-class/add --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserAddTaskClass-fm (6 handlers)
|
||||
[GIN-debug] GET /api/v1/task-class/list --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserGetTaskClassInfos-fm (5 handlers)
|
||||
[GIN-debug] GET /api/v1/task-class/get --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserGetCompleteTaskClass-fm (5 handlers)
|
||||
[GIN-debug] PUT /api/v1/task-class/update --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserUpdateTaskClass-fm (6 handlers)
|
||||
[GIN-debug] POST /api/v1/task-class/insert-into-schedule --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserAddTaskClassItemIntoSchedule-fm (6 handlers)
|
||||
[GIN-debug] DELETE /api/v1/task-class/delete-item --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).DeleteTaskClassItem-fm (6 handlers)
|
||||
[GIN-debug] DELETE /api/v1/task-class/delete-class --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).DeleteTaskClass-fm (6 handlers)
|
||||
[GIN-debug] PUT /api/v1/task-class/apply-batch-into-schedule --> github.com/LoveLosita/smartflow/backend/api.(*TaskClassHandler).UserInsertBatchTaskClassItemsIntoSchedule-fm (6 handlers)
|
||||
[GIN-debug] GET /api/v1/schedule/today --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserTodaySchedule-fm (5 handlers)
|
||||
[GIN-debug] GET /api/v1/schedule/week --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserWeeklySchedule-fm (5 handlers)
|
||||
[GIN-debug] DELETE /api/v1/schedule/delete --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).DeleteScheduleEvent-fm (6 handlers)
|
||||
[GIN-debug] GET /api/v1/schedule/recent-completed --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserRecentCompletedSchedules-fm (5 handlers)
|
||||
[GIN-debug] GET /api/v1/schedule/current --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).GetUserOngoingSchedule-fm (5 handlers)
|
||||
[GIN-debug] DELETE /api/v1/schedule/undo-task-item --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).UserRevocateTaskItemFromSchedule-fm (6 handlers)
|
||||
[GIN-debug] GET /api/v1/schedule/smart-planning --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).SmartPlanning-fm (5 handlers)
|
||||
[GIN-debug] POST /api/v1/schedule/smart-planning-multi --> github.com/LoveLosita/smartflow/backend/api.(*ScheduleAPI).SmartPlanningMulti-fm (5 handlers)
|
||||
[GIN-debug] POST /api/v1/agent/chat --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).ChatAgent-fm (6 handlers)
|
||||
[GIN-debug] GET /api/v1/agent/conversation-meta --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationMeta-fm (5 handlers)
|
||||
[GIN-debug] GET /api/v1/agent/conversation-list --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationList-fm (5 handlers)
|
||||
[GIN-debug] GET /api/v1/agent/conversation-history --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetConversationHistory-fm (5 handlers)
|
||||
[GIN-debug] GET /api/v1/agent/schedule-preview --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetSchedulePlanPreview-fm (5 handlers)
|
||||
[GIN-debug] GET /api/v1/agent/context-stats --> github.com/LoveLosita/smartflow/backend/api.(*AgentHandler).GetContextStats-fm (5 handlers)
|
||||
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
|
||||
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
|
||||
[GIN-debug] Listening and serving HTTP on :8080
|
||||
[GIN] 2026/04/16 - 16:00:38 | 200 | 47.9273ms | 127.0.0.1 | GET "/api/v1/agent/conversation-list?page=1&page_size=12&limit=12&status=active"
|
||||
[GIN] 2026/04/16 - 16:00:38 | 200 | 12.4182ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=d1dda8e1-b7f0-4721-ad84-529ecad5d637"
|
||||
[GIN] 2026/04/16 - 16:00:38 | 200 | 88.1335ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=d1dda8e1-b7f0-4721-ad84-529ecad5d637"
|
||||
|
||||
2026/04/16 16:01:07 D:/SmartFlow-Agent/backend/dao/agent.go:306 record not found
|
||||
[42.474ms] [rows:0] SELECT * FROM `agent_chats` WHERE user_id = 1 AND chat_id = '284c4b76-d6cc-40a6-b3de-fa4c8288022b' ORDER BY `agent_chats`.`id` LIMIT 1
|
||||
2026/04/16 16:01:07 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 284c4b76-d6cc-40a6-b3de-fa4c8288022b
|
||||
2026/04/16 16:01:07 [DEBUG] loadOrCreateRuntimeState chatID=284c4b76-d6cc-40a6-b3de-fa4c8288022b ok=false err=<nil> hasRuntime=false hasPending=false hasCtx=false hasSchedule=false hasOriginal=false
|
||||
2026/04/16 16:01:08 rag level=info component=store operation=ensure_collection action=search collection=smartflow_rag_chunks corpus=memory latency_ms=4 metric_type=COSINE status=already_exists store=milvus vector_dim=1024
|
||||
2026/04/16 16:01:08 rag level=info component=store operation=search action=search collection=smartflow_rag_chunks corpus=memory filter_count=3 latency_ms=51 result_count=0 status=success store=milvus top_k=18 vector_dim=1024
|
||||
2026/04/16 16:01:08 rag level=info component=runtime operation=retrieve action=search corpus=memory fallback_used=false hit_count=0 latency_ms=255 query_len=51 raw_count=0 status=success threshold=0.55 top_k=18
|
||||
2026/04/16 16:01:26 [DEBUG] chat routing chat=284c4b76-d6cc-40a6-b3de-fa4c8288022b route=direct_reply needs_rough_build=false needs_refine_after_rough_build=false allow_reorder=false thinking=false has_rough_build_done=false task_class_count=0 raw=<SMARTFLOW_ROUTE nonce="9b04f5df-3452-4a15-a39f-0449c1851729" route="direct_reply"/>
|
||||
[GIN] 2026/04/16 - 16:01:27 | 200 | 19.3318195s | 127.0.0.1 | POST "/api/v1/agent/chat"
|
||||
[GIN] 2026/04/16 - 16:01:27 | 200 | 84.0901ms | 127.0.0.1 | GET "/api/v1/agent/conversation-list?page=1&page_size=12&limit=12&status=active"
|
||||
2026/04/16 16:01:27 outbox due messages=3, start dispatch
|
||||
[GIN] 2026/04/16 - 16:01:27 | 200 | 2.24ms | 127.0.0.1 | GET "/api/v1/agent/conversation-meta?conversation_id=284c4b76-d6cc-40a6-b3de-fa4c8288022b"
|
||||
[GIN] 2026/04/16 - 16:01:27 | 200 | 46.4062ms | 127.0.0.1 | GET "/api/v1/agent/conversation-history?conversation_id=284c4b76-d6cc-40a6-b3de-fa4c8288022b"
|
||||
2026/04/16 16:01:28 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 284c4b76-d6cc-40a6-b3de-fa4c8288022b
|
||||
2026/04/16 16:01:29 [GORM-Cache] Invalidated conversation history cache for user 1 conversation 284c4b76-d6cc-40a6-b3de-fa4c8288022b
|
||||
2026/04/16 16:01:30 outbox due messages=1, start dispatch
|
||||
2026/04/16 16:01:31 异步生成会话标题失败(模型生成失败) chat=284c4b76-d6cc-40a6-b3de-fa4c8288022b err=failed to create chat completion: context deadline exceeded
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MemoryReadModeLegacy 表示读取侧沿用“RAG 优先,失败再走 legacy”旧链路。
|
||||
MemoryReadModeLegacy = "legacy"
|
||||
// MemoryReadModeHybrid 表示读取侧走“结构化强约束 + 语义候选”混合链路。
|
||||
MemoryReadModeHybrid = "hybrid"
|
||||
|
||||
// MemoryInjectRenderModeFlat 表示沿用扁平列表渲染。
|
||||
MemoryInjectRenderModeFlat = "flat"
|
||||
// MemoryInjectRenderModeTypedV2 表示按记忆类型分段渲染。
|
||||
MemoryInjectRenderModeTypedV2 = "typed_v2"
|
||||
|
||||
// DefaultReadConstraintLimit 是 constraint 默认预算上限。
|
||||
DefaultReadConstraintLimit = 5
|
||||
// DefaultReadPreferenceLimit 是 preference 默认预算上限。
|
||||
DefaultReadPreferenceLimit = 5
|
||||
// DefaultReadFactLimit 是 fact 默认预算上限。
|
||||
DefaultReadFactLimit = 5
|
||||
// DefaultReadTodoHintLimit 是 todo_hint 默认预算上限。
|
||||
DefaultReadTodoHintLimit = 3
|
||||
)
|
||||
|
||||
// Config 是记忆模块配置对象(Day1 首版)。
|
||||
//
|
||||
@@ -11,6 +35,13 @@ type Config struct {
|
||||
Enabled bool
|
||||
RAGEnabled bool
|
||||
|
||||
ReadMode string
|
||||
ReadConstraintLimit int
|
||||
ReadPreferenceLimit int
|
||||
ReadFactLimit int
|
||||
ReadTodoHintLimit int
|
||||
InjectRenderMode string
|
||||
|
||||
ExtractPrompt string
|
||||
DecisionPrompt string
|
||||
|
||||
@@ -35,3 +66,68 @@ type Config struct {
|
||||
DecisionFallbackMode string // "legacy_add"(退回旧路径直接新增)/ "drop"(丢弃)
|
||||
WriteMode string // "legacy"(旧路径)/ "decision"(决策流程),仅 DecisionEnabled=true 时生效
|
||||
}
|
||||
|
||||
// NormalizeReadMode 统一读取模式字符串。
|
||||
func NormalizeReadMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case MemoryReadModeHybrid:
|
||||
return MemoryReadModeHybrid
|
||||
default:
|
||||
return MemoryReadModeLegacy
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeInjectRenderMode 统一注入渲染模式字符串。
|
||||
func NormalizeInjectRenderMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case MemoryInjectRenderModeTypedV2:
|
||||
return MemoryInjectRenderModeTypedV2
|
||||
default:
|
||||
return MemoryInjectRenderModeFlat
|
||||
}
|
||||
}
|
||||
|
||||
// EffectiveReadConstraintLimit 返回 constraint 生效预算。
|
||||
func (c Config) EffectiveReadConstraintLimit() int {
|
||||
return normalizePositiveLimit(c.ReadConstraintLimit, DefaultReadConstraintLimit)
|
||||
}
|
||||
|
||||
// EffectiveReadPreferenceLimit 返回 preference 生效预算。
|
||||
func (c Config) EffectiveReadPreferenceLimit() int {
|
||||
return normalizePositiveLimit(c.ReadPreferenceLimit, DefaultReadPreferenceLimit)
|
||||
}
|
||||
|
||||
// EffectiveReadFactLimit 返回 fact 生效预算。
|
||||
func (c Config) EffectiveReadFactLimit() int {
|
||||
return normalizePositiveLimit(c.ReadFactLimit, DefaultReadFactLimit)
|
||||
}
|
||||
|
||||
// EffectiveReadTodoHintLimit 返回 todo_hint 生效预算。
|
||||
func (c Config) EffectiveReadTodoHintLimit() int {
|
||||
return normalizePositiveLimit(c.ReadTodoHintLimit, DefaultReadTodoHintLimit)
|
||||
}
|
||||
|
||||
// EffectiveReadMode 返回生效读取模式。
|
||||
func (c Config) EffectiveReadMode() string {
|
||||
return NormalizeReadMode(c.ReadMode)
|
||||
}
|
||||
|
||||
// EffectiveInjectRenderMode 返回生效渲染模式。
|
||||
func (c Config) EffectiveInjectRenderMode() string {
|
||||
return NormalizeInjectRenderMode(c.InjectRenderMode)
|
||||
}
|
||||
|
||||
// TotalReadBudget 返回四类记忆的总预算上限。
|
||||
func (c Config) TotalReadBudget() int {
|
||||
return c.EffectiveReadConstraintLimit() +
|
||||
c.EffectiveReadPreferenceLimit() +
|
||||
c.EffectiveReadFactLimit() +
|
||||
c.EffectiveReadTodoHintLimit()
|
||||
}
|
||||
|
||||
func normalizePositiveLimit(value int, defaultValue int) int {
|
||||
if value <= 0 {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type ItemDTO struct {
|
||||
MemoryType string
|
||||
Title string
|
||||
Content string
|
||||
ContentHash string
|
||||
Confidence float64
|
||||
Importance float64
|
||||
SensitivityLevel int
|
||||
|
||||
@@ -91,6 +91,72 @@ func (r *ItemRepo) FindByQuery(ctx context.Context, query memorymodel.ItemQuery)
|
||||
return items, err
|
||||
}
|
||||
|
||||
// FindPinnedByUser 读取“应优先注入”的结构化记忆。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先在同一组 user/conversation/assistant/run 作用域下查 constraint,保证硬约束不会因语义召回波动丢失;
|
||||
// 2. 再查高置信 preference,并按 importance 降序裁到预算,避免偏好噪声过多;
|
||||
// 3. 两路结果按“constraint 在前、preference 在后”拼接,后续由 service 层统一去重、排序和预算裁剪;
|
||||
// 4. 这里不直接做最终预算,是因为读取侧还要和语义候选合并后统一重排。
|
||||
func (r *ItemRepo) FindPinnedByUser(
|
||||
ctx context.Context,
|
||||
query memorymodel.ItemQuery,
|
||||
preferenceLimit int,
|
||||
) ([]model.MemoryItem, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("memory item repo is nil")
|
||||
}
|
||||
if query.UserID <= 0 {
|
||||
return nil, errors.New("memory item query user_id is invalid")
|
||||
}
|
||||
|
||||
includeConstraint := allowPinnedMemoryType(query.MemoryTypes, memorymodel.MemoryTypeConstraint)
|
||||
includePreference := allowPinnedMemoryType(query.MemoryTypes, memorymodel.MemoryTypePreference)
|
||||
if !includeConstraint && !includePreference {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
base := r.db.WithContext(ctx).Model(&model.MemoryItem{}).Where("user_id = ?", query.UserID)
|
||||
base = applyScopedEquality(base, "conversation_id", query.ConversationID, query.IncludeGlobal)
|
||||
base = applyScopedEquality(base, "assistant_id", query.AssistantID, query.IncludeGlobal)
|
||||
base = applyScopedEquality(base, "run_id", query.RunID, query.IncludeGlobal)
|
||||
base = applyPinnedUnexpiredScope(base, query)
|
||||
|
||||
result := make([]model.MemoryItem, 0, preferenceLimit+4)
|
||||
if includeConstraint {
|
||||
var constraints []model.MemoryItem
|
||||
err := base.Session(&gorm.Session{}).
|
||||
Where("memory_type = ? AND status = ?", memorymodel.MemoryTypeConstraint, model.MemoryItemStatusActive).
|
||||
Order("importance DESC").
|
||||
Order("updated_at DESC").
|
||||
Find(&constraints).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, constraints...)
|
||||
}
|
||||
|
||||
if includePreference {
|
||||
if preferenceLimit <= 0 {
|
||||
preferenceLimit = memorymodel.DefaultReadPreferenceLimit
|
||||
}
|
||||
|
||||
var preferences []model.MemoryItem
|
||||
err := base.Session(&gorm.Session{}).
|
||||
Where("memory_type = ? AND confidence >= ? AND status = ?", memorymodel.MemoryTypePreference, 0.8, model.MemoryItemStatusActive).
|
||||
Order("importance DESC").
|
||||
Order("updated_at DESC").
|
||||
Limit(preferenceLimit).
|
||||
Find(&preferences).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, preferences...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByIDForUser 读取某个用户的一条记忆条目。
|
||||
func (r *ItemRepo) GetByIDForUser(ctx context.Context, userID int, memoryID int64) (*model.MemoryItem, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@@ -292,3 +358,27 @@ func applyScopedEquality(db *gorm.DB, column, value string, includeGlobal bool)
|
||||
}
|
||||
return db.Where(column+" = ?", value)
|
||||
}
|
||||
|
||||
func applyPinnedUnexpiredScope(db *gorm.DB, query memorymodel.ItemQuery) *gorm.DB {
|
||||
if db == nil || !query.OnlyUnexpired {
|
||||
return db
|
||||
}
|
||||
now := query.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
return db.Where("(ttl_at IS NULL OR ttl_at > ?)", now)
|
||||
}
|
||||
|
||||
func allowPinnedMemoryType(memoryTypes []string, target string) bool {
|
||||
if len(memoryTypes) == 0 {
|
||||
return true
|
||||
}
|
||||
target = memorymodel.NormalizeMemoryType(target)
|
||||
for _, item := range memoryTypes {
|
||||
if memorymodel.NormalizeMemoryType(item) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,7 @@ func toItemDTO(item model.MemoryItem) memorymodel.ItemDTO {
|
||||
MemoryType: item.MemoryType,
|
||||
Title: item.Title,
|
||||
Content: item.Content,
|
||||
ContentHash: fallbackContentHash(item.MemoryType, item.Content, strValue(item.ContentHash)),
|
||||
Confidence: item.Confidence,
|
||||
Importance: item.Importance,
|
||||
SensitivityLevel: item.SensitivityLevel,
|
||||
@@ -117,3 +119,31 @@ func strValue(v *string) string {
|
||||
}
|
||||
return strings.TrimSpace(*v)
|
||||
}
|
||||
|
||||
// fallbackContentHash 返回条目可用于服务级去重的内容哈希。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 优先复用库内已落表的 content_hash,避免同一条数据多套算法口径不一致;
|
||||
// 2. 若历史数据或 RAG metadata 没带 hash,则按“类型 + 规范化内容”补算;
|
||||
// 3. 若类型非法或正文为空,则返回空字符串,让上游继续走文本兜底去重。
|
||||
func fallbackContentHash(memoryType, content, currentHash string) string {
|
||||
currentHash = strings.TrimSpace(currentHash)
|
||||
if currentHash != "" {
|
||||
return currentHash
|
||||
}
|
||||
|
||||
normalizedType := memorymodel.NormalizeMemoryType(memoryType)
|
||||
normalizedContent := normalizeContentForHash(content)
|
||||
if normalizedType == "" || normalizedContent == "" {
|
||||
return ""
|
||||
}
|
||||
return memoryutils.HashContent(normalizedType, normalizedContent)
|
||||
}
|
||||
|
||||
func normalizeContentForHash(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.Join(strings.Fields(content), " "))
|
||||
}
|
||||
|
||||
@@ -15,17 +15,23 @@ import (
|
||||
// 3. 轮询与重试参数给出保守默认值,避免对主链路造成压力。
|
||||
func LoadConfigFromViper() memorymodel.Config {
|
||||
cfg := memorymodel.Config{
|
||||
Enabled: viper.GetBool("memory.enabled"),
|
||||
RAGEnabled: viper.GetBool("memory.rag.enabled"),
|
||||
ExtractPrompt: viper.GetString("memory.prompt.extract"),
|
||||
DecisionPrompt: viper.GetString("memory.prompt.decision"),
|
||||
Threshold: viper.GetFloat64("memory.threshold"),
|
||||
EnableReranker: viper.GetBool("memory.enableReranker"),
|
||||
LLMTemperature: viper.GetFloat64("memory.llm.temperature"),
|
||||
LLMTopP: viper.GetFloat64("memory.llm.topP"),
|
||||
JobMaxRetry: viper.GetInt("memory.job.maxRetry"),
|
||||
WorkerPollEvery: viper.GetDuration("memory.worker.pollEvery"),
|
||||
WorkerClaimBatch: viper.GetInt("memory.worker.claimBatch"),
|
||||
Enabled: viper.GetBool("memory.enabled"),
|
||||
RAGEnabled: viper.GetBool("memory.rag.enabled"),
|
||||
ReadMode: memorymodel.NormalizeReadMode(viper.GetString("memory.read.mode")),
|
||||
InjectRenderMode: memorymodel.NormalizeInjectRenderMode(viper.GetString("memory.inject.renderMode")),
|
||||
ExtractPrompt: viper.GetString("memory.prompt.extract"),
|
||||
DecisionPrompt: viper.GetString("memory.prompt.decision"),
|
||||
Threshold: viper.GetFloat64("memory.threshold"),
|
||||
EnableReranker: viper.GetBool("memory.enableReranker"),
|
||||
LLMTemperature: viper.GetFloat64("memory.llm.temperature"),
|
||||
LLMTopP: viper.GetFloat64("memory.llm.topP"),
|
||||
JobMaxRetry: viper.GetInt("memory.job.maxRetry"),
|
||||
WorkerPollEvery: viper.GetDuration("memory.worker.pollEvery"),
|
||||
WorkerClaimBatch: viper.GetInt("memory.worker.claimBatch"),
|
||||
ReadConstraintLimit: viper.GetInt("memory.read.constraintLimit"),
|
||||
ReadPreferenceLimit: viper.GetInt("memory.read.preferenceLimit"),
|
||||
ReadFactLimit: viper.GetInt("memory.read.factLimit"),
|
||||
ReadTodoHintLimit: viper.GetInt("memory.read.todoHintLimit"),
|
||||
|
||||
// 决策层配置:默认关闭,灰度开启后才会生效。
|
||||
DecisionEnabled: viper.GetBool("memory.decision.enabled"),
|
||||
@@ -53,6 +59,12 @@ func LoadConfigFromViper() memorymodel.Config {
|
||||
if cfg.WorkerClaimBatch <= 0 {
|
||||
cfg.WorkerClaimBatch = 1
|
||||
}
|
||||
cfg.ReadConstraintLimit = cfg.EffectiveReadConstraintLimit()
|
||||
cfg.ReadPreferenceLimit = cfg.EffectiveReadPreferenceLimit()
|
||||
cfg.ReadFactLimit = cfg.EffectiveReadFactLimit()
|
||||
cfg.ReadTodoHintLimit = cfg.EffectiveReadTodoHintLimit()
|
||||
cfg.ReadMode = cfg.EffectiveReadMode()
|
||||
cfg.InjectRenderMode = cfg.EffectiveInjectRenderMode()
|
||||
|
||||
// 决策层配置默认值兜底。
|
||||
// 说明:
|
||||
|
||||
83
backend/memory/service/read_scope.go
Normal file
83
backend/memory/service/read_scope.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
infrarag "github.com/LoveLosita/smartflow/backend/infra/rag"
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
)
|
||||
|
||||
// buildReadScopedItemQuery 构造读侧统一使用的 MySQL 查询条件。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把 RetrieveRequest 映射成“读侧作用域”查询参数;
|
||||
// 2. 不负责真正查库,也不负责排序、裁剪或注入;
|
||||
// 3. conversation_id 字段在这里刻意不参与过滤,仅保留在记忆记录元数据里供审计与溯源使用。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 读侧始终按 user_id 作为硬隔离边界,避免跨用户串记忆。
|
||||
// 2. assistant_id / run_id 仍允许参与过滤,因为它们表达的是助手实例与执行轮次边界,而不是“是否跨对话召回”的问题。
|
||||
// 3. conversation_id 明确置空,原因是聊天上下文窗口已经覆盖同对话信息;记忆读侧的价值主要在跨对话补充。
|
||||
func buildReadScopedItemQuery(
|
||||
req memorymodel.RetrieveRequest,
|
||||
now time.Time,
|
||||
statuses []string,
|
||||
limit int,
|
||||
) memorymodel.ItemQuery {
|
||||
return memorymodel.ItemQuery{
|
||||
UserID: req.UserID,
|
||||
ConversationID: "",
|
||||
AssistantID: req.AssistantID,
|
||||
RunID: req.RunID,
|
||||
Statuses: statuses,
|
||||
MemoryTypes: normalizeRetrieveMemoryTypes(req.MemoryTypes),
|
||||
IncludeGlobal: true,
|
||||
OnlyUnexpired: true,
|
||||
Limit: limit,
|
||||
Now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// buildReadScopedRAGRequest 构造读侧统一使用的 RAG 检索请求。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责生成 memory 检索请求,不负责执行向量检索;
|
||||
// 2. 不负责阈值外的重排、fallback 或去重;
|
||||
// 3. conversation_id 字段同样只保留在文档 metadata 中,不再作为聊天读侧的硬过滤条件。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. user_id 仍是唯一必须保留的硬过滤条件,确保召回范围限定在当前用户。
|
||||
// 2. conversation_id 明确置空,避免旧对话记忆在进入相似度计算前就被 metadata filter 提前挡掉。
|
||||
// 3. assistant_id / run_id 保持透传,方便后续若存在多助手场景时继续做更细粒度隔离。
|
||||
func buildReadScopedRAGRequest(
|
||||
req memorymodel.RetrieveRequest,
|
||||
topK int,
|
||||
threshold float64,
|
||||
) infrarag.MemoryRetrieveRequest {
|
||||
return infrarag.MemoryRetrieveRequest{
|
||||
Query: req.Query,
|
||||
TopK: topK,
|
||||
Threshold: threshold,
|
||||
Action: "search",
|
||||
UserID: req.UserID,
|
||||
ConversationID: "",
|
||||
AssistantID: req.AssistantID,
|
||||
RunID: req.RunID,
|
||||
MemoryTypes: normalizeRetrieveMemoryTypes(req.MemoryTypes),
|
||||
}
|
||||
}
|
||||
|
||||
// shouldReturnSemanticRAGResult 判断当前是否可以直接采用 RAG 结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责表达“RAG 是否足以短路后续 MySQL fallback”这一条业务规则;
|
||||
// 2. 不负责执行任何检索,也不负责日志记录;
|
||||
// 3. 返回 false 不代表错误,只代表调用方应继续尝试数据库兜底。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. RAG 报错时,一定不能短路,必须继续走 MySQL fallback。
|
||||
// 2. RAG 0 命中时,同样不能短路;否则会把“成功执行但没有候选”误当成最终结果。
|
||||
// 3. 只有“无报错且结果非空”时,才允许直接返回 RAG 结果。
|
||||
func shouldReturnSemanticRAGResult(items []memorymodel.ItemDTO, err error) bool {
|
||||
return err == nil && len(items) > 0
|
||||
}
|
||||
@@ -71,6 +71,9 @@ func (s *ReadService) Retrieve(ctx context.Context, req memorymodel.RetrieveRequ
|
||||
}
|
||||
|
||||
limit := normalizeLimit(req.Limit, defaultRetrieveLimit, maxRetrieveLimit)
|
||||
if s.cfg.EffectiveReadMode() == memorymodel.MemoryReadModeHybrid {
|
||||
return s.HybridRetrieve(ctx, req, effectiveSetting, limit, now)
|
||||
}
|
||||
if s.cfg.RAGEnabled && s.ragRuntime != nil && strings.TrimSpace(req.Query) != "" {
|
||||
items, ragErr := s.retrieveByRAG(ctx, req, effectiveSetting, limit, now)
|
||||
if ragErr == nil && len(items) > 0 {
|
||||
@@ -91,18 +94,12 @@ func (s *ReadService) retrieveByLegacy(
|
||||
if !effectiveSetting.MemoryEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
query := memorymodel.ItemQuery{
|
||||
UserID: req.UserID,
|
||||
ConversationID: req.ConversationID,
|
||||
AssistantID: req.AssistantID,
|
||||
RunID: req.RunID,
|
||||
Statuses: []string{model.MemoryItemStatusActive},
|
||||
MemoryTypes: normalizeRetrieveMemoryTypes(req.MemoryTypes),
|
||||
IncludeGlobal: true,
|
||||
OnlyUnexpired: true,
|
||||
Limit: normalizeLimit(limit*3, limit*3, maxRetrieveLimit*3),
|
||||
Now: now,
|
||||
}
|
||||
query := buildReadScopedItemQuery(
|
||||
req,
|
||||
now,
|
||||
[]string{model.MemoryItemStatusActive},
|
||||
normalizeLimit(limit*3, limit*3, maxRetrieveLimit*3),
|
||||
)
|
||||
|
||||
items, err := s.itemRepo.FindByQuery(ctx, query)
|
||||
if err != nil {
|
||||
@@ -114,8 +111,8 @@ func (s *ReadService) retrieveByLegacy(
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
left := scoreRetrievedItem(items[i], now, req.ConversationID)
|
||||
right := scoreRetrievedItem(items[j], now, req.ConversationID)
|
||||
left := scoreRetrievedItem(items[i], now)
|
||||
right := scoreRetrievedItem(items[j], now)
|
||||
if left == right {
|
||||
return items[i].ID > items[j].ID
|
||||
}
|
||||
@@ -140,17 +137,7 @@ func (s *ReadService) retrieveByRAG(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, err := s.ragRuntime.RetrieveMemory(ctx, infrarag.MemoryRetrieveRequest{
|
||||
Query: req.Query,
|
||||
TopK: limit,
|
||||
Threshold: s.cfg.Threshold,
|
||||
Action: "search",
|
||||
UserID: req.UserID,
|
||||
ConversationID: req.ConversationID,
|
||||
AssistantID: req.AssistantID,
|
||||
RunID: req.RunID,
|
||||
MemoryTypes: normalizeRetrieveMemoryTypes(req.MemoryTypes),
|
||||
})
|
||||
result, err := s.ragRuntime.RetrieveMemory(ctx, buildReadScopedRAGRequest(req, limit, s.cfg.Threshold))
|
||||
if err != nil || result == nil || len(result.Items) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,14 +180,17 @@ func normalizeRetrieveMemoryTypes(raw []string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func scoreRetrievedItem(item model.MemoryItem, now time.Time, conversationID string) float64 {
|
||||
// scoreRetrievedItem 计算 legacy 读链路的确定性排序分数。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里只保留 importance / confidence / recency / explicit / type 这些稳定特征;
|
||||
// 2. conversation_id 已不再参与读侧打分,因为同对话信息本就已经在上下文窗口内;
|
||||
// 3. 若后续需要引入语义分或 reranker,应在 DTO 层补齐对应字段后再统一并入。
|
||||
func scoreRetrievedItem(item model.MemoryItem, now time.Time) float64 {
|
||||
score := 0.35*clamp01(item.Importance) + 0.3*clamp01(item.Confidence) + 0.2*recencyScore(item, now)
|
||||
if item.IsExplicit {
|
||||
score += 0.1
|
||||
}
|
||||
if strValue(item.ConversationID) != "" && strValue(item.ConversationID) == conversationID {
|
||||
score += 0.08
|
||||
}
|
||||
switch item.MemoryType {
|
||||
case memorymodel.MemoryTypeConstraint:
|
||||
score += 0.12
|
||||
@@ -262,15 +252,18 @@ func collectMemoryIDs(items []model.MemoryItem) []int64 {
|
||||
func buildMemoryDTOFromRetrieveHit(hit infrarag.RetrieveHit) (memorymodel.ItemDTO, int64) {
|
||||
memoryID := parseMemoryIDFromDocumentID(hit.DocumentID)
|
||||
metadata := hit.Metadata
|
||||
content := strings.TrimSpace(hit.Text)
|
||||
memoryType := readString(metadata["memory_type"])
|
||||
dto := memorymodel.ItemDTO{
|
||||
ID: memoryID,
|
||||
UserID: int(readFloatLike(metadata["user_id"])),
|
||||
ConversationID: readString(metadata["conversation_id"]),
|
||||
AssistantID: readString(metadata["assistant_id"]),
|
||||
RunID: readString(metadata["run_id"]),
|
||||
MemoryType: readString(metadata["memory_type"]),
|
||||
MemoryType: memoryType,
|
||||
Title: readString(metadata["title"]),
|
||||
Content: strings.TrimSpace(hit.Text),
|
||||
Content: content,
|
||||
ContentHash: fallbackContentHash(memoryType, content, readString(metadata["content_hash"])),
|
||||
Confidence: readFloatLike(metadata["confidence"]),
|
||||
Importance: readFloatLike(metadata["importance"]),
|
||||
SensitivityLevel: int(readFloatLike(metadata["sensitivity_level"])),
|
||||
|
||||
333
backend/memory/service/retrieve_merge.go
Normal file
333
backend/memory/service/retrieve_merge.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
// HybridRetrieve 统一承接读取侧混合召回链路。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 结构化路由先取 constraint / 高置信 preference,给模型一份稳定“硬约束底座”;
|
||||
// 2. 再补语义候选,优先走 RAG;RAG 报错或 0 命中时都回退 MySQL,保证链路韧性;
|
||||
// 3. 两路结果统一做三级去重、排序与类型预算裁剪,只对最终真正注入的条目刷新 last_access_at;
|
||||
// 4. 旧 legacy 链路完全保留,方便通过配置快速回滚。
|
||||
func (s *ReadService) HybridRetrieve(
|
||||
ctx context.Context,
|
||||
req memorymodel.RetrieveRequest,
|
||||
effectiveSetting model.MemoryUserSetting,
|
||||
limit int,
|
||||
now time.Time,
|
||||
) ([]memorymodel.ItemDTO, error) {
|
||||
if s == nil || s.itemRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if !effectiveSetting.MemoryEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
pinnedItems, err := s.retrievePinnedCandidates(ctx, req, effectiveSetting, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
semanticItems, err := s.retrieveSemanticCandidates(ctx, req, effectiveSetting, limit, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
merged := make([]memorymodel.ItemDTO, 0, len(pinnedItems)+len(semanticItems))
|
||||
merged = append(merged, pinnedItems...)
|
||||
merged = append(merged, semanticItems...)
|
||||
if len(merged) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
merged = dedupByID(merged)
|
||||
merged = dedupByHash(merged)
|
||||
merged = dedupByText(merged)
|
||||
merged = RankItems(merged, now)
|
||||
merged = applyTypeBudget(merged, s.cfg)
|
||||
if len(merged) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
_ = s.itemRepo.TouchLastAccessAt(ctx, collectItemDTOIDs(merged), now)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func (s *ReadService) retrievePinnedCandidates(
|
||||
ctx context.Context,
|
||||
req memorymodel.RetrieveRequest,
|
||||
effectiveSetting model.MemoryUserSetting,
|
||||
now time.Time,
|
||||
) ([]memorymodel.ItemDTO, error) {
|
||||
query := buildReadScopedItemQuery(req, now, nil, 0)
|
||||
items, err := s.itemRepo.FindPinnedByUser(ctx, query, s.cfg.EffectiveReadPreferenceLimit())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = memoryutils.FilterItemsBySetting(items, effectiveSetting)
|
||||
return toItemDTOs(items), nil
|
||||
}
|
||||
|
||||
func (s *ReadService) retrieveSemanticCandidates(
|
||||
ctx context.Context,
|
||||
req memorymodel.RetrieveRequest,
|
||||
effectiveSetting model.MemoryUserSetting,
|
||||
limit int,
|
||||
now time.Time,
|
||||
) ([]memorymodel.ItemDTO, error) {
|
||||
queryText := strings.TrimSpace(req.Query)
|
||||
if queryText == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
candidateLimit := hybridSemanticTopK(s.cfg, limit)
|
||||
if s.cfg.RAGEnabled && s.ragRuntime != nil {
|
||||
items, err := s.retrieveSemanticCandidatesByRAG(ctx, req, effectiveSetting, candidateLimit, now)
|
||||
if shouldReturnSemanticRAGResult(items, err) {
|
||||
return items, nil
|
||||
}
|
||||
}
|
||||
return s.retrieveSemanticCandidatesByMySQL(ctx, req, effectiveSetting, candidateLimit, now)
|
||||
}
|
||||
|
||||
func (s *ReadService) retrieveSemanticCandidatesByRAG(
|
||||
ctx context.Context,
|
||||
req memorymodel.RetrieveRequest,
|
||||
effectiveSetting model.MemoryUserSetting,
|
||||
candidateLimit int,
|
||||
now time.Time,
|
||||
) ([]memorymodel.ItemDTO, error) {
|
||||
result, err := s.ragRuntime.RetrieveMemory(ctx, buildReadScopedRAGRequest(req, candidateLimit, s.cfg.Threshold))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil || len(result.Items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
items := make([]memorymodel.ItemDTO, 0, len(result.Items))
|
||||
for _, hit := range result.Items {
|
||||
dto, memoryID := buildMemoryDTOFromRetrieveHit(hit)
|
||||
if !effectiveSetting.ImplicitMemoryEnabled && !dto.IsExplicit {
|
||||
continue
|
||||
}
|
||||
if !effectiveSetting.SensitiveMemoryEnabled && dto.SensitivityLevel > 0 {
|
||||
continue
|
||||
}
|
||||
if dto.ID <= 0 && memoryID > 0 {
|
||||
dto.ID = memoryID
|
||||
}
|
||||
items = append(items, dto)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ReadService) retrieveSemanticCandidatesByMySQL(
|
||||
ctx context.Context,
|
||||
req memorymodel.RetrieveRequest,
|
||||
effectiveSetting model.MemoryUserSetting,
|
||||
candidateLimit int,
|
||||
now time.Time,
|
||||
) ([]memorymodel.ItemDTO, error) {
|
||||
query := buildReadScopedItemQuery(
|
||||
req,
|
||||
now,
|
||||
[]string{model.MemoryItemStatusActive},
|
||||
normalizeLimit(candidateLimit*3, candidateLimit*3, maxRetrieveLimit*3),
|
||||
)
|
||||
|
||||
items, err := s.itemRepo.FindByQuery(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = memoryutils.FilterItemsBySetting(items, effectiveSetting)
|
||||
return toItemDTOs(items), nil
|
||||
}
|
||||
|
||||
// dedupByID 按 memory_id 去重,后出现的结果覆盖先出现的结果。
|
||||
func dedupByID(items []memorymodel.ItemDTO) []memorymodel.ItemDTO {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(items))
|
||||
result := make([]memorymodel.ItemDTO, 0, len(items))
|
||||
for i := len(items) - 1; i >= 0; i-- {
|
||||
item := items[i]
|
||||
if item.ID <= 0 {
|
||||
result = append(result, item)
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[item.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
reverseItemDTOs(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// dedupByHash 按 content_hash 去重;缺失 hash 时跳过,保留 importance 更高的条目。
|
||||
func dedupByHash(items []memorymodel.ItemDTO) []memorymodel.ItemDTO {
|
||||
return dedupByKey(items, func(item memorymodel.ItemDTO) string {
|
||||
return fallbackContentHash(item.MemoryType, item.Content, item.ContentHash)
|
||||
})
|
||||
}
|
||||
|
||||
// dedupByText 按“类型标签 + 文本”兜底去重,用于覆盖历史数据未带 hash 的场景。
|
||||
func dedupByText(items []memorymodel.ItemDTO) []memorymodel.ItemDTO {
|
||||
return dedupByKey(items, func(item memorymodel.ItemDTO) string {
|
||||
text := strings.TrimSpace(item.Content)
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return renderMemoryTypeLabelForDedup(item.MemoryType) + "::" + normalizeContentForHash(text)
|
||||
})
|
||||
}
|
||||
|
||||
func dedupByKey(items []memorymodel.ItemDTO, keyBuilder func(item memorymodel.ItemDTO) string) []memorymodel.ItemDTO {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
selectedIndex := make(map[string]int, len(items))
|
||||
for index, item := range items {
|
||||
key := strings.TrimSpace(keyBuilder(item))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if previous, exists := selectedIndex[key]; exists {
|
||||
if preferCurrentItem(items[previous], item) {
|
||||
selectedIndex[key] = index
|
||||
}
|
||||
continue
|
||||
}
|
||||
selectedIndex[key] = index
|
||||
}
|
||||
|
||||
result := make([]memorymodel.ItemDTO, 0, len(items))
|
||||
for index, item := range items {
|
||||
key := strings.TrimSpace(keyBuilder(item))
|
||||
if key == "" {
|
||||
result = append(result, item)
|
||||
continue
|
||||
}
|
||||
if selectedIndex[key] == index {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func preferCurrentItem(previous memorymodel.ItemDTO, current memorymodel.ItemDTO) bool {
|
||||
if current.Importance != previous.Importance {
|
||||
return current.Importance > previous.Importance
|
||||
}
|
||||
if current.Confidence != previous.Confidence {
|
||||
return current.Confidence > previous.Confidence
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyTypeBudget 在排序结果上应用四类记忆预算。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 每种类型先保底自己的预算上限,避免 fact 抢掉 constraint 的位置;
|
||||
// 2. 裁剪时保持当前排序顺序,不在这里重新打分;
|
||||
// 3. 最终总量由四类预算之和共同决定,默认 18 条。
|
||||
func applyTypeBudget(items []memorymodel.ItemDTO, cfg memorymodel.Config) []memorymodel.ItemDTO {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
budgetByType := map[string]int{
|
||||
memorymodel.MemoryTypeConstraint: cfg.EffectiveReadConstraintLimit(),
|
||||
memorymodel.MemoryTypePreference: cfg.EffectiveReadPreferenceLimit(),
|
||||
memorymodel.MemoryTypeFact: cfg.EffectiveReadFactLimit(),
|
||||
memorymodel.MemoryTypeTodoHint: cfg.EffectiveReadTodoHintLimit(),
|
||||
}
|
||||
usedByType := make(map[string]int, len(budgetByType))
|
||||
result := make([]memorymodel.ItemDTO, 0, minInt(len(items), cfg.TotalReadBudget()))
|
||||
for _, item := range items {
|
||||
if len(result) >= cfg.TotalReadBudget() {
|
||||
break
|
||||
}
|
||||
|
||||
memoryType := resolveBudgetMemoryType(item.MemoryType)
|
||||
if usedByType[memoryType] >= budgetByType[memoryType] {
|
||||
continue
|
||||
}
|
||||
usedByType[memoryType]++
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hybridSemanticTopK(cfg memorymodel.Config, limit int) int {
|
||||
if cfg.TotalReadBudget() > limit {
|
||||
return cfg.TotalReadBudget()
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func resolveBudgetMemoryType(memoryType string) string {
|
||||
normalized := memorymodel.NormalizeMemoryType(memoryType)
|
||||
if normalized == "" {
|
||||
return memorymodel.MemoryTypeFact
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func renderMemoryTypeLabelForDedup(memoryType string) string {
|
||||
switch memorymodel.NormalizeMemoryType(memoryType) {
|
||||
case memorymodel.MemoryTypePreference:
|
||||
return "偏好"
|
||||
case memorymodel.MemoryTypeConstraint:
|
||||
return "约束"
|
||||
case memorymodel.MemoryTypeTodoHint:
|
||||
return "待办线索"
|
||||
case memorymodel.MemoryTypeFact:
|
||||
return "事实"
|
||||
default:
|
||||
return "记忆"
|
||||
}
|
||||
}
|
||||
|
||||
func collectItemDTOIDs(items []memorymodel.ItemDTO) []int64 {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func reverseItemDTOs(items []memorymodel.ItemDTO) {
|
||||
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
|
||||
items[left], items[right] = items[right], items[left]
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(left, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
78
backend/memory/service/retrieve_rank.go
Normal file
78
backend/memory/service/retrieve_rank.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
)
|
||||
|
||||
// RankItems 对读取结果做统一重排。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先基于 importance / confidence / recency 构造基础分,保持和旧链路相近的排序直觉;
|
||||
// 2. 再叠加“显式记忆 / 类型优先级”奖励,让 constraint 与 preference 更稳定地排在前面;
|
||||
// 3. 同分按 ID 降序,保证排序在日志与测试里具备稳定性。
|
||||
func RankItems(items []memorymodel.ItemDTO, now time.Time) []memorymodel.ItemDTO {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ranked := make([]memorymodel.ItemDTO, len(items))
|
||||
copy(ranked, items)
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
left := scoreRankedItem(ranked[i], now)
|
||||
right := scoreRankedItem(ranked[j], now)
|
||||
if left == right {
|
||||
return ranked[i].ID > ranked[j].ID
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
return ranked
|
||||
}
|
||||
|
||||
// scoreRankedItem 计算 hybrid 读链路的统一重排分数。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里仍然只依赖条目自身属性,不引入 conversation_id 加分;
|
||||
// 2. 原因是同对话内容本就已经存在于上下文窗口,记忆读侧应专注跨对话补充;
|
||||
// 3. 类型加权仍然保留,用于确保 constraint / preference 的业务优先级稳定生效。
|
||||
func scoreRankedItem(item memorymodel.ItemDTO, now time.Time) float64 {
|
||||
score := 0.35*clamp01(item.Importance) + 0.3*clamp01(item.Confidence) + 0.2*recencyScoreDTO(item, now)
|
||||
if item.IsExplicit {
|
||||
score += 0.1
|
||||
}
|
||||
switch memorymodel.NormalizeMemoryType(item.MemoryType) {
|
||||
case memorymodel.MemoryTypeConstraint:
|
||||
score += 0.15
|
||||
case memorymodel.MemoryTypePreference:
|
||||
score += 0.10
|
||||
case memorymodel.MemoryTypeTodoHint:
|
||||
score += 0.05
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func recencyScoreDTO(item memorymodel.ItemDTO, now time.Time) float64 {
|
||||
base := item.UpdatedAt
|
||||
if base == nil {
|
||||
base = item.CreatedAt
|
||||
}
|
||||
if base == nil || now.Before(*base) {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
age := now.Sub(*base)
|
||||
switch {
|
||||
case age <= 24*time.Hour:
|
||||
return 1
|
||||
case age <= 7*24*time.Hour:
|
||||
return 0.85
|
||||
case age <= 30*24*time.Hour:
|
||||
return 0.65
|
||||
case age <= 90*24*time.Hour:
|
||||
return 0.45
|
||||
default:
|
||||
return 0.25
|
||||
}
|
||||
}
|
||||
521
backend/memory/第三步治理与观测落地计划.md
Normal file
521
backend/memory/第三步治理与观测落地计划.md
Normal file
@@ -0,0 +1,521 @@
|
||||
# Memory 第三步治理与观测落地计划
|
||||
|
||||
## 1. 这份文档解决什么问题
|
||||
|
||||
这份文档只回答第三步要做什么,不再重复前两步已经完成的抽取、决策、召回细节。
|
||||
|
||||
第三步的目标很简单:
|
||||
|
||||
1. 把 memory 从“能跑”升级成“敢灰度、敢排障、敢清理、敢回滚”。
|
||||
2. 把“日志打在哪里、我怎么看、会不会给接口”说清楚。
|
||||
3. 把改动范围收敛在治理层,不再继续扩算法和能力边界。
|
||||
|
||||
一句人话总结:
|
||||
|
||||
前两步解决的是“有没有能力”,第三步解决的是“出了问题怎么查、怎么收、怎么退”。
|
||||
|
||||
---
|
||||
|
||||
## 2. 先说结论
|
||||
|
||||
第三步我会分成两块做:
|
||||
|
||||
1. 观测与切流
|
||||
2. 用户管理与清理
|
||||
|
||||
为什么这么拆:
|
||||
|
||||
1. 现在第二步最小闭环已经通了,最怕的不是“能力不够多”,而是“出了问题不知道卡在哪一层”。
|
||||
2. 如果没有统一日志、指标和开关,后面再继续加功能,只会让 memory 变成一个越来越难维护的黑箱。
|
||||
3. 历史重复脏数据不先治理,后面读链路和注入链路的数据噪音会越来越重。
|
||||
|
||||
第三步不追求“更聪明”,追求“更稳、更可控”。
|
||||
|
||||
---
|
||||
|
||||
## 3. 你最关心的三个问题
|
||||
|
||||
## 3.1 日志会打在哪里
|
||||
|
||||
第三步不会把所有信息都塞进一个地方,而是分三层:
|
||||
|
||||
### A. 运行日志
|
||||
|
||||
运行日志打到后端服务本身的标准日志,也就是当前 `backend` 进程控制台 / 容器 stdout。
|
||||
|
||||
这层主要看实时链路,适合排查:
|
||||
|
||||
1. 这次写入为什么是 `ADD / UPDATE / DELETE / NONE`
|
||||
2. 这次召回为什么没命中
|
||||
3. 这次注入为什么降级到 `flat` 或 `legacy`
|
||||
4. 这次 worker 为什么走了 fallback
|
||||
|
||||
这层的形态参考当前 RAG 轻量 Observer 的做法,不单独造一套散装日志方案。
|
||||
|
||||
参考文件:
|
||||
|
||||
1. `backend/cmd/start.go`
|
||||
2. `backend/infra/rag/core/observer.go`
|
||||
|
||||
### B. 变更留痕
|
||||
|
||||
变更留痕继续落库,不只打终端。
|
||||
|
||||
当前已经有:
|
||||
|
||||
1. `memory_audit_logs` 表
|
||||
2. `backend/model/memory.go`
|
||||
3. `backend/memory/repo/audit_repo.go`
|
||||
|
||||
这层主要看“已经发生过的变更事实”,适合研发排查和后端自查:
|
||||
|
||||
1. 哪条记忆被删了
|
||||
2. 删之前和删之后内容是什么
|
||||
3. 这次 dedup 清理保留了哪条,归档了哪条
|
||||
4. 某次 update / delete / restore 是谁触发的,原因是什么
|
||||
|
||||
### C. 汇总指标
|
||||
|
||||
第一版不先上完整 Prometheus / Grafana 平台,而是先把关键指标打稳,再视需要接统一观测平台。
|
||||
|
||||
这层主要看趋势和健康度,适合回答:
|
||||
|
||||
1. 最近写入成功率怎么样
|
||||
2. hybrid 召回到底有没有提升
|
||||
3. 去重到底丢了多少垃圾数据
|
||||
4. 是否频繁回滚到 legacy
|
||||
|
||||
---
|
||||
|
||||
## 3.2 我会怎么看
|
||||
|
||||
开发和联调阶段,推荐分两种看法:
|
||||
|
||||
### 看实时问题
|
||||
|
||||
直接看后端运行日志。
|
||||
|
||||
适合看:
|
||||
|
||||
1. 单次请求链路
|
||||
2. 单次 worker 执行过程
|
||||
3. fallback / 降级 / 回滚是否发生
|
||||
|
||||
### 看历史问题
|
||||
|
||||
直接查数据库留痕表和主表。
|
||||
|
||||
适合看:
|
||||
|
||||
1. 某条 memory 历史上被怎么改过
|
||||
2. 某次清理动作具体处理了哪些记录
|
||||
3. 当前 active / archived / deleted 分布
|
||||
|
||||
建议排查时优先查这几张表:
|
||||
|
||||
1. `memory_jobs`
|
||||
2. `memory_items`
|
||||
3. `memory_audit_logs`
|
||||
|
||||
第一版就够用了,不强依赖前端页面才能排查。
|
||||
|
||||
---
|
||||
|
||||
## 3.3 会不会提供接口
|
||||
|
||||
会,但原则上只补“面向当前用户管理自己记忆”的接口,不补“原始运行日志接口”,也不把 `memory` 先做成全项目唯一完整的审计后台。
|
||||
|
||||
原因很直接:
|
||||
|
||||
1. 原始日志噪音很大,不适合直接给前端看。
|
||||
2. 原始日志字段会迭代,直接对外暴露会把内部实现绑死。
|
||||
3. 原始日志可能带内部 trace、错误细节,不适合直接外露。
|
||||
|
||||
所以第三步对外提供的是“用户管理自己记忆”的接口,不是“把 stdout 原样吐给前端”,也不是“先给 memory 单独造一套管理后台接口”。
|
||||
|
||||
第三步建议优先补这几类用户接口:
|
||||
|
||||
### 第一组:当前用户查看自己的记忆
|
||||
|
||||
1. `GET /api/v1/memory/items`
|
||||
- 分页查看“我自己的记忆”
|
||||
2. `GET /api/v1/memory/items/:id`
|
||||
- 查看“我自己的某条记忆”详情
|
||||
|
||||
### 第二组:当前用户主动维护自己的记忆
|
||||
|
||||
1. `POST /api/v1/memory/items`
|
||||
- 手动新增一条记忆
|
||||
2. `PATCH /api/v1/memory/items/:id`
|
||||
- 修改自己的一条记忆
|
||||
3. `DELETE /api/v1/memory/items/:id`
|
||||
- 删除自己的一条记忆
|
||||
|
||||
### 第三组:当前用户恢复误删内容
|
||||
|
||||
1. `POST /api/v1/memory/items/:id/restore`
|
||||
- 若底层采用软删或归档,可补恢复动作
|
||||
|
||||
这些接口都默认只允许操作“当前登录用户自己的记忆”,不支持跨用户查询和跨用户修改。
|
||||
|
||||
原则:
|
||||
|
||||
1. 原始日志看后端 stdout
|
||||
2. 内部变更留痕优先给后端查表和排障使用,不急着做成前端正式能力
|
||||
3. 对外先开放用户真正会用到的“我的记忆”增删改查
|
||||
|
||||
---
|
||||
|
||||
## 4. 第三步到底要做什么
|
||||
|
||||
## 4.1 观测与切流
|
||||
|
||||
这是第三步的第一优先级。
|
||||
|
||||
### 要做的事
|
||||
|
||||
1. 给写入决策链路补统一结构化日志
|
||||
2. 给读侧召回链路补统一结构化日志
|
||||
3. 给注入渲染链路补统一结构化日志
|
||||
4. 给上述三条链路补关键计数指标
|
||||
5. 把现有配置字段整理成清晰的切流顺序和回滚手册
|
||||
|
||||
### 为什么先做这个
|
||||
|
||||
因为第三步如果先做 dedup 清理,但没有日志和切流能力,一旦清错了,排查成本会很高。
|
||||
|
||||
---
|
||||
|
||||
## 4.2 用户管理与清理
|
||||
|
||||
这是第三步的第二优先级。
|
||||
|
||||
### 要做的事
|
||||
|
||||
1. 给“我的记忆”补完整增删改查语义
|
||||
2. 给历史重复数据补离线 dedup 工具
|
||||
3. 给关键变更动作补最小留痕
|
||||
4. 把 dedup 保持在后端内部治理流程,不急着做成前端接口
|
||||
|
||||
### 为什么不一上来绑主 worker
|
||||
|
||||
因为第一版 dedup 的目标是“可留痕、可回滚”,不是“全自动”,也不是先给 `memory` 单独造一个很重的治理后台。
|
||||
|
||||
离线或手动触发更安全,出问题也更容易止血。
|
||||
|
||||
---
|
||||
|
||||
## 5. 具体改动计划
|
||||
|
||||
## 5.1 第一轮:先把观测底座补起来
|
||||
|
||||
### 目标
|
||||
|
||||
先让系统“可看见”。
|
||||
|
||||
### 预计改动
|
||||
|
||||
新增:
|
||||
|
||||
1. `backend/memory/observe/log_fields.go`
|
||||
|
||||
修改:
|
||||
|
||||
1. `backend/memory/worker/decision_flow.go`
|
||||
2. `backend/memory/worker/apply_actions.go`
|
||||
3. `backend/memory/service/read_service.go`
|
||||
4. `backend/memory/service/retrieve_merge.go`
|
||||
5. `backend/service/agentsvc/agent_memory.go`
|
||||
6. `backend/service/agentsvc/agent_memory_render.go`
|
||||
|
||||
### 这一轮会补什么日志
|
||||
|
||||
#### 写入决策日志
|
||||
|
||||
至少记录这些字段:
|
||||
|
||||
1. `trace_id`
|
||||
2. `user_id`
|
||||
3. `conversation_id`
|
||||
4. `job_id`
|
||||
5. `fact_type`
|
||||
6. `candidate_count`
|
||||
7. `final_action`
|
||||
8. `fallback_mode`
|
||||
9. `success`
|
||||
|
||||
#### 读侧召回日志
|
||||
|
||||
至少记录这些字段:
|
||||
|
||||
1. `trace_id`
|
||||
2. `user_id`
|
||||
3. `read_mode`
|
||||
4. `query_len`
|
||||
5. `legacy_hit_count`
|
||||
6. `semantic_hit_count`
|
||||
7. `dedup_drop_count`
|
||||
8. `final_count`
|
||||
9. `degraded`
|
||||
|
||||
#### 注入渲染日志
|
||||
|
||||
至少记录这些字段:
|
||||
|
||||
1. `trace_id`
|
||||
2. `user_id`
|
||||
3. `inject_mode`
|
||||
4. `input_count`
|
||||
5. `rendered_count`
|
||||
6. `token_budget`
|
||||
7. `fallback`
|
||||
|
||||
---
|
||||
|
||||
## 5.2 第二轮:补指标,不急着开 overview 接口
|
||||
|
||||
### 目标
|
||||
|
||||
先让系统“可量化”。
|
||||
|
||||
### 第一版建议补的指标
|
||||
|
||||
优先补这 8 个:
|
||||
|
||||
1. `memory_job_success_rate`
|
||||
2. `memory_job_retry_rate`
|
||||
3. `memory_decision_distribution`
|
||||
4. `memory_decision_fallback_rate`
|
||||
5. `memory_retrieve_hit_count`
|
||||
6. `memory_retrieve_dedup_drop_count`
|
||||
7. `memory_inject_item_count`
|
||||
8. `memory_rag_fallback_rate`
|
||||
|
||||
暂不强求第一版就补:
|
||||
|
||||
1. `memory_wrong_mention_rate`
|
||||
2. `memory_user_correction_rate`
|
||||
|
||||
因为这两个更依赖后续“用户纠错入口”。
|
||||
|
||||
### 这一轮先不做什么
|
||||
|
||||
这一轮先不单独新增 `GET /api/v1/memory/overview`。
|
||||
|
||||
原因不是这个接口没价值,而是现在别的模块还没有统一的观测面板和汇总接口规范。`memory` 这一轮先把指标打稳,后续如果全项目一起做观测面板,再统一收口更对称。
|
||||
|
||||
也就是说,这一轮优先把“数据先有”做出来,不急着把“看板接口先长出来”。
|
||||
|
||||
---
|
||||
|
||||
## 5.3 第三轮:补用户管理动作
|
||||
|
||||
### 目标
|
||||
|
||||
先让用户“能管理自己的记忆”。
|
||||
|
||||
### 预计改动
|
||||
|
||||
修改:
|
||||
|
||||
1. `backend/memory/service/manage_service.go`
|
||||
2. `backend/memory/repo/item_repo.go`
|
||||
3. `backend/memory/utils/audit.go`
|
||||
4. `backend/memory/module.go`
|
||||
|
||||
新增:
|
||||
|
||||
1. `backend/api/memory.go`
|
||||
2. 路由注册文件中的 memory 接线
|
||||
|
||||
### 要补的动作
|
||||
|
||||
1. `list`
|
||||
2. `detail`
|
||||
3. `create`
|
||||
4. `update`
|
||||
5. `delete`
|
||||
6. 若底层保留软删语义,再补 `restore`
|
||||
|
||||
### 接口建议
|
||||
|
||||
新增:
|
||||
|
||||
1. `GET /api/v1/memory/items`
|
||||
2. `GET /api/v1/memory/items/:id`
|
||||
3. `POST /api/v1/memory/items`
|
||||
4. `PATCH /api/v1/memory/items/:id`
|
||||
5. `DELETE /api/v1/memory/items/:id`
|
||||
6. `POST /api/v1/memory/items/:id/restore`
|
||||
- 仅在底层采用软删或归档方案时开放
|
||||
|
||||
### 设计要求
|
||||
|
||||
1. 所有接口默认只作用于“当前登录用户自己的记忆”
|
||||
2. 后端仍保留最小变更留痕,但不把它包装成用户侧“审计接口”
|
||||
3. 接口返回给前端的是“人能看懂的记忆内容和操作结果”,不是底层日志
|
||||
|
||||
---
|
||||
|
||||
## 5.4 第四轮:做离线 dedup 治理
|
||||
|
||||
### 目标
|
||||
|
||||
先让系统“可清理”。
|
||||
|
||||
### 预计新增
|
||||
|
||||
1. `backend/memory/cleanup/dedup_runner.go`
|
||||
2. `backend/memory/cleanup/dedup_policy.go`
|
||||
|
||||
### 第一版治理规则
|
||||
|
||||
按以下维度扫描重复组:
|
||||
|
||||
1. `user_id`
|
||||
2. `memory_type`
|
||||
3. `content_hash`
|
||||
4. `status = active`
|
||||
|
||||
每组处理规则:
|
||||
|
||||
1. 选一条主记录保留
|
||||
2. 优先保留最近更新的
|
||||
3. 若最近更新时间接近,则优先保留置信度更高的
|
||||
4. 其余记录改为 `archived`
|
||||
5. 每次治理动作都写最小变更留痕
|
||||
|
||||
### 接口建议
|
||||
|
||||
这一轮不对外新增 dedup 接口。
|
||||
|
||||
dedup 先保留为后端内部治理能力,必要时通过离线任务、后台命令或内部 job 触发,避免 `memory` 先演化成一个比其他模块更重的专用治理后台。
|
||||
|
||||
### 明确限制
|
||||
|
||||
第一版不做:
|
||||
|
||||
1. 直接危险 SQL 清表
|
||||
2. 自动定时常驻清理
|
||||
3. 无留痕的批量删除
|
||||
|
||||
---
|
||||
|
||||
## 6. 日志、留痕、接口分别给谁看
|
||||
|
||||
这个地方一定要分清,不然第三步会越做越乱。
|
||||
|
||||
### 运行日志
|
||||
|
||||
给研发和排障看。
|
||||
|
||||
特点:
|
||||
|
||||
1. 实时
|
||||
2. 噪音大
|
||||
3. 字段多
|
||||
4. 不直接给前端
|
||||
|
||||
### 变更留痕
|
||||
|
||||
先给研发和后端排障使用。
|
||||
|
||||
特点:
|
||||
|
||||
1. 是持久化结果
|
||||
2. 适合看历史
|
||||
3. 这一轮不急着做成正式用户接口
|
||||
|
||||
### 用户接口
|
||||
|
||||
给用户和前端页面看。
|
||||
|
||||
特点:
|
||||
|
||||
1. 只暴露“我的记忆”内容和操作结果
|
||||
2. 不暴露内部 raw log
|
||||
3. 不承载平台级观测职责
|
||||
|
||||
---
|
||||
|
||||
## 7. 切流顺序
|
||||
|
||||
第三步不允许一刀切。
|
||||
|
||||
建议严格按下面顺序灰度:
|
||||
|
||||
1. 阶段 A:决策层 shadow
|
||||
- 真正写库仍然走 `legacy`
|
||||
- 新决策层只记日志,不生效
|
||||
2. 阶段 B:决策层仅对显式记忆生效
|
||||
3. 阶段 C:决策层对全部写入生效
|
||||
4. 阶段 D:读侧切到 `hybrid`
|
||||
5. 阶段 E:注入切到 `typed_v2`
|
||||
6. 阶段 F:历史清理跑完,再考虑关闭 `legacy` 默认路径
|
||||
|
||||
这里的配置基础已经存在,关键是把切流顺序写清、用清、能回退。
|
||||
|
||||
参考文件:
|
||||
|
||||
1. `backend/memory/model/config.go`
|
||||
2. `backend/memory/service/config_loader.go`
|
||||
|
||||
---
|
||||
|
||||
## 8. 回滚方案
|
||||
|
||||
第三步的回滚不应影响前两步代码保留,只回切开关。
|
||||
|
||||
### 最小回滚动作
|
||||
|
||||
1. 写侧回到 `legacy`
|
||||
2. 读侧回到 `legacy`
|
||||
3. 注入回到 `flat`
|
||||
4. 停掉 dedup 清理任务
|
||||
|
||||
### 回滚原则
|
||||
|
||||
1. 先停治理动作,再回切主路径
|
||||
2. 不做破坏性 schema 回滚
|
||||
3. 不依赖人工热修逻辑判断
|
||||
|
||||
---
|
||||
|
||||
## 9. 第三步明确不做什么
|
||||
|
||||
为了防止范围失控,这一轮明确不做:
|
||||
|
||||
1. 不做图记忆
|
||||
2. 不做多 Provider 工厂化
|
||||
3. 不拆独立 memory 服务
|
||||
4. 不在这一轮给 `memory` 先单独做完整审计后台
|
||||
5. 不把 WebSearch 和 Memory 强行合并成一轮上线
|
||||
6. 不再扩新的召回算法分支
|
||||
|
||||
---
|
||||
|
||||
## 10. 完成标准
|
||||
|
||||
满足以下条件,算第三步完成:
|
||||
|
||||
1. 能从日志看清某条记忆为什么被判成 `ADD / UPDATE / DELETE / NONE`
|
||||
2. 能从指标看清召回命中、去重、降级、回滚情况
|
||||
3. 用户能通过接口管理自己的记忆
|
||||
4. 能对历史重复数据做可留痕清理
|
||||
5. 出异常时能通过开关在分钟级切回 `legacy`
|
||||
6. 文档和代码现状一致,不再靠口头传递
|
||||
|
||||
---
|
||||
|
||||
## 11. 如果只看一页,请看这个执行顺序
|
||||
|
||||
第三步不要散着做,建议按这个顺序推进:
|
||||
|
||||
1. 先补统一日志字段和结构化日志
|
||||
2. 再补指标,把观测数据打稳
|
||||
3. 再补“我的记忆”增删改查能力
|
||||
4. 最后做离线 dedup 和内部清理能力
|
||||
|
||||
一句人话总结:
|
||||
|
||||
先让系统“看得见”,再让系统“能管理”,最后再让系统“敢清理”。
|
||||
363
backend/memory/记忆模块第二步计划.md
Normal file
363
backend/memory/记忆模块第二步计划.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# 第二步执行计划:读取与注入层升级
|
||||
|
||||
## Context
|
||||
|
||||
第一步(写入决策层)已完成,写侧已有"召回 → 比对 → ADD/UPDATE/DELETE/NONE"能力。
|
||||
但读侧仍是"查到就拼",存在四个问题:
|
||||
|
||||
1. RAG 和 legacy **互斥**,无法做到"MySQL 强约束 + RAG 语义补充"双路合并
|
||||
2. 去重仅 `seen[line]` 字符串级,无 `memory_id` / `content_hash` 级去重
|
||||
3. 所有类型平铺、limit=5 一刀切,constraint 可被 fact 挤掉
|
||||
4. `memory_context` 虽已写入 `PinnedBlocks`,但 Execute 阶段走自定义 `msg0~msg3` 骨架,当前并未消费这块内容
|
||||
|
||||
---
|
||||
|
||||
## 当前数据流(legacy)
|
||||
|
||||
```
|
||||
用户发消息
|
||||
│
|
||||
▼
|
||||
agent_newagent.go:114 injectMemoryContext()
|
||||
│ 调用 MemoryReader.Retrieve()
|
||||
│ 入参: userID, chatID, query=userMessage, limit=5
|
||||
▼
|
||||
ReadService.Retrieve() ← read_service.go:51
|
||||
│ 门控: 用户设置检查
|
||||
│ 分支: RAG成功→走RAG / 否则→走legacy
|
||||
│ 两路互斥,只走一条
|
||||
▼
|
||||
├── retrieveByRAG() ← read_service.go:132
|
||||
│ ragRuntime.RetrieveMemory() → []RetrieveHit
|
||||
│ 转为 []ItemDTO, 用户设置过滤, 截断到 limit
|
||||
│
|
||||
└── retrieveByLegacy() ← read_service.go:84
|
||||
itemRepo.FindByQuery(limit*3) → []MemoryItem
|
||||
用户设置过滤 → scoreRetrievedItem排序 → 截断到 limit
|
||||
toItemDTOs() 转换, TouchLastAccessAt
|
||||
│
|
||||
▼ 返回 []ItemDTO(最多5条,无类型预算,无服务级去重)
|
||||
│
|
||||
renderMemoryPinnedContent() ← agent_memory.go:105
|
||||
│ 遍历 items, 对每条生成 "[类型] 内容"
|
||||
│ seen[line] 字符串级弱去重
|
||||
▼
|
||||
拼接为一段纯文本 → ConversationContext.UpsertPinnedBlock(key="memory_context")
|
||||
│
|
||||
├── base.go:55 renderPinnedBlocks()
|
||||
│ 把所有 pinned blocks 拼成 system message
|
||||
│ Chat / Plan / Deliver / 走通用 buildStageMessages 的节点可自动消费
|
||||
│
|
||||
└── execute_context.go:52 buildExecuteStageMessages()
|
||||
Execute 走自定义 msg0~msg3 骨架
|
||||
当前未渲染 memory_context,等价于 Execute 看不到这段记忆
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 目标数据流(hybrid)
|
||||
|
||||
```
|
||||
用户发消息
|
||||
│
|
||||
▼
|
||||
agent_newagent.go:114 injectMemoryContext() ← 不改触发点,改内部链路
|
||||
│ 调用 MemoryReader.Retrieve()
|
||||
▼
|
||||
ReadService.Retrieve() ← read_service.go
|
||||
│ 门控: 用户设置检查(不变)
|
||||
│ 分支: cfg.ReadMode == "hybrid" → 走新链路
|
||||
│ 否则 → 走旧链路(完全不变)
|
||||
▼ ══════════════════════════════════════════════
|
||||
HybridRetrieve() ← 新文件 retrieve_merge.go
|
||||
║ ← 整个混合链路收口在一个函数里
|
||||
║ ┌─────────────────────────────────────────┐
|
||||
║ │ 第一路:结构化强约束召回 │
|
||||
║ │ │
|
||||
║ │ ItemRepo.FindPinnedByUser() │ ← 新方法 item_repo.go
|
||||
║ │ → constraint: status=active, 全取 │
|
||||
║ │ → preference: confidence>=0.8, │
|
||||
║ │ 按 importance 降序取 limit 条 │
|
||||
║ │ 合并 → []MemoryItem → toItemDTOs() │
|
||||
║ │ 结果 A │
|
||||
║ └─────────────────────────────────────────┘
|
||||
║ ↓
|
||||
║ ┌─────────────────────────────────────────┐
|
||||
║ │ 第二路:语义候选召回 │
|
||||
║ │ │
|
||||
║ │ RAG 可用? │
|
||||
║ │ 是 → ragRuntime.RetrieveMemory() │ ← 复用现有 RAG 链路
|
||||
║ │ → []RetrieveHit │
|
||||
║ │ → buildMemoryDTOFromRetrieveHit() │ ← 复用 read_service.go 已有函数
|
||||
║ │ → 用户设置过滤 │
|
||||
║ │ 否 → itemRepo.FindByQuery() │ ← 复用现有 FindByQuery
|
||||
║ │ → toItemDTOs() │
|
||||
║ │ → 用户设置过滤 │
|
||||
║ │ 结果 B │
|
||||
║ └─────────────────────────────────────────┘
|
||||
║ ↓
|
||||
║ 合并 A + B → []ItemDTO
|
||||
║ ↓
|
||||
║ ┌─────────────────────────────────────────┐
|
||||
║ │ 三级去重 │
|
||||
║ │ │
|
||||
║ │ 1. dedupByID — 按 memory_id 去重 │ ← 同 ID 只保留一条
|
||||
║ │ 后出现的覆盖先出现的 │
|
||||
║ │ 2. dedupByHash — 按 content_hash 去重 │ ← 复用 HashContent 算法
|
||||
║ │ hash 为空的跳过 │ (normalize_facts.go)
|
||||
║ │ 保留 importance 更高的 │
|
||||
║ │ 3. dedupByText — 按渲染文本兜底去重 │ ← hash 缺失/空值兜底
|
||||
║ │ 用 localizeMemoryType + │
|
||||
║ │ Content 生成 key │
|
||||
║ └─────────────────────────────────────────┘
|
||||
║ ↓
|
||||
║ ┌─────────────────────────────────────────┐
|
||||
║ │ 排序 │
|
||||
║ │ │
|
||||
║ │ RankItems() │ ← 新文件 retrieve_rank.go
|
||||
║ │ 类型优先级权重叠加原加权分: │
|
||||
║ │ constraint +0.15 │
|
||||
║ │ preference +0.10 │
|
||||
║ │ todo_hint +0.05 │
|
||||
║ │ fact +0 │
|
||||
║ │ + 原 0.35*importance + 0.3*confidence │
|
||||
║ │ + 0.2*recency + 0.1*explicit │
|
||||
║ │ + 0.08*同会话加分 │
|
||||
║ │ 同分按 ID 降序 │
|
||||
║ └─────────────────────────────────────────┘
|
||||
║ ↓
|
||||
║ ┌─────────────────────────────────────────┐
|
||||
║ │ 类型预算裁剪 │
|
||||
║ │ │
|
||||
║ │ applyTypeBudget() │
|
||||
║ │ constraint: 最多 ConstraintLimit 条 │ ← 默认 5
|
||||
║ │ preference: 最多 PreferenceLimit 条 │ ← 默认 5
|
||||
║ │ todo_hint: 最多 TodoHintLimit 条 │ ← 默认 3
|
||||
║ │ fact: 最多 FactLimit 条 │ ← 默认 5
|
||||
║ │ 类型内部保持 RankItems 排序结果 │
|
||||
║ │ 总计最多 18 条(仍受 Execute 上下文预算约束)│
|
||||
║ └─────────────────────────────────────────┘
|
||||
║ ↓
|
||||
║ 返回 []ItemDTO(去重、排序、预算裁剪后的最终结果)
|
||||
══════════════════════════════════════════════
|
||||
│
|
||||
▼ 返回到 injectMemoryContext()
|
||||
│
|
||||
│ cfg.InjectRenderMode == "typed_v2" ?
|
||||
│
|
||||
├── typed_v2 → RenderTypedMemoryContent() ← 新文件 agent_memory_render.go
|
||||
│ 按类型分组渲染:
|
||||
│ ┌──────────────────────────────────┐
|
||||
│ │ 以下是与当前对话相关的用户记忆, │
|
||||
│ │ 仅在确实有帮助时参考,不要机械复述。 │
|
||||
│ │ │
|
||||
│ │ 【必守约束】 │
|
||||
│ │ - 用户点外卖不要香菜。 │
|
||||
│ │ │
|
||||
│ │ 【用户偏好】 │
|
||||
│ │ - 用户偏爱黑咖啡。 │
|
||||
│ │ │
|
||||
│ │ 【当前话题相关事实】 │
|
||||
│ │ - 用户最近在准备周四的程序设计作业。 │
|
||||
│ │ │
|
||||
│ │ 【近期待办】 │
|
||||
│ │ - 周五前交英语作文。 │
|
||||
│ └──────────────────────────────────┘
|
||||
│ 规则: 空段不输出, 段内 "- " 前缀
|
||||
│
|
||||
└── flat → RenderFlatMemoryContent() ← 新文件 agent_memory_render.go
|
||||
从 agent_memory.go 迁入现有 renderMemoryPinnedContent 逻辑,不变
|
||||
│
|
||||
▼ 拼接为纯文本
|
||||
│
|
||||
ConversationContext.UpsertPinnedBlock(key="memory_context")
|
||||
│
|
||||
├── 通用阶段 → base.go:55 renderPinnedBlocks() ← 不改
|
||||
│ 把所有 pinned blocks 拼成 system message
|
||||
│ Chat / Plan / Deliver / 走通用组装的节点自动消费 memory_context
|
||||
│
|
||||
└── Execute 阶段 → buildExecuteMessage3() ← 修改 execute_context.go
|
||||
renderExecuteMemoryContext(ctx) ← 新文件 execute_pinned.go
|
||||
→ 只白名单读取 key="memory_context"
|
||||
→ 以“相关记忆”补充段拼入 msg3
|
||||
→ 不复用通用 renderPinnedBlocks,避免 execution_context/current_step 等块重复注入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 每个阶段对应的代码改动
|
||||
|
||||
### 阶段 0:前置准备(配置 + DTO 补齐)
|
||||
|
||||
改造开始前,先让配置和 DTO 能支撑后续链路。
|
||||
|
||||
**改动 1:Config 新增读侧配置字段**
|
||||
- 文件:`backend/memory/model/config.go`
|
||||
- 新增 6 个字段:`ReadMode` / `ReadConstraintLimit` / `ReadPreferenceLimit` / `ReadFactLimit` / `ReadTodoHintLimit` / `InjectRenderMode`
|
||||
|
||||
**改动 2:ConfigLoader 读取 + 默认值**
|
||||
- 文件:`backend/memory/service/config_loader.go`
|
||||
- 读取上述 6 个 viper key,默认值:ReadMode="legacy", ConstraintLimit=5, PreferenceLimit=5, FactLimit=5, TodoHintLimit=3, RenderMode="flat"
|
||||
|
||||
**改动 3:ItemDTO 补齐 ContentHash**
|
||||
- 文件:`backend/memory/model/item.go` — ItemDTO 新增 `ContentHash string`
|
||||
- 文件:`backend/memory/service/common.go` — `toItemDTO` 补映射 `ContentHash: strValue(item.ContentHash)`
|
||||
- 原因:去重阶段需要 content_hash,当前 ItemDTO 没有这个字段
|
||||
|
||||
### 阶段 1:第一路 — 结构化强约束召回
|
||||
|
||||
**改动 4:ItemRepo 新增 FindPinnedByUser**
|
||||
- 文件:`backend/memory/repo/item_repo.go`
|
||||
- 两次查询合并:
|
||||
- 查 1:`memory_type=constraint AND status=active AND user_id=? AND (未过期)`
|
||||
- 查 2:`memory_type=preference AND confidence>=0.8 AND status=active AND user_id=? AND (未过期)` 按 importance DESC LIMIT preferenceLimit
|
||||
- 合并返回,约束在前偏好在后
|
||||
- 复用已有的 `applyScopedEquality` 模式构建 WHERE
|
||||
|
||||
### 阶段 2:第二路 — 语义候选召回
|
||||
|
||||
**无新文件**。直接在 HybridRetrieve 内部实现:
|
||||
- RAG 可用:调 `ragRuntime.RetrieveMemory()` → 复用 `buildMemoryDTOFromRetrieveHit()` 转 DTO
|
||||
- RAG 不可用:调 `itemRepo.FindByQuery()` → 复用 `toItemDTOs()` 转 DTO
|
||||
- 两路复用现有函数,不重写
|
||||
|
||||
### 阶段 3:三级去重
|
||||
|
||||
**新增文件:`backend/memory/service/retrieve_merge.go`**
|
||||
|
||||
三个纯函数,输入 `[]ItemDTO` 输出 `[]ItemDTO`:
|
||||
|
||||
1. `dedupByID` — map[int64]ItemDTO,后出现的覆盖先出现的
|
||||
2. `dedupByHash` — map[string]ItemDTO,保留 importance 更高的;hash 为空跳过
|
||||
3. `dedupByText` — map[string]ItemDTO,用 `localizeMemoryType + Content` 生成 key
|
||||
|
||||
复用:`HashContent` 算法(来自 `normalize_facts.go`,已导出)
|
||||
|
||||
### 阶段 4:排序
|
||||
|
||||
**新增文件:`backend/memory/service/retrieve_rank.go`**
|
||||
|
||||
- `RankItems(items, now, conversationID)` — 在原 `scoreRetrievedItem` 基础上叠加类型优先级权重
|
||||
- 原 `scoreRetrievedItem` 保留给 legacy 路径,不删除
|
||||
|
||||
### 阶段 5:类型预算裁剪
|
||||
|
||||
**同文件:`backend/memory/service/retrieve_merge.go`**
|
||||
|
||||
- `applyTypeBudget(items, cfg)` — 按 4 个类型 limit 截断,类型内部保持排序结果
|
||||
|
||||
### 阶段 6:ReadService 接入
|
||||
|
||||
**改动 5:ReadService.Retrieve 新增 hybrid 分支**
|
||||
- 文件:`backend/memory/service/read_service.go`
|
||||
- 改动极小:在现有 Retrieve 方法中,门控通过后、limit 计算后,加一个 `if cfg.ReadMode == "hybrid"` 分支调 HybridRetrieve
|
||||
- 旧路径(RAG 优先 → legacy 兜底)完全不动
|
||||
|
||||
### 阶段 7:渲染
|
||||
|
||||
**新增文件:`backend/service/agentsvc/agent_memory_render.go`**
|
||||
|
||||
- `RenderTypedMemoryContent(items)` — 按类型分组渲染,空段不输出
|
||||
- `RenderFlatMemoryContent(items)` — 迁入现有 `renderMemoryPinnedContent` 逻辑
|
||||
- 产物仍统一收口为 `ConversationContext.PinnedBlock(key="memory_context")`,后续 Execute 只消费这块内容,不再重复维护第二套 memory 渲染逻辑
|
||||
|
||||
### 阶段 8:Execute 记忆消费补齐
|
||||
|
||||
**新增文件:`backend/newAgent/prompt/execute_pinned.go`**
|
||||
- 新增 `renderExecuteMemoryContext(ctx)`:只白名单读取 `memory_context` 这一个 pinned block
|
||||
- 输出定位:作为 Execute `msg3` 的补充段,不进入 `msg1/msg2`,避免污染历史归档与 ReAct 窗口
|
||||
- 设计约束:**不**直接复用通用 `renderPinnedBlocks()`,避免 `execution_context` / `current_step` / `rough_build_done` 等 Execute 自有 pinned block 重复注入
|
||||
|
||||
**改动 6:`execute_context.go` 接入 memory_context**
|
||||
- 文件:`backend/newAgent/prompt/execute_context.go`
|
||||
- 在 `buildExecuteMessage3()` 中拼接 `renderExecuteMemoryContext(ctx)` 的结果
|
||||
- 空记忆不输出;只追加“相关记忆”段,不改动 `msg0/msg1/msg2` 既有职责
|
||||
|
||||
### 阶段 9:注入入口切换
|
||||
|
||||
**改动 7:agent_memory.go 接入 renderMode**
|
||||
- 文件:`backend/service/agentsvc/agent.go` — AgentService 新增 `memoryCfg memorymodel.Config` 字段
|
||||
- 文件:`backend/service/agentsvc/agent_memory.go` — `SetMemoryReader` 签名增加 cfg 参数;`injectMemoryContext` 根据 cfg.InjectRenderMode 选渲染函数
|
||||
|
||||
**改动 8:启动层传参**
|
||||
- 文件:`backend/cmd/start.go` — `SetMemoryReader(memoryModule)` → `SetMemoryReader(memoryModule, memoryCfg)`
|
||||
- memoryCfg 在同函数第 78 行已定义,无需额外引入
|
||||
|
||||
---
|
||||
|
||||
## 文件变更汇总
|
||||
|
||||
| 文件 | 操作 | 对应阶段 |
|
||||
|---|---|---|
|
||||
| `backend/memory/model/config.go` | 修改 | 阶段 0 |
|
||||
| `backend/memory/service/config_loader.go` | 修改 | 阶段 0 |
|
||||
| `backend/memory/model/item.go` | 修改 | 阶段 0 |
|
||||
| `backend/memory/service/common.go` | 修改 | 阶段 0 |
|
||||
| `backend/memory/repo/item_repo.go` | 修改 | 阶段 1 |
|
||||
| `backend/memory/service/retrieve_merge.go` | **新增** | 阶段 3 + 5 |
|
||||
| `backend/memory/service/retrieve_rank.go` | **新增** | 阶段 4 |
|
||||
| `backend/memory/service/read_service.go` | 修改 | 阶段 6 |
|
||||
| `backend/service/agentsvc/agent_memory_render.go` | **新增** | 阶段 7 |
|
||||
| `backend/newAgent/prompt/execute_pinned.go` | **新增** | 阶段 8 |
|
||||
| `backend/newAgent/prompt/execute_context.go` | 修改 | 阶段 8 |
|
||||
| `backend/service/agentsvc/agent.go` | 修改 | 阶段 9 |
|
||||
| `backend/service/agentsvc/agent_memory.go` | 修改 | 阶段 9 |
|
||||
| `backend/cmd/start.go` | 修改 | 阶段 9 |
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序(严格依赖链)
|
||||
|
||||
```
|
||||
阶段 0(前置): config.go → config_loader.go → item.go + common.go
|
||||
↓
|
||||
阶段 1(Repo): item_repo.go (FindPinnedByUser)
|
||||
↓
|
||||
阶段 3+4(去重+排序): retrieve_merge.go(去重函数)+ retrieve_rank.go(可并行)
|
||||
↓
|
||||
阶段 5(预算): retrieve_merge.go(HybridRetrieve 入口 + applyTypeBudget)
|
||||
↓ ↑ 合并阶段 1~5 为完整 HybridRetrieve 函数
|
||||
阶段 6(接入): read_service.go(hybrid 分支)
|
||||
↓
|
||||
阶段 7(渲染): agent_memory_render.go(可和阶段 6 并行)
|
||||
↓
|
||||
阶段 8(Execute 消费): execute_pinned.go + execute_context.go
|
||||
↓
|
||||
阶段 9(集成): agent.go + agent_memory.go + start.go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 回滚策略
|
||||
|
||||
全部配置开关回滚,不改代码:
|
||||
|
||||
| 配置 | 回滚值 | 效果 |
|
||||
|---|---|---|
|
||||
| `memory.read.mode` | `legacy` | 读侧回到当前行为 |
|
||||
| `memory.inject.renderMode` | `flat` | 注入渲染回到当前行为 |
|
||||
|
||||
---
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. **默认启动不变**:不配置任何新参数,系统行为与当前完全一致
|
||||
2. **hybrid 双路召回**:设 `memory.read.mode=hybrid`,日志确认两路召回 + 合并 + 去重生效
|
||||
3. **constraint 优先**:写入 5 条 fact + 2 条 constraint,确认 constraint 不被挤出
|
||||
4. **去重生效**:同一用户多条同义记忆,注入只保留一条
|
||||
5. **RAG 降级**:关 Milvus,hybrid 模式仍通过 MySQL fallback 正常工作
|
||||
6. **typed_v2 渲染**:设 `memory.inject.renderMode=typed_v2`,pinned block 按段输出
|
||||
7. **Execute 可见记忆**:进入 Execute 节点时,送入 LLM 的 `msg3` 含“相关记忆”段,且内容来自 `memory_context`
|
||||
8. **Execute 无重复注入**:`execution_context` / `current_step` 等 Execute 自有 pinned block 不因 memory 接入被重复渲染
|
||||
9. **单元测试**:对去重/预算/排序/渲染 / Execute 记忆桥接编写测试,跑完删除
|
||||
|
||||
---
|
||||
|
||||
## 本轮明确不做
|
||||
|
||||
1. 不把 memory 改造成工具调用
|
||||
2. 不改 newAgent 的图路由结构
|
||||
3. 不把 WebSearch 并进统一召回
|
||||
4. 不清理历史重复脏数据
|
||||
5. 不动写入决策层代码
|
||||
6. 不让 Execute 无差别复用通用 `renderPinnedBlocks()`,避免把全部 pinned block 一股脑塞进 `msg3`
|
||||
Reference in New Issue
Block a user