AI 服务

何为AI服务,与LangChain4j的两个层级又有什么关系

还记得前面提到过的,LangChain4j 把整个框架分成两层,一层底层、一层高级,分工完全不同:

基础组件层:就是前面提到的,ChatModel,ChatMessage,ChatMemory等等内容,我们很容易发现它们虽然使用起来非常的灵活,几乎是想怎么拼就怎么拼,但是重复代码和样板代码是真没少写。在这个层面工作非常灵活,赋予你完全的自由,但也意味着需要编写大量的样板代码。

但是由于基于 LLM 的应用程序通常需要多个组件协同工作 (例如,提示模板、聊天记忆、LLM、输出解析器、RAG 组件:嵌入模型和存储),并且通常涉及多轮交互,因此对它们的编排变得愈加繁琐。LangChain4j 希望你能专注于业务逻辑,而不是底层实现的细节。为此,LangChain4j 目前提供了更高级的一层

编排抽象层:框架帮你把底层组件自动组装、自动管理,你不用写重复代码,只关注业务。两个核心高层工具分别是

  • AI Service 也就是 AI 服务
  • Chains 用于多步骤复杂工作流的链

而 AI 服务 就是一个 Java 接口 + 注解,这样开发获得的全自动的 AI 能力,AI Service 是一个代理的设计模式,你定义接口,框架自动生成实现类,自动把所有底层组件组装好之后,你的调用非常简单,就像 Spring Service 一样使用了,而且能自动支持聊天记忆,工具,RAG等功能。

这种方法与 Spring Data JPA 或 Retrofit 非常相似,你声明性地定义一个具有所需 API 的接口,然后 LangChain4j 提供一个实现此接口的对象(代理)。实际上,你可以将 AI 服务看作应用程序中服务层的一个组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 只需要定义一个接口
interface Assistant {
// 用注解定义提示词
@SystemMessage("你是一个友好的编程助手")
@UserMessage("帮我解释:{{question}}")
String answer(String question);
}

// 他能自动绑定记忆,自己就支持多轮对话,而且还自带一些别的内容
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.chatMemory(MessageWindowChatMemory.withMaxMessages(10))
.build();

// 框架自动创建实现
Assistant assistant = AiServices.create(Assistant.class, model);

// 直接调用
String answer = assistant.answer("什么是Java接口?");

AI 服务可用于构建有状态的聊天机器人,以促进来回交互,也可用于自动化流程,其中每次对 LLM 的调用都是独立的。

而链下面再说具体的内容,它是一个 多步骤、固定流程 的 AI 任务,就类似于编排一个以 LLM 为核心的工作流

项目示例

首先,我们 AI 服务在本章主要以 AI Services + Prompt + Structured Output 的形式来进行,因为结构化输出也很重要,但是目前本身先展示 AI Services 更强相关的内容

在引入了某个具体集成的 Starter 后,再额外引入:

1
2
3
4
5
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
<version>1.3.0-beta9</version>
</dependency>

流式对话 AI Service 接口

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* AI 助手服务接口 —— AI Services 核心用法演示。
*
* 开发者只需定义接口 + 注解,无需编写任何实现代码,框架会通过动态代理自动完成:
* <ol>
* <li>将方法参数格式化为 {@code UserMessage} / {@code SystemMessage}</li>
* <li>管理 {@code ChatMemory}(按 {@code @MemoryId} 隔离会话)</li>
* <li>调用底层 {@code StreamingChatModel} 或 {@code ChatModel}</li>
* <li>将模型响应转换为方法返回类型</li>
* </ol>
*/
@AiService
public interface AssistantService {

/**
* 带多会话记忆的流式对话方法。
*
* @param sessionId 会话唯一标识,用于记忆隔离
* @param message 用户输入消息
* @return {@code TokenStream},调用者需链式调用 {@code .onPartialResponse().start()} 启动流式输出
*/
@SystemMessage("""
你是一个友好、专业的 AI 助手,名叫「灵犀 Pro」。
你基于 LangChain4j AI Services 高级 API 构建,具备多会话记忆能力。
请用简洁、准确的语言回答用户问题,必要时提供示例。
""")
TokenStream chat(@MemoryId String sessionId, @UserMessage String message);

/**
* 支持动态角色设定的流式对话
*
* @param sessionId 会话 ID
* @param role 角色描述,如"资深 Java 工程师"、"英语老师"等
* @param message 用户消息
* @return 流式输出 TokenStream
*/
@SystemMessage("你是一位{{role}},请以该角色的专业视角和语气回答用户的问题。")
TokenStream chatWithRole(
@MemoryId String sessionId,
@V("role") String role,
@UserMessage String message
);

/**
* 同步阻塞式对话(无记忆,一次性调用)。
*
* 此接口绑定的是 StreamingChatModel,框架对于 String 返回类型会自动收集所有 token 后一次性返回,效果等同于同步调用。
*
* @param question 用户问题
* @return 完整的回答字符串
*/
@SystemMessage("你是一个简洁高效的 AI 助手,请用最少的文字给出最准确的答案。")
@UserMessage("请回答以下问题:{{it}}")
String quickAnswer(String question);
}

其中,可以看到,我们只是使用接口,在接口中声明的方法添加提示词相关的注解,就可以进行 AI Service 层的开发,而且完全屏蔽了底层大模型调用、会话记忆管理、消息封装的细节

其中的接口无需实现,由 Spring 配置(AiServiceConfig 中的 AiServices.builder())生成动态代理 Bean,注入到容器中。

其中涉及到了四个注解

  • @SystemMessage:定义系统提示词,对整个方法有效,支持 {{变量名}} 模板语法(模板变量必须用 @V 绑定)

    1
    2
    3
    4
    5
    @SystemMessage("""
    你是专业的Java编程助手。
    回答必须简洁、准确,禁止编造内容。
    当前用户等级:{{level}}
    """)
  • @UserMessage:定义用户消息模板,绑定方法参数作为用户输入

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 三种写法
    // 无模板
    @UserMessage
    String chat(String message);

    // 模板 + 单个参数
    @UserMessage("请解释这个概念:{{it}}") // {{it}} = 唯一参数的简写
    String explain(String topic);

    // 多参数模板
    @UserMessage("用户问题:{{question}},语言:{{lang}}")
    String answer(String question, String lang);
  • @MemoryId:标注会话 ID 参数,框架根据此 ID 隔离不同会话的记忆(ChatMemory),如果不加,所有对话共享一个记忆,加了的话,对于不同 ID 就拥有不同对话上下文

    1
    TokenStream chat(@MemoryId String userId, @UserMessage String msg);
  • @V("变量名"):为 @SystemMessage 中的模板变量绑定方法参数值,只用于系统提示词模板,用户消息模板不需要它,会自动匹配参数名

    1
    2
    3
    4
    5
    6
    7
    @SystemMessage("你是一位{{role}},专业领域:{{domain}}")
    TokenStream chat(
    @MemoryId String id,
    @V("role") String role, // 绑定 {{role}}
    @V("domain") String domain, // 绑定 {{domain}}
    @UserMessage String msg
    );

其中,对于服务接口中的基础流式对话 chat()方法

1
2
3
4
5
6
@SystemMessage("""
你是一个友好、专业的 AI 助手,名叫「灵犀 Pro」。
你基于 LangChain4j AI Services 高级 API 构建,具备多会话记忆能力。
请用简洁、准确的语言回答用户问题,必要时提供示例。
""")
TokenStream chat(@MemoryId String sessionId, @UserMessage String message);
  • 返回类型 TokenStream 是 LangChain4j 流式响应的核心类型,调用方可以通过链式 API 消费实时返回的 token

    1
    2
    3
    4
    5
    // 调用示例
    assistantService.chat("session-001", "什么是LangChain4j?")
    .onPartialResponse(token -> System.out.print(token)) // 实时接收每个token
    .onCompleteResponse(full -> System.out.println("\n完成"))
    .start(); // 启动流式输出

而对于角色扮演流式对话 chatWithRole()方法,这部分演示了动态系统提示词的内容

1
2
3
4
5
6
@SystemMessage("你是一位{{role}},请以该角色的专业视角和语气回答用户的问题。")
TokenStream chatWithRole(
@MemoryId String sessionId,
@V("role") String role,
@UserMessage String message
);
  • @SystemMessage 中使用 {{role}} 模板占位符,不再是固定角色
  • @V("role") String role 将方法参数 role 绑定到模板中的 {{role}}实现动态角色设定

实际上,这部分对于记忆的管理依旧是本地,通常情况下,我们会自定义 ChatMemoryProvider 控制记忆的存储,而且为 @MemoryId 配置默认值,或通过 AOP 自动注入会话 ID,但是我们的这部分并没有演示相关内容

为什么需要一个编排层来实现流式请求

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/**
* AI Services 门面层,负责编排 {@link AssistantService}
* 并将 {@link TokenStream} 桥接到 {@link SseEmitter} 以实现 SSE 流式响应。
*/
@Service
public class AiServiceFacade {

private final AssistantService assistantService;
private final StructuredOutputService structuredOutputService;
private final SimpleTypeOutputService simpleTypeOutputService;

public AiServiceFacade(AssistantService assistantService,
StructuredOutputService structuredOutputService,
SimpleTypeOutputService simpleTypeOutputService) {
this.assistantService = assistantService;
this.structuredOutputService = structuredOutputService;
this.simpleTypeOutputService = simpleTypeOutputService;
}

/**
* 普通流式对话,将 AI Services 的 {@link TokenStream} 转为 SSE 推送。
*
* @param sessionId 会话 ID
* @param message 用户消息
* @param emitter SSE 推送器
*/
public void streamChat(String sessionId, String message, SseEmitter emitter) {
TokenStream tokenStream = assistantService.chat(sessionId, message);
bridgeTokenStreamToSse(tokenStream, emitter);
}

/**
* 角色扮演流式对话。
*
* @param sessionId 会话 ID
* @param role 角色描述
* @param message 用户消息
* @param emitter SSE 推送器
*/
public void streamChatWithRole(String sessionId, String role, String message, SseEmitter emitter) {
TokenStream tokenStream = assistantService.chatWithRole(sessionId, role, message);
bridgeTokenStreamToSse(tokenStream, emitter);
}

/**
* 快速问答(同步,无记忆)。
*
* @param question 问题
* @return 回答字符串
*/
public String quickAnswer(String question) {
return assistantService.quickAnswer(question);
}

/**
* 将 {@link TokenStream} 桥接到 {@link SseEmitter}。
*/
private void bridgeTokenStreamToSse(TokenStream tokenStream, SseEmitter emitter) {
tokenStream
.onPartialResponse(token -> {
try {
emitter.send(SseEmitter.event().data(token));
} catch (IOException e) {
emitter.completeWithError(e);
}
})
.onCompleteResponse(response -> {
try {
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
} catch (IOException ignored) {
}
emitter.complete();
})
.onError(error -> {
try {
emitter.send(SseEmitter.event().name("error").data(error.getMessage()));
} catch (IOException ignored) {
}
emitter.completeWithError(error);
})
.start();
}
}

首先,门面模式是一种结构型设计模式,核心目标是为复杂的子系统提供一个统一的入口,隐藏内部复杂逻辑,简化外部调用。

因为,在我们需要一个流式对话的情况,虽然在流式响应场景下,可以将 AI Service 的返回类型声明为 Flux<String>来实现流式的情况,但是我更想用原生的 SSE,就需要这么一个TokenStream → SSE 的桥接逻辑

1
2
3
4
5
6
@AiService
interface Assistant {

@SystemMessage("You are a polite assistant")
Flux<String> chat(String userMessage);
}

这是因为 LangChain4j AI Services 对 流式响应 有专属的返回类型约定,框架原生支持 TokenStream 作为流式返回类型,TokenStream 内置了 onPartialResponse()/onComplete() 等链式 API,但是若强行用 Flux<String>,需要手动将 TokenStream 转换为 Reactor 类型,这会带来成本

代码库中所有流式接口最终对外暴露的是 SseEmitter,因为我感觉,前端消费 AI 流式响应时,SSE 是比 raw Flux 更通用的选择,而 SseEmitter 是 Spring 对 SSE 的原生支持,不用特意去搞 Reactor 响应式和langchain4j-reactor

测试一下效果

先测试一下流式对话,嗯嗯,没什么问题

image-20260602110605802

然后使用提示词模板注入的流式对话,可以发现,注入的效果还是很明显的

image-20260602110638024

上述的示例项目中的关键内容

AI Service 如何编排的

在最简单的情况下,我们可以剥开我们的例子让它在更加简单的时候更加原始

首先,我们定义一个只有一个方法 chat 的接口,没实现类,它接受一个 String 作为输入并返回一个 String。什么意思,差不多就是,我只想要一个 聊天方法,输入一句话,返回一句话,我不在乎细节,不想写实现逻辑,不想调大模型,不想处理消息格式。

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

然后,我们创建对应的低级组件。这些组件将在我们的 AI 服务底层使用。

1
2
3
4
ChatModel model = OpenAiChatModel.builder()
.apiKey(System.getenv("DEEPSEEK_API_KEY"))
.modelName("deepseek-v4-pro")
.build();

它的用法很原始:

1
model.chat(chatMessage); // 只能发 ChatMessage 类型,不能直接发 String

最后,我们可以使用 AiServices 类来创建 AI 服务的一个实例,动态生成一个代理类:

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

现在我们可以使用 Assistant 这个 AI Service 接口了

1
2
String answer = assistant.chat("Hello");
System.out.println(answer);

那么,上面发生了什么

  • 你将接口的 Class 以及低级组件提供给 AiServices,然后通过反射 AiServices 会创建一个实现此接口的代理对象。这个代理对象处理所有输入和输出的转换。
  • 在本例中,输入是一个 String,但我们使用的是 ChatModel,它接受 ChatMessage 作为输入。因此,AiService 会自动将其转换为 UserMessage 并调用 ChatModel
  • 由于 chat 方法的输出类型是 String,在 ChatModel 返回 AiMessage 后,它将在从 chat 方法返回之前被转换成 String

什么意思,也就是说,整个 AiServices.create 创建的代理类在你调用了String answer = assistant.chat("Hello");的时候做了这些事情

1
2
3
4
5
6
7
8
9
10
11
你传入 String "Hello"

代理自动转 UserMessage("Hello")

调用底层 model.chat(message)

模型返回 AiMessage("Hi there!")

代理自动转 String "Hi there!"

返回给你

这么一个官方文档的例子你看完了就会发现,我草,我怎么看不懂。。。。因为实际上我们并非这样进行的开发,Spring Boot Starter 把上面所有代码全部自动帮你写了,声明了注解,就会有对应的东西自己产生

上面官方提到的示例中,interface Assistant { ... },实际上就是

1
2
@AiService
interface Assistant { ... }

ChatModel model = ...可以对应application.yml 中的配置然后自动创建 Bean,assistant.chat(...)相当于@Autowired 注入后直接调用,而AiServices.create(...)这步及其生成代理都是 Starter 自动执行了

1
2
3
4
@AiService
public interface Assistant {
String chat(String message);
}
1
2
3
4
5
@RestController
public class Controller {
@Autowired
Assistant assistant;
}

对于流式响应的场景下,可以将 AI Service 的返回类型声明为 Flux<String>,需要引入 langchain4j-reactor 模块。

1
2
3
4
5
@AiService
interface Assistant {
@SystemMessage("You are a polite assistant")
Flux<String> chat(String userMessage);
}

这下看懂了,实际上,AI Service (带@AiService的接口) 就是一个普通 Spring Bean,你可以注入到Controller,Service,Component 等任何 Spring 管理的类

包装返回类型

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

  • String:在这种情况下,返回 LLM 生成的输出,不进行任何处理/解析
  • 结构化输出:支持的任何类型,在这种情况下,AI 服务将在返回前将 LLM 生成的输出解析为所需类型,下面再说

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

  • TokenUsage:AI 服务调用期间使用的令牌总数。如果 AI 服务对 LLM 进行了多次调用(例如,因为执行了工具),它将对所有调用的令牌使用量求和。
  • RAG 检索期间检索到的 Content
  • AI 服务调用期间执行的所有工具(包括请求和结果)
  • 最终聊天响应的 FinishReason
  • 所有中间 ChatResponse
  • 最终 ChatResponse

一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Assistant {

@UserMessage("Generate an outline for the article on the following topic: {{it}}")
Result<List<String>> generateOutlineFor(String topic);
}

Result<List<String>> result = assistant.generateOutlineFor("Java");

List<String> outline = result.content();
TokenUsage tokenUsage = result.tokenUsage();
List<Content> sources = result.sources();
List<ToolExecution> toolExecutions = result.toolExecutions();
FinishReason finishReason = result.finishReason();

Spring Boot 集成的组件注入

如果上下文中存在以下组件,它们会被自动注入到 AI Service:

  • ChatModel
  • StreamingChatModel
  • ChatMemory
  • ChatMemoryProvider
  • ContentRetriever
  • RetrievalAugmentor
  • 所有 @Component@Service 类中带 @Tool 注解的方法

什么意思,差不多就是你只要把这些 Bean 放到 Spring 容器里, Starter 会自动绑定给 AI Service。不需要写这种.chatModel(),.chatMemoryProvider()这种原始API的链式调用方法,全部自动注入

如果你有多个 AI Service,且需要为它们分别指定不同的组件,可以通过 @AiService(wiringMode = EXPLICIT) 来启用显式注入。

例如,假设配置了两个 ChatModel

1
2
3
4
5
6
7
# OpenAI
langchain4j.open-ai.chat-model.api-key=${OPENAI_API_KEY}
langchain4j.open-ai.chat-model.model-name=gpt-4o-mini

# Ollama
langchain4j.ollama.chat-model.base-url=http://localhost:11434
langchain4j.ollama.chat-model.model-name=llama3.1

可以这样指定:

1
2
3
4
5
6
7
8
9
10
11
12
13
@AiService(wiringMode = EXPLICIT, chatModel = "openAiChatModel")
interface OpenAiAssistant {

@SystemMessage("You are a polite assistant")
String chat(String userMessage);
}

@AiService(wiringMode = EXPLICIT, chatModel = "ollamaChatModel")
interface OllamaAssistant {

@SystemMessage("You are a polite assistant")
String chat(String userMessage);
}

而且,在完成声明式 AI Service 开发后,可以通过实现ApplicationListener<AiServiceRegisteredEvent> 来监听 AI Service 注册事件。该事件会在 AI Service 注册到 Spring 容器时触发,允许你在运行时获取已注册的 AI Service 及其工具信息

当 Spring 启动时,扫描到 @AiService然后生成代理实现类,把它放进 Spring 容器,我们就认为它注册好了一个 @AiService 接口,每注册好一个 @AiService 接口,就会触发一次 AiServiceRegisteredEvent 这个事件。你可以在这个事件里,拿到这个 AI 服务、知道它绑定了哪些工具。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
class AiServiceRegisteredEventListener implements ApplicationListener<AiServiceRegisteredEvent> {

@Override
public void onApplicationEvent(AiServiceRegisteredEvent event) {
// 哪个 AI Service 被注册了?
Class<?> aiServiceClass = event.aiServiceClass();
// 这个 AI Service 拥有哪些工具?
List<ToolSpecification> toolSpecifications = event.toolSpecifications();
for (int i = 0; i < toolSpecifications.size(); i++) {
System.out.printf("[%s]: [Tool-%s]: %s%n", aiServiceClass.getSimpleName(), i + 1, toolSpecifications.get(i));
}
}
}

其实这部分涉及到的自动注入和依赖装配的内容,跟 Spring Boot 一绑定起来讲还是很复杂的,但是我只说了官方文档中的内容,剩下的代理到底是怎么生成的,记忆RAG什么的如何自动注入的等,说实话我懒得讲了而且貌似 AI 开发不像微服务,业务开发什么的,这种东西没那么关注的必要

AI Service 涉及到的注解

其中

  • @AiService

    它类似于一个标准的 Spring Boot @Service,但自带 AI 能力。应用启动时,LangChain4j Starter 会扫描所有带 @AiService 的接口,为每个接口生成实现类,并将其注册为 Spring Bean

  • @SystemMessage

    定义系统提示词,对整个方法有效,支持 {{变量名}} 模板语法(模板变量必须用 @V 绑定)

    1
    2
    3
    4
    5
    @SystemMessage("""
    你是专业的Java编程助手。
    回答必须简洁、准确,禁止编造内容。
    当前用户等级:{{level}}
    """)

    而且,@SystemMessage 还可以从资源中加载提示模板,例如@SystemMessage(fromResource = "my-prompt-template.txt")

  • @UserMessage

    定义用户消息模板,绑定方法参数作为用户输入,对于绑定方法参数

    注意,如果不是与 Quarkus 或 Spring Boot 一起使用 LangChain4j 时,使用 @V 是必需的。只有在 Java 编译时未启用 -parameters 选项时,才需要此注解。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 三种写法
    // 无模板
    @UserMessage
    String chat(String message);

    // 模板 + 单个参数
    @UserMessage("请解释这个概念:{{it}}") // {{it}} = 唯一参数的简写
    String explain(String topic);

    // 多参数模板
    @UserMessage("用户问题:{{question}},语言:{{lang}}")
    String answer(String question, String lang);

    同样的,@UserMessage 也可以从资源中加载提示模板,@UserMessage(fromResource = "my-prompt-template.txt")

  • @MemoryId

    标注会话 ID 参数,框架根据此 ID 隔离不同会话的记忆(ChatMemory),如果不加,所有对话共享一个记忆,加了的话,对于不同 ID 就拥有不同对话上下文

    1
    TokenStream chat(@MemoryId String userId, @UserMessage String msg);
  • @V("变量名")

    @SystemMessage 中的模板变量绑定方法参数值,只用于系统提示词模板,用户消息模板不需要它,会自动匹配参数名

    1
    2
    3
    4
    5
    6
    7
    @SystemMessage("你是一位{{role}},专业领域:{{domain}}")
    TokenStream chat(
    @MemoryId String id,
    @V("role") String role, // 绑定 {{role}}
    @V("domain") String domain, // 绑定 {{domain}}
    @UserMessage String msg
    );
  • @Moderate

    内容安全审核,自动过滤违规内容。如果内容违规,直接抛出异常,不发给大模型,也不返回违规内容。这个需要自己去配置一个ModerationModel

    1
    2
    @Moderate
    String chat(@UserMessage String msg);
  • @Timeout

    设置 AI 调用超时时间,单位是秒,对外 API 接口一般加

    1
    2
    @Timeout(30) // 30秒
    String ask(@UserMessage String msg);
  • @Temperature

    设置模型温度(随机性),值越小,回答越准确、固定、严谨,值越大,回答越创意、发散、随机

    1
    2
    @Temperature(0.1) 
    String answer(@UserMessage String q);
  • @MaxTokens

    最大输出 token 限制

    1
    2
    @MaxTokens(50000)  // 最多输出 50000 token
    String shortAnswer(@UserMessage String question);

其中,没有被提到的结构化输出和工具调用的相关注解,等到用到的时候再细说