封面来源:由博主个人绘制,如需使用请联系博主。

参考链接:尚硅谷 LangChain4j 实战

LangChain4J 官网:LangChain4j

源码仓库:mofan212/langchain4j-demo

1. 开工前的准备

1.1 前置约定

以阿里百炼平台(通义千问)为主,并辅以 DeepSeek 模型。

涉及的所有调用均基于 OpenAI 协议标准或者 SpringBoot 官方推荐整合规则, 实现一致的接口设计与规范,确保多模型切换的便利性,提供高度可扩展的开发支持。

1.2 三件套准备

阿里百炼平台:大模型服务平台百炼控制台

三件套是什么?

  1. 获得 API-KEY
  2. 获得模型名
  3. 获得 base_url 开发地址

获得 API-KEY

登录控制台后,创建自己的 API-KEY:

创建自己的API-KEY

获得模型名

选择需要的模型并查看详情

「模型名」是模型对应的 Code,而不是模型名称:

模型名是模型Code

获得 base_url 开发地址

查看阿里云百炼API参考

使用SDK调用时需要配置的base_url

2. 入门案例

2.1 Hello World

父工程内需要的配置与依赖:

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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- Spring Boot -->
<spring-boot.version>3.5.0</spring-boot.version>
<!-- langchain4j -->
<langchain4j.version>1.0.1</langchain4j.version>
<!--langchain4j-community 引入阿里云百炼平台依赖管理清单-->
<langchain4j-community.version>1.0.1-beta6</langchain4j-community.version>
<!-- maven plugin -->
<maven-deploy-plugin.version>3.1.1</maven-deploy-plugin.version>
<flatten-maven-plugin.version>1.3.0</flatten-maven-plugin.version>
<maven-compiler-plugin.version>3.8.1</maven-compiler-plugin.version>
</properties>

<dependencyManagement>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!--
langchain4j 的依赖清单,加载BOM后所有 langchain4j 版本号可以被统一管理起来
https://docs.langchain4j.dev/get-started
-->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>${langchain4j.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!--
引入阿里云百炼平台依赖管理清单
https://docs.langchain4j.dev/integrations/language-models/dashscope
-->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-bom</artifactId>
<version>${langchain4j-community.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
<version>${maven-deploy-plugin.version}</version>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<release>${java.version}</release>
<compilerArgs>
<compilerArg>-parameters</compilerArg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>${flatten-maven-plugin.version}</version>
<inherited>true</inherited>
<executions>
<execution>
<id>flatten</id>
<phase>process-resources</phase>
<goals>
<goal>flatten</goal>
</goals>
<configuration>
<updatePomFile>true</updatePomFile>
<flattenMode>ossrh</flattenMode>
<pomElements>
<distributionManagement>remove</distributionManagement>
<dependencyManagement>remove</dependencyManagement>
<repositories>remove</repositories>
<scm>keep</scm>
<url>keep</url>
<organization>resolve</organization>
</pomElements>
</configuration>
</execution>
<execution>
<id>flatten.clean</id>
<phase>clean</phase>
<goals>
<goal>clean</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
<repository>
<id>aliyunmaven</id>
<name>aliyun</name>
<url>https://maven.aliyun.com/repository/public</url>
</repository>
</repositories>

<pluginRepositories>
<pluginRepository>
<id>public</id>
<name>aliyun nexus</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>

子工程内引入以下依赖:

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
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- https://docs.langchain4j.dev/get-started -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

API-Key 配置到环境变量

复制在阿里云百炼中配置的 API-Key,将其配置到系统环境变量中,名称任意,比如:

配置API-Key到环境变量

本文使用 AliQwen_Key 作为环境变量名称。

配置完环境变量后,务必重新 IDEA,避免运行时无法成功读取到环境变量!

创建配置类,设置 API-Key、模型名称和调用地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author mofan
* @date 2025/7/10 0:00
*/
@Configuration
public class LLMConfig {
@Bean
public ChatModel chatModelQwen() {
return OpenAiChatModel.builder()
// 从环境变量中获取配置的 API-Key(配置环境变量后,记得重启 IDEA,否则可能获取不到!)
.apiKey(System.getenv("AliQwen_Key"))
.modelName("qwen-plus")
.baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
.build();
}
}

使用 ChatModel#chat() 方法调用大模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author mofan
* @date 2025/7/10 0:05
*/
@Slf4j
@RestController
public class HelloLangChain4JController {

@Autowired
private ChatModel chatModel;

@GetMapping("/langchain4j/hello")
public String hello(@RequestParam(value = "question", defaultValue = "你是谁") String question) {
return chatModel.chat(question);
}
}

启动服务,浏览器访问 http://localhost:9001/langchain4j/hello

LangChain4J的Hello-World示例

2.2 多模型共存

如果需要配置多个模型,只需在配置类中定义不同名称、ChatModel 类型的 Bean 即可:

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
@Configuration
public class LLMConfig {

@Bean(name = "qwen")
public ChatModel chatModelQwen() {
return OpenAiChatModel.builder()
.apiKey(System.getenv("AliQwen_Key"))
.modelName("qwen-plus")
.baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
.build();
}

/**
* <a href="https://platform.deepseek.com/usage">DeepSeek 开放平台</a>
* <a href="https://api-docs.deepseek.com/zh-cn/">DeepSeek API 文档</a>
*/
@Bean(name = "deepseek")
public ChatModel chatModelDeepSeek() {
return OpenAiChatModel.builder()
.apiKey(System.getenv("DeepSeek_Key"))
.modelName("deepseek-chat")
.baseUrl("https://api.deepseek.com/v1")
.build();
}
}

按需注入对应的 Bean 即可完成调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Slf4j
@RestController
public class MultiModelController {

@Resource(name = "qwen")
private ChatModel chatModelQwen;

@Resource(name = "deepseek")
private ChatModel chatModelDeepSeek;

@GetMapping("/multi-model/qwen")
public String qwenCall(@RequestParam(value = "prompt", defaultValue = "你是谁") String prompt) {
return chatModelQwen.chat(prompt);
}

@GetMapping("/multi-model/deepseek")
public String deepseekCall(@RequestParam(value = "prompt", defaultValue = "你是谁") String prompt) {
return chatModelDeepSeek.chat(prompt);
}
}

3. 整合 SpringBoot

官方介绍:Spring Boot Integration

LangChain4J 有 两种抽象级别

  1. 低阶。在这个级别,你可以最自由地访问所有底层组件,比如 ChatModelUserMessageAiMessageEmbeddingStoreEmbedding 等等。这些是由 LLM 驱动的应用程序的「原语」(primitives)。你可以完全控制如何组合它们,但需要编写更多的胶水代码。
  2. 高阶。在这个级别,你可以使用高级 API(如 AI Services)与 LLM 进行交互,从而隐藏所有复杂性和样板代码。你仍可以以声明式的方式对模型进行微调。

LangChain4J-Components

导入以下两个依赖,分别对应低级和高级抽象级别的使用:

1
2
3
4
5
6
7
8
9
10
<!--    普通 Starter    -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
</dependency>
<!-- 在普通 Starter 的基础上,增加更多自动配置 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
</dependency>

可以在 .yaml 文件中配置大模型的必要信息(也可以使用配置类):

1
2
3
4
5
6
langchain4j:
open-ai:
chat-model:
api-key: ${AliQwen_Key}
model-name: qwen-plus
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1

使用低级抽象级别

和先前的使用方式不变,注入 ChatModel 即可:

1
2
3
4
5
6
7
8
9
10
@RestController
public class PopularIntegrationController {
@Resource
private ChatModel chatModel;

@GetMapping("/lc4j/boot/chat")
public String chat(@RequestParam(value = "prompt", defaultValue = "你是谁") String prompt) {
return chatModel.chat(prompt);
}
}

使用高级抽象级别

使用声明式 AI Service 时,需要定义一个被 @AiService 注解标记的接口:

1
2
3
4
@AiService
public interface ChatAssistant {
String chat(String userMessage);
}

然后注入并使用即可:

1
2
3
4
5
6
7
8
9
10
@RestController
public class DeclarativeAIServiceController {
@Resource
private ChatAssistant chatAssistant;

@GetMapping("/lc4j/boot/declarative")
public String declarative(@RequestParam(value = "prompt", defaultValue = "who are you?") String prompt) {
return chatAssistant.chat(prompt);
}
}

4. 低阶和高阶 API

4.1 低阶 API

官方介绍:Chat and Language Models

LLM 目前有两种 API 类型:

  1. LanguageModel:它们的 API 非常简单,接收一个字符串入参,并返回一个字符串出参。这种 API 目前已经过时,取而代之的是 chat API(接下来的第二章 API 类型);
  2. ChatModel:接收多种 ChatMessage 作为入参,并返回一个 AiMessage 作为出参。ChatMessage 通常包含文本,但一些 LLM 也支持其他模态的输入(图片,音频等),比如 OpenAI 的 gpt-4o-mini 和谷歌的 gemini-1.5-pro

LangChain4J 不再拓展对 LanguageModel 的支持,因此在所有新特新中,都将使用 ChatModel

ChatModel 是 LangChain4J 中与 LLM 进行交互的低阶 API,提供了强大的功能和灵活性。

ChatModel 外,UserMessageAiMessageEmbeddingStoreEmbedding 等也属于低阶 API。

4.2 高阶 API

官方介绍:AI Services

使用低阶 API 可以自由组合各个组件,但需要编写更多的胶水代码。由 LLM 驱动的应用程序不仅需要单个组件,通常需要多个组件相互协同工作,编排它们也变得更加繁琐。

为了更专注于业务逻辑,而不是低阶实现细节,为此 LangChain4J 提供了 AI Services 和 Chains 两个高级概念。其中的 Chain 被官方认定为过时的,而 AI Services 是专为 Java 量身定制的解决方案。

使用高阶 API 时,需要自行定义接口,调用 AiServices 类中的方法进行实现,不仅减少了代码的复杂性,也可以进行灵活的微调(见文档:Simplest AI Service)。

使用示例

定义接口:

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

定义配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class LLMConfig {

@Bean(name = "qwen")
public ChatModel chatModelQwen() {
return OpenAiChatModel.builder()
.apiKey(System.getenv("AliQwen_Key"))
.modelName("qwen-plus")
.baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
.build();
}

@Bean
public ChatAssistant chatAssistant(@Qualifier("qwen") ChatModel chatModelQwen) {
return AiServices.create(ChatAssistant.class, chatModelQwen);
}
}

最后使用:

1
2
3
4
5
6
7
8
9
10
@RestController
public class HighApiController {
@Resource
private ChatAssistant chatAssistant;

@GetMapping("/highapi/highapi")
public String highApi(@RequestParam(value = "prompt", defaultValue = "你是谁") String prompt) {
return chatAssistant.chat(prompt);
}
}

在 Web 开发中,定义一个 Service 后,需要定义其对应的实现类。现在使用高阶 API 定义接口后,不用再定义实现类,LangChain4J 会帮你完成。

注意与前文使用案例的区分,创建 Assistant 接口后,并没有使用 @AiService 标记它,因为 @AiService 是与 SpringBoot 集成所需要的。

工作原理

官方介绍:How does it work?

简单来说,将接口的 Class 对象和低阶组件(ChatModel)传给 AiServices 后,会使用反射创建接口的代理对象。

4.3 Token

参考 DeepSeek API 文档中的 介绍

token 是模型用来表示自然语言文本的基本单位,也是我们的计费单元,可以直观的理解为「字」或「词」;通常 1 个中文词语、1 个英文单词、1 个数字或 1 个符号计为 1 个 token。

一般情况下模型中 token 和字数的换算比例大致如下:

  • 1 个英文字符 ≈ 0.3 个 token。
  • 1 个中文字符 ≈ 0.6 个 token。

但因为不同模型的分词不同,所以换算比例也存在差异,每一次实际处理 token 数量以模型返回为准,您可以从返回结果的 usage 中查看。

Token 一词在 Web 开发中也很常见。在 Web 开发中,token 通常指的是用于认证和授权的一种加密字符串。它被用来确保用户身份的安全验证,比如 JWT。这类 token 由服务器生成,并发给客户端保存(比如存储在浏览器的本地存储或 Cookie 中),之后每次请求都需要携带这个 token 来证明用户的身份。

使用低阶 API 获取 token 用量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Resource(name = "deepseek")
private ChatModel chatModelDeepSeek;

/**
* Token 用量计算的底层 API 演示验证案例
*/
@GetMapping("/lowapi/api02")
public String getTokenUsage(@RequestParam(value = "prompt", defaultValue = "你是谁") String prompt) {
ChatResponse response = chatModelDeepSeek.chat(UserMessage.from(prompt));
String result = response.aiMessage().text();
System.out.println("调用大模型的返回结果: " + result);
TokenUsage tokenUsage = response.tokenUsage();
System.out.println("本次调用消耗的 token: " + tokenUsage);
return result + "\t\n" + tokenUsage;
}

5. 模型参数

官方介绍:Model Parameters

在与 SpringBoot 进行整合时,可以在配置文件中配置模型参数,参考:Spring Boot

日志

官方介绍:Logging

在配置类中添加 ChatModel 类型的 Bean 时调用 logRequests(true)logResponses(true) 方法:

1
2
3
4
5
OpenAiChatModel.builder()
// --snip--
.logRequests(true)
.logResponses(true)
.build();

然后添加日志依赖,比如:

1
2
3
4
5
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback.version}</version>
</dependency>

不需要在配置文件里设置日志级别为 DEBUG。

监听

官方介绍:Observability

整合了 SpringBoot 时,可以在配置类中进行监听的配置:Observability | Spring Boot Integration

实现 ChatModelListener 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Slf4j
public class TestChatModelListener implements ChatModelListener {
@Override
public void onRequest(ChatModelRequestContext requestContext) {
String uuid = UUID.randomUUID().toString();
requestContext.attributes().put("TraceId", uuid);
log.info("请求参数 requestContext: {}", requestContext + "\t" + uuid);
}

@Override
public void onResponse(ChatModelResponseContext responseContext) {
Object traceId = responseContext.attributes().get("TraceId");
log.info("返回结果 responseContext: {}", responseContext + "\t" + traceId);
}

@Override
public void onError(ChatModelErrorContext errorContext) {
log.error("请求异常 errorContext: {}", errorContext);
}
}

构造 ChatModel 时,添加监听器:

1
2
3
4
OpenAiChatModel.builder()
// --snip--
.listeners(List.of(new TestChatModelListener()))
.build();

ChatModel#chat() 方法与大模型进行交互时,且存在监听器时,整个流程如下:

flowchart TD
    A("ChatModelListener#onRequest()")
    B("ChatModel#chat()")
    C{"是否出现异常"}
    D("ChatModelListener#onResponse()")
    E("ChatModelListener#onError()")

    A --> B --> C
    C -- 是 --> E
    C -- 否 --> D

重试

默认重试 3 次。

可以在构造 ChatModel 时调用 maxRetries() 方法进行配置,在与 SpringBoot 进行整合时,也可以在配置文件中以下方形式进行配置:

1
2
3
4
langchain4j:
open-ai:
chat-model:
max-retries: 3

超时

向大模型发送请求时,如果在制定时间内没有收到响应,该请求会被终端,并提示超时。

同样的,既可以在构造 ChatModel 时调用对应方法进行配置:

1
2
3
4
OpenAiChatModel.builder()
// --snip--
.timeout(Duration.ofSeconds(2))
.build();

与 SpringBoot 进行整合时,也可以在配置文件中以下方形式配置:

1
2
3
4
langchain4j:
open-ai:
chat-model:
timeout: 2s

6. 多模态视觉理解

官方介绍:Multimodality

UserMessage 不仅仅可以包含文本,还可以包含其他类型的内容。UserMessage 包含一个 List<Content> contents

Content 是一个接口,有下列实现:

  • TextContent
  • ImageContent
  • AudioContent
  • VideoContent
  • PdfFileContent

可以从 这个表 中查看哪些 LLM 提供商提供了哪些输入模式。

6.1 解读图片

本节选用 通义千问VL-Max 作为使用的模型。

相应地需要调整配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class LLMConfig {

@Bean(name = "qwen")
public ChatModel chatModelQwen() {
return OpenAiChatModel.builder()
.apiKey(System.getenv("AliQwen_Key"))
.modelName("qwen-vl-max")
.baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
.build();
}

}

需要将图片转换成 Base64 字符串作为提示词:

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
@RestController
public class ImageModelController {

@Resource
private ChatModel chatModel;

@Value("classpath:static/images/mi.jpg")
private org.springframework.core.io.Resource resource;

@SneakyThrows
@GetMapping("image/call")
public String readImageContent() {
// 1. 通过 Base64 编码将图片转化为字符串
byte[] byteArray = resource.getContentAsByteArray();
String base64Data = Base64.getEncoder().encodeToString(byteArray);

// 2. 指定提示词
UserMessage userMessage = UserMessage.from(
TextContent.from("从下面图片中获取来源网站名称、股价走势和 5 月 30 日的股价"),
ImageContent.from(base64Data, "image/jpg")
);

// 3. api 调用
ChatResponse response = chatModel.chat(userMessage);

// 4. 解析与输出
return response.aiMessage().text();
}
}

6.2 接入三方平台

以接入 DashScope(Qwen) 为例,按 官网 说明,父工程新增 Maven 配置(最开始其实已经导入):

1
2
3
4
5
6
7
8
9
<dependencyManagement>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-bom</artifactId>
<version>${langchain4j-community.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencyManagement>

在子工程导入接入 DashScope 需要的依赖:

1
2
3
4
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-dashscope-spring-boot-starter</artifactId>
</dependency>

使用 wanx2.1-t2i-turbo 模型完成文生图:

1
2
3
4
5
6
7
8
9
10
11
/**
* 使用通义万相实现图片生成
*/
@Bean
public WanxImageModel wanxImageModel() {
return WanxImageModel.builder()
.apiKey(System.getenv("AliQwen_Key"))
// 已经指定了使用通义万相,因此不再需要配置 baseUrl
.modelName("wanx2.1-t2i-turbo")
.build();
}

7. 流式输出

7.1 基本概念

流式输出(StreamingOutput)是一种逐步返回大模型生成结果的技术,允许服务器将响应内容分批次实时传输给客户端,而不是等待全部内容生成完毕后再一次性返回。

这种机制能显著提升用户体验,尤其适用于大模型响应较慢的场景(如生成长文本或复杂推理结果)。

官方介绍:Response Streaming

LLM 一次生成一个 token,因此大多数 LLM 提供商都提供了逐个 token 流式输出的方式,而不是等待整个文本生成完成后才输出。这极大地改善了用户的体验,用户无需再等待未知的时间,可以立即开始阅读 LLM 的输出内容。

ChatModel 对应,其流式输出接口是 StreamingChatModel,它接收一个 StreamingChatResponseHandler 接口的实现作为参数。

1
2
3
4
5
6
7
public interface StreamingChatResponseHandler {
void onPartialResponse(String partialResponse);

void onCompleteResponse(ChatResponse completeResponse);

void onError(Throwable error);
}

通过实现 StreamingChatResponseHandler 接口,可以为下列事件定义操作:

  • 当下一部分响应生成时,将调用 onPartialResponse() 方法,这部分响应可以包含一个和多个 token,你可以将这些 token 直接发送给 UI;
  • 当 LLM 完成内容生成时,将调用 onCompleteResponse() 方法。ChatResponse 对象包含完整的响应(AiMessage)以及响应元数据(ChatResponseMetadata);
  • 当发生错误时,onError() 方法将被执行。

ChatModel 一样,StreamingChatModel 只是流式输出的低阶 API,使用高阶 API AI Service 时,将 TokenStream 作为返回类型就可以实现流式输出。

除此之外,还可以使用 Flux<String> 作为返回类型,但这时需要额外引入 Maven 坐标:

1
2
3
4
5
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-reactor</artifactId>
<version>${langchain4j.version}</version>
</dependency>

7.2 编码实战

Maven 中引入 langchain4j-reactor,在配置文件中添加以下信息:

1
2
3
4
5
6
7
server:
# 设置响应的字符编码,避免流式返回输出乱码
servlet:
encoding:
charset: utf-8
enabled: true
force: true

定义 AI Service 接口:

1
2
3
public interface ChatAssistant {
Flux<String> chatFlux(String prompt);
}

在配置类中完成对 StreamingChatModel 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class LLMConfig {

@Bean
public StreamingChatModel streamingChatModel() {
return OpenAiStreamingChatModel.builder()
.apiKey(System.getenv("AliQwen_Key"))
.modelName("qwen-plus")
.baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
.build();
}

@Bean
public ChatAssistant chatAssistant(StreamingChatModel streamingChatModel) {
return AiServices.create(ChatAssistant.class, streamingChatModel);
}
}

最后是一份简单的食用指南:

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
@Slf4j
@RestController
public class StreamChatModelController {

@Resource
private StreamingChatModel streamingChatModel;
@Resource
private ChatAssistant chatAssistant;

@GetMapping("/chat-stream/chat")
public void simpleChat(@RequestParam(value = "prompt", defaultValue = "成都有什么吃的?") String prompt) {
streamingChatModel.chat(prompt, new StreamingChatResponseHandler() {

@Override
public void onPartialResponse(String s) {
System.out.println(s);
}

@Override
public void onCompleteResponse(ChatResponse chatResponse) {
System.out.println("-- response over: " + chatResponse);
}

@Override
public void onError(Throwable throwable) {
log.error(throwable.getMessage());
}
});
}

@GetMapping("/chat-stream/chat-flux")
public Flux<String> chatFlux(@RequestParam(value = "prompt", defaultValue = "重庆有什么好吃的?") String prompt) {
return Flux.create(i -> streamingChatModel.chat(prompt, new StreamingChatResponseHandler() {
@Override
public void onPartialResponse(String s) {
i.next(s);
}

@Override
public void onCompleteResponse(ChatResponse chatResponse) {
i.complete();
}

@Override
public void onError(Throwable throwable) {
i.error(throwable);
}
}));
}

@GetMapping("/chat-stream/high-level-chat-flux")
public Flux<String> highLevelChatFlux(@RequestParam(value = "prompt", defaultValue = "浙江有什么好吃的?") String prompt) {
return chatAssistant.chatFlux(prompt);
}
}

8. Chat Memory

官方介绍:Chat Memory

Chat Memory 是聊天系统中的重要组件,用于存储和管理对话的上下文信息。它的主要作用是让 AI 助手能够「记住」之前的对话内容,从而提供连贯和个性化的回复。

手动维护和管理 ChatMessage 非常麻烦,因此 LangChain4J 提供了一个 ChatMemory 抽象以及多种开箱即用的实现。ChatMemory 既可以作为独立的底层组件使用,也可以作为 AI Service 等高级组件的一部分使用。

ChatMemory 作为 ChatMessage 的容器(由 List 支持),并具有以下附加功能:

  • 驱逐策略
  • 持久性
  • SystemMessage 的特殊处理
  • tool messages 的特殊处理

8.1 Memory vs History

「memory」和「history」是既相似但又不同的概念:

  • 「history」完整地保存了用户和 AI 之间的所有信息。「history」是用户在 UI 中看到的内容,代表实际说过的话。
  • 「memory」会保留一些信息,这些信息被呈现给 LLM,使其表现得好像「记住」了对话。「memory」和「history」截然不同。根据使用的不同「memory」算法,可以以各种方式修改「history」:驱逐一些消息、汇总多条消息、汇总单独的消息、删除消息中不重要的细节、向消息中注入额外的信息(如 RAG)或指令(如结构化输出)等等。

LangChain4J 目前只提供「memory」,不提供「history」,如果需要保存整个「history」,请手动操作。

8.2 驱逐策略

出于以下几个原因,驱逐策略是必要的:

  • 适应 LLM 的上下文窗口。LLM 一次可以处理的 token 数量是有限的,某些时候可能会超过这个限制。在这种情况下,一些消息应该被驱逐。通常情况下,最旧的消息会被驱逐,但如果需要,也可以实现更复杂的算法。
  • 控制成本。每个 token 都是有成本的,这使得每次调用 LLM 的成本逐渐增加,删除不必要的消息可以降低成本。
  • 控制延迟。发送给 LLM 的 token 越多,处理它们的时间就越长。

目前 LangChain4J 提供了两种开箱即用的实现:

  1. 其中简单的是 MessageWindowChatMemory,它作为一个滑动窗口,保留 N 条最新的消息,删除不再适用的旧消息。
  2. 更为复杂的是 TokenWindowChatMemory,它也作为一个滑动窗口,但在保留 N 条最新的消息时,根据需要删除旧消息。消息是不可分割的。如果一条消息不再适用,它会被完全驱逐。TokenWindowChatMemory 需要 TokenCountEstimator 来计算每个 ChatMessage 中的 token。

8.3 编码实现

定义 AI Service 接口 ChatMemoryAssistant

1
2
3
public interface ChatMemoryAssistant {
String chatWithChatMemory(@MemoryId Long userId, @UserMessage String prompt);
}

注意标注 @MemoryId@UserMessage

这次选用 qwen-long 模型,编写配置类:

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
@Configuration
public class LLMConfig {

@Bean
public ChatModel streamingChatModel() {
return OpenAiChatModel.builder()
.apiKey(System.getenv("AliQwen_Key"))
.modelName("qwen-long")
.baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
.build();
}

@Bean(name = "chatMessageWindowChatMemory")
public ChatMemoryAssistant chatMessageWindowChatMemory(ChatModel chatModel) {
return AiServices.builder(ChatMemoryAssistant.class)
.chatModel(chatModel)
.chatMemoryProvider(i -> MessageWindowChatMemory.builder()
.id(i)
.maxMessages(100)
.build())
.build();
}

@Bean(name = "chatTokenWindowChatMemory")
public ChatMemoryAssistant chatTokenWindowChatMemory(ChatModel chatModel) {
return AiServices.builder(ChatMemoryAssistant.class)
.chatModel(chatModel)
.chatMemoryProvider(i -> TokenWindowChatMemory.builder()
.id(i)
.maxTokens(1000, new OpenAiTokenCountEstimator("gpt-4"))
.build())
.build();
}

}

在使用时注入对应的 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
@Resource(name = "chatMessageWindowChatMemory")
private ChatMemoryAssistant chatMessageWindowChatMemory;

@Resource(name = "chatTokenWindowChatMemory")
private ChatMemoryAssistant chatTokenWindowChatMemory;

@GetMapping("/chat-memory/message-window")
public String chatMessageWindow() {
chatMessageWindowChatMemory.chatWithChatMemory(1L, "你好,我的名字是张三");
String ans01 = chatMessageWindowChatMemory.chatWithChatMemory(1L, "我的名字是什么?");
System.out.println("ans01 的返回结果是: " + ans01);

chatMessageWindowChatMemory.chatWithChatMemory(100L, "你好,我的名字是李四");
String ans02 = chatMessageWindowChatMemory.chatWithChatMemory(100L, "我的名字是什么?");
System.out.println("ans02 的返回结果是: " + ans02);
return "chatMessageWindow success: " + LocalDateTime.now() + "<br />\n\n ans01: " + ans01 + "<br />\n\n ans02: " + ans02;
}

@GetMapping("/chat-memory/token-window")
public String chatTokenWindow() {
chatTokenWindowChatMemory.chatWithChatMemory(1L, "你好,我的名字是张三");
String ans01 = chatTokenWindowChatMemory.chatWithChatMemory(1L, "我的名字是什么?");
System.out.println("ans01 的返回结果是: " + ans01);

chatTokenWindowChatMemory.chatWithChatMemory(100L, "你好,我的名字是李四");
String ans02 = chatTokenWindowChatMemory.chatWithChatMemory(100L, "我的名字是什么?");
System.out.println("ans02 的返回结果是: " + ans02);
return "chatMessageWindow success: " + LocalDateTime.now() + "<br />\n\n ans01: " + ans01 + "<br />\n\n ans02: " + ans02;
}

9. 提示词工程

9.1 多种 ChatMessage

官方介绍:Types of ChatMessage

ChatMessage 是 LangChain4J 提供的一个接口,内置了 5 种默认实现,这在 ChatMessageType 枚举中也能发现:

1
2
3
4
5
6
7
public enum ChatMessageType {
SYSTEM(SystemMessage.class),
USER(UserMessage.class),
AI(AiMessage.class),
TOOL_EXECUTION_RESULT(ToolExecutionResultMessage.class),
CUSTOM(CustomMessage.class);
}

根据官网的描述:

  • UserMessage:来自用户的消息。这里的用户可以是应用程序的终端用户(人类),也可以是应用程序本身。根据 LLM 支持的多模态,UserMessage 可以只包含文本,也可以包含其他模态(比如图片、音频、视频、PDF)。
  • AiMessage:AI 生成的消息,通常是对 UserMessage 的响应。在先前的使用案例中,调用 ChatModel 中接收 UserMessagechat() 方法时,会返回一个 ChatResponse 对象,其内部就存在一个 AiMessage 对象。AiMessage 可以包含文本响应(String)和执行工具(Tool)的请求(ToolExecutionRequest),关于 Tool 将在后续介绍。
  • ToolExecutionResultMessageToolExecutionRequest 的结果。
  • SystemMessage:来自系统的消息。开发人员通常会定义该消息的内容。开发人员需要在这里写下 LLM 在本次对话中的角色、应该如何表现、以什么风格回答等指示。LLM 被训练成相比于其他类型的消息,更关注 SystemMessage,所以要小心,最好不要让终端用户自由定义或向 SystemMessage 注入一些内容。通常情况下,SystemMessage 位于对话的开头。
  • CustomMessage:包含任意属性的自定义消息。此消息只能在支持它的 ChatModel 实现上使用,目前只有 Ollama。

有这么多种 ChatMessage,如何在对话中将它们结合起来呢?

在最简单的情况下,我们可以在 chat 方法中提供一个单一的 UserMessage 实例。这与 ChatModel#chat(String) 很类似,只不过 chat(ChatMessage) 不再返回字符串,而是 ChatResponseChatResponse 内部不仅包含 AiMessage,还有 ChatResponseMetadataChatResponseMetadata 是封装了响应的元数据,包括 TokenUsage,它统计了输入(提供给 generate 方法的所有 ChatMessage )包含多少 token、输出(在 AiMessage)生成了多少 token,以及 token 总量。你可以根据这些信息计算调用 LLM 的成本。ChatResponseMetadata 还包含一个 FinishReason 枚举,定义了停止生成的各种原因。通常情况下,如果 LLM 自行决定停止生成,枚举值为 FinishReason.STOP

根据内容的不同,有多种创建 UserMessage 的方式。最简单的是 new UserMessage("Hi")UserMessage.from("Hi")

9.2 提示词的演化

提示词的演化分为三个历程:

flowchart TD
    A("简单纯文本提问")
    B("占位符(Prompt Template)")
    C("多角色消息")

    A --> B --> C

最初的提示词(Prompt)只是简单的文本字符串,后续引入占位符 {{it}} 以动态插入内容,如今将消息分为不同角色(如用户、助手、系统等),设置功能边界,增强交互的复杂性和上下文感知能力。

9.3 编码实现

定义一个模板类 LawPrompt,以便后续以面向对象的方式定义提示词:

1
2
3
4
5
6
7
@Getter
@Setter
@StructuredPrompt("根据中国{{legal}}法律,解答以下问题: {{question}}")
public class LawPrompt {
private String legal;
private String question;
}

新建 AI Service 接口 LawAssistant,使用 @SystemMessage@UserMessage@V 注解定义各种提示词:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface LawAssistant {
@SystemMessage("""
你是一位专业的中国法律顾问,只回答与中国法律相关的问题。
输出限制:对于其他领域的问题禁止回答,直接返回'抱歉,我只能回答中国法律相关的问题'。
""")
@UserMessage("请回答以下法律问题:{{question}},字数控制在 {{length}} 以内。")
String chat(@V("question") String question, @V("length") int length);

@SystemMessage("""
你是一位专业的中国法律顾问,只回答与中国法律相关的问题。
输出限制:对于其他领域的问题禁止回答,直接返回'抱歉,我只能回答中国法律相关的问题'。
""")
String chat(LawPrompt lawPrompt);
}

在配置类中创建 Bean:

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class LLMConfig {
@Bean
public ChatModel streamingChatModel() {
// --snip--
}

@Bean
public LawAssistant lawAssistant(ChatModel chatModel) {
return AiServices.create(LawAssistant.class, chatModel);
}
}

最后就是「酣畅淋漓」的使用:

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
@RestController
public class ChatPromptController {
@Resource
private LawAssistant lawAssistant;
@Resource
private ChatModel chatModel;

@GetMapping("/chat-prompt/test1")
public String test1() {
String chat1 = lawAssistant.chat("什么是知识产权?", 2000);
System.out.println(chat1);

String chat2 = lawAssistant.chat("什么是 Java?", 2000);
System.out.println(chat2);

String chat3 = lawAssistant.chat("介绍下西瓜和芒果?", 2000);
System.out.println(chat3);

String chat4 = lawAssistant.chat("飞机发动机原理?", 2000);
System.out.println(chat4);

return "success : " + LocalDateTime.now() + "<br /> \n\n chat1: " + chat1
+ "<br /> \n\n chat2: " + chat2
+ "<br /> \n\n chat3: " + chat3
+ "<br /> \n\n chat4: " + chat4;
}

@GetMapping("/chat-prompt/test2")
public String test2() {
LawPrompt lawPrompt = new LawPrompt();
lawPrompt.setLegal("知识产权");
lawPrompt.setQuestion("TRIPS协议");

String chat = lawAssistant.chat(lawPrompt);
return "success : " + LocalDateTime.now() + "<br /> \n\n chat: " + chat;
}

@GetMapping("/chat-prompt/test3")
public String test3() {
String role = "外科医生";
String question = "牙疼";

PromptTemplate template = PromptTemplate.from("你是一个{{it}}助手,{{question}}怎么办?");
Prompt prompt = template.apply(Map.of("it", role, "question", question));
UserMessage userMessage = prompt.toUserMessage();
ChatResponse response = chatModel.chat(userMessage);
String text = response.aiMessage().text();
return "success : " + LocalDateTime.now() + "<br /> \n\n chat: " + text;
}
}

10. 持久化

10.1 官方介绍

官方介绍:Persistence

默认情况下,ChatMemoryChatMessage 存储在内存中。

如果需要持久化,可以自定义 ChatMemoryStore 实现,将 ChatMessage 存储到选择的持久化存储区中:

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
class PersistentChatMemoryStore implements ChatMemoryStore {

@Override
public List<ChatMessage> getMessages(Object memoryId) {
// TODO: 通过 memory ID 从持久化存储区中获取所有信息
// ChatMessageDeserializer.messageFromJson(String) 和
// ChatMessageDeserializer.messagesFromJson(String) 辅助方法可以轻松地
// 将 JSON 反序列化为 chat message
}

@Override
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
// TODO: 通过 message ID 在持久化存储区中更新所有信息
// ChatMessageSerializer.messageToJson(ChatMessage) 和
// ChatMessageSerializer.messagesToJson(List<ChatMessage>) 辅助方法可以轻松地
// 将 chat message 序列化为 JSON
}

@Override
public void deleteMessages(Object memoryId) {
// TODO: 通过 message ID 在持久化存储区中删除所有信息
}
}

ChatMemory chatMemory = MessageWindowChatMemory.builder()
.id("12345")
.maxMessages(10)
.chatMemoryStore(new PersistentChatMemoryStore())
.build();

每当新的 ChatMessage 被添加到 ChatMemory 中时,就会调用 updateMessages() 方法。在每次与 LLM 交互时,这通常会发生两次:一次是添加新的 UserMessage,另一次是添加新的 AiMessageupdateMessages() 方法将更新与给定 memory ID 相关的所有信息。ChatMessage 既可以单独存储(例如每条信息对应一条记录、一行、一个对象),也可以一起存储(例如整个 ChatMemory 对应一条记录、一行、一个对象)。

请注意,从 ChatMemory 中驱逐的消息也会从 ChatMemoryStore 中驱逐。当消息被驱逐时,updateMessages() 方法会被调用,且传入的消息列表中不包含被驱逐的消息。

每当 ChatMemory 的用户请求所有消息时,就会调用 getMessages() 方法。在每次与 LLM 交互时,这通常会发生一次。参数 memoryId 对象的值与创建 ChatMemory 时指定的 id 相对应,它被用于区分多个用户或多个对话。getMessages() 方法会返回与给定 memory id 相关联的所有信息。

每当调用 ChatMemory.clear() 时,就会调用 deleteMessages() 方法。如果不使用这个功能,可以将此方法留空。

10.2 编码实现

将客户和大模型的对话回答保存进 Redis,进行持久化记忆留存。

额外引入 Redis 依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

新建 AI Service 接口:

1
2
3
public interface ChatPersistenceAssistant {
String chat(@MemoryId Long memoryId, @UserMessage String message);
}

由于需要将消息持久化到 Redis 中,配置 Redis Key-Value 的序列化策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class RedisConfig {

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}

}

自定义 ChatMemoryStore 实现类,定义持久化策略:

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
@Component
public class RedisChatMemoryStore implements ChatMemoryStore {

public static final String CHAT_MEMORY_PREFIX = "CHAT_MEMORY:";

@Resource
private RedisTemplate<String, String> redisTemplate;

@Override
public List<ChatMessage> getMessages(Object memoryId) {
String value = redisTemplate.opsForValue().get(CHAT_MEMORY_PREFIX + memoryId);
return ChatMessageDeserializer.messagesFromJson(value);
}

@Override
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
redisTemplate.opsForValue()
.set(CHAT_MEMORY_PREFIX + memoryId, ChatMessageSerializer.messagesToJson(messages));
}

@Override
public void deleteMessages(Object memoryId) {
redisTemplate.delete(CHAT_MEMORY_PREFIX + memoryId);
}
}

定义 LLM 配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
public class LLMConfig {

@Resource
private RedisChatMemoryStore redisChatMemoryStore;

@Bean
public ChatModel chatModel() {
// --snip--
}

@Bean
public ChatPersistenceAssistant chatPersistenceAssistant(ChatModel chatModel) {
ChatMemoryProvider provider = memoryId -> MessageWindowChatMemory.builder()
.id(memoryId)
.maxMessages(1000)
.chatMemoryStore(redisChatMemoryStore)
.build();
return AiServices.builder(ChatPersistenceAssistant.class)
.chatModel(chatModel)
.chatMemoryProvider(provider)
.build();
}
}

最后又是「酣畅淋漓」的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
public class ChatPersistenceController {
@Resource
private ChatPersistenceAssistant chatPersistenceAssistant;

@GetMapping("/chat-persistence/redis")
public String test() {
chatPersistenceAssistant.chat(1L, "你好,我的名字是 redis");
chatPersistenceAssistant.chat(2L, "你好,我的名字是 nacos");

String chat1 = chatPersistenceAssistant.chat(1L, "我的名字是什么?");
System.out.println(chat1);

String chat2 = chatPersistenceAssistant.chat(2L, "我的名字是什么?");
System.out.println(chat2);

return "success: " + LocalDateTime.now();
}
}

11. Tools (Function Calling)

11.1 官方介绍

官方介绍:Tools (Function Calling)

一些 LLM (并非所有)除了生成文本外,还可以触发操作。

有一个被称为「tools(工具)」或「function calling(函数调用)」的概念。它允许 LLM 在必要时调用一个或多个由开发者定义、可用的工具。这个工具可以是任何东西:网络搜索、调用外部 API 或执行特定的代码等等。LLM 自身并不能调用工具,而是在响应中表达调用特定工具的意图(不是以纯文本的形式回答)。作为开发者,我们应该用提供的参数执行这个工具,并报告工具的执行结果。

例如,我们知道 LLM 自身并不擅长数学计算。如果你的用例偶尔涉及到数学计算,你可能需要为 LLM 提供一个「数学工具」。通过在向 LLM 的请求中声明一个或多个工具,LLM 可以决定调用一个它认为合适的工具。给定一个数学问题和一系列「数学工具」,LLM 为了正确地回答问题,它应该首先调用提供的数学工具。

简单来说,就是给 LLM 配一个调用的外部工具类。

LLM 不仅仅是文本生成的能手,它们还能触发并调用第三方函数,比如微信查询、快递单号查询等等。使用 Tools,可以将 LLM 的智能与外部工具或 API 无缝衔接。注意,LLM 本身并不执行函数,它只指示应该如何调用函数。

那咋使用呢?

LangChain4J 提供了关于使用 tools 的两个抽象级别:

  • 低阶:使用 ChatModelToolSpecification
  • 高阶:使用 AI Service 和带有 @Tools 注解的 Java 方法

11.2 低阶 API

官方介绍:Low Level Tool API

定义 AI Service 接口:

1
2
3
public interface FunctionAssistant {
String chat(String message);
}

在配置类中手动指定工具说明、执行工具的业务逻辑:

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
@Configuration
public class LLMConfig {
@Bean
public ChatModel chatModel() {
// --snip--
}

@Bean
public FunctionAssistant functionAssistant(ChatModel chatModel) {
// 工具说明
ToolSpecification toolSpecification = ToolSpecification.builder()
.name("开具发票助手")
.description("根据用户提供的开票信息,开具发票")
.parameters(
JsonObjectSchema.builder()
.addStringProperty("companyName", "公司名称")
.addStringProperty("dutyNumber", "税号序列")
.addStringProperty("amount", "开票金额,保留两位有效数字")
.build()
)
.build();

// 业务逻辑
ToolExecutor toolExecutor = (req, memoryId) -> {
System.out.println(req.id());
System.out.println(req.name());
String args = req.arguments();
System.out.println("args: " + args);
return "开具成功";
};

return AiServices.builder(FunctionAssistant.class)
.chatModel(chatModel)
.tools(Map.of(toolSpecification, toolExecutor))
.build();
}
}

最后是「紧张刺激」的测试:

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
public class ChatFunctionCallingController {
@Resource
private FunctionAssistant functionAssistant;

@GetMapping("/chat-function/test")
public String test() {
String chat = functionAssistant.chat("开张发票,公司:啥都有小卖部 税号:everything666 金额:888");
System.out.println(chat);
return "success: " + LocalDateTime.now() + "<br />" + chat;
}
}

11.3 高阶 API

使用 @Tools 注解可以更方便地集成函数调用,只需用 @Tools 标记 Java 方法,LangChain4J 就可以自动地将其转换为 ToolSpecification

需求:查询某地实时的天气预报。

不再手动创建 ToolSpecification 对象,而是使用 @Tool@P 注解完成。

新增 WeatherAssistant

1
2
3
public interface WeatherAssistant {
String chat(String message);
}

定义 WeatherTools 类,用于获取某地的实时天气预报:

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
public class WeatherTools {

/**
* 一个免费、免注册的天气 API,有使用频率限制,但测试够了
*/
private static final String BASE_URL = "https://cn.apihz.cn/api/tianqi/tqyb.php?id=88888888&key=88888888&sheng=%s&place=%s";

@Tool("返回给定省、市的天气预报")
public String getWeather(@P("省") String sheng,
@P("市") String place) {
return getRealWeather(sheng, place);
}

private String getRealWeather(String sheng, String place) {
String url = String.format(BASE_URL, sheng, place);
// 用 JDK 自带的
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.GET()
.build();
String weather = null;
try (HttpClient client = HttpClient
.newBuilder()
.connectTimeout(Duration.ofMillis(500))
.build()) {
HttpResponse<String> resp = client.send(request, HttpResponse.BodyHandlers.ofString());
/// {
/// "precipitation": 0,
/// "temperature": 31.8,
/// "pressure": 945,
/// "humidity": 67,
/// "windDirection": "东南风",
/// "windDirectionDegree": 114,
/// "windSpeed": 0.4,
/// "windScale": "微风",
/// "feelst": 37.3,
/// "code": 200,
/// "place": "中国, 四川, 成都",
/// "weather1": "阵雨",
/// "weather2": "阵雨",
/// "weather1img": "https://rescdn.apihz.cn/resimg/tianqi/zhenyu.png",
/// "weather2img": "https://rescdn.apihz.cn/resimg/tianqi/zhenyu.png",
/// "uptime": "2025/07/19 16:20",
/// "jieqi": ""
/// }
weather = resp.body();
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
return weather;
}
}

创建 WeatherAssistant AI Service 时指定可使用的工具:

1
2
3
4
5
6
7
8
9
10
11
12
@Bean
public WeatherTools weatherService() {
return new WeatherTools();
}

@Bean
public WeatherAssistant weatherAssistant(ChatModel chatModel, WeatherTools weatherTools) {
return AiServices.builder(WeatherAssistant.class)
.chatModel(chatModel)
.tools(weatherTools)
.build();
}

「震撼人心」的调用:

1
2
3
4
5
6
7
@Resource
private WeatherAssistant weatherAssistant;

@GetMapping("/chat-function/weather")
public String getWeather() {
return weatherAssistant.chat("今天成都的天气怎么样?");
}

12. 向量数据库

12.1 相关概念

Vector

Vector,向量(数学概念)、矢量(物理概念)。

向量是用于表示具有大小和方向的量。

向量可以在不同的维度空间中定义,最常见的是二维和三维空间中的向量,但理论上也可以有更高维的向量。例如在二维平面上的一个向量可以写作 (x,y)(x, y),这里 xxyy 分别表示该向量沿两个坐标轴方向上的分量;而在三维空间里,则会有一个额外的 zz 坐标,即 (x,y,z)(x,y,z)

向量化

向量化

向量化是将数据转化为向量形式的过程,使得计算机能够更有效地处理和理解复杂信息。

维度

维度,Dimensions。

xyx-y 坐标系中,每个数值向量都有 xxyy 坐标(或者在多维系统中的 xxyyzz…)。xxyy 是这个向量空间的轴,成为维度。

对于想要表示为向量的一些非数值实体,需要先决定其维度,并为每个实体在每个维度分配一个值。

例如在一个交通工具数据集中,可以定义四个维度:

  1. 轮子数量
  2. 是否有发动机
  3. 是否可以在地上开动
  4. 最大乘客数

然后可以将一些交通工具表示为:

item number of wheels has an engine moves on land max occupants
car 4 yes yes 5
bicycle 2 no yes 1
tricycle 3 no yes 1
motorcycle 2 yes yes 2
sailboat 0 no no 20
ship 0 yes no 1000

可以使用向量 (4,yes,yes,5)(4, yes, yes, 5) 来表示汽车 car,如果将 yes 设置为 1,no 设置为 0,可以进一步简化为 (4,1,1,5)(4, 1, 1, 5)

向量的每个维度代表数据的不同特性,维度越多对事物的描述越精准。

如何确定最相似?

xyx-y 坐标系中的每个向量都有一个长度和方向。例如下图中的 ppaa 指向了相同的方法,但长度不同。ppbb 指向相反的方向,但拥有相似的长度。此外还有 cc,长度比 pp 短一点,方向也不相同,但很接近。

x-y坐标系中向量的相似性

那么哪一个最接近 pp 呢?

如果「相似」仅仅意味着指向相似的方向,那么 aa 最接近 pp;如果「相似」仅仅意为着相似的长度,那么 bb 最接近 pp

由于向量通常用于描述语义,仅仅看长度无法满足需求。

大多数相似度的测量要么仅依赖于方向,要么同时考虑方向和大小。

12.2 Embedding Model

官方介绍:Embedding Model

EmbeddingModel 接口代表一种特殊类型的模型,可以将文本转换为 Embedding。

目前支持的 Embedding Model:Embedding Models

使用方式:

  • EmbeddingModel.embed(String) 嵌入给定文本
  • EmbeddingModel.embed(TextSegment) 嵌入给定的 TextSegment
  • EmbeddingModel.embedAll(List<TextSegment>) 嵌入所有给定的 TextSegment
  • EmbeddingModel.dimension() 返回该模型产生的 Embedding 的维度

什么是 Embedding?

Embedding,嵌入,其工作原理是将文本、图形和视频转换为向量(Vectors)的浮点数数组。这些向量旨在捕捉文本、图像和视频的含义。嵌入数组的长度成为向量的维度(Dimensionality)。

嵌入模型(EmbeddingModel)是嵌入过程中采用的模型。当前 EmbeddingModel 的接口主要用于将文本转换为数值向量,接口的设计主要围绕这两个目标展开:

  1. 可移植性:该接口确保在各种嵌入模型之间的轻松适配。它允许开发者在不同的嵌入技术或模型之间切换,所需的代码更改最小化,这一设计与 Spring 模块化和互换性的理念一致。
  2. 简单性:嵌入模型简化了文本转换为嵌入的过程。通过提供如 embed(String text)embed(Document document) 这样简单的方法,去除处理原始文本数据和嵌入算法的复杂性。这个设计选择使开发者(尤其是那些初次接触 AI 的开发者),更容易在他们的应用程序中使用嵌入,而无需深入了解其底层机制。

12.3 Embedding Store

官方介绍:Embedding Store

EmbeddingStore 接口表示 Embedding 的存储空间,也成为向量数据库。它允许存储和搜索相似的 Embedding。

目前支持的 Embedding Store:Comparison table of all supported Embedding Stores

EmbeddingStore 可以单独存储 Embedding,也可以与相应的 TextSegment 一起存储:

  • 它只能按 ID 存储 Embedding。原始嵌入数据可以存储在其他地方,并使用 ID 进行关联。
  • 它可以同时存储 Embedding 和已嵌入的原始数据(通常是 TextSegment)。

Vector Store

向量存储(Vector Store)是一种用于存储和检索高维向量数据的数据库或存储解决方案,它特别适用于处理那些经过嵌入模型转换后的数据。在 Vector Store 中,查询与传统关系型数据库不一样,它们执行相似性搜索,而不是精准匹配。当给定一个向量作为查询时,Vector Store 返回与查询向量「相似」的向量。

Vector Store 用于将您的数据与 AI 模型集成。在使用它们时的第一步是将您的数据加载到矢量数据库中。当用户将查询发送到 AI 模型时,首先检索一组相似文档。这些文档作为用户问题的上下文,并与用户的查询一起发送到 AI 模型。这种技术也被称为检索增强生成(Retrieval Augmented Generation,RAG)。

EmbeddingSearchRequest

官方介绍:EmbeddingSearchRequest

EmbeddingSearchRequest 表示在 EmbeddingStore 中进行搜索的请求。它具有以下属性:

  • Embedding queryEmbedding:用作参考的 Embedding
  • int maxResults:返回结果的最大数量。这是一个可选参数。默认值 3
  • double minScore:最小分数,范围 0 到 1(包括 1)。只有得分不小于 minScore 的 Embedding 才会被返回。这也是一个可选参数。默认值 0
  • Filter filter:用于搜索 Metadata 的过滤器。只有 Metadata 符合该 FilterTextSegment 才会被返回。

12.4 特征点总结

向量数据库能够:

  1. 捕捉复杂的词汇关系(语义相似性、同义词、多义词)
  2. 超越传统词袋模型的简单计数方式
  3. 动态嵌入模型(如 BERT)可根据上下文生成不同的词向量
  4. 向量嵌入为现代搜索和检索增强生成(RAG)应用程序提供支持

即:将文本映射到高维空间中的点,使语义相似的文本在这个空间中距离接近。

12.5 编码实现

本次选用 Qdrant 作为使用的向量数据库。

Qdrant 是一个高性能的向量数据库,用于存储 Embedding 并进行快速的向量搜索。

安装 Qdrant:

1
docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant

其中:

  • 6333 端口用于 HTTP API,浏览器 Web 界面
  • 6334 端口用于 gRPC API

在浏览器中访问 http://localhost:6333/,如果出现类似以下信息,则证明启动成功:

1
2
3
4
5
{
"title": "qdrant - vector search engine",
"version": "1.15.0",
"commit": "137b6c1e4a91320e4658987d47f235078fb0ab11"
}

访问 http://localhost:6333/dashboard#/collections 时能够进入 Qdrant 的后台。

工程中引入 Qdrant 相关依赖:

1
2
3
4
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-qdrant</artifactId>
</dependency>

更换模型为 text-embedding-v3 并在配置类完成 Qdrant 的相关配置:

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
@Configuration
public class LLMConfig {
@Bean
public EmbeddingModel embeddingModel() {
return OpenAiEmbeddingModel.builder()
.apiKey(System.getenv("AliQwen_Key"))
// 更换模型名称
.modelName("text-embedding-v3")
.baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
.build();
}

/**
* 创建 Qdrant 客户端
*/
@Bean
public QdrantClient qdrantClient() {
QdrantGrpcClient.Builder builder
= QdrantGrpcClient.newBuilder("127.0.0.1", 6334, false);
return new QdrantClient(builder.build());
}

@Bean
public EmbeddingStore<TextSegment> embeddingStore() {
return QdrantEmbeddingStore.builder()
.host("127.0.0.1")
.port(6334)
.collectionName("test-qdrant")
.build();
}
}

先测试下文本向量化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Resource
private EmbeddingModel embeddingModel;

/**
* 文本向量化测试,查看形成向量后的文本
*/
@GetMapping("/embedding/embed")
public String embed() {
String prompt = """
咏鸡
鸡鸣破晓光,
红冠映朝阳。
金羽披霞彩,
昂首步高岗。
""";
Response<Embedding> embeddingResponse = embeddingModel.embed(prompt);
return embeddingResponse.content().toString();
}

存储向量时,需要先创建数据库实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Resource
private QdrantClient qdrantClient;

/**
* 新增向量数据库实例和创建索引:test-qdrant
* 类似于 MySQL `CREATE DATABASE test-qdrant`
*/
@GetMapping(value = "/embedding/create-collection")
public void createCollection() {
var vectorParams = Collections.VectorParams.newBuilder()
.setDistance(Collections.Distance.Cosine)
.setSize(1024)
.build();
qdrantClient.createCollectionAsync("test-qdrant", vectorParams);
}

向向量数据库中添加信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 往向量数据库新增文本记录
*/
@GetMapping(value = "/embedding/add")
public String add() {
String prompt = """
咏鸡
鸡鸣破晓光,
红冠映朝阳。
金羽披霞彩,
昂首步高岗。
""";
TextSegment segment = TextSegment.from(prompt);
segment.metadata().put("author", "mofan");
Embedding embedding = embeddingModel.embed(segment).content();
return embeddingStore.add(embedding, segment);
}

最后再测试:

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
@GetMapping(value = "/embedding/query1")
public String query1() {
Embedding queryEmbedding = embeddingModel.embed("咏鸡说的是什么").content();
EmbeddingSearchRequest req = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(1)
.build();
EmbeddingSearchResult<TextSegment> result = embeddingStore.search(req);
return result.matches().getFirst().embedded().text();
}

@GetMapping(value = "/embedding/query2")
public String query2() {
Embedding queryEmbedding = embeddingModel.embed("咏鸡").content();
EmbeddingSearchRequest req = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.filter(MetadataFilterBuilder.metadataKey("author").isEqualTo("mofan212"))
.maxResults(1)
.build();
EmbeddingSearchResult<TextSegment> result = embeddingStore.search(req);
List<EmbeddingMatch<TextSegment>> list = result.matches();
if (list.isEmpty()) {
return "未查询到指定内容";
}
return list.getFirst().embedded().text();
}

13. RAG

13.1 相关概念

官方介绍:RAG

LLM 的知识仅限于训练数据。如果想要让 LLM 了解特定领域的知识或专有数据,可以:

  • 使用 RAG
  • 使用您的数据微调大模型
  • 结合 RAG 和微调

什么是 RAG

RAG,Retrieval-Augmented Generation,检索增强生成。

简单来说,RAG 是在将提示词发送给 LLM 前,从给定数据中找到相关信息并注入到提示词中的方式。这样 LLM 就能够获取到相关信息,并使用这些信息进行回复,从而降低出现「幻觉」的概率。

可以使用各种信息检索方式找到相关信息,最常用的方法有:

  • 全文(关键词)检索
  • 向量(矢量)检索
  • 混合检索(全文 + 向量)

前面提到的「幻觉」又是什么呢?

  • 已读乱回
  • 已读不回
  • 似是而非

RAG 给 LLM 装上了「实时百科大脑」,为了让 LLM 获取足够的上下文,以便获取到更广泛的信息源,通过先查资料再回答的机制,让 LLM 摆脱「知识遗忘和幻觉回复」的困境。

13.2 RAG Stages

官方介绍:RAG Stages

RAG 过程分为两个明确的阶段:索引和检索。LangChain4j 为这两个阶段都提供了工具。

Indexing(索引)

官方介绍:Indexing

在索引阶段会对给定文档进行预处理,以便在检索阶段进行高效地搜索。

这一过程会根据使用的信息检索方式发生变化。对于向量搜索,这通常包括清理文档、用附加数据和元数据补充文档、将文档分割成更小的片段(分块)、嵌入(embedding)这些片段,最后将它们存储到向量数据库中。

索引阶段通常是离线进行的,这意味着终端用户无需等待索引完成。例如,可以每周末定时对公司内部文档进行一次索引操作。负责检索的代码可以是一个只处理索引任务的单个应用程序。

然而在某些情况下,终端用户可能希望上传自己的定制文档,以便 LLM 能够访问这些文件。在这种情况下,索引应该在线进行,并成为主应用程序的一部分。

下面是索引阶段的简化流程图:

索引阶段简化流程图

Retrieval(检索)

官方介绍:Retrieval

检索阶段通常是在线进行的,当用户提交一个问题时,将使用索引的文档给出答案。

这一过程会根据使用的信息检索方式发生变化。对于向量搜索,这通常包括嵌入(embedding)用户的查询(问题),并在向量数据库中执行相似性搜索,最后将相关片段(原始文档中的片段)注入到提示词中并发送给 LLM。

检索阶段简化流程图

13.3 核心 API

官方介绍:Core RAG APIs

Embedding Store Ingestor

官方介绍:Embedding Store Ingestor

LangChain4j 中存在名为 EmbeddingStoreIngestor 的类,其内部有 5 个成员变量:

  1. DocumentTransformer documentTransformer:文档转换
  2. DocumentSplitter documentSplitter:文档分割
  3. TextSegmentTransformer textSegmentTransformer:转换单个文本段(用于标准化或清理)
  4. EmbeddingModel embeddingModel:文本段向量化
  5. EmbeddingStore<TextSegment> embeddingStore:存储生成的嵌入向量及其对应的文本段

Document Loader

官方介绍:Document Loader

可以根据一个 String 创建一个 Document 对象,但更简单的方式是通过 LangChain4j 中内置的文档加载器创建,比如:

  • FileSystemDocumentLoader:从文档系统中加载
  • UrlDocumentLoader:从 URL 中加载
  • AmazonS3DocumentLoader:从 Amazon S3 中加载
  • AzureBlobStorageDocumentLoader:从 Azure BLOB 中加载
  • GitHubDocumentLoader:从 GitHub 中加载
  • TencentCosDocumentLoader:从腾讯云中加载

Document Parser

官方介绍:Document Parser

Document 代表各种格式的文件,比如 PDF、DOC、TXT 等等。为了解析这些格式,LangChain4j 提供了 DocumentParser 接口及多种实现:

  • TextDocumentParser:解析纯文本格式的文档,比如 TXT、HTML、MD 等等
  • ApachePdfBoxDocumentParser:解析 PDF
  • ApachePoiDocumentParser:解析 MS Office 格式的文档,比如 DOC、DOCX、PPT、PPTX、XLS、XLSX 等等
  • ApacheTikaDocumentParser:自动检测并解析几乎所有格式的文档 😲

Document Transformer

官方介绍:Document Transformer

对文档执行各种转换,包括清理、过滤、增强或总结。

没有通用的解决方案,需要根据需求自行实现 DocumentTransformer

Document Splitter

官方介绍:Document Splitter

LangChain4j 提供了 DocumentSplitter 接口,及多种开箱即用的实现:

  • DocumentByParagraphSplitter:按段落拆分
  • DocumentBySentenceSplitter:按句子拆分
  • DocumentByWordSplitter:按单词拆分
  • DocumentByCharacterSplitter:按字符拆分
  • DocumentByRegexSplitter:按正则拆分

构建 RAG 一般步骤

  1. 加载文档:使用适当的 DocumentLoaderDocumentParser 加载文档
  2. 转换文档:使用 DocumentTransformer 清理或增强文档(可选)
  3. 拆分文档:使用 DocumentSplitter 将文档拆分为更小的片段(可选)
  4. 嵌入文档:使用 EmbeddingModel 将文档片段转换为嵌入向量
  5. 存储嵌入:使用 EmbeddingStoreIngestor 存储嵌入向量
  6. 检索相关内容:根据用户查询,从 EmbeddingStore 检索最相关的文档片段
  7. 生成响应:将检索到的相关内容与用户查询一起提供给语言模型,生成最终响应

13.4 简单实现

本节只做最简单的 Easy RAG 演示。

引入依赖:

1
2
3
4
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-easy-rag</artifactId>
</dependency>

不使用向量数据库,简单起见,在内存中存储 Embeddding:

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
@Configuration
public class LLMConfig {
@Bean
public ChatModel chatModel() {
return OpenAiChatModel.builder()
.apiKey(System.getenv("AliQwen_Key"))
.modelName("qwen-plus")
.baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
.build();
}

/**
* 为了简单起见,现在将使用内存中的嵌入存储
*/
@Bean
public InMemoryEmbeddingStore<TextSegment> embeddingStore() {
return new InMemoryEmbeddingStore<>();
}

@Bean
public ChatAssistant assistant(ChatModel chatModel, EmbeddingStore<TextSegment> embeddingStore) {
return AiServices.builder(ChatAssistant.class)
.chatModel(chatModel)
.chatMemory(MessageWindowChatMemory.withMaxMessages(50))
.contentRetriever(EmbeddingStoreContentRetriever.from(embeddingStore))
.build();
}
}

简单测试下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class RAGController {

@Resource
private InMemoryEmbeddingStore<TextSegment> embeddingStore;

@Resource
private ChatAssistant chatAssistant;

@GetMapping("/rag/add")
public String test() throws MalformedURLException {
// about me
URI uri = URI.create("https://mofan212.github.io/mine/");
Document document = UrlDocumentLoader.load(
uri.toURL(),
new ApacheTikaDocumentParser()
);
EmbeddingStoreIngestor.ingest(document, embeddingStore);
return chatAssistant.chat("如何联系默烦?");
}
}

14. MCP

14.1 相关概念

LangChain4j 官方介绍:Model Context Protocol (MCP)

MCP 官方介绍:Introduction - Model Context Protocol

MCP,Model Context Protocol,模型上下文协议。

MCP 是一种开放协议,它规范了应用程序向 LLM 提供上下文的方式。把 MCP 想象成 AI 应用程序的 USB-C 接口,就像 USB-C 接口提供了将设备连接到各种外设和配件,MCP 也提供了将 AI 模型连接到不同数据源和工具(Tool)的标准化方式。

MCP架构图示

MCP 协议规定了两种传输类型,这在 LangChain4j 中都得到了支持:

  • HTTP:客户端请求 SSE 通道接收来自服务器的事件,然后通过 HTTP POST 请求发送命令。
  • stdio:客户端可以将 MCP 服务器作为本地子进程运行,并通过标准输入/输出与之直接通信。
特新 SSE STDIO
传输协议 HTTP(长连接) 操作系统级文件描述符
方向 服务器到客户端(单向传输) 双向流(stdin,stdout)
保持连接 长连接(Connection: keep-alive) 不保证长时间打开,取决于进程生命周期
数据格式 文本流(EventStream 格式) 原始字节流
异常处理 可通过 HTTP 状态码或重连机制 进程退出或管道断裂

MCP 可以类比于 OpenFeign,前者用于大模型之间的通讯,后者用于微服务之间的通讯。

MCP 是比 Tools(Function Calling) 更高一级的抽象,也是实现智能体 Agent 的基础。

访问 MCP.so 选择调用的 MCP Server。

14.2 通用架构

官方链接:General architecture

MCP 遵循客户端-服务器架构,其中主机应用程序可以连接到多个服务器:

flowchart LR
    subgraph "Your Computer"
        Host["Host with MCP Client<br/>(Claude, IDEs, Tools)"]
        S1["MCP Server A"]
        S2["MCP Server B"]
        D1[("Local<br/>Data Source A")]
        Host <-->|"MCP Protocol"| S1
        Host <-->|"MCP Protocol"| S2
        S1 <--> D1
    end
    subgraph "Internet"
        S3["MCP Server C"]
        D2[("Remote<br/>Service B")]
        D3[("Remote<br/>Service C")]
        S2 <-->|"Web APIs"| D2
        S3 <-->|"Web APIs"| D3
    end
    Host <-->|"MCP Protocol"| S3
  • MCP Hosts(MCP 主机):发起请求的 AI 应用程序,比如聊天机器人、AI 驱动的 IDE 等
  • MCP Clients(MCP 客户端):在主机程序内部,与 MCP 服务器保持 1:1 的链接
  • MCP Servers(MCP 服务器):为 MCP 客户端提供上下文、工具和提示信息
  • Local Data Sources(本地数据源):本地计算机中可供 MCP 服务器安全访问的资源,如文件、数据库
  • Remote Services(远程服务):MCP 服务器可以连接到的远程资源,如通过 API 提供的数据

14.3 简单使用

选择 Baidu Map 作为本次 MCP Server。

预先安装 Node.js。

进入 百度地图开发平台,创建应用,获取「访问应用 (AK)」并将其配置到系统环境变量,可以参考百度地图 MCP Server 的介绍,同样以 BAIDU_MAP_API_KEY 作为环境变量名称。

配置完环境变量后,记得重启 IDEA!

引入依赖:

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-reactor</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-dashscope-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-mcp</artifactId>
</dependency>

修改配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server:
port: 9994
servlet:
encoding:
charset: UTF-8
enabled: true
force: true

spring:
application:
name: langchain4j-14chat-mcp

langchain4j:
community:
dashscope:
streaming-chat-model:
api-key: ${AliQwen_Key}
model-name: qwen-plus
chat-model:
api-key: ${AliQwen_Key}
model-name: qwen-plus

创建 AI Service 接口:

1
2
3
public interface McpService {
Flux<String> chat(String question);
}

先尝试在 cmd 中执行 npx -y @baidumap/mcp-server-baidu-map 命令,查看 MCP 服务端是否能够正常启动。如果不能正常启动,需要查看错误信息并解决。我在此遇到问题,错误信息是「请求 npm 淘宝源下的 baidu map mcp server 失败,原因是证书过期」,于是我将 npm 镜像源又切回默认,然后再尝试执行,提示 Baidu Map MCP Server running on stdio,正常启动 MCP 服务端。

激动人心的测试:

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
@RestController
public class McpCallServerController {
@Resource
private StreamingChatModel streamingChatModel;

@GetMapping("/mcp/chat")
public Flux<String> chat(@RequestParam("question") String question) throws Exception {
/*
* 1. 构建 McpTransport 协议
* 1.1 cmd:启动 Windows 命令行解释器。
* 1.2 /c:告诉 cmd 执行完后面的命令后关闭自身。
* 1.3 npx:npx = npm execute package,Node.js 的一个工具,用于执行 npm 包中的可执行文件。
* 1.4 -y 或 --yes:自动确认操作(类似于默认接受所有提示)
* 1.5 @baidumap/mcp-server-baidu-map:要通过 npx 执行的 npm 包名
* 1.6 BAIDU_MAP_API_KEY 是访问百度地图开放平台 API 的 AK
*
* 先尝试在 cmd 中执行 `npx -y @baidumap/mcp-server-baidu-map` 命令,查看 MCP 服务端是否能够正常启动
* 如果不能正常启动,需要查看错误信息并解决。
* 我在此遇到问题,错误信息是:请求 npm 淘宝源下的 baidu map mcp server 失败,原因是证书过期
* 于是我将 npm 镜像源又切回默认,然后再尝试执行,提示 `Baidu Map MCP Server running on stdio`,正常启动 MCP 服务端
*/
McpTransport transport = new StdioMcpTransport.Builder()
.command(List.of("cmd", "/c", "npx", "-y", "@baidumap/mcp-server-baidu-map"))
.environment(Map.of("BAIDU_MAP_API_KEY", System.getenv("BAIDU_MAP_API_KEY")))
.build();

// 2. 构建 McpClient 客户端
McpClient mcpClient = new DefaultMcpClient.Builder()
.transport(transport)
.build();

// 3. 创建工具集和原生的 FunctionCalling 类似
ToolProvider toolProvider = McpToolProvider.builder()
.mcpClients(mcpClient)
.build();

// 4. 通过 AiServices 给自定义接口 McpService 构建实现类并将工具集和大模型赋值给 AiService
McpService mcpService = AiServices.builder(McpService.class)
.streamingChatModel(streamingChatModel)
.toolProvider(toolProvider)
.build();

// 5.调用定义的接口,通过大模型对百度 MCP Server 进行调用
try {
return mcpService.chat(question);
} finally {
mcpClient.close();
}
}
}