ARTICLE · 999097
手搓一个 Agent10:从文档问答到生产级智能体
✦ 干货分享 ✦
第10章 给 Agent 记忆:会话状态与上下文延续
◆
"
本专栏由「Java + Spring AI」保姆级连载,配套源码见文末。本章配套代码:memory-chat 模块。
"
上一章我们让 Agent 能干多步活了——会思考、会调用工具、会一步一步把任务推进下去。看起来挺像那么回事了,对吧?
但今天我要给你泼一盆冷水:你上章做出来的 Agent,其实是一条金鱼。
金鱼记忆只有七秒——不对,金鱼好歹还能记七秒。我们上章那个 Agent 更惨,它每句话的"生命"只有一轮。对不起,可能连七秒都不到。
来,我们做个实验。假设第9章你做了个算增长率的 Agent,它辛辛苦苦计算完:"你去年Q4营收是100万,Q1是120万,同比增长20%。"你心里那个美啊,接着问它:"那照这个趋势,明年大概能到多少?"
结果它一脸茫然:"您说的'这个趋势'是哪个趋势?Q4和Q1是啥?我这边没有任何信息。"
你是不是想当场把显示器摔了。问题不在它算得不对,在于——它根本不记得你刚跟它说过什么。
这就是本章要解决的核心问题:让 Agent 记住你们之间聊过的内容,接得住上下文。
01为什么需要记忆?——跟同事说了三遍的话,你什么感受?
先想一个生活场景。
你跟同事小王对接项目,同一个需求你讲了三遍:第一遍他点头,第二遍他"哦对",第三遍……他第四遍又问你要一遍。你的内心活动是什么?
"我不是刚跟你说过吗?""这个不是上礼拜开会定下来的吗?""你到底有没有在听我说话?"
对,就是这个感觉。一个助手如果没有记忆,它跟你的每一次对话都像第一次见面,你得把背景从头到尾交代清楚。一次两次还能忍,天天这样,谁受得了?
有记忆的 Agent 叫'陪伴助手',没记忆的只能叫'一次性问答机'。
问答机的使用方式永远是"一句话问,一句话答"——Google一下这就是问答机。而陪伴助手是"我们正在一起处理一件事",它记得背景、记得偏好、记得你刚才说过"先不要动数据库那部分",然后下一条指令还能在正确的语境里继续。
你的业务要的,永远是后者。
那这个问题在 Spring AI 里怎么解?主角登场——ChatMemory。
02Spring AI 的 ChatMemory:MessageChatMemoryAdvisor
Spring AI 1.0 里,记忆的核心组件叫 MessageChatMemoryAdvisor,它位于 org.springframework.ai.chat.client.advisor 包下。
先别被"Advisor"这个词吓到。Advisor 是 Spring AI 的"钩子机制"——它可以挂在请求的前后,做各种横切处理。你上章接触过的日志、审计,其实本质也可以做成 Advisor。而 MessageChatMemoryAdvisor 干的很单纯一件事:
- 每次请求发出前
,它把此前的历史对话消息,悄悄注入到 prompt 里,拼在用户新问题前面; - 每次请求完成后
,它把刚产生的一问一答,存进记忆仓库(ChatMemory)。
一句话总结它的工作原理:请求前"取历史塞进 prompt",请求后"把新对话存进记忆"。 我们作为使用者,什么都不用动,只要把它挂上去。
挂上去就一行:
1ChatClient chatClient = ChatClient.builder(chatModel)
2 .defaultAdvisors(
3 MessageChatMemoryAdvisor.builder(chatMemory).build()
4 )
5 .build();
编译过了,跑通了,Agent 就"记性"上身了。
但等等,还有个问题:这个 chatMemory 是什么?上面代码可还没定义它。
03存哪?——先别急着上数据库
chatMemory 是 ChatMemory 接口的实例。它管的就是"记忆放哪、怎么取"。
这里我要敲一下黑板,强调一个老原则——我们在第3章讲过"别过早持久化",今天兑现它。
现在很多人写 Agent,一上来就想"记忆肯定要存数据库啊"。错。理由非常现实:
你的记忆结构还没稳定。
历史对话里到底该存哪些字段?工具调用要不要记录?系统提示词算不算历史?中期要不要做压缩?这些问题你还没想明白,记忆的表结构、Redis 的 key 设计就都是空中楼阁。你一旦现在定了 schema、上了持久化,就等于把一个还没定型的结构固化死了——后面想改,就得再踩一轮迁移的坑。
所以正确的姿势是:先用内存把它跑通,原理清楚了,再考虑持久化。
Spring AI 1.0 里有个开箱即用的内存实现叫 InMemoryChatMemory。它的实现思路特别朴素——内部就是一个线程安全的 Map,key 是会话 ID,value 是消息列表。自己手写也就这几行:
1@Component
2publicclassInMemoryChatMemoryimplements ChatMemory {
3
4// conversationId -> 消息列表
5privatefinal ConcurrentMap<String, List<Message>> store = new ConcurrentHashMap<>();
6
7privatefinal AtomicInteger seq = new AtomicInteger();
8
9@Override
10publicvoidadd(String conversationId, List<Message> messages) {
11 store.computeIfAbsent(conversationId, k -> synchronizedList(new LinkedList<>()))
12 .addAll(messages);
13 }
14
15@Override
16public List<Message> get(String conversationId, int lastN) {
17 List<Message> all = store.getOrDefault(conversationId, emptyList());
18return all.subList(max(0, all.size() - lastN), all.size());
19 }
20
21@Override
22publicvoidclear(String conversationId) {
23 store.remove(conversationId);
24 }
25}
说实话,我更推荐你直接用框架现成的 InMemoryChatMemory,不必自己造轮子。上面这段给你看的是"本质就这么回事"——别把它想得有多神秘。
那么,什么是会话ID? 这就引出下一个关键问题:谁和谁的对话,绝不能混。
04多会话隔离:别让用户A看到用户B的对话
想象一下你的 Agent 上线了,两个用户同时在用。
用户甲问:"帮我写个相亲简历。" 用户乙问:"帮我算下房贷月供。" 然后甲又问:"按刚才那个来一版加幽默风格的。"
如果甲和乙共用同一个记忆,会发生什么?甲这句"按刚才那个来",鬼知道"刚才"指的是谁刚才。串了。更糟的是隐私问题——用户A的对话被塞给了用户B,这在真实系统里是事故级别的 bug。
所以记忆仓库必须按会话隔离。怎么隔离?靠一个 conversationId(会话 ID)。Spring AI 里通常这么传:
1ChatClient chatClient = ChatClient.builder(chatModel)
2 .defaultSystem("你是一个贴心的助手")
3 .defaultAdvisors(
4 MessageChatMemoryAdvisor.builder(chatMemory).build()
5 )
6 .build();
7
8// 每次调用时,把当前会话的ID放进 advisorContext
9String response = chatClient.prompt()
10 .user("帮我写个相亲简历")
11 .advisors(a -> a
12 .param(ChatMemory.CONVERSATION_ID, sessionId))
13 .call()
14 .content();
关键就这一句:.param(ChatMemory.CONVERSATION_ID, sessionId)。
这个 sessionId 就是从你业务层传过来的、每个会话全局唯一的标识。InMemoryChatMemory 按它做 key,MessageChatMemoryAdvisor 取历史/存历史都走它,天然就把不同会话隔开了。
在日常接口里,sessionId 通常由后端生成并下发,前端每次请求带回来。比如:
1@PostMapping("/chat")
2public String chat(@RequestBody ChatRequest req) {
3// req.sessionId 由前端回传;新会话则为空,由后端生成一个新的 UUID
4 String sessionId = (req.sessionId() == null || req.sessionId().isBlank())
5 ? UUID.randomUUID().toString()
6 : req.sessionId();
7return chatClient.prompt()
8 .user(req.message())
9 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
10 .call()
11 .content();
12}
这样每个用户的对话各自成链,互相不干扰。记住:会话 ID 必须每会话唯一,绝不能用用户名、固定值或 IP,否则必然串话。
05对话窗口管理:历史全塞,Token 会炸
现在记忆有了,也隔离了。但你马上会遇到新麻烦。
Agent 每轮都会把"全部历史"塞进 prompt。这一开始没事,用户聊个三四轮,五六轮,还没感觉。可一旦长,问题就来了:
历史消息几百轮,全塞进去,你的 token 预算很快打满; 更糟的是,会突破模型的上下文窗口上限,直接报错"This model's maximum context length is ..."; 就算没报错,历史越滚越长,模型的理解也会被大量无关的旧内容干扰,反而变笨。
所以记忆不是越高越好,得管理。本章先给你最朴素、也最立竿见影的思路:滑动窗口截断。
思路一句话:只留最近 N 轮对话,更早的直接丢。ChatMemory 的 get 方法里,其实已经带了"取最近多少条"的参数——就是上面那个 lastN。所以窗口控制甚至可以在取的时候做:
1MessageChatMemoryAdvisor.builder(chatMemory)
2 .chatMemoryRetrieveSize(20) // 每次最多从记忆里取最近 20 条
3 .build();
chatMemoryRetrieveSize 就是你的窗口大小,默认是100。改成 20,意味着这个会话再怎么长,每次实际注入 prompt 的历史也不会超过最近 20 轮。旧对话不是"删了",是"不喂给模型了"——随时还能调大窗口再取回来(前提是内存实现里还留着)。
这是最简单的一层:控制数量。 但要注意,滑动窗口只砍"数量",砍不动"长度"——某一条历史本身特别长(比如贴了你一篇几万字的文档),截断也救不了。所以还有第二层:对早期内容做摘要压缩,把老对话总结成几句话再留着。这是更进阶的思路,我把它放到第12章专门讲。本章你先把窗口这层做对,就已经躲开 80% 的 token 爆炸了。
06记忆 vs 知识库:读者最容易混的一对概念
写到这里,必须停下来严肃澄清一个东西。因为做 Agent 的人里,绝大多数都在这上面栽过跟头——把记忆和知识库搞混。
很多新手一脸困惑:"我上章不是已经做了 RAG、存了 PDF 吗?那不就叫记忆吗?你还让我存记忆干嘛?"
这是两个完全不同的事。我先甩一张对比表,再讲人话。
| ChatMemory | VectorStore |
人话版本就三句:
- 你上传的那份 PDF 是知识库,不是记忆。
它是你喂给 Agent 的"参考资料",和你们俩聊了啥没关系,下次对话它还在。 - 你俩刚才聊的那几句话是记忆,不是知识库。
它跟这篇文档半毛钱关系没有,纯粹记录"我们刚聊到哪了"。 - 知识库回答"之前懂不懂",记忆解决"刚才说了啥"。
一个管"长期记得的资料",一个管"这次对话的上下文"。
一句话记牢:记忆是"会话内的短期上下文",知识库是"会话外的长期资料"。 一个是"唠嗑记录",一个是"图书资料室"。两者可以并存、可以协同——但绝不是一回事。很多人部署时把图纸、手册塞进"记忆",又把对话历史灌进"知识库",全搞反了,跑出来的效果一团糟。
07产物:跨轮对话能接上上下文
理论讲完了,我们把前面所有串起来,跑一个完整的效果。
接入记忆后,同一会话里的对话是这样的:
用户:帮我把上季度的周报整理成一页纸,重点突出增长和风险。
"
(Agent 内部取出历史为空,正常回答,并把这轮追问/回答存进记忆。)
"
用户:再精简一下,压缩到三行。
"
(Agent 内部取出历史 = 刚才那轮,看到"周报""增长""风险",直接把"一页纸"精简成三行。)
"
如果没记忆呢?第二条"再精简一下"进来,Agent 只会一脸懵:"'再'?哪个再?你要精简的东西我这儿可没有啊。"
接得住上下文,靠的就是那个"内部取出历史"的动作——记忆在背后默默把前几轮塞进了 prompt。
「刚才那份周报再精简下」这种最朴素的自然语言指令能成立,恰恰是记忆让用户产生"这助手真的在跟我对话"感觉的瞬间。这就是本章的最终产物:跨轮对话能接上下文。
08本章踩坑
每一章我都会把最常见的坑列出来。格式照旧:现象 → 根因 → 解法。
坑一:长对话 token 爆炸
- 现象
:聊到第 8 轮起,开始报错 "context length exceeded",或者开销暴涨。 - 根因
:MessageChatMemoryAdvisor 默认把全部历史塞进 prompt,对话越长,token 呈线性增长。 - 解法
:上滑动窗口,设 chatMemoryRetrieveSize 只留最近 N 轮(本章)。想要更高效率、保留更多语义,就做摘要压缩(第12章)。
坑二:sessionId 忘传或用固定值,多用户串话
- 现象
:用户 A 刷着刷着,突然看到了用户 B 的对话历史;或多人共用时上下文变得乱七八糟。 - 根因
:所有请求用了同一个 memory(同一个会话 key),大家贴着同一个"大一统"记忆在聊。 - 解法
:每会话一个全局唯一 ID(UUID 为上),由后端生成、前端回传;通过 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) 传入。别用用户名、IP、固定值。 上线前务必做并发多用户的验证。
坑三:System Prompt 该放记忆里还是每次重发?
- 现象
:系统提示词和对话历史混在一起,滚动窗口把系统提示词也裁掉了,结果 Agent 忘了自己的身份或规则。 - 根因
:把"角色设定"误当成了"可变历史"。 - 解法
:系统提示词应'高于记忆'。它定义 Agent 是谁、守哪些规矩,属于常量,应每轮固定带上(defaultSystem(...)),且不参与滑动窗口的计数与裁剪。窗口截断只作用于"用户问 + 助手答"的可变历史,别让系统提示被卷进去当炮灰。
坑四:记忆里要不要存工具调用记录?
- 现象
:Agent 一边调工具一边存历史,token 涨得飞快;又不存,Agent 老重复做已经做过的事。 - 根因
:工具调用的完整参数、返回结果占了大头 token。 - 解法
:看利弊权衡——存有好处(Agent 知道"我前面做过什么",减少重复动作),但全量存伤不起。建议只存精简摘要(如"已调用 queryOrder,参数 orderId=xxx,返回 200"),丢弃冗长的原始返回体。后续若做摘要压缩(第12章),工具调用也可以一并压缩。
09下章预告:落地产物,文件读写工具
这一章,Agent 终于有了记性,能和你在同一件事里来回周旋了。
但对话终究是"嘴上说说"。咱做个周报 Agent,光会聊有什么用?用户要的是"把周报写成文件、落到盘上"。第11章,我们让 Agent 真正动手——给 Agent 加上文件读写工具,让它能读文件、改文件、把产物落地成 .md / .txt。
到时候,配上这一章的记忆,你会得到一个有手有脚、还记得住你说过什么的 Agent。下章见。
源码示例对照(本章可用)
1@Configuration
2publicclassMemoryChatConfig {
3
4@Bean
5 ChatMemory chatMemory() {
6// 1.0 自带的内存实现,内部即线程安全 Map
7returnnew InMemoryChatMemory();
8 }
9
10@Bean
11 ChatClient memoryChatClient(ChatModel chatModel, ChatMemory chatMemory) {
12return ChatClient.builder(chatModel)
13 .defaultSystem("你是一个贴心、严谨的助手")
14 .defaultAdvisors(
15 MessageChatMemoryAdvisor.builder(chatMemory)
16 .chatMemoryRetrieveSize(20) // 滑动窗口:仅保留最近 20 条
17 .build()
18 )
19 .build();
20 }
21}
"
提示:示例为了可读省了部分 import。InMemoryChatMemory、MessageChatMemoryAdvisor 都来自 Spring AI 1.0 的 spring-ai 核心模块,build 方式与构造方式均可。
"