LangChain4j-AiServices实现原理解析
Hi,我是阿昌,今天记录下LangChain4j里面AiServices的实现原理。
一开始看这个东西的时候,我觉得它的写法还是挺有意思的。因为我们只是写了一个接口,并没有写实现类,但是最后可以直接调用请求发给大模型。
示例代码如下:
1 | public class AiServicesDemo { |
这里最容易让人疑惑的点是:
Assistant明明只是一个接口,连实现类都没有,为什么可以调用assistant.chat(...)?
所以下面会,简单看下AiServices到底做了什么。
一、先把这件事说简单点
AiServices做的事情,其实可以理解为:
它帮我们在运行时生成了一个接口实现类。
只不过这个实现类不是我们自己手写的,而是通过JDK动态代理生成的。
调用:
1 | assistant.chat("你好"); |
表面上看是调用接口方法,实际执行的是代理对象里面的统一拦截逻辑。
这个代理逻辑大概做了这么几步:
1 | 拿到方法参数 |
在当前例子里面,返回类型是String,所以最后就是返回AiMessage.text()。
二、AiServices.create不是直接new对象
先看这行代码:
1 | Assistant assistant = AiServices.create(Assistant.class, model); |
它不是去找AssistantImpl,也不是帮自动编译生成一个.java文件。
源码里面create方法大概是这样的:
1 | public static <T> T create(Class<T> aiService, ChatLanguageModel chatLanguageModel) { |
也就是说,create只是一个便捷入口。
真正关键的点在后面的build():
aiService记录的是接口类型,也就是Assistant.classchatLanguageModel记录的是模型对象,也就是创建的OpenAiChatModelbuild()负责把这两个东西组装起来,最后返回一个代理对象
所以这里一定要注意:
1 | Assistant assistant = AiServices.create(Assistant.class, model); |
返回的assistant不是普通实现类对象,而是一个代理对象。
三、代理对象怎么来的?
继续看DefaultAiServices#build,里面能看到熟悉的Proxy.newProxyInstance:
1 | Object proxyInstance = Proxy.newProxyInstance( |
这个就是JDK动态代理。
翻译一下就是:
创建一个对象,让它实现
Assistant接口;以后这个接口里面的方法被调用时,都走InvocationHandler#invoke。
所以当我们调用:
1 | assistant.chat("你好,我叫阿昌,很高兴认识你"); |
实际上会变成类似下面这种调用:
1 | invoke(proxy, chat方法, ["你好,我叫阿昌,很高兴认识你"]); |
到这里就清楚了。
Assistant接口确实没有实现类,但是AiServices在运行时给它做了一个代理实现。
四、方法参数是怎么变成用户消息的?
定义的方法是:
1 | interface Assistant { |
这个方法很简单:
- 没有
@UserMessage - 没有
@V - 只有一个普通的
String参数
LangChain4j对这种情况有一个默认约定:如果方法只有一个无注解参数,那么这个参数就当作用户输入。
源码里有个方法就专门处理这个场景:
1 | private static Optional<String> findUserMessageTemplateFromTheOnlyArgument( |
所以这个调用:
1 | assistant.chat("你好,我叫阿昌,很高兴认识你"); |
会先被当成一段用户消息内容。
后面还会走一层PromptTemplate:
1 | String template = getUserMessageTemplate(method, args); |
为什么这里还要搞一个PromptTemplate?
因为AiServices不只是支持这种简单字符串,它还支持模板变量。
比如:
1 | interface Assistant { |
这种写法里面,{{content}}就会被真实参数替换。
不过回到当前例子,它没有模板变量,所以最后就可以理解成:
1 | UserMessage.from("你好,我叫阿昌,很高兴认识你"); |
五、UserMessage接下来怎么走?
大模型接口并不是直接吃我们这个String参数。
在LangChain4j里面,对话消息有自己的抽象,比如:
UserMessage:用户消息AiMessage:模型回复SystemMessage:系统消息ChatMessage:上面这些消息的统一抽象
所以AiServices会先把用户输入变成UserMessage,再把它放到消息列表里面:
1 | List<ChatMessage> messages = new ArrayList<>(); |
然后组装成ChatRequest:
1 | ChatRequest chatRequest = ChatRequest.builder() |
最后调用底层模型:
1 | ChatResponse chatResponse = context.chatModel.chat(chatRequest); |
这里的context.chatModel,就是我们一开始传进去的OpenAiChatModel。
所以从这里开始,就进入真正的大模型调用逻辑了:
1 | AiServices代理对象 |
在OpenAiChatModel#doChat里面,会把LangChain4j自己的ChatRequest再转换成OpenAI格式的请求对象,然后通过HTTP请求发出去。
这一层其实就是适配不同模型厂商的地方。
六、返回值为什么能直接拿到String?
模型返回后,LangChain4j拿到的不是直接的String,而是:
1 | ChatResponse |
ChatResponse里面包含模型回复:
1 | AiMessage |
但是我们接口方法声明的是:
1 | String chat(String userMessage); |
所以这里还有一步返回值转换。
这块逻辑在ServiceOutputParser里面:
1 | AiMessage aiMessage = response.content(); |
这段就很好理解了。
如果你的方法返回值是:
1 | String |
它就返回:
1 | aiMessage.text() |
如果你方法返回值写的是:
1 | AiMessage |
那它就直接把AiMessage返回给你。
如果你返回的是Boolean、Integer、List<String>、自定义对象之类的类型,它就会继续找对应的OutputParser去解析。
所以当前例子里:
1 | String chat = assistant.chat("你好,我叫阿昌,很高兴认识你"); |
最终拿到的就是模型回复文本。
七、用普通代码还原一下
为了方便理解,先不看动态代理。
假设我们自己手写这段逻辑,大概会写成这样:
1 | String userInput = "你好,我叫阿昌,很高兴认识你"; |
而AiServices的价值就是:
它把上面这些通用代码藏到了代理对象里面。
所以只需要写:
1 | String result = assistant.chat("你好,我叫阿昌,很高兴认识你"); |
看起来像本地方法调用,实际背后是一次大模型请求。
八、这个设计有什么好处?
直接调用模型当然也可以,比如手动构造UserMessage、ChatRequest、再解析ChatResponse。
但是如果业务里面到处都是这种代码,很快就会变乱。
比如商品场景里面,可能会有这些能力:
1 | interface ProductAiService { |
业务代码调用的时候就比较舒服:
1 | List<String> sellingPoints = productAiService.generateSellingPoints("夏季纯棉短袖T恤"); |
这时候接口方法本身就表达了业务含义。
至于底层怎么组装消息、怎么调模型、怎么解析结果,都交给AiServices统一处理。
这也是它比直接写一堆模型调用代码更适合业务封装的地方。
九、这里不要理解成玄学
刚开始看到这个写法,可能会觉得有点“神奇”:
1 | Assistant assistant = AiServices.create(Assistant.class, model); |
但拆开看以后,其实都是Java里面比较常见的东西:
JDK动态代理:运行时创建接口实现类反射:读取方法、参数、注解、返回值类型PromptTemplate:处理提示词模板ChatRequest:封装模型请求ServiceOutputParser:处理模型返回值
所以它不是接口自己有能力调用AI,而是:
LangChain4j在接口外面套了一层代理,代理里面帮我们完成了大模型调用。
十、总结
回到最开始的问题:
Assistant只是接口,为什么可以直接调用?
因为:
AiServices.create返回的是一个实现了Assistant接口的动态代理对象。
整个调用过程可以简单记成:
1 | assistant.chat(String) |
所以AiServices最核心的作用,就是把一次普通Java方法调用,翻译成一次大模型调用。
理解到这一层,再看后面的@UserMessage、@SystemMessage、@MemoryId、Tool、RAG这些扩展能力,就会顺很多。