记忆系统:用 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:把记忆接进 LangChain4j AiService,一行代码搞定
  • 三个实战大坑:序列化丢类型、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,理由很简单:

  1. 多实例一致 —— 应用横向扩展时,所有节点共享同一份会话记忆
  2. 重启不丢 —— Redis 自带 RDB/AOF 持久化
  3. TTL 原生 —— 一行配置自动过期 24 小时未活跃的会话
  4. 延迟可控 —— 单次 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。一秒钟可能尝试几十次,结果是:

  1. CPU 浪费在重连上 —— 大量超时等待
  2. 日志被刷屏 —— 每次失败都打 ERROR
  3. 数据撕裂 —— 这条消息进了 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 内部就会:

  1. 调用 provider.get(conversationId) → 拿到一个 MessageWindowChatMemory
  2. 该 memory 内部用 FallbackChatMemoryStore 读历史 → 拼到 Prompt
  3. 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>,多态完全消失。

解决: 两条路任选一条:

  1. activateDefaultTyping(见 RedisConfig)—— 让 Jackson 在 JSON 里嵌入 class 信息,反序列化时能还原子类
  2. 用 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 触不到的长期上下文

五个核心结论:

  1. AI 的"记忆"是工程问题,不是模型能力问题 —— LLM 天生无状态,所有记忆都要靠系统外部提供
  2. 短期 + 长期是不可合并的两层 —— 一个按 memoryId 精取,一个按语义模糊找,职责完全不同
  3. 生产级记忆必须有 Fallback —— Redis 一定会挂的某一天,系统不能跟着挂
  4. Fallback 的难点不是切换,是不撕裂 —— 冷却窗口 + 探活回写是最小可用解
  5. 序列化是隐蔽的雷区 —— 类型擦除 + 多态 + 包名重构,任何一个都能让你的历史数据全废,提前规划好序列化策略

如果你也在做 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

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐