一、业务背景: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 不知道该怎么还原多态
三个因素叠加:
- Java 泛型擦除 。
List<ChatMessage>编译后就是List,运行时元素类型信息不存在,反序列化时没有任何线索。 - 接口 / 抽象类没有可实例化的字段信息 。Jackson 拿到
{"content":"好的"}时无从判断该 new 哪个实现类,它只能退而求其次,选一个「最安全」的目标类型:LinkedHashMap。这不是 bug,是它唯一合理的默认行为。 - 序列化器配置 。
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 读取后 ClassCastExceptionrefactor(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 并发两次请求,记忆会不会串?你怎么保证?
分三层答,重点是第三层的判断力:
- 现状 :会。
load → 模型推理 → save不是原子的,后写覆盖先写,风险窗口 = 模型推理耗时。 - 加固方案 :Redis 分布式锁,锁粒度 =
sessionId,SET key uuid NX PX 5000,解锁用 Lua 校验 value,避免误删他人锁。要注意锁超时必须大于模型推理的 P99 耗时,否则锁提前释放等于没加。 - 工程取舍 :先确认业务上是否真的会并发(同一用户连点两次发送?多端同时登录?)。若只是前者,前端按钮防抖 + 幂等
requestId就够,比引入分布式锁划算得多。面试里能讲清「我先判断要不要加锁」,比背一堆锁的实现更加分。
推荐标签:Redis、Jackson、Spring Boot、AI Agent、序列化