Spring AI 实战——启用会话记忆

109 阅读15分钟

本章内容

  • 维护对话状态
  • 基于内存的聊天历史
  • 长期记忆的保留

你看过电影《初恋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

MessageChatMemoryAdvisorPromptChatMemoryAdvisor 都会通过某个 ChatMemory 实现来保存历史,不同点在于“如何把历史写回到提示里”:

  • MessageChatMemoryAdvisor 以“多条消息”的形式(区分 user/assistant 角色)加入 prompt。
  • 但并非所有模型都支持“带角色的多消息”。对这些模型,PromptChatMemoryAdvisor 会把历史拼成一段大字符串,注入到 system 消息模板中。 image.png

(图 5.1 对话记忆:将用户与 LLM 的交互保留下来,作为后续提示的参考。)

VectorStoreChatMemoryAdvisor 则完全不同:它把聊天历史存入向量库,在每次提问时像 RAG 一样检索,与当前问题最相似的历史片段被选出并作为字符串注入到 system 模板中(与 PromptChatMemoryAdvisor 的注入方式相同)。

为了演示这些 advisor 的用法,我们将为 “Board Game Buddy(桌游助手)” 应用加上会话记忆,看看它们如何让 LLM 记住上下文、正确理解指代。

5.2 为应用加入会话记忆

现实里,问规则往往不是“一问一答”就结束,通常会紧跟若干追问。以《Burger Battle》为例,你可能先问一张战斗牌,再追问使用细节,比如:

  • “什么是 Burger Force Field 这张牌?”
  • “它能防住 Burgerpocalypse 吗?”

虽然只有两问,但已经体现出会话记忆的重要性:第一个问题清楚指明了主题(Burger Force Field 牌),第二个问题用代词“它”,如果没有第一问的上下文,第二问就不清楚指代对象。

MessageChatMemoryAdvisorPromptChatMemoryAdvisor 都能很好地处理这类多轮对话:把每次的用户问题与模型回答存起来,并在下次提问时把脚本回放给 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 消息是相应的回答。

MessageChatMemoryAdvisorPromptChatMemoryAdvisor 这两种方式下,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 后,将其与问题一起传给 SpringAiBoardGameServiceaskQuestion()。这意味着服务接口需要接收会话 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”,因此只能自行推断(且推断错误)。

由此可见,使用 MessageChatMemoryAdvisorPromptChatMemoryAdvisor 为 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 已内置了三个可选实现:

  • CassandraChatMemoryRepository
  • JdbcChatMemoryRepository
  • Neo4jChatMemoryRepository

并且由于自动配置的存在,把它们接入项目非常容易。下面先看如何在 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 的可视化:

image.png

图 5.2 在 Neo4j Browser 中查看聊天历史(示意)

视图聚焦在右上象限,默认会话(即默认会话 ID)位于左下;从该实体节点分出多个用户消息与助手消息。选中某条助手消息时,右侧显示其属性,包括回答文本等。每条用户/助手消息又与一个元数据节点相连,用于存放额外属性。

继续缩小视图,会看到数据库中存在多段会话,如图 5.3 所示:

image.png

图 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) ,以便之后通过相似度检索来查询。简而言之,VectorStoreChatMemoryAdvisorRAG 模式 应用到了聊天记忆上——只是它存储的不是第 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();
}

MessageChatMemoryAdvisorPromptChatMemoryAdvisor 一样,VectorStoreChatMemoryAdvisor 也只需在 ChatClient一行即可启用。不同的是,它不是用某个 ChatMemory 实现来创建,而是直接用已有的 VectorStore 引用 来创建(你在第 4 章里为了 QuestionAnswerAdvisor 已经把它注入到 ChatClient 的定义中了)。

这就是启用 VectorStoreChatMemoryAdvisor 唯一需要的变化。不过你依然可以像之前那样,通过 CONVERSATION_ID 这个键来设置会话 ID,并设置聊天记忆的大小

小结

  • LLM 本身没有短期记忆,无法独立完成多轮对话。
  • 应用可以“代记忆”:记录用户与助手的每句对话,并在每次提示中把必要历史作为上下文提供给 LLM。
  • Spring AI 提供三种聊天记忆 advisor,用不同策略管理对话记忆。
  • 通过 VectorStoreChatMemoryAdvisor(或自定义 ChatMemory 实现)可实现跨会话的长期记忆
  • 将它与 QuestionAnswerAdvisor 结合,就能实现带文档对话的能力。