五、Spring AI Alibaba · Memory · Saver(短期会话)

一、为什么需要记忆

记忆让 Agent 记住之前的会话内容:记住先前交互、从反馈中学习、适应用户偏好。随着任务变复杂、交互变多,这对效率和用户满意度都至关重要。

短期记忆 :在单个线程(thread)/ 会话内记住先前的交互。

会话起到隔离作用 ------ 同一个 Agent 实例可以服务多个互不干扰的对话,类似于电子邮件把消息分组到不同会话里。


二、ReactAgent 如何管理短期记忆

Spring AI Alibaba 把短期记忆作为 Agent 状态(Graph State)的一部分来管理:

  • 存入 Graph 状态 → Agent 能访问某次对话的完整上下文,同时保持不同对话相互隔离;
  • 状态由 checkpointer 持久化到数据库(或内存),线程可随时恢复;
  • 调用 Agent 时 或完成步骤(如工具调用)时更新状态 ,并在每个步骤开始时读取状态。

默认通过 state 里的 messages 键管理对话历史(含 SystemMessage 指令与 UserMessage 输入)。


三、记忆带来的上下文过长问题

保留全部对话历史是最常见的短期记忆形式,但长对话会导致:

  1. 超出 LLM 上下文窗口 → 上下文丢失或报错;
  2. 即便窗口够大,模型表现仍会变差 ------ 被过时或偏离主题的内容"分散注意力";
  3. 响应变慢、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");

六条铁律

  1. 没有 saver() 就没有短期记忆 ------ Agent 每次调用都是全新的。
  2. 同一会话的多轮调用必须传同一个 threadId,否则记忆不共享。
  3. 裁剪/删除时必须保留第 0 条 SystemMessage,否则人设与规则丢失。
  4. 结果消息列表必须合法:以 user 开头 ;带 tool_calls 的 assistant 必须紧跟 tool 结果。
  5. 敏感内容拦截用替换而不是删除,避免破坏历史结构。
  6. 摘要模型与主模型可以分离,用便宜模型做总结降本。

九、对照本项目(xs-interview-agent)

当前的"记忆"是手写方案:

java 复制代码
private final Map<String, ResumeData> resumeStorage = new ConcurrentHashMap<>();
// resumeId(UUID) → { resumeText, scoreResult, questions, evaluation }

对照后的三个问题:

  1. 重启即丢 → 换 RedisSaver,threadId = resumeId,天然持久化且支持多实例部署。
  2. 无对话历史 → 现在四个阶段是割裂的三次调用;接入 ReactAgent 后,resumeText 可放在状态里,模型能记住"刚才问过什么、答得怎么样",从而实现追问式面试(如"针对第 3 题再深入问一下")。
  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 在这里不是可选项而是必需品 ------ 否则多轮追问后必然触顶上下文窗口。

相关推荐
夜之眷属2 小时前
Core dump 崩溃排查:JVM 宕机后,那份 core 文件怎么用 gdb 还原现场
java·运维·服务器·jvm
波加曼大王2 小时前
# vLLM不要迷信PagedAttention神话,聊聊线上藏着的内部碎片陷阱
java·架构
SL_staff2 小时前
目标健康度自检清单:开发者视角下的目标-计划-任务链路断点诊断
java·开源·github
for_ever_love__2 小时前
MySQL 高频面试题 30 问:索引、事务、锁、日志、主从与分库分表(MySQL 8.0 版)
java·数据库·spring boot·mysql·spring cloud·面试·总结
神仙别闹2 小时前
基于C++实现的贪吃蛇游戏平台
java·c++·游戏
布吉岛的石头2 小时前
Java 程序员第 49 阶段11:Java 接 BERT:用 ONNX Runtime 做句向量推理
java·人工智能·python·深度学习·bert·transformer
niucloud-admin2 小时前
JAVA V6 多商户商城 开发文档——JAVA服务端
java·开发语言
alexlifexyz3 小时前
写了个 Claude Code 插件:AI 写的 Java 违反阿里规约,就写不进文件
java
威哥爱编程3 小时前
【AI全栈12-01】Spring Boot 为何是 AI 全栈后端优选
java·spring boot·后端