Java 对象写入 Redis 后类型变了?Jackson 多态序列化问题复盘

一、业务背景:Agent 的「记忆」为什么必须落 Redis

在 order-agent-assistant 这个项目里,用户说「帮我取消订单 2026...」,Agent 需要多轮对话:先查单拿到订单号 → 提示用户确认 → 用户点「批准执行」→ 才真正取消。如果每一轮 HTTP 请求都是无状态的,模型根本不知道上一轮聊了什么,只会把用户的第二句话当成一句全新的、莫名其妙的指令。

企业里的通行做法:把会话上下文(messages 列表)存到 Redis,key = sessionId,TTL = 30 分钟。

为什么不用 Session 内存?

  • Agent 服务要能水平扩容,用户第二次请求可能落到另一台机器上,内存态会话直接丢失;
  • 滚动发布时进程重启,内存态会话全没;
  • Agent 的上下文体积不小(含工具调用记录),放 JVM 堆里对 GC 不友好。

数据流向:

bash 复制代码
浏览器 Vue3 聊天面板
   │ POST /query  { sessionId, message }
   ▼
AgentLoop(order-agent-assistant :8081)
   │ 1. sessionStore.load(sessionId)              ← 从 Redis 读历史
   │ 2. 拼上下文 = [system, ...history, 本轮 user]
   │ 3. 调 DeepSeek(工具调用 / 最终答案)
   │ 4. sessionStore.save(sessionId, 追加后的消息) ← 写回 Redis
   ▼
Redis(会话记忆,TTL 30 分钟)

坑就出在第 1 步和第 4 步之间。


下面是整体数据流向的 Mermaid 时序图:
DeepSeek Redis AgentLoop(:8081) 浏览器(Vue3) DeepSeek Redis AgentLoop(:8081) 浏览器(Vue3) #mermaid-svg-m8dUJz0mQiXHfp1g{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-m8dUJz0mQiXHfp1g .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-m8dUJz0mQiXHfp1g .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-m8dUJz0mQiXHfp1g .error-icon{fill:#552222;}#mermaid-svg-m8dUJz0mQiXHfp1g .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-m8dUJz0mQiXHfp1g .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-m8dUJz0mQiXHfp1g .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-m8dUJz0mQiXHfp1g .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-m8dUJz0mQiXHfp1g .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-m8dUJz0mQiXHfp1g .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-m8dUJz0mQiXHfp1g .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-m8dUJz0mQiXHfp1g .marker{fill:#333333;stroke:#333333;}#mermaid-svg-m8dUJz0mQiXHfp1g .marker.cross{stroke:#333333;}#mermaid-svg-m8dUJz0mQiXHfp1g svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-m8dUJz0mQiXHfp1g p{margin:0;}#mermaid-svg-m8dUJz0mQiXHfp1g .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-m8dUJz0mQiXHfp1g text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-m8dUJz0mQiXHfp1g .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-m8dUJz0mQiXHfp1g .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-m8dUJz0mQiXHfp1g .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-m8dUJz0mQiXHfp1g .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-m8dUJz0mQiXHfp1g #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-m8dUJz0mQiXHfp1g .sequenceNumber{fill:white;}#mermaid-svg-m8dUJz0mQiXHfp1g #sequencenumber{fill:#333;}#mermaid-svg-m8dUJz0mQiXHfp1g #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-m8dUJz0mQiXHfp1g .messageText{fill:#333;stroke:none;}#mermaid-svg-m8dUJz0mQiXHfp1g .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-m8dUJz0mQiXHfp1g .labelText,#mermaid-svg-m8dUJz0mQiXHfp1g .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-m8dUJz0mQiXHfp1g .loopText,#mermaid-svg-m8dUJz0mQiXHfp1g .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-m8dUJz0mQiXHfp1g .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-m8dUJz0mQiXHfp1g .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-m8dUJz0mQiXHfp1g .noteText,#mermaid-svg-m8dUJz0mQiXHfp1g .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-m8dUJz0mQiXHfp1g .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-m8dUJz0mQiXHfp1g .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-m8dUJz0mQiXHfp1g .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-m8dUJz0mQiXHfp1g .actorPopupMenu{position:absolute;}#mermaid-svg-m8dUJz0mQiXHfp1g .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-m8dUJz0mQiXHfp1g .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-m8dUJz0mQiXHfp1g .actor-man circle,#mermaid-svg-m8dUJz0mQiXHfp1g line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-m8dUJz0mQiXHfp1g :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /query { sessionId, message } 1. load(sessionId) 读历史 历史 messages 2. 拼上下文 system, ...history, user 3. 调 DeepSeek(工具调用/答案) 工具调用 / 最终答案 4. save(sessionId, 追加后的消息) 回复

二、现象:写进去是 AssistantMessage,读出来成了 LinkedHashMap

领域模型长这样(这是有问题的那一版):

java 复制代码
public interface ChatMessage {
    MessageType type();
    String content();
}

public record UserMessage(String content) implements ChatMessage { /* ... */ }

public record AssistantMessage(String content, List<ToolCall> toolCalls) implements ChatMessage { /* ... */ }

public record ToolMessage(String toolCallId, String content) implements ChatMessage { /* ... */ }

存取就两行:

java 复制代码
// 存
redisTemplate.opsForValue().set(key, messages, Duration.ofMinutes(30));
// 取
List<ChatMessage> history = (List<ChatMessage>) redisTemplate.opsForValue().get(key);

单轮对话完全正常,所以单元测试、手工点一次、第一次联调都发现不了。到第二轮直接炸:

bash 复制代码
java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to
class com.orderagent.memory.AssistantMessage

还有一种更隐蔽的表现形式------不抛异常,但 assistantMessage.toolCalls() 返回 null,模型收到一条内容被掏空的消息,现象是「越聊越傻」:Agent 忘记了自己刚才已经查过订单,或者反复问同一个问题。不报错的 bug 永远比报错的 bug 贵,因为它会被当成「模型能力不行」而拖延排查方向。


三、根因:泛型擦除 + Jackson 不知道该怎么还原多态

三个因素叠加:

  1. Java 泛型擦除 。List<ChatMessage> 编译后就是 List,运行时元素类型信息不存在,反序列化时没有任何线索。
  2. 接口 / 抽象类没有可实例化的字段信息 。Jackson 拿到 {"content":"好的"} 时无从判断该 new 哪个实现类,它只能退而求其次,选一个「最安全」的目标类型:LinkedHashMap。这不是 bug,是它唯一合理的默认行为。
  3. 序列化器配置 。GenericJackson2JsonRedisSerializer 会在 JSON 里额外写一个 @class 字段做多态还原,但如果项目里用的是自定义 ObjectMapper、或者没开启 default typing,这条线索就丢了。

修法有两条路,各有权衡:

方案 做法 代价
Jackson 自动多态 开 activateDefaultTyping,或用 GenericJackson2JsonRedisSerializer 把 Java 全限定类名固化成存储格式 。类一改名 / 挪包,历史数据全部反序列化失败;且 activateDefaultTyping 默认信任任意类型,历史上出过反序列化 RCE 的 CVE,必须配合 PolymorphicTypeValidator 白名单
中间格式摊平(本项目采用) 不存多态对象,存一个扁平 DTO :基本类型字段 + 一个显式 type 判别字段,读出来用 switch 还原 要写一遍双向映射代码;但这部分逻辑集中在一个类里,可测可控

核心判断:存储格式是一份独立于代码结构的稳定契约。TTL 30 分钟的数据看起来「坏了也无所谓」,但把类名写进存储格式是个定时炸弹------它会在某次「顺手重命名包」的提交之后,以「线上偶发反序列化失败」的形式爆出来。


下面是两种修复方案的对比流程图:
#mermaid-svg-QByKgIL3SDNUwiaE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QByKgIL3SDNUwiaE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QByKgIL3SDNUwiaE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QByKgIL3SDNUwiaE .error-icon{fill:#552222;}#mermaid-svg-QByKgIL3SDNUwiaE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QByKgIL3SDNUwiaE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QByKgIL3SDNUwiaE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QByKgIL3SDNUwiaE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QByKgIL3SDNUwiaE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QByKgIL3SDNUwiaE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QByKgIL3SDNUwiaE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QByKgIL3SDNUwiaE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QByKgIL3SDNUwiaE .marker.cross{stroke:#333333;}#mermaid-svg-QByKgIL3SDNUwiaE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QByKgIL3SDNUwiaE p{margin:0;}#mermaid-svg-QByKgIL3SDNUwiaE .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-QByKgIL3SDNUwiaE .cluster-label text{fill:#333;}#mermaid-svg-QByKgIL3SDNUwiaE .cluster-label span{color:#333;}#mermaid-svg-QByKgIL3SDNUwiaE .cluster-label span p{background-color:transparent;}#mermaid-svg-QByKgIL3SDNUwiaE .label text,#mermaid-svg-QByKgIL3SDNUwiaE span{fill:#333;color:#333;}#mermaid-svg-QByKgIL3SDNUwiaE .node rect,#mermaid-svg-QByKgIL3SDNUwiaE .node circle,#mermaid-svg-QByKgIL3SDNUwiaE .node ellipse,#mermaid-svg-QByKgIL3SDNUwiaE .node polygon,#mermaid-svg-QByKgIL3SDNUwiaE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QByKgIL3SDNUwiaE .rough-node .label text,#mermaid-svg-QByKgIL3SDNUwiaE .node .label text,#mermaid-svg-QByKgIL3SDNUwiaE .image-shape .label,#mermaid-svg-QByKgIL3SDNUwiaE .icon-shape .label{text-anchor:middle;}#mermaid-svg-QByKgIL3SDNUwiaE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QByKgIL3SDNUwiaE .rough-node .label,#mermaid-svg-QByKgIL3SDNUwiaE .node .label,#mermaid-svg-QByKgIL3SDNUwiaE .image-shape .label,#mermaid-svg-QByKgIL3SDNUwiaE .icon-shape .label{text-align:center;}#mermaid-svg-QByKgIL3SDNUwiaE .node.clickable{cursor:pointer;}#mermaid-svg-QByKgIL3SDNUwiaE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QByKgIL3SDNUwiaE .arrowheadPath{fill:#333333;}#mermaid-svg-QByKgIL3SDNUwiaE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QByKgIL3SDNUwiaE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QByKgIL3SDNUwiaE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QByKgIL3SDNUwiaE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QByKgIL3SDNUwiaE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QByKgIL3SDNUwiaE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QByKgIL3SDNUwiaE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QByKgIL3SDNUwiaE .cluster text{fill:#333;}#mermaid-svg-QByKgIL3SDNUwiaE .cluster span{color:#333;}#mermaid-svg-QByKgIL3SDNUwiaE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QByKgIL3SDNUwiaE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QByKgIL3SDNUwiaE rect.text{fill:none;stroke-width:0;}#mermaid-svg-QByKgIL3SDNUwiaE .icon-shape,#mermaid-svg-QByKgIL3SDNUwiaE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QByKgIL3SDNUwiaE .icon-shape p,#mermaid-svg-QByKgIL3SDNUwiaE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QByKgIL3SDNUwiaE .icon-shape .label rect,#mermaid-svg-QByKgIL3SDNUwiaE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QByKgIL3SDNUwiaE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QByKgIL3SDNUwiaE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QByKgIL3SDNUwiaE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 方案一
方案二(本项目)
问题:List<ChatMessage> 反序列化失败
选择修复方案
Jackson 自动多态
中间格式摊平
activateDefaultTyping 或 GenericJackson2JsonRedisSerializer
把全限定类名固化进存储格式
类改名/挪包 → 历史数据全挂
默认信任任意类型 → RCE 风险
存扁平 DTO + 显式 type 字段
读出来用 switch 还原
存储格式独立于代码结构
类重构不影响历史数据

四、代码实现:扁平 DTO + 显式类型还原

4.1 存储层 DTO:扁平、可演进、向前兼容

java 复制代码
package com.orderagent.memory;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

/**
 * 会话记忆的「中间格式」------只用于 Redis 存取,不参与任何业务逻辑。
 *
 * 设计要点:
 * 1) 不用接口 / 继承,从根上避免 Jackson 多态还原失败;
 * 2) type 是显式业务判别字段,不是 @class,类改名 / 挪包不影响历史数据;
 * 3) @JsonIgnoreProperties(ignoreUnknown = true):将来加字段,旧版本实例读新数据不炸(滚动发布安全)。
 */
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class StoredMessage {

    /** USER / ASSISTANT / TOOL ------ 显式判别字段,由业务语义决定,不由类名决定 */
    private String type;

    private String content;

    /** ASSISTANT 专用:本轮模型请求了哪些工具 */
    private List<StoredToolCall> toolCalls;

    /** TOOL 专用:回填给模型时对应的 toolCallId */
    private String toolCallId;

    @Data
    @NoArgsConstructor
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class StoredToolCall {
        private String id;
        private String name;
        /** 已是 JSON 字符串,原样透传,不再嵌套解析------少一层解析就少一类失败 */
        private String arguments;
    }
}

4.2 双向映射:领域模型 ⇄ 存储模型

java 复制代码
package com.orderagent.memory;

import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Objects;

/**
 * ChatMessage(领域模型)与 StoredMessage(存储模型)的转换器。
 *
 * 单独抽一个类,是为了让「存储格式」与「业务模型」解耦:
 * 将来把记忆从 Redis 换成别的存储、或升级格式,只改这里,AgentLoop 一行不动。
 */
@Component
public class MessageCodec {

    public StoredMessage toStored(ChatMessage message) {
        StoredMessage stored = new StoredMessage();
        stored.setType(message.type().name());
        stored.setContent(message.content());

        if (message instanceof AssistantMessage am) {
            stored.setToolCalls(am.toolCalls().stream().map(tc -> {
                StoredMessage.StoredToolCall s = new StoredMessage.StoredToolCall();
                s.setId(tc.id());
                s.setName(tc.name());
                s.setArguments(tc.arguments());
                return s;
            }).toList());
        } else if (message instanceof ToolMessage tm) {
            stored.setToolCallId(tm.toolCallId());
        }
        return stored;
    }

    /**
     * 反序列化:显式 switch 还原,而不是靠框架猜。
     *
     * 遇到未知 type(例如回滚版本后读到新版本写入的数据)不抛异常,返回 null 由上层过滤。
     * 理由:记忆是「增强」不是「关键路径」,缺一条历史消息不应该让整轮对话 500。
     */
    public ChatMessage fromStored(StoredMessage stored) {
        if (stored == null) {
            return null;
        }
        MessageType type = MessageType.parseOrNull(stored.getType());
        if (type == null) {
            return null;
        }
        return switch (type) {
            case USER -> new UserMessage(stored.getContent());
            case ASSISTANT -> new AssistantMessage(
                    stored.getContent(),
                    stored.getToolCalls() == null ? List.of()
                            : stored.getToolCalls().stream()
                                    .map(s -> new ToolCall(s.getId(), s.getName(), s.getArguments()))
                                    .toList());
            case TOOL -> new ToolMessage(stored.getToolCallId(), stored.getContent());
        };
    }

    public List<ChatMessage> fromStoredList(List<StoredMessage> list) {
        if (list == null) {
            return List.of();
        }
        return list.stream()
                .map(this::fromStored)
                .filter(Objects::nonNull)   // 未知类型静默跳过,不让脏数据中断对话
                .toList();
    }
}

4.3 会话存储:TTL、截断、降级

java 复制代码
package com.orderagent.memory;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.List;

/**
 * Redis 会话记忆存储。
 *
 * 用 StringRedisTemplate + 显式 ObjectMapper,而不是把对象直接丢给 RedisTemplate:
 * 让「存进 Redis 的字节长什么样」是代码里一眼可见的事,出问题好排查、好回放。
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisSessionStore {

    private static final Duration TTL = Duration.ofMinutes(30);
    private static final String KEY_PREFIX = "agent:session:";

    /** 单会话最多保留最近 40 条:老上下文对当前决策价值低,留着纯烧 token */
    private static final int MAX_MESSAGES = 40;

    private final StringRedisTemplate redis;
    private final ObjectMapper objectMapper;
    private final MessageCodec codec;

    /**
     * 加载历史。任何解析失败都降级为「空历史」,只记 WARN,不向上抛。
     * 一条脏数据让整轮对话 500,是最糟糕的选择。
     */
    public List<ChatMessage> load(String sessionId) {
        String key = KEY_PREFIX + sessionId;
        try {
            String json = redis.opsForValue().get(key);
            if (json == null || json.isBlank()) {
                return List.of();
            }
            List<StoredMessage> stored = objectMapper.readValue(json, new TypeReference<>() {});
            return codec.fromStoredList(stored);
        } catch (Exception e) {
            log.warn("[session] 历史记忆反序列化失败,已丢弃脏数据。sessionId={}, err={}",
                    sessionId, e.getMessage());
            redis.delete(key);   // 顺手清掉,避免每轮都重复踩
            return List.of();
        }
    }

    /** 保存:截断 → 序列化 → 覆盖写并续期 TTL */
    public void save(String sessionId, List<ChatMessage> messages) {
        if (messages == null || messages.isEmpty()) {
            return;
        }
        List<StoredMessage> stored = messages.stream().map(codec::toStored).toList();
        if (stored.size() > MAX_MESSAGES) {
            stored = stored.subList(stored.size() - MAX_MESSAGES, stored.size());
        }
        try {
            String json = objectMapper.writeValueAsString(stored);
            redis.opsForValue().set(KEY_PREFIX + sessionId, json, TTL);
        } catch (Exception e) {
            // 写失败不阻塞回复:用户已经拿到答案,记忆丢失只影响下一轮
            log.error("[session] 记忆写入失败。sessionId={}", sessionId, e);
        }
    }

    public void clear(String sessionId) {
        redis.delete(KEY_PREFIX + sessionId);
    }
}

4.4 调用点:只追加「本次新增」,别把整个上下文重复存回去

java 复制代码
// 1) 读历史
List<ChatMessage> history = sessionStore.load(sessionId);

// 2) 拼本轮上下文(system + 历史 + 本轮 user)
List<ChatMessage> context = new ArrayList<>();
context.add(systemPrompt());
context.addAll(history);
context.add(new UserMessage(dto.getMessage()));

// 3) 跑 Agent 循环(最大 8 步,超限立即停止并给用户可理解提示)
AgentResult result = agentLoop.run(context);

// 4) 只把「本轮新增」追加进历史再落库。
//    如果把整个 context 存回去,system 提示词会被反复累积,越聊越贵且越长。
List<ChatMessage> toSave = new ArrayList<>(history);
toSave.add(new UserMessage(dto.getMessage()));
toSave.addAll(result.newMessages());
sessionStore.save(sessionId, toSave);

下面是 4.1 到 4.4 的整体实现架构图:
#mermaid-svg-8erkZgJMvjA2kD1J{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8erkZgJMvjA2kD1J .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8erkZgJMvjA2kD1J .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8erkZgJMvjA2kD1J .error-icon{fill:#552222;}#mermaid-svg-8erkZgJMvjA2kD1J .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8erkZgJMvjA2kD1J .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8erkZgJMvjA2kD1J .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8erkZgJMvjA2kD1J .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8erkZgJMvjA2kD1J .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8erkZgJMvjA2kD1J .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8erkZgJMvjA2kD1J .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8erkZgJMvjA2kD1J .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8erkZgJMvjA2kD1J .marker.cross{stroke:#333333;}#mermaid-svg-8erkZgJMvjA2kD1J svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8erkZgJMvjA2kD1J p{margin:0;}#mermaid-svg-8erkZgJMvjA2kD1J .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8erkZgJMvjA2kD1J .cluster-label text{fill:#333;}#mermaid-svg-8erkZgJMvjA2kD1J .cluster-label span{color:#333;}#mermaid-svg-8erkZgJMvjA2kD1J .cluster-label span p{background-color:transparent;}#mermaid-svg-8erkZgJMvjA2kD1J .label text,#mermaid-svg-8erkZgJMvjA2kD1J span{fill:#333;color:#333;}#mermaid-svg-8erkZgJMvjA2kD1J .node rect,#mermaid-svg-8erkZgJMvjA2kD1J .node circle,#mermaid-svg-8erkZgJMvjA2kD1J .node ellipse,#mermaid-svg-8erkZgJMvjA2kD1J .node polygon,#mermaid-svg-8erkZgJMvjA2kD1J .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8erkZgJMvjA2kD1J .rough-node .label text,#mermaid-svg-8erkZgJMvjA2kD1J .node .label text,#mermaid-svg-8erkZgJMvjA2kD1J .image-shape .label,#mermaid-svg-8erkZgJMvjA2kD1J .icon-shape .label{text-anchor:middle;}#mermaid-svg-8erkZgJMvjA2kD1J .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8erkZgJMvjA2kD1J .rough-node .label,#mermaid-svg-8erkZgJMvjA2kD1J .node .label,#mermaid-svg-8erkZgJMvjA2kD1J .image-shape .label,#mermaid-svg-8erkZgJMvjA2kD1J .icon-shape .label{text-align:center;}#mermaid-svg-8erkZgJMvjA2kD1J .node.clickable{cursor:pointer;}#mermaid-svg-8erkZgJMvjA2kD1J .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8erkZgJMvjA2kD1J .arrowheadPath{fill:#333333;}#mermaid-svg-8erkZgJMvjA2kD1J .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8erkZgJMvjA2kD1J .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8erkZgJMvjA2kD1J .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8erkZgJMvjA2kD1J .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8erkZgJMvjA2kD1J .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8erkZgJMvjA2kD1J .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8erkZgJMvjA2kD1J .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8erkZgJMvjA2kD1J .cluster text{fill:#333;}#mermaid-svg-8erkZgJMvjA2kD1J .cluster span{color:#333;}#mermaid-svg-8erkZgJMvjA2kD1J div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8erkZgJMvjA2kD1J .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8erkZgJMvjA2kD1J rect.text{fill:none;stroke-width:0;}#mermaid-svg-8erkZgJMvjA2kD1J .icon-shape,#mermaid-svg-8erkZgJMvjA2kD1J .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8erkZgJMvjA2kD1J .icon-shape p,#mermaid-svg-8erkZgJMvjA2kD1J .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8erkZgJMvjA2kD1J .icon-shape .label rect,#mermaid-svg-8erkZgJMvjA2kD1J .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8erkZgJMvjA2kD1J .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8erkZgJMvjA2kD1J .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8erkZgJMvjA2kD1J :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Redis 层
转换层
存储层
StoredMessage DTO
StoredToolCall
领域模型层
ChatMessage 接口
UserMessage
AssistantMessage
ToolMessage
MessageCodec
toStored()
fromStored()
StringRedisTemplate
ObjectMapper
agent:session:{sessionId}

五、异常与并发边界

  • 脏数据 :load 吞掉所有 Exception 并删除 key。记忆是增强能力,不是关键路径------降级后对话仍能继续,只是丢失上下文。
  • 向前兼容 :@JsonIgnoreProperties(ignoreUnknown = true)。滚动发布期间新旧实例并存,旧实例读到新字段不会炸。
  • 未知 type :fromStored 返回 null 后统一 filter,而不是 throw。版本回滚同样安全。
  • 并发写 :同一 sessionId 的两次并发请求会互相覆盖,风险窗口正好是「模型推理」那几百毫秒到几秒。当前定位(单用户会话)下 key 粒度已够;若确需严格串行,可上 Redis 分布式锁(SET key value NX PX,value 用 UUID,解锁走 Lua 保证「谁加锁谁解」),或在网关按 sessionId 做一致性哈希路由。但先问清楚「真的会并发吗」,别过度设计。
  • 存储格式演进 :判断依据不是「代码能不能跑」,而是「Redis 里还有没有老数据」。因为有 30 分钟 TTL,最省事的迁移策略是换 key 前缀 (agent:session:v2:),让老数据自然过期,不做在线迁移。

下面是异常与并发边界的处理策略图:
#mermaid-svg-zoWFdIr4G1Lcl0w8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .error-icon{fill:#552222;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .marker.cross{stroke:#333333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zoWFdIr4G1Lcl0w8 p{margin:0;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .cluster-label text{fill:#333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .cluster-label span{color:#333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .cluster-label span p{background-color:transparent;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .label text,#mermaid-svg-zoWFdIr4G1Lcl0w8 span{fill:#333;color:#333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .node rect,#mermaid-svg-zoWFdIr4G1Lcl0w8 .node circle,#mermaid-svg-zoWFdIr4G1Lcl0w8 .node ellipse,#mermaid-svg-zoWFdIr4G1Lcl0w8 .node polygon,#mermaid-svg-zoWFdIr4G1Lcl0w8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .rough-node .label text,#mermaid-svg-zoWFdIr4G1Lcl0w8 .node .label text,#mermaid-svg-zoWFdIr4G1Lcl0w8 .image-shape .label,#mermaid-svg-zoWFdIr4G1Lcl0w8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .rough-node .label,#mermaid-svg-zoWFdIr4G1Lcl0w8 .node .label,#mermaid-svg-zoWFdIr4G1Lcl0w8 .image-shape .label,#mermaid-svg-zoWFdIr4G1Lcl0w8 .icon-shape .label{text-align:center;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .node.clickable{cursor:pointer;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .arrowheadPath{fill:#333333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zoWFdIr4G1Lcl0w8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zoWFdIr4G1Lcl0w8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zoWFdIr4G1Lcl0w8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .cluster text{fill:#333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .cluster span{color:#333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zoWFdIr4G1Lcl0w8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .icon-shape,#mermaid-svg-zoWFdIr4G1Lcl0w8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .icon-shape p,#mermaid-svg-zoWFdIr4G1Lcl0w8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .icon-shape .label rect,#mermaid-svg-zoWFdIr4G1Lcl0w8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zoWFdIr4G1Lcl0w8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zoWFdIr4G1Lcl0w8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zoWFdIr4G1Lcl0w8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
是
否
RedisSessionStore
load() 解析失败?
记录 WARN
删除 key
返回空历史,对话继续
正常返回历史
save() 写入失败?
记录 ERROR
不阻塞回复,仅丢下一轮记忆
正常写入并续期 TTL
同一 sessionId 并发?
后写覆盖先写
风险窗口 = 模型推理耗时
需要严格串行?
Redis 分布式锁 SET NX PX + Lua 解锁
前端防抖 + 幂等 requestId

六、面试 / 简历亮点

  • 「Agent 会话记忆的持久化格式设计」 ------用中间格式 DTO + 显式 type 判别字段替代 Jackson 默认多态。简历可写:「设计 Agent 会话记忆的存储格式,不依赖全限定类名,类重构不影响线上历史数据」。
  • 「降级设计」 ------记忆层任何异常都不影响主链路。可量化表述:记忆读取失败时对话仍可继续(仅丢上下文),而非返回 500。
  • 「上下文成本控制」------单会话最多 40 条 + TTL 30 分钟,直接对应 token 成本与模型注意力两个真实约束。
  • 「可测性」 ------MessageCodec 是纯函数式的,不碰 Redis,可以直接写单元测试,这是 agent 模块 146 个单测能跑绿的组成部分。
  • 规范 Commit Message :
    • fix(memory): 会话记忆改用中间格式 DTO,修复 Redis 读取后 ClassCastException
    • refactor(memory): 抽出 MessageCodec,解耦存储格式与领域模型

下面是面试 / 简历亮点的思维导图:
渲染错误: Mermaid 渲染失败: Parse error on line 22: ...it fix(memory): 修复 ClassCastExcept... ----------------------^ Expecting 'SPACELINE', 'NL', 'EOF', got 'NODE_ID'

七、场景题暴击

Q1:你选了中间格式摊平,那如果就是要存多态对象,Jackson 有哪几种做法?各自的坑是什么?

三个维度答:

  • @JsonTypeInfo(use = Id.NAME, property = "type") + @JsonSubTypes :判别字段可控、可读性好。但所有子类必须注册在父类注解里,新增一种消息类型要改父类,违反开闭原则;且依然要序列化器配合开启多态支持。
  • GenericJackson2JsonRedisSerializer / activateDefaultTyping :最省事,自动写 @class。但把 Java 全限定类名固化进存储格式------类改名 / 包迁移 → 历史数据全部反序列化失败;更严重的是它默认信任任意类型,历史上出现过反序列化 RCE 漏洞,生产必须配合 PolymorphicTypeValidator 白名单。
  • 本项目选中间格式 :存储格式是一份独立契约,不该跟着代码结构走。代价是写一遍双向映射,但逻辑集中在一个 MessageCodec 里,可测、可控、可演进。

Q2:同一个 sessionId 并发两次请求,记忆会不会串?你怎么保证?

分三层答,重点是第三层的判断力:

  1. 现状 :会。load → 模型推理 → save 不是原子的,后写覆盖先写,风险窗口 = 模型推理耗时。
  2. 加固方案 :Redis 分布式锁,锁粒度 = sessionId,SET key uuid NX PX 5000,解锁用 Lua 校验 value,避免误删他人锁。要注意锁超时必须大于模型推理的 P99 耗时,否则锁提前释放等于没加。
  3. 工程取舍 :先确认业务上是否真的会并发(同一用户连点两次发送?多端同时登录?)。若只是前者,前端按钮防抖 + 幂等 requestId 就够,比引入分布式锁划算得多。面试里能讲清「我先判断要不要加锁」,比背一堆锁的实现更加分。

推荐标签:Redis、Jackson、Spring Boot、AI Agent、序列化

相关推荐
小白的码BUG之路1 小时前
Jenkins -- 配置jdk和maven
java·jenkins·maven
Chasing__Dreams1 小时前
python杂识--24--Nuitak打包exe
开发语言·python
xiaoqiMikko1 小时前
Redis 实战(基础篇):把一个老 RedisUtil 搬到 Spring Boot 4,一路踩出来的坑(真机实测)
java·spring boot·redis
yuniko-n1 小时前
【JUC】读写锁
java·开发语言
niucloud-admin1 小时前
NIUCLOUD LITE AI开发文档--基础开发规范
开发语言·php
小羊没烦恼!1 小时前
T-SQL查询高级—SQL Server索引中的碎片和填充因子
开发语言·前端·javascript·数据库·后端·python·web安全
yuzhiboyouye1 小时前
后端怎么评估工作量
java
java资料站1 小时前
十五、Spring AI Alibaba · 高级 ·Graph
java·人工智能·spring
纪念 2291 小时前
C++ vector(一)
开发语言·c++