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

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

示例代码如下:

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动态代理生成的。

调用:

assistant.chat("你好");

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

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

拿到方法参数
  ↓
把String转成UserMessage
  ↓
组装ChatRequest
  ↓
调用ChatLanguageModel
  ↓
拿到ChatResponse
  ↓
把AiMessage转成方法声明的返回类型

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

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

先看这行代码:

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

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

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

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

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

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

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

所以这里一定要注意:

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

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

三、代理对象怎么来的?

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

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

所以当我们调用:

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

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

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

到这里就清楚了。

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

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

定义的方法是:

interface Assistant {
    String chat(String userMessage);
}

这个方法很简单:

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

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

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

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();
}

所以这个调用:

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

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

后面还会走一层PromptTemplate

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不只是支持这种简单字符串,它还支持模板变量。

比如:

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

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

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

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

五、UserMessage接下来怎么走?

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

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

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

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

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

然后组装成ChatRequest

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

最后调用底层模型:

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

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

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

AiServices代理对象
  ↓
ChatRequest
  ↓
OpenAiChatModel
  ↓
OpenAI兼容接口

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

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

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

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

ChatResponse

ChatResponse里面包含模型回复:

AiMessage

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

String chat(String userMessage);

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

这块逻辑在ServiceOutputParser里面:

AiMessage aiMessage = response.content();

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

String text = aiMessage.text();

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

这段就很好理解了。

如果你的方法返回值是:

String

它就返回:

aiMessage.text()

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

AiMessage

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

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

所以当前例子里:

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

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

七、用普通代码还原一下

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

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

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的价值就是:

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

所以只需要写:

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

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

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

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

interface ProductAiService {

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

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

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

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

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

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

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

九、这里不要理解成玄学

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

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

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

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

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

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

十、总结

回到最开始的问题:

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

因为:

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

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

assistant.chat(String)
  ↓
代理对象拦截
  ↓
String变成UserMessage
  ↓
UserMessage变成ChatRequest
  ↓
ChatLanguageModel发起模型调用
  ↓
ChatResponse里面取AiMessage
  ↓
AiMessage.text()作为String返回

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

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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐