基于AiService实现智能文章小助手
前言
上一篇文章,我们提到了AiService,这一篇文章实现一个简单的文章助手,这个应用就是希望能利用大模型的能力来帮助我写文章,那这样一个应用该如何利用LangChain4j来实现呢?接下来我们来利用AiService进行实现。
Java程序员的AI之LangChain4j(一)从零到企业级AI开发
一、定义一个AiService代理
interfaceWriter{Stringwrite();}
然后利用定义AiService来创建一个Writer代理对象:
packagecom.qjc.demo;importdev.langchain4j.model.chat.ChatLanguageModel;importdev.langchain4j.model.openai.OpenAiChatModel;importdev.langchain4j.service.AiServices;/***
* @projectName langchain4j-project-1
* @packageName com.qjc.demo
* @author qjc
* @description TODO
* @Email [email protected]
* @date 2024-10-12 10:00
**/publicclassAiService{interfaceWriter{Stringwrite(String title);}publicstaticvoidmain(String[] args){ChatLanguageModel model =OpenAiChatModel.builder().baseUrl("http://langchain4j.dev/demo/openai/v1").apiKey("demo").build();Writer writer =AiServices.create(Writer.class, model);}}
创建代理对象时,我们传入一个提前定义了的ChatLanguageModel,拿到Writer对象后,就可以调用write()方法来写文章了:
String content = writer.write("我最喜欢的人");System.out.println(content);
执行代码结果为:
我最喜欢的是我的家人,他们是我生命中最重要的人,无论发生什么事情,他们都会一直支持和爱护着我。他们给予我无限的爱和关怀,让我感到无比幸福和幸运。我愿意为他们奉献一切,尽我所能地去照顾和呵护他们。他们是我生命中最珍贵的存在,我永远都会珍惜和爱护他们。
以上例子可以看出AiServices的第一个作用:能够生成特定接口的代理对象,从而使得在调用代理对象方法时,能间接的调用大模型。这种代理模式的实现在Java各种框架中是非常常见的,这样就使得Writer接口或Writer对象具有了大模型的智能能力。
只不过上面的答案并不是一篇作文,大模型似乎是在回答“它喜欢的人是谁?”这个问题,而不是在写文章,也就是大模型并不知道它需要扮演为一名作家,因此,我们需要告诉大模型让它先扮演一名作家,然后再回答我的问题,这就需要用到上一节提到的SystemMessage,只不过我们这里用的是@SystemMessage注解。
@SystemMessage
我们只需要在write()方法上定义@SystemMessage,并描述系统提示词,如下:
interfaceWriter{@SystemMessage("请扮演一名作家,根据输入的文章题目写一篇300字以内的作文")Stringwrite(String title);}
那么当我调用write()方法时,LangChain4j就会自动组合SystemMessage和用户输入的标题,然后发送给大模型,这样大模型就知道自己是一名作家了。
比如运行以上代码的结果就变为了:
在我生命中,最爱的人是我母亲。她是我生命中最重要的人,也是我永远的依靠和支持。母亲那温暖的微笑,总是能给我无限的力量和勇气。
我记得小时候,母亲总是在我生病时守在我身边,用温柔的手轻轻拍着我的背,给我端来热腾腾的粥汤。她总是用她的爱和关心包裹着我,让我感受到无比的安全和幸福。
母亲是一个坚强而又温柔的女人,她用她的辛勤劳动支撑起这个家庭,用她的慈爱呵护着我们。我常常想,如果没有母亲,我将会是一个怎样的人呢?母亲是我生命中的灯塔,指引着我前行的方向。
无论我遇到什么困难和挑战,母亲总是在我身边默默支持着我。她的爱如同一股暖流,贯穿着我的整个生命。我爱你,母亲,永远都爱你。
写得比我好多了,并且现在Writer接口就是一名作家了,当然我们可以把创建ChatLanguageModel、代理对象的操作都封装到Writer接口中,比如:
packagecom.qjc.demo;importdev.langchain4j.model.chat.ChatLanguageModel;importdev.langchain4j.model.openai.OpenAiChatModel;importdev.langchain4j.service.AiServices;importdev.langchain4j.service.SystemMessage;/***
* @projectName langchain4j-project-1
* @packageName com.qjc.demo
* @author qjc
* @description TODO
* @Email [email protected]
* @date 2024-10-12 10:00
**/publicclassAiService{interfaceWriter{@SystemMessage("请扮演一名作家,根据输入的文章题目写一篇300字以内的作文")Stringwrite(String title);staticWritercreate(){ChatLanguageModel model =OpenAiChatModel.builder().baseUrl("http://langchain4j.dev/demo/openai/v1").apiKey("demo").build();returnAiServices.create(Writer.class, model);}}publicstaticvoidmain(String[] args){Writer writer =Writer.create();String content = writer.write("我最喜欢的人");System.out.println(content);}}
这样,我们只需要调用Writer.create()就能得到一名作家了,如果你用SpringBoot,那么就可以把创建出来的Writer代理对象注册为一个Bean,在其他Controller、Service中任意使用了,你甚至可以基于同样的思路定义更多的角色扮演,比如算命大师、取名大师、冷笑话大师等等。
2.源码分析
主要逻辑为:
- 代理对象的创建流程
- 代理对象的方法执行流程
代理对象的创建流程
创建代理对象是通过AiServices.create(Writer.class, model)进行的,由于AiServices是一个抽象类,源码中有一个默认的子类DefaultAiServices,核心实现源码都在DefaultAiServices中。
DefaultAiServices的build方法就是用来创建指定接口的代理对象:
publicTbuild(){// 验证是否配置了ChatLanguageModelperformBasicValidation();// 验证接口中是否有方法上加了@Moderate,但是又没有配置ModerationModelfor(Method method : context.aiServiceClass.getMethods()){if(method.isAnnotationPresent(Moderate.class)&& context.moderationModel ==null){throwillegalConfiguration("The @Moderate annotation is present, but the moderationModel is not set up. "+"Please ensure a valid moderationModel is configured before using the @Moderate annotation.");}}// JDK动态代理创建代理对象Object proxyInstance =Proxy.newProxyInstance(
context.aiServiceClass.getClassLoader(),newClass<?>[]{context.aiServiceClass},newInvocationHandler(){@OverridepublicObjectinvoke(Object proxy,Method method,Object[] args)throwsException{// ...}});return(T) proxyInstance;}
可以发现,其实就是用的JDK动态代理机制创建的代理对象,只不过在创建代理对象之前有两步验证:
- 验证是否配置了ChatLanguageModel:这一步不难理解,如果代理对象没有配置ChatLanguageModel,那就利用不上大模型的能力了
- 验证接口中是否有方法上加了@Moderate,但是又没有配置ModerationModel
@Moderate和ModerationModel
Moderate是温和的意思,这是一种安全机制,比如:
@SystemMessage("请扮演一名作家,根据输入的文章题目写一篇300字以内的作文")@ModerateStringwrite(String title);
我们在write()方法上加了@Moderate注解,那么当调用write()方法时,会调用两次大模型:
- 首先是配置的ModerationModel,如果没有配置则创建代理对象都不会成功,ModerationModel会对方法的输入进行审核,看是否涉及敏感、不安全的内容。
- 然后才是配置的ChatLanguageModel
配置ModerationModel的方式如下:
interfaceWriter{@SystemMessage("请扮演一名作家,根据输入的文章题目写一篇300字以内的作文")@ModerateStringwrite(String title);staticWritercreate(){ChatLanguageModel model =OpenAiChatModel.builder().baseUrl("http://langchain4j.dev/demo/openai/v1").apiKey("demo").build();ModerationModel moderationModel =OpenAiModerationModel.builder().baseUrl("http://langchain4j.dev/demo/openai/v1").apiKey("demo").build();returnAiServices.builder(Writer.class).chatLanguageModel(model).moderationModel(moderationModel).build();}}
虽然ChatLanguageModel和ModerationModel都是OpenAi,但是你可以理解为OpenAiModerationModel在安全方面更近专业。
代理对象的方法执行流程
代理对象创建出来之后,就可以指定代理对象的方法了,而一旦执行代理对象的方法就是进入到上述源码中InvocationHandler的invoke()方法,而这个invoke()方法是LangChain4j中的最为重要的,里面涉及的组件、功能是非常多的,而本节我们只关心是怎么解析@SystemMessage得到系统提示词,然后组合用户输入的标题,最后发送给大模型得到响应结果的。
在invoke()方法的源码中有这么两行代码:
Optional<SystemMessage> systemMessage =prepareSystemMessage(method, args);UserMessage userMessage =prepareUserMessage(method, args);
分别调用了prepareSystemMessage()和prepareUserMessage()两个方法,而入参都是代理对象当前正在执行的方法和参数。
在看prepareSystemMessage()方法之前,我们需要再了解一个跟@SystemMessage有关的功能,前面我们是这么定义SystemMessage的:
@SystemMessage("请扮演一名作家,根据输入的文章题目写一篇300字以内的作文")
其中300是固定的,但是作为一名作家不可能永远只能写300字以内的作文,而这个字数应该都用户来指定,也就是说300应该得是个变量,那么我们可以这么做:
@SystemMessage("请扮演一名作家,根据输入的文章题目写一篇{{num}}字以内的作文")Stringwrite(@UserMessageString title,@V("num")int num);
其中{num}就是变量,该变量的值由用户在调用write方法时指定,注意由于write()有两个参数了,需要在title参数前面定义@UserMessage,表示title是用户消息。
这样我们就可以让Write写一篇任意字数以内的文章了:
String content = writer.write("我最喜欢的人",300);
知道了这个场景,我们再来看prepareSystemMessage()方法的实现:
privateOptional<SystemMessage>prepareSystemMessage(Method method,Object[] args){// 得到当前正在执行的方法参数Parameter[] parameters = method.getParameters();// 解析方法参数前定义的@V注解,@V的value为Map的key,对应的参数值为Map的valueMap<String,Object> variables =getPromptTemplateVariables(args, parameters);// 解析方法上的@SystemMessage注解dev.langchain4j.service.SystemMessage annotation = method.getAnnotation(dev.langchain4j.service.SystemMessage.class);if(annotation !=null){// 拼接多个SystemMessage注解String systemMessageTemplate =String.join(annotation.delimiter(), annotation.value());if(systemMessageTemplate.isEmpty()){throwillegalConfiguration("@SystemMessage's template cannot be empty");}// 填充变量Prompt prompt =PromptTemplate.from(systemMessageTemplate).apply(variables);// 返回最终的SystemMessage对象returnOptional.of(prompt.toSystemMessage());}returnOptional.empty();}
从源码看出@SystemMessage注解的value属性是一个String[]:
@Target({TYPE, METHOD})@Retention(RUNTIME)public@interfaceSystemMessage{String[]value();Stringdelimiter()default"\n";}
表示如果系统提示词比较长,可以写成多个String,不过最后会使用delimiter的值将这多个String拼接为一个SystemMessage,并且在拼接完以后会根据@V的值填充SystemMessage中的变量,从而得到最终的SystemMessage。
再来看prepareUserMessage()方法,本篇我们只关心:
// 如果有多个参数,获取加了@UserMessage注解参数的值作为UserMessagefor(int i =0; i < parameters.length; i++){if(parameters[i].isAnnotationPresent(dev.langchain4j.service.UserMessage.class)){String text =toString(args[i]);if(userName !=null){returnuserMessage(userName, text);}else{returnuserMessage(text);}}}// 如果只有一个参数,则直接使用该参数值作为UserMessageif(args.length ==1){String text =toString(args[0]);if(userName !=null){returnuserMessage(userName, text);}else{returnuserMessage(text);}}
还是比较简单的:
- 如果有多个参数,获取加了@UserMessage注解参数的值作为UserMessage
- 如果只有一个参数,则直接使用该参数值作为UserMessage
这样就得到了最终的SystemMessage和UserMessage,那么如何将他们组装在一起呢?
还记得上一篇 Java程序员的AI之LangChain4j(一)从零到企业级AI开发 提到的历史对话吗?请看代码:
List<ChatMessage> messages;if(context.hasChatMemory()){
messages = context.chatMemory(memoryId).messages();}else{
messages =newArrayList<>();// 添加SystemMessage
systemMessage.ifPresent(messages::add);// 添加UserMessage
messages.add(userMessage);}
我们还没有设置ChatMemory,所以组装的逻辑其实就是按顺序将SystemMessage和UserMessage添加到一个List中,后续只要将这个List传入给ChatLanguageModel的generate()方法就可以了。
那么ChatLanguageModel的generate()方法是如何处理List的呢?会拼接为一个字符串吗?比如:“请扮演一名作家,根据输入的文章题目写一篇300字以内的作文,我喜欢的人”,并不会,我们看看OpenAiChatModel的实现:
publicstaticList<Message>toOpenAiMessages(List<ChatMessage> messages){return messages.stream().map(InternalOpenAiHelper::toOpenAiMessage).collect(toList());}
在正式调用OpenAi的接口之前,OpenAiChatModel会利用toOpenAiMessages来处理List,不过注意它的返回仍然是一个List,只不过变成了List,ChatMessage是LangChain4j定义的,Message是OpenAi定义的,实际上它们并没有太多的区别,我们看下转换之后的List:
content确实没有区别,多了role属性,意义其实是一样的,SYSTEM表示系统提示词,USER表示用户提示词,那为什么这样可以呢?是因为OpenAi提供的接口本来就支持通过这种方式来设置系统提示词,比如:
大家可以访问OpenAI的官网,同时大家也要注意到OpenAi的接口还支持Assistant message和Tool message两种类型,这两种类型是跟工具机制有关系的,我后续会进行分析。
总结
本篇讲了什么是AiService以及基本应用,制作了一个用户可以指定字数和标题的作家应用,同时研究了AiService的基本工作原理和源码,其中再次提到了ChatMemory,那么下篇内容我们就来介绍到底什么是ChatMemory。
如果你觉得我写的不错,请留下评论,或者点个赞收藏,一下是我的动力,关注我,我会尽量根据时间来学习一些东西,进行发表文章。
版权归原作者 怎么起个名就那么难 所有, 如有侵权,请联系我们删除。