目标: 定义 MemoryProvider 接口,将现有 TenantMemory 包装为 FlatMemory 实现,所有调用方改为面向接口。行为完全不变,为后续分层记忆(Phase 2)和 Agentic Memory(Phase 4)打基础。
架构: 新增 memory 包定义接口 + memory/flat 包平移现有逻辑。session 和 agent 包改为依赖接口而非具体类型。存储层 storage/sqlite 不改。
技术栈: Go,纯重构,零新依赖
文件:
- 创建:
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"
文件:
- 创建:
memory/flat/flat.go - 创建:
memory/flat/flat_test.go
步骤 1: 创建 memory/flat/flat.go
将 session/memory.go 中 TenantMemory 的逻辑平移到 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"
文件:
- 修改:
session/tenant.go—Memory()返回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.go 的 GetOrCreateSession
+ 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"
文件:
- 修改:
agent/context.go— 删除MemoryAccessor接口,BuildMessages改用memory.MemoryProvider - 修改:
agent/agent.go—maybeConsolidate和handleNewSession改用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"
}
}
注意:Recall 的 query 参数传入 userContent(用户当前消息)。FlatMemory 忽略此参数,但未来 TieredMemory 会用它做检索。
步骤 2: 修改 agent/agent.go 的 maybeConsolidate
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.go 的 handleNewSession
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"
文件:
- 修改:
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"
步骤 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 行 |