企业级记忆知识库:短期上下文与四层记忆的 RAG/向量检索实现
你是一个 Java 后端工程师,手上有一个订单中心。某天你让 Agent “帮我在下单流程里加一个库存不足的兜底”。
它没有翻代码,也没有问你 createOrder 里调了哪几个服务,而是直接告诉你:
下单流程依次是
RiskService.checkRisk→InventoryService.freezeStock→CouponService.useCoupon→OrderDAO.insert→InventoryService.deductStock。库存不足会抛InventoryShortageException,冻结的库存要回滚。建议在freezeStock之后加 try-catch 兜底。
你愣了一下:它既没去读你的 Git 仓库,也没问你接口签名,这些”调用顺序”和”异常约定”它是怎么”记得”的?
答案不在模型里——模型是无状态的,上一篇《大模型无状态》已经讲过。答案在一套企业级记忆知识库里:这些接口信息、业务规则,早就被切成片段、压成向量、写进了一个向量数据库,Agent 只是在你发问的那一刻,做了一次语义检索把它捞了回来。
这篇文章要讲的就是这件事:一套企业级记忆知识库到底怎么实现——短期上下文怎么做、四层记忆怎么分层、代码库信息和业务知识怎么变成长期记忆、怎么写进向量数据库、又怎么在恰当的时机被检索回来。 它以得物企业级 MultiAgent 记忆系统(短期上下文 + 四层记忆)为骨架,重点把其中”RAG + 向量检索/向量数据库”这一段挖到代码级。
如果你读过《Agent 记忆:从会话记忆到长期记忆的架构与投毒防御》,这篇是它的工程化续篇:上一篇讲”记忆分哪几层、为什么会被投毒”,这一篇讲”每一层到底用什么数据结构、写什么代码、向量库内部怎么跑”。
先立住贯穿全文的一句话:
短期记忆管”这一轮”(把会话历史带进下一次调用),长期记忆管”以后每一轮”(把一个向量知识库按语义检索回来)。前者靠 Redis/MySQL 和 Token 窗口,后者靠 RAG——切分、嵌入、建索引、检索、注入。
一、为什么 Agent 记忆必须做成”知识库”,而不是”把历史全拼进 Prompt”
1.1 无状态模型 × 有状态业务
模型的输入输出都是 token。你在多轮对话里感觉到的”连续”,是客户端每轮把整份 messages 数组原样重发,上一篇已经拆过。这个机制的三个硬伤:
- 窗口有限:注意力是 O(n²) 的,KV Cache 随长度线性涨,128K 已接近极限。把几个月的历史全拼进去,先撞墙的是窗口,不是业务。
- 又贵又慢:每轮重发 10 万 token 历史,你付的是 10 万 token 的钱,等的是 10 万 token 的延迟,而真正有用的可能只有最后 2000 token。
- 拼不进去的才是大头:代码库里几千个接口、几百条业务规则、用户跨会话的偏好,这些根本不可能也不应该塞进 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_mem、pref_mem、skill_mem、tool_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;
这段代码的巧妙之处:
overLimit一旦置 true 就再也回不来——保证recentWindow是”从最新消息往前、连续的一段”,不会出现”中间断了、后面又接上”的乱序;- 预留
SUMMARY_TOKEN_BUDGET——裁剪时给摘要留位置,而不是把窗口用到 100% 再挤摘要; - 摘要触发是懒的:未被摘要覆盖的消息达到 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.checkRisk、inventoryService.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);
}
判断要点(对应得物原文):
[[NEW]]标记:按role:content生成消息 key 标记重点内容,让 LLM 借助完整上下文、只对新增部分提取新信息;- importance 1–10:按五类信息定义重要度区间(用户明确偏好 > 稳定事实 > 一次性任务细节);
- scope 归属:明确这条该进
user_profile还是agent_{agentId}; - 降级: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()
);
source 和 version 尤其重要——它们是后面”投毒防御”和”时效治理”(旧规则别覆盖新规则)的抓手,上一篇《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,写入 userId、writableCubeIds 和 messages,以 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 里 hnsw 和 ivfflat 就是这两种,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();
三个工程要点:
- 专用线程池
memoryLoadExecutor,避免争抢ForkJoinPool.commonPool; - 长期记忆是增强能力,不阻塞主链路——未开启、未绑定模型、查询异常,统统返回空 Map,主对话继续;
- 长期查询以
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:什么时候需要
生产级记忆知识库,很少只靠纯向量。两个补强手段:
- 混合检索(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); // 倒数排名融合
适用:既需要”接口名/异常类名”这种精确词命中,又需要”同义改写”这种语义命中的场景——代码库记忆恰好两者都要。
- 重排(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 | 外部服务无法本地事务包住,容忍旧记忆残留 |
几个要格外警惕的边界:
- hash 幂等是 best-effort:得物 onSessionEndAsync 用 10 分钟会话锁 + 7 天 TTL 的 hash 去重;但 Redis 读取失败时会把整段会话当新增继续处理,hash 写入失败只记日志。外部服务失败后没有自动回放,运行侧要同时关注”重复写入”和”处理未完成”两类异常。
- MemOS 查询异常不自动切 MySQL:得物明确——MemOS 查询异常由 Provider 自身记日志返回空,不自动降级切换。这意味着长期记忆的降级是”空结果”而非”换源”,你的主链路要能接受”长期记忆为空”这个状态。
- 可观测:得物提供运行概览、记忆类型分布、时间趋势、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 + 向量检索/向量数据库的实现细节;代码为示意性伪实现,用于讲清机制,非可直接运行的得物源码。