深入理解 AI Agent · MEMORY #03:从设计到落地

从 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 在检索时融合为上下文,注入下一轮对话。

对话进入 → 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:纯接口层,定义 MemoryStoreMemoryExtractorMemorySearcher 等核心接口
  • memory-pgvector:基于 PGvector 的实现
  • memory-redis:基于 Redis 的 L3 对话缓存实现
  • memory-mcp:MCP 工具暴露层,把记忆能力以 Tool 的形式提供给 Agent

切换存储引擎只需一行配置:

application.yml:

spring:
  dream:
    memory:
      store: pgvector   # 可选: pgvector | redis-mcp

通过 @ConditionalOnProperty 实现条件装配:

@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 模块的全部内容:

/**
 * 记忆存储接口 —— 管理 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);
}
/**
 * 记忆提取接口 —— 从对话中自动提取结构化记忆条目。
 * 实现类通过调用 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);
}
/**
 * 记忆检索接口 —— 根据查询内容检索相关记忆。
 * 实现类采用混合检索策略(向量 + 关键词 + 时间衰减融合)。
 */
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 索引配置:

-- 启用 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 参数选择依据:

参数取值选择理由
m16每个节点的最大连接数。16 是 pgvector 默认值,在 1536 维场景下,经验表明 16~32 之间检索质量差异不大,但 m=16 的索引构建速度更快、内存占用更小。当单用户记忆超过 5000 条时可尝试调至 32
ef_construction64构建时搜索范围。值越大索引质量越好,但构建越慢。64 在 1000~5000 条/用户的规模下,Recall@10 可达 0.95 以上,且构建速度可接受(约 200 条/秒)

不同数据量下的检索性能对比(P99 延迟,单次检索):

数据量(条/用户)无索引(暴力扫描)HNSW (m=16, ef=64)Recall@10索引构建耗时
50012 ms8 ms0.97< 1s
100025 ms11 ms0.962~3s
300078 ms18 ms0.958~10s
5000135 ms28 ms0.9415~18s
10000280 ms45 ms0.9230~35s

实测环境:PostgreSQL 16 + pgvector 0.7,4C8G 云主机,单张表 10 万条总记录。结论:在单用户 5000 条以内的场景下,PGvector 的检索延迟完全可控,P99 均在 50ms 以内。超过 1 万条/用户后,建议考虑分表或切换到专用向量数据库。


三、记忆提取的代码实现

3.1 MemoryExtractService:让 LLM 决定"记什么"

提取是整个记忆流水线的入口。核心逻辑是:把对话内容交给 LLM,让它输出结构化的记忆条目(含置信度)。

@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 字段),或类型不对

处理策略是三层防御 + 指数退避重试

@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 表设计

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 审核流程

@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)PrecisionRecallF1自动通过率pending 通过率
0.90 / 0.600.950.680.7938%71%
0.85 / 0.550.920.740.8248%75%
0.82 / 0.550.900.810.8555%76%
0.75 / 0.500.840.870.8568%78%
0.70 / 0.450.780.910.8478%82%

规则类记忆(rules)的阈值调优:

阈值组合(auto / pending)PrecisionRecallF1备注
0.90 / 0.700.970.620.76太保守,漏提取多
0.85 / 0.650.940.730.82可用
0.82 / 0.600.910.800.85采用
0.78 / 0.550.860.850.85F1 相当但 Precision 偏低

最终选择 0.82/0.55 的依据:在 F1 基本持平的情况下,这组阈值的 Precision 更高(0.90 vs 0.84),意味着自动通过的记忆中错误更少——这对用户体验更重要。漏提取的记忆可以在后续对话中补提,但错误的记忆一旦写入就会持续影响决策。

附:简易网格搜索脚本(Python),可复现调优过程:

"""
记忆提取阈值网格搜索
用法: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 的解决方案是三路信号融合:

hybrid_score = 0.6 × vector_score
             + 0.3 × keyword_score
             + 0.1 × time_score

5.2 三路信号的实现

信号计算方式权重为什么这么定
vector_scorepgvector cosine 相似度0.6语义理解是核心能力,权重最高
keyword_scoreILIKE 关键词匹配率0.3补偿向量检索的精确匹配弱点
time_score1 / (1 + days / 30)0.1近期记忆优先,但只作为微调

代码实现:

@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 的已有记忆,如果有则合并而非新增:

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_scorekeyword_scorehybrid_score是否命中正确记忆
"PostgreSQL"0.611.00.82✅ 精确命中
"编程语言"0.880.00.38✅ 语义命中(用户偏好 Java)
"航空航班"0.320.00.09✅ 正确过滤(无相关记忆)

如果只用向量检索,"PostgreSQL"这个查询的得分只有 0.61——因为"PostgreSQL"作为一个专有名词,语义空间里距离很远,但它确实精确命中了用户的记忆。关键词信号把它从 0.61 拉到了 0.82。

5.5 Embedding 模型选型对比

向量检索的质量上限取决于 Embedding 模型。Dream-SaaS 在记忆检索场景下对比了四款主流模型:

模型维度检索 Recall@10平均延迟(单次)成本($/1M tokens)中文效果
text-embedding-3-small15360.86120 ms$0.02良好
text-embedding-3-large30720.91180 ms$0.13优秀
bge-m3(BAAI)10240.8985 ms自部署免费优秀
jina-embeddings-v310240.88150 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职责记忆使用场景举例
CompanionAI 搭子,日常陪伴对话记住用户聊过的生活偏好、情绪状态、兴趣爱好
CodeReviewAI 代码审查记住用户的代码风格偏好、常犯的代码问题、技术栈
Study学习助手记住用户的学习进度、薄弱知识点、学习计划
Writing写作助手记住用户的写作风格、常用表达、内容偏好
TaskFlow任务流管理记住用户的工作流习惯、任务优先级偏好、常用项目
记忆层级隔离策略说明
L1 结构化记忆按 ownerId 隔离,可跨 Agent 共享用户的规则/事实/状态是全局的,所有 Agent 都能读到
L2 向量记忆按 Agent 隔离(agent_type 字段)每个 Agent 的语义记忆独立,Companion 的记忆不会干扰 CodeReview
L3 对话窗口按 Agent + sessionId 隔离短期上下文完全隔离,Agent 之间不共享对话历史
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 如何在对话中接入记忆系统:

@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存储向量记忆🟡 写入(有限)

故意不暴露 deleteMemoryclearAll 等危险操作。Agent 可以新增和查询记忆,但不能直接删除——删除操作只能通过管理后台或蒸馏流程执行。这是 MEMORY-02 提到的"安全边界"原则的落地。

6.4 Spring AI 的完整配置参考

以下是 Dream-SaaS 记忆模块的完整 application.yml 配置,覆盖 Embedding 模型、PGvector 连接、ChatMemory 和记忆业务参数,每个关键配置项都有注释:

# ============================================
# 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


有问题评论区见,欢迎交流~


参考资料

  1. Dream-SaaS 记忆模块源码:https://dream-saas.com
  2. Spring AI 官方文档:Introduction :: Spring AI Reference
  3. Mem0:GitHub - mem0ai/mem0: Universal memory layer for AI Agents · GitHub
  4. Zep:GitHub - getzep/zep: Zep | Examples, Integrations, & More · GitHub
  5. Letta (MemGPT):GitHub - letta-ai/letta: Platform for stateful agents: AI with advanced memory that can learn and self-improve over time. · GitHub
  6. Lilian Weng, "LLM Powered Autonomous Agents". LLM Powered Autonomous Agents | Lil'Log
  7. Memory in the Age of AI Agents, 2026. https://blog.langchain.dev/memory-in-the-age-of-ai-agents/
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值