【Spring AI】ChatClient

331 阅读12分钟

简介

ChatClient 提供了一个用于与 AI 模型通信的 API,支持同步和反应式编程模型。同时具有构建提示词(Prompt)的组成部分的方法,这些部分作为输入传递给 AI 模型。该提示词包含指导 AI 模型的输出和行为的说明文本。从 API 的角度来看,提示词由一组消息组成。

AI 模型处理两种主要类型的消息:用户消息(来自用户的直接输入)和系统消息(由系统生成以指导对话)。

这些消息通常包含占位符,这些占位符在运行时根据用户输入进行替换,以自定义 AI 模型对用户输入的响应。

还有一些可以指定的提示词选项,例如要使用的 AI 模型的名称以及控制生成输出的随机性或创造性的温度设置。

创建 ChatClient

ChatClient 可以通过ChatClient.Builder来创建。也可以通过任意 ChatModel Spring Boot 自动配置依赖来获取相应的自动配置的 ChatClient.Builder 实例。也可以通过编程方式创建一个实例。

自动配置的 ChatClient

在最简单的用例中, Spring AI 提供 Spring Boot 自动配置,创建一个原型 的 ChatClient.Builder bean 供你注入到你的类中。下面是对简单用户请求的 String 检索响应的简单示例。

@RestController
class MyController {

    private final ChatClient chatClient;

    public MyController(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @GetMapping("/ai")
    String generation(String userInput) {
        return this.chatClient.prompt()
            .user(userInput)
            .call()
            .content();
    }
}

在这个简单的示例中,用户输入设置用户消息的内容。 call 方法向 AI 模型发送请求,content 方法以 String 形式返回 AI 模型的响应。

以上是官方例子,实际测试我以编程方式来创建 ChatClient。

编程方式创建 ChatClient

首先设置属性spring.ai.chat.client.enabled=false来禁用 ChatClient.Builder 的自动配置,如果有多个 ChatModel,可以通过编程的方式创建每个 ChatClient.Builder 实例。下面通过引入 OllamaChatModel 来创建 ChatCliet 的代码实例:

@Configuration
public class ChatConfig {

    @Resource
    private OllamaChatModel ollamaChatModel;

    @Bean
    public ChatClient chatClient(SimpleVectorStore simpleVectorStore) {
        ChatClient.Builder builder = ChatClient.builder(ollamaChatModel);
        return builder.build();
    }
}

ChatClient 响应

ChatClient API 提供了多种方法来格式化来自 AI 模型的响应。

返回 ChatResponse

来自 AI 模型的响应是由 ChatResponse 类型定义的丰富结构。它包括有关响应生成方式的元数据,还可以包含多个响应,称为Generation,每个响应都有自己的元数据。元数据包括用于创建响应的 Token(每个 token 大约是一个单词的 3/4)。此信息非常重要,因为托管 AI 模型根据每个请求使用的 token 数量收费。

通过在方法 call() 后调用 chatResponse(),如下所示返回包含元数据的 ChatResponse 对象的示例。

@GetMapping("call02")
public ChatResponse call02(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
    return chatClient.prompt(new Prompt(new UserMessage(message))).call().chatResponse();
}

执行结果

{
    "result": {
        "metadata": {
            "contentFilterMetadata": null,
            "finishReason": "stop"
        },
        "output": {
            "messageType": "ASSISTANT",
            "metadata": {
                "messageType": "ASSISTANT"
            },
            "toolCalls": [],
            "content": "Sure, here's one:\nWhy was the math book sad?\nBecause it had too many problems."
        }
    },
    "metadata": {
        "id": "",
        "model": "qwen2:1.5b",
        "rateLimit": {
            "requestsRemaining": 0,
            "requestsReset": "PT0S",
            "tokensReset": "PT0S",
            "requestsLimit": 0,
            "tokensLimit": 0,
            "tokensRemaining": 0
        },
        "usage": {
            "promptTokens": 12,
            "generationTokens": 21,
            "totalTokens": 33
        },
        "promptMetadata": [],
        "empty": false
    },
    "results": [
        {
            "metadata": {
                "contentFilterMetadata": null,
                "finishReason": "stop"
            },
            "output": {
                "messageType": "ASSISTANT",
                "metadata": {
                    "messageType": "ASSISTANT"
                },
                "toolCalls": [],
                "content": "Sure, here's one:\nWhy was the math book sad?\nBecause it had too many problems."
            }
        }
    ]
}

返回实体

通常需要将返回结果从 string 与对应的 bean 映射。

创建对应的 Java 实体。

public record ActorFilms(String actor, List<String> movies) {
}

将 AI 模型的输出结果映射到此 bean。

@GetMapping("call03")
public ActorFilms call03(@RequestParam(value = "message", defaultValue = "Generate the filmography for a random actor.") String message) {
    ChatClient.CallResponseSpec result = chatClient.prompt().user(message).call();
    return result.entity(ActorFilms.class);
}

执行结果

{
    "actor": "Actor Name",
    "movies": [
        "Movie Title",
        "Movie Title"
    ]
}

另外还提供带有签名entityentity(ParameterizedTypeReference<T> type)的重载方法,允许指定类型,例如泛型 List:

@GetMapping("call04")
public List<ActorFilms> call04(@RequestParam(value = "message", defaultValue = "Generate the filmography of 5 movies for Tom Hanks and Bill Murray.") String message) {
    ChatClient.CallResponseSpec result = chatClient.prompt().user(message).call();
    return result.entity(new ParameterizedTypeReference<List<ActorFilms>>() {
    });
}

执行结果

[
    {
        "actor": "Tom Hanks",
        "movies": [
            "Saving Private Ryan",
            "Forrest Gump",
            "Cast Away",
            "The wire",
            "The Da Vinci Code"
        ]
    },
    {
        "actor": "Bill Murray",
        "movies": [
            "Groundhog Day",
            "Ghostbusters II",
            "Caddyshack: The Movie",
            "Monty Python and the Holy Grail"
        ]
    }
]

流式响应

通过 stream() 可以获得异步响应。

@GetMapping("call05")
public Flux<String> call05(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
    return chatClient.prompt().user(message).stream().content();
}

执行结果跟打印机输出效果类似。

可以通过Flux<ChatResponse> chatResponse()方法获取流式 ChatResponse。

@GetMapping("call06")
public Flux<ChatResponse> call06(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
    return chatClient.prompt().user(message).stream().chatResponse();
}

默认配置

在 @Configuration 类中创建具有默认系统文本的 ChatClient 可简化运行时代码。通过设置默认值,只需在 ChatClient 调用 时指定用户文本,无需为运行时代码路径中的每个请求设置系统文本。

默认系统文本

为了避免在运行时代码中重复系统文本,可以在 @Configuration 类中创建一个 ChatClient 实例。以下示例中,将系统文本配置为始终以海盗的声音回复。

@Configuration
public class ChatConfig {

    @Resource
    private OllamaChatModel ollamaChatModel;

    public static final String DEFAULT_SYSTEM = "You are a friendly chat bot that answers question in the voice of a Pirate";

    @Bean
    public ChatClient chatClient(SimpleVectorStore simpleVectorStore) {
        ChatClient.Builder builder = ChatClient.builder(ollamaChatModel);
        builder.defaultSystem(DEFAULT_SYSTEM);
        return builder.build();
    }
}

示例代码:

@GetMapping("call07")
public Map<String, String> call07(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
    return Map.of("completion", chatClient.prompt().user(message).call().content());
}

执行结果:

2024-09-05T16:48:24.423+08:00 DEBUG 5016 --- [nio-8081-exec-2] o.s.a.c.c.advisor.SimpleLoggerAdvisor    : request: Custom request: Tell me a joke
Context information is below.
---------------------
{question_answer_context}
---------------------
Given the context and provided history information and not prior knowledge,
reply to the user comment. If the answer is not in the context, inform
the user that you can't answer the question.

2024-09-05T16:48:26.481+08:00 DEBUG 5016 --- [nio-8081-exec-2] o.s.a.c.c.advisor.SimpleLoggerAdvisor    : response: Custom response: Generation[assistantMessage=AssistantMessage [messageType=ASSISTANT, toolCalls=[], textContent=Sure thing! What's the context for your joke?, metadata={messageType=ASSISTANT}], chatGenerationMetadata=ChatGenerationMetadata{finishReason=stop,contentFilterMetadata=null}]

带参数的默认系统文本

以下示例中,在系统文本中使用占位符来指定在运行时(而不是设计时)完成的声音。

@Configuration
public class ChatConfig {

    @Resource
    private OllamaChatModel ollamaChatModel;

    public static final String DEFAULT_SYSTEM_PARAM = "You are a friendly chat bot that answers question in the voice of a {voice}";

    @Bean
    public ChatClient chatClient(SimpleVectorStore simpleVectorStore) {
        ChatClient.Builder builder = ChatClient.builder(ollamaChatModel);
        builder.defaultSystem(DEFAULT_SYSTEM_PARAM);
        return builder.build();
    }
}

示例代码:

@GetMapping("call08")
public Map<String, String> call08(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message, String voice) {
    return Map.of("completion", chatClient.prompt().system(e -> e.param("voice", voice)).user(message).call().content());
}

执行结果:

2024-09-05T17:05:32.593+08:00 DEBUG 5016 --- [nio-8081-exec-5] o.s.a.c.c.advisor.SimpleLoggerAdvisor    : request: Custom request: Tell me a joke
Context information is below.
---------------------
{question_answer_context}
---------------------
Given the context and provided history information and not prior knowledge,
reply to the user comment. If the answer is not in the context, inform
the user that you can't answer the question.

2024-09-05T17:05:36.588+08:00 DEBUG 5016 --- [nio-8081-exec-5] o.s.a.c.c.advisor.SimpleLoggerAdvisor    : response: Custom response: Generation[assistantMessage=AssistantMessage [messageType=ASSISTANT, toolCalls=[], textContent=Sure thing matey! Here's a joke for ya:

Why was the math book sad? 
Because it had too many problems.

I hope you enjoyed that joke as much as I enjoyed telling it. Do you have any other questions or topics you'd like me to help with?, metadata={messageType=ASSISTANT}], chatGenerationMetadata=ChatGenerationMetadata{finishReason=stop,contentFilterMetadata=null}]

其它默认配置

在该 ChatClient.Builder 级别,可以指定默认提示。

  • defaultOptions(ChatOptions chatOptions):传入 ChatOptions 类中定义的可移植选项或特定于 OpenAiChatOptions 模型的选项。

  • defaultFunction(String name, String description, java.util.function.Function<I, O> function):name 用于在用户文本中引用的函数。description 解释了函数的用途,并帮助 AI 模型选择正确的函数以实现准确响应。function 参数是模型将在必要时执行的 Java 函数实例。

  • defaultFunctions(String…​ functionNames):在应用程序上下文中定义的java.util.Function的 bean 名称。

  • defaultUser(String text), defaultUser(Resource text), defaultUser(Consumer<UserSpec> userSpecConsumer):这些方法允许你定义用户文本。Consumer<UserSpec>允许你使用 lambda 指定用户文本和任何默认参数。

  • defaultAdvisors(RequestResponseAdvisor…​ advisor):顾问程序允许修改已经创建的 Prompt,QuestionAnswerAdvisor 的实现类通过在 prompt 后附加与用户文本相关的上下文信息来启用 RAG 模式。

  • defaultAdvisors(Consumer<AdvisorSpec> advisorSpecConsumer):此方法允许通过 Consumer 来配置多个 advisor。顾问可以修改已经创建的 Prompt,允许通过Consumer<AdvisorSpec>指定一个 lambda 来添加顾问,例如 QuestionAnswerAdvisor,它基于用户文本的相关上下文信息并附加在提示词里面来支持 RAG。

另外可以在运行时使用不带 default 前缀的相应方法覆盖这些默认值。

  • options(ChatOptions chatOptions)
  • function(String name, String description, java.util.function.Function<I, O> function)
  • functions(String…​ functionNames)
  • user(String text) , user(Resource text), user(Consumer<UserSpec> userSpecConsumer)
  • advisors(RequestResponseAdvisor…​ advisor)
  • advisors(Consumer<AdvisorSpec> advisorSpecConsumer)

顾问 Advisor

使用用户文本调用 AI 模型时,一种常见模式是使用上下文数据附加或增强提示词。

此上下文数据可以是不同的类型。常见类型包括:

  • 用户自己数据:这是 AI 模型尚未训练的数据。即使模型内具备了类似的数据,在生成响应时会优先使用附加的上下文数据。
  • 对话历史记录:聊天模型的 API 是无状态的。如果你告诉 AI 模型你的名字,它在后续交互中也不会记住它。必须随每个请求发送对话历史记录,以确保在生成响应时考虑以前的交互。

RAG

向量数据库用于存储 AI 模型不知道的数据。将用户问题发送到 AI 模型时,QuestionAnswerAdvisor 会在向量数据库中查询与用户问题相关的文档。

来自向量数据库的响应将附加到用户文本中,以便为 AI 模型生成响应提供上下文。

假设你已将数据加载到向量数据库中,则可以通过向 ChatClient 提供 QuestionAnswerAdvisor 来执行检索增强生成(RAG)。

@Configuration
public class ChatConfig {

    @Resource
    private OllamaChatModel ollamaChatModel;
    @Resource
    private OllamaEmbeddingModel ollamaEmbeddingModel;

    public static final String DEFAULT_SYSTEM = "You are a friendly chat bot that answers question in the voice of a Pirate";

    @Bean
    public SimpleVectorStore simpleVectorStore() {
        return new SimpleVectorStore(ollamaEmbeddingModel);
    }

    @Bean
    public ChatClient chatClient(SimpleVectorStore simpleVectorStore) {
        ChatClient.Builder builder = ChatClient.builder(ollamaChatModel);

        QuestionAnswerAdvisor questionAnswerAdvisor = new QuestionAnswerAdvisor(simpleVectorStore, SearchRequest.defaults());
        
        builder.defaultAdvisors(List.of(questionAnswerAdvisor));

        builder.defaultSystem(DEFAULT_SYSTEM);
        return builder.build();
    }
}

在此示例中,SearchRequest.defaults() 将对向量数据库中的所有文档执行相似性搜索。为了限制搜索的文档类型,SearchRequest 采用一个类似 SQL 的过滤器表达式,该表达式可在所有向量数据库中。

动态筛选表达式

在运行时使用 FILTER_EXPRESSION 的 advisor 来更新 SearchRequest 的筛选条件表达式:

@GetMapping("call10")
public String call10(@RequestParam(value = "message", defaultValue = "Please answer my question XYZ") String message) {
    return chatClient.prompt().advisors(e -> e.param(QuestionAnswerAdvisor.FILTER_EXPRESSION, "type == 'Spring'")).user(message).call().content();
}

Chat Memory

该 ChatMemory 接口表示聊天对话历史记录的存储,它提供了向对话添加消息、从对话中检索消息以及清除对话历史记录的方法。

有两种实现 InMemoryChatMemory 和 CassandraChatMemory,它们为聊天对话历史记录提供存储以及存储中内存时的存活时间。

以下有三种 advisor 实现了 ChatMemory 接口,可以将聊天对话历史记录放入 prompt,但是如何将内存中这些记录添加到 prompt 中,存在细微差别:

  • MessageChatMemoryAdvisor:根据会话ID检索内存中相应的历史消息并添加到提示词文本中。
  • PromptChatMemoryAdvisor:检索内存并将相关的历史消息添加到提示的系统文本中。
  • VectorStoreChatMemoryAdvisor:结合唯一对话 ID 检索向量数据库中的令牌大小的历史消息将添加到提示的系统文本中。

常规对话记录

使用简单的List<Message>记录用户输入提示词以及大模型返回的消息,实现 chat 上下文记忆能力:

@GetMapping("/call11")
public String call11(@RequestParam(value = "prompt") String prompt) {
    // 将用户消息添加到历史消息列表中
    historyMessage.add(new UserMessage(prompt));
    ChatResponse response = chatClient.prompt(new Prompt(historyMessage)).call().chatResponse();
    // 将AI消息添加到历史消息列表中
    AssistantMessage assistantMessage = response.getResult().getOutput();
    historyMessage.add(assistantMessage);
    return assistantMessage.getContent();
}

执行结果

image.png

image.png

通过验证,以上方式可以实现聊天上下文记忆能力。但会存在以下问题:

  1. 如果聊天内容很多,会超过ChatGPT窗口大小,比如想GPT-3限制4000多token,很容易导致大模型无法回答内容。
  2. 问题二:聊天内容可能中间会有一些不相关的文本,如果一同传过去会消耗更多的成本。ChatGPT是按照token收费的。token越多,一次交互的成本就越高。
  3. 历史消息没有和会话进行关联,应该是每次会话一个历史消息。

InMemoryChatMemory

Spring Ai 提供 InMemoryChatMemory 作为 ChatMemory 的实现类,表示为聊天对话历史记录提供内存中存储。

调整配置文件:

@Configuration
public class ChatConfig {

    @Resource
    private OllamaChatModel ollamaChatModel;

    public static final String DEFAULT_SYSTEM = "You are a friendly chat bot that answers question in the voice of a Pirate";

    @Bean
    public InMemoryChatMemory inMemoryChatMemory() {
        return new InMemoryChatMemory();
    }

    @Bean
    public ChatClient chatClient(SimpleVectorStore simpleVectorStore, InMemoryChatMemory inMemoryChatMemory) {
        ChatClient.Builder builder = ChatClient.builder(ollamaChatModel);

        PromptChatMemoryAdvisor promptChatMemoryAdvisor = new PromptChatMemoryAdvisor(inMemoryChatMemory);
        SimpleLoggerAdvisor simpleLoggerAdvisor = new SimpleLoggerAdvisor(request -> "Custom request: " + request.userText(), response -> "Custom response: " + response.getResult());

        builder.defaultAdvisors(List.of(promptChatMemoryAdvisor, simpleLoggerAdvisor));

        builder.defaultSystem(DEFAULT_SYSTEM);
        return builder.build();
    }
}

示例代码:

@GetMapping("/call12")
public Flux<String> call12(String chatId, String prompt) {
    // CHAT_MEMORY_CONVERSATION_ID_KEY 表示会话ID,实现上下文与会话绑定
    // CHAT_MEMORY_RETRIEVE_SIZE_KEY 表示历史会话最多100条发给AI
    return chatClient.prompt().user(prompt).advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId).param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 100)).stream().content();
}

执行结果:

image.png

image.png

从回答的结果上看,已经实现了上下文记忆的能力。如果把参数 chatId 改为 1257,AI 的回答如下图所示

image.png

Logging

SimpleLoggerAdvisor 是一个 advisor,用于记录 ChatClient 的请求和响应的数据。这对于调试和监控 AI 交互非常有用。

要启用日志记录,请在创建 ChatClient 时将 SimpleLoggerAdvisor 添加到 advisor 链中。建议将其添加到链的末尾:

@Configuration
public class ChatConfig {

    @Resource
    private OllamaChatModel ollamaChatModel;
    @Resource
    private OllamaEmbeddingModel ollamaEmbeddingModel;

    public static final String DEFAULT_SYSTEM = "You are a friendly chat bot that answers question in the voice of a Pirate";

    @Bean
    public SimpleVectorStore simpleVectorStore() {
        return new SimpleVectorStore(ollamaEmbeddingModel);
    }

    @Bean
    public ChatClient chatClient(SimpleVectorStore simpleVectorStore) {
        ChatClient.Builder builder = ChatClient.builder(ollamaChatModel);

        QuestionAnswerAdvisor questionAnswerAdvisor = new QuestionAnswerAdvisor(simpleVectorStore, SearchRequest.defaults());
        
        SimpleLoggerAdvisor simpleLoggerAdvisor = new SimpleLoggerAdvisor(request -> "Custom request: " + request.userText(), response -> "Custom response: " + response.getResult());

        builder.defaultAdvisors(List.of(questionAnswerAdvisor, simpleLoggerAdvisor));

        builder.defaultSystem(DEFAULT_SYSTEM);
        return builder.build();
    }
}

要查看日志,在配置文件中将日志记录级别设置为:DEBUG

logging.level.org.springframework.ai.chat.client.advisor=DEBUG

日志效果:

2024-09-05T18:10:41.156+08:00 DEBUG 5016 --- [io-8081-exec-10] o.s.a.c.c.advisor.SimpleLoggerAdvisor    : request: Custom request: Tell me a joke
Context information is below.
---------------------
{question_answer_context}
---------------------
Given the context and provided history information and not prior knowledge,
reply to the user comment. If the answer is not in the context, inform
the user that you can't answer the question.

2024-09-05T18:10:45.968+08:00 DEBUG 5016 --- [io-8081-exec-10] o.s.a.c.c.advisor.SimpleLoggerAdvisor    : response: Custom response: Generation[assistantMessage=AssistantMessage [messageType=ASSISTANT, toolCalls=[], textContent=You're a funny pirate! Why? Because you tell jokes like this:

"Ahoy, me hearty! Here's a riddle for ya: why did the tomato turn red when it saw the salad dressing? Because it was dressed to impress!"

Don't worry, I'm not a bad pirate. Just a friendly chatbot who loves telling jokes!, metadata={messageType=ASSISTANT}], chatGenerationMetadata=ChatGenerationMetadata{finishReason=stop,contentFilterMetadata=null}]

文章参考