0%

langchain4j AI-Services解读

因为ChatModelChatMessageChatMemory 等底层组件工作非常灵活,但也正式由于给予的这些自由,迫使开发者需要编写大量的样板代码。而由于 LLM 驱动的应用程序通常需要多个组件协同工作(例如提示模板、聊天记忆、LLM、输出解析器、RAG 组件:嵌入模型和存储),并且通常涉及多次交互,协调所有这些组件变得更加繁琐。故LangChain4j 目前提供了两个高级概念来解决这个问题:AI 服务(AI Services)链(Chains),以便于开发者能专注于业务逻辑而不是底层细节。

链(Chains)

链的概念源于 Python 版的 LangChain,其理念是为每个常见用例提供一个 Chain,例如聊天机器人、RAG 等。链将多个底层组件组合起来,并协调它们之间的交互。链的主要问题在于,如果你需要自定义某些内容,它们过于僵化,所以LangChain4j 目前只实现了两个链(ConversationalChainConversationalRetrievalChain),并不计划添加更多。

AI 服务(AI Services)

langchain4j提出了另一种专为 Java 设计的解决方案,称为 AI 服务,其理念是将与 LLM 和其他组件交互的复杂性隐藏在一个简单的 API 后面。

这种方法与 Spring Data JPA 或 Retrofit 非常相似:你以声明方式定义具有所需 API 的接口,LangChain4j 提供一个实现该接口的对象(代理)。你可以将 AI 服务视为应用程序中服务层的组件,它提供 AI 服务,因此得名。

AI 服务处理的最常见操作如下:

  • 输入/输出转换
  • 系统消息和用户消息的处理
  • ChatModel 的交互

还支持更高级的功能:

  • 聊天记忆(Chat Memory)
  • 工具/函数调用(Tools/Function Calling)
  • RAG(检索增强生成)
  • 内容审核(Content Moderation)
  • 结构化输出(Structured Output)
  • 流式响应(Streaming)

AI 服务可用于构建有状态的聊天机器人(支持来回交互),以及自动化流程(每次调用 LLM 都是独立的)。


最简单的 AI 服务示例

  1. 定义接口

首先,定义一个只有一个方法 chat 的接口,该方法接受 String 作为输入并返回 String

1
2
3
interface Assistant {
String chat(String userMessage);
}
  1. 创建底层组件

然后,我们创建底层组件。这些组件将在 AI 服务的幕后使用。在本例中,我们只需要 ChatModel

1
2
3
4
ChatModel chatModel = OpenAiChatModel.builder()
.apiKey("demo")
.modelName("gpt-4o-mini")
.build();
  1. 创建 AI 服务实例

最后,我们可以使用 AiServices 类来创建 AI 服务的实例:

1
Assistant assistant = AiServices.create(Assistant.class, chatModel);

QuarkusSpring Boot 应用程序中,自动配置会处理 Assistant bean 的创建。这意味着你不需要调用 AiServices.create(...),只需在需要的地方注入/自动装配 Assistant 即可。

  1. 使用 AI 服务
1
2
String response = assistant.chat("Hello!");
System.out.println(response);

工作原理:你向 AiServices 提供接口的 Class 以及底层组件,AiServices 会创建一个实现该接口的代理对象。这个代理对象处理输入和输出的所有转换。在本例中,输入是一个 String,但我们使用的是接受 ChatMessage 作为输入的 ChatModel。因此,AI 服务会自动将其转换为 UserMessage 并调用 ChatModel。由于 chat 方法的输出类型是 String,在 ChatModel 返回 AiMessage 后,它将在从 chat 方法返回之前转换为 String

系统消息(System Message)

现在让我们看一个更复杂的例子:强制 LLM 使用特定语言回复 。这通常通过在 SystemMessage 中提供指令来实现。

@SystemMessage 注解

1
2
3
4
interface Assistant {
@SystemMessage("You are a pirate. Reply using pirate slang.")
String chat(String userMessage);
}

在这个例子中,我们添加了 @SystemMessage 注解和我们想要使用的系统提示模板。这将在幕后转换为 SystemMessage,并与 UserMessage 一起发送给 LLM。

@SystemMessage 也可以从资源文件加载提示模板:

1
@SystemMessage(fromResource = "my-prompt-template.txt")

系统消息提供者(System Message Provider)

系统消息也可以通过系统消息提供者动态定义:

可以根据聊天记忆 ID(用户或对话)提供不同的系统消息。

1
2
3
interface Assistant {
String chat(@MemoryId String memoryId, String userMessage);
}
1
2
3
4
5
6
7
8
9
10
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.systemMessageProvider(memoryId -> {
if ("user1".equals(memoryId)) {
return "You are a helpful assistant.";
} else {
return "You are a coding expert.";
}
})
.build();

系统消息转换器(System Message Transformer)

系统消息转换器允许你在每次调用时动态修改系统消息,它会在从 @SystemMessagesystemMessageProvider 解析之后、但在 chatRequestTransformer 运行之前执行。当你需要向系统消息追加或前置内容时,无论原始配置如何,这都非常有用。

如果没有配置系统消息,转换器将接收 null

1
2
3
4
5
6
7
8
9
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.systemMessageTransformer(systemMessage -> {
if (systemMessage == null) {
return "You are a helpful assistant.";
}
return systemMessage + "\nAlways be polite.";
})
.build();

当你还需要访问调用上下文(例如方法名或其参数)时,可以使用接受 InvocationContext 的双参数重载:

1
2
3
4
.systemMessageTransformer((systemMessage, context) -> {
// context contains method name, arguments, etc.
return systemMessage;
})

用户消息(User Message)

@UserMessage 注解

1
2
3
4
interface Assistant {
@UserMessage("Reply using pirate slang: {{it}}")
String chat(String userMessage);
}

@UserMessage 注解替换了 @SystemMessage 注解,并指定了一个包含变量 it 的提示模板,该变量引用唯一的方法参数。

也可以使用 @V 注解 String userMessage 并为提示模板变量分配自定义名称:

1
2
3
4
interface Assistant {
@UserMessage("Reply using pirate slang: {{userInput}}")
String chat(@V("userInput") String userMessage);
}

@UserMessage 也可以从资源文件加载提示模板:

1
@UserMessage(fromResource = "my-prompt-template.txt")

note: 在使用 LangChain4j 与 Quarkus 或 Spring Boot 时,使用 @V 不是必需的。只有在 Java 编译期间未启用 -parameters 选项时,才需要此注解。


聊天请求转换器(Chat Request Transformer)

在某些情况下,在将 ChatRequest 发送到 LLM 之前修改它是有用的。例如,可能需要根据某些外部条件向用户消息追加一些额外的上下文或修改系统消息。

可以通过使用实现 ChatRequest 转换的 UnaryOperator 配置 AI 服务来实现:

1
2
3
4
5
6
7
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.chatRequestTransformer(chatRequest -> {
// Modify the chat request as needed
return chatRequest;
})
.build();

如果还需要访问 ChatMemory 来实现所需的 ChatRequest 转换,也可以使用 BiFunction 配置 chatRequestTransformer 方法,其中传递给该函数的第二个参数是记忆 ID:

1
2
3
4
.chatRequestTransformer((chatRequest, memoryId) -> {
// Access chat memory and modify the request
return chatRequest;
})

调用参数配置

另一个自由度是可以基于每次调用配置参数(例如 temperature、toolsChoice、最大 token 数等)。例如,你可能希望某些请求更具”创造性”(更高的 temperature),而其他请求更具确定性(更低的 temperature)。

要实现这一点,你可以创建一个也接受 ChatRequestParameters 类型参数(或任何提供商特定的类型,如 OpenAiChatRequestParameters)的 AI 服务方法。这告诉 LangChain4j 在每次调用时接受并合并这些参数。

请注意,ChatRequestParameters 中指定的 toolSpecificationsresponseFormat 将覆盖 AI 服务生成的那些。

定义带第二个参数的接口:

1
2
3
interface Assistant {
String chat(String userMessage, ChatRequestParameters parameters);
}

构建 AI 服务:

1
2
3
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.build();

使用每次调用的参数调用:

1
2
3
4
5
6
ChatRequestParameters parameters = OpenAiChatRequestParameters.builder()
.temperature(0.9)
.maxTokens(100)
.build();

String response = assistant.chat("Tell me a joke", parameters);

传递给 AI 服务方法作为参数的 ChatRequestParameters 也会传播到前面章节讨论的 chatRequestTransformer,因此如有必要,也可以在那里访问和修改它。


有效的 AI 服务方法示例

以下是一些有效的 AI 服务方法的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 简单文本输入输出
String chat(String message);

// 带系统消息
@SystemMessage("You are a helpful assistant.")
String chat(String message);

// 带用户消息模板
@UserMessage("Translate to French: {{it}}")
String translate(String text);

// 带多次调用参数
String chat(String message, ChatRequestParameters parameters);

// 带聊天记忆
String chat(@MemoryId String memoryId, String message);

多模态内容输入

除了文本内容或替代文本内容,AI 服务方法还可以接受一个或多个 ContentList<Content> 参数:

1
2
3
String chat(Content content);
String chat(List<Content> contents);
String chat(String text, ImageContent image);

AI 服务将按照参数声明的顺序将所有内容放入最终的 UserMessage 中。

有关可用内容类型的更多详细信息,请查看 Content API


返回类型

AI 服务方法可以返回以下类型:

返回类型 说明
String 返回非结构化文本
自定义 POJO 返回结构化输出(JSON 自动解析)
Result<T> 包含元数据(如 token 使用量)的包装器
TokenStream 用于流式响应
Flux<T> 响应式流式响应(需要 langchain4j-reactor 模块)

任何类型都可以额外包装到 Result 中,以获取有关 AI 服务调用的额外元数据:

1
Result<String> chat(String message);
1
2
3
Result<String> result = assistant.chat("Hello");
String response = result.content();
TokenUsage tokenUsage = result.tokenUsage();

结构化输出(Structured Output)

如果你想从 LLM 接收结构化输出(例如复杂的 Java 对象,而不是 String 中的非结构化文本),可以将 AI 服务方法的返回类型从 String 更改为其他类型。

1
2
3
4
5
6
7
8
9
class Person {
String name;
int age;
String occupation;
}

interface Assistant {
Person extractPerson(String text);
}

JSON 模式

当提取自定义 POJO(实际上是 JSON,然后解析为 POJO)时,建议在模型配置中启用”JSON 模式”, 这样,LLM 将被强制使用有效的 JSON 进行响应。

请注意,JSON 模式工具/函数调用是相似的功能,但具有不同的 API 并用于不同的目的:

  • JSON 模式在你始终需要 LLM 以结构化格式(有效 JSON)响应时非常有用。此外,通常不需要状态/记忆,因此每次与 LLM 的交互都是独立的。例如,你可能希望从文本中提取信息,例如文本中提到的人员列表,或将自由格式的产品评论转换为具有 String productNameSentiment sentimentList<String> claimedProblems 等字段的结构化表单。

  • 工具/函数在 LLM 应该能够执行某些操作(例如查找数据库、搜索网络、取消用户预订等)时非常有用。在这种情况下,具有其预期 JSON 模式列表的工具会提供给 LLM,它由自主决定是否调用其中任何一个来满足用户请求。

以前,函数调用经常用于结构化数据提取,但现在我们有了 JSON 模式功能,它更适合此目的。

如何启用 JSON 模式:

对于 OpenAI:

1
2
3
4
5
ChatModel chatModel = OpenAiChatModel.builder()
.apiKey("your-api-key")
.modelName("gpt-4o-mini")
.responseFormat(ResponseFormat.JSON)
.build();

对于 Azure OpenAI:

1
2
3
4
5
6
ChatModel chatModel = AzureOpenAiChatModel.builder()
.apiKey("your-api-key")
.endpoint("your-endpoint")
.deploymentName("your-deployment")
.responseFormat(ResponseFormat.JSON)
.build();

或者通过 Java 类指定显式模式:

1
2
3
4
.responseFormat(ResponseFormat.builder()
.type(ResponseFormatType.JSON_SCHEMA)
.jsonSchema(JsonSchema.from(Person.class))
.build())

从 JSON 模式字符串:

1
2
3
4
.responseFormat(ResponseFormat.builder()
.type(ResponseFormatType.JSON_SCHEMA)
.jsonSchema(JsonSchema.from("{\"type\":\"object\",\"properties\":...}"))
.build())

流式响应(Streaming)

AI 服务可以使用 TokenStream 返回类型逐 token 流式响应:

1
2
3
interface Assistant {
TokenStream chat(String message);
}
1
2
3
4
5
assistant.chat("Tell me a story")
.onNext(token -> System.out.print(token))
.onComplete(response -> System.out.println("\nDone!"))
.onError(error -> error.printStackTrace())
.start();

取消流式响应

如果你想取消流式响应,可以从以下回调之一中执行:

1
2
3
4
5
6
7
8
assistant.chat("Tell me a story")
.onNext(token -> {
if (shouldStop) {
throw new CancellationException("User cancelled");
}
System.out.print(token);
})
.start();

或者使用 StreamingHandle

1
2
3
4
5
6
StreamingHandle handle = assistant.chat("Tell me a story")
.onNext(token -> System.out.print(token))
.start();

// Later, cancel the streaming
handle.cancel();

当调用 StreamingHandle.cancel() 时,LangChain4j 将关闭连接并停止流式传输。一旦调用了 StreamingHandle.cancel()TokenStream 将不会收到任何进一步的回调。

Flux

你也可以使用 Flux 代替 TokenStream。为此,请导入 langchain4j-reactor 模块:

1
2
3
interface Assistant {
Flux<String> chat(String message);
}

聊天记忆(Chat Memory)

AI 服务可以使用聊天记忆来”记住”以前的交互:

1
2
3
interface Assistant {
String chat(@MemoryId String memoryId, String message);
}
1
2
3
4
5
6
ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);

Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.chatMemory(chatMemory)
.build();

在这种场景下,相同的 ChatMemory 实例将用于 AI 服务的所有调用。但是,如果你有多个用户,这种方法将不起作用,因为每个用户都需要自己的 ChatMemory 实例来维护各自的对话。

使用 ChatMemoryProvider

这个问题的解决方案是使用 ChatMemoryProvider

1
2
3
4
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
.build();

在这种场景下,ChatMemoryProvider 将提供两个不同的 ChatMemory 实例,每个记忆 ID 一个。

当以这种方式使用 ChatMemory 时,还需要驱逐不再需要的对话的记忆,以避免内存泄漏。为了使 AI 服务内部使用的聊天记忆可访问,定义它的接口只需扩展 ChatMemoryAccess 接口即可:

1
2
3
interface Assistant extends ChatMemoryAccess {
String chat(@MemoryId String memoryId, String message);
}

这使得既可以访问单个对话的 ChatMemory 实例,又可以在对话终止时清除它:

1
2
3
4
5
// Access the chat memory for a specific conversation
ChatMemory memory = assistant.getChatMemory("user1");

// Clear it when the conversation is done
assistant.removeChatMemory("user1");

请注意,如果 AI 服务方法没有使用 @MemoryId 注解的参数,则 ChatMemoryProvider 中的 memoryId 值将默认为字符串 "default"

请注意,不应为相同的 @MemoryId 并发调用 AI 服务,因为这可能导致 ChatMemory 损坏。目前,AI 服务没有实现任何机制来防止对同一 @MemoryId 的并发调用。


工具(Tools)

AI 服务可以配置 LLM 可以使用的工具:

1
2
3
4
5
6
7
8
9
10
11
class Calculator {
@Tool("Add two numbers")
int add(@P("First number") int a, @P("Second number") int b) {
return a + b;
}

@Tool("Multiply two numbers")
int multiply(@P("First number") int a, @P("Second number") int b) {
return a * b;
}
}
1
2
3
4
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.tools(new Calculator())
.build();

在这种场景下,LLM 将在提供最终答案之前请求执行 add(1, 2)multiply(3, 4) 方法。LangChain4j 将自动执行这些方法。


RAG(检索增强生成)

基础 RAG

AI 服务可以配置 ContentRetriever 以启用简单的 RAG:

1
2
3
4
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.contentRetriever(contentRetriever)
.build();

高级 RAG

配置 RetrievalAugmentor 提供了更大的灵活性,支持高级 RAG 功能,如查询转换、重新排序等:

1
2
3
4
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.retrievalAugmentor(retrievalAugmentor)
.build();

按需检索(工具式检索)

默认情况下,内容检索对每個用户查询都执行。或者,可以将检索视为一种类似工具的能力,仅当模型确定需要额外上下文时才调用。通过这种方法,检索仍然是 RAG 管道的一部分,但是有条件地执行,避免了对简单查询的不必要搜索。

要实现这一点,你可以将 ContentRetriever 封装在 @Tool 中并向 AiServices 注册。这允许 LLM 根据工具的描述自主决定是否触发检索。

创建一个包装你的 ContentRetriever 的类。@Tool 描述至关重要,因为它告诉 LLM 何时调用搜索:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class SearchTool {
private final ContentRetriever contentRetriever;

public SearchTool(ContentRetriever contentRetriever) {
this.contentRetriever = contentRetriever;
}

@Tool("Search for relevant information in the knowledge base")
String search(@P("Search query") String query) {
List<Content> contents = contentRetriever.retrieve(query);
return contents.stream()
.map(Content::text)
.collect(Collectors.joining("\n"));
}
}

代替使用全局的 RetrievalAugmentor,将检索逻辑注册为工具:

1
2
3
4
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.tools(new SearchTool(contentRetriever))
.build();

LLM 根据工具的描述评估用户的意图,以决定是否执行搜索。

场景 A — 一般对话

  • 输入:Hello, how are you today?
  • 行为:LLM 直接从内部知识响应,不调用工具。

场景 B — 技术问题

  • 输入:How do I configure a ContentRetriever?
  • 行为:LLM 识别技术意图,调用 search(),并根据检索到的文档生成响应。

这种方法允许检索作为按需能力运行,类似于工具,而不是每个查询的强制步骤。


内容审核(Content Moderation)

AI 服务可以自动执行内容审核。当检测到不适当的内容时,会抛出 ModerationException,其中包含原始的 Moderation 对象。该对象包含有关被标记内容的信息,例如被标记的具体文本。

可以在构建 AI 服务时配置自动审核:

1
2
3
4
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.moderationModel(moderationModel)
.build();

将应用分解为多个 AI 服务

你的 LLM 驱动应用程序的逻辑越复杂,将其分解为更小的部分就越重要,这是软件开发中的常见做法。

例如,在系统提示中塞入大量指令以应对所有可能的场景容易出错且效率低下。如果指令太多,LLM 可能会忽略一些。此外,指令呈现的顺序也很重要,使过程更具挑战性。

这个原则也适用于工具、RAG 和模型参数(如 temperature、maxTokens 等)。

你的聊天机器人可能不需要始终知道你拥有的每个工具。例如,当用户只是问候聊天机器人或说再见时,给 LLM 访问数十或数百个工具的权限是昂贵的,有时甚至是危险的(每个包含在 LLM 调用中的工具都会消耗大量 token),并可能导致意想不到的结果(LLM 可能会产生幻觉或被操纵以使用意想不到的输入调用工具)。

关于 RAG:类似地,有时需要向 LLM 提供一些上下文,但并非总是如此,因为这会产生额外成本(更多上下文 = 更多 token)并增加响应时间(更多上下文 = 更高延迟)。

关于模型参数:在某些情况下,你可能需要 LLM 高度确定性,因此你会设置较低的 temperature,在其他情况下,你可能会选择较高的 temperature,依此类推。

关键是,更小、更具体的组件更容易、更便宜地开发、测试、维护和理解。

另一个需要考虑的方面涉及两个极端:

  • 单个庞大的 AI 服务处理所有事情?
  • 还是许多小而专注的 AI 服务协同工作?

或者根据情况混合使用两者?当你将应用程序分解为更小、更易于管理的部分时,所有这些选项都是可能的。

AI 服务可以用作常规(确定性)软件组件并与之组合:

示例:公司聊天机器人

让我们考虑一个简单的例子。我想为我的公司构建一个聊天机器人。

  • 如果用户问候聊天机器人,我希望它使用预定义的问候语响应,而不依赖 LLM 生成问候语。
  • 如果用户提出问题,我希望 LLM 使用公司的内部知识库(即 RAG)生成响应。

以下是如何将此任务分解为 2 个独立的 AI 服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Service 1: Determine if the message is a greeting
interface GreetingExpert {
boolean isGreeting(String message);
}

// Service 2: Answer questions using RAG
interface ChatBot {
String answer(String question);
}

// Service 3: Orchestrates the above two
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 message) {
if (greetingExpert.isGreeting(message)) {
return "Hello! Welcome to our company. How can I help you today?";
}
return chatBot.answer(message);
}
}