本章内容
- 维护对话状态
- 基于内存的聊天历史
- 长期记忆的保留
你看过电影《初恋50次》吗?片中 Drew Barrymore 扮演的 Lucy 因车祸导致短期记忆受损,每天醒来记忆都会回到事故前的那一天,前一天发生的一切都会被清空。
同一部电影里还有个角色叫“10 秒汤姆”。他因打猎事故导致记忆每 10 秒重置一次。Lucy 稍长一点的记忆推动了剧情,而汤姆的情况更具喜感——他每 10 秒就会重新自我介绍:“嗨,我是汤姆。”
尽管大型语言模型(LLM)看起来很聪明、几乎无所不知,但它们也有与 Lucy 和“10 秒汤姆”相似的记忆问题。事实上,从某种意义上说,Lucy 和汤姆的记忆还“强”过 LLM:LLM 在一次生成(一次请求)完成后就不会保留任何记忆——LLM 是无状态服务。
这种极端的“短期记忆缺失”让与 LLM 进行连贯对话变得困难。比如你先问“天空为什么是蓝色?”,接着又问“它会变成橙色吗?”。第二个问题中的“它”指代什么,LLM 并不知道,因为它已经忘了你刚才讨论的是“天空”。
好在 Spring AI 为这种超短记忆提供了解法,而且与它在 RAG 中用 QuestionAnswerAdvisor 的思路很相似——也是通过 advisors 实现。下面来看看它是如何工作的。
5.1 让 AI “记住”对话
当用户在一段对话中向 LLM 提问时,这条承载问题的 user 消息会被存入某种记忆介质(Spring AI 中由 ChatMemory 接口定义)。随后,当 LLM 返回答案时,这条 assistant 消息也会被保存备用。
下次用户再提问时,依然会重复以上过程,不过在发送新问题之前,系统会先把该会话中“过往的 user/assistant 消息”从记忆中取出,并像 RAG 把文档放入上下文那样,把聊天历史写入提示(prompt)上下文里(图 5.1)。
这样,整段对话就被保存为“用户—助手”的来回脚本,并在每次请求前回放给 LLM。LLM 由此被“提醒”此前说过什么,得以延续话题、保持上下文一致。
Spring AI 将“会话记忆管理”做成了 advisors。具体提供了三种,各自采用不同策略维护聊天历史:
- MessageChatMemoryAdvisor
- PromptChatMemoryAdvisor
- VectorStoreChatMemoryAdvisor
MessageChatMemoryAdvisor 与 PromptChatMemoryAdvisor 都会通过某个 ChatMemory 实现来保存历史,不同点在于“如何把历史写回到提示里”:
MessageChatMemoryAdvisor以“多条消息”的形式(区分 user/assistant 角色)加入 prompt。- 但并非所有模型都支持“带角色的多消息”。对这些模型,
PromptChatMemoryAdvisor会把历史拼成一段大字符串,注入到 system 消息模板中。
(图 5.1 对话记忆:将用户与 LLM 的交互保留下来,作为后续提示的参考。)
VectorStoreChatMemoryAdvisor 则完全不同:它把聊天历史存入向量库,在每次提问时像 RAG 一样检索,与当前问题最相似的历史片段被选出并作为字符串注入到 system 模板中(与 PromptChatMemoryAdvisor 的注入方式相同)。
为了演示这些 advisor 的用法,我们将为 “Board Game Buddy(桌游助手)” 应用加上会话记忆,看看它们如何让 LLM 记住上下文、正确理解指代。
5.2 为应用加入会话记忆
现实里,问规则往往不是“一问一答”就结束,通常会紧跟若干追问。以《Burger Battle》为例,你可能先问一张战斗牌,再追问使用细节,比如:
- “什么是 Burger Force Field 这张牌?”
- “它能防住 Burgerpocalypse 吗?”
虽然只有两问,但已经体现出会话记忆的重要性:第一个问题清楚指明了主题(Burger Force Field 牌),第二个问题用代词“它”,如果没有第一问的上下文,第二问就不清楚指代对象。
MessageChatMemoryAdvisor 与 PromptChatMemoryAdvisor 都能很好地处理这类多轮对话:把每次的用户问题与模型回答存起来,并在下次提问时把脚本回放给 LLM。
下面我们把这些 advisor 作为 默认 advisor 配在 ChatClient 上。
5.2.1 启用“内存中的”聊天历史
如果你正期待为此写一堆代码,可能要失望了;但如果你喜欢“改一行见奇效”,那接下来会很爽。
实际上,给应用加上聊天记忆,只需要在创建 ChatClient 时再加一个默认 advisor。如下所示:
清单 5.1 将基于消息的聊天记忆作为默认 advisor 添加
@Bean
ChatClient chatClient(
ChatClient.Builder chatClientBuilder,
VectorStore vectorStore,
ChatMemory chatMemory) {
return chatClientBuilder
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().build()).build())
.build();
}
虽然“背后”做了不少工作,但从代码层面看,只是把 MessageChatMemoryAdvisor 加进了 defaultAdvisors()。它接收注入进来的 ChatMemory(自动配置为 MessageWindowChatMemory 的实例),与上一章用到的 QuestionAnswerAdvisor 一并成为默认 advisor。默认情况下,MessageWindowChatMemory 使用内存实现保存会话历史。
你也可以选择非常类似的方式使用 PromptChatMemoryAdvisor:
@Bean
ChatClient chatClient(
ChatClient.Builder chatClientBuilder,
VectorStore vectorStore,
ChatMemory chatMemory) {
return chatClientBuilder
.defaultAdvisors(
PromptChatMemoryAdvisor.builder(chatMemory).build(),
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().build()).build())
.build();
}
无论选择哪一个,要点都是:当创建 advisor 时传入了 MessageWindowChatMemory。该实现依赖 ChatMemoryRepository 存储消息。默认的 ChatMemoryRepository 把对话历史放在内存里的 Map 中——这意味着应用重启后不会保留历史。不过对于很多场景(尤其是入门与开发态),这种做法足够好用。
现在给“桌游助手”加上了“记忆力”,来试一试吧。启动应用,并确保向量库里已经导入了《Burger Battle》的规则,然后这样发问:
$ http :8080/ask gameTitle="Burger Battle" \
question="What is the Burger Force Field card?" -b
{
"answer": "The Burger Force Field card is a Battle Card that protects
your Burger from all other Battle Cards.",
"gameTitle": "Burger Battle"
}
很好!接着来一个需要“前文语境”的追问:
$ http :8080/ask gameTitle="Burger Battle" \
question="Does it protect against Burgerpocalypse?" -b
{
"answer": "The Burger Force Field card does not protect against
Burgerpocalypse.",
"gameTitle": "Burger Battle"
}
虽然第二问没有再次提到 “Burger Force Field”,答案仍然基于它作出判断。这说明会话记忆已生效——而你只做了一个小改动就达成了目标!
接下来,我们“掀开引擎盖”,看看这些聊天历史是如何被写入每次请求的 prompt 的。
5.2.2 检视用于聊天记忆的提示(prompt)
到目前为止,你用 user 消息承载用户提出的问题与文本,用 system 消息承载应用本身给 LLM 的指令。而在使用聊天记忆(chat memory)类的 advisor 时,你也会用到 assistant 消息来代表 LLM 的响应。聊天历史就是一组 user 与 assistant 消息的集合:user 消息是某次提问,assistant 消息是相应的回答。
在 MessageChatMemoryAdvisor 与 PromptChatMemoryAdvisor 这两种方式下,user 与 assistant 消息的存储方式相同;两者的关键差异在于:如何把这些已存的历史消息重新放入 prompt 中发给 LLM。
检视 MessageChatMemoryAdvisor 生成的 prompt
对 MessageChatMemoryAdvisor 而言,历史中的 user/assistant 消息会原样作为多条消息放入 prompt。
例如对话刚开始时,聊天记忆中还没有任何消息,因此当用户第一次询问 “Burger Force Field 这张牌是什么?” 时,发送给(OpenAI GPT-4o)的请求 JSON 形如:
{
"messages" : [ {
"content" : [ {
"type" : "text",
"text" : "What is the Burger Force Field?\nContext information
is below.\n---------------------\n
HOW TO PLAY\n\nBURGER BATTLE\n\n2-6 plavers ... "
} ],
"role" : "user"
} ],
"model" : "gpt-4o",
"stream" : false,
"temperature" : 0.7
}
注意 "messages" 里只有一条消息:一条 user 消息,既包含当前问题,也包含(为 RAG 提供的)上下文信息(此处为节选)。
当第一次问答完成后,该 user 消息(问题)与 assistant 消息(答案)会被写入聊天记忆。随后用户再追问时,prompt 的 JSON 会变成这样:
{
"messages" : [ {
"content" : [ {
"type" : "text",
"text" : "What is the Burger Force Field?"
} ],
"role" : "user"
}, {
"content" : [ {
"type" : "text",
"text" : "{\n "answer": "The Burger Force Field is a Battle
Card that protects your Burger from all other Battle Cards.
",\n "gameTitle": "Burger Battle"\n}"
} ],
"role" : "assistant"
}, {
"content" : [ {
"type" : "text",
"text" : "Does it protect against Burgerpocalypse?\nContext information
is below.\n---------------------\n
HOW TO PLAY\n\nBURGER BATTLE\n\n2-6 plavers ... "
} ],
"role" : "user"
} ],
"model" : "gpt-4o",
"stream" : false,
"temperature" : 0.7
}
现在 "messages" 有三条:第一条是最初的问题(user) ,第二条是刚才的回答(assistant) ,第三条是**当前的新问题(user)**并带有 RAG 上下文。此后每多问一次,都会再增加两条:上一问的 assistant 答案 + 这一问的 user 问题。
检视 PromptChatMemoryAdvisor 生成的 prompt
对 PromptChatMemoryAdvisor 来说,不会把每一轮的 user/assistant 作为多条消息加入;而是把整段对话拼成一个字符串,注入到 system 消息里作为上下文。
假设对话刚开始、聊天记忆为空。第一次提问后的 prompt 如下:
{
"messages" : [ {
"content" : [ {
"type" : "text",
"text" : "\nUse the conversation memory from the MEMORY section to
provide accurate answers.\n\n---------------------\nMEMORY:\n
---------------------\n\n"
} ],
"role" : "system"
}, {
"content" : [ {
"type" : "text",
"text" : "What is the Burger Force Field?\nContext information
is below.\n---------------------\nHOW TO PLAY\n\nBURGER ..."
} ],
"role" : "user"
} ],
"model" : "gpt-4o",
"stream" : false,
"temperature" : 0.7
}
此时有两条消息:一条 system 消息(告知 LLM 使用“MEMORY 段”的会话记忆),一条 user 消息(用户问题 + RAG 上下文)。由于是第一轮,会话记忆为空。
第二次追问时,system 消息中会带上已有的会话记忆,prompt 变为:
{
"messages" : [ {
"content" : [ {
"type" : "text",
"text" : "\nUse the conversation memory from the MEMORY section to
provide accurate answers.\n\n---------------------\nMEMORY:\n
USER:What is the Burger Force Field?\n
ASSISTANT:{\n "answer": "The Burger Force Field is a Battle
Card that protects your Burger from all other Battle Cards.",\n
"gameTitle": "Burger Battle"\n}\n---------------------\n\n"
} ],
"role" : "system"
}, {
"content" : [ {
"type" : "text",
"text" : "Does it protect against Burgerpocalypse?\nContext information
is below.\n---------------------\nHOW TO PLAY\n\nBURGER BATTLE..."
} ],
"role" : "user"
} ],
"model" : "gpt-4o",
"stream" : false,
"temperature" : 0.7
}
仍然只有两条消息,但 system 消息里已经内嵌了上一轮的 user 提问与 assistant 回答文本,LLM 会据此理解并回答新的 user 问题。
和 MessageChatMemoryAdvisor 类似,会话记忆会随对话推进而增长。不同之处是:这里不是不断增加多条消息,而是system 消息中的文本越来越长,承载整段对话脚本。
当只问几轮时,这不是问题;但对话越长,聊天历史越大,作为上下文发送时也会占用更多 token,导致成本上升;对话极长时,还可能超过上下文窗口。为避免聊天历史无限膨胀,下面看看如何控制发送到 prompt 中的历史长度。
5.2.3 配置聊天记忆的大小
即便你什么都不做,Spring AI 也默认帮你限制了注入到 prompt 的聊天历史长度:默认只发送最近 20 条消息。当用户与助手之间累计达到 50 次往返后,较早的消息会被丢弃,以保证注入到 prompt 的消息数不超过 20。
当然你可以调整这个上限:需要更多上下文时调大;想控制 token 成本时调小。做法是覆盖自动配置的 ChatMemory bean,给 MessageWindowChatMemory 设定自定义参数。
例如你想把聊天历史上限设为 50,可以显式定义一个 @Bean 来创建 ChatMemory,并在其 builder 上调用 maxMessages():
@Bean
ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(50)
.build();
}
验证这个上限可能略显繁琐:你需要进行较长对话并检查 prompt。但例如在连续问了 25 次并得到 25 个回答后,第 26 次再问时,第一轮的问题就不会再被带入上下文(已被“滑窗”淘汰)。旧消息会随着新消息的加入而被丢弃。一般而言,最近的对话内容已经足够支撑语义理解与指代解析。
接下来,你还可以设置会话 ID 来同时管理多段并行对话。
5.3 指定会话 ID(Conversation ID)
每段对话都关联一个会话 ID。若不额外指定,默认值为 default。这在应用只有一个用户时尚可,但在多数场景你会有很多用户,需要将他们的对话彼此隔离。因此,你需要为每个会话分配一个唯一的会话 ID。
会话 ID 可以是任意用于区分会话的字符串:例如用户名、会话 Session ID,或某个请求头的值(由客户端传入)。下面的示例展示了如何修改控制器的 ask() 方法,从名为 X_AI_CONVERSATION_ID 的自定义请求头中提取会话 ID:
代码清单 5.2 从请求头获取会话 ID
@PostMapping(path = "/ask", produces = "application/json")
public Answer ask(
@RequestHeader(name="X_AI_CONVERSATION_ID",
defaultValue = "default") String conversationId,
@RequestBody @Valid Question question) {
return boardGameService.askQuestion(question, conversationId);
}
注意:X_AI_CONVERSATION_ID 是为本应用定义的自定义请求头,并非 Spring AI 的特定要求;你可以按需命名。
从请求头拿到会话 ID 后,将其与问题一起传给 SpringAiBoardGameService 的 askQuestion()。这意味着服务接口需要接收会话 ID,并在内部用于跟踪会话。首先,修改 BoardGameService 接口以接收会话 ID:
package com.example.boardgamebuddy;
public interface BoardGameService {
Answer askQuestion(Question question, String conversationId);
}
接着对 SpringAiBoardGameService 做相应修改。如下清单展示了向 advisor 传入会话 ID 的做法。
代码清单 5.3 向 advisor 提供会话 ID
import static org.springframework.ai.chat.memory
.ChatMemory.CONVERSATION_ID;
// ...
@Override
public Answer askQuestion(Question question, String conversationId) {
var gameNameMatch = String.format(
"gameTitle == '%s'",
normalizeGameTitle(question.gameTitle()));
return chatClient.prompt()
.system(systemSpec -> systemSpec
.text(promptTemplate)
.param("gameTitle", question.gameTitle()))
.user(question.question())
.advisors(advisorSpec -> advisorSpec
.param(FILTER_EXPRESSION, gameNameMatch)
.param(CONVERSATION_ID, conversationId))
.call()
.entity(Answer.class);
}
在上一版的 askQuestion() 中,你通过 advisors() 指定使用 QuestionAnswerAdvisor 来执行该请求的 RAG。本版仍然如此,但又额外调用了一个接受 Consumer<AdvisorSpec> 的 advisors() 重载,用它来把会话 ID作为参数传给各个 advisor。CONVERSATION_ID 常量提供了统一的键名,与内部聊天记忆 advisor 的实现保持一致,你无需自己记忆具体键名。
至于会话 ID 的值,控制器的 ask() 使用 @RequestHeader 注解从 X_AI_CONVERSATION_ID 请求头中提取;如果请求头不存在,则使用 default。随后 ask() 会把这个会话 ID 一并传给服务层的 askValue()(此处即 askQuestion())。
现在重启应用试一试。为确保会话 ID 生效,请在请求中带上该请求头。使用 HTTPie 时,可以用 名字:值 的形式指定请求头。以下示例在会话 conversation_1 中提交首个问题:
$ http :8080/ask gameTitle="Burger Battle" \
question="What is the Burger Force Field card?" \
X_AI_CONVERSATION_ID:conversation_1 -b
{
"answer": "The Burger Force Field card is a Battle Card in the Burger
card game that allows a player's Burger to be protected from
all other Battle Cards.",
"gameTitle": "Burger Battle"
}
一切正常。现在再问一个上下文相关的追问:
$ http :8080/ask gameTitle="Burger Battle" \
question="Does it protect against Burgerpocalypse?" \
X_AI_CONVERSATION_ID:conversation_1 -b
{
"answer": "The Burger Force Field card does not protect against
Burgerpocalypse as it specifically states that all players'
ingredients are destroyed, regardless of protection.",
"gameTitle": "Burger Battle"
}
如前,LLM 能够从会话历史中推断“it”指的是 “Burger Force Field card”,从而给出正确回答。
接下来我们“绊”一下它:把会话切换为 conversation_2,但问题仍然模糊地用 “it” 指代:
$ http :8080/ask gameTitle="Burger Battle" \
question="How do you remove it?" \
X_AI_CONVERSATION_ID:conversation_2 -b
{
"answer": "To remove ingredients from your Burger, you can play Battle
Cards such as Burger Bomb or Picky Eater, which send
ingredients to the Graveyard.",
"gameTitle": "Burger Battle"
}
这次它虽然给了答案,但同样缺少上下文:谈到了如何移除配料。由于这是一段全新的会话,没有任何历史帮助它知道你指的是 “Burger Force Field card”,因此只能自行推断(且推断错误)。
由此可见,使用 MessageChatMemoryAdvisor 或 PromptChatMemoryAdvisor 为 Spring AI 应用增加聊天记忆非常容易,也足以作为对话功能的起点。但它们是否可直接用于生产?
最大的问题在于:当前用到的 MessageWindowChatMemory 依赖的默认 ChatMemoryRepository 是内存实现,无法在应用重启后保留,也无法在多实例之间共享。并且,随着会话增长,历史会持续占用应用内存,直到重启才释放。
因此,在生产环境中不推荐用默认内存实现来持久化聊天历史。接下来,我们看看如何为 Spring AI 应用添加持久化的聊天记忆,使其在重启后仍可恢复、在多实例间共享,并避免无限制占用内存。
5.4 启用持久化聊天记忆
给 Board Game Buddy 加上聊天记忆并不意味着让 LLM 自带记忆;而是在应用层面辅助,使其能够进行多轮对话、回顾过往,并据此生成下一步回应。相比“10 秒汤姆”,它已摆脱极短期失忆;但仍像《初恋 50 次》中的露西——白天能记住,晚上“睡一觉”就全忘了:使用 MessageWindowChatMemory 的默认内存实现时,应用一旦停止再启动,就会丢失全部记忆。
Spring AI 提供了两种避免“露西式”记忆丢失的方案:
- 使用 Spring AI 提供的持久化型
ChatMemoryRepository实现; - 使用 VectorStoreChatMemoryAdvisor。
先来看如何使用 Spring AI 的持久化 ChatMemoryRepository。
5.4.1 将聊天记忆持久化到数据库
前文提到,MessageWindowChatMemory 默认使用的 ChatMemoryRepository 实现会把聊天条目存放在内存中。如果你能替换为将聊天条目持久化到更可靠存储的 ChatMemoryRepository 实现,那么对话历史就能跨应用重启而保留。虽然你可以自己实现一个,但 Spring AI 已内置了三个可选实现:
CassandraChatMemoryRepositoryJdbcChatMemoryRepositoryNeo4jChatMemoryRepository
并且由于自动配置的存在,把它们接入项目非常容易。下面先看如何在 Spring AI 应用中使用基于 Cassandra 的聊天记忆。
Cassandra
Apache Cassandra 是一款流行的高吞吐 NoSQL 数据库;它具备高可扩展性,设计用于多节点数据存储。其数据结构介于表格和键值存储之间。查询语言为 CQL(Cassandra Query Language),与传统关系型数据库的 SQL 相当相似。
Spring AI 的 CassandraChatMemoryRepository 用于把聊天历史保存到 Cassandra 集群。即使你对 Cassandra 并不熟悉,也可直接使用它——自动配置屏蔽了底层细节。
使用 Cassandra 存储聊天记忆的第一步:在构建中加入如下 starter 依赖:
implementation 'org.springframework.ai:spring-ai-starter-model-chat-memory-repository-cassandra'
(你也可以在 Spring Initializr 中勾选 Cassandra Chat Memory Repository。)
接着,把之前定义的 ChatMemory Bean 改为使用注入的 ChatMemoryRepository 来构建 MessageWindowChatMemory:
@Bean
ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.build();
}
无需自己定义 ChatMemoryRepository Bean——Spring AI 的自动配置会创建并注入它。
做到这一步即可使用 Cassandra 实现持久化聊天记忆。接下来要确保 Cassandra 集群可用。我们已经用 Docker Compose 运行了向量库,因此把 Cassandra 加进去很方便:在 Compose 文件中加入如下服务:
services:
cassandra:
image: 'cassandra:latest'
environment:
- 'CASSANDRA_DC=dc1'
- 'CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch'
ports:
- '9042:9042'
应用启动后,聊天历史会持久化到 Cassandra 的表中。默认表名为 ai_chat_memory,消息列为 messages,命名空间(keyspace)为 springframework。可通过配置修改:
spring.ai.chat.memory.repository.cassandra.keyspace=boardgamebuddy
spring.ai.chat.memory.repository.cassandra.messages-column=chat_messages
spring.ai.chat.memory.repository.cassandra.table=chat_memory
上例中表名为 chat_memory、消息列为 chat_messages、命名空间为 boardgamebuddy。这些 schema 会自动创建;在生产环境你也可以选择自己管理 schema,并通过如下设置关闭自动初始化:
spring.ai.chat.memory.repository.cassandra.initialize-schema=false
Cassandra 是稳妥的持久化方案,但你也可以考虑把聊天历史存入图数据库 Neo4j。下面看看如何实现。
Neo4j
Neo4j 是一款流行而强大的图数据库。数据以实体节点及其关系的形式存储。可视化时,实体为圆点,边表示关系。
使用 Neo4j 存储聊天历史与 Cassandra 几乎相同,仅有少数差异。核心是依赖变更:改用 Neo4j 的聊天记忆 starter:
implementation 'org.springframework.ai:spring-ai-starter-model-chat-memory-repository-neo4j'
(也可在 Initializr 中选择 Neo4j Chat Memory Repository。)
之前为 Cassandra 编写的 ChatMemory Bean 对 Neo4j 同样适用,无需改动;只是被注入的 ChatMemoryRepository 将变为 Neo4jChatMemoryRepository。
若通过 Docker Compose 启动 Neo4j,请在 compose.yaml 中添加:
services:
neo4j:
image: 'neo4j:latest'
environment:
- 'NEO4J_AUTH=neo4j/notverysecret'
ports:
- '7687:7687'
- '7474:7474'
若使用外部 Neo4j 数据库,则在 application.properties 中配置连接信息:
spring.neo4j.uri=bolt://some-neo4j-host:7687
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=l3tM31n
其他无需更改。运行应用并与 Board Game Buddy 交互后,聊天历史会作为一组实体与关系写入 Neo4j。若在 Neo4j Browser(mng.bz/Nwov)中查看,能看到类似图 5.2 的可视化:
图 5.2 在 Neo4j Browser 中查看聊天历史(示意)
视图聚焦在右上象限,默认会话(即默认会话 ID)位于左下;从该实体节点分出多个用户消息与助手消息。选中某条助手消息时,右侧显示其属性,包括回答文本等。每条用户/助手消息又与一个元数据节点相连,用于存放额外属性。
继续缩小视图,会看到数据库中存在多段会话,如图 5.3 所示:
图 5.3 Neo4j 中的多会话可视化(示意)
把历史以图结构存储在 Neo4j 中相当有趣。但传统关系型数据库长期是应用持久化的主力。下面看看如何用 Spring AI 的 JDBC 实现把聊天历史存入关系库。
JDBC
Spring AI 的 JDBC 版 ChatMemoryRepository 允许把对话持久化到关系数据库,包括:
- HSQLDB
- MySQL
- PostgreSQL
- SQL Server
选择哪种数据库由你决定,配置方式基本一致。下面以 PostgreSQL 为例说明 JdbcChatMemoryRepository 的用法。
首先,在构建中改用 JDBC 相关 starter(而不是 Cassandra/Neo4j)并在 Compose 中添加 PostgreSQL:
services:
postgres:
image: 'postgres:latest'
environment:
- 'POSTGRES_DB=mydatabase'
- 'POSTGRES_PASSWORD=secret'
- 'POSTGRES_USER=myuser'
ports:
- '5432:5432'
同时添加 PostgreSQL JDBC 驱动:
runtimeOnly 'org.postgresql:postgresql'
接着需要显式配置一个 ChatMemoryRepository Bean。与 Cassandra / Neo4j 不同,JDBC 不会自动配置该 Bean;你需要根据数据库类型提供细节,包括方言(生成适配该数据库的 SQL)。如下是 PostgreSQL 的示例:
@Bean
ChatMemoryRepository chatMemoryRepository(DataSource dataSource) {
return JdbcChatMemoryRepository.builder()
.dialect(new PostgresChatMemoryRepositoryDialect())
.dataSource(dataSource)
.build();
}
这里通过 dialect() 指定 PostgresChatMemoryRepositoryDialect,并注入 DataSource 以便执行 SQL。
默认情况下,不会自动初始化用于存放聊天历史的表;因此如果你没有预先建表,需要开启 schema 初始化:
spring.ai.chat.memory.repository.jdbc.initialize-schema=always
将其设为 always 表示始终初始化 schema。若你在测试或演示中使用嵌入式数据库(如 HSQLDB),也可设置为 embedded,表示仅在嵌入式数据库场景初始化。
除了上述差异,其余用法与 Cassandra/Neo4j 基本一致。无论你选择 Cassandra、Neo4j 还是 JDBC,现在都可以开跑了。
试运行
像先前使用内存聊天记忆那样启动应用并尝试:
$ http :8080/ask gameTitle="Burger Battle" \
question="What is the Burger Force Field card?" \
X_AI_CONVERSATION_ID:conversation_1 -b
{
"answer": "The Burger Force Field card is a Battle Card in the Burger
card game that allows a player's Burger to be protected from
all other Battle Cards.",
"gameTitle": "Burger Battle"
}
$ http :8080/ask gameTitle="Burger Battle" \
question="Does it protect against Burgerpocalypse?" \
X_AI_CONVERSATION_ID:conversation_1 -b
{
"answer": "The Burger Force Field card does not protect against
Burgerpocalypse as it specifically states that all players'
ingredients are destroyed, regardless of protection.",
"gameTitle": "Burger Battle"
}
可以看到,一切正常!无论选用哪种数据库,效果相同:对话历史被持久化并在对话推进中被用作上下文。只要数据库未被销毁(例如未删除容器挂载的卷),应用就能在停止/启动之间保留会话。
此外,聊天记忆也可以存入向量库(如 Qdrant)。实际上,Spring AI 提供了一个无需自定义 ChatMemoryRepository、直接把聊天历史存进向量库的聊天记忆 advisor 实现。最后,我们将看看它的原理以及与前述 advisor 的差异。
5.4.2 将聊天记忆存入向量库
Spring AI 的 VectorStoreChatMemoryAdvisor 在把聊天记忆作为上下文提供给 LLM 的方式上,与 PromptChatMemoryAdvisor 很相似:都是通过系统提示注入上下文。但在记忆如何持久化这一点上,它与前两者截然不同。
最明显的区别是:它把聊天记忆存入向量库。更不那么显而易见的一点是,由于要写入向量库,系统会为聊天记忆计算嵌入向量(embeddings) ,以便之后通过相似度检索来查询。简而言之,VectorStoreChatMemoryAdvisor 把 RAG 模式 应用到了聊天记忆上——只是它存储的不是第 4 章中那样的“文档切片”,而是对话历史中的消息。
因此,相比于简单地“取最近几条消息”,VectorStoreChatMemoryAdvisor 注入系统提示的聊天记忆更聚焦、更相关:它不是无差别拼接最近的消息,而是只返回与当前问题最相似的历史消息。
尽管这种处理方式更先进,VectorStoreChatMemoryAdvisor 的使用依旧与前两种记忆 advisor 一样简单。
代码清单 5.4 将长期记忆存入向量库
@Bean
ChatClient chatClient(ChatClient.Builder chatClientBuilder,
VectorStore vectorStore) {
return chatClientBuilder
.defaultAdvisors(
VectorStoreChatMemoryAdvisor.builder(vectorStore).build(),
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().build()).build())
.build();
}
和 MessageChatMemoryAdvisor、PromptChatMemoryAdvisor 一样,VectorStoreChatMemoryAdvisor 也只需在 ChatClient 上一行即可启用。不同的是,它不是用某个 ChatMemory 实现来创建,而是直接用已有的 VectorStore 引用 来创建(你在第 4 章里为了 QuestionAnswerAdvisor 已经把它注入到 ChatClient 的定义中了)。
这就是启用 VectorStoreChatMemoryAdvisor 唯一需要的变化。不过你依然可以像之前那样,通过 CONVERSATION_ID 这个键来设置会话 ID,并设置聊天记忆的大小。
小结
- LLM 本身没有短期记忆,无法独立完成多轮对话。
- 应用可以“代记忆”:记录用户与助手的每句对话,并在每次提示中把必要历史作为上下文提供给 LLM。
- Spring AI 提供三种聊天记忆 advisor,用不同策略管理对话记忆。
- 通过
VectorStoreChatMemoryAdvisor(或自定义ChatMemory实现)可实现跨会话的长期记忆。 - 将它与
QuestionAnswerAdvisor结合,就能实现带文档对话的能力。