Spring AI Advisor 指南

1、概览

AI 驱动的应用已成为我们的新现实。我们正在广泛实现各种 RAG 应用和提示 API,并使用 LLM 创建令人印象深刻的项目。借助 Spring AI,我们可以更快、更稳定地完成这些任务。

本文将带你了解 Spring AI Advisor 这一功能,它可以为我们处理各种常规任务。

2、Spring AI Advisor 是什么?

Advisors 是在 AI 应用程序中处理请求和响应的拦截器。我们可以使用它们为提示流程设置额外的功能。例如,可以建立聊天历史、排除敏感词或为每个请求添加额外的上下文。

该功能的核心组件是 CallAroundAdvisor 接口。我们通过实现该接口来创建 Advisor 链,从而影响我们的请求或响应。Advisor 流程如下图所示:

Advisor 流程图

我们会将提示(prompt)发送到一个聊天模型,该模型关联了一个 Advisor 链。在发送提示之前,链上的每个 Advisor 都会执行其 before 操作。同样,在收到聊天模型的回复之前,每个 Advisor 都会调用自己的 after 操作。

3、ChatMemoryAdvisor

ChatMemoryAdvisor 是一组非常有用的 Advisor 实现。我们可以使用这些 Advisor 提供与聊天提示的交流历史,从而提高聊天回复的准确性。

3.1、MessageChatMemoryAdvisor

使用 MessageChatMemoryAdvisor,我们可以通过 messages 属性提供聊天客户端调用的聊天历史记录。我们可以将所有消息保存在 ChatMemory 实现中,并控制历史记录的大小。

示例如下:

@SpringBootTest(classes = ChatModel.class)
@EnableAutoConfiguration
@ExtendWith(SpringExtension.class)
public class SpringAILiveTest {

    @Autowired
    @Qualifier("openAiChatModel")
    ChatModel chatModel;
    ChatClient chatClient;

    @BeforeEach
    void setup() {
        chatClient = ChatClient.builder(chatModel).build();
    }

    @Test
    void givenMessageChatMemoryAdvisor_whenAskingChatToIncrementTheResponseWithNewName_thenNamesFromTheChatHistoryExistInResponse() {
        ChatMemory chatMemory = new InMemoryChatMemory();
        MessageChatMemoryAdvisor chatMemoryAdvisor = new MessageChatMemoryAdvisor(chatMemory);

        String responseContent = chatClient.prompt()
          .user("Add this name to a list and return all the values: Bob")
          .advisors(chatMemoryAdvisor)
          .call()
          .content();

        assertThat(responseContent)
          .contains("Bob");

        responseContent = chatClient.prompt()
          .user("Add this name to a list and return all the values: John")
          .advisors(chatMemoryAdvisor)
          .call()
          .content();

        assertThat(responseContent)
          .contains("Bob")
          .contains("John");

        responseContent = chatClient.prompt()
          .user("Add this name to a list and return all the values: Anna")
          .advisors(chatMemoryAdvisor)
          .call()
          .content();

        assertThat(responseContent)
          .contains("Bob")
          .contains("John")
          .contains("Anna");
    }
}

在这个测试中,我们创建了一个 MessageChatMemoryAdvisor 实例,其中包含 InMemoryChatMemory。然后,我们发送了一些提示,要求聊天返回包括历史数据在内的人名。我们可以看到,对话中的所有姓名都已返回。

3.2、PromptChatMemoryAdvisor

通过 PromptChatMemoryAdvisor,我们可以实现相同的目标 - 为聊天模型提供对话历史记录。不同的是,我们使用此 Advisor 将聊天历史添加到了提示中。在底层,我们通过以下 Advisor 扩展了提示文本:

Use the conversation memory from the MEMORY section to provide accurate answers.
---------------------
MEMORY:
{memory}
---------------------

示例如下:

@Test
void givenPromptChatMemoryAdvisor_whenAskingChatToIncrementTheResponseWithNewName_thenNamesFromTheChatHistoryExistInResponse() {
    ChatMemory chatMemory = new InMemoryChatMemory();
    PromptChatMemoryAdvisor chatMemoryAdvisor = new PromptChatMemoryAdvisor(chatMemory);

    String responseContent = chatClient.prompt()
      .user("Add this name to a list and return all the values: Bob")
      .advisors(chatMemoryAdvisor)
      .call()
      .content();

    assertThat(responseContent)
      .contains("Bob");

    responseContent = chatClient.prompt()
      .user("Add this name to a list and return all the values: John")
      .advisors(chatMemoryAdvisor)
      .call()
      .content();

    assertThat(responseContent)
      .contains("Bob")
      .contains("John");

    responseContent = chatClient.prompt()
      .user("Add this name to a list and return all the values: Anna")
      .advisors(chatMemoryAdvisor)
      .call()
      .content();

    assertThat(responseContent)
      .contains("Bob")
      .contains("John")
      .contains("Anna");
}

再次尝试创建了一些提示,这次使用 PromptChatMemoryAdvisor 让聊天模型考虑对话历史。果然,所有数据都正确返回给我们。

3.3、VectorStoreChatMemoryAdvisor

通过使用 VectorStoreChatMemoryAdvisor,我们可以获得更强大的功能。

我们通过向量存储中的相似性匹配搜索消息的上下文。搜索相关文档时,我们会考虑对话 ID。在我们的示例中,我们将使用稍作改动的 SimpleVectorStore,但也可以替换为任何向量数据库。

首先,创建一个向量存储的 Bean:

@Configuration
public class SimpleVectorStoreConfiguration {

    @Bean
    public VectorStore vectorStore(@Qualifier("openAiEmbeddingModel")EmbeddingModel embeddingModel) {
        return new SimpleVectorStore(embeddingModel) {
            @Override
            public List<Document> doSimilaritySearch(SearchRequest request) {
                float[] userQueryEmbedding = embeddingModel.embed(request.query);
                return this.store.values()
                  .stream()
                  .map(entry -> Pair.of(entry.getId(),
                    EmbeddingMath.cosineSimilarity(userQueryEmbedding, entry.getEmbedding())))
                  .filter(s -> s.getSecond() >= request.getSimilarityThreshold())
                  .sorted(Comparator.comparing(Pair::getSecond))
                  .limit(request.getTopK())
                  .map(s -> this.store.get(s.getFirst()))
                  .toList();
            }
        };
    }
}

如上,我们创建了一个 SimpleVectorStore 类的 Bean 并重写了其 doSimilaritySearch() 方法。默认的 SimpleVectorStore 不支持元数据过滤,在这里我们将忽略这一点。由于测试中只有一个对话,这种方法非常适合我们。

现在,测试一下历史上下文行为:

@Test
void givenVectorStoreChatMemoryAdvisor_whenAskingChatToIncrementTheResponseWithNewName_thenNamesFromTheChatHistoryExistInResponse() {
    VectorStoreChatMemoryAdvisor chatMemoryAdvisor = new VectorStoreChatMemoryAdvisor(vectorStore);

    String responseContent = chatClient.prompt()
      .user("Find cats from our chat history, add Lion there and return a list")
      .advisors(chatMemoryAdvisor)
      .call()
      .content();

    assertThat(responseContent)
      .contains("Lion");

    responseContent = chatClient.prompt()
      .user("Find cats from our chat history, add Puma there and return a list")
      .advisors(chatMemoryAdvisor)
      .call()
      .content();

    assertThat(responseContent)
      .contains("Lion")
      .contains("Puma");

    responseContent = chatClient.prompt()
      .user("Find cats from our chat history, add Leopard there and return a list")
      .advisors(chatMemoryAdvisor)
      .call()
      .content();

    assertThat(responseContent)
      .contains("Lion")
      .contains("Puma")
      .contains("Leopard");
}

我们要求聊天在列表中填充一些项目,同时在幕后进行相似性搜索,以获得所有相似文档,然后我们的聊天 LLM 会根据这些文档准备答案。

4、QuestionAnswerAdvisor

在 RAG 应用中,我们广泛使用 QuestionAnswerAdvisor

使用该 Advisor,我们可以根据准备好的上下文准备一个请求信息的提示。上下文通过相似性搜索从向量存储中获取。

如下:

@Test
void givenQuestionAnswerAdvisor_whenAskingQuestion_thenAnswerShouldBeProvidedBasedOnVectorStoreInformation() {

    Document document = new Document("The sky is green");
    List<Document> documents = new TokenTextSplitter().apply(List.of(document));
    vectorStore.add(documents);
    QuestionAnswerAdvisor questionAnswerAdvisor = new QuestionAnswerAdvisor(vectorStore);

    String responseContent = chatClient.prompt()
      .user("What is the sky color?")
      .advisors(questionAnswerAdvisor)
      .call()
      .content();

    assertThat(responseContent)
      .containsIgnoringCase("green");
}

我们用文档中的具体信息填充向量存储(Vector Store)。然后,使用 QuestionAnswerAdvisor 创建一个提示,并验证其响应是否与文档内容一致。

5、SafeGuardAdvisor

有时,我们必须防止在客户端提示中使用某些敏感词。我们可以使用 SafeGuardAdvisor 来实现这一目的,具体做法是指定一个违禁词语列表,并将其包含在提示的顾问实例中。如果在搜索请求中使用了其中任何一个词,该请求将被拒绝,顾问会提示我们重新措辞:

@Test
void givenSafeGuardAdvisor_whenSendPromptWithSensitiveWord_thenExpectedMessageShouldBeReturned() {

    List<String> forbiddenWords = List.of("Word2");
    SafeGuardAdvisor safeGuardAdvisor = new SafeGuardAdvisor(forbiddenWords);

    String responseContent = chatClient.prompt()
      .user("Please split the 'Word2' into characters")
      .advisors(safeGuardAdvisor)
      .call()
      .content();

    assertThat(responseContent)
      .contains("I'm unable to respond to that due to sensitive content");
}

如上例,我们首先创建了一个带有单个违禁词的 SafeGuardAdvisor。然后,我们尝试在提示符中使用该单词,不出所料,收到了禁用单词验证信息。

6、实现自定义 Advisor

当然,我们可以根据需求实现自定义的 Advisor

创建一个 CustomLoggingAdvisor,记录所有聊天请求和回复:

public class CustomLoggingAdvisor implements CallAroundAdvisor {
    private final static Logger logger = LoggerFactory.getLogger(CustomLoggingAdvisor.class);

    @Override
    public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {

        advisedRequest = this.before(advisedRequest);

        AdvisedResponse advisedResponse = chain.nextAroundCall(advisedRequest);

        this.observeAfter(advisedResponse);

        return advisedResponse;
    }

    private void observeAfter(AdvisedResponse advisedResponse) {
        logger.info(advisedResponse.response()
          .getResult()
          .getOutput()
          .getContent());

    }

    private AdvisedRequest before(AdvisedRequest advisedRequest) {
        logger.info(advisedRequest.userText());
        return advisedRequest;
    }

    @Override
    public String getName() {
        return "CustomLoggingAdvisor";
    }

    @Override
    public int getOrder() {
        return Integer.MAX_VALUE;
    }
}

如上,我们实现了 CallAroundAdvisor 接口,并在调用前后添加了日志逻辑。此外,getOrder() 方法返回了 Integer 最大值,因此该 adviser 是链中的最后一个。

测试如下:

@Test
void givenCustomLoggingAdvisor_whenSendPrompt_thenPromptTextAndResponseShouldBeLogged() {

    CustomLoggingAdvisor customLoggingAdvisor = new CustomLoggingAdvisor();

    String responseContent = chatClient.prompt()
      .user("Count from 1 to 10")
      .advisors(customLoggingAdvisor)
      .call()
      .content();

    assertThat(responseContent)
      .contains("1")
      .contains("10");
}

如上,创建 CustomLoggingAdvisor 并将其附加到提示。

执行日志如下,可以看到,我们的 Advisor 成功记录了提示文本和聊天回复。

c.b.s.advisors.CustomLoggingAdvisor      : Count from 1 to 10
c.b.s.advisors.CustomLoggingAdvisor      : 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

7、总结

本文介绍了 Spring AI 中 Advisor 这一个强大的功能,还介绍了 Spring AI 中预定义的常用 Advisor 实现,


Ref:https://www.baeldung.com/spring-ai-advisors