LangChain4j-AiServices实现原理解析
阿昌 Java小菜鸡

LangChain4j-AiServices实现原理解析

Hi,我是阿昌,今天记录下LangChain4j里面AiServices的实现原理。

一开始看这个东西的时候,我觉得它的写法还是挺有意思的。因为我们只是写了一个接口,并没有写实现类,但是最后可以直接调用请求发给大模型。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class AiServicesDemo {
public static void main(String[] args) {
ChatLanguageModel model =
OpenAiChatModel.builder()
.baseUrl(Env.BASE_URL)
.apiKey(Env.APP_KEY)
.modelName("gpt-5.4")
.build();

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

String chat = assistant.chat("你好,我叫阿昌,很高兴认识你");

System.out.println(chat);
}

interface Assistant {
String chat(String userMessage);
}
}

这里最容易让人疑惑的点是:

Assistant明明只是一个接口,连实现类都没有,为什么可以调用assistant.chat(...)

所以下面会,简单看下AiServices到底做了什么。

一、先把这件事说简单点

AiServices做的事情,其实可以理解为:

它帮我们在运行时生成了一个接口实现类。

只不过这个实现类不是我们自己手写的,而是通过JDK动态代理生成的。

调用:

1
assistant.chat("你好");

表面上看是调用接口方法,实际执行的是代理对象里面的统一拦截逻辑。

这个代理逻辑大概做了这么几步:

1
2
3
4
5
6
7
8
9
10
11
拿到方法参数

把String转成UserMessage

组装ChatRequest

调用ChatLanguageModel

拿到ChatResponse

把AiMessage转成方法声明的返回类型

在当前例子里面,返回类型是String,所以最后就是返回AiMessage.text()

二、AiServices.create不是直接new对象

先看这行代码:

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

它不是去找AssistantImpl,也不是帮自动编译生成一个.java文件。

源码里面create方法大概是这样的:

1
2
3
public static <T> T create(Class<T> aiService, ChatLanguageModel chatLanguageModel) {
return builder(aiService).chatLanguageModel(chatLanguageModel).build();
}

也就是说,create只是一个便捷入口。

真正关键的点在后面的build()

  • aiService记录的是接口类型,也就是Assistant.class
  • chatLanguageModel记录的是模型对象,也就是创建的OpenAiChatModel
  • build()负责把这两个东西组装起来,最后返回一个代理对象

所以这里一定要注意:

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

返回的assistant不是普通实现类对象,而是一个代理对象。

三、代理对象怎么来的?

继续看DefaultAiServices#build,里面能看到熟悉的Proxy.newProxyInstance

1
2
3
4
5
6
7
8
9
Object proxyInstance = Proxy.newProxyInstance(
context.aiServiceClass.getClassLoader(),
new Class<?>[] {context.aiServiceClass},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Exception {
//....
}
});

这个就是JDK动态代理。

翻译一下就是:

创建一个对象,让它实现Assistant接口;以后这个接口里面的方法被调用时,都走InvocationHandler#invoke

所以当我们调用:

1
assistant.chat("你好,我叫阿昌,很高兴认识你");

实际上会变成类似下面这种调用:

1
invoke(proxy, chat方法, ["你好,我叫阿昌,很高兴认识你"]);

到这里就清楚了。

Assistant接口确实没有实现类,但是AiServices在运行时给它做了一个代理实现。

四、方法参数是怎么变成用户消息的?

定义的方法是:

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

这个方法很简单:

  • 没有@UserMessage
  • 没有@V
  • 只有一个普通的String参数

LangChain4j对这种情况有一个默认约定:如果方法只有一个无注解参数,那么这个参数就当作用户输入。

源码里有个方法就专门处理这个场景:

1
2
3
4
5
6
7
8
private static Optional<String> findUserMessageTemplateFromTheOnlyArgument(
Parameter[] parameters, Object[] args) {

if (parameters != null && parameters.length == 1 && parameters[0].getAnnotations().length == 0) {
return Optional.of(toString(args[0]));
}
return Optional.empty();
}

所以这个调用:

1
assistant.chat("你好,我叫阿昌,很高兴认识你");

会先被当成一段用户消息内容。

后面还会走一层PromptTemplate

1
2
3
4
5
6
String template = getUserMessageTemplate(method, args);
Map<String, Object> variables = findTemplateVariables(template, method, args);

Prompt prompt = PromptTemplate.from(template).apply(variables);

return prompt.toUserMessage();

为什么这里还要搞一个PromptTemplate

因为AiServices不只是支持这种简单字符串,它还支持模板变量。

比如:

1
2
3
4
interface Assistant {
@UserMessage("请把下面内容翻译成英文:{{content}}")
String translate(@V("content") String content);
}

这种写法里面,{{content}}就会被真实参数替换。

不过回到当前例子,它没有模板变量,所以最后就可以理解成:

1
UserMessage.from("你好,我叫阿昌,很高兴认识你");

五、UserMessage接下来怎么走?

大模型接口并不是直接吃我们这个String参数。

在LangChain4j里面,对话消息有自己的抽象,比如:

  • UserMessage:用户消息
  • AiMessage:模型回复
  • SystemMessage:系统消息
  • ChatMessage:上面这些消息的统一抽象

所以AiServices会先把用户输入变成UserMessage,再把它放到消息列表里面:

1
2
List<ChatMessage> messages = new ArrayList<>();
messages.add(userMessage);

然后组装成ChatRequest

1
2
3
4
ChatRequest chatRequest = ChatRequest.builder()
.messages(messages)
.parameters(parameters)
.build();

最后调用底层模型:

1
ChatResponse chatResponse = context.chatModel.chat(chatRequest);

这里的context.chatModel,就是我们一开始传进去的OpenAiChatModel

所以从这里开始,就进入真正的大模型调用逻辑了:

1
2
3
4
5
6
7
AiServices代理对象

ChatRequest

OpenAiChatModel

OpenAI兼容接口

OpenAiChatModel#doChat里面,会把LangChain4j自己的ChatRequest再转换成OpenAI格式的请求对象,然后通过HTTP请求发出去。

这一层其实就是适配不同模型厂商的地方。

六、返回值为什么能直接拿到String?

模型返回后,LangChain4j拿到的不是直接的String,而是:

1
ChatResponse

ChatResponse里面包含模型回复:

1
AiMessage

但是我们接口方法声明的是:

1
String chat(String userMessage);

所以这里还有一步返回值转换。

这块逻辑在ServiceOutputParser里面:

1
2
3
4
5
6
7
8
9
10
11
AiMessage aiMessage = response.content();

if (rawClass == AiMessage.class) {
return aiMessage;
}

String text = aiMessage.text();

if (rawClass == String.class) {
return text;
}

这段就很好理解了。

如果你的方法返回值是:

1
String

它就返回:

1
aiMessage.text()

如果你方法返回值写的是:

1
AiMessage

那它就直接把AiMessage返回给你。

如果你返回的是BooleanIntegerList<String>、自定义对象之类的类型,它就会继续找对应的OutputParser去解析。

所以当前例子里:

1
String chat = assistant.chat("你好,我叫阿昌,很高兴认识你");

最终拿到的就是模型回复文本。

七、用普通代码还原一下

为了方便理解,先不看动态代理。

假设我们自己手写这段逻辑,大概会写成这样:

1
2
3
4
5
6
7
8
9
10
11
String userInput = "你好,我叫阿昌,很高兴认识你";

UserMessage userMessage = UserMessage.from(userInput);

ChatRequest chatRequest = ChatRequest.builder()
.messages(userMessage)
.build();

ChatResponse chatResponse = model.chat(chatRequest);

String result = chatResponse.aiMessage().text();

AiServices的价值就是:

它把上面这些通用代码藏到了代理对象里面。

所以只需要写:

1
String result = assistant.chat("你好,我叫阿昌,很高兴认识你");

看起来像本地方法调用,实际背后是一次大模型请求。

八、这个设计有什么好处?

直接调用模型当然也可以,比如手动构造UserMessageChatRequest、再解析ChatResponse
但是如果业务里面到处都是这种代码,很快就会变乱。
比如商品场景里面,可能会有这些能力:

1
2
3
4
5
6
7
8
interface ProductAiService {

@UserMessage("请根据商品标题生成5个卖点:{{title}}")
List<String> generateSellingPoints(@V("title") String title);

@UserMessage("判断下面的用户评价是正向还是负向:{{content}}")
Boolean isPositiveComment(@V("content") String content);
}

业务代码调用的时候就比较舒服:

1
List<String> sellingPoints = productAiService.generateSellingPoints("夏季纯棉短袖T恤");

这时候接口方法本身就表达了业务含义。

至于底层怎么组装消息、怎么调模型、怎么解析结果,都交给AiServices统一处理。

这也是它比直接写一堆模型调用代码更适合业务封装的地方。

九、这里不要理解成玄学

刚开始看到这个写法,可能会觉得有点“神奇”:

1
2
Assistant assistant = AiServices.create(Assistant.class, model);
assistant.chat("你好");

但拆开看以后,其实都是Java里面比较常见的东西:

  • JDK动态代理:运行时创建接口实现类
  • 反射:读取方法、参数、注解、返回值类型
  • PromptTemplate:处理提示词模板
  • ChatRequest:封装模型请求
  • ServiceOutputParser:处理模型返回值

所以它不是接口自己有能力调用AI,而是:

LangChain4j在接口外面套了一层代理,代理里面帮我们完成了大模型调用。

十、总结

回到最开始的问题:

Assistant只是接口,为什么可以直接调用?

因为:

AiServices.create返回的是一个实现了Assistant接口的动态代理对象。

整个调用过程可以简单记成:

1
2
3
4
5
6
7
8
9
10
11
12
13
assistant.chat(String)

代理对象拦截

String变成UserMessage

UserMessage变成ChatRequest

ChatLanguageModel发起模型调用

ChatResponse里面取AiMessage

AiMessage.text()作为String返回

所以AiServices最核心的作用,就是把一次普通Java方法调用,翻译成一次大模型调用。

理解到这一层,再看后面的@UserMessage@SystemMessage@MemoryIdToolRAG这些扩展能力,就会顺很多。

 请作者喝咖啡