Skip to main content
xbot
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage
Edit page

可插拔记忆系统 Phase 1:接口抽取

目标: 定义 MemoryProvider 接口,将现有 TenantMemory 包装为 FlatMemory 实现,所有调用方改为面向接口。行为完全不变,为后续分层记忆(Phase 2)和 Agentic Memory(Phase 4)打基础。

架构: 新增 memory 包定义接口 + memory/flat 包平移现有逻辑。sessionagent 包改为依赖接口而非具体类型。存储层 storage/sqlite 不改。

技术栈: Go,纯重构,零新依赖


任务 1: 定义 MemoryProvider 接口

文件:

  • 创建: memory/memory.go

步骤 1: 创建 memory/memory.go

package memory

import (
	"context"
	"xbot/llm"
)

// MemoryProvider 可插拔记忆系统的核心接口
// 所有记忆实现(flat/tiered/agentic)必须满足此接口
type MemoryProvider interface {
	// Recall 为当前对话检索相关记忆,返回注入 system prompt 的文本
	// query 为用户当前消息,用于按需检索(flat 实现忽略此参数)
	Recall(ctx context.Context, query string) (string, error)

	// Memorize 对话结束后处理记忆(压缩、存储、进化等)
	Memorize(ctx context.Context, input MemorizeInput) (MemorizeResult, error)

	// Close 释放资源
	Close() error
}

// MemorizeInput 记忆写入的输入参数
type MemorizeInput struct {
	Messages         []llm.ChatMessage // 需要处理的对话消息
	LastConsolidated int               // 上次合并的偏移量
	LLMClient        llm.LLM           // 用于压缩/分析的 LLM
	Model            string            // 模型名称
	ArchiveAll       bool              // true=归档所有消息(/new 命令)
	MemoryWindow     int               // 上下文窗口大小
}

// MemorizeResult 记忆写入的结果
type MemorizeResult struct {
	NewLastConsolidated int  // 新的合并偏移量
	OK                  bool // 是否成功
}

// --- 可选能力接口(Phase 2+ 使用,此处预定义) ---

// Manageable 支持手动记忆管理(pin/unpin/delete)
type Manageable interface {
	Pin(ctx context.Context, noteID string) error
	Unpin(ctx context.Context, noteID string) error
	Delete(ctx context.Context, noteID string) error
}

// Evolvable 支持记忆进化(A-Mem 风格)
type Evolvable interface {
	Evolve(ctx context.Context, content string) ([]Evolution, error)
}

// Evolution 记忆进化操作记录
type Evolution struct {
	Action string // "created" | "merged" | "updated" | "strengthened" | "discarded"
	NoteID string
	Detail string
}

步骤 2: 验证编译

cd /root/work/xbot && go build ./memory/...

步骤 3: 提交

make fmt && git add memory/ && git commit -m "feat(memory): define MemoryProvider interface"

任务 2: 实现 FlatMemory(平移现有逻辑)

文件:

  • 创建: memory/flat/flat.go
  • 创建: memory/flat/flat_test.go

步骤 1: 创建 memory/flat/flat.go

session/memory.goTenantMemory 的逻辑平移到 FlatMemory,实现 MemoryProvider 接口。

package flat

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"
	"time"

	"xbot/llm"
	log "xbot/logger"
	"xbot/memory"
	"xbot/storage/sqlite"
)

// FlatMemory 全量注入式记忆(现有逻辑的接口化包装)
// 所有长期记忆全量注入 system prompt,不做按需检索
type FlatMemory struct {
	tenantID  int64
	memorySvc *sqlite.MemoryService
}

// New 创建 FlatMemory 实例
func New(tenantID int64, memorySvc *sqlite.MemoryService) *FlatMemory {
	return &FlatMemory{
		tenantID:  tenantID,
		memorySvc: memorySvc,
	}
}

// Recall 返回全量长期记忆(忽略 query 参数)
func (m *FlatMemory) Recall(ctx context.Context, query string) (string, error) {
	content, err := m.memorySvc.ReadLongTerm(m.tenantID)
	if err != nil {
		return "", err
	}
	if content == "" {
		return "", nil
	}
	return "## Long-term Memory\n" + content, nil
}

// Memorize 使用 LLM 合并旧消息到长期记忆(平移自 TenantMemory.Consolidate)
func (m *FlatMemory) Memorize(ctx context.Context, input memory.MemorizeInput) (memory.MemorizeResult, error) {
	messages := input.Messages
	lastConsolidated := input.LastConsolidated
	archiveAll := input.ArchiveAll
	memoryWindow := input.MemoryWindow

	var oldMessages []llm.ChatMessage
	keepCount := 0

	if archiveAll {
		oldMessages = messages
		log.WithField("tenant_id", m.tenantID).Infof("Memory consolidation (archive_all): %d messages", len(messages))
	} else {
		keepCount = memoryWindow / 2
		if len(messages) <= keepCount {
			return memory.MemorizeResult{NewLastConsolidated: lastConsolidated, OK: true}, nil
		}
		if len(messages)-lastConsolidated <= 0 {
			return memory.MemorizeResult{NewLastConsolidated: lastConsolidated, OK: true}, nil
		}
		end := len(messages) - keepCount
		if lastConsolidated >= end {
			return memory.MemorizeResult{NewLastConsolidated: lastConsolidated, OK: true}, nil
		}
		oldMessages = messages[lastConsolidated:end]
		if len(oldMessages) == 0 {
			return memory.MemorizeResult{NewLastConsolidated: lastConsolidated, OK: true}, nil
		}
		log.WithField("tenant_id", m.tenantID).Infof("Memory consolidation: %d to consolidate, %d keep", len(oldMessages), keepCount)
	}

	// Format old messages as text
	var lines []string
	for _, msg := range oldMessages {
		if msg.Content == "" {
			continue
		}
		role := strings.ToUpper(msg.Role)
		toolHint := ""
		if msg.Role == "tool" && msg.ToolName != "" {
			toolHint = fmt.Sprintf(" [tool: %s]", msg.ToolName)
		}
		if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
			names := make([]string, len(msg.ToolCalls))
			for i, tc := range msg.ToolCalls {
				names[i] = tc.Name
			}
			toolHint = fmt.Sprintf(" [tools: %s]", strings.Join(names, ", "))
		}
		ts := time.Now().Format("2006-01-02 15:04")
		content := msg.Content
		if len(content) > 500 {
			content = content[:500] + "..."
		}
		lines = append(lines, fmt.Sprintf("[%s] %s%s: %s", ts, role, toolHint, content))
	}

	if len(lines) == 0 {
		newLC := 0
		if !archiveAll {
			newLC = len(messages) - keepCount
		}
		return memory.MemorizeResult{NewLastConsolidated: newLC, OK: true}, nil
	}

	currentMemory, err := m.memorySvc.ReadLongTerm(m.tenantID)
	if err != nil {
		log.WithError(err).Error("Failed to read long-term memory for consolidation")
		return memory.MemorizeResult{NewLastConsolidated: lastConsolidated, OK: false}, nil
	}

	memoryDisplay := currentMemory
	if memoryDisplay == "" {
		memoryDisplay = "(empty)"
	}

	prompt := fmt.Sprintf(`Process this conversation and call the save_memory tool with your consolidation.

## Current Long-term Memory
%s

## Conversation to Process
%s`, memoryDisplay, strings.Join(lines, "\n"))

	resp, err := input.LLMClient.Generate(ctx, input.Model, []llm.ChatMessage{
		llm.NewSystemMessage("You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."),
		llm.NewUserMessage(prompt),
	}, saveMemoryTool)
	if err != nil {
		log.WithError(err).Error("Memory consolidation LLM call failed")
		return memory.MemorizeResult{NewLastConsolidated: lastConsolidated, OK: false}, nil
	}

	if !resp.HasToolCalls() {
		log.Warn("Memory consolidation: LLM did not call save_memory, skipping")
		return memory.MemorizeResult{NewLastConsolidated: lastConsolidated, OK: false}, nil
	}

	var args saveMemoryArgs
	if err := json.Unmarshal([]byte(resp.ToolCalls[0].Arguments), &args); err != nil {
		log.WithError(err).Error("Memory consolidation: failed to parse save_memory arguments")
		return memory.MemorizeResult{NewLastConsolidated: lastConsolidated, OK: false}, nil
	}

	if args.HistoryEntry != "" {
		if err := m.memorySvc.AppendHistory(m.tenantID, args.HistoryEntry); err != nil {
			log.WithError(err).Error("Failed to append history entry")
		}
	}

	if args.MemoryUpdate != "" && args.MemoryUpdate != currentMemory {
		if err := m.memorySvc.WriteLongTerm(m.tenantID, args.MemoryUpdate); err != nil {
			log.WithError(err).Error("Failed to write long-term memory")
		}
	}

	newLC := 0
	if archiveAll {
		newLC = 0
	} else {
		newLC = len(messages) - keepCount
	}
	log.WithField("tenant_id", m.tenantID).Infof("Memory consolidation done: lastConsolidated=%d", newLC)
	return memory.MemorizeResult{NewLastConsolidated: newLC, OK: true}, nil
}

// Close 释放资源(FlatMemory 无需清理)
func (m *FlatMemory) Close() error {
	return nil
}

// --- save_memory tool definition (平移自 session/memory.go) ---

var saveMemoryTool = []llm.ToolDefinition{&saveMemoryToolDef{}}

type saveMemoryToolDef struct{}

func (t *saveMemoryToolDef) Name() string { return "save_memory" }
func (t *saveMemoryToolDef) Description() string {
	return "Save the memory consolidation result to persistent storage."
}
func (t *saveMemoryToolDef) Parameters() []llm.ToolParam {
	return []llm.ToolParam{
		{
			Name:        "history_entry",
			Type:        "string",
			Description: "A paragraph (2-5 sentences) summarizing key events/decisions/topics. Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.",
			Required:    true,
		},
		{
			Name:        "memory_update",
			Type:        "string",
			Description: "Full updated long-term memory as markdown. Include all existing facts plus new ones. Return unchanged if nothing new.",
			Required:    true,
		},
	}
}

type saveMemoryArgs struct {
	HistoryEntry string `json:"history_entry"`
	MemoryUpdate string `json:"memory_update"`
}

步骤 2: 创建 memory/flat/flat_test.go

package flat

import (
	"context"
	"testing"

	"xbot/storage/sqlite"
)

func setupTestDB(t *testing.T) (*sqlite.DB, int64) {
	t.Helper()
	db, err := sqlite.Open(t.TempDir() + "/test.db")
	if err != nil {
		t.Fatalf("Failed to open test db: %v", err)
	}
	t.Cleanup(func() { db.Close() })

	tenantSvc := sqlite.NewTenantService(db)
	tenantID, err := tenantSvc.GetOrCreateTenantID("test", "chat1")
	if err != nil {
		t.Fatalf("Failed to create tenant: %v", err)
	}
	return db, tenantID
}

func TestFlatMemory_Recall_Empty(t *testing.T) {
	db, tenantID := setupTestDB(t)
	memorySvc := sqlite.NewMemoryService(db)
	m := New(tenantID, memorySvc)

	result, err := m.Recall(context.Background(), "any query")
	if err != nil {
		t.Fatalf("Recall failed: %v", err)
	}
	if result != "" {
		t.Errorf("Expected empty, got %q", result)
	}
}

func TestFlatMemory_Recall_WithContent(t *testing.T) {
	db, tenantID := setupTestDB(t)
	memorySvc := sqlite.NewMemoryService(db)
	m := New(tenantID, memorySvc)

	// Write some memory
	if err := memorySvc.WriteLongTerm(tenantID, "# Facts\nUser likes Go"); err != nil {
		t.Fatalf("WriteLongTerm failed: %v", err)
	}

	result, err := m.Recall(context.Background(), "ignored query")
	if err != nil {
		t.Fatalf("Recall failed: %v", err)
	}
	if result == "" {
		t.Fatal("Expected non-empty result")
	}
	if result != "## Long-term Memory\n# Facts\nUser likes Go" {
		t.Errorf("Unexpected result: %q", result)
	}
}

func TestFlatMemory_Close(t *testing.T) {
	db, tenantID := setupTestDB(t)
	memorySvc := sqlite.NewMemoryService(db)
	m := New(tenantID, memorySvc)

	if err := m.Close(); err != nil {
		t.Errorf("Close failed: %v", err)
	}
}

步骤 3: 验证编译和测试

cd /root/work/xbot && go build ./memory/... && go test ./memory/...

步骤 4: 提交

make fmt && git add memory/ && git commit -m "feat(memory): implement FlatMemory provider"

任务 3: 改造 session 层 — Memory() 返回接口

文件:

  • 修改: session/tenant.goMemory() 返回 memory.MemoryProvider
  • 修改: session/multitenant.go — 创建 FlatMemory 替代 TenantMemory
  • 删除: session/memory.go — 逻辑已迁移到 memory/flat/flat.go

步骤 1: 修改 session/tenant.go

- import "xbot/storage/sqlite"
+ import "xbot/memory"

  type TenantSession struct {
      ...
-     memory     *TenantMemory
+     memory     memory.MemoryProvider
      ...
  }

- func (s *TenantSession) Memory() *TenantMemory {
+ func (s *TenantSession) Memory() memory.MemoryProvider {
      return s.memory
  }

同时删除 memorySvc 字段(不再直接持有)。

步骤 2: 修改 session/multitenant.goGetOrCreateSession

+ import "xbot/memory/flat"

  sess = &TenantSession{
      tenantID:   tenantID,
      channel:    channel,
      chatID:     chatID,
      sessionSvc: m.sessionSvc,
-     memorySvc:  m.memorySvc,
-     memory: &TenantMemory{
-         tenantID:  tenantID,
-         memorySvc: m.memorySvc,
-     },
+     memory:     flat.New(tenantID, m.memorySvc),
      mcpManager: mcpManager,
      lastActive: time.Now(),
  }

步骤 3: 删除 session/memory.go(逻辑已完整迁移到 memory/flat/flat.go

git rm session/memory.go

步骤 4: 验证编译

cd /root/work/xbot && go build ./...

步骤 5: 提交

make fmt && git add -A && git commit -m "refactor(session): use MemoryProvider interface"

任务 4: 改造 agent 层 — 删除 MemoryAccessor,使用 MemoryProvider

文件:

  • 修改: agent/context.go — 删除 MemoryAccessor 接口,BuildMessages 改用 memory.MemoryProvider
  • 修改: agent/agent.gomaybeConsolidatehandleNewSession 改用 Memorize 接口

步骤 1: 修改 agent/context.go

删除 MemoryAccessor 接口定义(第 14-16 行)。

修改 BuildMessages 签名:

- func BuildMessages(... memory MemoryAccessor ...) []llm.ChatMessage {
+ func BuildMessages(... memory xmemory.MemoryProvider ...) []llm.ChatMessage {

修改记忆注入部分:

  if memory != nil {
-     memCtx, err := memory.GetMemoryContext()
+     memCtx, err := memory.Recall(context.TODO(), userContent)
      if err != nil {
          log.WithError(err).Warn("Failed to get memory context")
      } else if memCtx != "" {
          systemContent += "\n# Memory\n\n" + memCtx + "\n"
      }
  }

注意:Recallquery 参数传入 userContent(用户当前消息)。FlatMemory 忽略此参数,但未来 TieredMemory 会用它做检索。

步骤 2: 修改 agent/agent.gomaybeConsolidate

  func (a *Agent) maybeConsolidate(ctx context.Context, tenantSession *session.TenantSession) {
      ...
      go func() {
          ...
-         memory := tenantSession.Memory()
-         newLC, ok := memory.Consolidate(ctx, messages, lastConsolidated, a.llmClient, a.model, false, a.memoryWindow)
-         if ok {
+         mem := tenantSession.Memory()
+         result, _ := mem.Memorize(ctx, memory.MemorizeInput{
+             Messages:         messages,
+             LastConsolidated: lastConsolidated,
+             LLMClient:        a.llmClient,
+             Model:            a.model,
+             ArchiveAll:       false,
+             MemoryWindow:     a.memoryWindow,
+         })
+         if result.OK {
+             newLC := result.NewLastConsolidated
              if err := tenantSession.SetLastConsolidated(newLC); err != nil {
                  ...
              }
          }
      }()
  }

步骤 3: 修改 agent/agent.gohandleNewSession

  func (a *Agent) handleNewSession(...) {
      ...
-     memory := tenantSession.Memory()
      ...
      if len(snapshot) > 0 {
-         _, ok := memory.Consolidate(ctx, snapshot, 0, a.llmClient, a.model, true, a.memoryWindow)
-         if !ok {
+         mem := tenantSession.Memory()
+         result, _ := mem.Memorize(ctx, memory.MemorizeInput{
+             Messages:         snapshot,
+             LastConsolidated: 0,
+             LLMClient:        a.llmClient,
+             Model:            a.model,
+             ArchiveAll:       true,
+             MemoryWindow:     a.memoryWindow,
+         })
+         if !result.OK {
              return &bus.OutboundMessage{...}, nil
          }
      }
      ...
  }

步骤 4: 验证编译和全部测试

cd /root/work/xbot && go build ./... && go test ./...

步骤 5: 提交

make fmt && git add -A && git commit -m "refactor(agent): use MemoryProvider.Recall and Memorize"

任务 5: 更新测试

文件:

  • 修改: session/memory_test.go — 改为测试 memory/flat
  • 验证: session/multitenant_test.go — 确保 Memory() 返回接口后测试仍通过

步骤 1: 重写 session/memory_test.go

由于 session/memory.go 已删除,原测试需要迁移。有两个选择:

  • A: 删除 session/memory_test.go,因为 memory/flat/flat_test.go 已覆盖
  • B: 保留但改为通过 session.TenantSession.Memory() 间接测试

选择 A(删除),因为 flat_test.go + multitenant_test.go 已覆盖所有场景。

git rm session/memory_test.go

步骤 2: 修改 session/multitenant_test.go 中的 TestMultiTenantSession_MemoryIsolation

Memory() 现在返回 memory.MemoryProvider 接口,不再有 ReadLongTerm/WriteLongTerm 方法。改为通过 Recall 测试:

- mem1 := sess1.Memory()
- if err := mem1.WriteLongTerm("# Memory 1\nUser likes Go"); err != nil {
-     ...
- }
+ // 通过 Recall 验证隔离性(写入通过底层 memorySvc 直接操作)
+ // 注意:FlatMemory 不暴露 WriteLongTerm,测试改为验证 Recall 返回空
+ mem1 := sess1.Memory()
+ result1, err := mem1.Recall(context.Background(), "")
+ if err != nil {
+     t.Fatalf("Recall failed: %v", err)
+ }
+ if result1 != "" {
+     t.Errorf("Expected empty memory, got %q", result1)
+ }

或者更好的方式:保留写入测试,通过 sqlite.MemoryService 直接写入,再通过 Recall 验证。

步骤 3: 运行全部测试

cd /root/work/xbot && go test ./...

步骤 4: 提交

make fmt && git add -A && git commit -m "test: update tests for MemoryProvider interface"

任务 6: 创建 PR

步骤 1: 推送分支

cd /root/work/xbot
git push origin refactor/pluggable-memory-phase1

步骤 2: 创建 PR

gh pr create \
  --title "refactor: pluggable memory system (Phase 1 - interface extraction)" \
  --body "## 概述

将记忆系统抽象为可插拔接口 \`MemoryProvider\`,为后续分层记忆(Phase 2)和 Agentic Memory(Phase 4)打基础。

## 改动

- **新增** \`memory/memory.go\`:定义 \`MemoryProvider\` 接口(\`Recall\` + \`Memorize\` + \`Close\`)
- **新增** \`memory/flat/flat.go\`:\`FlatMemory\` 实现(现有逻辑平移,行为不变)
- **删除** \`session/memory.go\`:逻辑已迁移到 \`memory/flat/\`
- **修改** \`session/tenant.go\`:\`Memory()\` 返回 \`memory.MemoryProvider\` 接口
- **修改** \`session/multitenant.go\`:创建 \`FlatMemory\` 实例
- **修改** \`agent/context.go\`:删除 \`MemoryAccessor\`,\`BuildMessages\` 使用 \`MemoryProvider.Recall\`
- **修改** \`agent/agent.go\`:\`maybeConsolidate\` 和 \`handleNewSession\` 使用 \`MemoryProvider.Memorize\`

## 后续计划

- Phase 2: TieredMemory(热+冷分层,FTS5 检索)
- Phase 3: Embedding 后端
- Phase 4: AgenticMemory(A-Mem 风格进化)

## 测试

\`\`\`bash
go test ./...
\`\`\`
" \
  --base master

变更汇总

文件操作行数估算
memory/memory.go新增~60
memory/flat/flat.go新增~200
memory/flat/flat_test.go新增~70
session/memory.go删除-180
session/memory_test.go删除-80
session/tenant.go修改~5 行改动
session/multitenant.go修改~5 行改动
agent/context.go修改~10 行改动
agent/agent.go修改~30 行改动
session/multitenant_test.go修改~20 行改动
净变化约 +100 行