Dream-SaaS 记忆模块架构复盘,ADR 全记录
0. 背景:Agent 最后缺的那块板——记忆
Dream-SaaS 多 Agent 平台开发中遇到一个很现实的问题:
代码审查 Agent 第一次审完代码,用户说"以后安全相关的多关注",第二次对话 Agent 忘了。同样的需求说了三遍。
Graph 编排解决了,RAG 解决了文档检索,但记住用户是谁、记住用户偏好这块——没有现成方案。自己写,核心三条诉求:
- Spring 生态原生接入,不要跨语言
- 按业务分层设计记忆单元,不能套固定模板
- HITL 机制,LLM 提取的记忆要经过确认,不能直接写进去
1. 记忆的三个层次:不是越多越好
Agent 的记忆需求其实分三层,这三层解决的问题完全不同:
┌─────────────────────────────────────────────────────────┐
│ Agent 的记忆需求 │
├────────────────┬──────────────────┬────────────────────┤
│ 对话窗口 │ 长期结构化记忆 │ 语义知识记忆 │
│ ChatMemory │ Structured KV │ Vector Embedding │
├────────────────┼──────────────────┼────────────────────┤
│ 最近 N 轮对话 │ 规则/事实/状态 KV │ embedding 语义检索 │
│ 窗口滑动过期 │ 精确读写,按 ID │ 模糊匹配,topK 返回 │
├────────────────┴──────────────────┴────────────────────┤
│ Dream-SaaS 记忆模块覆盖:L1 + L2 │
│ Spring AI ChatMemory 覆盖:L3(对话窗口) │
└─────────────────────────────────────────────────────────┘
L1 结构化记忆(StructuredMemoryService) — 回答"用户是谁、规则是什么"
rules:行为准则。比如"回复用中文"、"代码审查优先看安全性"facts:客观事实。比如"用户是 Java 开发者"、"正在做 Dream-SaaS 项目"status:当前状态 KV。比如current_project=dream-saas、mood=tired
L2 向量语义记忆(VectorMemoryService) — 回答"之前讨论过什么相关的"
- 语义检索:"之前讨论过 K8s 部署的内容吗?"
- 跨对话积累:用户和 Agent 聊过多次 Spring AI,不需要每次重新说明上下文
L3 对话窗口(Spring AI ChatMemory) — 回答"最近几轮聊了什么"
- 这是 Spring AI 自带的,不是我们做的
- 纯消息历史,滑动窗口,超出窗口就"忘了"
2. 核心设计:双层接口 + 双套实现
dream-ai-agent-memory/
├── dream-ai-agent-memory-api ← 接口层,对业务透明
│ ├── StructuredMemoryService ← L1 接口
│ ├── VectorMemoryService ← L2 接口
│ ├── MemorySnapshot ← 返回模型(不可变快照)
│ └── MemoryPendingItem ← HITL 待确认条目
├── dream-ai-agent-memory-pgvector ← 生产实现(推荐)
│ ├── PgStructuredMemoryService ← L1 PG 持久化
│ ├── PgVectorMemoryService ← L2 pgvector 向量检索
│ ├── MemoryExtractService ← LLM 自动提取 + HITL
│ └── metrics/MemoryMetrics ← Micrometer 监控
├── dream-ai-agent-memory-redis ← 降级实现(热缓存场景)
│ ├── RedisStructuredMemoryService ← L1 Redis(TTL 自动过期)
│ └── RedisVectorMemoryService ← L2 降级(无真正向量)
└── dream-ai-agent-memory-mcp ← MCP 工具暴露层
└── MemoryMcpContributor ← 暴露为 MCP 工具(read/search/store)
为什么分层
接口与实现分离,业务代码永远只依赖 StructuredMemoryService / VectorMemoryService 两个接口。切换存储引擎只需要改一行配置:
# 切 PGvector
ai.memory.pgvector.enabled: true
# 切 Redis 降级
ai.memory.pgvector.enabled: false
ai.memory.redis.enabled: true
@ConditionalOnProperty 自动装配,零代码改动。
3. L1 StructuredMemoryService:三种单元的 CRUD
记忆模型:MemorySnapshot
get(ownerId) 返回一个不可变快照:
MemorySnapshot snapshot = structuredMemory.get("author");
// snapshot.rules → List<MemoryRule>
// snapshot.facts → List<MemoryFact>
// snapshot.status → Map<String, String>
// snapshot.pending → List<MemoryPendingItem> // 待确认
注入 Agent 的方式很简单:
String systemPrompt = """
你是一个 AI 搭子,用户信息如下:
规则:%s
事实:%s
当前状态:%s
""".formatted(
snapshot.ruleContents(),
snapshot.factContents(),
snapshot.status()
);
三种单元的定位差异
| 单元 | 语义 | 典型场景 | 修改频率 |
|---|---|---|---|
| rules | 用户明确要求的行为准则 | "回复时先说结论再说原因" | 低 |
| facts | 关于用户/环境的客观事实 | "用户是 Java 开发者" | 中 |
| status | 当前工作的上下文状态 | project=dream-saas | 高 |
HITL 待确认机制
LLM 自动提取记忆时,不是直接写入,而是走 HITL(Human-in-the-Loop)三色阈值:
置信度 ≥ 0.82 → 直接写入(高置信度)
置信度 ≥ 0.55 → 入队待用户确认(中等置信度)
置信度 < 0.55 → 跳过(低置信度不写入)
为什么这样设计?防幻觉。LLM 有时会"创造"用户没说过的事实,不经过确认就写入会影响 Agent 长期行为。
4. L2 VectorMemoryService:混合检索的三信号融合
为什么需要混合检索
纯向量检索有个问题:语义相关但用词不同可能漏掉(比如用户说"部署"而记忆中写的是"上线"),纯关键词检索则无法处理同义词扩展。解决方案:三个信号加权融合
hybrid_score = 0.6 × vector_score + 0.3 × keyword_score + 0.1 × time_score
| 信号 | 来源 | 权重 | 解决什么问题 |
|---|---|---|---|
| vector_score | pgvector cosine 相似度 | 0.6 | 语义理解("K8s"≈"Kubernetes") |
| keyword_score | ILIKE 关键词匹配率 | 0.3 | 精确召回("部署"必须匹配"部署") |
| time_score | 1/(1+days/30) 指数衰减 | 0.1 | 新记忆优先(防止古老记忆反复出现) |
权重为什么这么定?0.6/0.3/0.1 是经验值,语义权重最高但不能独占(否则关键词退化严重),时间权重最低(只是微调,不喧宾夺主)。
去重策略
store 时检测相似记忆,cosine > 0.85 即认为是"重复":
store(content)
→ 生成 embedding
→ cosine > 0.85 → 合并(更新 content + 合并 metadata,保留原有 embedding)
→ cosine ≤ 0.85 → 新增
合并时 merged_from 和 merged_at 记录到 metadata,可追溯。
5. 核心设计决策
ADR-001:分层架构(接口与实现分离)
决策:接口层独立于实现层,业务代码只依赖接口。
理由:L1/L2 接口不变,实现可以在 Redis / PGvector / MCP 之间切换,不影响上层 Agent 代码。
放弃:无。要做多 Agent 共享记忆,存储灵活性是必须的。
ADR-002:选择 PGvector 而非 Milvus
决策:向量存储选 PostgreSQL + pgvector,不单独部署 Milvus。
理由:
- Dream-SaaS 已有 PostgreSQL,复用现有基础设施
- pgvector 在 PG 15+ 性能已足够(1000条记忆检索 < 50ms)
- 无额外运维成本
放弃:Milvus 的分布式向量检索能力。对于几千条记忆的场景,pgvector 完全够用。
ADR-003:混合检索三信号融合
决策:hybrid_score = 0.6×vector + 0.3×keyword + 0.1×time
理由:纯向量在专有名词/产品名场景召回率差,纯关键词无法处理同义词扩展。三信号互补。
放弃:纯向量检索(简单但不够精准)。
ADR-004:HITL 置信度分层
决策:≥0.82 自动写入,≥0.55 待确认,<0.55 跳过。
理由:防止 LLM 幻觉导致错误记忆永久化。用户确认为记忆的参与感,也能提升用户对 Agent 的信任。
放弃:直接写入(省事但风险高)。纯人工确认(体验差)。
ADR-005:Embedding 可选注入
决策:EmbeddingModel 有就注入,没有就走 ILIKE 降级。
理由:降低接入门槛。开发/测试环境不一定有可用模型,降级到 ILIKE 至少能跑通。
放弃:强制要求 EmbeddingModel(接入成本高)。
6. 篇2 预告
下篇讲双层实现的源码拆解:PgStructuredMemoryService 三种单元的 PG 存储、PgVectorMemoryService 混合检索三信号融合完整调用链、Redis 降级方案的读写模式,以及测试兼容的坑。