Version: 0.9.76.dev.260505

后端:
1.阶段 6 agent / memory 服务化收口
- 新增 cmd/agent 独立进程入口,承载 agent zrpc server、agent outbox relay / consumer 和运行时依赖初始化
- 补齐 services/agent/rpc 的 Chat stream 与 conversation meta/list/timeline、schedule-preview、context-stats、schedule-state unary RPC
- 新增 gateway/client/agent 与 shared/contracts/agent,将 /api/v1/agent chat 和非 chat 门面切到 agent zrpc
- 收缩 gateway 本地 AgentService 装配,双 RPC 开关开启时不再初始化本地 agent 编排、LLM、RAG 和 memory reader fallback
- 将 backend/memory 物理迁入 services/memory,私有实现收入 internal,保留 module/model/observe 作为 memory 服务门面
- 调整 memory outbox、memory reader 和 agent 记忆渲染链路的 import 与服务边界,cmd/memory 独占 memory worker / consumer
- 关闭 gateway 侧 agent outbox worker 所有权,agent relay / consumer 由 cmd/agent 独占,gateway 仅保留 HTTP/SSE 门面与迁移期开关回退
- 更新阶段 6 文档,记录 agent / memory 当前切流点、smoke 结果,以及 backend/client 与 gateway/shared 的目录收口口径
This commit is contained in:
Losita
2026-05-05 19:31:39 +08:00
parent d7184b776b
commit 2a96f4c6f9
72 changed files with 2775 additions and 291 deletions

View 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` 服务的记忆系统。

View File

@@ -0,0 +1,78 @@
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.
[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

View 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 和内部清理能力
一句人话总结:
先让系统“看得见”,再让系统“能管理”,最后再让系统“敢清理”。

View File

@@ -0,0 +1,558 @@
# 记忆模块实施计划(面试优先版 -> 产品可用版)
## 1. 文档目标
1. 在 3 天内交付一个“可演示、可讲清楚、可继续演进”的记忆系统 MVP。
2. 兼容当前单体工程,不引入高风险拆分,不破坏现有聊天主链路。
3. 复用现有 Outbox 异步基础设施,避免重复造轮子。
4. 形成可直接用于面试讲述的架构故事线、指标体系与演示脚本。
5. 在不增加过度复杂度的前提下,吸收 Mem0 中已被验证的关键机制(抽取、决策、检索、降级、防幻觉)。
## 2. 背景与约束
1. 当前系统是单体 Go 项目,已有稳定的 `Outbox + Kafka + 消费事务` 通路。
2. 当前项目定位先是日程助手,长期演进为陪伴型助手。
3. 短期目标是快速做出“真的可用”的记忆能力,不追求一次做成完整通用平台。
4. 风险约束:
- 不能让重型 LLM 处理阻塞聊天实时响应。
- 不能在 Outbox 消费主循环里堆重计算,避免拖垮其他事件消费。
- 不能牺牲数据一致性与可审计性。
## 3. 总体方案
### 3.1 核心思路
采用“同步快路径 + 异步慢路径”:
1. 同步快路径:回复前快速读取可用记忆(以 MySQL 结构化事实为主),保证“下一轮能用”。
2. 异步慢路径:通过 Outbox 触发记忆抽取任务,执行去重、冲突消解、打分、向量化等重操作。
3. 读写解耦:写路径确保可靠入队,读路径优先稳定可控,再做语义增强。
### 3.2 存储职责分层
1. MySQL事实主库偏好、约束、任务上下文、TTL、置信度、敏感级别、来源
2. Milvus语义召回同义表达匹配、模糊语义联想
3. Redis可选热数据缓存后续优化不作为 MVP 必选项)。
### 3.3 编排层职责
`Memory Orchestrator` 负责两条链路:
1. 写入链路:候选抽取 -> 去重/冲突 -> 打分 -> 分流落库MySQL/Milvus
2. 读取链路:硬约束优先 -> 语义召回补充 -> 重排 -> 门控 -> 注入上下文。
### 3.4 借鉴 Mem0 的关键机制(已裁剪版)
1. 双阶段去重决策:先向量召回候选旧记忆,再由 LLM 决策 `ADD/UPDATE/DELETE/NONE`,而不是只靠相似度阈值硬判。
2. UUID 映射防幻觉:把真实 `memory_id` 映射成临时整数给 LLM回收结果时再反查防止模型编造不存在 ID。
3. 结构化输出刚性约束:抽取与决策都用 JSON 结构,失败时走 `extract_json -> normalize_facts` 容错链,不让解析失败直接污染主流程。
4. 动作分型嵌入:嵌入接口显式传入 `memory_action``add/search/update`),为后续差异化 embedding 策略预留接口。
5. 检索后处理标准化:`threshold 过滤 -> 可选 reranker -> 统一降级`,当重排器异常时保留向量原始排序并打告警日志。
6. 多维隔离语义:统一采用 `user_id + agent_id + run_id` 三维过滤;在本项目映射为 `user_id + assistant_id + conversation_id`
### 3.5 本项目明确不做(本轮)
1. 不做图记忆Graph Memory落地实现仅预留扩展点避免 3 天范围失控。
2. 不做多 Provider 工厂体系,只保留单 Provider 可替换接口,后续再扩展。
3. 不做独立 server 化记忆服务,先在单体内完成闭环与指标验证。
## 4. 3 天执行计划(可直接照着做)
## Day 1把“可写入”打通可靠入队 + 可追踪)
### 目标
1. 记忆任务能稳定从聊天主链路发出。
2. 能看到任务从 `pending``success/failed` 的状态流转。
3. 保证失败可重试、可追踪、可补偿。
### 任务清单
1. 新增文档与目录占位:
- `backend/memory/README.md`(模块说明)
- `backend/memory/service/`(门面)
- `backend/memory/model/`DTO 与状态)
- `backend/memory/repo/`(数据访问)
- `backend/memory/orchestrator/`(编排)
- `backend/memory/worker/`(异步执行)
2. 新增 MySQL 表(建议先手写 SQL + DAO
- `memory_items`
- `memory_jobs`
- `memory_audit_logs`
- `memory_user_settings`
3. 新增配置对象(`memory config`
- 抽取 prompt、更新决策 prompt、阈值、是否启用 reranker、LLM 温度参数。
- 默认采用低随机参数(`temperature/top_p` 低值)提高可复现性。
4. 新增 Outbox 事件:
- `memory.extract.requested`v1
5. 在聊天后置持久化环节发布事件:
- 仅传轻量字段,避免超大 payload。
6. 新增消费处理器:
- 只做任务入库,不做重型 LLM 调用。
7. 新增解析与标准化工具:
- `extract_json()`:从模型输出中抽取 JSON兼容代码块包裹
- `normalize_facts()`:去重、去空、长度校验、非法项过滤。
8. 新增决策状态机定义:
- `ADD/UPDATE/DELETE/NONE` 的合法状态与动作映射。
9. 启动期接线:
-`backend/cmd/start.go` 注册记忆事件处理器。
### Day 1 验收标准
1. 一次聊天后Outbox 中能看到 `memory.extract.requested` 事件。
2. 事件消费后,`memory_jobs` 生成记录。
3. 人工触发 worker 可完成一次任务状态推进(哪怕先是 mock 抽取)。
## Day 2把“可读取可注入”打通先 MySQL 后向量)
### 目标
1. 记忆可在回复前被检索并注入上下文。
2. 能避免明显的“尬提”与无关提及。
3. 提供最小用户可控能力(查看/删除/关闭)。
### 任务清单
1. 实现 `MemoryReadService`
- 按用户与会话上下文读取记忆。
- 优先结构化硬约束(时间偏好、排程禁忌、显式偏好)。
2. 实现 `MemoryInjector`
- Top-K 记忆选择。
- token 预算截断。
- 注入模板统一化。
3. 实现门控逻辑:
- 相关性阈值。
- 置信度阈值。
- 时间衰减权重。
- 敏感级别检查。
4. 增加“阈值 + 可选重排 + 降级”链路:
- 阈值过滤作为第一道过滤。
- `reranker` 失败时自动降级为原排序并记录原因码。
5. 新增最小管理接口:
- `GET /api/v1/memory/items`
- `DELETE /api/v1/memory/items/:id`
- `POST /api/v1/memory/settings`(开关)
6. 完成首版日志埋点:
- 检索命中数、注入条数、门控丢弃原因。
- 决策分布ADD/UPDATE/DELETE/NONE 占比)。
### Day 2 验收标准
1. 给出偏好后,下一轮排程请求能利用该偏好。
2. 无关话题不会频繁硬提旧记忆。
3. 用户可删除指定记忆,删除后不再注入。
## Day 3把“可讲清楚”与“可评估”补齐面试可答
### 目标
1. 输出完整可讲架构,说明设计取舍。
2. 增加可量化指标,证明记忆“有用”而不是“看起来有”。
3. 可选接入 Milvus若环境未就绪先保留接口 + mock
### 任务清单
1. 实现/预留向量接口:
- `VectorStore.Upsert()`
- `VectorStore.Search()`
- `VectorStore.Delete()`
- `VectorStore.Get()`(为 UPDATE/DELETE 决策回查旧值)
2. 对接 Milvus可选
- collection 初始化。
- 向量 + 元数据过滤检索。
3. 指标体系落地:
- 记忆命中率retrieved/useful
- 错误提及率wrong mention
- 用户纠正率user correction
- 回复延迟影响P50/P95
4. 准备演示脚本与面试问答稿:
- 5 分钟架构说明。
- 3 个典型失败案例及兜底策略。
- 未来迭代路线。
5. 输出“借鉴 Mem0 但本地化裁剪”的对比说明:
- 借鉴了什么。
- 为什么暂时不做图记忆与多 Provider 工厂。
### Day 3 验收标准
1. 能现场演示“记住偏好 -> 下轮生效 -> 删除后失效”。
2. 能答清楚“为什么不是纯同步/纯异步”。
3. 能答清楚“为什么 MySQL + Milvus 双存储”。
## 5. 数据模型设计(首版)
## 5.1 `memory_items`(长期事实记忆)
用途:保存对业务有约束价值的可注入记忆。
关键字段建议:
1. `id` bigint PK
2. `user_id` bigint必填
3. `conversation_id` varchar(64)(可空,表示全局用户记忆)
4. `assistant_id` varchar(64)(可空,区分不同助手人格/技能域)
5. `run_id` varchar(64)(可空,会话级隔离)
6. `memory_type` varchar(32)
- `preference`(偏好)
- `constraint`(硬约束)
- `fact`(事实)
- `todo_hint`(近期提醒线索)
7. `title` varchar(128)
8. `content` text
9. `normalized_content` text去噪后
10. `content_hash` varchar(64)(幂等去重)
11. `confidence` decimal(5,4)0~1
12. `importance` decimal(5,4)0~1
13. `sensitivity_level` tinyint
- 0 普通
- 1 中敏
- 2 高敏
14. `source_message_id` bigint
15. `source_event_id` varchar(64)
16. `is_explicit` tinyint(1)(是否用户明确要求记住)
17. `status` varchar(16)
- `active`
- `archived`
- `deleted`
18. `ttl_at` datetime到期时间
19. `last_access_at` datetime
20. `created_at` datetime
21. `updated_at` datetime
22. `vector_status` varchar(16)`pending/synced/failed`
23. `vector_id` varchar(128)(向量库主键映射)
索引建议:
1. `(user_id, status, memory_type, updated_at desc)`
2. `(user_id, conversation_id, status, updated_at desc)`
3. `(source_message_id)`(排查链路)
4. `(ttl_at)`(过期清理)
5. `(user_id, assistant_id, run_id, status, updated_at desc)`
6. `(user_id, memory_type, content_hash)`(幂等去重)
## 5.2 `memory_jobs`(异步任务队列表)
用途:承接 Outbox 消费后的待处理任务,解耦重计算。
关键字段建议:
1. `id` bigint PK
2. `user_id` bigint
3. `conversation_id` varchar(64)
4. `source_message_id` bigint
5. `source_event_id` varchar(64)
6. `job_type` varchar(32)
- `extract`
- `embed`
- `reconcile`
7. `idempotency_key` varchar(128)
8. `payload_json` longtext
9. `status` varchar(16)
- `pending`
- `processing`
- `success`
- `failed`
- `dead`
10. `retry_count` int
11. `max_retry` int
12. `next_retry_at` datetime
13. `last_error` varchar(2000)
14. `created_at` datetime
15. `updated_at` datetime
索引建议:
1. `(status, next_retry_at, id)`
2. `(user_id, created_at desc)`
3. `(source_event_id)`(幂等与追踪)
4. `(idempotency_key)`(消费防重)
## 5.3 `memory_audit_logs`(审计日志)
用途:回答“这条记忆是谁在什么条件下写的/改的/删的”。
关键字段建议:
1. `id` bigint PK
2. `memory_id` bigint
3. `user_id` bigint
4. `operation` varchar(32)
- `create`
- `update`
- `archive`
- `delete`
- `restore`
5. `operator_type` varchar(16)
- `system`
- `user`
6. `reason` varchar(255)
7. `before_json` longtext
8. `after_json` longtext
9. `created_at` datetime
## 5.4 `memory_user_settings`(用户记忆开关)
用途:实现用户可控能力。
关键字段建议:
1. `user_id` bigint PK
2. `memory_enabled` tinyint(1)
3. `implicit_memory_enabled` tinyint(1)
4. `sensitive_memory_enabled` tinyint(1)
5. `updated_at` datetime
## 6. 事件与协议设计
## 6.1 事件类型
1. `memory.extract.requested`v1
2. 预留:
- `memory.embed.requested`
- `memory.cleanup.requested`
## 6.2 载荷字段v1
1. `user_id`
2. `conversation_id`
3. `assistant_id`
4. `run_id`
5. `source_message_id`
6. `source_role`
7. `source_text`
8. `occurred_at`
9. `trace_id`
10. `idempotency_key`
设计约束:
1. Payload 只放执行需要的最小字段。
2. 大文本允许截断并保留摘要,防止消息膨胀。
3. 必须包含幂等标识(如 `source_message_id + user_id`)。
4. 过滤维度必须完整(`user_id + assistant_id + run_id`),避免跨会话串记忆。
## 7. 写入流程详细设计
## 7.1 主流程
1. 聊天主链路完成并落历史消息。
2. 发布 `memory.extract.requested` 到 Outbox。
3. Outbox 消费处理器验证 payload。
4. 处理器创建或幂等更新 `memory_jobs`(仅任务入库)。
5. `memory/worker` 扫描 `pending` 任务并抢占为 `processing`
6. Worker 调用 LLM 执行“候选事实抽取”JSON 输出)。
7. 执行 `extract_json -> normalize_facts` 容错标准化链路。
8. 对每条候选事实做向量检索,召回 Top-K 旧记忆候选。
9. 对召回结果执行“临时整数 ID 映射”,再交给 LLM 决策 `ADD/UPDATE/DELETE/NONE`
10. 根据决策执行写入动作:
- `ADD`:新增 `memory_items` + 审计日志。
- `UPDATE`:更新记录并保留历史旧值。
- `DELETE`:软删除并记录删除原因。
- `NONE`:不写入,仅记调试日志。
11. 按决策动作触发向量同步(支持 `vector_pending`)。
12. 成功后任务标记 `success`,失败按重试策略推进。
## 7.2 失败处理策略
1. Payload 非法:直接标记 dead不重试。
2. LLM 短时失败:指数退避重试。
3. DB 写失败:重试,超过上限 dead。
4. 向量写失败:
- MVP 策略:不阻塞事实写入,记录 `vector_pending` 状态。
- 后续策略:补偿任务重建向量索引。
## 7.3 幂等策略
1. 幂等键:`user_id + source_message_id + memory_type + normalized_content_hash`
2. 同幂等键重复写入:更新 `updated_at`、提升访问热度,不新增重复条目。
3. 由 Outbox 重试导致的重复消费必须无副作用。
4. 对 UPDATE/DELETE 必须先校验目标 `memory_id` 是否存在且属于当前过滤域。
## 8. 读取流程详细设计
## 8.1 主流程
1. 接收用户新问题,先做意图分类(排程/闲聊/混合)。
2.`memory_items` 拉取硬约束记忆(高优先级)。
3. 若 Milvus 可用,执行语义召回补充记忆候选。
4. 对候选执行重排:
- 相关性分
- 置信度分
- 时间衰减分
- 显式记忆加权
5. 执行门控:
- 低相关丢弃
- 高敏过滤
- 过期过滤
6. 执行阈值过滤后可选 reranker若 reranker 异常则自动降级使用原排序。
7. 按 token budget 选择最终注入条目。
8. 组装统一注入上下文,传给主模型生成回复。
## 8.2 重排评分(建议公式)
`final_score = 0.45 * relevance + 0.25 * confidence + 0.20 * recency + 0.10 * explicit_bonus`
说明:
1. 排程类场景可增加硬约束权重。
2. 闲聊类场景可提高语义相关权重。
3. 该公式为 MVP 默认值,后续可通过线上数据调参。
## 8.3 门控规则MVP
1. `final_score < 0.55` 不注入。
2. `sensitivity_level >= 2` 且用户未开启敏感记忆时不注入。
3. `ttl_at < now` 不注入。
4. 同主题最多注入 1~2 条,防止重复轰炸。
## 9. 对外接口MVP
## 9.1 用户接口
1. `GET /api/v1/memory/items`
- 支持按类型、时间、状态过滤。
2. `DELETE /api/v1/memory/items/:id`
- 软删除并写审计日志。
3. `POST /api/v1/memory/settings`
- 修改记忆总开关、隐式记忆开关。
## 9.2 内部接口
1. `MemoryService.EnqueueExtractJob(ctx, payload)`
2. `MemoryService.RetrieveForPrompt(ctx, req)`
3. `MemoryService.UpsertMemoryItems(ctx, items)`
4. `MemoryService.DeleteMemory(ctx, userID, memoryID)`
## 10. 可观测性与指标
## 10.1 指标定义
1. `memory_job_success_rate`
2. `memory_job_retry_rate`
3. `memory_retrieval_hit_rate`
4. `memory_injection_count_avg`
5. `memory_wrong_mention_rate`
6. `memory_user_correction_rate`
7. `chat_p95_latency_delta_with_memory`
8. `memory_json_parse_fail_rate`
9. `memory_decision_distribution`ADD/UPDATE/DELETE/NONE
10. `reranker_fallback_rate`
## 10.2 日志与追踪
1. 每个任务写 `trace_id`,贯穿聊天请求 -> outbox -> memory_job -> memory_item。
2. 对门控丢弃记录原因码:
- `LOW_SCORE`
- `EXPIRED`
- `SENSITIVE_BLOCKED`
- `DUP_TOPIC`
3. 保证可以反查“为什么这次没有提某条记忆”。
## 11. 安全与隐私约束
1. 敏感信息默认不做隐式记忆(如健康、财务、证件等)。
2. 用户必须可删除历史记忆,删除后不再用于注入。
3. 记忆开关关闭后,仅保留必要系统数据,不再新增记忆条目。
4. 审计日志保留系统写入行为,便于风控与合规排查。
## 12. 测试策略
## 12.1 单元测试范围(实现阶段)
1. 候选抽取结果解析函数。
2. 冲突消解函数。
3. 重排评分函数。
4. 门控函数。
5. 幂等去重函数。
6. `extract_json` 容错解析函数。
7. `normalize_facts` 标准化函数。
8. UUID 映射与反查函数。
9. `ADD/UPDATE/DELETE/NONE` 决策结果校验函数。
## 12.2 集成测试范围(实现阶段)
1. 聊天后事件成功入 outbox。
2. Outbox 消费后任务成功入 `memory_jobs`
3. Worker 成功写 `memory_items`
4. 读取链路能在回复中注入预期记忆。
## 12.3 注意事项(遵循项目约束)
1. 若编写 Go 测试文件(`*_test.go`)做验证,任务完成后按项目约定移除测试文件。
2. 每次执行本地 `go test` 后清理项目根目录 `.gocache`
## 13. 风险与回滚
## 13.1 主要风险
1. 记忆误提影响体验。
2. LLM 抽取不稳定导致脏记忆。
3. 向量检索误召回导致不相关注入。
4. 任务积压影响时效。
## 13.2 应对策略
1. 先严门控,宁可少提,不要乱提。
2. 保留“用户纠正”入口,纠正后提高冲突更新优先级。
3. 对召回做 metadata 过滤(近 30 天、类型限定)。
4. 监控任务积压长度,超阈值降级(停向量,仅结构化记忆)。
## 13.3 回滚方案
1. 配置开关 `memory.enabled=false` 可一键关闭记忆注入。
2. 保留写入链路但停读取链路,避免历史数据丢失。
3. 极端情况下停 worker仅保留主链路聊天功能。
## 14. 面试表达模板(可直接复述)
1. “我们做的是同步快路径 + 异步慢路径。同步保证下轮可用,异步负责治理和质量。”
2. “结构化事实放 MySQL 保证可控可审计,语义联想放 Milvus 提高召回覆盖。”
3. “Outbox 保证事件可靠入队Worker 解耦重计算,避免阻塞主链路。”
4. “借鉴 Mem0 的双阶段策略:先向量召回旧记忆,再让 LLM 决策 ADD/UPDATE/DELETE/NONE兼顾召回率与准确率。”
5. “我们用 UUID 映射防止模型伪造 ID并且用 JSON 容错链保证抽取稳定性。”
6. “我们用命中率、误提率、纠正率和 reranker 降级率验证记忆是否真的有价值。”
## 15. DoD完成定义
1. 代码层:
- 记忆事件可发布、可消费、可重试。
- 记忆可检索、可注入、可删除、可关闭。
2. 质量层:
- 有基础指标与日志,支持问题排查。
- 有失败兜底与降级路径。
3. 叙事层:
- 3 分钟能讲清架构。
- 5 分钟能演示端到端效果。
- 能回答核心取舍与后续演进。
## 16. 本轮执行顺序建议
1. 先做 Day 1 的表结构与事件接线,不进入复杂抽取细节。
2. 再做 Day 2 的读取注入,优先 MySQL 结构化记忆。
3. 最后补 Day 3 的 Milvus 与指标,确保面试讲述闭环。
## 17. Mem0 借鉴清单与取舍结论(本轮新增)
### 17.1 直接借鉴
1. `ADD/UPDATE/DELETE/NONE` 统一决策状态机。
2. `threshold -> reranker(可选) -> fallback` 的检索后处理套路。
3. 三维过滤隔离(`user_id/agent_id/run_id`)的语义边界设计。
4. 历史追踪思路(本项目落在 `memory_audit_logs`)。
5. 低随机参数 + JSON 输出约束,提升可复现性。
### 17.2 延后借鉴
1. 图记忆(关系三元组与软删除)延后到 V2/V3。
2. 多 Provider 工厂体系延后到“需要跨云/跨模型”时再上。
3. 托管 API 平台化能力延后到单体稳定后再拆。
### 17.3 不照搬的原因
1. 当前目标是 3 天可演示 MVP优先“稳定可讲”而非“能力最全”。
2. 项目已有 Outbox 可靠链路,先最大化复用,避免架构重复。
3. 日程助手是强约束场景,结构化事实主库优先级高于图谱表达能力。
---
本文件定位为“落地执行蓝图”。后续每完成一块能力,建议在本文件追加“已落地清单 + 待办差距”,持续收敛为真实实施记录。

View 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 能支撑后续链路。
**改动 1Config 新增读侧配置字段**
- 文件:`backend/memory/model/config.go`
- 新增 6 个字段:`ReadMode` / `ReadConstraintLimit` / `ReadPreferenceLimit` / `ReadFactLimit` / `ReadTodoHintLimit` / `InjectRenderMode`
**改动 2ConfigLoader 读取 + 默认值**
- 文件:`backend/memory/service/config_loader.go`
- 读取上述 6 个 viper key默认值ReadMode="legacy", ConstraintLimit=5, PreferenceLimit=5, FactLimit=5, TodoHintLimit=3, RenderMode="flat"
**改动 3ItemDTO 补齐 ContentHash**
- 文件:`backend/memory/model/item.go` — ItemDTO 新增 `ContentHash string`
- 文件:`backend/memory/service/common.go``toItemDTO` 补映射 `ContentHash: strValue(item.ContentHash)`
- 原因:去重阶段需要 content_hash当前 ItemDTO 没有这个字段
### 阶段 1第一路 — 结构化强约束召回
**改动 4ItemRepo 新增 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 截断,类型内部保持排序结果
### 阶段 6ReadService 接入
**改动 5ReadService.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 渲染逻辑
### 阶段 8Execute 记忆消费补齐
**新增文件:`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注入入口切换
**改动 7agent_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
阶段 1Repo: item_repo.go (FindPinnedByUser)
阶段 3+4去重+排序): retrieve_merge.go去重函数+ retrieve_rank.go可并行
阶段 5预算: retrieve_merge.goHybridRetrieve 入口 + applyTypeBudget
↓ ↑ 合并阶段 1~5 为完整 HybridRetrieve 函数
阶段 6接入: read_service.gohybrid 分支)
阶段 7渲染: agent_memory_render.go可和阶段 6 并行)
阶段 8Execute 消费): 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 降级**:关 Milvushybrid 模式仍通过 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`