后端: 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;历史列表首屏不足时自动继续分页直到形成滚动区 仓库:无
18 KiB
18 KiB
第二步执行计划:读取与注入层升级
Context
第一步(写入决策层)已完成,写侧已有"召回 → 比对 → ADD/UPDATE/DELETE/NONE"能力。 但读侧仍是"查到就拼",存在四个问题:
- RAG 和 legacy 互斥,无法做到"MySQL 强约束 + RAG 语义补充"双路合并
- 去重仅
seen[line]字符串级,无memory_id/content_hash级去重 - 所有类型平铺、limit=5 一刀切,constraint 可被 fact 挤掉
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
- 查 1:
- 合并返回,约束在前偏好在后
- 复用已有的
applyScopedEquality模式构建 WHERE
阶段 2:第二路 — 语义候选召回
无新文件。直接在 HybridRetrieve 内部实现:
- RAG 可用:调
ragRuntime.RetrieveMemory()→ 复用buildMemoryDTOFromRetrieveHit()转 DTO - RAG 不可用:调
itemRepo.FindByQuery()→ 复用toItemDTOs()转 DTO - 两路复用现有函数,不重写
阶段 3:三级去重
新增文件:backend/memory/service/retrieve_merge.go
三个纯函数,输入 []ItemDTO 输出 []ItemDTO:
dedupByID— map[int64]ItemDTO,后出现的覆盖先出现的dedupByHash— map[string]ItemDTO,保留 importance 更高的;hash 为空跳过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 |
注入渲染回到当前行为 |
验证方式
- 默认启动不变:不配置任何新参数,系统行为与当前完全一致
- hybrid 双路召回:设
memory.read.mode=hybrid,日志确认两路召回 + 合并 + 去重生效 - constraint 优先:写入 5 条 fact + 2 条 constraint,确认 constraint 不被挤出
- 去重生效:同一用户多条同义记忆,注入只保留一条
- RAG 降级:关 Milvus,hybrid 模式仍通过 MySQL fallback 正常工作
- typed_v2 渲染:设
memory.inject.renderMode=typed_v2,pinned block 按段输出 - Execute 可见记忆:进入 Execute 节点时,送入 LLM 的
msg3含“相关记忆”段,且内容来自memory_context - Execute 无重复注入:
execution_context/current_step等 Execute 自有 pinned block 不因 memory 接入被重复渲染 - 单元测试:对去重/预算/排序/渲染 / Execute 记忆桥接编写测试,跑完删除
本轮明确不做
- 不把 memory 改造成工具调用
- 不改 newAgent 的图路由结构
- 不把 WebSearch 并进统一召回
- 不清理历史重复脏数据
- 不动写入决策层代码
- 不让 Execute 无差别复用通用
renderPinnedBlocks(),避免把全部 pinned block 一股脑塞进msg3