Files
smartmate/backend/services/memory/docs/legacy/记忆模块第二步计划.md
Losita 2a96f4c6f9 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 的目录收口口径
2026-05-05 19:31:39 +08:00

364 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 第二步执行计划:读取与注入层升级
## 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`