Skip to content
oldmanpushcart edited this page May 21, 2024 · 7 revisions

DashScope4j:灵积 Java SDK

License JDK17+ LLM-通义千问

项目简介

DashScope4j是一个开源的灵积非官方 Java SDK,基于 JDK17 构建。它旨在提供一个功能丰富、易于集成和使用灵积API(通义千问模型)的Java库,以便开发者能够通灵积API轻松实现多模态对话、向量嵌入和图像处理等功能。

写在前边

我是一名Java程序员,曾经写过C、汇编、python2.7,但不得不说Java是最符合我思考模式的语言。随着越来越多的LLM开始落地,越发感受到如果不去理解和应用这项技术,迟早也会被这个社会所淘汰。所以我决定要在团队内部推广和应用这门技术。

在团队内推广LLM的时候,最大的障碍就是大家对Python这门语言的畏难情绪。虽然程序员不应该受到语言的限制,语言只是一门工具。但事实是:大家都在考虑机会和成本。我们的精力都有限,为了学习一个未来自己并不笃定的方向而投入时间去学习一个可能应用不到当前工作和生活的工具,大家都多少都表现出了畏难的情绪。

为了解决这个问题,所以决定开发一个Java SDK,将通义千问(灵积平台)的API与Java进行对接,从而解决LLM应用对Java开发者的门槛。

这个项目是动用周末和晚上的业余时间完成,在编写的过程中让逐步熟悉了LLM的API,通过API也逐步理解了LLM的魅力与边界。之所以要开源也是希望能将思考和学习的过程向Java同行分享,但愿能对为看到这篇文章的同行有一定的帮助。

项目特点

其实灵积是有官方 Java SDK ,功能也非常丰富。但有一点是肯定的:这货不开源,缺少必要的注释帮助你去理解实现特点和使用方式,文档也不够丰富。

极简依赖,更便于被项目整合

整个项目需要考虑易被整合,使用了JDK11自带的HttpClient来发送请求、JDK9自带的Flow来处理流式响应,所以相比官方的SDK降低了很多不必要的依赖包。当前dashscope4j只引入了3个直接依赖(共计6个依赖包),分别是jackson、jackson-module-jsonSchema和slf4j-api。后续我也会非常严谨的审视项目所引入的依赖库。

mvn dependency:tree -Dscope=compile

[INFO] io.github.oldmanpushcart:dashscope4j:jar:1.4.1
[INFO] +- com.fasterxml.jackson.core:jackson-databind:jar:2.16.1:compile
[INFO] |  +- com.fasterxml.jackson.core:jackson-annotations:jar:2.16.1:compile
[INFO] |  \- com.fasterxml.jackson.core:jackson-core:jar:2.16.1:compile
[INFO] +- com.fasterxml.jackson.module:jackson-module-jsonSchema:jar:2.16.1:compile
[INFO] |  \- javax.validation:validation-api:jar:1.1.0.Final:compile
[INFO] \- org.slf4j:slf4j-api:jar:2.0.11:compile

高度抽象,更容易被理解使用

多模态的Message输入

Message / Content 支持多模态的输入,适配了 qwen-vl-*qwen-*qwen-audio-* 以及 pluginmultimodal-embedding-one-peace-v1 等多套接口,使得用户无需关注多套接口的差异,直接使用一个接口即可。Content类被设计为支持了多模态的输入,所以在各种场合下编码方式都保持一致,dashscope4j在底层实现中会根据model的区别来分别适配不同的API接口实现。

举个栗子:

  1. 文本理解对话

    final var request = ChatRequest.newBuilder()
         .model(ChatModel.QWEN_PLUS)
         .user(
             Content.ofText("你好!")
         )
         // .user("HELLO!") // 也可以这样写
         .build();
  2. 图/音/文理解对话下

    // 图像理解
    final var request = ChatRequest.newBuilder()
         .model(ChatModel.QWEN_VL_PLUS)
         .user(
             Content.ofImage(URI.create("https://ompc-images.oss-cn-hangzhou.aliyuncs.com/image-002.jpeg")),
             Content.ofText("图片中一共多少辆自行车?")
         )
         .build();
    
    
    // 音频理解
    final var request = ChatRequest.newBuilder()
         .model(ChatModel.QWEN_AUDIO_CHAT)
         .user(
             Content.ofAudio(URI.create("https://dashscope.oss-cn-beijing.aliyuncs.com/audios/2channel_16K.wav")),
             Content.ofText("说话的人是男还是女?")
         )
         .build();
  3. 插件调用对话

    final var request = ChatRequest.newBuilder()
         .model(ChatModel.QWEN_PLUS)
         .plugins(ChatPlugin.PDF_EXTRACTER)
         .user(
             Content.ofFile(URI.create("https://ompc.oss-cn-hangzhou.aliyuncs.com/share/P020210313315693279320.pdf")),
             Content.ofText("请总结这篇文档") 
         )
         .build();

当前 qwen-dl-* 模型正在灰度测试中,我如果有幸能要到邀请测试资格后也会尽快实现这个模型的适配工作。

四种调用模式的支持

ApiRequest / ApiResponse 的请求接口设计规范了api调用的 asyncsyncstreamtask 四种调用方式实现,可以使后续新增功能更便于实现扩展。

举个例子:

  1. async / sync 调用

    // 异步调用,通过CompletableFuture<R>来实现
    final var future = client.embedding(request).async();
    
    // 同步调用,通过CompletableFuture<R>的join()方法实现
    final var response = future.join();
  2. stream 调用

    通过flow()方法异步获取publisher并消费数据流

    client.chat(request).flow()
             .thenAccept(publisher-> {
                 publisher.subscribe(new Flow.Subscriber<>(){
    
                     @Override
                     public void onSubscribe(Flow.Subscription subscription) {
                         
                     }
    
                     @Override
                     public void onNext(ChatResponse item) {
    
                     }
    
                     @Override
                     public void onError(Throwable throwable) {
    
                     }
    
                     @Override
                     public void onComplete() {
    
                     }
    
                 });
             });

    更极致一些,你也可以直接在flow()方法中传入Flow.Subscriber对象进行消费数据流

    client.chat(request).flow(new Flow.Subscriber<>() {
    
         @Override
         public void onSubscribe(Flow.Subscription subscription) {
    
         }
    
         @Override
         public void onNext(ChatResponse item) {
    
         }
    
         @Override
         public void onError(Throwable throwable) {
    
         }
    
         @Override
         public void onComplete() {
    
         }
    
     });
  3. task 调用

    灵积平台中不少api调用过程非常耗时,比如负责图片生成的 wanx-v1 模型。灵积解决调用时间过长的方案是使用task的设计模式,通过taskId来查询任务状态和结果。过程中你需要不停地轮询查询task的状态直到任务结束,或者也可以通过监听EventBridge / RocketMQ的消息,也或者可以通过HTTP回调来触发任务完成调度。

    task的设计模式很好的解决了平台的慢接口调用问题,但却苦了我们使用这个api的人。本着我不下地狱谁下地狱的SDK设计理念,dashscope4j对task的调用方式抽象成为 Half -> Wait -> Completed 的调用过程,并提供了默认的WaitStrategy实现简化。当然你也可以通过实现自己的WaitStrategy来定制自己的等待策略。

    举个栗子:

    final var response = 
    
         // Half阶段:client调用api提交task任务,此时任务尚未开始执行
         client.genImage(request)
         
         // Wait阶段:需要给task设置一个等待策略,这里是 “永久等待间隔1秒检测” 策略,此时任务正在执行
         .task(Task.WaitStrategies.perpetual(Duration.ofSeconds(1)))
    
         // Completed阶段:此时任务已经执行完毕,返回CompletableFuture<R>,你可以通过join()方法获取任务结果
         .join();

四种调用模式被高度封装和统一,后续灵积的API均可通过这四种API快速实现支持。

函数的支持与级联调用

函数调用是我实际开发中最喜欢的一个功能,它扩展了大模型的能力边界,让AI具备了操纵现实的能力。而之前要做到这些事情我得通过langchain来变相实现。

函数的原生支持:声明式

首先我们通过注解来声明一个函数为DashScope的Function。@ChatFn 注解声明了函数的名称和描述,参数与返回值对象的注解通过 jacksonjsonSchema 来声明与解析

@ChatFn(name = "echo", description = "当用户输入echo:,回显后边的文字")
public class EchoFunction implements ChatFunction<EchoFunction.Echo, EchoFunction.Echo> {

    @Override
    public CompletableFuture<Echo> call(Echo echo) {
        return CompletableFuture.completedFuture(new Echo(echo.words()));
    }

    public record Echo(
            @JsonPropertyDescription("需要回显的文字")
            String words
    ) {

    }

}

这样即可完成一个函数的声明。然后进行调用

final var request = ChatRequest.newBuilder()
    .model(ChatModel.QWEN_MAX)
    .functions(new EchoFunction())
    .user("echo: HELLO!")
    .build();
final var response = client.chat(request)
    .async()
    .join();

输出日志

2024-03-19 21:28:38 DEBUG dashscope://chat/qwen-max => {"model":"qwen-max","input":{"messages":[{"role":"user","content":"echo: HELLO!"}]},"parameters":{"result_format":"message","tools":[{"function":{"name":"echo","description":"当用户输入echo:,回显后边的文字","parameters":{"type":"object","properties":{"words":{"type":"string","description":"需要回显的文字"}}}},"type":"function"}]}}
2024-03-19 21:28:40 DEBUG dashscope://chat/qwen-max <= {"output":{"choices":[{"finish_reason":"tool_calls","message":{"role":"assistant","tool_calls":[{"function":{"name":"echo","arguments":"{\"words\": \"HELLO!\"}"},"id":"","type":"function"}],"content":""}}]},"usage":{"total_tokens":28,"output_tokens":23,"input_tokens":5},"request_id":"8af40d7a-d43d-9d7f-9f12-8d52accfe8ac"}
2024-03-19 21:28:40 DEBUG dashscope://chat/function/echo <= {"words":"HELLO!"}
2024-03-19 21:28:40 DEBUG dashscope://chat/function/echo => {"words":"HELLO!"}
2024-03-19 21:28:40 DEBUG dashscope://chat/qwen-max => {"model":"qwen-max","input":{"messages":[{"role":"user","content":"echo: HELLO!"},{"role":"assistant","tool_calls":[{"function":{"name":"echo","arguments":"{\"words\": \"HELLO!\"}"},"type":"function"}],"content":""},{"role":"tool","name":"echo","content":"{\"words\":\"HELLO!\"}"}]},"parameters":{"result_format":"message","tools":[{"function":{"name":"echo","description":"当用户输入echo:,回显后边的文字","parameters":{"type":"object","properties":{"words":{"type":"string","description":"需要回显的文字"}}}},"type":"function"}]}}
2024-03-19 21:28:42 DEBUG dashscope://chat/qwen-max <= {"output":{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"HELLO!"}}]},"usage":{"total_tokens":8,"output_tokens":3,"input_tokens":5},"request_id":"37ff7303-c1b2-9d7c-966d-82a7446fc52e"}
HELLO!
函数的原生支持:定义式

有些情况下对应的函数已经存在,你需要的是将这些函数包装为DashScope的Function。这种情况下你可以使用API来完成一个已有函数的封装定义。

// 定义一个已有函数工具
final var functionTool = ChatFunctionTool.newBuilder()

    // 定义函数名
    .name("echo")

    // 定义函数描述
    .description("当用户输入echo:,回显后边的文字")

    // 定义函数入参(第一个参数)
    .parameterType(Echo.class, """
            {"words":{"type":"string","description":"需要回显的文字"}}
            """
    )

    // 你也可以让函数工具自动生成jsonSchema
    // .parameterType(Echo.class)

    // 引入已有函数实现,这里采用一个匿名内部类代替
    .function(new Function<Echo, Echo>() {

        @Override
        public Echo apply(Echo echo) {
            return new Echo(echo.words());
        }

    })

    // 完成定义
    .build();


// 使用定义函数工具完成ChatRequest构建
final var request = ChatRequest.newBuilder()
    .model(ChatModel.QWEN_PLUS)
    .tools(functionTool)
    .user("echo: HELLO!")
    .build();

调用后效果和上边声明式的调用是一个效果。

函数的级联调用

我们有两个函数

现在需要查询某个同学的所有成绩,并计算其平均分。LLM需要先调用 query_score 函数查询成绩,然后再调用 compute_avg_score 函数计算平均分。

final var request = ChatRequest.newBuilder()
    .model(ChatModel.QWEN_PLUS)
    .functions(new QueryScoreFunction(), new ComputeAvgScoreFunction())
    .user("张三的所有成绩,并计算平均分")
    .build();
final var response = client.chat(request)
    .async()
    .join();

输出日志

2024-03-20 23:50:17 DEBUG dashscope://chat/qwen-plus => {"model":"qwen-plus","input":{"messages":[{"role":"user","content":"张三的所有成绩,并计算平均分"}]},"parameters":{"result_format":"message","tools":[{"function":{"name":"query_score","description":"query student's scores","parameters":{"type":"object","properties":{"name":{"type":"string","description":"the student name to query"},"subjects":{"type":"array","description":"the subjects to query","items":{"type":"string","enum":["CHINESE","MATH","ENGLISH"]}}},"required":["name","subjects"]}},"type":"function"},{"function":{"name":"compute_avg_score","description":"计算平均成绩","parameters":{"type":"object","properties":{"scores":{"type":"array","description":"分数集合","items":{"type":"number"}}}}},"type":"function"}]}}
2024-03-20 23:50:20 DEBUG dashscope://chat/qwen-plus <= {"output":{"choices":[{"finish_reason":"tool_calls","message":{"role":"assistant","tool_calls":[{"function":{"name":"query_score","arguments":"{\"name\": \"张三\", \"subjects\": [\"CHINESE\", \"MATH\", \"ENGLISH\"]}"},"id":"","type":"function"}],"content":""}}]},"usage":{"total_tokens":47,"output_tokens":39,"input_tokens":8},"request_id":"4703f631-a245-967e-ba86-8f01327a82bf"}
2024-03-20 23:50:20 DEBUG dashscope://chat/function/query_score <= {"name":"张三","subjects":["CHINESE","MATH","ENGLISH"]}
2024-03-20 23:50:20 DEBUG dashscope://chat/function/query_score => {"message":"查询成功","data":[{"name":"张三","subject":"CHINESE","value":90.0},{"name":"张三","subject":"MATH","value":80.0},{"name":"张三","subject":"ENGLISH","value":70.0}],"success":true}
2024-03-20 23:50:20 DEBUG dashscope://chat/qwen-plus => {"model":"qwen-plus","input":{"messages":[{"role":"user","content":"张三的所有成绩,并计算平均分"},{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"name\": \"张三\", \"subjects\": [\"CHINESE\", \"MATH\", \"ENGLISH\"]}","name":"query_score"},"type":"function"}],"content":""},{"role":"tool","name":"query_score","content":"{\"message\":\"查询成功\",\"data\":[{\"name\":\"张三\",\"subject\":\"CHINESE\",\"value\":90.0},{\"name\":\"张三\",\"subject\":\"MATH\",\"value\":80.0},{\"name\":\"张三\",\"subject\":\"ENGLISH\",\"value\":70.0}],\"success\":true}"}]},"parameters":{"result_format":"message","tools":[{"function":{"name":"query_score","description":"query student's scores","parameters":{"type":"object","properties":{"name":{"type":"string","description":"the student name to query"},"subjects":{"type":"array","description":"the subjects to query","items":{"type":"string","enum":["CHINESE","MATH","ENGLISH"]}}},"required":["name","subjects"]}},"type":"function"},{"function":{"name":"compute_avg_score","description":"计算平均成绩","parameters":{"type":"object","properties":{"scores":{"type":"array","description":"分数集合","items":{"type":"number"}}}}},"type":"function"}]}}
2024-03-20 23:50:24 DEBUG dashscope://chat/qwen-plus <= {"output":{"choices":[{"finish_reason":"tool_calls","message":{"role":"assistant","tool_calls":[{"function":{"name":"compute_avg_score","arguments":"{\"scores\": [90.0, 80.0, 70.0]}"},"id":"","type":"function"}],"content":"张三的成绩如下:\n\n- 中文: 90.0分\n- 数学: 80.0分\n- 英语: 70.0分\n\n现在我们来计算他的平均分。"}}]},"usage":{"total_tokens":93,"output_tokens":85,"input_tokens":8},"request_id":"0f662c8b-ca5d-9512-9f92-597045977eca"}
2024-03-20 23:50:24 DEBUG dashscope://chat/function/compute_avg_score <= {"scores":[90.0,80.0,70.0]}
2024-03-20 23:50:24 DEBUG dashscope://chat/function/compute_avg_score => {"avg_score":80.0}
2024-03-20 23:50:24 DEBUG dashscope://chat/qwen-plus => {"model":"qwen-plus","input":{"messages":[{"role":"user","content":"张三的所有成绩,并计算平均分"},{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"name\": \"张三\", \"subjects\": [\"CHINESE\", \"MATH\", \"ENGLISH\"]}","name":"query_score"},"type":"function"}],"content":""},{"role":"tool","name":"query_score","content":"{\"message\":\"查询成功\",\"data\":[{\"name\":\"张三\",\"subject\":\"CHINESE\",\"value\":90.0},{\"name\":\"张三\",\"subject\":\"MATH\",\"value\":80.0},{\"name\":\"张三\",\"subject\":\"ENGLISH\",\"value\":70.0}],\"success\":true}"},{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"scores\": [90.0, 80.0, 70.0]}","name":"compute_avg_score"},"type":"function"}],"content":"张三的成绩如下:\n\n- 中文: 90.0分\n- 数学: 80.0分\n- 英语: 70.0分\n\n现在我们来计算他的平均分。"},{"role":"tool","name":"compute_avg_score","content":"{\"avg_score\":80.0}"}]},"parameters":{"result_format":"message","tools":[{"function":{"name":"query_score","description":"query student's scores","parameters":{"type":"object","properties":{"name":{"type":"string","description":"the student name to query"},"subjects":{"type":"array","description":"the subjects to query","items":{"type":"string","enum":["CHINESE","MATH","ENGLISH"]}}},"required":["name","subjects"]}},"type":"function"},{"function":{"name":"compute_avg_score","description":"计算平均成绩","parameters":{"type":"object","properties":{"scores":{"type":"array","description":"分数集合","items":{"type":"number"}}}}},"type":"function"}]}}
2024-03-20 23:50:25 DEBUG dashscope://chat/qwen-plus <= {"output":{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"张三的平均分是 80.0 分。"}}]},"usage":{"total_tokens":68,"output_tokens":13,"input_tokens":55},"request_id":"c01da60a-21d7-9e2f-ae5d-17a9b622ed41"}
张三的平均分是 80.0 分。

无感临时空间使用

对话多模态向量计算文档分析插件等请求中如果需要解析图片、音频、文档等内容,不再需要提前上传到OSS转换为外网可访问的URL连接。这样极不方便也不安全。

通过灵积平台提供的临时空间可以很好的解决这个问题,但操作起来需要调用额外的api且需要对url进行拼接和替换,略为繁琐。

dashscope4j帮你封装了这个繁琐的操作,你只需要设置内容的时候将本地文件、BufferedImage甚至byte[]直接传入Content,框架会自动识别并帮你完成临时空间上传和转换连接操作。并自带一个缓存避免重复上传。

final var request = ChatRequest.newBuilder()
        .model(ChatModel.QWEN_VL_MAX)
        .option(ChatOptions.ENABLE_INCREMENTAL_OUTPUT, true)
        .user(
                Content.ofImage(new File("C:\\Users\\vlinux\\图片\\image-002.jpeg").toURI()),
                Content.ofText("图片中一共多少辆自行车?")
        )
        .build();

拦截器的支持

在实际使用场景中,你会遇到对请求的入参、返回值感知和篡改的需求。比如无感临时空间使用、请求日志记录、请求限流、请求重试等。dashscope4j提供了拦截器支持,你可以通过实现 RequestInterceptorResponseInterceptor 接口来定制自己的拦截器。

拦截器分为两类,分别是

  • RequestInterceptor:在请求发送前拦截,可以做入参的校验、修改、记录日志等操作。
  • ResponseInterceptor:在请求返回后拦截,可以做返回值的校验、修改、记录日志等操作。

他们需要在构造DashScope客户端时被定义。

举个栗子:输出所有请求的Usage信息。

DashScopeClient client = DashScopeClient.newBuilder()
    .ak(AK)
    .executor(executor)

    // 这里定义了一个应答拦截器,他的作用是输出所有请求的Usage信息,便于统计成本
    .responseInterceptors((context, response) -> {
        System.out.println(response.usage());
        return CompletableFuture.completedFuture(response);
    })

    .build();

写在最后

基于JDK17的思考

// 等待补充

功能缺失清单

dashscope4j毕竟是用我业余时间完成,所以能力上难免与官方的正规军精力不匹配。我也只是实现了我个人项目中需要的和感兴趣的功能。所以代码实现严谨性、完整灵积功能支持以及性能表现上难免和官方的Java SDK实现存在偏差。也恳请各位看官来帮我一起完善。

// 等待补充

性能优化

// 等待补充