导读:当你的 AI 应用从"一问一答"升级为"自主完成任务",Agent 就登场了。但 Agent 不只是能调工具的聊天机器人------它还需要"记忆"。本文将从零开始,带你用 Spring AI 构建一个具备工具调用能力的个人助理 Agent,再逐步为它装上三层记忆系统(工作记忆、历史压缩、长期记忆),让它真正"记住"用户、理解上下文。全文涵盖设计思路、核心代码与踩坑经验,适合有 Spring Boot 基础的开发者阅读。
一、用 Spring AI 构建第一个 Agent
1.1 Agent 与普通聊天的区别
在普通聊天场景中,大模型只做一件事:根据用户输入生成回答。而 Agent 则多了三个核心能力:
- 工具调用:能主动调用外部 API 获取实时数据(天气、汇率、日历等);
- 自主推理:基于 ReAct(Reasoning + Acting)框架,循环执行"思考 -> 行动 -> 观察"直到任务完成;
- 多轮编排:一次用户请求可能触发多次工具调用,框架自动处理循环流程。
因此,Agent 天然比普通聊天慢------它不是 Bug,而是每一轮都包含了"大模型推理 + 工具执行"的开销。
1.2 最小可用 Agent:个人助理
我们的目标是构建一个能 查天气、查日期、查汇率、创建提醒 的个人助理。Spring AI 没有一个叫 Agent 的类,但它提供了工具注册 + 自动多轮调用的机制,框架会帮我们完成之前需要手写 for 循环才能实现的 ReAct 流程。
工具类:AssistantTools
首先定义一个工具组件 com.jichi.agent.tools.AssistantTools,把所有助理能力集中在一个类里:
@Component
public class AssistantTools {
@Tool(description = """
查询指定城市的实时天气。
适用于:用户询问某城市当前天气、今天天气、是否需要带伞等问题。
返回:城市、天气状况、温度范围、湿度、风力。
注意:只查当前天气,不查天气预报。
""")
public String getWeather(
@ToolParam(description = "城市名,中文,例如:北京、上海、深圳") String city) {
// 演示用模拟数据,真实项目对接天气 API(高德、心知天气等)
return String.format("""
城市:%s
天气:晴转多云
温度:12~20°C
湿度:55%%
风力:东南风 2 级
查询时间:%s
""", city, LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
}
@Tool(description = "获取当前日期和时间。适用于用户询问今天几号、现在几点、这周星期几等时间相关问题。")
public String getCurrentDateTime() {
LocalDateTime now = LocalDateTime.now();
return String.format("""
当前日期:%s
当前时间:%s
星期:%s
""",
now.toLocalDate(),
now.format(DateTimeFormatter.ofPattern("HH:mm")),
getDayOfWeekChinese(now));
}
@Tool(description = """
查询两种货币之间的汇率。
适用于:用户询问人民币兑换外币的比率、外汇换算、出国换多少钱等问题。
返回:汇率数值和换算说明。
""")
public String getExchangeRate(
@ToolParam(description = "源货币,3位代码,例如:CNY、USD、EUR、JPY") String from,
@ToolParam(description = "目标货币,3位代码") String to) {
// 演示用模拟汇率,真实项目对接汇率 API
if ("CNY".equals(from) && "USD".equals(to)) {
return "1 CNY = 0.138 USD(1 美元 ≈ 7.24 人民币)";
}
if ("USD".equals(from) && "CNY".equals(to)) {
return "1 USD = 7.24 CNY";
}
return from + " 兑 " + to + " 的汇率:暂无实时数据,建议查询银行官网";
}
@Tool(description = """
创建一个定时提醒事项。
适用于:用户说"提醒我..."、"帮我记一下..."、"x点提醒我"等需要设置提醒的场景。
返回:提醒创建结果(成功或失败)。
""")
public String createReminder(
@ToolParam(description = "提醒内容描述") String content,
@ToolParam(description = "提醒时间,格式:yyyy-MM-dd HH:mm,例如:2024-03-20 09:00") String remindAt) {
// 真实项目里这里存数据库并触发推送通知(微信模板消息、短信等)
return String.format("提醒已创建:「%s」,将在 %s 提醒你", content, remindAt);
}
}
关键点 :
@Tool注解的description决定了大模型是否能正确选择工具。注意我们用了多行描述,包含"适用于"和"返回"说明,让模型更精准地匹配用户意图。@ToolParam注解则为每个参数提供了详细格式示例。
Agent 核心代码:PersonalAssistantAgent
@Service
public class PersonalAssistantAgent {
private final ChatClient chatClient;
public PersonalAssistantAgent(@Qualifier("dashScopeChatModel") ChatModel chatModel,
AssistantTools assistantTools) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("""
你是一个聪明的个人助理,名字叫小智。
你可以:
- 查询任意城市的实时天气
- 告知当前日期、时间和星期
- 查询外汇汇率
- 帮用户创建提醒事项
工作原则:
- 需要数据时主动调用工具,不要猜测或编造任何数据
- 回答简洁,重点突出,不要废话
- 用户一次问多个问题时,把所有相关工具都调完再统一回答
- 工具调用失败时,如实告知用户并说明原因
""")
.defaultTools(assistantTools)
.build();
}
public String chat(String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
}
就这么简单------把系统提示词写好、工具注册进去,Spring AI 会自动完成多轮工具调用和结果整合。注意这里通过 @Qualifier("dashScopeChatModel") 指定了具体的模型实现,AssistantTools 直接作为整个对象传入 defaultTools(),Spring AI 会自动扫描其中所有 @Tool 方法。
1.3 系统提示词的 Agent 化
普通聊天的系统提示往往只是"你是一个友善的助手",但 Agent 的系统提示需要额外做好三件事:
| 维度 | 说明 | 示例 |
|---|---|---|
| 能力声明 | 明确列出 Agent 能做什么 | "你可以查询天气、汇率..." |
| 工作原则 | 告诉模型何时该调工具 | "需要数据时主动调用工具,不要猜测" |
| 边界限制 | 防止 Agent 做超出预期的事 | "只回答与上述能力相关的问题" |
把能力显式列出来后,模型会更积极地调用工具而非"瞎编",幻觉问题会明显减少。
二、为 Agent 加上会话记忆
2.1 问题:无记忆的 Agent
当前 Agent 有一个明显缺陷:每次请求都是全新的。用户问"北京天气怎么样"后再问"上海呢",Agent 不知道"上海呢"是在问天气,因为它没有上下文。
2.2 解决方案:ChatMemory + 会话隔离
Spring AI 提供了 MessageChatMemoryAdvisor,只需在构建 ChatClient 时加一个 Advisor。来看 PersonalAssistantAgentWithMemory 的完整实现:
@Service
public class PersonalAssistantAgentWithMemory {
private final ChatClient chatClient;
public PersonalAssistantAgentWithMemory(@Qualifier("dashScopeChatModel") ChatModel chatModel,
AssistantTools assistantTools) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("""
你是一个聪明的个人助理,名字叫小智。
能查天气、告知时间、查汇率、创建提醒。
需要数据时调工具,不要猜测和编造。
""")
.defaultTools(assistantTools)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(
MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build())
.build())
.build();
}
public String chat(String message, String sessionId) {
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
.call()
.content();
}
}
几个关键细节:
MessageWindowChatMemory搭配InMemoryChatMemoryRepository作为存储后端;- 调用时通过
ChatMemory.CONVERSATION_ID常量传入sessionId,实现会话隔离; - 同一个
sessionId:多轮对话共享上下文,问"上海呢"时 Agent 知道是在聊天气; - 不同
sessionId:记忆完全隔离,新会话不受旧会话影响。
生产环境提示 :
InMemoryChatMemoryRepository仅适合开发调试,生产环境应替换为 Redis 或数据库实现,接口不变,只需换实现类。
三、Agent 记忆系统的三层架构
框架级别的 ChatMemory 是被动存取 ------它不知道什么消息重要、什么消息是噪声,只是机械地存和取。但真正的 Agent 需要主动管理记忆:决定记什么、怎么压缩、什么时候回忆。
这就引出了 Agent 记忆的三层结构:
┌─────────────────────────────────────────┐
│ 长期记忆 (Long-term) │
│ 跨会话的用户偏好、身份、重要事件 │
│ 存储:向量数据库 (PGVector / Milvus) │
├─────────────────────────────────────────┤
│ 工作记忆 (Working Memory) │
│ 当前任务的中间结果、已知事实 │
│ 存储:内存 Map (任务结束即清除) │
├─────────────────────────────────────────┤
│ 语义记忆 (Semantic Memory) │
│ 历史对话的压缩摘要 │
│ 存储:向量数据库 / Redis │
└─────────────────────────────────────────┘
类比来说:长期记忆 像你的人生经历------你是谁、喜欢什么;工作记忆 像你桌上的便签纸------当前任务的要点;语义记忆像你的日记本------过去发生的事的概要。
四、工作记忆:给 Agent 一块"白板"
4.1 问题场景
假设用户要求 Agent 规划一次五天的日本行程:查航班、查酒店、查景点、汇总方案。Agent 执行到第六步汇总时,突然说"航班信息还没查到"------但实际上第二步就查过了。
根因:对话历史里夹杂着工具调用的原始响应、模型的推理过程、用户闲聊等大量噪声,Agent 缺少一个干净的地方存放"已知事实"。
4.2 设计思路:把工作记忆做成工具
核心思想:创建一组工具方法,让 Agent 自己决定何时写入、何时读取工作记忆。来看 WorkingMemoryTools 的实现:
/**
* Agent 工作记忆------任务执行过程中的临时状态板
* 每次任务开始时清空,任务结束后可以选择性提炼到长期记忆
*/
@Component
public class WorkingMemoryTools {
// 结构化的工作记忆:key 是记忆标签,value 是内容
private final Map<String, String> workingMemory = new LinkedHashMap<>();
// 已完成的步骤列表
private final List<String> completedSteps = new ArrayList<>();
@Tool(description = """
在工作记忆中保存一条信息,用于后续步骤使用。
适用于:保存工具调用结果、中间推断结论、需要跨步骤传递的数据。
示例:key="上海天气",value="晴,26°C,适合户外活动"
""")
public String memorize(
@ToolParam(description = "记忆标签,用于之后检索") String key,
@ToolParam(description = "要记忆的内容") String value) {
workingMemory.put(key, value);
return "已记住:" + key;
}
@Tool(description = """
从工作记忆中检索之前保存的信息。
当需要用到之前步骤保存的数据时调用。
""")
public String recall(
@ToolParam(description = "要检索的记忆标签") String key) {
String value = workingMemory.get(key);
return value != null ? key + ":" + value : "工作记忆中没有关于 " + key + " 的记录";
}
@Tool(description = "列出当前工作记忆中所有已保存的信息,用于整理当前任务状态")
public String listMemory() {
if (workingMemory.isEmpty()) return "工作记忆为空";
StringBuilder sb = new StringBuilder("当前工作记忆:\n");
workingMemory.forEach((k, v) -> sb.append("- ").append(k).append(":").append(v).append("\n"));
return sb.toString();
}
@Tool(description = "标记一个步骤已完成,并记录执行结果摘要")
public String markStepDone(
@ToolParam(description = "步骤描述") String step,
@ToolParam(description = "执行结果摘要") String result) {
completedSteps.add(step + " → " + result);
return "已标记完成:" + step;
}
// 任务结束时,由外部调用清空状态,准备下一个任务
public void reset() {
workingMemory.clear();
completedSteps.clear();
}
public Map<String, String> snapshot() {
return Map.copyOf(workingMemory);
}
}
把这个工具注册到 Agent 后,Agent 在执行多步任务时会自主调用 memorize 保存中间结果,需要回顾时调用 recall 或 listMemory。第六步汇总时再也不会"忘记"第二步查过的航班信息了。注意 reset() 和 snapshot() 是普通方法而非 @Tool,供外部代码在任务边界调用。
五、历史压缩:解决上下文爆满问题
5.1 截断 vs 压缩
当 Agent 执行轮次较多时,消息历史会迅速膨胀,撑爆上下文窗口。常见做法有两种:
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 截断 | 实现简单,零额外成本 | 早期关键信息(如任务约束"预算3万")会丢失 | 简单问答型 Agent |
| 压缩摘要 | 关键信息保留,长度可控 | 多一次模型调用,摘要质量影响后续推理 | 任务型 Agent(超过10轮必上) |
经验之谈:如果你的 Agent 单次任务超过 10 轮对话,一定要上压缩器,否则任务约束极易丢失。
5.2 滚动摘要压缩器:RollingMemoryCompressor
设计两个关键参数:
-
COMPRESS_THRESHOLD = 20:消息超过 20 条触发压缩; -
KEEP_RECENT = 6:保留最近 6 条原始消息。/**
-
滚动摘要压缩器:消息超过阈值时,把旧消息压缩成一段摘要,
-
摘要 + 最近 N 条消息继续往下跑,不丢信息
*/
@Component
public class RollingMemoryCompressor {private static final int COMPRESS_THRESHOLD = 20; // 超过 20 条时触发压缩
private static final int KEEP_RECENT = 6; // 压缩后保留最近 6 条原始消息private final ChatClient summaryClient;
public RollingMemoryCompressor(@Qualifier("dashScopeChatModel") ChatModel chatModel) {
// 专门用于摘要的轻量 ChatClient,不挂工具,职责单一
this.summaryClient = ChatClient.builder(chatModel).build();
}/**
-
检查消息列表是否需要压缩,需要则执行,返回压缩后的列表
*/
public List<Message> maybeCompress(List<Message> messages) {
if (messages.size() < COMPRESS_THRESHOLD) {
return messages;
}// 把前面的旧消息压缩,保留最近几条
List<Message> toCompress = messages.subList(0, messages.size() - KEEP_RECENT);
List<Message> toKeep = messages.subList(messages.size() - KEEP_RECENT, messages.size());String summary = summarize(toCompress);
// 用摘要替换旧消息,放在消息历史的最前面
List<Message> compressed = new ArrayList<>();
compressed.add(new SystemMessage(
"以下是此前对话的摘要(部分历史已被压缩):\n" + summary));
compressed.addAll(toKeep);return compressed;
}
private String summarize(List<Message> messages) {
StringBuilder history = new StringBuilder();
for (Message msg : messages) {
String role = msg instanceof UserMessage ? "用户" :
msg instanceof AssistantMessage ? "助手" : "系统";
history.append(role).append(": ").append(msg.getText()).append("\n");
}return summaryClient.prompt() .system(""" 请把以下对话历史压缩成一段简洁的摘要。 保留:任务目标、关键决策、重要数据、用户明确表达的偏好和约束。 丢弃:闲聊、重复内容、过程中的细节推理。 摘要用第三人称,限 200 字以内。 """) .user(history.toString()) .call() .content();}
} -
-
关键细节 :摘要专用一个独立的 ChatClient(不挂任何工具),通过 system 提示明确要求保留"任务目标"和"用户偏好"。在摘要的 Prompt 中,任务目标应列为最高优先级保留项。有时摘要模型会认为目标是"废话"而丢弃,导致后续推理偏离方向。
六、长期记忆:让 Agent 真正"认识"用户
6.1 什么该存入长期记忆
不是所有信息都值得长期记忆。三类信息值得存:
- 用户偏好:喜欢简短回答、常用 Python、坐标上海...
- 任务经验:用户查天气通常是为了出行决策,对价格敏感...
- 重要事件:用户提到的重要时间节点、里程碑...
判断标准:跨会话有价值,且不会很快过时。临时性内容(如"今天中午吃什么")不存。
6.2 用大模型做提炼:MemoryExtractionService
不要用关键词匹配(如检测到"记住"就存入)------"记住这个错误别犯了"可能把错误日志当成用户偏好。正确做法是让大模型来判断:
@Service
public class MemoryExtractionService {
private final ChatClient extractorClient;
private final VectorStore vectorStore;
public MemoryExtractionService(
@Qualifier("dashScopeChatModel") ChatModel chatModel,
VectorStore vectorStore) {
this.extractorClient = ChatClient.builder(chatModel).build();
this.vectorStore = vectorStore;
}
/**
* 任务结束后调用:从对话记录中提炼有长期价值的信息
*
* @param userId 用户 ID(记忆按用户隔离)
* @param taskSummary 任务对话的摘要或关键消息
*/
public void extractAndStore(String userId, String taskSummary) {
String extraction = extractorClient.prompt()
.system("""
分析以下对话内容,提炼出值得长期记住的信息。
值得记的(提取出来):
- 用户明确告知的个人偏好、职业、技术栈、公司信息
- 用户的决策模式(关注什么因素)
- 重要的时间节点或计划
不值得记的(忽略):
- 临时性内容(打招呼、确认理解)
- 可以从公开信息查到的内容
- 模型自己的推理过程
如果没有值得记的内容,直接输出:无
如果有,每条单独一行,格式:[类型] 内容
类型可选:偏好 / 背景 / 计划 / 经验
""")
.user(taskSummary)
.call()
.content();
if (extraction.isBlank() || extraction.strip().equals("无")) {
return; // 没有值得记的内容,不写入
}
// 逐条存入向量库
for (String line : extraction.split("\n")) {
line = line.strip();
if (line.isEmpty()) continue;
vectorStore.add(List.of(new Document(
line,
Map.of("userId", userId,
"timestamp", System.currentTimeMillis(),
"source", "memory_extraction"))));
}
}
}
注意几个设计要点:提炼结果为"无"时直接返回不写入;每条记忆附带 userId、timestamp、source 三个元数据,方便后续按用户过滤和按时间排序。
6.3 异步提炼,不阻塞主流程
长期记忆的提炼需要调用大模型 + 写入向量库,耗时较长。最佳实践是在 Agent 返回回答后,异步 执行提炼。在 FullMemoryAgent 中是这样做的:
// 5. 异步提炼本轮对话,写入长期记忆
CompletableFuture.runAsync(() ->
extractor.extractAndStore(userId,
"用户:" + message + "\n助手:" + finalAnswer));
Q:异步提炼时下一次对话来得很快,记忆还没写入怎么办?
大多数场景下这是可以接受的------用户偏好本身就是渐进积累的,偶尔少一条不影响体验。如果要求更严格,可以用 Redis 队列做写入缓冲,再异步刷入向量库。
七、三层记忆的完整链路:FullMemoryAgent
把三层记忆组装到一起,来看 FullMemoryAgent 的完整实现:
@Service
public class FullMemoryAgent {
private final ChatClient chatClient;
private final WorkingMemoryTools workingMemory;
private final RollingMemoryCompressor compressor;
private final MemoryExtractionService extractor;
private final VectorStore vectorStore;
// 按 userId 隔离的对话历史(实际生产用 Redis,这里简化)
private final ConcurrentHashMap<String, List<Message>> sessionHistoryMap = new ConcurrentHashMap<>();
public FullMemoryAgent(
@Qualifier("dashScopeChatModel") ChatModel chatModel,
WorkingMemoryTools workingMemory,
RollingMemoryCompressor compressor,
MemoryExtractionService extractor,
VectorStore vectorStore) {
this.chatClient = ChatClient.builder(chatModel)
.defaultTools(workingMemory) // 挂上工作记忆工具
.build();
this.workingMemory = workingMemory;
this.compressor = compressor;
this.extractor = extractor;
this.vectorStore = vectorStore;
}
public String chat(String userId, String message, int maxIterations) {
// 1. 检索语义记忆(长期记忆),注入系统提示
String longTermContext = recallLongTerm(userId, message);
// 2. 加入当前消息(按 userId 隔离)
List<Message> sessionHistory = sessionHistoryMap
.computeIfAbsent(userId, k -> new ArrayList<>());
sessionHistory.add(new UserMessage(message));
// 3. 超长时压缩历史(不截断,保留摘要)
List<Message> messages = compressor.maybeCompress(sessionHistory);
// 4. 执行 Agent 循环(Agent 会自己用 WorkingMemory 工具管中间状态)
String finalAnswer = runAgentLoop(messages, longTermContext, maxIterations);
// 5. 异步提炼本轮对话,写入长期记忆
CompletableFuture.runAsync(() ->
extractor.extractAndStore(userId,
"用户:" + message + "\n助手:" + finalAnswer));
return finalAnswer;
}
private String recallLongTerm(String userId, String query) {
List<Document> memories = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(3)
.similarityThreshold(0.65)
.filterExpression("userId == '" + userId + "'")
.build());
if (memories.isEmpty()) return "";
return "关于这个用户的背景信息:\n" +
memories.stream()
.map(Document::getText)
.reduce("", (a, b) -> a + "\n- " + b).strip();
}
private String runAgentLoop(List<Message> messages, String longTermContext, int maxIter) {
return chatClient.prompt()
.system(buildSystem(longTermContext))
.messages(messages)
.call()
.content();
}
private String buildSystem(String longTermContext) {
String base = """
你是一个智能助理。你有工作记忆工具可以使用:
- memorize:保存当前任务的中间结果
- recall:检索之前保存的信息
- listMemory:查看当前记忆中所有内容
执行多步任务时,主动用记忆工具管理中间状态。
""";
return longTermContext.isBlank() ? base :
base + "\n\n用户背景(来自长期记忆):\n" + longTermContext;
}
}
Agent 每次处理请求的完整流程:
用户发送消息
│
▼
① 检索长期记忆:根据 userId + 当前消息,从向量库相似度搜索(topK=3, threshold=0.65)
│
▼
② 加载对话历史:从 ConcurrentHashMap 获取当前用户的消息列表,加入本次消息
│
▼
③ 历史压缩:调用 compressor.maybeCompress(),超过 20 条则压缩旧消息为摘要
│
▼
④ Agent 执行:带上长期记忆 + 压缩后的历史 + 工作记忆工具,调用大模型
│
▼
⑤ 返回回答给用户
│
▼
⑥ 异步提炼长期记忆:CompletableFuture.runAsync 调用 extractor.extractAndStore()
最妙的地方在于 :每次对话结束后,Agent 都在悄悄往长期记忆里写东西。用户可能没感知到,但下次来的时候,Agent 已经"认识"他了。这和框架层面的 ChatMemory 有本质区别------前者是"真正的记忆",后者只是"假装有记忆"。
八、调试技巧
在开发 Agent 时,观察工具调用过程非常重要,推荐两种方式:
- SimpleLogAdvisor :在
defaultAdvisors中加入,配合application.yml设置日志级别为DEBUG,可以看到完整的请求和响应日志; - 工具方法内打日志 :在每个
@Tool方法中加log.info(),精准追踪每次工具调用的入参和出参。
实践建议:开发阶段两种都开,上线后精简为仅保留工具内日志,DEBUG 级别仅在排查问题时临时开启。
总结
本文从零构建了一个具备完整记忆能力的 Spring AI Agent,核心要点回顾:
- 构建 Agent :Spring AI 通过
@Tool+ChatClient自动处理 ReAct 循环,开发者只需注册工具、写好系统提示词(声明能力、原则、边界); - 会话记忆 :通过
MessageChatMemoryAdvisor+MessageWindowChatMemory+sessionId实现多轮对话和会话隔离; - 工作记忆 :
WorkingMemoryTools将记忆操作封装为memorize/recall/listMemory三个工具,Agent 自主决定存取,解决多步任务中中间结果丢失的问题; - 历史压缩 :
RollingMemoryCompressor超过 20 条消息时用独立 ChatClient 生成摘要替换旧消息,既省 Token 又保留关键约束; - 长期记忆 :
MemoryExtractionService用大模型从对话中提炼有价值的用户信息,存入向量库,FullMemoryAgent在每次请求时检索并注入系统提示。
记住两个实践原则:任务型 Agent 超过 10 轮必上压缩器 ;长期记忆用模型判断而非硬编码。掌握了这套三层记忆架构,你的 Agent 就不再是"金鱼记忆",而是一个真正能积累经验、越用越懂你的智能助手。