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

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:纯接口层,定义 MemoryStoreMemoryExtractorMemorySearcher 等核心接口
  • 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 存储向量记忆 🟡 写入(有限)

故意不暴露 deleteMemoryclearAll 等危险操作。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


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


参考资料

  1. Dream-SaaS 记忆模块源码: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. blog.langchain.dev/memory-in-t...
相关推荐
全栈弄潮儿1 小时前
AI 写代码后,如何自己检查有没有问题?
aigc·openai·ai编程
禁止摆烂_才浅1 小时前
前端 AI 面试题
前端·面试·ai编程
百工蜂Agent1 小时前
对话才几轮,上下文窗口怎么就满了?
agent
小四的小六1 小时前
两个Agent同时写同一个Tool,数据被覆盖了——我是怎么用乐观锁修好的
openai·agent·ai编程
可爱的小Cherry1 小时前
DeepSeek Harness 手机版来咯!支持图片识别,一键开启「梁神模式」
agent
盖伦发发1 小时前
RAG 能跑≠能用:用 EDD 把 Eval 做成基础设施 (附源码)
人工智能·后端·python·功能测试
_codeOH1 小时前
大模型上下文窗口管理:从滑动窗口到 RAG
人工智能·ai编程
MistyStar1 小时前
一切皆插件,DeepSeek Harness 来了,模型终于配上自己的马具
人工智能·github
析数塔1 小时前
Meta 卖的不是模型,是你的代码
agent·ai编程