夜雨聆风学习资料网

ARTICLE · 999097

手搓一个 Agent10:从文档问答到生产级智能体

手搓一个 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 / RAG)
数据是什么
你和 Agent 的对话历史
你的业务资料/文档
本质
会话上下文、短期记忆
长期的外部知识
生命周期
跟随这个会话,会话结束就没了
独立存在,与你聊了几轮无关
检索方式
按会话 ID 直接取序列
向量化后按"语义相似度"检索相近内容
存的是什么
"你刚才说了什么"
"你让 Agent'长期记住'的资料"
用途
让对话能接上下、不重问
让 Agent 知道它没训练过的事实
对应 Spring 组件
ChatMemory
 / MessageChatMemoryAdvisor
VectorStore
 / RAG(第7-8章)

人话版本就三句:

  • 你上传的那份 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 方式与构造方式均可。

"

相关学习资料