MingJunDuan的博客
热爱可抵一切,探索未知之境
全站访问量

企业级记忆知识库:短期上下文与四层记忆的 RAG/向量检索实现

你是一个 Java 后端工程师,手上有一个订单中心。某天你让 Agent “帮我在下单流程里加一个库存不足的兜底”。

它没有翻代码,也没有问你 createOrder 里调了哪几个服务,而是直接告诉你:

下单流程依次是 RiskService.checkRiskInventoryService.freezeStockCouponService.useCouponOrderDAO.insertInventoryService.deductStock。库存不足会抛 InventoryShortageException,冻结的库存要回滚。建议在 freezeStock 之后加 try-catch 兜底。

你愣了一下:它既没去读你的 Git 仓库,也没问你接口签名,这些”调用顺序”和”异常约定”它是怎么”记得”的?

答案不在模型里——模型是无状态的,上一篇《大模型无状态》已经讲过。答案在一套企业级记忆知识库里:这些接口信息、业务规则,早就被切成片段、压成向量、写进了一个向量数据库,Agent 只是在你发问的那一刻,做了一次语义检索把它捞了回来。

这篇文章要讲的就是这件事:一套企业级记忆知识库到底怎么实现——短期上下文怎么做、四层记忆怎么分层、代码库信息和业务知识怎么变成长期记忆、怎么写进向量数据库、又怎么在恰当的时机被检索回来。 它以得物企业级 MultiAgent 记忆系统(短期上下文 + 四层记忆)为骨架,重点把其中”RAG + 向量检索/向量数据库”这一段挖到代码级。

如果你读过《Agent 记忆:从会话记忆到长期记忆的架构与投毒防御》,这篇是它的工程化续篇:上一篇讲”记忆分哪几层、为什么会被投毒”,这一篇讲”每一层到底用什么数据结构、写什么代码、向量库内部怎么跑”。

先立住贯穿全文的一句话:

短期记忆管”这一轮”(把会话历史带进下一次调用),长期记忆管”以后每一轮”(把一个向量知识库按语义检索回来)。前者靠 Redis/MySQL 和 Token 窗口,后者靠 RAG——切分、嵌入、建索引、检索、注入。


一、为什么 Agent 记忆必须做成”知识库”,而不是”把历史全拼进 Prompt”

1.1 无状态模型 × 有状态业务

模型的输入输出都是 token。你在多轮对话里感觉到的”连续”,是客户端每轮把整份 messages 数组原样重发,上一篇已经拆过。这个机制的三个硬伤:

  1. 窗口有限:注意力是 O(n²) 的,KV Cache 随长度线性涨,128K 已接近极限。把几个月的历史全拼进去,先撞墙的是窗口,不是业务。
  2. 又贵又慢:每轮重发 10 万 token 历史,你付的是 10 万 token 的钱,等的是 10 万 token 的延迟,而真正有用的可能只有最后 2000 token。
  3. 拼不进去的才是大头:代码库里几千个接口、几百条业务规则、用户跨会话的偏好,这些根本不可能也不应该塞进 prompt——但 Agent 又确实需要它们。

于是结论只有一个:历史不能”全量带上”,只能”按需取用”。 这正是信息检索(IR)领域最古老的那个思想,套到 LLM 上,就叫 RAG(Retrieval-Augmented Generation,检索增强生成)

1.2 记忆分层的工程动机

为什么还要分”短期/长期”这么多层,而不是一个库全搞定?因为一个残酷的约束:持久性和提取成本是矛盾的。 记得越久、越多,写和读的代价就越大。

层次 生命周期 容量 读写成本 载体
工作记忆 一次推理 极小 全量参与计算 上下文窗口
会话记忆 一次会话 有限 每轮全量重发 messages 数组
短期记忆 会话内 有限 窗口裁剪+摘要 Redis/MySQL
长期记忆 跨会话 近乎无限 检索/规则注入 向量库/Profile

人脑分层的理由,对 Agent 完全成立,甚至更严苛——因为 Agent 的”大脑”是一个无状态纯函数,所有”记忆”都要靠外面的壳用工程手段一层层堆。

1.3 长期记忆的本质 = RAG 四件事

长期记忆(尤其是跨会话的代码库、业务知识)本质是一个检索系统,四件事:

写入:  原始信息 ─▶ 切分(chunk) ─▶ 嵌入(embedding) ─▶ 建索引(index)
读取:  当前问题 ─▶ 嵌入(query) ─▶ 相似度检索(ANN) ─▶ 注入(prompt)

这四件事,就是这篇博客后五章的全部内容。你可以先记住这个四步,后面每一章都在填它的细节。

1.4 从得物 MemOS 到”通用企业级记忆知识库”

得物选型了 MemOS 作为长期记忆主路径(评测 74.33%,仅作选型参考),同时保留 MySQL/Mem0 路由作兼容。但本文不打算把你绑死在一个产品上——MemOS 只是”检索式长期记忆”的一种落地。我会把它抽象成三个后端工程师能自己实现的接口:

// 嵌入:文本 → 向量
public interface EmbeddingClient {
    float[] embed(String text);           // 例如返回 float[1024]
}

// 向量库:向量 + 元数据的存取
public interface VectorDatabase {
    void upsert(String collection, float[] vector, Map<String, Object> metadata);
    List<Hit> search(String collection, float[] queryVector, int topK, double threshold);
}

// 记忆门面:短期 + 长期的读写入口
public interface MemoryService {
    List<Message> loadShortTerm(String conversationId, int contextRounds);
    Map<String, String> loadLongTerm(Long tenantId, Long userId, Long agentId, String query);
}

不管底层是 MemOS、Milvus、Qdrant、pgvector 还是自研,这三个抽象是不变的。下面每一章,你都能在脑子里把它们换成自己公司的实现。


二、四层记忆架构与两套分类维度

2.1 四层生命周期

得物的 Agent 四层记忆模型,四层对应四个生命周期:

生命周期 作用域 对应得物实现
Working Memory 一次推理 当前一步 上下文窗口
Session Memory 一次会话 会话内消息历史 Redis/MySQL(ConversationMemory
User Memory 跨会话 跨 Agent 共享的偏好与稳定事实 MemOS 的 user_profile
Agent Memory 跨会话 某个 Agent 的任务经验与协作约定 MemOS 的 agent_{agentId}

核心是最后两层的区分:User 记忆是”这个用户”的,换哪个 Agent 都用得上;Agent 记忆是”这个 Agent 自己”的,换用户就不一定适用。 这是两条不同的 scope,后面检索和预算分配都要按这个拆。

2.2 两套维度是”正交”的,不能一一对应

这里有个特别容易踩的坑。四层模型描述的是生命周期和作用域;而 MemOS 的 text_mempref_memskill_memtool_mem 描述的是记忆的内容形态。两套是正交的:

                    生命周期维度(四层)
                   ┌─────────────┐
  内容形态维度      │ User  Agent │
  ┌───────────┐    │             │
  │ text_mem  │    │  偏好可能同时 │
  │ pref_mem  │ ◄──┤  出现在 User  │
  │ skill_mem │    │  和 Agent 层  │
  │ tool_mem  │    │             │
  └───────────┘    └─────────────┘

不能把 pref_mem 直接等同于 User Memory。比如”用户喜欢 Java 17、用 Spring Boot 3”是 pref_mem 且属于 User;”这个 Agent 擅长处理订单超卖”是 skill_mem 但属于 Agent。分类维度不同,路由时要分开处理。

2.3 整体链路:请求前并行加载,会话后异步沉淀

一次请求的记忆路径:

AgentExecutor#execute
   │
   ├─ 并行启动 ─┬─ 短期链路:读 ConversationMemory(Redis/MySQL)
   │            └─ 长期链路:queryMemory → 按 Agent 配置选 MEMOS/Mem0/MySQL
   │
   ▼
  汇合(join) → 按 scope 过滤 + Token Budget 分配 → 写进 AgentContext
   │
   ▼
  参与模型调用
   │
   ▼
  会话结束 onSessionEndAsync → 筛选/去重/LLM 判断 → 沉淀到 User/Agent 层

得物的两个实现边界要记住:短期历史和窗口裁剪由平台的 ConversationMemory 负责;会话状态持久化由 AgentScope Harness 的 state store 负责。 长期查询用 originalMessage 作为主检索词,短期历史不会反向增强这次查询——所以”并行”带来的是执行重叠,不是”先注入短期历史再增强查询”。


三、短期记忆(Session Memory):上下文窗口工程

短期记忆保存当前会话按时间排列的对话历史,是指代消解和多轮推理的直接上下文。它的实现重点就三个字:快、稳、省(token)

3.1 读取链路:Redis 热点缓存 + MySQL 兜底

得物的 ConversationApplicationServiceImpl#getMessagesFromCache 是这么干的:

// 1. 优先读 Redis List
List<String> raw = redisUtil.range(key, 0, Long.MAX_VALUE);
if (raw != null && !raw.isEmpty()) {
    // 命中:JSON 反序列化,翻转为 oldest-first
    return raw.stream().map(json -> JSON.parseObject(json, ChatMessageDto.class))
              .collect(Collectors.toList());
}
// 2. 未命中/异常:回源 MySQL,再按倒序 leftPush 回写
List<ChatMessageDto> fromDb = messageDao.selectByConversationId(cid);
for (int i = fromDb.size() - 1; i >= 0; i--) {
    redisUtil.leftPush(key, JSON.toJSONString(fromDb.get(i)));
}
redisUtil.expire(key, CACHE_TTL_SECONDS);
// 3. 超出上限从尾部淘汰最旧
while (redisUtil.size(key) > MAX_CACHED_MESSAGES) {
    redisUtil.rightPop(key);
}
return fromDb;

Redis 存储结构的关键细节:

  • leftPush 写入,最新消息在 index 0(表头),range(0, MAX_VALUE) 全量读回应用层再裁窗;
  • rightPop 移除最旧(表尾);
  • 需要注意:ConversationMemory#get(String, int)lastN 参数当前不参与 Redis range 边界计算,实际读取范围由列表内容和后续 Token 窗口共同决定;
  • Redis 只承担热点缓存,MySQL 才是冷启动回源和持久化兜底

3.2 Token 滑动窗口:交替校验 + 摘要预算

读回来之后,不能全塞进 prompt,要做窗口裁剪。得物主路径是”从尾部往前扫 + 交替校验 + 预留摘要空间”:

trimAlternationFromEnd(chatMsgs);                                  // 保证 user/assistant 交替
int windowSize = MAX_TOKEN_WINDOW_SIZE - SUMMARY_TOKEN_BUDGET;      // 预留摘要空间(约 2000 tokens)
int totalTokens = 0, recentTokens = 0;
boolean overLimit = false;
List<Message> recentWindow = new ArrayList<>();
for (int i = chatMsgs.size() - 1; i >= 0; i--) {
    Message message = chatMsgs.get(i);
    int tokens = TikTokensUtil.tikTokensCount(message.getText());
    totalTokens += tokens;
    if (!overLimit && recentTokens + tokens <= windowSize) {
        recentWindow.add(0, message);                               // 倒序插入 = 正序输出
        recentTokens += tokens;
    } else {
        overLimit = true;                                           // 一旦超限,只统计总量不再收纳
    }
}
if (totalTokens <= MAX_TOKEN_WINDOW_SIZE) {
    removeLeadingAssistant(chatMsgs);
    return mergeSystemAndChat(systemMsgs, chatMsgs);                // 没超:全量返回
}
removeLeadingAssistant(recentWindow);
String summaryMd = iMemoryRpcService.getConversationSummaryMd(Long.parseLong(cid));
if (StringUtils.isNotBlank(summaryMd)) {
    recentWindow.add(0, new SystemMessage(summaryMd));              // 超了:用摘要顶替被裁掉的历史
}
return recentWindow;

这段代码的巧妙之处:

  1. overLimit 一旦置 true 就再也回不来——保证 recentWindow 是”从最新消息往前、连续的一段”,不会出现”中间断了、后面又接上”的乱序;
  2. 预留 SUMMARY_TOKEN_BUDGET——裁剪时给摘要留位置,而不是把窗口用到 100% 再挤摘要;
  3. 摘要触发是懒的:未被摘要覆盖的消息达到 20 条才触发摘要生成,注入预算约 2000 tokens,并在 Redis 缓存 1 小时。把高频对话写入与低频摘要生成拆开,避免每轮都调一次摘要模型。

3.3 写入双写与回退

messages.forEach(message -> {
    String conversationId0 = conversationId;
    if (conversationId0.startsWith("agent:")) {
        // 子 Agent 的非 ChatMessage 不写入主会话,避免虚拟会话污染主链路
        if (message instanceof ChatMessageDto) {
            conversationId0 = conversationId.replace("agent:", "");
        } else {
            return;
        }
    }
    ChatMessageDto chatMessage = ...;   // 类型转换、清洗文本、补租户字段
    // 先落 MySQL,Redis 只作为可失效的热点缓存
    Long messageId = TenantFunctions.callWithTenantId(chatMessage.getTenantId(),
        () -> conversationDomainService.addConversationMessage(conversationMessage));
    chatMessage.setIndex(messageId);
    try {
        String key = generateConversationKey(conversationId0);
        redisUtil.leftPush(key, JSON.toJSONString(chatMessage));
        redisUtil.expire(key, CACHE_TTL_SECONDS);
        if (redisUtil.size(key) > MAX_CACHED_MESSAGES) {   // 最新在表头,超 200 条从尾部淘汰
            redisUtil.rightPop(key);
        }
    } catch (Exception e) {
        // 缓存失败不回滚已完成的 MySQL 写入,读取时会回源数据库
        log.warn("Failed to cache message to Redis, conversationId={}", conversationId0, e);
    }
});

双写的意义:MySQL 负责持久化兜底防丢失,Redis 负责高性能读支撑实时对话;Redis 失败时仍可从 MySQL 回退。 注意 agent: 前缀的处理——子 Agent 的虚拟会话消息不能污染主会话链路。

3.4 短期记忆的边界 → 自然引出长期记忆

短期记忆再精巧,也有个硬边界:它只在”这一个会话”里有效,会话一关,Redis 里的 List 过期,就什么都没了。 而真正值钱的——”下单流程调哪些服务”“库存不足抛什么异常”“用户喜欢 Java 17”——是跨会话的。这些不能靠 Redis List,要靠向量知识库。下面四章全是它。


四、长期记忆的知识形态:代码库 + 业务知识怎么”提炼”成记忆

这是你最关心的一层。长期记忆不是”把代码文件原样塞进数据库”,而是先把原始信息提炼成”AI 以后不需要再翻原文、直接知道”的结构化文本,再走向量入库。两类知识,两种提炼方式。

4.1 代码库信息:接口签名 + 调用顺序 + 异常约定

先说结论:对”代码库”这类知识,AI 真正需要的不是源码本身,而是源码里藏在调用链和约定里的语义。我们以”一个接口”为最小记忆单元,把它抽成一段结构化文本。

假设订单中心有:

@DubboService
public class OrderFacadeImpl implements OrderFacade {
    // 下单:先风控 → 冻结库存 → 核销优惠券 → 落库 → 扣减库存
    @Override
    public CreateOrderResponse createOrder(CreateOrderRequest req) { ... }
}

提炼成一条记忆(chunk 的原始文本):

【接口】OrderFacade#createOrder
入参:CreateOrderRequest(userId, skuList, couponId, addressId)
出参:CreateOrderResponse(orderId, status)
调用顺序:RiskService.checkRisk → InventoryService.freezeStock
         → CouponService.useCoupon → OrderDAO.insert → InventoryService.deductStock
异常约定:库存不足抛 InventoryShortageException,需回滚已冻结库存

这条文本,就是后面要 embed 的一个 chunk。它的关键特征:把”调用顺序”这种原本要靠读源码才能得到的信息,显式地写成自然语言。向量检索按语义匹配,这条文本里出现”库存不足”+”回滚”,用户问”库存不足怎么办”时就能被命中。

怎么自动生成这类文本?Java 侧有几种采集手段,从易到难:

手段 采集什么 代价 适用
注解 + 反射 方法签名、参数类型、返回类型 签名和类型信息
Javadoc 解析 方法上的文档注释、@throws@param 有写注释习惯的团队
AST 解析(javaparser) 方法体里的调用顺序、字段引用 自动还原调用链
LLM 蒸馏 把 AST 结果 + 源码片段交给模型总结成”调用顺序 + 约定” 中(一次模型调用) 提炼语义、补全注释没写的约定

这里推荐”AST 出骨架 + LLM 出语义“的组合:javaparser 能精确拿到”createOrder 方法体里依次调用了 riskService.checkRiskinventoryService.freezeStock…“这个调用顺序(这是纯结构信息,LLM 反而不一定准),LLM 负责把骨架和上下文总结成可读的自然语言并判断 importance。下面是一个采集侧的示意:

// 用 javaparser 还原调用链骨架(示意,简化了类型解析)
public String extractInvocationChain(String source) {
    CompilationUnit cu = StaticJavaParser.parse(source);
    StringBuilder sb = new StringBuilder();
    cu.findAll(MethodCallExpr.class).forEach(call -> {
        // 只保留本地 service/DAO 调用,过滤 JDK/工具类噪音
        if (isBusinessCall(call)) {
            sb.append(call.getScope().map(s -> s + ".").orElse(""))
              .append(call.getNameAsString()).append(" -> ");
        }
    });
    return sb.toString();
}

要点:采集侧只负责”忠实还原结构”,语义总结交给 LLM,避免让代码解析器去理解业务含义。

4.2 业务知识:业务规则、约束、稳定事实

业务知识更”软”:一条退款规则、一个库存扣减约定、一个用户偏好。它们散落在对话、工单、文档里,没法用 AST 采。得物的做法是会话结束后用 LLM 做”记忆判断”(蒸馏),从对话里挑出值得沉淀的新信息。

得物 judgeByLLM 的核心是:把完整对话喂给 LLM,用 [[NEW]] 标记本轮新增消息,让模型只看新增部分提取新信息。

private MemoryJudgeResult judgeByLLM(Long tenantId, Long modelId,
        List<MemoryMessage> fullContext, List<MemoryMessage> newMessages) {
    Set<String> newMessageKeys = newMessages.stream()
        .map(m -> (m.getRole() != null ? m.getRole() : "user")
            + ":" + (m.getContent() != null ? m.getContent() : ""))
        .collect(Collectors.toSet());

    StringBuilder content = new StringBuilder("## 完整对话上下文\n\n");
    for (MemoryMessage message : fullContext) {
        String role = message.getRole() != null ? message.getRole() : "user";
        String text = message.getContent() != null ? message.getContent() : "";
        content.append(newMessageKeys.contains(role + ":" + text)
                ? "[[NEW] " : "[")
            .append(role).append("]: ").append(text).append("\n");
    }

    // 调用 LLM,约束输出 JSON:{ category, scope, importance, content }
    MemoryJudgeResult result = iModelRpcService.call(
        tenantId, modelId, JUDGE_SYSTEM_PROMPT, content.toString(),
        new ParameterizedTypeReference<MemoryJudgeResult>() {});
    return result == null ? null : validateAndFixResult(result);
}

判断要点(对应得物原文):

  1. [[NEW]] 标记:按 role:content 生成消息 key 标记重点内容,让 LLM 借助完整上下文、只对新增部分提取新信息;
  2. importance 1–10:按五类信息定义重要度区间(用户明确偏好 > 稳定事实 > 一次性任务细节);
  3. scope 归属:明确这条该进 user_profile 还是 agent_{agentId}
  4. 降级:LLM 调用异常或返回空时,降级到”仅基于新增消息的规则判断”;返回非空时统一补缺省字段、过滤空内容、单条截断到 200 字符。

4.3 元数据设计:向量不是”裸文本”,要带着身份

一条记忆入库时,向量只是”用于检索的那部分”,真正决定它”该不该被搜到、搜到了该不该信”的是元数据。至少这几项:

Map<String, Object> metadata = Map.of(
    "tenantId",   tenantId,            // 多租户隔离
    "scope",      "agent_order_agent", // 对应 cube:user_profile 或 agent_{id}
    "type",       "tool_mem",          // text_mem / pref_mem / skill_mem / tool_mem
    "name",       "OrderFacade#createOrder",
    "importance", 8,
    "version",    "v3",                // 版本,旧版本要能降权/失效
    "source",     "codebase",          // 来源:codebase / conversation / doc / manual
    "updatedAt",  System.currentTimeMillis()
);

sourceversion 尤其重要——它们是后面”投毒防御”和”时效治理”(旧规则别覆盖新规则)的抓手,上一篇《Agent 记忆》已经展开过,这里不再重复。


五、写入向量库:端到端 Java 实现

现在到了”怎么写进向量数据库”。这是 RAG 写入侧的完整三步:切分 → 嵌入 → 入库,外加去重与冲突处理

5.1 切分(Chunking):以”接口/规则”为自然边界

切分的目标是让每个 chunk 语义完整、大小适中。对代码库记忆,天然边界就是”一个接口”或”一条规则”,不要按固定字符数硬切——硬切会劈开”调用顺序”这种连续语义。

// 以接口为单位的 chunk(简化)
public List<Chunk> chunkCodeMemory(List<InterfaceMemory> interfaces) {
    return interfaces.stream().map(itf ->
        new Chunk(
            String.format("【接口】%s#%s\n调用顺序:%s\n异常约定:%s",
                itf.clazz(), itf.method(), itf.invocationChain(), itf.exceptionContract()),
            Map.of("type", "tool_mem", "name", itf.clazz() + "#" + itf.method())
        )
    ).collect(Collectors.toList());
}

如果某条记忆太长(比如一个超大接口的完整文档),再按语义子块切,但保持”调用顺序”“入参出参”这类字段完整不拆散。

5.2 嵌入(Embedding):文本 → float[] 向量

嵌入是把文本压成一个固定维度的浮点向量,让”语义相近的文本在向量空间里距离近”。Java 侧通常是一个 OpenAI 兼容的 HTTP 调用:

public class OpenAiEmbeddingClient implements EmbeddingClient {
    private final String apiKey;
    private final RestClient rest = RestClient.create();

    @Override
    public float[] embed(String text) {
        // POST /v1/embeddings  { model: "text-embedding-3-small", input: text }
        EmbeddingResponse resp = rest.post()
            .uri("https://api.openai.com/v1/embeddings")
            .header("Authorization", "Bearer " + apiKey)
            .body(Map.of("model", "text-embedding-3-small", "input", text))
            .retrieve()
            .body(EmbeddingResponse.class);
        return resp.data().get(0).embedding();   // 例如 float[1536]
    }
}

不同模型的维度不同,选型时记两个数:维度(决定内存和检索成本)和归一化与否(决定相似度度量)。常见:

嵌入模型 维度 说明
OpenAI text-embedding-3-small 1536 可传 dimensions 降维
OpenAI text-embedding-3-large 3072 质量更高
BGE-m3 / bge-large-zh 768 / 1024 开源、中文好
Qwen / 通义 embedding 1024~2048 国内可用

生产上,嵌入是一个外部 HTTP 调用,必须像对待任何下游一样处理:超时、重试、熔断、批量(一次 embed 多条省请求数)。别忘了它是”又慢又贵”的下游之一。

5.3 入库(Upsert):向量 + 元数据写进集合

public void persistMemory(Chunk chunk, String scope) {
    float[] vector = embeddingClient.embed(chunk.text());
    vectorDatabase.upsert(
        scope,                     // 集合名 = cube:user_profile 或 agent_{agentId}
        vector,
        chunk.metadata()
    );
}

对应得物的 MemOS:readableCubeIds 就是这里的集合列表,writableCubeIds 是写入侧。得物按 memCubeId 分组组装 AddMemoryRequest,写入 userIdwritableCubeIdsmessages,以 async、fine 模式调用 addMemory

5.4 索引构建:向量库内部到底存了什么、为什么快

这里回答”向量数据库凭什么快”。向量入库后,库内会建索引。先理解精确检索为什么不行:

  • 精确 KNN(最近邻)是暴力全扫:拿 query 向量和库里 N 条向量逐个算距离,复杂度 O(N·D)。N 到了百万级、D 到了千维,一次查询几十亿次浮点运算,撑不住。
  • 于是引入 ANN(Approximate Nearest Neighbor,近似最近邻):用一点点召回率的损失,换数量级的提速。主流两种索引:

HNSW(分层可导航小世界图)

层 2:  ○───○          (稀疏、长跳,负责快速逼近)
层 1:  ○─○─○─○        (中等)
层 0:  ○─○─○─○─○─○    (稠密,负责精确局部搜索)

查询:从顶层入口贪心往下钻,每层走"离 query 最近的邻居",落到底层精确扫

特点:检索快、召回高,但建索引慢、内存大(要存图的所有边),且删改麻烦(图结构要维护)。

IVF(-PQ)(倒排文件 + 乘积量化)

建索引:k-means 把 N 条向量聚成 C 个簇(质心)
查询:  query 先找最近的 nprobe 个簇,只在这几个簇里精确扫
IVF-PQ:再对残差做乘积量化压缩,内存骤降,召回略降

特点:建索引快、省内存,召回取决于 nprobe(探针数越多越准越慢)。

选型一句话:追求检索质量选 HNSW,追求内存和写入吞吐选 IVF-PQ。 pgvector 里 hnswivfflat 就是这两种,Faiss/Milvus/Qdrant 里同款。

得物 mode=fast 大概率就是这类”速度/召回权衡”的一个档位(类似”快速模式”用更少的探针或更低的召回换延迟)。具体语义以 MemOS 文档为准,但工程含义一致:向量检索的每个参数都在”快”和”准”之间拨一个开关。

5.5 去重与冲突处理:别让记忆库越写越臃肿

写入前先本地去重,得物 calculateSimilarity 按四道关依次判断:

// exact → contains → 字符级 Jaccard(0.7) → 短文本 Levenshtein(0.8)
public String calculateSimilarity(String a, String b) {
    if (a.equals(b)) return "exact";                      // 完全相同
    if (a.contains(b) || b.contains(a)) return "contains"; // 包含关系
    double jaccard = jaccard(a, b);                        // 字符级 Jaccard 相似度
    if (jaccard >= 0.7) return "similar";
    if (a.length() < 50 && b.length() < 50) {
        double lev = levenshteinRatio(a, b);               // 短文本用编辑距离
        if (lev >= 0.8) return "similar";
    }
    return "none";
}

相似即标记重复,跳过写入。这只是同批次的本地预去重,跨历史记忆的冲突交给冲突服务:batchDetectConflict 逐条检测,然后决定”写新 / 跳过 / 标记旧记忆待失效”。

写入时采用先写后删

// 先写新记忆,只有返回结果确认新增成功,才删除冲突旧记忆
AddMemoryResult added = memosClient.addMemory(request);  // async, fine 模式
if (added.hasNewMemories()) {
    if (!memoryIdsToDelete.isEmpty()) {
        try {
            memosClient.deleteMemories(memoryIdsToDelete);
        } catch (Exception e) {
            log.warn("delete conflict memories failed, keep new ones", e); // best-effort
        }
    }
}

这是”写入优先”的风险控制,不是事务级一致性:MemOS 是外部 HTTP 服务,本地事务包不住”新增 + 删除”两个动作,删除失败只记 warning,旧记忆暂时保留,等后续检索或下一次冲突处理再清理。


六、检索出来:何时用、怎么语义命中

6.1 何时触发检索:请求开始时的并行加载

长期记忆在每次请求开始时、和短期记忆并行加载。得物在 AgentExecutor#execute 里用 CompletableFuture

final int finalContextRounds = contextRounds;
CompletableFuture<List<Message>> contextMessagesFuture = CompletableFuture.supplyAsync(() -> {
    if (finalContextRounds <= 0) {                       // contextRounds=0 合法,跳过短期
        return new ArrayList<Message>();
    }
    return new ArrayList<>(
        chatMemory.get(agentContext.getConversationId(), finalContextRounds * 3));
}, memoryLoadExecutor);

CompletableFuture<Map<String, String>> longMemoryFuture = CompletableFuture.supplyAsync(() -> {
    if (agentContext.getAgentConfig().getOpenLongMemory() != AgentConfig.OpenStatus.Open) {
        return Collections.emptyMap();                    // 未开启长期记忆:直接返回空
    }
    try {
        AgentComponentConfigDto modelConfig =
            agentContext.getAgentConfig().getModelComponentConfig();
        if (modelConfig == null || modelConfig.getTargetId() == null) {
            return Collections.emptyMap();                // 未绑定模型:跳过长期检索
        }
        boolean justKeywordMatch = resolveJustKeywordMatch(agentContext);
        return conversationApplicationService.queryMemory(
            agentContext.getUser().getTenantId(), agentContext.getUser().getId(),
            agentContext.getAgentConfig().getId(), modelConfig.getTargetId(),
            agentContext.getOriginalMessage(), "", justKeywordMatch,
            agentContext.isFilterSensitive());
    } catch (Exception e) {
        log.warn("查询长期记忆失败", e);                 // 长期记忆是增强能力,失败不阻塞主对话
        return Collections.emptyMap();
    }
}, memoryLoadExecutor);

// 汇合点:主流程等待两条链路都完成
agentContext.setContextMessages(contextMessagesFuture.join());
Map<String, String> longMemoryMap = longMemoryFuture.join();

三个工程要点:

  1. 专用线程池 memoryLoadExecutor,避免争抢 ForkJoinPool.commonPool
  2. 长期记忆是增强能力,不阻塞主链路——未开启、未绑定模型、查询异常,统统返回空 Map,主对话继续;
  3. 长期查询以 originalMessage 为主检索词,context 传空——短期历史不反向增强本次 query,所以”并行”是执行重叠,不是”先注入短期再增强查询”。

6.2 query embedding → ANN → 过滤 → 去重 → 截断

“检索”这一步,站在 Java 侧看,就是把用户问题压成向量,去向量库里找近邻:

public List<MemoryUnit> queryMemory(String userMessage, String context) {
    String query = buildSearchQuery(userMessage, context);   // context 非空时只取前 200 字符拼接
    float[] qv = embeddingClient.embed(query);               // ① query embedding

    List<Hit> hits = vectorDatabase.search(
        List.of("user_profile", "agent_" + agentId),         // ② 一次检索同时覆盖两个 cube
        qv,
        DEFAULT_TOP_K,                                        // ③ topK
        0.45);                                                // ④ 阈值:低于此分不返回

    // ⑤ 转换阶段:过滤低分、截断单条、按 score 降序
    List<MemoryUnit> result = hits.stream()
        .filter(h -> h.score() >= 0.3)                        // 低分过滤
        .map(h -> new MemoryUnit(h.content().substring(0, Math.min(1000, h.content().length()))))
        .collect(Collectors.toList());
    result.sort(Comparator.comparingDouble(MemoryUnit::score).reversed());
    return result;
}

得物 Search 请求的参数,逐个映射到理论:

参数 得物取值 工程含义
readableCubeIds user_profile + agent_{agentId} 一次检索同时覆盖用户画像和当前 Agent 记忆
query userMessage(context 非空时拼前 200 字符) 检索词
topK DEFAULT_TOP_K 取前 K 条候选
mode fast 速度/召回权衡档位
relativity 0.45 相关度阈值
dedup mmr 用 MMR 去冗余
includePreference / prefTopK true / 6 额外补用户偏好 top6
后处理 score<0.3 过滤、单条 1000 字符截断 防低相关/过长挤占上下文

MMR(Maximal Marginal Relevance) 是这里最值得讲的一个参数:它不只挑”最相关”,还惩罚”和已选结果重复”的:

MMR = λ·sim(query, doc) − (1−λ)·max(sim(doc, 已选结果))

好处:top-K 里不会全是同一条接口的重复描述,而是覆盖多个不同方面。这是”去重”发生在检索侧的体现。

6.3 向量检索 vs 关键词检索:为什么”库存不足”能命中”createOrder”

这是向量知识库和传统搜索(ES 的 match、数据库的 LIKE、BM25 词法检索)最本质的区别。回到开头的例子:

  • 用户问:”库存不足会抛什么异常?”
  • 库里那条记忆写的是”OrderFacade#createOrder … 库存不足抛 InventoryShortageException“。

关键词检索(BM25):把 query 分词成 [库存, 不足, 会抛, 什么, 异常],去倒排索引里找同时含这些词的文档。它确实能命中”库存不足”和”异常”——但换个问法”下单超卖怎么兜底”,词全变了,就漏了。

向量检索(dense):把整句话压成一个向量,和库里的向量算余弦相似度。”下单超卖怎么兜底”和”库存不足抛 InventoryShortageException 需回滚”语义相近,向量距离近,就能命中——即使一个词都没对上

两者的关系不是取代,而是互补:

维度 词法检索(BM25/ES) 向量检索(dense)
匹配依据 词面(term 命中) 语义(向量距离)
强项 精确词、专有名词、编号 同义改写、跨语言、模糊意图
弱项 同义/改写会漏 专有名词/精确 ID 会漂
典型失败 “超卖”搜不到”库存不足” “订单号 20260901001” 搜不准

6.4 混合检索 + Rerank:什么时候需要

生产级记忆知识库,很少只靠纯向量。两个补强手段:

  1. 混合检索(Hybrid):同一 query 同时跑 BM25(稀疏)和向量(稠密)两路,结果用 RRF(Reciprocal Rank Fusion) 融合:
List<Hit> sparse = esClient.bm25(query, topK);       // 词法路
List<Hit> dense  = vectorDB.search(queryVec, topK);  // 向量路
List<Hit> merged = rrfFusion(sparse, dense);          // 倒数排名融合

适用:既需要”接口名/异常类名”这种精确词命中,又需要”同义改写”这种语义命中的场景——代码库记忆恰好两者都要。

  1. 重排(Rerank):先用向量检索粗召回 top-100,再用一个 cross-encoder 精排:
List<Hit> candidates = vectorDB.search(queryVec, 100);   // 粗排:快,召回高
List<Hit> reranked = reranker.rerank(query, candidates);  // 精排:慢,准
return reranked.subList(0, 5);

cross-encoder 把 [query, doc] 拼在一起让模型打分,比向量余弦准得多,但慢得多,所以只用在 top-N 候选上。

记忆知识库的落点:规模小、条目短、语义明确的记忆(个人偏好、接口约定),纯向量 + MMR 通常够了;规模大、条目混杂、有精确 ID 和专有名词的(企业级代码库 + 业务规则),上混合检索 + rerank 才有明显收益。

6.5 向量数据库内核再补三刀

前面 5.4 讲了索引,这里补三个检索侧经常被问的:

① 相似度度量怎么选? 三种主流:

余弦相似度: cos(a,b) = (a·b) / (|a||b|)     —— 只关心方向,不关心长度
点积(内积): a·b                            —— 方向 + 长度都有意义
欧氏距离:   ‖a−b‖² = 2 − 2·cos(a,b)         —— 归一化后与余弦等价

如果向量已归一化,余弦、点积、欧氏距离三者等价(差一个常数/单调变换)。所以工程上常先把向量归一化,再任选一种实现即可。选内积通常计算最快。

② 为什么”阈值 0.45”和”score 0.3”要分开? 得物有两个数:检索请求的 relativity=0.45(库内初步过滤)和结果后处理的 score<0.3(应用层再过滤)。两层过滤的意图是:库内先砍掉明显不相关的,省传输和计算;应用层再按更严格的阈值兜底,防止”库内阈值和业务阈值不一致”带来的漏网。score 的绝对数值取决于归一化方式和 embedding 模型,不能跨模型直接比大小——这是坑。

③ 为什么”改了记忆”或”删了记忆”很麻烦? 向量库的删除/更新比写入贵得多(尤其 HNSW 图要维护结构)。这就是第 5.5 节”先写后删、删除 best-effort”的深层原因——向量库天然”写易删难”,所以企业级方案宁可容忍旧记忆暂时残留,也不要为了强一致牺牲写入可用性。


七、Token Budget:检索结果如何”挤进”上下文

检索回来了,不能全塞进 prompt——记忆会撑爆上下文。得物按 scope 分组后,用 Token Budget 分配:

int budget = DEFAULT_LONG_MEMORY_TOKEN_BUDGET;              // 4000 tokens
int userProfileRawTokens =
    TikTokensUtil.tikTokensCount(userProfileRaw != null ? userProfileRaw : "");

if (userProfileRawTokens <= budget * 0.6) {                 // user_profile 最多占 60%
    userProfile = userProfileRaw;                           // 实际没占满,剩余让给 Agent 记忆
    agentMemory = truncateLongMemory(agentMemoryRaw, budget - userProfileRawTokens);
} else {
    userProfile = truncateLongMemory(userProfileRaw, (int) (budget * 0.6));
    int userProfileTokens =                                 // 重新计算截断后 token,别把估算带进来
        TikTokensUtil.tikTokensCount(userProfile != null ? userProfile : "");
    agentMemory = truncateLongMemory(agentMemoryRaw, budget - userProfileTokens);
}

截断算法按行累加,保留完整语义:

String[] lines = longMemory.split("\n", -1);
for (String line : lines) {
    int lineTokens = TikTokensUtil.tikTokensCount(line + "\n");
    if (currentTokens + lineTokens > budget) {
        break;                                              // 达到预算停止,不拆散单条
    }
    truncated.append(line);
    currentTokens += lineTokens;
}

设计意图很清楚:用户画像优先(它跨 Agent、最稳定、价值最高),最多占 60%;剩余预算给 Agent 专属记忆;两类都按行截断,避免把”一条记忆”从中间劈开。 这对应得物原文”优先满足用户画像,再把剩余预算分配给 Agent 专属记忆”。


八、工程权衡与运行观测

把前面所有章节收拢,这套记忆系统真正的价值在四点权衡:

权衡 得物做法 你该记住的原则
并行加载 短期+长期提交同一专用线程池,join 处汇合 长期是增强能力,永不阻塞主链路
可靠写入 Redis 锁 + MD5(role:content) hash 幂等 幂等做在写入前,挡住重复结束事件
预算控制 短期滑动窗口 + 长期 scope 分组固定 tokens 记忆是”被裁剪”的,不是”全量带上”的
一致性取舍 先写后删、删除 best-effort 外部服务无法本地事务包住,容忍旧记忆残留

几个要格外警惕的边界:

  1. hash 幂等是 best-effort:得物 onSessionEndAsync 用 10 分钟会话锁 + 7 天 TTL 的 hash 去重;但 Redis 读取失败时会把整段会话当新增继续处理,hash 写入失败只记日志。外部服务失败后没有自动回放,运行侧要同时关注”重复写入”和”处理未完成”两类异常。
  2. MemOS 查询异常不自动切 MySQL:得物明确——MemOS 查询异常由 Provider 自身记日志返回空,不自动降级切换。这意味着长期记忆的降级是”空结果”而非”换源”,你的主链路要能接受”长期记忆为空”这个状态。
  3. 可观测:得物提供运行概览、记忆类型分布、时间趋势、Top Agents、Cube 明细,以及长期记忆搜索趋势。没有这些,记忆库就是个黑盒——你既不知道它在写什么,也不知道它在检索什么。

九、总结与后续

把整篇收成一段话:

短期记忆管”这一轮”,长期记忆(向量知识库)管”以后每一轮”。 短期靠 Redis 热点缓存 + MySQL 兜底 + Token 滑动窗口;长期靠 RAG——把代码库接口(签名 + 调用顺序 + 异常约定)和业务规则提炼成结构化文本,切分、embedding、写进向量库,请求开始时按语义检索回来,再用 Token Budget 挤进上下文。两条链路在会话结束处用异步任务衔接:筛选、LLM 判断、去重、先写后删、沉淀。

回到你最初那个”没感觉”的问题,现在应该清晰了:

  • 代码库怎么进向量库:接口信息(签名/调用顺序/异常约定)用 AST 还原骨架 + LLM 总结语义,切成以”接口”为单位的 chunk,embed 成向量,带元数据 upsert 进 agent_{id} 集合;
  • 业务知识怎么进向量库:会话结束后 LLM 用 [[NEW]] 标记蒸馏出偏好/规则,定 importance 和 scope,去重后同样走向量入库;
  • 何时用:每次请求开始时和短期记忆并行加载,用 originalMessage 做主检索词;
  • 怎么检索到:query embedding → 向量库 ANN(HNSW/IVF)近邻 → 阈值过滤 → MMR 去重 → 截断,必要时混合检索 + rerank 补精确词短板。

后续工作,得物自己列的清单也值得你抄:生产流量下的延迟与失败率观测(记忆加载/检索的 P99 到底多少)、外部记忆服务失败后的补偿机制(现在是 best-effort,缺自动回放)、再加上上一篇文章反复强调的投毒防御(来源分级、时效治理、权限前置)和评测(检索召回/准确率的持续回归)。

如果你刚接触这个系列,建议按这个顺序读:先《大模型无状态:Cursor 多轮对话底层原理与上下文窗口》搞清”模型本身不记得任何事”,再看《Agent 记忆:从会话记忆到长期记忆的架构与投毒防御》搞清”记忆分几层、怎么防投毒”,最后回到这篇——每一层到底写什么代码、向量库内部怎么跑

下次你惊叹”它居然还记得 createOrder 的调用顺序”时,心里应该已经有谱了:那不是模型记住了,是一个向量知识库在正确的时候、把正确的那条记忆检索了回来。


本文以得物技术《企业级 MultiAgent 的记忆系统:短期上下文与四层记忆架构实现》为骨架整理,重点扩写了其中 RAG + 向量检索/向量数据库的实现细节;代码为示意性伪实现,用于讲清机制,非可直接运行的得物源码。

本文阅读量