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~10,poignancy(读路径权重) 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+value;DELETE 需 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) } }