Files
smartmate/backend/memory/记忆模块第二步计划.md
Losita a1b2ffedb8 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;历史列表首屏不足时自动继续分页直到形成滚动区
仓库:无
2026-04-16 18:29:17 +08:00

18 KiB
Raw Blame History

第二步执行计划:读取与注入层升级

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.gotoItemDTO 补映射 ContentHash: strValue(item.ContentHash)
  • 原因:去重阶段需要 content_hash当前 ItemDTO 没有这个字段

阶段 1第一路 — 结构化强约束召回

改动 4ItemRepo 新增 FindPinnedByUser

  • 文件:backend/memory/repo/item_repo.go
  • 两次查询合并:
    • 查 1memory_type=constraint AND status=active AND user_id=? AND (未过期)
    • 查 2memory_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]ItemDTOlocalizeMemoryType + 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 重复注入

改动 6execute_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.goSetMemoryReader 签名增加 cfg 参数;injectMemoryContext 根据 cfg.InjectRenderMode 选渲染函数

改动 8启动层传参

  • 文件:backend/cmd/start.goSetMemoryReader(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_v2pinned 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