后端: 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 的目录收口口径
364 lines
18 KiB
Markdown
364 lines
18 KiB
Markdown
# 第二步执行计划:读取与注入层升级
|
||
|
||
## 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`
|