《Agentx专栏》06-记忆系统:用Redis+Milvus给AI配上短期+长期双层记忆
本文介绍了AgentX技术专栏中的双层记忆架构设计,通过Redis实现短期记忆存储和Milvus实现长期记忆存储,解决LLM无状态问题。Redis存储最近20轮对话,24小时TTL自动过期,保证多实例一致性;Milvus存储语义化知识片段,支持RAG检索。文章详细解析了RedisMemoryStore的实现、Fallback机制保障高可用,以及如何与LangChain4j集成。同时总结了序列化、m
记忆系统:用 Redis + Milvus 给 AI 配上"短期 + 长期"双层记忆|AgentX 专栏⑥
本文是 AgentX 技术专栏第六篇。基于真实项目源码(
RedisMemoryStore/FallbackChatMemoryStore/RedisConfig/LangChainConfig),完整拆解 AgentX 的双层记忆架构——Redis 存最近 20 轮对话、Milvus 存语义化长期知识、Fallback 保证 Redis 挂了也不中断服务,每一层背后都有清晰的边界与代价。
本文速览:
- 为什么 LLM 天生是"金鱼脑"?记忆系统在 Agent 架构里到底解决什么问题?
- AgentX 的双层记忆:Redis 短期记忆 + Milvus 长期记忆,职责怎么分?
- 为什么短期记忆用 Redis,而不是进程内存或数据库?
RedisMemoryStore完整源码:24 小时 TTL、agentx:memory:前缀、ChatMessageSerializer 序列化FallbackChatMemoryStore:Redis 挂了切本地内存,15 秒冷却窗口,自动探活恢复RedisConfig的两个深坑:Jackson 类型擦除、activateDefaultTyping是怎么救命的ChatMemoryProvider:把记忆接进 LangChain4jAiService,一行代码搞定- 三个实战大坑:序列化丢类型、memoryId 设计、fallback 抖动导致数据撕裂
一、AI 的"金鱼脑"问题
LLM 本身是无状态的(stateless)——每次调用 chat(),模型并不知道你上一轮说了什么。如果你不主动把历史对话塞进 Prompt,AI 永远只能回答"当前这一句"。
举个例子:
用户:我叫张三。
AI :你好张三,有什么可以帮您的?
用户:我叫什么?
AI :抱歉,我没有这方面的信息。
这就是"金鱼脑"。要让 AI 记住"张三",必须把第一轮的对话作为上下文重新发给模型。这件事看似简单,工程上的问题却很多:
| 问题 | 表现 | 解决方向 |
|---|---|---|
| 历史无处可存 | 进程一重启,记忆全丢 | 外部持久化 |
| 多实例不一致 | 用户在 A 节点聊的,B 节点不知道 | 集中式存储 |
| Prompt 越变越长 | 历史越多,token 成本越高,响应越慢 | 滑动窗口 |
| 长期信息无法语义检索 | "用户之前提过的房贷利率"找不回 | 向量化长期记忆 |
AgentX 的记忆系统就是为了系统性地解决这四个问题,最终架构是 Redis 短期记忆 + Milvus 长期记忆 的双层设计。
二、双层记忆架构总览
┌──────────────────────────────────────────────────────────────────────┐
│ AgentX 记忆系统 │
│ │
│ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │
│ │ 短期记忆(Redis) │ │ 长期记忆(Milvus) │ │
│ │ │ │ │ │
│ │ • 最近 20 轮对话 │ │ • 历史知识、文档片段 │ │
│ │ • 24h TTL 自动过期 │ │ • bge-m3 向量化 │ │
│ │ • Key: agentx:memory:{id} │ │ • Collection: agentx_knowledge │ │
│ │ • 直接拼到 Prompt 上下文 │ │ • RAG 检索后注入 Prompt │ │
│ │ │ │ │ │
│ │ 实现:RedisMemoryStore │ │ 实现:MilvusV2EmbeddingStore │ │
│ │ + FallbackChatStore │ │ (详见专栏⑤) │ │
│ └─────────────────────────────┘ └─────────────────────────────┘ │
│ │ │ │
│ └───────────┬──────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ LangChain4j AiService │ │
│ │ ChatMemoryProvider │ ← 注入对话历史 │
│ │ + RetrievalAugmentor │ ← 注入语义检索结果 │
│ └──────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Ollama (qwen2.5)│ → 连贯、有上下文的回答 │
│ └─────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
两层的职责切割:
| 维度 | 短期记忆(Redis) | 长期记忆(Milvus) |
|---|---|---|
| 存储内容 | 原始对话消息(System/User/Ai/Tool) | 向量化的文档/知识片段 |
| 检索方式 | 按 memoryId 精确取整段历史 |
按语义相似度近邻搜索 |
| 写入时机 | 每轮对话结束自动 update | 文档导入时一次性入库 |
| 生命周期 | 24 小时 TTL | 长期保留 |
| 典型用途 | 多轮对话上下文 | RAG 知识问答 |
⑤ 篇已经把长期记忆(Milvus)讲透了。本文聚焦短期记忆——为什么用 Redis,以及怎么让它不挂、不丢、不乱。
三、为什么短期记忆用 Redis
业界其实有几种短期记忆的实现路径:
| 方案 | 优势 | 致命缺陷 |
|---|---|---|
进程内 ConcurrentHashMap |
零延迟、零依赖 | ❌ 重启即丢;多实例数据不一致 |
| MySQL / PostgreSQL | 持久化好 | ❌ 读写延迟太高,对话级 QPS 撑不住 |
| Redis | 亚毫秒读写 · 集中式 · TTL 原生支持 | ⚠️ 需要额外部署 |
| MongoDB | Schema 灵活 | ❌ 对短文本会话来说大材小用 |
AgentX 的选择是 Redis,理由很简单:
- 多实例一致 —— 应用横向扩展时,所有节点共享同一份会话记忆
- 重启不丢 —— Redis 自带 RDB/AOF 持久化
- TTL 原生 —— 一行配置自动过期 24 小时未活跃的会话
- 延迟可控 —— 单次
GET/SET通常 < 1ms,对对话级别完全够用
但 Redis 也有它的脆弱性:网络抖动、节点宕机、密码改了忘同步……这些都会导致整个 AI 对话流程崩溃。AgentX 的对应方案是 FallbackChatMemoryStore —— Redis 挂了自动切本地内存,让 AI 继续聊,等 Redis 恢复后自动切回去。
四、RedisMemoryStore — 短期记忆的核心存储
LangChain4j 已经定义好了 ChatMemoryStore 接口(三个核心方法:getMessages / updateMessages / deleteMessages),AgentX 要做的只是基于 Redis 实现它:
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisMemoryStore implements ChatMemoryStore {
private final StringRedisTemplate redisTemplate;
/** Key 前缀,防止与其他业务数据混用 */
private static final String PREFIX = "agentx:memory:";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Override
public List<ChatMessage> getMessages(Object memoryId) {
String json = redisTemplate.opsForValue().get(PREFIX + memoryId);
if (json == null || json.isBlank()) {
return List.of();
}
try {
// 1. JSON 字符串数组反序列化
List<String> jsonMessages = OBJECT_MAPPER.readValue(json, new TypeReference<>() {});
// 2. 每条消息再用 LangChain4j 官方反序列化器还原 ChatMessage
return jsonMessages.stream()
.map(ChatMessageDeserializer::messageFromJson)
.collect(Collectors.toList());
} catch (Exception e) {
log.error("[Redis] 反序列化对话历史失败, key={}: {}", PREFIX + memoryId, e.getMessage());
return List.of(); // 反序列化失败时降级返回空,不让单条坏数据炸掉整个会话
}
}
@Override
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
String key = PREFIX + memoryId;
List<String> jsonMessages = messages.stream()
.map(ChatMessageSerializer::messageToJson)
.collect(Collectors.toList());
if (!jsonMessages.isEmpty()) {
try {
// 24h TTL:超过 1 天没活跃的会话自动清理
redisTemplate.opsForValue().set(
key,
OBJECT_MAPPER.writeValueAsString(jsonMessages),
Duration.ofHours(24)
);
} catch (Exception e) {
log.error("[Redis] 序列化对话历史失败, key={}: {}", key, e.getMessage());
}
} else {
// 消息列表为空时直接删除 key,不留垃圾数据
redisTemplate.delete(key);
}
}
@Override
public void deleteMessages(Object memoryId) {
redisTemplate.delete(PREFIX + memoryId);
}
}
为什么消息要做"双层序列化"?
外层用 Jackson 把 List<String> 序列化成一个 JSON 字符串,内层用 LangChain4j 自带的 ChatMessageSerializer / ChatMessageDeserializer 处理每条 ChatMessage。看起来绕了一层,但好处很大:
| 场景 | 直接 Jackson 序列化整个 List | 双层序列化 |
|---|---|---|
ToolExecutionResultMessage 等子类 |
❌ Jackson 不识别多态,反序列化丢类型 | ✅ LangChain4j 内置 type 字段 |
Metadata / 多模态消息 |
❌ 容易丢字段 | ✅ 官方序列化器全字段覆盖 |
| LangChain4j 升级新消息类型 | ❌ 需要改自定义序列化逻辑 | ✅ 跟随官方升级 |
| 出问题排查 | ⚠️ 序列化结构未知 | ✅ 标准格式,Redis Desktop Manager 直接看 |
为什么 TTL 设 24 小时?
这是个权衡。设太短,用户隔夜回来对话上下文就丢了;设太长,僵尸会话会持续占内存。24 小时是大多数对话场景的合理边界——白天聊的,晚上回来还能接得上;超过 1 天大概率是已经"忘了"的会话,清理掉反而干净。如果业务有强需求(比如客服系统要保留 7 天),改 Duration.ofHours(24) 这一行即可。
为什么单条反序列化失败要返回空 List,而不是抛异常?
这是个生产防御设计。一条历史消息坏了(比如手动改了 Redis 里的 JSON、或者升级时遗留的旧格式),不应该让整个会话崩溃——返回空列表等于"忘了这次对话",AI 可以从零开始继续,用户最多感知到"AI 突然不记得了",但绝不会看到 500 错误。
五、FallbackChatMemoryStore — Redis 挂了也不中断
这是整个记忆系统最有意思的一个类。它做了一件事:把 Redis 当一等公民,但允许它偶尔抽风。
@Slf4j
@Component
public class FallbackChatMemoryStore implements ChatMemoryStore {
private final RedisMemoryStore redisStore;
private final ConcurrentHashMap<Object, List<ChatMessage>> localFallback = new ConcurrentHashMap<>();
private final AtomicLong fallbackCount = new AtomicLong(0);
private volatile boolean warned = false;
private volatile Instant lastFallbackTime = null;
/** 进入 fallback 模式后,至少冷却此时长才尝试 Redis 恢复,防抖动数据撕裂 */
private static final Duration FALLBACK_COOLDOWN = Duration.ofSeconds(15);
/** 每 N 次 fallback 操作探活一次 Redis */
private static final long RECOVERY_CHECK_INTERVAL = 5;
public FallbackChatMemoryStore(RedisMemoryStore redisStore) {
this.redisStore = redisStore;
}
@Override
public List<ChatMessage> getMessages(Object memoryId) {
// 在 fallback 冷却期,直接走本地,不再骚扰 Redis
if (isInFallbackCooldown()) {
List<ChatMessage> local = localFallback.get(memoryId);
return local != null ? new ArrayList<>(local) : new ArrayList<>();
}
try {
// 正常路径:访问 Redis
List<ChatMessage> result = redisStore.getMessages(memoryId);
exitFallbackMode(); // 成功 → 解除 fallback 状态
return result;
} catch (Exception e) {
// 失败 → 进入 fallback 模式
enterFallbackMode();
return fallbackGet(memoryId, e);
}
}
@Override
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
if (isInFallbackCooldown()) {
fallbackUpdate(memoryId, messages, null);
return;
}
try {
redisStore.updateMessages(memoryId, messages);
exitFallbackMode();
} catch (Exception e) {
enterFallbackMode();
fallbackUpdate(memoryId, messages, e);
}
}
}
核心是一个有冷却窗口的状态机:
正常态 Fallback 态
┌───────────┐ ┌───────────────┐
│ Redis OK │── 异常 ────→ │ 本地内存兜底 │
│ │ │ + 15s 冷却 │
└───────────┘ └───────────────┘
▲ │
│ │
└── 冷却结束 + 探活成功 ───────┘
为什么需要 15 秒冷却?
如果不加冷却,Redis 一旦抖动,每次 getMessages 都要尝试一次 Redis 连接、超时、抛异常、再走 fallback。一秒钟可能尝试几十次,结果是:
- CPU 浪费在重连上 —— 大量超时等待
- 日志被刷屏 —— 每次失败都打 ERROR
- 数据撕裂 —— 这条消息进了 Redis(恰好那一刻好了),下一条又被本地兜底,会话历史像被人砍了一半
冷却窗口 = 抑制抖动。15 秒内一律走本地,等 Redis 真正稳定下来再切回去。
自动恢复探活的细节:
private void maybeRecoveryCheck(Object memoryId) {
if (!isInFallbackCooldown() && fallbackCount.get() % RECOVERY_CHECK_INTERVAL == 0) {
try {
List<ChatMessage> local = localFallback.get(memoryId);
if (local != null && !local.isEmpty()) {
// 关键:探活的同时把本地数据写回 Redis,避免恢复后丢失这段对话
redisStore.updateMessages(memoryId, local);
} else {
redisStore.getMessages(memoryId);
}
log.info("[Memory] Redis 已恢复连接, 自动切回远端存储");
fallbackCount.set(0);
warned = false;
lastFallbackTime = null;
} catch (Exception ignored) {
// 探活失败保持 fallback 状态,下一轮再试
}
}
}
每 5 次 fallback 操作探活一次。探活成功的瞬间,把本地堆积的最新消息列表回写到 Redis——这是关键,否则故障期间的对话会随着进程重启永久丢失。
为什么 warned 用 volatile 而不是 AtomicBoolean?
因为这里只是控制"是否打过一次警告日志",read-modify-write 不是原子操作但也无所谓——最坏情况是几个并发请求各自打一次警告,日志多几行,不影响功能。volatile 保证可见性就够了,没必要付 CAS 的成本。这是一个典型的"工程权衡比纯粹严格性更重要"的例子。
六、ChatMemoryProvider — 把记忆接进 AiService
LangChain4j 提供了 ChatMemoryProvider 抽象,AgentX 用它把 FallbackChatMemoryStore 接进每一次 AI 对话:
@Bean
public ChatMemoryProvider chatMemoryProvider(FallbackChatMemoryStore conversationMemory) {
return memoryId -> MessageWindowChatMemory.builder()
.id(memoryId)
.maxMessages(20) // 滑动窗口:最近 20 条
.chatMemoryStore(conversationMemory) // 持久化用 Fallback Store
.build();
}
为什么 maxMessages 设 20?
这是 token 成本与上下文完整性的平衡:
| maxMessages | 上下文质量 | Prompt token | 成本 / 响应速度 |
|---|---|---|---|
| < 5 | ❌ 上下文太短,多轮对话接不住 | 极低 | 快 |
| 20(AgentX 默认) | ✅ 覆盖最近 10 轮对话(用户+AI 各一条) | ~2k token | 可控 |
| 50+ | 上下文充分但 token 飙升 | ~5k+ token | 慢 + 贵 |
| 不限 | 长对话场景 token 爆炸 | 几十 k | ❌ 拒绝服务级慢 |
20 条 ≈ 10 轮对话,对绝大多数客服、问答、Agent 场景已经够用。如果业务确实有"长对话强相关"的场景,可以叠加摘要式压缩(把超过 20 条的旧消息总结成一条 system message),不过那是另一个话题。
这一个 Bean 是怎么生效的?
LangChain4j 的 AiServices.builder() 在创建 AI 接口代理时会自动检测容器里有没有 ChatMemoryProvider:
return AiServices.builder(MyAiAssistant.class)
.chatLanguageModel(chatModel)
.chatMemoryProvider(chatMemoryProvider) // ← 注入
.build();
之后调用 AI 接口时只要带上 @MemoryId:
public interface MyAiAssistant {
String chat(@MemoryId String conversationId, @UserMessage String message);
}
LangChain4j 内部就会:
- 调用
provider.get(conversationId)→ 拿到一个MessageWindowChatMemory - 该 memory 内部用
FallbackChatMemoryStore读历史 → 拼到 Prompt - AI 回答完,自动把新一轮对话写回 Store
整个"记忆"的连接,业务代码只需要一个 @MemoryId 参数。 这才是接口抽象的力量。
七、RedisConfig — 那个救命的 activateDefaultTyping
记忆系统能跑起来,离不开 Redis 序列化的正确配置。先看代码:
@Configuration
@AutoConfigureAfter(RedisAutoConfiguration.class)
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
ObjectMapper objectMapper = new ObjectMapper();
// ① 处理 Java 8 时间类型(LocalDateTime 等)
objectMapper.registerModule(new JavaTimeModule());
// ② 允许访问所有字段(含 private 无 getter 的)
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// ③ ⚠️ 核心:激活类型标签,否则反序列化 ChatMessage 子类会全部退化成 Map
objectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
Jackson2JsonRedisSerializer<Object> jsonSerializer =
new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
// Key 用普通字符串(方便在 Redis 客户端里直接看)
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// Value 用 JSON
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
activateDefaultTyping 到底解决什么问题?
Java 的 List/Map 泛型在运行时会被擦除。也就是说,当你存一个 List<UserMessage> 进 Redis,Jackson 序列化出来是:
[{"text":"你好"}, {"text":"我叫张三"}]
注意——没有任何字段标记这些是 UserMessage。反序列化时 Jackson 只能猜,结果默认全变成 LinkedHashMap,原本的 UserMessage / AiMessage / SystemMessage / ToolExecutionResultMessage 这些子类全丢了。
加上 activateDefaultTyping 之后,序列化结果会变成:
["java.util.ArrayList", [
["dev.langchain4j.data.message.UserMessage", {"text":"你好"}],
["dev.langchain4j.data.message.AiMessage", {"text":"你好张三"}]
]]
每个对象前面都有"身份证"(class FQN),反序列化时 Jackson 就知道该实例化谁。
为什么 AgentX 没用这一层来直接存 ChatMessage?
虽然 RedisConfig 配了 activateDefaultTyping,但 RedisMemoryStore 实际用的是 StringRedisTemplate + LangChain4j 官方的 ChatMessageSerializer,绕开了 Jackson 的多态机制。理由是:
| 路径 | 优点 | 缺点 |
|---|---|---|
| 走 RedisTemplate + activateDefaultTyping | 通用,所有 POJO 都能存 | 序列化后多了 class FQN,Redis 里看起来像天书;重构包名会让旧数据全废 |
| 走 StringRedisTemplate + 官方 Serializer | LangChain4j 升级跟得上、Redis 里就是干净的 JSON | 只能用于 ChatMessage 类型 |
短期记忆只存 ChatMessage,走第二条路;其他业务对象(Workflow 状态、用户配置等)走第一条路。两个序列化体系各管各的边界,是 AgentX 一个非常实用的工程决策。
八、长期记忆:与短期记忆的协同
长期记忆基于 MilvusV2EmbeddingStore(专栏⑤已经完整讲过),这里只补充它和短期记忆的协同关系:
@Bean
public ContentRetriever contentRetriever(EmbeddingStore<TextSegment> embeddingStore,
EmbeddingModel embeddingModel) {
return EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(5) // 最多带 5 条相关知识
.minScore(0.8) // 相似度门槛
.build();
}
一次完整的 AI 请求里,两层记忆同时参与组装 Prompt:
最终 Prompt 结构:
┌────────────────────────────────────────────┐
│ [System Message] │ ← 角色设定
│ 你是 AgentX 智能助手... │
├────────────────────────────────────────────┤
│ [Retrieved Knowledge] │ ← Milvus 长期记忆
│ 根据知识库:[相关文档 chunk × top5] │
├────────────────────────────────────────────┤
│ [Conversation History] │ ← Redis 短期记忆
│ User: 我叫张三 │
│ AI: 你好张三 │
│ User: 我说过我叫什么? │
│ ...(最近 20 条) │
├────────────────────────────────────────────┤
│ [Current Question] │
│ User: 那我刚才问了什么? │
└────────────────────────────────────────────┘
两层职责的清晰边界:
- 短期记忆只负责"这场对话里说过什么"——按
memoryId精确取回最近 N 条原文 - 长期记忆负责"和这个问题语义相关的所有历史信息"——按向量距离模糊匹配
如果非要类比:短期记忆像你的工作记忆(刚才说过的话),长期记忆像你的知识储备(学过的所有东西)。两者缺一不可,但不能互相替代。
九、三个实战大坑
坑一:直接用 RedisTemplate 存 ChatMessage,反序列化全是 Map
现象: 用 LangChain4j 官方文档的简单用法 redisTemplate.opsForValue().set(key, messages),写入没问题;读出来调用 ((UserMessage) messages.get(0)).text() 直接 ClassCastException: LinkedHashMap cannot be cast to UserMessage。
原因: Jackson 序列化时丢了类型信息。List<ChatMessage> 序列化成 [{...}, {...}] 后,反序列化只能默认成 List<LinkedHashMap>,多态完全消失。
解决: 两条路任选一条:
- 配
activateDefaultTyping(见RedisConfig)—— 让 Jackson 在 JSON 里嵌入 class 信息,反序列化时能还原子类 - 用 LangChain4j 官方序列化器(见
RedisMemoryStore)——ChatMessageSerializer.messageToJson内部已经写好了 type 字段,不依赖 Jackson 多态机制
AgentX 选了第二条,理由:Redis 里的 JSON 不会被 ["java.util.ArrayList", ...] 这种东西污染,重构包名也不会让旧数据全废。
坑二:memoryId 设计错,会话串了或全员一个号
现象: 用户 A 看到用户 B 的聊天记录;或所有用户共享同一段历史,AI 完全乱套。
原因: memoryId 设计不对。常见错误:
| 错误用法 | 问题 |
|---|---|
memoryId = "default" |
所有人共用一段历史 |
memoryId = userId |
同一个用户的多个会话窗口互相污染 |
memoryId = sessionId(但 session 过期重新生成) |
每次刷新页面 AI 都"失忆" |
解决: memoryId = userId + ":" + conversationId,其中 conversationId 是稳定的、由前端在新建会话时一次性生成的 UUID。AgentX 的做法:
// AgentService.process()
final String convId = getOrCreateConvId(request.conversationId());
// → memoryId 后续就用这个 convId
getOrCreateConvId:前端传了就用前端的,没传就生成新的 UUID 并在响应里返回。这样:
- 同一用户开两个会话窗口 → 两个 convId → 互不干扰
- 用户刷新页面 → 前端把 convId 持久化(localStorage),AI 接得上
- 用户主动"新对话" → 前端清掉 localStorage 重新生成 UUID,记忆从零开始
坑三:Fallback 抖动导致会话数据撕裂
现象: Redis 时好时坏,用户聊到一半,明显能感觉到 AI 突然"忘了"几句,过一会儿又记起来了。
原因: 没有 fallback 冷却机制时,Redis 抖动期间消息的写入路径会在 Redis 和本地内存之间反复切换:
Time Event Storage
0s user msg #1 Redis OK → 写入 Redis
1s user msg #2 Redis 超时 → 写入本地内存
2s user msg #3 Redis OK → 写入 Redis(注意:msg #2 还在本地,没同步过来!)
3s AI 读取历史 Redis OK → 拿到的列表里没有 msg #2
消息 #2 永久丢失,AI 的回答上下文里就缺了一段,表现就是"突然失忆"。
解决: AgentX 的 FALLBACK_COOLDOWN = 15s 就是为此设计:
Time Event Path
0s user msg #1 Redis OK → 写 Redis
1s user msg #2 Redis 超时 → 写本地 + 进入 fallback 冷却 15s
2s user msg #3 冷却期内 → 直接走本地(不再尝试 Redis)
...
16s 探活成功 Recovery → 把本地完整列表回写 Redis + 解除冷却
17s user msg #4 Redis OK → 写 Redis(此时已包含 #1~#3 的完整历史)
冷却 + 探活时回写本地堆积数据 = 保证故障期间的会话也不丢失。
关键原则:fallback 不是"切换",是"原子的状态切换 + 数据同步"。少了任何一步都会留数据撕裂的坑。
十、总结
一张表收尾:
| 设计点 | 为什么这样选 |
|---|---|
| Redis 做短期记忆 | 亚毫秒读写,多实例共享,TTL 原生 |
| 24h TTL | 隔夜对话能接上,僵尸会话自动清理 |
| maxMessages = 20 | 上下文够用 + token 成本可控 |
| 双层序列化(外 Jackson + 内 LangChain4j 官方) | 类型安全 + Redis 里 JSON 干净可读 |
| FallbackChatMemoryStore | Redis 抖动不中断服务,是生产级的基本要求 |
| 15s 冷却窗口 + 5 次探活 | 抑制抖动 + 自动恢复,避免数据撕裂 |
| memoryId = userId + “:” + convId | 一个用户多窗口隔离,刷新页面对话不丢 |
activateDefaultTyping for 通用 Bean |
让非 ChatMessage 的业务对象也能正确多态反序列化 |
| Milvus 做长期记忆 | 语义检索,覆盖 Redis 触不到的长期上下文 |
五个核心结论:
- AI 的"记忆"是工程问题,不是模型能力问题 —— LLM 天生无状态,所有记忆都要靠系统外部提供
- 短期 + 长期是不可合并的两层 —— 一个按 memoryId 精取,一个按语义模糊找,职责完全不同
- 生产级记忆必须有 Fallback —— Redis 一定会挂的某一天,系统不能跟着挂
- Fallback 的难点不是切换,是不撕裂 —— 冷却窗口 + 探活回写是最小可用解
- 序列化是隐蔽的雷区 —— 类型擦除 + 多态 + 包名重构,任何一个都能让你的历史数据全废,提前规划好序列化策略
如果你也在做 Agent 项目,建议这样演进:
- 第一阶段:先把
RedisMemoryStore+ChatMemoryProvider接进 LangChain4j,跑通多轮对话 - 第二阶段:加
FallbackChatMemoryStore,做 Redis 容错 - 第三阶段:接入 Milvus 长期记忆(参考⑤),让 AI 记住"上周聊过的事"
- 第四阶段:考虑 memory 摘要压缩,应对超长对话场景
代码在公众号回复 「记忆」 获取,包含完整的 RedisMemoryStore / FallbackChatMemoryStore / RedisConfig 源码与单元测试用例。
💬 你在 Agent 记忆系统上踩过哪些坑? 欢迎在评论区分享,一起把"AI 不再金鱼脑"这件事做到工业级稳定。
关注公众号 【SuniaCoder-AI全栈架构实战】,回复「记忆」获取记忆系统完整代码包,回复「RAG」获取 RAG 知识库代码包,回复「工具」获取 AgentX 工具系统模板。
关于作者 & 联系方式
汪旭 / Sunia — Java 全栈开发者,AI 应用工程化实践者
专注企业级 AI 落地,擅长极限资源优化,有 RAG、Agent、知识图谱方向的完整实战经验。
| 平台 | 地址 / 说明 |
|---|---|
| CSDN | SuniaCoder-AI|13.5 万+ 阅读,RAG/Agent 系列持续更新 |
| 微信公众号 | 搜索【SuniaCoder-AI全栈架构实战】|回复「工具」获取工具开发完整模板 |
| 掘金 | SuniaCoder-AI |
| 知乎 | SuniaCoder-AI |
| 合作咨询 | 企业私有化大模型部署与定制开发,欢迎私信洽谈 |
如果内容对你有帮助,点赞 + 收藏 + 关注是最大的支持。
上一篇 → RAG进阶:用Milvus+bge-m3构建比ES更懂语义的企业知识库
Tags:#AgentX #Redis #Milvus #记忆系统 #ChatMemory #LangChain4j #Java21 #Fallback #多轮对话 #企业AI
更多推荐




所有评论(0)