第5章:在LangChain中如何使用AI Services
这篇文章详细介绍了 LangChain4j 中的 AI Services 概念,展示了如何通过高层次的抽象来简化与大语言模型(LLM)的交互。AI Services 的核心思想是隐藏底层复杂性,让开发者专注于业务逻辑,同时支持聊天记忆、工具调用和 RAG 等高级功能。通过示例和代码片段,文章展示了如何定义和使用 AI Services,以及如何将它们组合起来构建复杂的 LLM 驱动的应用程
AI Services | LangChain4j
引言
到目前为止,我们已经介绍了低层次的组件,例如 ChatLanguageModel、ChatMessage 和 ChatMemory 等。在这一层次上工作非常灵活,给你完全的自由,但这也迫使你编写大量的样板代码(boilerplate code)。由于基于 LLM 的应用程序通常不仅需要单个组件,而是多个组件协同工作(例如,提示词模板、聊天记忆、LLM、输出解析器、RAG 组件:嵌入模型和存储),并且通常涉及多次交互,因此协调它们变得更加繁琐。
解决方案
我们希望你专注于业务逻辑,而不是底层实现细节。因此,LangChain4j 提出了两个高层次的概念来帮助实现这一点:AI Services 和 Chains。
- Chains(已废弃)
Chains 的概念源自 Python 的 LangChain(在引入 LCEL 之前)。其想法是为每个常见用例提供一个 Chain,例如聊天机器人、RAG 等。Chains 结合了多个低层次组件,并协调它们之间的交互。然而,它们的主要问题是,如果你需要自定义某些内容,它们会显得过于僵化。LangChain4j 目前只实现了两个 Chains(ConversationalChain 和 ConversationalRetrievalChain),并且目前不计划添加更多。 - AI Services
我们提出了另一种解决方案,称为 AI Services,专为 Java 设计。其想法是将与 LLM 和其他组件交互的复杂性隐藏在一个简单的 API 后面。
这种方法类似于 Spring Data JPA 或 Retrofit:你声明性地定义一个接口,指定所需的 API,而 LangChain4j 提供一个实现该接口的对象(代理)。你可以将 AI Service 视为应用程序服务层的一个组件,它提供 AI 服务,因此得名。
AI Services 处理最常见的操作:
- 为 LLM 格式化输入。
- 解析 LLM 的输出。
它们还支持更高级的功能: - 聊天记忆(Chat Memory)。
- 工具(Tools)。
- RAG(Retrieval-Augmented Generation,检索增强生成)。
AI Services 可以用于构建支持来回交互的状态化聊天机器人,也可以用于自动化每个 LLM 调用都是独立的流程。
AI Service初探
最简单的 AI Service 示例
首先,我们定义一个接口,其中包含一个名为 chat 的方法,该方法接受一个 String 类型的输入并返回一个 String 类型的输出:
interface Assistant {
String chat(String userMessage);
}
然后,我们创建低层次组件。这些组件将在 AI Service 的底层使用。在这个例子中,我们只需要 ChatLanguageModel:
ChatLanguageModel model = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName(GPT_4_O_MINI)
.build();
最后,我们使用 AiServices 类创建 AI Service 的实例:
Assistant assistant = AiServices.create(Assistant.class, model);
注意:在 Quarkus 和 Spring Boot 应用程序中,自动配置会处理 Assistant 的创建。这意味着你不需要调用 AiServices.create(…),只需在需要的地方注入/自动装配 Assistant 即可。
现在我们可以使用 Assistant:
String answer = assistant.chat("Hello");
System.out.println(answer); // 输出:Hello, how can I help you?
工作原理
你将接口的 Class 和低层次组件提供给 AiServices,AiServices 会创建一个实现该接口的代理对象。目前,它使用反射实现,但我们也在考虑其他替代方案。这个代理对象处理所有输入和输出的转换。在这个例子中,输入是一个单独的 String,但我们使用的是接受 ChatMessage 作为输入的 ChatLanguageModel。因此,AiService 会自动将其转换为 UserMessage 并调用 ChatLanguageModel。由于 chat 方法的输出类型是 String,因此在从 chat 方法返回之前,ChatLanguageModel 返回的 AiMessage 将被转换为 String。
在 Quarkus 和 Spring Boot 应用程序中使用 AI Services
LangChain4j 提供了 Quarkus 扩展和 Spring Boot 启动器,极大地简化了在这些框架中使用 AI Services 的过程。
@SystemMessage
现在,我们来看一个更复杂的例子。我们将强制 LLM 使用俚语回答。这通常是通过在
SystemMessage 中提供指令来实现的:
interface Friend {
@SystemMessage("You are a good friend of mine. Answer using slang.")
String chat(String userMessage);
}
Friend friend = AiServices.create(Friend.class, model);
String answer = friend.chat("Hello"); // 输出:Hey! What's up?
在这个例子中,我们添加了 @SystemMessage 注解,并指定了我们想要使用的系统提示模板。这将在后台被转换为 SystemMessage,并与 UserMessage 一起发送给 LLM。
@SystemMessage 也可以从资源文件中加载提示模板:
@SystemMessage(fromResource = "my-prompt-template.txt")
系统消息提供者(System Message Provider)
系统消息也可以通过系统消息提供者动态定义:
Friend friend = AiServices.builder(Friend.class)
.chatLanguageModel(model)
.systemMessageProvider(chatMemoryId -> "You are a good friend of mine. Answer using slang.")
.build();
你可以根据聊天记忆 ID(用户或对话)提供不同的系统消息。
@UserMessage
假设我们使用的模型不支持系统消息,或者我们只想使用 UserMessage 来实现:
interface Friend {
@UserMessage("You are a good friend of mine. Answer using slang. {{it}}")
String chat(String userMessage);
}
Friend friend = AiServices.create(Friend.class, model);
String answer = friend.chat("Hello"); // 输出:Hey! What's shakin'?
我们将 @SystemMessage 替换为 @UserMessage,并指定了一个包含变量 it 的提示模板,该变量指向方法的唯一参数。
你也可以使用 @V 注解为提示模板变量指定自定义名称:
interface Friend {
@UserMessage("You are a good friend of mine. Answer using slang. {{message}}")
String chat(@V("message") String userMessage);
}
注意:在使用 LangChain4j 的 Quarkus 或 Spring Boot 应用程序中,@V 注解不是必需的。只有在 Java 编译时未启用 -parameters 选项时,才需要使用它。
@UserMessage 也可以从资源文件中加载提示模板:
@UserMessage(fromResource = "my-prompt-template.txt")
有效的 AI Service 方法示例
以下是一些有效的 AI Service 方法示例:
使用 UserMessage
String chat(String userMessage);
String chat(@UserMessage String userMessage);
String chat(@UserMessage String userMessage, @V("country") String country); // userMessage 包含 "{{country}}" 模板变量
@UserMessage("What is the capital of Germany?")
String chat();
@UserMessage("What is the capital of {{it}}?")
String chat(String country);
@UserMessage("What is the capital of {{country}}?")
String chat(@V("country") String country);
@UserMessage("What is the {{something}} of {{country}}?")
String chat(@V("something") String something, @V("country") String country);
@UserMessage("What is the capital of {{country}}?")
String chat(String country); // 仅在 Quarkus 和 Spring Boot 应用程序中有效
结合 SystemMessage 和 UserMessage
@SystemMessage("Given a name of a country, answer with a name of its capital")
String chat(String userMessage);
@SystemMessage("Given a name of a country, answer with a name of its capital")
String chat(@UserMessage String userMessage);
@SystemMessage("Given a name of a country, {{answerInstructions}}")
String chat(@V("answerInstructions") String answerInstructions, @UserMessage String userMessage);
@SystemMessage("Given a name of a country, answer with a name of its capital")
String chat(@UserMessage String userMessage, @V("country") String country); // userMessage 包含 "{{country}}" 模板变量
@SystemMessage("Given a name of a country, answer with a name of its capital")
@UserMessage("Germany")
String chat();
@SystemMessage("Given a name of a country, {{answerInstructions}}")
@UserMessage("Germany")
String chat(@V("answerInstructions") String answerInstructions);
@SystemMessage("Given a name of a country, answer with a name of its capital")
@UserMessage("Germany")
String chat();
@SystemMessage("Given a name of a country, {{answerInstructions}}")
@UserMessage("Germany")
String chat(@V("answerInstructions") String answerInstructions);
@SystemMessage("Given a name of a country, answer with a name of its capital")
@UserMessage("{{country}}")
String chat(@V("country") String country);
@SystemMessage("Given a name of a country, {{answerInstructions}}")
@UserMessage("{{country}}")
String chat(@V("answerInstructions") String answerInstructions, @V("country") String country);
多模态(Multimodality)
目前,AI Services 不支持多模态功能,需要使用基础的套件和 API 实现。
结构化输出(Structured Outputs)
如果你希望从 LLM 中获取结构化输出,可以将 AI Service 方法的返回类型从 String 改为其他类型。目前,AI Services 支持以下返回类型:
- String
- AiMessage
- 任意自定义 POJO(Plain Old Java Object)
- 任意 Enum 或 List 或 Set(用于对文本进行分类,例如情感分析、用户意图等)
- boolean/Boolean(用于获取“是”或“否”的回答)
- byte/short/int/BigInteger/long/float/double/BigDecimal
- Date/LocalDate/LocalTime/LocalDateTime
- List/Set(用于以项目符号列表的形式返回答案)
- Map<K, V>
- Result(如果需要访问 TokenUsage、FinishReason、来源(RAG 中检索到的内容)和执行的工具,除了 T,T 可以是上述任意类型。例如:Result、Result)
除非返回类型是 String、AiMessage 或 Map<K, V>,AI Service 会自动在 UserMessage 的末尾附加指示 LLM 应该如何响应的指令。在方法返回之前,AI Service 会将 LLM 的输出解析为所需的类型。
你可以通过启用日志记录来观察附加的指令。
注意:某些 LLM 提供商(例如 OpenAI 和 Google Gemini)允许为期望的输出指定 JSON 模式。如果此功能被支持且启用,自由格式的文本指令不会被附加到 UserMessage 的末尾。在这种情况下,将从你的 POJO 自动生成 JSON 模式并传递给 LLM,从而确保 LLM 遵循该 JSON 模式。
现在,让我们来看一些示例。
1. 返回类型为 boolean 的示例
interface SentimentAnalyzer {
@UserMessage("Does {{it}} have a positive sentiment?")
boolean isPositive(String text);
}
SentimentAnalyzer sentimentAnalyzer = AiServices.create(SentimentAnalyzer.class, model);
boolean positive = sentimentAnalyzer.isPositive("It's wonderful!");
// 输出:true
2. 返回类型为 Enum 的示例
enum Priority {
@Description("Critical issues such as payment gateway failures or security breaches.")
CRITICAL,
@Description("High-priority issues like major feature malfunctions or widespread outages.")
HIGH,
@Description("Low-priority issues such as minor bugs or cosmetic problems.")
LOW
}
interface PriorityAnalyzer {
@UserMessage("Analyze the priority of the following issue: {{it}}")
Priority analyzePriority(String issueDescription);
}
PriorityAnalyzer priorityAnalyzer = AiServices.create(PriorityAnalyzer.class, model);
Priority priority = priorityAnalyzer.analyzePriority("The main payment gateway is down, and customers cannot process transactions.");
// 输出:CRITICAL
注意:@Description 注解是可选的。当枚举名称不够直观时,建议使用它来帮助 LLM 更好地理解。
3. 返回类型为 POJO 的示例
class Person {
@Description("first name of a person") // 可选描述,帮助 LLM 更好地理解
String firstName;
String lastName;
LocalDate birthDate;
Address address;
}
@Description("an address") // 可选描述,帮助 LLM 更好地理解
class Address {
String street;
Integer streetNumber;
String city;
}
interface PersonExtractor {
@UserMessage("Extract information about a person from {{it}}")
Person extractPersonFrom(String text);
}
PersonExtractor personExtractor = AiServices.create(PersonExtractor.class, model);
String text = """
In 1968, amidst the fading echoes of Independence Day,
a child named John arrived under the calm evening sky.
This newborn, bearing the surname Doe, marked the start of a new journey.
He was welcomed into the world at 345 Whispering Pines Avenue
a quaint street nestled in the heart of Springfield
an abode that echoed with the gentle hum of suburban dreams and aspirations.
""";
Person person = personExtractor.extractPersonFrom(text);
System.out.println(person);
// 输出:Person { firstName = "John", lastName = "Doe", birthDate = 1968-07-04, address = Address { ... } }
JSON 模式(JSON Mode)
当提取自定义 POJO(实际上是 JSON,然后解析为 POJO)时,建议在模型配置中启用“JSON 模式”。这样,LLM 将被强制以有效的 JSON 格式响应。
注意:JSON 模式和工具/函数调用是类似的功能,但它们有不同的 API,并且用于不同的目的。
- JSON 模式:当你总是需要 LLM 以结构化格式(有效的 JSON)响应时非常有用。此外,通常不要状态/记忆,因此每次与 LLM 的交互都是独立的。例如,你可能希望从文本中提取信息,例如文本中提到的人的列表,或者将自由格式的产品评论转换为具有字段(如 String productName、Sentiment sentiment、List claimedProblems 等)的结构化形式。
- 工具/函数调用:当 LLM 应该能够执行某些操作时(例如查询数据库、搜索网络、取消用户的预订等),此功能非常有用。在这种情况下,向 LLM 提供一组工具及其期望的 JSON 模式,LLM 将自主决定是否调用其中的任何一个来满足用户请求。
以前,函数调用常用于结构化数据提取,但现在我们有了 JSON 模式功能,它更适合此目的。
以下是启用 JSON 模式的方法:
OpenAI
- 对于支持结构化输出的较新模型(例如 gpt-4o-mini、gpt-4o-2024-08-06)
OpenAiChatModel.builder()
...
.responseFormat("json_schema")
.strictJsonSchema(true)
.build();
- 对于较旧的模型(例如 gpt-3.5-turbo、gpt-4):
OpenAiChatModel.builder()
...
.responseFormat("json_object")
.build();
Azure OpenAI
AzureOpenAiChatModel.builder()
...
.responseFormat(new ChatCompletionsJsonResponseFormat())
.build();
Vertex AI Gemini
VertexAiGeminiChatModel.builder()
...
.responseMimeType("application/json")
.build();
或者通过指定一个 Java 类的显式模式:
GoogleAiGeminiChatModel.builder()
...
.responseFormat(ResponseFormat.builder()
.type(JSON)
.jsonSchema(JsonSchemas.jsonSchemaFrom(Person.class).get())
.build())
.build();
或者通过指定一个 JSON 模式:
GoogleAiGeminiChatModel.builder()
...
.responseFormat(ResponseFormat.builder()
.type(JSON)
.jsonSchema(JsonSchema.builder()...build())
.build())
.build();
Mistral AI
MistralAiChatModel.builder()
...
.responseFormat(MistralAiResponseFormatType.JSON_OBJECT)
.build();
Ollama
OllamaChatModel.builder()
...
.responseFormat(JSON)
.build();
其他模型提供商
如果底层模型提供商不支持 JSON 模式,提示工程(Prompt Engineering)是你的最佳选择。此外,尝试降低 temperature 参数以获得更确定性的结果。
流式响应(Streaming)
AI Service 可以通过使用 TokenStream 返回类型逐个流式传输响应令牌:
interface Assistant {
TokenStream chat(String message);
}
StreamingChatLanguageModel model = OpenAiStreamingChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName(GPT_4_O_MINI)
.build();
Assistant assistant = AiServices.create(Assistant.class, model);
TokenStream tokenStream = assistant.chat("Tell me a joke");
tokenStream.onNext((String token) -> System.out.println(token))
.onRetrieved((List<Content> contents) -> System.out.println(contents))
.onToolExecuted((ToolExecution toolExecution) -> System.out.println(toolExecution))
.onComplete((Response<AiMessage> response) -> System.out.println(response))
.onError((Throwable error) -> error.printStackTrace())
.start();
使用 Flux
你也可以使用 Flux 替代 TokenStream。为此,请导入 langchain4j-reactor 模块:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-reactor</artifactId>
<version>1.0.0-beta1</version>
</dependency>
代码示例
interface Assistant {
Flux<String> chat(String message);
}
聊天记忆(Chat Memory)
AI Service 可以使用聊天记忆来“记住”之前的交互:
Assistant assistant = AiServices.builder(Assistant.class)
.chatLanguageModel(model)
.chatMemory(MessageWindowChatMemory.withMaxMessages(10))
.build();
在这种情况下,相同的 ChatMemory 实例将用于所有 AI Service 的调用。然而,这种方法在有多个用户时将无法工作,因为每个用户都需要自己的 ChatMemory 实例来维护各自的对话。
解决这个问题的方法是使用 ChatMemoryProvider:
interface Assistant {
String chat(@MemoryId int memoryId, @UserMessage String message);
}
Assistant assistant = AiServices.builder(Assistant.class)
.chatLanguageModel(model)
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
.build();
String answerToKlaus = assistant.chat(1, "Hello, my name is Klaus");
String answerToFrancine = assistant.chat(2, "Hello, my name is Francine");
在这种情况下,ChatMemoryProvider 将为每个内存 ID 提供两个不同的 ChatMemory 实例。
注意:
- 如果 AI Service 方法没有带有 @MemoryId 注解的参数,则 ChatMemoryProvider 中的 memoryId 默认值为字符串 “default”。
- 目前,AI Service 不支持对同一个 @MemoryId 的并发调用,因为这可能导致 ChatMemory 被破坏。AI Service 目前没有实现任何机制来防止对同一个 @MemoryId 的并发调用。
工具(Tools)
AI Service 可以配置工具,LLM 可以使用这些工具:
class Tools {
@Tool
int add(int a, int b) {
return a + b;
}
@Tool
int multiply(int a, int b) {
return a * b;
}
}
Assistant assistant = AiServices.builder(Assistant.class)
.chatLanguageModel(model)
.tools(new Tools())
.build();
String answer = assistant.chat("What is 1+2 and 3*4?");
在这种情况下,LLM 将请求执行 add(1, 2) 和 multiply(3, 4) 方法,然后才提供最终答案。LangChain4j 将自动执行这些方法。
关于工具的更多信息可以参考 LangChain4j 文档。
RAG(检索增强生成)
AI Service 可以配置 ContentRetriever 来启用简单的 RAG:
EmbeddingStore embeddingStore = ...;
EmbeddingModel embeddingModel = ...;
ContentRetriever contentRetriever = new EmbeddingStoreContentRetriever(embeddingStore, embeddingModel);
Assistant assistant = AiServices.builder(Assistant.class)
.chatLanguageModel(model)
.contentRetriever(contentRetriever)
.build();
配置 RetrievalAugmentor 可以提供更大的灵活性,启用高级的 RAG 功能,例如查询转换、重新排序等:
RetrievalAugmentor retrievalAugmentor = DefaultRetrievalAugmentor.builder()
.queryTransformer(...)
.queryRouter(...)
.contentAggregator(...)
.contentInjector(...)
.executor(...)
.build();
Assistant assistant = AiServices.builder(Assistant.class)
.chatLanguageModel(model)
.retrievalAugmentor(retrievalAugmentor)
.build();
关于 RAG 的更多信息可以参考 LangChain4j 文档。
自动审核(Auto-Moderation)
(示例略)
链接多个 AI Services
随着你的 LLM 驱动应用程序的逻辑变得越来越复杂,将其分解为更小的部分变得至关重要,这在软件开发中是一种常见的实践。
例如,将大量指令塞入系统提示中以涵盖所有可能的场景,容易出错且效率低下。如果指令过多,LLM 可能会忽略一些。此外,指令的呈现顺序也很重要,这使得整个过程更加复杂。
这一原则也适用于工具、RAG 和模型参数(例如 temperature、maxTokens 等)。
你的聊天机器人可能并不需要在所有情况下都了解你所有的工具。例如,当用户仅仅是问候聊天机器人或说再见时,让 LLM 访问数十个甚至数百个工具(每个工具都会消耗大量的 token)是成本高昂的,有时甚至是危险的,可能会导致意外的结果(LLM 可能会幻觉或被操纵以调用工具并输入意外的内容)。
关于 RAG:同样,有时需要为 LLM 提供一些上下文,但并非总是如此,因为这会增加额外的成本(更多上下文 = 更多 token)并增加响应时间(更多上下文 = 更高的延迟)。
关于模型参数:在某些情况下,你可能需要 LLM 高度确定性,因此你会设置较低的 temperature。在其他情况下,你可能会选择较高的 temperature,依此类推。
要点是,更小且更具体的组件更容易、更便宜开发、测试、维护和理解。
另一个需要考虑的方面是两个极端:
- 你是否希望你的应用程序高度确定性,其中应用程序控制流程,LLM 只是其中一个组件?
- 或者你希望 LLM 完全自主并驱动你的应用程序?
或许根据情况,两者都有?所有这些选项都可以通过将你的应用程序分解为更小、更易于管理的部分来实现。
AI Services 可以作为常规(确定性)软件组件使用,并与其他组件结合:
- 你可以依次调用一个 AI Service(即链式调用)。
- 你可以使用确定性和基于 LLM 的 if/else 语句(AI Services 可以返回 boolean)。
- 你可以使用确定性和基于 LLM 的 switch 语句(AI Services 可以返回 enum)。
- 你可以使用确定性和基于 LLM 的 for/while 循环(AI Services 可以返回 int 和其他数值类型)。
- 你可以模拟 AI Service(因为它是一个接口)以进行单元测试。
- 你可以单独集成测试每个 AI Service。
- 你可以分别评估每个 AI Service 并找到每个子任务的最优参数。
等等。
让我们考虑一个简单的例子。我想为我的公司构建一个聊天机器人。如果用户问候聊天机器人,我希望它用预定义的问候语回答,而不依赖 LLM 生成问候语。如果用户提问,我希望 LLM 使用公司的内部知识库生成回答(即 RAG)。
以下是如何将此任务分解为两个独立的 AI Services:
interface GreetingExpert {
@UserMessage("Is the following text a greeting? Text: {{it}}")
boolean isGreeting(String text);
}
interface ChatBot {
@SystemMessage("You are a polite chatbot of a company called Miles of Smiles.")
String reply(String userMessage);
}
class MilesOfSmiles {
private final GreetingExpert greetingExpert;
private final ChatBot chatBot;
public MilesOfSmiles(GreetingExpert greetingExpert, ChatBot chatBot) {
this.greetingExpert = greetingExpert;
this.chatBot = chatBot;
}
public String handle(String userMessage) {
if (greetingExpert.isGreeting(userMessage)) {
return "Greetings from Miles of Smiles! How can I make your day better?";
} else {
return chatBot.reply(userMessage);
}
}
}
GreetingExpert greetingExpert = AiServices.create(GreetingExpert.class, llama2);
ChatBot chatBot = AiServices.builder(ChatBot.class)
.chatLanguageModel(gpt4)
.contentRetriever(milesOfSmilesContentRetriever)
.build();
MilesOfSmiles milesOfSmiles = new MilesOfSmiles(greetingExpert, chatBot);
String greeting = milesOfSmiles.handle("Hello");
System.out.println(greeting); // 输出:Greetings from Miles of Smiles! How can I make your day better?
String answer = milesOfSmiles.handle("Which services do you provide?");
System.out.println(answer); // 输出:At Miles of Smiles, we provide a wide range of services ...
注意我们如何使用较便宜的 Llama2 来完成简单的问候识别任务,而使用更昂贵的 GPT-4(带有内容检索器,即 RAG)来完成更复杂的任务。
这是一个非常简单且有些幼稚的例子,但希望它能说明这个想法。
现在,我可以分别模拟 GreetingExpert 和 ChatBot,并独立测试 MilesOfSmiles。此外,我还可以分别集成测试 GreetingExpert 和 ChatBot,分别评估它们,并为每个子任务找到最优化的参数,甚至在长期内为每个特定任务微调一个小的专用模型。
总结
这篇文章详细介绍了 LangChain4j 中的 AI Services 概念,展示了如何通过高层次的抽象来简化与大语言模型(LLM)的交互。AI Services 的核心思想是隐藏底层复杂性,让开发者专注于业务逻辑,同时支持聊天记忆、工具调用和 RAG 等高级功能。通过示例和代码片段,文章展示了如何定义和使用 AI Services,以及如何将它们组合起来构建复杂的 LLM 驱动的应用程序。