文章摘要
土豆丝 GPT

需求

实现一个挂号小助手的AI对话功能,患者可以通过描述自己的症状,AI根据描述告知患者应该挂哪个科室。

需求比较简单,主要是通过这个需求来学习运用LangChain4j框架的功能,包括大模型平台接入,通过chatModel与大模型进行对话,自定义系统提示词,实现会话记忆,结构化输出,外接RAG知识库,Tool,mcp,护栏机制,流式输出;

开始之前的碎碎念

先简单聊下LangChain4j的作用,有一个基本的概念。目前关于AI仍然处于探索可扩展的阶段,也就是说还有一些能力边界和某些场景下的处理方式还不够完整,在探索的过程中有些问题有了阶段性的解决方式,那为了方便实现这些方式,就有了各种AI框架,比如本文介绍的LangChain4j。这种框架和spring、mybatis等框架一样,都是为了解决某些场景化的问题,只不过LangChain4j解决的问题场景是我们之前所没有接触到的。不过不用担心,既然是框架,学习和使用起来就不会太难,阅读完这篇文章之后,你一定会有所收获!

本文使用到的环境

一、实现一个最简单的对话功能

  • LangChain4j官方的示例的默认实现是使用的open-ai,并且支持的大部分是国外的大模型,对比国产大模型来讲,开发成本会有一点高,使用也没有那么方便。所以想接入国产大模型就需要使用到LangChain4j社区提供的代码和依赖。

  • 可以在社区GitHub的models中看到一些眼熟的模型,qianfan、zhipu、和本篇文章使用的dashscope,也就是阿里系的大模型。

  • 创建项目:选择maven、java版本选择21、配置文件类型选择YAML方便配置,springboot版本选择4.0.x,依赖选择spring web和lombok。

  • 引入依赖:LangChain4j社区的dashscope大模型的springboot整合包,也可以在maven仓库中选择其他你喜欢的其他大模型整合依赖

1
2
3
4
5
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-dashscope-spring-boot-starter</artifactId>
<version>1.1.0-beta7</version>
</dependency>
  • 申请大模型平台KEY:在阿里百炼平台申请一个API KEY,如何使用的是其他大模型平台,就在其他大模型平台申请对于的API KEY,注意key不要泄露!

  • 配置参数:在yml文件中配置模型参数, api-key就是上一步申请的API KEY。

参数简单说明:langchain4j - 社区 - 阿里大模型 - 对话模型 - 模型名称 - API KEY,参数的设置方式是层层递进的关系,之后在配置其他参数时,可以便于理解为什么这么配。

1
2
3
4
5
6
langchain4j:
community:
dashscope:
chat-model:
model-name: qwen-max
api-key:
  • 新建一个service类:实现一个chat方法,调用这个方法就可以实现与大模型的对话啦,就像deepseek、chatGpt那种。

    简单说明下代码

    • qwenChatModel:引入千问的对话模型
    • UserMessage:用户消息,UserMessage.from可以传入多种类型的Content,包括AudioContent、ImageContent、PdfContent、TextContent、VideoContent,也就是说传入的参数可以是文本,也可以是图片等其他用户消息,传入String msg后也是封装成了TextContent进行的实现。注意如果使用了其他类型的Content需要大模型支持多模态。
    • ChatModel.chat:调用大模型对话接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
@Slf4j
public class AiChat {

@Resource
private ChatModel qwenChatModel;

/**
* 简单对话
* @param msg 用户文本
*/
public String chat(String msg){
// 构建userMessage
UserMessage userMessage = UserMessage.from(msg);
// 通过ChatModel调用大模型
ChatResponse chatResponse = qwenChatModel.chat(userMessage);
// 大模型的相应结果
AiMessage aiMessage = chatResponse.aiMessage();
log.info("AI 回复的消息:{}", aiMessage.toString());
return aiMessage.text();
}
}
  • 测试:可以创建一个测试类对这个对话方法进行测试
1
2
3
4
5
6
7
8
9
10
11
@SpringBootTest
class AiChatApplicationTests {

@Resource
private AiChat aiChat;

@Test
void chat() {
aiChat.chat("你好,我是土豆丝");
}
}
1
AI 回复的消息:AiMessage { text = "你好,土豆丝!很高兴认识你。有什么我可以帮助你的吗?或者你想聊些什么呢?" toolExecutionRequests = [] }

一个最简单的对话功能就实现了。

二、多模态

  • 白话解释:简单理解为可以处理文本以外的数据类型的能力。比如图片、视频等。
  • 上面举得例子是直接传入了String msg,也就是文本,为了实现多模态,新建一个方法,入参改为UserMessage,方便调用方自定义Content类型
1
2
3
4
5
6
7
8
9
10
11
12
/**
* 自定义 UserMessage
* @param userMessage 用户消息
*/
public String chatWithUserMessage(UserMessage userMessage){
// 通过ChatModel调用大模型
ChatResponse chatResponse = qwenChatModel.chat(userMessage);
// 大模型的相应结果
AiMessage aiMessage = chatResponse.aiMessage();
log.info("AI 回复的消息:{}", aiMessage.toString());
return aiMessage.text();
}
  • 测试
1
2
3
4
5
6
7
8
@Test
void chatWithUserMessage() {
UserMessage userMessage = UserMessage.from(
TextContent.from("描述下这张图片"),
ImageContent.from("https://www.baidu.com/favicon.ico")
);
aiChat.chatWithUserMessage(userMessage);
}
1
AI 回复的消息:AiMessage { text = "我目前无法直接查看或访问图片。如果你能提供关于图片的一些描述或者细节,比如图片中的主要对象、颜色、场景等信息,我会很乐意帮你进一步描述或分析这张图片的内容。" toolExecutionRequests = [] }

可以看到目前所使用的大模型是不具备多模态功能的,如果想使用相关功能可以切换其他支持多模态的大模型,这里只做如何实现多模态对话的例子。

ps:在写到这里的时候我有一个想法,我想确认下大模式是不支持多模态还是大模型不具备联网功能,因为如果是不具备联网功能也可能导致上面的回复。于是我做了一个实验,ImageContent.from支持uri和base64两种方式,我想是不是可以使用base64的方式去验证,但是很遗憾,base64字符串过长无法传入,于是我调用了普通文本对话的方式传入了工具网址的icon,让大模型来解答。

1
2
3
4
@Test
void chatWithMessage() {
aiChat.chatWithMessage("https://tools.tdsay.cn/favicon.ico 图片是什么");
}
1
2
3
AI 回复的消息:AiMessage { text = "我直接访问了您提供的链接(https://tools.tdsay.cn/favicon.ico),但作为一个文本交互的AI,我无法直接查看或分析图像内容。不过,通常情况下,favicon.ico 文件是用来显示网站图标的小图片,它会出现在浏览器标签、书签栏等地方。

如果您想要知道这个 favicon 的具体内容或者样子,最简单的方法是直接在您的浏览器中打开该链接,或者将此链接复制到浏览器地址栏中回车,这样就可以看到这个小图标是什么样的了。如果需要更详细的描述或者其他帮助,请告诉我!" toolExecutionRequests = [] }

上面的消息回复比较明确了,“我直接访问了您提供的链接”也就是说大模型具备联网查询功能,
“作为一个文本交互的AI,我无法直接查看或分析图像内容” ,确实不具备多模态功能。

三、系统提示词

  • 白话解释:提示词分为两种,一种是系统提示词、一种是用户提示词,系统提示词是系统默认设置的提示词,一般来说用户是不可以直接看到的,多用于设置AI的定位和规则约束。目的在于更好的去理解和解决用户提出的问题。

    1
    2
    3
    4
    5
    6
    7
    你是一位专业的挂号助手,擅长根据用户描述的症状或已知疾病,快速、准确地推荐合适的就诊科室。你的建议仅供分诊参考,不能替代医生的诊断。

    请围绕以下 4 个方向给出帮助:
    1、常见症状分诊:如发热、咳嗽、头痛、腹痛、胸痛、头晕、呕吐、皮疹等,分别建议挂什么科(呼吸内科、神经内科、消化内科、心内科、皮肤科等)。
    2、特定疾病对应科室:如高血压、糖尿病、骨折、眼红、牙痛、耳鸣等,告知首选科室,并提示可能涉及的相关科室。
    3、急诊与危重指征:明确哪些情况必须去急诊(如剧烈胸痛、大出血、意识丧失、严重呼吸困难等),避免耽误病情。
    4、特殊人群与复杂情况:儿童(14岁以下优先儿科)、孕产妇(产科或妇产科)、外伤(急诊外科或骨科)、老年人多种病共存(可建议老年科或先看全科)。

比如有一些患者生病了但是不知道挂什么科室,可以通过上述提示词,规定了AI的角色,按照什么方式去回答问题,重点关注方向是什么。

  • 实现原理:开发者自定义程序的系统提示词,用户在调用AI对话时,系统会默认将上述提示词一同发给大模型,不需要用户每次手动编写。

  • 实现方式也很简单,首先定义系统提示词常量,在调用ChatModel.chat时传入就好。注意ChatModel.chat是不区分提示词类型的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 简单对话
* @param msg 用户文本
*/
public String chatWithMessage(String msg){
// 构建系统提示词
SystemMessage systemMessage = SystemMessage.from(SYSTEM_MESSAGE);
// 构建userMessage
UserMessage userMessage = UserMessage.from(msg);
// 通过ChatModel调用大模型
ChatResponse chatResponse = qwenChatModel.chat(systemMessage,userMessage);
// 大模型的相应结果
AiMessage aiMessage = chatResponse.aiMessage();
log.info("AI 回复的消息:{}", aiMessage.toString());
return aiMessage.text();
}
1
AI 回复的消息:AiMessage { text = "你好,土豆丝!很高兴为你提供帮助。如果你有任何健康上的疑问或需要就医方面的建议,请告诉我你的症状或具体问题,我会尽力为你推荐合适的就诊科室。请描述一下你的情况吧。" toolExecutionRequests = [] }

可以看到已经生效了

四、AI Service

  • 白话解释:除了上面的通过chatModel的实现方式,langchain4j还提供了AI Service的实现方式,AI Service可以简单理解为在chatModel模式下做了一层封装,封装成了一个更高级的API,对于封装来讲越底层的越灵活,越高级的越方便,大家可以根据自己的场景自由选择。

  • 首先先引入依赖

  • 1
    2
    3
    4
    5
    <dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j</artifactId>
    <version>1.1.0</version>
    </dependency>
  • 在resources目录下创建一个系统提示词文件system-prompt.txt,并在里面编写好系统提示词。

  • 创建一个AI service接口

1
2
3
4
5
public interface AiChatService {

@SystemMessage(fromResource = "system-prompt.txt")
String chatWithMsg(String msg);
}
  • 接口是无法直接调用的,所以还需要创建实现类,使用Factory模式进行创建。
1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class AiChatServiceFactory {

@Resource
private ChatModel qwenChatModel;

@Bean
public AiChatService aiChatService(){
return AiServices.create(AiChatService.class, qwenChatModel);
}
}

代码说明:AI Servie的调用和我们之前先写的Service接口,在写接口的impl实现一样,都是通过spring对service bean进行注入,提供service接口调用,langchain4j的AiServices.create方法通过反射以及传入ChatModel帮我们生成好了一个接口的实现,也就是不需要我们手动的去实现impl,这就是前面说的高级封装。

  • 测试一下
1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
class AiChatServiceTest {

@Resource
private AiChatService aiChatService;

@Test
void chatWithMsg() {
String response = aiChatService.chatWithMsg("你好,我是土豆丝");
System.out.println(response);
}
}
1
你好,土豆丝!很高兴为你提供帮助。请告诉我你或需要帮助的人目前的症状或已知的疾病情况,我会根据你的描述推荐合适的就诊科室。如果涉及紧急情况,请立即前往医院急诊科或拨打急救电话。

可以看到,通过Ai Service的方式实现成功了。

  • 还有一种更方便的不需要手动创建bean的方式,先引入依赖
1
2
3
4
5
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
<version>1.1.0-beta7</version>
</dependency>
  • 注释掉AiChatServiceFactory,不然bean会冲突。在AiChatService上加一个注解
1
2
3
4
5
6
@AiService
public interface AiChatService {

@SystemMessage(fromResource = "system-prompt.txt")
String chatWithMsg(String msg);
}

然后直接调用也是可以的,但是不建议使用这种方式,因为对于AI Service来讲,后续还有一些参数需要配置,如果使用注解的方式,这些参数都需要在注解上进行指定,而且如果你的bean的创建需要一些自定义的流程,使用这这种方式将会很麻烦。这就是封装的代价,封装和灵活总要有一些取舍。

五、会话记忆ChatMemory

  • 白话解释:我们在调用大模型时,可以简单理解为是无状态调用,也就是说大模型并不知道对话的历史,每次调用都是一个新的对话,包括提示词也好、之后使用的MCP也好,等等大模型都是没有记忆的。比如第一次调用大模型说我是土豆丝,再次调用问我是谁的时候,大模型是不知道的,所以需要有一个地方记录之前的对话并发个大模型,让大模型知道上下文的数据,方便之后的处理。
  • 实现方式:可以自己记录找地方存起来,也可以通过LangChain4j框架为我们提供的方式进行实现。
1
2
3
4
5
6
7
8
9
10
@Bean
public AiChatService aiChatService(){
// 会话记忆,最大10条
ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);
// 构造AI Service
return AiServices.builder(AiChatService.class)
.chatModel(qwenChatModel)
.chatMemory(chatMemory)
.build();
}

AiServices可以通过构造器的方式对serivice进行构建,方便自定义的去配置功能组件,比如我们现在要使用ChatMemory,就可以直接构建一个并组装进去。

  • 测试一下
1
2
3
4
5
6
7
@Test
void chatWithMemory() {
String response = aiChatService.chatWithMsg("你好,我是土豆丝");
System.out.println(response);
response = aiChatService.chatWithMsg("你好,我是谁");
System.out.println(response);
}
1
2
3
第一次回复:你好,土豆丝!很高兴为你提供帮助。请告诉我你或你的家人目前遇到了什么健康问题,比如有什么症状或者已知的疾病,我将根据你提供的信息推荐合适的就诊科室。如果不确定具体症状,也可以描述一下具体情况,我会尽力给你建议。

第二次回复:你好!你刚才提到自己是“土豆丝”,现在又问“我是谁”。如果你是在开玩笑或者使用昵称,那没问题。不过,为了更好地帮助你,请告诉我你或你的家人目前遇到的健康问题或症状,这样我才能给出合适的就诊建议。如果有其他需要帮助的地方,也请告诉我!

可以看到会话记忆功能已经实现了。通过debug可以看到AiService是一个代理对象,对象里的chatMemoryService里已经存储了对话内容,注意不仅是用户发的消息,AI回复的消息也是会存储的。

  • 会话记忆默认是存储在内容中的,如果需要持久化,比如存储到数据库中,LangChain4j也提供了持久化的方法。

简单介绍:创建一个ChatMemoryStore的实现类实现ChatMemoryStore,ChatMemory 也可以通过构造器的方式进行构造,包括最大条数,和chatMemoryStore,把chatMemoryStore实现类传入构造器就可以了,还是挺简单的。后续的文章介绍开发Agent智能体的时候在进行实现,

六、会话隔离

  • 白话解释:在实际对话中,比如土豆丝和土豆片两个人都与大模型进行对话,那么如何进行区分呢,总不能土豆丝的消息回复到土豆片的对话里对吧。

所以为了进行区分,LangChain4j引入了memoryId这个参数,简单理解为会话id。

  • 实现方式也很简单,AiChatService新增一个方法,增加memoryId参数并配置入参的注解
1
2
@SystemMessage(fromResource = "system-prompt.txt")
String chatWithMsg(@MemoryId int memoryId, @UserMessage String msg);

然后在AI Service进行构造

1
2
3
4
5
6
7
8
9
10
11
@Bean
public AiChatService aiChatService(){
// 会话记忆,最大10条
ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);
// 构造AI Service
return AiServices.builder(AiChatService.class)
.chatModel(qwenChatModel)
.chatMemory(chatMemory)
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10)) // 会话记忆隔离
.build();
}
  • 测试一下
1
2
3
4
5
6
7
@Test
void chatWithMemory() {
String response = aiChatService.chatWithMsg(1,"你好,我是土豆丝");
System.out.println(response);
response = aiChatService.chatWithMsg(2,"你好,我是谁");
System.out.println(response);
}
1
2
3
第一次回复:你好,土豆丝!很高兴为你提供帮助。请告诉我你或你需要帮助的人目前的症状或已知疾病,我会根据你的描述推荐合适的就诊科室。如果还有其他相关问题,也欢迎一并告知。

第二次回复:您好!看起来您可能不小心输入了“我是谁”,请问您是否有身体上的不适或需要咨询挂号相关的问题呢?如果有任何症状或疾病需要了解应该挂哪个科室,欢迎您告诉我,我会尽力提供帮助。

可以看到会话成功被隔离了,通过debug可以发现LangChain4j在chatMemories里对传入的不同的memoryId进行了区分处理。

七、结构化输出

  • 白话解释:上面讲述的大模型返回的消息都是纯文本,如果想对大模型回复的消息做一些处理,比如转成json储存到数据库中,都需要对返回的消息进行结构化的处理

  • 实现方式:

  1. JSON Schema:最精准,大模型本身提供的能力,但是稍有一些麻烦,需要自定义必须有哪些返回值,可以阅读上面的文档。
  2. Prompt + JSON Mode:可以阅读这篇文章https://glaforge.dev/posts/2024/11/18/data-extraction-the-many-ways-to-get-llms-to-spit-json-content/
  3. Prompt:通过提示词进行输出,最方便,相对于上面的两种不够稳定,但是也够用,本文介绍的就是这种方式
  • 创建一个java类,用于接收并格式化消息,AIService新增一个chat方法返回值改为创建的类
1
2
3
4
5
6
7
8
9
10
@SystemMessage(fromResource = "system-prompt.txt")
Patient chatForPatient(String msg);

/**
* 患者对象
* @param name 姓名
* @param symptom 症状
* @param suggestionList 科室建议
*/
record Patient(String name, String symptom, List<String> suggestionList){}
  • 测试一下
1
2
3
4
5
@Test
void chatForPatient() {
AiChatService.Patient patient = aiChatService.chatForPatient("你好,我是土豆丝,是一个8岁男孩,我有点头晕,请问哪些科室的号可以挂");
System.out.println(patient);
}
1
Patient(name=土豆丝, disease=头晕, suggestionList=[儿科, 神经内科])

可以发现已经成功了,如果你发现有的时候这种方式无法成功的实现结构化输出,可以参考文档使用JSON Schema的方式进行实现,准确度和可靠性都是最高的一种模式。

八、RAG

  • 啥是RAG:百度百科的定义,检索增强生成(Retrieval-Augmented Generation,RAG)是一种结合检索和生成技术的模型。它通过引用外部知识库的信息来生成答案或内容。

  • 白话解释:LLM大模型是通过数据进行训练的,它的通用性比较强,但是问一些比如关于企业内部的数据问题,它可能回答不知道,也可能产生幻读,乱给出一些答案。因此就需要引入外部的数据来供大模型进行判断。

但是外部数据格式不统一,可能是千奇百怪的,如何才能使得大模型可以检索这些数据呢,答案是“向量”,所有的数据都可以通过算法来转换成向量。

向量的数据格式大概长这样(421,123,123,5457,67,……)每一个数字都是一个维度,维度越多,对于数据的描述越精确(基本上), 根据向量算法可以找到与问题最匹配的数据。

也就是说需要处理一些大模型不知道的数据时,我们要先将数据转换成向量,然后才能进行答案的生成,数据转换的过程称之为embedding。

embedding也是各大模型平台提供的一个服务,可以通过调用平台接口,把数据转换成向量。

  • 关于rag的实现方式LangChain4j有三种实现方式

    • Easy RAG:也就是在依赖包中内嵌了一个小型的embedding实现,不需要调用外部就可以实现,但是效果一般,只做展示使用,没有太大价值。
    • 核心RAG:常用实现方式,本文介绍的就是这种方式。
    • 高级RAG:有一些高级特性,支持查询转换、查询路由、内容聚合等,感兴趣的可以阅读官方文档。
  • embedding也选择阿里的text-embedding-v4

1
2
3
embedding-model:
model-name: text-embedding-v4
api-key: 输入自己申请的key!!!
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 RagConfig {

/** 千问的Embedding模型 */
@Resource
private EmbeddingModel qwenEmbeddingModel;

@Resource
private EmbeddingStore<TextSegment> embeddingStore;

@Bean
public ContentRetriever contentRetriever(){
// 1.加载文件
List<Document> documents = FileSystemDocumentLoader.loadDocuments("src/main/resources/docs");
// 2、文档切割:段落分割器,最大1000字符,最多重叠200字符
DocumentByParagraphSplitter documentByParagraphSplitter =
new DocumentByParagraphSplitter(500, 100);
// 3、自定义文档加载器,把文档转换成向量,把转换后的向量存储在向量数据库中
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
.documentSplitter(documentByParagraphSplitter)
// 为了提高文档的质量,为每个切割后的文档碎片 TextSegment 添加文档名称作为元信息
.textSegmentTransformer(textSegment -> TextSegment.from(textSegment.metadata().getString("file_name") + "\n" + textSegment.text(), textSegment.metadata()))
.embeddingModel(qwenEmbeddingModel)
.embeddingStore(embeddingStore)
.build();
// 加载文档
ingestor.ingest(documents);

// 4、自定义内容加载器
return EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(qwenEmbeddingModel)
.maxResults(5) // 自定义最多返回结果
.minScore(0.75) // 匹配程度
.build();
}
}

说明下代码

  • FileSystemDocumentLoader是langchain4j提供的文档读取器,可以对本地的文件进行读取
  • 切割:维度有很多种,比如按照段落、按照段落切割、按照单词切割、按照正则表达式切割等,本文采用的是按照段落切割。

对文档进行切割,参数为最大字符和重叠字符,为什么要对文档进行切割呢,因为在把文档转为向量时,相当于对文档进行拆解,就像章节一样,每部分讲解的内容是不一样的,这样在对问题进行查询时,匹配的才会更准确。重叠的作用是当文档切割时,可能会超出最大字符数。所以在切割时可能会导致切割后的结果不完整,重叠的含义就是前一个切分会有后一个切分的前100个字符的内容,后一个切分会有前一个切分的前100个字符的内容,保证内容的完整性。

  • 自定义文档加载器:作用就是如何把文档转成向量,为文档碎片配置元信息方便匹配,配置段落分割器、embedding模型、Embedding存储位置;
  • 自定义内容加载器:maxResults可以 自定义最多返回结果,自定义minScore匹配程度(在实际开发中需要不断调整)
  • 我们在大模型问答时,有的时候可以看到大模型回答问题引用了哪些文档,这个功能可以使用Result作为AiService返回结果进行实现,而且还可以看到token的使用情况。
1
Result<String> chatWithRag(String msg);

九、Tool Caling

  • 白话解释:LLM大模型本身只有推理能力,但是没有动手能力,RAG是为大模型加了一个外部知识库,而Tools相当于为大模型增加了手脚,相当于如虎添翼。实现原理是告诉大模型有哪些工具可以使用,比如在挂号时查询某个科室还有几个号,这种不使用工具大模型是完成不了的,所以基于这种场景催生了Tool。

整体的调用流程是,用户提问现在内科还有几个号,调用我们开发的AI程序,我们的AI程序会把用户的问题和我们自己开发的Tool工具一起发给大模型,大模型在思考之后,告诉我们的AI程序,需要调用Tool,AI程序调用Tool之后把调用的结果发给大模型,大模型拿到结果之后,将结果和回答组装成结果返回给我们的AI程序,AI程序对用户进行一个结果的展示。

  • 实现方式很简单,创建一个Tool类,新增一个Tool方法,方法上增加一个@Tool注解,注解包含两个参数,name是定义Tool的名称,value是对这个工具进行描述。方法入参是对参数进行一个描述。以上建议使用英文,效果会更好一些。

在AiChatServiceFactory构建AIService时增加新增的Tools即可。AiServices.builder.tools(new RegistrationsNumTool())

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 查询挂号数量
*/
@Slf4j
public class RegistrationsNumTool {

/**
* 查询
* @param department 科室名称
*/
@Tool(name = "Query the number of registrations", value = """
Query the remaining registration slots for a specified department.
Support precise query by department and return the number of remaining slots.
"""
)
public Integer searchInterviewQuestions(@P(value = "Department names, such as 'Internal Medicine', 'Pediatrics', 'Dermatology'.") String department) {
return 4;
}
}

十、MCP

  • 白话解释:Tool的工具服务标准,相当于把Tool从本地抽离,可以单独启动一套提供Tool的MCP的服务,这样做的好处一是解耦,二是可以直接搭建一个MCP平台,对外提供MCP服务。

  • 本文主要讲解LangChain4j的使用,就不搭建本地的MCP服务了,直接使用智谱的MCP的联网查询服务

  • 引入依赖,配置智谱平台API KEY,创建McpConfig,AiChatService构建AiServices.builder.toolProvider(mcpToolProvider)

1
2
3
4
5
6
<!-- mcp依赖 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-mcp</artifactId>
<version>1.1.0-beta7</version>
</dependency>
1
2
bigmodel:
api-key: 输入自己申请的key!!!
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
@Configuration
public class McpConfig {

@Value("${bigmodel.api-key}")
private String apiKey;

@Bean
public McpToolProvider mcpToolProvider() {
// 和 MCP 服务通讯
McpTransport transport = new HttpMcpTransport.Builder()
.sseUrl("https://open.bigmodel.cn/api/mcp/web_search/sse?Authorization=" + apiKey)
.logRequests(true) // 开启日志,查看更多信息
.logResponses(true)
.build();
// 创建 MCP 客户端
McpClient mcpClient = new DefaultMcpClient.Builder()
.key("yupiMcpClient")
.transport(transport)
.build();
// 从 MCP 客户端获取工具
McpToolProvider toolProvider = McpToolProvider.builder()
.mcpClients(mcpClient)
.build();
return toolProvider;
}
}
  • 测试
1
2
3
4
5
@Test
void chatWithMCP() {
String response = aiChatService.chatWithMsg("https://tools.tdsay.cn/ 这个网址是做什么的");
System.out.println(response);
}
1
2
网址 https://tools.tdsay.cn/ 是一个提供多种在线工具服务的网站,从搜索结果中可以看到它提供了如下几种工具:.............
文字太多了我就不粘了

十、护栏机制

  • 白话解释:所谓护栏就是在调用AI之前干什么,调用之后干什么,就和AOP一个意思。还有点filter的意思,可以实现多个护栏依次调用,根据返回结果进行处理,返回结果以及处理方式可以参考官方文档。
  • 支持输入护栏和输出护栏,本文以输入护栏敏感词校验为例。

编写完护栏之后依旧AiChatService构建AiServices.builder.inputGuardrails(new SafeInputGuardrail())

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
/**
* 安全检测输入护栏
*/
public class SafeInputGuardrail implements InputGuardrail {

private static final Set<String> sensitiveWords = Set.of("kill");

/**
* 检测用户输入是否合法
*/
@Override
public InputGuardrailResult validate(UserMessage userMessage) {
// 获取用户输入并转换为小写以确保大小写不敏感
String inputText = userMessage.singleText().toLowerCase();
// 使用正则表达式分割输入文本为单词
String[] words = inputText.split("\\W+");
// 遍历所有单词,检查是否存在敏感词
for (String word : words) {
if (sensitiveWords.contains(word)) {
return fatal("Sensitive word detected: " + word);
}
}
return success();
}
}
1
2
3
4
5
6
7
8
@Test
void chatWithMCP() {
String response = aiChatService.chatWithMsg("kill ~");
System.out.println(response);
}

报错输出
dev.langchain4j.guardrail.InputGuardrailException: The guardrail com.tdsay.aichat.ai.guardrail.SafeInputGuardrail failed with this message: Sensitive word detected: kill

十一、SSE流式输出

  • 白话解释:在我们使用deepseek或者gpt时,响应的结果就像打字机一样流式输出,实现的原理和LLM的原理有关,LLM本质是一个随机推理大模型。每次都是预测下一个输出字符是什么,所以可以实现这种流式效果,这种效果也方便告知用户我这边一直在处理结果,不用让用户一直等待。

  • 注意:使用流式输出之后,不支持结构化输出。原因也很好理解,不是整体输出无法准确构建。

  • 实现:引入流式输出依赖,配置流式输出大模型,AiService定义流式方法,AiServices构建流式输出model,AiServices.builder.streamingChatModel(qwenStreamingChatModel),创建Controller提供接口调用

1
2
3
4
5
6
<!-- 流式输出 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-reactor</artifactId>
<version>1.1.0-beta7</version>
</dependency>
1
2
3
streaming-chat-model:
model-name: qwen-max
api-key: 输入自己申请的key!!!
1
Flux<String> chatStream(@MemoryId int memoryId, @UserMessage String msg);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Resource
private StreamingChatModel qwenStreamingChatModel;

@Bean
public AiChatService aiChatService(){
// 会话记忆,最大10条
ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);
// 构造AI Service
return AiServices.builder(AiChatService.class)
.chatModel(qwenChatModel)
.streamingChatModel(qwenStreamingChatModel)
.chatMemory(chatMemory)
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10)) // 会话记忆隔离
.build();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/ai")
public class AiChatController {

@Resource
private AiChatService aiChatService;

@GetMapping("/chat")
public Flux<ServerSentEvent<String>> chat(int memoryId, String message) {
return aiChatService.chatStream(memoryId, message)
.map(chunk -> ServerSentEvent.<String>builder()
.data(chunk)
.build());
}
}

调用结果

十二、前端页面生成

  • 由于是对话形式,前端就使用一个简单的页面进行实现就可以,可以使用Trea或者cursor直接生成前端项目代码,下面是提示词。
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
你是一位专业的前端开发,请帮我根据下列信息来生成对应的前端项目代码。

#需求

I

应用为《AI 挂号小助手》,帮助用户解答用户挂号相关的问题,并给出建议。

只有一个页面,就是主页:页面风格为聊天室,上方是聊天记录(用户信息在右边,AI信息在左边),下方是输入框,进入页面后自动生成一个聊天室id,用于区分不同的会话。通过 SSE 的方式调用chat 接口,实时显示对话内容。

#技术选型

1.Vue3 项目

2.Axios请求库

#后端接口信息

接口地址前缀:http://localhost:8081/api

# SpringBoot 后端接口代码

@RestController

@RequestMapping("/ai")

public class AiChatController {



@Resource

private AiChatService aiChatService;



@GetMapping("/chat")

public Flux<ServerSentEvent<String>> chat(int memoryId, String message) {

return aiChatService.chatStream(memoryId, message)

.map(chunk -> ServerSentEvent.<String>builder()

.data(chunk)

.build());

}

}
  • 生成效果根据使用的不同模型会有所差别,但是这种比较简单的页面,微调几把也就实现了,下面是实现结果图。

总结

以上,就是关于LangChain4j的全部知识分享,还有一些没有讲到的内容可以查看LangChain4j的官方文档进行查阅。