Files
sundynix-agentix/sundynix-dispatcher/internal/eino/memory_extract.go
T
Blizzard 1674252d81 feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen
把"逐轮盲写抽取"升级为 Mem0 式对账(方案见 memory_industry_analysis.md 落地节):

mcp-go:
- Profile 加 Importance(1~10, poignancy) + LastSeenAt(为 Generative Agents 读路径
  Score=w1·Relevance+w2·Recency+w3·Importance 铺路)。
- Upsert 收 importance + 每次置 last_seen(印证);新增 Delete(软删,BaseModel.DeletedAt
  已具备,失效不物删可审计)+ Touch;memory_upsert 透传 importance、新增 memory_delete 工具。

dispatcher:
- extractMemory → consolidateMemory:一次 LLM 调用同时做 抽取+对账,输出
  [{op:ADD|UPDATE|DELETE|NOOP,key,value,importance}];ADD/UPDATE→upsert、DELETE→软删;
  sanitizeOps 防幻删(DELETE 须命中已有)/夹 importance[1,10]/同key保末个/丢 NOOP。
- 攒批:每 3 轮(per-session 计数)才 consolidate 一次,省成本,对齐 ChatGPT 周期整理。
  从根上解决 exact-key 盲写的记忆腐烂。

验证:parseOps/sanitizeOps/parseProfile 纯逻辑单测;store 集成测试(真 PG)覆盖
importance/last_seen 写入 + 软删(live 0 / 物理 1);dispatcher -race 全过。
(注:完整多轮 LLM consolidate 未做实跑,属构造性验证 + 沿用已证 pool.Chat 模式。)

P2 待做:读路径按 Score(Recency+Importance) 排序/衰减/截断 + 桌面端记忆面板。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:27:16 +08:00

200 lines
6.5 KiB
Go
Raw 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.
package eino
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
"github.com/sundynix/sundynix-shared/contract"
)
// memOp 是 consolidate 的一条记忆操作(Mem0 式对账结果)。
type memOp struct {
Op string `json:"op"` // ADD / UPDATE / DELETE / NOOP
Key string `json:"key"`
Value string `json:"value"`
Importance float64 `json:"importance"` // 1~10poignancy(读路径权重)
Reason string `json:"reason"`
}
// consolidateMemory 写回阶段(异步、攒批):把近期对话与已有画像交给 LLM 一次对账,
// 输出 ADD/UPDATE/DELETE/NOOP 操作并执行。ADD/UPDATE→upsert(带 importance)、DELETE→软删。
// 一次 LLM 调用同时做"抽取+对账",从根上解决 exact-key 盲写导致的记忆腐烂。
func (o *Orchestrator) consolidateMemory(ctx context.Context, uid string, hist []*schema.Message) {
if uid == "" || o.tools == nil || o.pool == nil || !o.pool.Ready() {
return
}
dialogue := renderDialogue(hist)
if strings.TrimSpace(dialogue) == "" {
return
}
existing := parseProfile(o.fetchMemory(ctx, uid, ""))
cctx, cancel := llmCtx(ctx)
defer cancel()
sys := "你是用户长期记忆管理器。对照【已有记忆】与【近期对话】,只针对用户长期稳定的偏好/事实做对账," +
"忽略一次性临时信息。"
txt, err := o.pool.Chat(cctx, []llm.ChatMessage{
{Role: "system", Content: sys},
{Role: "user", Content: consolidatePrompt(dialogue, existing)},
})
if err != nil {
log.Printf("[eino] (writeback) 记忆对账失败 user=%s: %v", uid, err)
return
}
ops := sanitizeOps(parseOps(txt), existing)
var add, del int
for _, op := range ops {
switch op.Op {
case "DELETE":
o.deleteMemory(ctx, uid, op.Key)
del++
default: // ADD / UPDATE
o.upsertMemory(ctx, uid, op.Key, op.Value, op.Importance)
add++
}
}
if add+del > 0 {
log.Printf("[eino] (writeback) 记忆对账 user=%s:写入/更新 %d,删除 %d", uid, add, del)
}
}
// consolidatePrompt 拼对账提示词:给出已有记忆 + 近期对话,要求输出操作列表。
func consolidatePrompt(dialogue string, existing map[string]string) string {
var ex strings.Builder
if len(existing) == 0 {
ex.WriteString("(暂无)")
} else {
for k, v := range existing {
fmt.Fprintf(&ex, "- %s%s\n", k, v)
}
}
return fmt.Sprintf(`【已有记忆】
%s
【近期对话】
%s
请对账并只输出 JSON 数组,每项 {"op":"ADD|UPDATE|DELETE|NOOP","key":"维度","value":"值","importance":1到10的整数,"reason":"简述"}
- ADD:对话中出现、已有记忆里没有的新长期偏好;
- UPDATE:与已有某条同维度但值变了(**key 必须复用已有的那个**);
- DELETE:已有记忆被对话明确推翻/过时(key 用已有的);
- NOOP:无需变更的不要输出。
importance 表示该偏好的重要程度(随口一提=低,强烈/反复强调=高)。只输出 JSON,无多余文字。`, strings.TrimRight(ex.String(), "\n"), dialogue)
}
// renderDialogue 把近期消息渲染为"用户/助手"对话文本(取末尾若干条,限长)。
func renderDialogue(hist []*schema.Message) string {
const maxMsgs = 8
start := 0
if len(hist) > maxMsgs {
start = len(hist) - maxMsgs
}
var b strings.Builder
for _, m := range hist[start:] {
role := "用户"
if m.Role == schema.Assistant {
role = "助手"
} else if m.Role == schema.System {
continue
}
fmt.Fprintf(&b, "%s%s\n", role, truncate(m.Content, 500))
}
return strings.TrimRight(b.String(), "\n")
}
// parseOps 解析 LLM 对账结果(容忍 ```json 围栏)为 []memOp。
func parseOps(txt string) []memOp {
var ops []memOp
if json.Unmarshal([]byte(stripFence(txt)), &ops) != nil {
return nil
}
return ops
}
// sanitizeOps 校验/规整操作:规范 op 名;ADD/UPDATE 需 key+valueDELETE 需 key 且必须命中已有;
// importance 夹到 [1,10];同 key 去重(保留末个);过滤 NOOP。
func sanitizeOps(ops []memOp, existing map[string]string) []memOp {
byKey := map[string]memOp{}
order := []string{}
for _, op := range ops {
op.Op = strings.ToUpper(strings.TrimSpace(op.Op))
op.Key = strings.TrimSpace(op.Key)
op.Value = strings.TrimSpace(op.Value)
if op.Key == "" {
continue
}
switch op.Op {
case "ADD", "UPDATE":
if op.Value == "" {
continue
}
if op.Importance < 1 {
op.Importance = 1
} else if op.Importance > 10 {
op.Importance = 10
}
case "DELETE":
if _, ok := existing[op.Key]; !ok { // 只删确实存在的,防误删/幻删
continue
}
default: // NOOP 或未知 → 丢弃
continue
}
if _, seen := byKey[op.Key]; !seen {
order = append(order, op.Key)
}
byKey[op.Key] = op
}
out := make([]memOp, 0, len(order))
for _, k := range order {
out = append(out, byKey[k])
}
return out
}
// parseProfile 把 memory_get 渲染的画像("- 维度:值" 多行)解析回 map,供对账。
func parseProfile(s string) map[string]string {
m := map[string]string{}
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-"))
if line == "" {
continue
}
for _, sep := range []string{"", ":"} { // 兼容全角/半角冒号
if i := strings.Index(line, sep); i > 0 {
k := strings.TrimSpace(line[:i])
if k != "" {
m[k] = strings.TrimSpace(line[i+len(sep):])
}
break
}
}
}
return m
}
// upsertMemory 经 mcp-go memory_upsert 登记/更新一条偏好(带 importance)。
func (o *Orchestrator) upsertMemory(ctx context.Context, uid, key, value string, importance float64) {
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
defer cancel()
if _, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("memory_upsert"),
&contract.ToolCall{Tool: "memory_upsert", Args: map[string]any{"user_id": uid, "key": key, "value": value, "importance": importance}}); err != nil {
log.Printf("[eino] memory_upsert 失败 %s=%s: %v", key, value, err)
}
}
// deleteMemory 经 mcp-go memory_delete 软删一条偏好。
func (o *Orchestrator) deleteMemory(ctx context.Context, uid, key string) {
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
defer cancel()
if _, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("memory_delete"),
&contract.ToolCall{Tool: "memory_delete", Args: map[string]any{"user_id": uid, "key": key}}); err != nil {
log.Printf("[eino] memory_delete 失败 %s: %v", key, err)
}
}