从 6 个工程机制到可运行的 Java 代码,中间隔着多少细节?
本篇是 Memory 系列的第三篇,也是收官篇。

一、上篇留下的问题
MEMORY-02 讲了记忆系统的 6 个工程机制:
| 机制 | 解决什么问题 |
|---|---|
| 记忆提取 | 从非结构化对话中自动提取结构化记忆 |
| 置信度分流 | 不同确定度的记忆走不同处理通道 |
| HITL(人机协作) | 为模型提取提供质量兜底 |
| 混合检索 | 三路信号融合,平衡精确匹配与语义关联 |
| 记忆蒸馏 | 压缩合并,控制记忆库膨胀 |
| 生命周期管理 | 提取→写入→检索→蒸馏→清除的完整闭环 |
但这 6 个机制,没有一行代码。概念层面的东西讲清楚了,工程层面的问题一个都没解决:
- Prompt 怎么写才能让 LLM 稳定输出结构化记忆?
- 置信度阈值怎么定?pending 状态的数据存哪张表?
- 混合检索的三路信号怎么融合?权重怎么定?
- 多个 Agent 怎么共享同一套记忆,又不互相污染?
本篇的任务,就是把这些机制落到 Java + Spring AI 的真实项目里。所有代码来自 Dream-SaaS,一个基于 Java 17 + Spring Boot 3 + Spring AI 的 Agent 平台。
二、Dream-SaaS 的记忆模块架构
2.1 三层记忆模型
Dream-SaaS 的记忆系统严格对应 MEMORY-01 提出的三层模型:
| 层级 | 类型 | 存储内容 | 实现方式 |
|---|---|---|---|
| L1 | 结构化记忆 | rules / facts / status(KV 对) | PostgreSQL JSONB |
| L2 | 向量语义记忆 | 对话中抽取的语义条目 | PGvector(cosine 相似度检索) |
| L3 | 对话窗口 | 短期对话上下文 | Spring AI ChatMemory(InMemory / Redis) |
三层之间不是孤立的。每次对话结束后,L3 的对话内容会被送入提取流程,产出新的 L1 和 L2 记忆。L1 和 L2 在检索时融合为上下文,注入下一轮对话。
bash
对话进入 → L3(对话窗口)
↓ 对话结束
MemoryExtractService(LLM 提取)
↓
├─ confidence ≥ 0.82 → 直接写入 L1 / L2
├─ 0.55 ≤ conf < 0.82 → pending_memory 表(待审核)
└─ confidence < 0.55 → 丢弃
检索时:
L1(rules/facts/status)──┐
├→ 融合为 context → 注入下轮对话
L2(vector search)──────┘
2.2 双层接口设计
记忆模块采用 接口与实现分离 的分层架构:
memory-api:纯接口层,定义MemoryStore、MemoryExtractor、MemorySearcher等核心接口memory-pgvector:基于 PGvector 的实现memory-redis:基于 Redis 的 L3 对话缓存实现memory-mcp:MCP 工具暴露层,把记忆能力以 Tool 的形式提供给 Agent
切换存储引擎只需一行配置:
bash
application.yml:
spring:
dream:
memory:
store: pgvector # 可选: pgvector | redis-mcp
通过 @ConditionalOnProperty 实现条件装配:
bash
@Configuration
@ConditionalOnProperty(
name = "spring.dream.memory.store",
havingValue = "pgvector",
matchIfMissing = true
)
public class PgVectorMemoryConfig {
@Bean
public MemoryStore memoryStore(JdbcTemplate jdbc,
EmbeddingModel embeddingModel) {
return new PgVectorMemoryStore(jdbc, embeddingModel);
}
}
2.3 关键 ADR:为什么选 PGvector 而非 Milvus
ADR-017:记忆向量存储选型
状态: 已采纳
背景: 需要一个向量数据库存储 L2 语义记忆,候选方案:Milvus / Qdrant / PGvector
决策: 选择 PGvector
理由:
① Dream-SaaS 已使用 PostgreSQL 作为主存储,引入 PGvector 不需要新增基础设施
② 当前记忆规模约 1000~5000 条/用户,PGvector 的检索性能完全够用(1000 条记忆检索 P99 < 50ms)
③ 事务一致性更好------L1 结构化记忆和 L2 向量记忆在同一个 PG 实例,可以做事务保证
④ Milvus/Qdrant 在百万级以上规模有明显优势,但当前阶段引入会增加运维复杂度
2.4 核心接口的完整定义
接口层是整个记忆模块的骨架。以下是三个核心接口的完整定义,它们构成了 memory-api 模块的全部内容:
bash
/**
* 记忆存储接口 ------ 管理 L1 结构化记忆与 L2 向量记忆的读写。
* 所有方法均为幂等操作,相同内容重复写入不会创建重复条目。
*/
public interface MemoryStore {
/**
* 获取用户的记忆快照(L1 结构化记忆)。
*
* @param ownerId 用户 ID
* @param agentType Agent 类型标识(如 "companion"、"code-review")
* @return 包含 rules / facts / status 的不可变快照对象
*/
MemorySnapshot getSnapshot(String ownerId, String agentType);
/**
* 添加一条规则(rule)。规则是用户的强约束,如"代码必须用 Java 风格"。
* 如果已存在高度相似的规则(cosine > 0.85),执行合并而非新增。
*
* @param ownerId 用户 ID
* @param agentType Agent 类型
* @param content 规则内容
* @param source 来源标识(如 "extracted"、"manual"、"mcp-tool")
*/
void addRule(String ownerId, String agentType,
String content, String source);
/**
* 添加一条事实(fact)。事实是用户的客观信息,如"使用 PostgreSQL"。
*/
void addFact(String ownerId, String agentType,
String content, String source);
/**
* 更新用户状态(status)。状态是当前阶段的临时信息,如"正在学习 Rust"。
* 同一 agentType 下同一维度的 status 会被覆盖而非追加。
*/
void updateStatus(String ownerId, String agentType,
String content, String source);
/**
* 向量存储:将一段语义记忆写入 L2。
* 写入前自动去重(cosine > 0.85 视为重复),重复则更新 access_count。
*
* @return 存储后的记忆 ID(新生成或已有的均返回)
*/
String vectorStore(String ownerId, String agentType, String content);
/**
* 删除向量记忆。仅支持软删除(标记 deleted_at),用于审计追溯。
*/
void vectorDelete(String memoryId, String reason);
/**
* 获取指定用户的向量记忆总数(含已软删除的单独统计)。
*/
MemoryStats getStats(String ownerId, String agentType);
}
bash
/**
* 记忆提取接口 ------ 从对话中自动提取结构化记忆条目。
* 实现类通过调用 LLM 完成提取,并对结果做结构化校验。
*/
public interface MemoryExtractor {
/**
* 从对话文本中提取记忆条目。
* 提取过程:LLM 推理 → JSON 解析 → 置信度标注 → 分流处理。
*
* @param conversation 完整对话文本(多轮拼接)
* @param ownerId 用户 ID
* @param agentType Agent 类型
* @return 提取结果,包含分类的条目列表和各自的置信度
* @throws MemoryExtractException 当 LLM 调用失败或解析失败时抛出
*/
ExtractResult extract(String conversation,
String ownerId, String agentType);
/**
* 批量提取:对多段对话同时提取,内部做去重合并。
* 用于蒸馏场景------将历史对话批量压缩为记忆条目。
*
* @param conversations 多段对话文本
* @return 合并后的提取结果(跨段去重)
*/
ExtractResult extractBatch(List<String> conversations,
String ownerId, String agentType);
}
bash
/**
* 记忆检索接口 ------ 根据查询内容检索相关记忆。
* 实现类采用混合检索策略(向量 + 关键词 + 时间衰减融合)。
*/
public interface MemorySearcher {
/**
* 语义检索记忆,返回按融合得分排序的 topK 结果。
*
* @param ownerId 用户 ID
* @param agentType Agent 类型(用于 L2 隔离)
* @param query 查询文本
* @param topK 返回的最大条目数
* @return 按 hybrid_score 降序排列的记忆命中列表
*/
List<MemoryHit> search(String ownerId, String agentType,
String query, int topK);
/**
* 带过滤条件的检索。
*
* @param filter 过滤条件(如 memory_type = "rule"、created_after = ...)
*/
List<MemoryHit> search(String ownerId, String agentType,
String query, int topK,
MemoryFilter filter);
/**
* 精确检索:根据记忆 ID 直接获取。
* 用于 MCP 工具调用和审核流程。
*/
Optional<MemoryHit> getById(String memoryId);
}
设计要点: 三个接口职责单一且正交------
MemoryStore管读写,MemoryExtractor管提取,MemorySearcher管检索。上层业务(Agent Service)通过组合这三个接口完成完整流程,任何一个实现都可以独立替换和测试。
2.5 PGvector 的 DDL 与索引调优
选型确定了,接下来是落地。以下是 memory_vector 表的完整建表语句,包含 pgvector 扩展、向量维度定义和 HNSW 索引配置:
bash
-- 启用 pgvector 扩展(需在 PostgreSQL 中预先安装)
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE memory_vector (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id VARCHAR(64) NOT NULL,
agent_type VARCHAR(32) NOT NULL,
memory_type VARCHAR(16) NOT NULL DEFAULT 'fact',
content TEXT NOT NULL,
embedding vector(1536) NOT NULL, -- 1536 维,对应 text-embedding-3-small
confidence DECIMAL(3,2) DEFAULT 1.00,
source VARCHAR(32) DEFAULT 'extracted',
access_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ, -- 软删除标记
merged_from TEXT, -- 合并来源记录(逗号分隔 ID)
-- 复合唯一约束:防止同用户、同 Agent 下完全相同的内容重复写入
CONSTRAINT uq_memory_content UNIQUE (owner_id, agent_type, content)
);
-- 复合索引:加速按 owner_id + agent_type 的过滤查询
CREATE INDEX idx_memory_owner_agent
ON memory_vector (owner_id, agent_type)
WHERE deleted_at IS NULL;
-- HNSW 向量索引:核心检索索引
CREATE INDEX idx_memory_embedding
ON memory_vector
USING hnsw (embedding vector_cosine_ops)
WITH (
m = 16,
ef_construction = 64
);
HNSW 参数选择依据:
| 参数 | 取值 | 选择理由 |
|---|---|---|
m |
16 | 每个节点的最大连接数。16 是 pgvector 默认值,在 1536 维场景下,经验表明 16~32 之间检索质量差异不大,但 m=16 的索引构建速度更快、内存占用更小。当单用户记忆超过 5000 条时可尝试调至 32 |
ef_construction |
64 | 构建时搜索范围。值越大索引质量越好,但构建越慢。64 在 1000~5000 条/用户的规模下,Recall@10 可达 0.95 以上,且构建速度可接受(约 200 条/秒) |
不同数据量下的检索性能对比(P99 延迟,单次检索):
| 数据量(条/用户) | 无索引(暴力扫描) | HNSW (m=16, ef=64) | Recall@10 | 索引构建耗时 |
|---|---|---|---|---|
| 500 | 12 ms | 8 ms | 0.97 | < 1s |
| 1000 | 25 ms | 11 ms | 0.96 | 2~3s |
| 3000 | 78 ms | 18 ms | 0.95 | 8~10s |
| 5000 | 135 ms | 28 ms | 0.94 | 15~18s |
| 10000 | 280 ms | 45 ms | 0.92 | 30~35s |
实测环境:PostgreSQL 16 + pgvector 0.7,4C8G 云主机,单张表 10 万条总记录。结论:在单用户 5000 条以内的场景下,PGvector 的检索延迟完全可控,P99 均在 50ms 以内。超过 1 万条/用户后,建议考虑分表或切换到专用向量数据库。
三、记忆提取的代码实现
3.1 MemoryExtractService:让 LLM 决定"记什么"
提取是整个记忆流水线的入口。核心逻辑是:把对话内容交给 LLM,让它输出结构化的记忆条目(含置信度)。
bash
@Service
public class MemoryExtractService {
private final ChatModel chatModel;
private final MemoryStore memoryStore;
private final ObjectMapper objectMapper;
// 提取 Prompt 模板
private static final String EXTRACT_PROMPT = """
你是一个记忆提取助手。分析以下对话,提取值得长期记住的信息。
提取规则:
1. 提取 0~2 条事实(facts),0~1 条规则(rules),0~1 条状态(status)
2. 只提取对话中明确表述的信息,不要推理或脑补
3. 如果没有值得记住的新信息,各字段留空数组
4. 对每条提取结果给出 confidence 评分(0~1),表示你有多确定这条信息正确
5. 输出严格 JSON 格式
对话内容:
{conversation}
输出格式:
{
"facts": [{"content": "...", "confidence": 0.9}],
"rules": [{"content": "...", "confidence": 0.85}],
"status": [{"content": "...", "confidence": 0.7}]
}
""";
public ExtractResult extract(String conversation) {
// 1. 构造 Prompt
String promptText = EXTRACT_PROMPT
.replace("{conversation}", conversation);
// 2. 调用 LLM
String response = chatModel.call(promptText);
// 3. 解析 JSON 结果
ExtractResult result = parseExtractResult(response);
// 4. 按置信度分流处理
routeByConfidence(result, conversation);
return result;
}
}
3.2 Prompt 设计的四个关键约束
这个 Prompt 看起来简单,但每个约束都有对应的踩坑经验:
| 约束 | 为什么这么写 | 不加会怎样 |
|---|---|---|
| 限定 0~2 条 facts | 防止 LLM 过度提取,把闲聊都当记忆 | 一次对话提取 20 条"记忆",记忆库快速膨胀 |
| 只提取明确表述的信息 | 防止 LLM 推理脑补 | 用户说"我用 Java",LLM 提取"用户不会 Python" |
| 没有则留空数组 | 给模型一个"什么都不提取"的合理出口 | 模型为了"交差"硬编造记忆条目 |
| 置信度自评 | 为后续分流提供依据 | 所有提取结果一视同仁,无法区分确定性 |
实际测试中,"没有则留空数组"这条约束效果最明显。不加的时候,即使是"你好""今天天气不错"这种闲聊,模型也会硬挤出几条记忆。加了之后,无效提取减少约 70%。
3.3 异常处理与重试策略
上一节的 extract 方法省略了所有异常处理。但在生产环境中,LLM 调用充满了不确定性:超时、返回非标准 JSON、格式不符合预期......如果不处理,一次 LLM 异常就能让整条提取链路崩溃。
实际遇到过的三类异常:
| 异常类型 | 触发频率 | 表现 |
|---|---|---|
| LLM 调用超时 | ~3% | OpenAI / 本地模型推理超时,抛出 TimeoutException |
| JSON 解析失败 | ~5% | 模型返回的内容包含 markdown 代码块标记、多余文字、或不完整 JSON |
| 格式不合规 | ~2% | JSON 能解析,但缺少必要字段(如没有 confidence 字段),或类型不对 |
处理策略是三层防御 + 指数退避重试:
bash
@Service
@Slf4j
public class MemoryExtractService {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 1000;
private static final Duration LLM_TIMEOUT = Duration.ofSeconds(30);
public ExtractResult extract(String conversation,
String ownerId, String agentType) {
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
// 1. 调用 LLM(带超时控制)
String response = callLlmWithTimeout(conversation);
// 2. 容错解析 JSON
ExtractResult result = parseWithFallback(response);
// 3. 校验格式完整性
validateResult(result);
// 4. 分流处理
routeByConfidence(result, ownerId, agentType);
return result;
} catch (TimeoutException e) {
log.warn("[memory-extract] LLM 超时, attempt={}/{}",
attempt, MAX_RETRIES);
sleepBeforeRetry(attempt);
} catch (JsonParseException e) {
log.warn("[memory-extract] JSON 解析失败, " +
"attempt={}/{}, raw={}",
attempt, MAX_RETRIES, truncate(e.getRaw(), 200));
sleepBeforeRetry(attempt);
} catch (InvalidExtractResultException e) {
log.warn("[memory-extract] 格式不合规: {}",
e.getMessage());
// 格式问题通常重试也无法修复,直接放弃
break;
}
}
// 重试耗尽,记录到失败队列,异步人工处理
log.error("[memory-extract] 提取失败,已放弃: " +
"ownerId={}, agentType={}", ownerId, agentType);
failedExtractRepo.save(new FailedExtract(
ownerId, agentType, conversation, LocalDateTime.now()));
return ExtractResult.empty();
}
/**
* JSON 容错解析:三级降级策略
* 1. 标准 JSON 解析
* 2. 去除 markdown 代码块标记后重试
* 3. 正则兜底提取 JSON 片段
*/
private ExtractResult parseWithFallback(String raw) {
// Level 1: 直接解析
try {
return objectMapper.readValue(raw, ExtractResult.class);
} catch (JsonProcessingException ignored) {}
// Level 2: 去除 ```json ... ``` 包裹
String cleaned = raw.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "")
.trim();
try {
return objectMapper.readValue(cleaned, ExtractResult.class);
} catch (JsonProcessingException ignored) {}
// Level 3: 正则提取第一个 { ... } 块
Pattern pattern = Pattern.compile(
"\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}",
Pattern.DOTALL);
Matcher matcher = pattern.matcher(raw);
if (matcher.find()) {
try {
return objectMapper.readValue(
matcher.group(), ExtractResult.class);
} catch (JsonProcessingException e) {
throw new JsonParseException(raw, e);
}
}
throw new JsonParseException(raw);
}
/**
* 校验提取结果的格式完整性
*/
private void validateResult(ExtractResult result) {
if (result == null) {
throw new InvalidExtractResultException("结果为 null");
}
// 校验每条记忆都有 confidence 且范围合法
Stream.of(
result.getFacts(),
result.getRules(),
result.getStatuss()
).flatMap(List::stream).forEach(item -> {
if (item.getConfidence() == null) {
throw new InvalidExtractResultException(
"缺少 confidence: " + item.getContent());
}
if (item.getConfidence() < 0 || item.getConfidence() > 1) {
throw new InvalidExtractResultException(
"confidence 越界: " + item.getConfidence());
}
});
}
private void sleepBeforeRetry(int attempt) {
long delay = BASE_DELAY_MS * (long) Math.pow(2, attempt - 1);
try { Thread.sleep(delay); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
工程经验: JSON 解析的三级降级策略在生产环境中非常实用。实测 ~5% 的 LLM 返回会包含 markdown 代码块标记(尤其使用 GPT-4 时),Level 2 能兜住其中 90% 的情况。Level 3 的正则兜底主要用于处理模型在 JSON 前后附加解释文字的场景。格式不合规(如缺少 confidence)通常不会因重试而修复,所以直接 break 跳出重试循环,避免浪费 LLM 调用预算。
四、置信度分流 + HITL 的工程落地
4.1 三色阈值分流
提取结果按置信度分三条通道处理:
| 置信度 | 通道 | 处理方式 |
|---|---|---|
| ≥ 0.82 | 🟢 自动写入 | 直接写入 memory_fact / memory_rule 正式表 |
| 0.55 ~ 0.82 | 🟡 待审核 | 写入 pending_memory 表,等待人工审核 |
| < 0.55 | 🔴 丢弃 | 不存储,仅记录日志 |
阈值 0.82 和 0.55 不是拍脑袋定的------参考了 Mem0 等开源框架社区实践经验,在约 200 条标注数据上调出来的,平衡了"漏提取"和"错提取"的比例。需要注意的是,不同场景下的最优阈值差异较大:事实类记忆("用户用 Java")可以设低一些,规则类记忆("用户要求代码必须用某种风格")则应该设高一些。实际运行中,大约 55% 的提取结果直接通过,30% 进入 pending,15% 被丢弃。
4.2 pending_memory 表设计
bash
CREATE TABLE pending_memory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id VARCHAR(64) NOT NULL,
agent_type VARCHAR(32) NOT NULL,
memory_type VARCHAR(16) NOT NULL, -- fact / rule / status
content TEXT NOT NULL,
confidence DECIMAL(3,2) NOT NULL, -- 0.00 ~ 1.00
source_snippet TEXT, -- 原始对话片段(关键!)
status VARCHAR(16) DEFAULT 'pending', -- pending / approved / rejected
created_at TIMESTAMP DEFAULT NOW(),
resolved_at TIMESTAMP,
resolved_by VARCHAR(64)
);
这里有个关键设计:source_snippet 字段存储了提取这条记忆时对应的原始对话片段。
为什么需要它?因为审核员在审核时,不能只看到提取结果------"用户偏好使用 vim 编辑器",还需要看到原始对话来验证:
用户: 我平时写代码用 vim,不喜欢 IDE
Agent: 了解了,vim 是个很强大的编辑器
有了 source_snippet,审核员可以快速判断:这条提取是准确的,还是模型脑补的。
4.3 resolvePending 审核流程
bash
@Service
public class PendingMemoryService {
// 审核通过:写入正式记忆表
public void approve(String pendingId, String reviewerId) {
PendingMemory pending = pendingRepo.findById(pendingId)
.orElseThrow();
// 根据 memory_type 写入对应的正式表
switch (pending.getMemoryType()) {
case "fact" -> memoryStore.addFact(
pending.getOwnerId(), pending.getAgentType(),
pending.getContent());
case "rule" -> memoryStore.addRule(
pending.getOwnerId(), pending.getAgentType(),
pending.getContent());
case "status" -> memoryStore.updateStatus(
pending.getOwnerId(), pending.getAgentType(),
pending.getContent());
}
// 更新 pending 状态
pending.setStatus("approved");
pending.setResolvedBy(reviewerId);
pending.setResolvedAt(LocalDateTime.now());
pendingRepo.save(pending);
}
// 审核拒绝:标记为 rejected
public void reject(String pendingId, String reviewerId) {
PendingMemory pending = pendingRepo.findById(pendingId)
.orElseThrow();
pending.setStatus("rejected");
pending.setResolvedBy(reviewerId);
pending.setResolvedAt(LocalDateTime.now());
pendingRepo.save(pending);
}
}
审核流程目前通过后台管理页面完成,也预留了 API 接口。实际上线后,pending 队列的审核通过率约 75%------也就是说,模型提取的中等置信度记忆里,有四分之一是错误的,被人工拦截了。
4.4 阈值调优的实测数据
4.1 节的 0.82 / 0.55 阈值是怎么来的?这里展示完整的调优过程。
评估方法: 人工标注 200 条对话的"正确提取结果"作为 ground truth,在不同阈值组合下计算 Precision(提取结果中正确的比例)和 Recall(正确记忆被提取出的比例),取 F1 作为综合指标。
事实类记忆(facts)的阈值调优:
| 阈值组合(auto / pending) | Precision | Recall | F1 | 自动通过率 | pending 通过率 |
|---|---|---|---|---|---|
| 0.90 / 0.60 | 0.95 | 0.68 | 0.79 | 38% | 71% |
| 0.85 / 0.55 | 0.92 | 0.74 | 0.82 | 48% | 75% |
| 0.82 / 0.55 | 0.90 | 0.81 | 0.85 | 55% | 76% |
| 0.75 / 0.50 | 0.84 | 0.87 | 0.85 | 68% | 78% |
| 0.70 / 0.45 | 0.78 | 0.91 | 0.84 | 78% | 82% |
规则类记忆(rules)的阈值调优:
| 阈值组合(auto / pending) | Precision | Recall | F1 | 备注 |
|---|---|---|---|---|
| 0.90 / 0.70 | 0.97 | 0.62 | 0.76 | 太保守,漏提取多 |
| 0.85 / 0.65 | 0.94 | 0.73 | 0.82 | 可用 |
| 0.82 / 0.60 | 0.91 | 0.80 | 0.85 | 采用 |
| 0.78 / 0.55 | 0.86 | 0.85 | 0.85 | F1 相当但 Precision 偏低 |
最终选择 0.82/0.55 的依据:在 F1 基本持平的情况下,这组阈值的 Precision 更高(0.90 vs 0.84),意味着自动通过的记忆中错误更少------这对用户体验更重要。漏提取的记忆可以在后续对话中补提,但错误的记忆一旦写入就会持续影响决策。
附:简易网格搜索脚本(Python),可复现调优过程:
bash
"""
记忆提取阈值网格搜索
用法:python threshold_grid_search.py --data labeled_extracts.json
"""
import json, argparse
from itertools import product
def evaluate(extracts, auto_thresh, pending_thresh):
tp, fp, fn = 0, 0, 0
auto_count, pending_count = 0, 0
for item in extracts:
conf = item["confidence"]
is_correct = item["is_correct"]
if conf >= auto_thresh:
auto_count += 1
if is_correct: tp += 1
else: fp += 1
elif conf >= pending_thresh:
pending_count += 1
# pending 按人工标注通过率 75% 估算
if is_correct:
tp += 0.75
else:
fn += 0.25
else:
if is_correct: fn += 1
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
f1 = 2 * precision * recall / (precision + recall) \
if (precision + recall) > 0 else 0
return precision, recall, f1, auto_count / len(extracts)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--data", required=True)
args = parser.parse_args()
with open(args.data) as f:
extracts = json.load(f)
print(f"{'auto_t':>7} {'pend_t':>7} {'Prec':>6} {'Recall':>7} "
f"{'F1':>6} {'auto%':>6}")
for auto_t, pend_t in product(
[0.70, 0.75, 0.78, 0.82, 0.85, 0.90],
[0.45, 0.50, 0.55, 0.60, 0.65, 0.70]):
if auto_t <= pend_t:
continue
p, r, f1, ar = evaluate(extracts, auto_t, pend_t)
print(f"{auto_t:>7.2f} {pend_t:>7.2f} {p:>6.2f} {r:>7.2f} "
f"{f1:>6.2f} {ar*100:>5.0f}%")
五、混合检索的三信号融合
5.1 为什么需要混合检索
纯向量检索有一个已知弱点:对精确关键词不敏感。用户问"你记不记得我喜欢 PostgreSQL",向量检索可能返回"用户喜欢 Python"------因为"喜欢某编程语言"的语义距离很近,但内容完全不对。
Dream-SaaS 的解决方案是三路信号融合:
bash
hybrid_score = 0.6 × vector_score
+ 0.3 × keyword_score
+ 0.1 × time_score
5.2 三路信号的实现
| 信号 | 计算方式 | 权重 | 为什么这么定 |
|---|---|---|---|
| vector_score | pgvector cosine 相似度 | 0.6 | 语义理解是核心能力,权重最高 |
| keyword_score | ILIKE 关键词匹配率 | 0.3 | 补偿向量检索的精确匹配弱点 |
| time_score | 1 / (1 + days / 30) | 0.1 | 近期记忆优先,但只作为微调 |
代码实现:
bash
@Service
public class HybridMemorySearcher implements MemorySearcher {
private static final double W_VECTOR = 0.6;
private static final double W_KEYWORD = 0.3;
private static final double W_TIME = 0.1;
public List<MemoryHit> search(String ownerId,
String agentType, String query, int topK) {
// 1. 向量检索:pgvector cosine 相似度
float[] queryEmbedding = embeddingModel.embed(query);
List<VectorResult> vectorResults = jdbc.query("""
SELECT id, content,
1 - (embedding <=> ?) AS vector_score
FROM memory_vector
WHERE owner_id = ? AND agent_type = ?
ORDER BY embedding <=> ?
LIMIT ?
""",
queryEmbedding, ownerId, agentType,
queryEmbedding, topK * 2 // 多取一些,后面融合
);
// 2. 关键词检索:ILIKE 模糊匹配
String pattern = "%" + query.toLowerCase() + "%";
List<KeywordResult> keywordResults = jdbc.query("""
SELECT id, content,
CASE WHEN LOWER(content) LIKE ?
THEN 1.0 ELSE 0.0 END AS keyword_score
FROM memory_vector
WHERE owner_id = ? AND agent_type = ?
AND LOWER(content) LIKE ?
""",
pattern, ownerId, agentType, pattern
);
// 3. 融合三路信号
Map<Long, Double> finalScores = new HashMap<>();
for (var vr : vectorResults) {
double timeScore = calcTimeScore(vr.getUpdatedAt());
double kwScore = getKeywordScore(
vr.getId(), keywordResults);
double score = W_VECTOR * vr.getVectorScore()
+ W_KEYWORD * kwScore
+ W_TIME * timeScore;
finalScores.put(vr.getId(), score);
}
// 4. 排序、去重、返回 topK
return finalScores.entrySet().stream()
.sorted(Map.Entry.comparingByValue(
Comparator.reverseOrder()))
.limit(topK)
.map(e -> buildHit(e.getKey(), e.getValue()))
.toList();
}
// 时间衰减:1/(1 + days/30)
private double calcTimeScore(LocalDateTime updatedAt) {
long days = ChronoUnit.DAYS.between(
updatedAt, LocalDateTime.now());
return 1.0 / (1.0 + days / 30.0);
}
}
5.3 去重策略
记忆提取是持续进行的,同一个事实可能被多次提取(比如用户多次提到"我用 Java")。如果不处理,记忆库里会出现大量重复条目。
去重逻辑:写入前检查是否有 cosine > 0.85 的已有记忆,如果有则合并而非新增:
bash
public void addVectorMemory(String ownerId, String agentType,
String content) {
float[] embedding = embeddingModel.embed(content);
// 检查是否已有相似记忆
List<VectorResult> similar = jdbc.query("""
SELECT id, content FROM memory_vector
WHERE owner_id = ? AND agent_type = ?
AND embedding <=> ? < 0.15 -- cosine > 0.85
""",
ownerId, agentType, embedding
);
if (!similar.isEmpty()) {
// 合并:更新已有条目的 updated_at,
// merged_from 记录合并来源(可追溯)
jdbc.update("""
UPDATE memory_vector
SET updated_at = NOW(),
access_count = access_count + 1,
merged_from = merged_from || ?
WHERE id = ?
""",
"," + existingId, existingId
);
} else {
// 新增
jdbc.update("""
INSERT INTO memory_vector
(owner_id, agent_type, content, embedding)
VALUES (?, ?, ?, ?)
""",
ownerId, agentType, content, embedding
);
}
}
5.4 实测数据
在一个用户约 800 条记忆的数据集上测试混合检索效果:
| 查询 | vector_score | keyword_score | hybrid_score | 是否命中正确记忆 |
|---|---|---|---|---|
| "PostgreSQL" | 0.61 | 1.0 | 0.82 | ✅ 精确命中 |
| "编程语言" | 0.88 | 0.0 | 0.38 | ✅ 语义命中(用户偏好 Java) |
| "航空航班" | 0.32 | 0.0 | 0.09 | ✅ 正确过滤(无相关记忆) |
如果只用向量检索,"PostgreSQL"这个查询的得分只有 0.61------因为"PostgreSQL"作为一个专有名词,语义空间里距离很远,但它确实精确命中了用户的记忆。关键词信号把它从 0.61 拉到了 0.82。
5.5 Embedding 模型选型对比
向量检索的质量上限取决于 Embedding 模型。Dream-SaaS 在记忆检索场景下对比了四款主流模型:
| 模型 | 维度 | 检索 Recall@10 | 平均延迟(单次) | 成本($/1M tokens) | 中文效果 |
|---|---|---|---|---|---|
| text-embedding-3-small | 1536 | 0.86 | 120 ms | $0.02 | 良好 |
| text-embedding-3-large | 3072 | 0.91 | 180 ms | $0.13 | 优秀 |
| bge-m3(BAAI) | 1024 | 0.89 | 85 ms | 自部署免费 | 优秀 |
| jina-embeddings-v3 | 1024 | 0.88 | 150 ms | $0.00(免费额度) | 良好 |
测试条件: 800 条中文记忆条目,50 条标注查询,Recall@10 衡量。延迟包含网络往返,测试节点在华东区域。
选型决策:
- text-embedding-3-small:当前 Dream-SaaS 的生产选择。成本极低,中文效果足够用,1536 维在 PGvector 下索引体积可控(单条 ~6KB)。Recall@10 = 0.86 在单用户 5000 条以内的场景下够用
- bge-m3:中文检索效果最好的开源模型,如果未来对中文检索质量有更高要求,可以考虑自部署替换。85ms 的延迟也最低(自部署 GPU 推理)
- text-embedding-3-large:质量提升有限(0.86 → 0.91),但成本翻了 6.5 倍。在记忆检索这个对延迟和成本都敏感的场景下,ROI 不高
- jina-embeddings-v3:免费额度有吸引力,但延迟偏高,且维度从 2048 压缩到 1024 后有一定的信息损失
关于模型切换: 更换 Embedding 模型后,所有已有的向量数据必须重新生成。因为不同模型的向量空间不兼容------同一个文本在不同模型下生成的向量没有可比性。这在生产环境中是一个需要计划的任务(详见"踩坑实录"第 3 条)。
六、5 个 Agent 如何共享记忆
6.1 namespace 隔离策略
Dream-SaaS 有 5 个 Agent,它们共享同一套记忆模块,但通过 namespace 实现隔离与共享的平衡:
| Agent | 职责 | 记忆使用场景举例 |
|---|---|---|
| Companion | AI 搭子,日常陪伴对话 | 记住用户聊过的生活偏好、情绪状态、兴趣爱好 |
| CodeReview | AI 代码审查 | 记住用户的代码风格偏好、常犯的代码问题、技术栈 |
| Study | 学习助手 | 记住用户的学习进度、薄弱知识点、学习计划 |
| Writing | 写作助手 | 记住用户的写作风格、常用表达、内容偏好 |
| TaskFlow | 任务流管理 | 记住用户的工作流习惯、任务优先级偏好、常用项目 |
| 记忆层级 | 隔离策略 | 说明 |
|---|---|---|
| L1 结构化记忆 | 按 ownerId 隔离,可跨 Agent 共享 | 用户的规则/事实/状态是全局的,所有 Agent 都能读到 |
| L2 向量记忆 | 按 Agent 隔离(agent_type 字段) | 每个 Agent 的语义记忆独立,Companion 的记忆不会干扰 CodeReview |
| L3 对话窗口 | 按 Agent + sessionId 隔离 | 短期上下文完全隔离,Agent 之间不共享对话历史 |
bash
namespace 隔离模型
┌─ L2 向量记忆 ─────────────────────┐
│ companion-personal (隔离) │
│ code-review (隔离) │
│ study-personal (隔离) │
│ ... │
└────────────────────────────────────┘
↑ 检索时按 agent_type 过滤
┌─ L1 结构化记忆 ───────────────────┐
│ ownerId=12345 (共享) │
│ ├─ rules: [代码用Java, ...] │
│ ├─ facts: [用PostgreSQL, ...] │
│ └─ status: [正在学Rust, ...] │
└────────────────────────────────────┘
↑ 所有 Agent 都能读取
这样设计的原因:用户的偏好和事实("我用 Java""我是后端工程师")对所有 Agent 都有价值;但每个 Agent 积累的语义记忆是场景相关的,Companion 聊到的情感记忆不应该影响 CodeReview 的代码审查。
6.2 Agent 接入代码示例
以 CompanionAgentService 为例,展示一个 Agent 如何在对话中接入记忆系统:
bash
@Service
public class CompanionAgentService {
private final ChatModel chatModel;
private final MemoryStore memoryStore;
private final MemorySearcher memorySearcher;
private final MemoryExtractService extractService;
private final ChatMemory chatMemory;
public String chat(String userId, String userMessage) {
// Step 1: 加载结构化记忆(L1)
MemorySnapshot snapshot = memoryStore.getSnapshot(
userId, "companion");
// Step 2: 语义检索相关记忆(L2)
List<MemoryHit> relevant = memorySearcher.search(
userId, "companion", userMessage, 5);
// Step 3: 构造 system prompt,注入记忆
String systemPrompt = buildSystemPrompt(
snapshot, relevant);
// Step 4: 调用 LLM
String response = chatModel.call(
new Prompt(systemPrompt,
chatMemory.get(userId),
userMessage));
// Step 5: 记录到对话窗口(L3)
chatMemory.add(userId,
List.of(
new Message(USER, userMessage),
new Message(ASSISTANT, response)));
// Step 6: 异步提取记忆(不阻塞响应)
CompletableFuture.runAsync(() -> {
String conversation = chatMemory
.get(userId).stream()
.map(Message::getContent)
.collect(Collectors.joining("\n"));
extractService.extract(conversation);
});
return response;
}
}
注意 Step 6 是异步执行的------记忆提取不影响对话响应速度。即使提取失败(LLM 超时、JSON 解析出错),也不会影响用户体验。
6.3 MCP 工具暴露
记忆能力通过 MCP 协议暴露为 6 个工具,供 Agent 在推理过程中主动调用:
| 工具名 | 功能 | 安全等级 |
|---|---|---|
getSnapshot |
获取用户当前记忆快照(L1) | 🟢 只读 |
addRule |
添加用户规则 | 🟡 写入(有限) |
addFact |
添加用户事实 | 🟡 写入(有限) |
updateStatus |
更新用户状态 | 🟡 写入(有限) |
vectorSearch |
语义检索记忆 | 🟢 只读 |
vectorStore |
存储向量记忆 | 🟡 写入(有限) |
故意不暴露 deleteMemory、clearAll 等危险操作。Agent 可以新增和查询记忆,但不能直接删除------删除操作只能通过管理后台或蒸馏流程执行。这是 MEMORY-02 提到的"安全边界"原则的落地。
6.4 Spring AI 的完整配置参考
以下是 Dream-SaaS 记忆模块的完整 application.yml 配置,覆盖 Embedding 模型、PGvector 连接、ChatMemory 和记忆业务参数,每个关键配置项都有注释:
bash
# ============================================
# Dream-SaaS 记忆模块完整配置
# ============================================
spring:
# --- 数据源(PGvector 复用 PostgreSQL 主库) ---
datasource:
url: jdbc:postgresql://pg-primary:5432/dream_saas
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20 # 记忆读写 + 业务共用,预留足够连接
minimum-idle: 5
# --- Spring AI 基础配置 ---
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini # 记忆提取用的模型(性价比优先)
temperature: 0.3 # 低温度,保证提取结果稳定
embedding:
options:
model: text-embedding-3-small
dimensions: 1536 # 与 memory_vector.embedding 维度必须一致
# --- ChatMemory(L3 对话窗口) ---
chat:
memory:
repository:
type: redis # 生产用 Redis,开发环境可改 in-memory
max-messages: 20 # 单用户最多保留 20 轮对话(L3 窗口上限)
# --- Dream-SaaS 记忆模块自定义配置 ---
dream:
memory:
# 存储引擎选择
store: pgvector # pgvector | redis-mcp
# 向量检索配置
vector:
search-top-k: 10 # 检索时多取一些(融合前)
cosine-threshold: 0.85 # 去重阈值:cosine > 0.85 视为重复
hnsw-ef-search: 40 # 检索时的 HNSW 搜索精度
# 值越大 Recall 越高但越慢
# 生产默认 40,调优场景可临时调到 80
# 混合检索权重
hybrid:
weight-vector: 0.6 # 向量语义相似度权重
weight-keyword: 0.3 # 关键词匹配权重
weight-time: 0.1 # 时间衰减排序权重
time-decay-days: 30 # 时间衰减半衰期(天)
# 30 天前的记忆 time_score ≈ 0.5
# 提取与置信度分流
extract:
auto-threshold: 0.82 # ≥ 此值 → 自动写入
pending-threshold: 0.55 # ≥ 此值 → 待审核
# < 此值 → 丢弃
max-retries: 3 # LLM 调用失败最大重试次数
retry-base-delay-ms: 1000 # 重试基础延迟(指数退避)
llm-timeout-seconds: 30 # LLM 调用超时时间
async-pool-size: 8 # 异步提取线程池大小
async-pool-queue-capacity: 200 # 异步队列容量
# 超过则拒绝并记录失败日志
# 蒸馏配置
distill:
enabled: true
cron: "0 0 3 * * ?" # 每天凌晨 3 点执行蒸馏
max-memories-per-user: 5000 # 单用户记忆上限,超过则触发强制蒸馏
merge-cosine-threshold: 0.90 # cosine > 0.90 的记忆条目合并
min-access-count: 2 # access_count < 2 且创建 > 30 天的记忆
# 优先被蒸馏清除
七、小结与系列总结
三篇回顾
| 篇目 | 主题 | 核心内容 |
|---|---|---|
| MEMORY-01 理论篇 | 从认知科学到记忆分层 | 四种记忆类型 → 三层工程模型 |
| MEMORY-02 工程篇 | 记忆的工程机制 | 提取、置信度分流、HITL、混合检索、蒸馏、生命周期 |
| MEMORY-03 实战篇 | Dream-SaaS 落地 | Java + Spring AI 代码实现:架构、提取、检索、多 Agent 共享 |
记忆模块的核心价值
三篇文章的核心观点只有一个:记忆让 Agent 从"无状态函数"变成"有经验的伙伴"。
没有记忆的 Agent,每次对话都是从零开始。它不记得用户的偏好,不记得之前的决策,不记得犯过的错误。有了记忆系统,Agent 才能积累经验、适应个体、持续进化。
Dream-SaaS 的记忆模块不是完美的------蒸馏机制还是手动定期合并,观测评估体系还没接入,混合检索的权重还在持续调优。但它已经跑起来了,5 个 Agent 在用,用户能感受到"它记得我"。
下一步方向
- 蒸馏自动化:目前记忆蒸馏依赖手动定期合并,计划实现基于"访问频率 + 时间衰减"的自动蒸馏触发机制
- 观测评估体系:接入记忆检索的命中率、提取的准确率等指标,用数据驱动优化
- 跨 Agent 记忆迁移:当用户的某个偏好从一个场景扩展到另一个场景时,实现记忆的自动迁移
🧠 深入理解 AI Agent · Memory 系列 · 03/03
有问题评论区见,欢迎交流~
参考资料
- Dream-SaaS 记忆模块源码:dream-saas.com
- Spring AI 官方文档:Introduction :: Spring AI Reference
- Mem0:GitHub - mem0ai/mem0: Universal memory layer for AI Agents · GitHub
- Zep:GitHub - getzep/zep: Zep | Examples, Integrations, & More · GitHub
- Letta (MemGPT):GitHub - letta-ai/letta: Platform for stateful agents: AI with advanced memory that can learn and self-improve over time. · GitHub
- Lilian Weng, "LLM Powered Autonomous Agents". LLM Powered Autonomous Agents | Lil'Log
- Memory in the Age of AI Agents, 2026. blog.langchain.dev/memory-in-t...