简单但是代码“说不清”的功能,不如让AI来实现!
简单但是代码“说不清”的功能,不如让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的回答,它以非常整齐的格式返回了你需要的信息。
流式返回
你非常高兴,迅速发挥你的老本行优势,搭了一个前端页面:
这样,你就可以预览并修正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让我以极低的成本实现了这一点。