一、为什么需要记忆
记忆让 Agent 记住之前的会话内容:记住先前交互、从反馈中学习、适应用户偏好。随着任务变复杂、交互变多,这对效率和用户满意度都至关重要。
短期记忆 :在单个线程(thread)/ 会话内记住先前的交互。
会话起到隔离作用 ------ 同一个 Agent 实例可以服务多个互不干扰的对话,类似于电子邮件把消息分组到不同会话里。
二、ReactAgent 如何管理短期记忆
Spring AI Alibaba 把短期记忆作为 Agent 状态(Graph State)的一部分来管理:
- 存入 Graph 状态 → Agent 能访问某次对话的完整上下文,同时保持不同对话相互隔离;
- 状态由 checkpointer 持久化到数据库(或内存),线程可随时恢复;
- 调用 Agent 时 或完成步骤(如工具调用)时更新状态 ,并在每个步骤开始时读取状态。
默认通过 state 里的 messages 键管理对话历史(含 SystemMessage 指令与 UserMessage 输入)。
三、记忆带来的上下文过长问题
保留全部对话历史是最常见的短期记忆形式,但长对话会导致:
- 超出 LLM 上下文窗口 → 上下文丢失或报错;
- 即便窗口够大,模型表现仍会变差 ------ 被过时或偏离主题的内容"分散注意力";
- 响应变慢、Token 成本上升。
由于 ReactAgent 中消息在「用户输入 ↔ 模型响应」之间交替追加,messages 列表会随时间不断变长。因此需要移除或"忘记"过时信息的技术 ------ 即"上下文工程"。
四、启用短期记忆
创建 Agent 时指定 checkpointer(saver) 即可。
开发环境:MemorySaver
java
ReactAgent agent = ReactAgent.builder()
.name("my_agent")
.model(chatModel)
.tools(getUserInfoTool)
.saver(new MemorySaver()) // ← 关键
.build();
RunnableConfig config = RunnableConfig.builder()
.threadId("1") // ← threadId 指定会话 ID
.build();
agent.call("你好!我叫 Bob。", config);
生产环境:RedisSaver(或 MongoSaver)
java
import com.alibaba.cloud.ai.graph.checkpoint.savers.RedisSaver;
import org.redisson.api.RedissonClient;
RedisSaver redisSaver = new RedisSaver(redissonClient);
ReactAgent agent = ReactAgent.builder()
.name("my_agent").model(chatModel)
.tools(getUserInfoTool)
.saver(redisSaver)
.build();
threadId是记忆的隔离维度:同一 threadId 共享上下文,不同 threadId 完全隔离。同一次会话的多轮调用必须传同一个 config。
五、自定义记忆(扩展状态)
默认只管 messages,你可以在 Hook 或工具中读写状态来扩展:
java
public class CustomMemoryHook extends ModelHook {
@Override public String getName() { return "custom_memory"; }
@Override public HookPosition[] getHookPositions() {
return new HookPosition[]{HookPosition.BEFORE_MODEL};
}
@Override
public CompletableFuture<Map<String, Object>> beforeModel(OverAllState state, RunnableConfig config) {
// 读:访问消息历史
Optional<Object> messagesOpt = state.value("messages");
if (messagesOpt.isPresent()) {
List<Message> messages = (List<Message>) messagesOpt.get();
}
// 写:返回的 Map 会合并进状态
return CompletableFuture.completedFuture(Map.of(
"user_id", "user_123",
"preferences", Map.of("theme", "dark")
));
}
@Override
public CompletableFuture<Map<String, Object>> afterModel(OverAllState state, RunnableConfig config) {
return CompletableFuture.completedFuture(Map.of());
}
}
六、上下文工程:四种常见模式
| 模式 | 做法 | 代价 |
|---|---|---|
| 修剪消息 | 调用 LLM 前移除前 N 条或后 N 条 | 可能丢信息 |
| 删除消息 | 从 Graph 状态中永久删除 | 可能丢信息 |
| 总结消息 | 用模型总结较早消息并替换为摘要 | 需额外一次 LLM 调用(成本最低化可用便宜模型) |
| 自定义策略 | 过滤、按 token 裁剪等 | 视实现 |
统一入口:MessagesModelHook ,返回 AgentCommand(消息列表, UpdatePolicy.REPLACE)。
6.1 修剪消息(BEFORE_MODEL)
java
@HookPositions({HookPosition.BEFORE_MODEL})
public class MessageTrimmingHook extends MessagesModelHook {
private static final int MAX_MESSAGES = 3;
@Override public String getName() { return "message_trimming"; }
@Override
public AgentCommand beforeModel(List<Message> previousMessages, RunnableConfig config) {
if (previousMessages.size() <= MAX_MESSAGES) {
return new AgentCommand(previousMessages); // 无需更改
}
Message firstMsg = previousMessages.get(0); // 保住第一条(通常是 System 指令)
int keepCount = previousMessages.size() % 2 == 0 ? 3 : 4;
List<Message> recentMessages = previousMessages.subList(
previousMessages.size() - keepCount, previousMessages.size());
List<Message> trimmed = new ArrayList<>();
trimmed.add(firstMsg);
trimmed.addAll(recentMessages);
return new AgentCommand(trimmed, UpdatePolicy.REPLACE);
}
}
ReactAgent agent = ReactAgent.builder()
.name("my_agent").model(chatModel).tools(tools)
.hooks(new MessageTrimmingHook())
.saver(new MemorySaver())
.build();
RunnableConfig config = RunnableConfig.builder().threadId("1").build();
agent.call("你好,我叫 bob", config);
agent.call("写一首关于猫的短诗", config);
agent.call("现在对狗做同样的事情", config);
AssistantMessage r = agent.call("我叫什么名字?", config);
// 输出:你的名字是 Bob。你之前告诉我的。
关键技巧:永远保留第 0 条(SystemMessage),否则人设/规则会丢。
更实用的做法是按 token 估算触发 (m.getText().length() / 4)而不是按条数。
6.2 删除消息(AFTER_MODEL)
java
@HookPositions({HookPosition.AFTER_MODEL})
public class MessageDeletionHook extends MessagesModelHook {
@Override public String getName() { return "message_deletion"; }
@Override
public AgentCommand afterModel(List<Message> previousMessages, RunnableConfig config) {
if (previousMessages.size() > 2) {
return new AgentCommand(previousMessages.subList(2, previousMessages.size()),
UpdatePolicy.REPLACE); // 删最早两条
}
return new AgentCommand(previousMessages);
}
}
清空全部:
java
@Override
public AgentCommand afterModel(List<Message> previousMessages, RunnableConfig config) {
return new AgentCommand(new ArrayList<>(), UpdatePolicy.REPLACE);
}
⚠️ 删除时务必保证结果仍是合法的消息历史,注意厂商限制:
- 某些提供商要求历史以 user 消息开始;
- 大多数提供商要求带 tool_calls 的 assistant 消息必须紧跟对应的 tool 结果消息(否则直接报错)。
6.3 总结消息(推荐,信息损失最小)
java
@HookPositions({HookPosition.BEFORE_MODEL})
public class MessageSummarizationHook extends MessagesModelHook {
private final ChatModel summaryModel;
private final int maxTokensBeforeSummary;
private final int messagesToKeep;
@Override public String getName() { return "message_summarization"; }
@Override
public AgentCommand beforeModel(List<Message> previousMessages, RunnableConfig config) {
int estimatedTokens = previousMessages.stream()
.mapToInt(m -> m.getText().length() / 4).sum();
if (estimatedTokens < maxTokensBeforeSummary) {
return new AgentCommand(previousMessages);
}
int messagesToSummarize = previousMessages.size() - messagesToKeep;
if (messagesToSummarize <= 0) return new AgentCommand(previousMessages);
String summary = generateSummary(previousMessages.subList(0, messagesToSummarize));
List<Message> newMessages = new ArrayList<>();
newMessages.add(new SystemMessage("## 之前对话摘要:\n" + summary)); // 摘要置顶
newMessages.addAll(previousMessages.subList(messagesToSummarize, previousMessages.size()));
return new AgentCommand(newMessages, UpdatePolicy.REPLACE);
}
private String generateSummary(List<Message> messages) {
StringBuilder conversation = new StringBuilder();
for (Message msg : messages) {
conversation.append(msg.getMessageType()).append(": ").append(msg.getText()).append("\n");
}
return summaryModel.call(new Prompt(new UserMessage("请简要总结以下对话:\n\n" + conversation)))
.getResult().getOutput().getText();
}
}
// 使用:4000 tokens 触发,总结后保留最后 20 条
MessageSummarizationHook hook = new MessageSummarizationHook(summaryModel, 4000, 20);
summaryModel可以用更便宜的模型(如 qwen-turbo)来做摘要,主模型继续用 qwen-max,成本最优。
七、访问记忆的四种途径
7.1 在工具中读取(ToolContext)
ToolContext 参数从工具签名中隐藏(模型看不到),但工具能拿到状态:
java
public class UserInfoTool implements BiFunction<String, ToolContext, String> {
@Override
public String apply(String query, ToolContext toolContext) {
RunnableConfig config = (RunnableConfig) toolContext.getContext().get("config");
String userId = (String) config.metadata("user_id").orElse("");
return "user_123".equals(userId) ? "用户是 John Smith" : "未知用户";
}
}
ToolCallback getUserInfoTool = FunctionToolCallback
.builder("get_user_info", new UserInfoTool())
.description("查找用户信息")
.inputType(String.class)
.build();
RunnableConfig config = RunnableConfig.builder()
.threadId("1")
.addMetadata("user_id", "user_123") // ← 注入上下文
.build();
7.2 在工具/Hook 中写入
在 Hook 返回的 Map 中放字段,或用工具返回的信息更新状态 ------ 用于持久化中间结果,让后续工具/提示可见。
基于记忆生成动态提示:
java
public class DynamicPromptInterceptor extends ModelInterceptor {
@Override
public ModelResponse interceptModel(ModelRequest request, ModelCallHandler handler) {
String userName = (String) request.getContext().get("user_name");
String systemPrompt = "你是一个有帮助的助手。称呼用户为 " + userName + "。";
SystemMessage enhanced = request.getSystemMessage() == null
? new SystemMessage(systemPrompt)
: new SystemMessage(request.getSystemMessage().getText() + "\n" + systemPrompt);
return handler.call(ModelRequest.builder(request).systemMessage(enhanced).build());
}
@Override public String getName() { return "DynamicPromptInterceptor"; }
}
7.3 BEFORE_MODEL:调用前处理消息
典型用途:裁剪/总结/注入上下文。
java
@HookPositions({HookPosition.BEFORE_MODEL})
public class TrimMessagesHook extends MessagesModelHook {
@Override public String getName() { return "trim_messages"; }
@Override
public AgentCommand beforeModel(List<Message> previousMessages, RunnableConfig config) {
if (previousMessages.size() <= 3) return new AgentCommand(previousMessages);
List<Message> trimmed = new ArrayList<>();
trimmed.add(previousMessages.get(0)); // 首条(System)
trimmed.addAll(previousMessages.subList(previousMessages.size() - 3,
previousMessages.size())); // 最近 3 条
return new AgentCommand(trimmed, UpdatePolicy.REPLACE);
}
}
7.4 AFTER_MODEL:调用后处理消息
典型用途:敏感词过滤、结果校验、落库审计。
java
@HookPositions({HookPosition.AFTER_MODEL})
public class ValidateResponseHook extends MessagesModelHook {
private static final List<String> STOP_WORDS = List.of("password", "secret", "api_key");
@Override public String getName() { return "validate_response"; }
@Override
public AgentCommand afterModel(List<Message> previousMessages, RunnableConfig config) {
if (previousMessages.isEmpty()) return new AgentCommand(previousMessages);
String content = previousMessages.get(previousMessages.size() - 1).getText();
for (String stopWord : STOP_WORDS) {
if (content.toLowerCase().contains(stopWord)) {
List<Message> filtered = new ArrayList<>(
previousMessages.subList(0, previousMessages.size() - 1));
filtered.add(new AssistantMessage("抱歉,我无法提供该信息。")); // 替换而非删除
return new AgentCommand(filtered, UpdatePolicy.REPLACE);
}
}
return new AgentCommand(previousMessages);
}
}
注意这里是替换为一条安全的 AssistantMessage 而非直接删除,正是为了避免产生"消息历史不合法"的问题。
八、速查卡
java
// 启用记忆
.saver(new MemorySaver()) // 开发
.saver(new RedisSaver(redissonClient)) // 生产
// 指定会话
RunnableConfig.builder().threadId("1").addMetadata("user_id", "user_123").build();
// 读写状态(Hook)
state.value("messages"); // 读
return CompletableFuture.completedFuture(Map.of(k, v)); // 写
// 改写消息列表(MessagesModelHook)
return new AgentCommand(newList, UpdatePolicy.REPLACE); // 替换
return new AgentCommand(previousMessages); // 不变
// 工具里拿上下文
RunnableConfig cfg = (RunnableConfig) toolContext.getContext().get("config");
cfg.metadata("user_id");
六条铁律
- 没有
saver()就没有短期记忆 ------ Agent 每次调用都是全新的。 - 同一会话的多轮调用必须传同一个
threadId,否则记忆不共享。 - 裁剪/删除时必须保留第 0 条 SystemMessage,否则人设与规则丢失。
- 结果消息列表必须合法:以 user 开头 ;带 tool_calls 的 assistant 必须紧跟 tool 结果。
- 敏感内容拦截用替换而不是删除,避免破坏历史结构。
- 摘要模型与主模型可以分离,用便宜模型做总结降本。
九、对照本项目(xs-interview-agent)
当前的"记忆"是手写方案:
java
private final Map<String, ResumeData> resumeStorage = new ConcurrentHashMap<>();
// resumeId(UUID) → { resumeText, scoreResult, questions, evaluation }
对照后的三个问题:
- 重启即丢 → 换
RedisSaver,threadId = resumeId,天然持久化且支持多实例部署。 - 无对话历史 → 现在四个阶段是割裂的三次调用;接入 ReactAgent 后,
resumeText可放在状态里,模型能记住"刚才问过什么、答得怎么样",从而实现追问式面试(如"针对第 3 题再深入问一下")。 - 越权风险 → 任何人拿到
/analysis/{resumeId}都能看他人简历;接入记忆后应把userId放进RunnableConfig.metadata,在工具/Hook 里做归属校验。
最小改造示意:
java
ReactAgent interviewAgent = ReactAgent.builder()
.name("interview_agent")
.model(chatModel)
.tools(questionBankTool, saveReportTool)
.hooks(new MessageSummarizationHook(cheapModel, 4000, 20)) // 长面试自动摘要
.saver(new RedisSaver(redissonClient))
.build();
RunnableConfig config = RunnableConfig.builder()
.threadId(resumeId)
.addMetadata("user_id", currentUserId)
.build();
AssistantMessage result = interviewAgent.call(userAnswer, config);
简历全文 + 逐题问答的 token 量不小,总结型 Hook 在这里不是可选项而是必需品 ------ 否则多轮追问后必然触顶上下文窗口。