简单但是代码“说不清”的功能,不如让AI来实现!

场景

想象你是一个售卖软件激活码的商家,在收款后把激活码通过邮件的方式发送到客户的邮箱里,你的美好生活就这样一单一单地继续着。

直到有一天,你的好朋友说他给你介绍了个大生意,有几百个客户要从你这里购买软件,他给你发来了几张手写下来的便签照片、几段格式杂乱的文本,上面记录了你所需要的信息:用户的名字和邮箱。

在你被迫转行干这个之前,你是个程序员,你给自己开发了个录入客户信息自动发货的系统。可是,这几百单要是一单一单录入的话要花很久。你摩拳擦掌地拿起ocr、正则表达式准备将这个苦差事自动化时,问题出现了:

  • ocr解析从图片里解析出来的文本还是乱糟糟的,错别字、无关的字符…显然ocr并不知道它要处理的是人名和邮箱,它只管识别图片里的字符,有一个算一个!
  • 记录客户信息的文本,有的前面有序号,有的没有,有的用逗号作为分隔符,有的用句号,有的人名和邮箱分开了两行…本来你的正则就是上网抄的水平,如何用正则匹配格式这么灵活的文本真让你犯了难

幸运的是,在2024年,AI已经足够聪明来解决这样的问题。

我将用上面的例子介绍我是如何在一个CRUD的小项目中使用AI实现这种简单却灵活的功能的。示例工程使用了jdk21 + springboot3,你可能会看到一些新的api,例如虚拟线程和有序集合,请放心,这不是必需的,稍微变更一下写法也可以达到目的。

模型选择

openai推出的chatgpt是大模型领域堪称规则制定者的产品,后来的大模型产品例如kimi、通义千问在产品形式上都大量借鉴了openai的规范,例如长得差不多的对话网页、按token计价的计价方式、流式输出、类似的api。

切换不同的大模型提供商,你的代码几乎不需要做改动,只需要修改api key、base url、模型名字就可以了,编码成本并无区别。因此在模型选择上,你只需要关注模型对问题的回答效果、价格即可。我最终选择了kimi

怎么调用AI接口?

由于我的后端使用Java写的,我又不想按照官网的api文档拼http请求,我最终选择了jvm-openai,一个厂商无关的、轻量级的sdk。(这个库要求jdk17+,如果你使用老版本jdk,可以使用OpenAI-Java,这些sdk都遵循同一套规范,用起来差异并不大)

官网给了一个简单的示例,演示了如何通过sdk和AI做一次对话。

// 创建客户端
OpenAI openAI = OpenAI.newBuilder(System.getenv("OPENAI_API_KEY")).build();
ChatClient chatClient = openAI.chatClient();

// 填充请求信息
CreateChatCompletionRequest createChatCompletionRequest = CreateChatCompletionRequest.newBuilder()
    .model(OpenAIModel.GPT_3_5_TURBO)
    .message(ChatMessage.userMessage("Who won the world series in 2020?"))
    .build();

// 发起请求
ChatCompletion chatCompletion = chatClient.createChatCompletion(createChatCompletionRequest);

可以把你的模型api key、base url和模型的名字入参,试一试能不能正常收到AI的回答。

我们先看更简单的文本解析。在这个场景下,我们应该告诉AI我们的要求、要解析的文本数据,一次请求包含两条内容。

OpenAI openAI = OpenAI.newBuilder(apiKey)
                    .baseUrl(baseUrl)
                    .build();
ChatClient chatClient = openAI.chatClient();

// 我们的要求
ChatMessage.UserMessage.UserMessageWithTextContent promotContent = ChatMessage.userMessage("""
        识别出这个文本内容中所有的人名和邮箱,使用如下格式逐行输出解析结果:
        姓名1 邮箱1
        姓名2 邮箱2
        ...
        """);
 // 要解析的文本数据                                                                              
ChatMessage.UserMessage.UserMessageWithTextContent textContent = ChatMessage.userMessage(text);
CreateChatCompletionRequest createChatCompletionRequest = CreateChatCompletionRequest.newBuilder()
       // 你选择的模型名
        .model(model)
        .messages(Arrays.asList(promotContent, textContent))
        .build();
ChatCompletion chatCompletion = chatClient.createChatCompletion(createChatCompletionRequest);
log.info("AI返回的结果:{}", chatCompletion.choices().getFirst().message().content());

几秒后,你应该能得到AI的回答,它以非常整齐的格式返回了你需要的信息。

流式返回

你非常高兴,迅速发挥你的老本行优势,搭了一个前端页面:

image-20240929192859992

这样,你就可以预览并修正AI可能产生的小错误,然后批量给这些用户发货。

但是,你发现解析的耗时相当久,一个请求可能会花费十几秒的时间,要不是因为这是你自己开发的系统,你肯定会刷新重试了。

chatgpt的解决方式是“流式返回”(stream),AI并不会憋个十几秒才给你返回结果,而是几个字符几个字符地返回,让前端的用户能看到它在正常运行。

把代码改成下面这样就可以了:

CreateChatCompletionRequest createChatCompletionRequest = CreateChatCompletionRequest.newBuilder()
        .model(model)
        .messages(Arrays.asList(promotContent, textContent))
    	// 流式返回
	    .stream(true)
        .build();
 chatClient.streamChatCompletion(createChatCompletionRequest)
        .forEach(chatCompletion -> {
            String content = chatCompletion.choices().getFirst().delta().content();
            log.info("AI返回的内容:{}", content);
        });

我们发现AI开始几个字符几个字符地返回了,我们可以用StringBuilder作为缓冲区,每收集到一个完整的客户数据就打印一次:

StringBuilder stringBuilder = new StringBuilder();
chatClient.streamChatCompletion(createChatCompletionRequest)
        .forEach(chatCompletion -> {
            String content = chatCompletion.choices().getFirst().delta().content();
            log.info("AI返回的内容:{}", content);
            if (content != null) {
                stringBuilder.append(content);
            }

            if ("\n".equals(content) || content == null) {
                String[] split = stringBuilder.toString().split(" ");
                String name = split[0].trim();
                String email = split[1].trim();
                log.info("名字:{}", name);
                log.info("邮箱:{}", email);
                stringBuilder.setLength(0);
            }
        });

那么,我们该怎么把这一段一段的数据发送到前端呢?

server-sent events

server-sent events,简称sse,是一种后端主动向前端推送数据的技术,相较于双向通信的websocket,sse更简单,后端可以用类似编写一个get请求接口的方式实现一个sse接口,只不过返回值是一个SseEmitter对象。

@GetMapping("/batch-parse-by-text")
@Operation(description = "根据文本批量解析客户名字和邮箱")
public SseEmitter batchParseByText(@RequestParam String text) {
    return clientService.batchParseByText(text);
}
public SseEmitter batchParseByText(String text) {
    log.info("收到的文本:{}", text);
    // 这里的openAI对象是我通过@Bean向Spring容器注入的
    ChatClient chatClient = openAI.chatClient();
    ChatMessage.UserMessage.UserMessageWithTextContent promotContent = ChatMessage.userMessage("""
            识别出这个文本内容中所有的人名和邮箱,使用如下格式逐行输出解析结果:
            姓名1 邮箱1
            姓名2 邮箱2
            ...
            """);
    ChatMessage.UserMessage.UserMessageWithTextContent textContent = ChatMessage.userMessage(text);
    CreateChatCompletionRequest createChatCompletionRequest = CreateChatCompletionRequest.newBuilder()
			// 填写你选择的模型名,这里我用的是kimi的moonshot-v1-auto
            .model("moonshot-v1-auto")
            .messages(Arrays.asList(promotContent, textContent))
            .stream(true)
            .build();

	// 构造方法指定超时时间(毫秒)
    SseEmitter sseEmitter = new SseEmitter((long) (1000 * 60 * 3));

    // 开一个虚拟线程去异步处理解析结果
    Thread.startVirtualThread(() -> {
        StringBuilder stringBuilder = new StringBuilder();
        chatClient.streamChatCompletion(createChatCompletionRequest)
                .forEach(chatCompletion -> {
                    String content = chatCompletion.choices().getFirst().delta().content();
                    log.info("AI返回的内容:{}", content);
                    if (content != null) {
                        stringBuilder.append(content);
                    }

                    if ("\n".equals(content) || content == null) {
                        String[] split = stringBuilder.toString().split(" ");
                        String name = split[0].trim();
                        String email = split[1].trim();
                        ClientInfo clientInfo = ClientInfo.builder()
                                .name(name)
                                .email(email)
                                .build();
                        try {
                            // 向前端发送数据
                            sseEmitter.send(objectMapper.writeValueAsString(clientInfo));
                        } catch (IOException e) {
                            log.error("发送SSE消息失败", e);
                        }
                    }
                });
        // 全部数据发送完成,关闭连接
        sseEmitter.complete();
    });

    return sseEmitter;
}

尽管jvm-openai提供了基于CompletableFuture的异步接口,我还是选择新开一个虚拟线程去执行阻塞式方法,因为我觉得这样更易于理解和调试。

前端就更简单了,只需要建立连接、监听事件

// 建立sse连接
const sse = new EventSource(你的sse接口url以及必要参数)
// 清空已有数据
clients.splice(0, clients.length)

// 注册回调方法
sse.onmessage = (event: MessageEvent) => {
    const parseResult = JSON.parse(event.data)
    clients.push({
      name: parseResult.name,
      email: parseResult.email
    })
}

sse.onerror = () => {
    sse.close()
}

这样,在开始解析后,我们很快就看到了后端陆陆续续传送来的数据。

图片解析

走完文本解析的全流程后,图片解析就很简单了,只需要修改几行代码。

File imgFile = xxx
// 将图片上传到openai,这里每个模型提供商的要求可能略有不同,这里以kimi为例
FilesClient filesClient = openAI.filesClient();
UploadFileRequest uploadInputFileRequest = UploadFileRequest.newBuilder()
        .file(imgFile.toPath())
        .purpose("file-extract")
        .build();
io.github.stefanbratanov.jvm.openai.File inputFile = filesClient.uploadFile(uploadInputFileRequest);
byte[] fileContent = filesClient.retrieveFileContent(inputFile.id());

ChatClient chatClient = openAI.chatClient();
ChatMessage.UserMessage.UserMessageWithTextContent promotContent = ChatMessage.userMessage("""
        识别出这个文本内容中所有的人名和邮箱,使用如下格式逐行输出解析结果:
        姓名1 邮箱1
        姓名2 邮箱2
        ...
        """);
// 在第二条消息中引用刚才上传的图片
ChatMessage.UserMessage.UserMessageWithTextContent imgContent = ChatMessage.userMessage(new String(fileContent));
CreateChatCompletionRequest createChatCompletionRequest = CreateChatCompletionRequest.newBuilder()
		// 解析图片的模型取决于你的模型提供商,这里以kimi为例
        .model("moonshot-v1-auto")
        .messages(Arrays.asList(promotContent, imgContent))
        .stream(true)
        .build();

总结

本文通过解析格式不整齐的数据的例子,演示了如何使用AI实现一些简单但是灵活的功能,落实到了前后端代码。

有趣的是,本文封面原本是我拍的竖屏照片,但是用AI扩图扩成了适合做封面的横图,这也是我第一个AI处理过的文章封面。这种工作放在2023年之前肯定是要花点时间成本的,但是AI让我以极低的成本实现了这一点。

文章作者: 白烛魁
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 白烛魁的小站
Java AI
喜欢就支持一下吧