Spring Boot项目集成阿里百炼大模型调用
业务背景如下,在用探店小程序中,用户通过我们的小程序完成探店,需要将小红书笔记的地址提交到订单里,之后后台审核笔记,完成订单。我们发现用户的笔记质量参差不齐,很多人写的像是评论一样。完全没有小红书的风格以及种草的感觉。所以我们一方面想提高我们平台里博主的笔记质量,另一方面也希望在我们平台沉淀用户的笔记。于是我们想通过大模型辅助用户生成笔记,然后用可以一键复制笔记内容,到小红书上去发布(目前小红书没有开放笔记发布接口,不然可以直接帮用户同步笔记到小红书)
具体在小程序中,用户在笔记发布页面,通过点击“AI导写”的按钮,调用后台接口,接口通过流式返回大模型生成的内容,然后前段就可以实现类似打字机效果的笔记生成过程。前置的百炼注册和申请apikey就不赘述了。直接上项目介绍
# 一、后端环境
- Spring boot版本:2.4.6
- dashscope-sdk-java: 2.20.8
- reactor-core: 3.4.6
如果Spring boot已经升级到了3.x,那么可以尝试一下spring-ai-alibaba。
具体的pom配置如下:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>dashscope-sdk-java</artifactId>
<version>2.20.8</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.9</version>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.4.6</version>
</dependency>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
集成dashscope-sdk-java的时候排除了依赖slf4j-simple,因为和我项目中的logback冲突了。reactor-core的版本选择也要和自己的spring boot版本兼容
# 二、后端接口设计
调用大模型,然后返回给前端大模型的内容,有两种方式,一种是一次性直接返回大模型的内容,一种是通过流式返回大模型的内容。一次性返回,可以控制返回的内容格式之类的,但大模型响应时间如果较长,这里体验肯定不好。而流式返回需要前端处理每个chunk返回的内容。这里两种方式都做一下介绍。
# 1. 一次性返回大模型内容
Controller层比较简单,项目中统一格式:
@BusinessLogger(value = "AppNoteAICreate")
@ApiOperation(value = "AppNoteAICreate App-笔记AI生成", httpMethod = "POST")
@PostMapping(value = "/rs/v1/AppNoteAICreate")
@ResponseBody
public R<XfaceAppNoteAICreateResponseDTO> processRequest(@Valid @RequestBody XfaceAppNoteAICreateRequestDTO requestDTO) {
XfaceAppNoteAICreateResponseDTO responseDTO = new XfaceAppNoteAICreateResponseDTO();
this.appNoteService.doAppNoteAICreate(requestDTO, responseDTO);
return R.body(responseDTO);
}
2
3
4
5
6
7
8
9
10
11
因为我们是根据订单来生产对应的探店笔记,reqeustDTO中包含订单ID即可。而返回的内容包含笔记的标题,内容和标签
@Getter
@Setter
public class XfaceAppNoteAICreateResponseDTO implements Serializable {
@ApiModelProperty(value = "笔记标题")
private String title;
@ApiModelProperty(value = "笔记内容")
private String content;
@ApiModelProperty(value = "笔记tag")
private List<String> tagList;
}
2
3
4
5
6
7
8
9
10
11
12
13
我们看笔记生成过程,首先我们需要顶一个用户端prompt, 这里基本上可以统一, 只需根据订单所属的店铺知道探的是什么店就可以了。
String prompt = "我正在\"" + rsOrderMast.getStoreName() +
"\"这家店探店,请使用小红书风格,生成一遍爆款笔记文章,要求500字以上,放在content字段,并生成20字以内的标题,放在title字段,最后生成10个热门话题标签,标签必须包含\"" + rsOrderMast.getStoreName() +
"\"和\"蚂蚁探店\",组成数组,放在tags字段中,最后以json的格式输出title, content和tags";
2
3
由于我们要返回笔记标题,笔记内容和笔记tag,这里我们要求大模型返回json格式的数据。调用大模型的方法如下:
public static String generateOrderNote(String apikey, String model, String prompt) throws NoApiKeyException, InputRequiredException {
Generation generation = new Generation();
Message systemMsg = Message.builder()
.role(Role.SYSTEM.getValue())
.content("你需要生成的内容中提取出title(笔记标题,为string类型)、content(笔记内容,为string类型)与tags(标签,为List类型),请输出JSON 字符串,不要输出其它无关内容。" +
"示例:" +
"Q:我正在\"蜜雪冰城\"这家店探店,请使用小红书风格,生成一遍爆款笔记文章,要求500字以上,放在content字段,并生成20字以内的标题,放在title字段,最后生成10个热门话题标签组成数组,放在tags字段中,最后以json的格式输出title, content和tags" +
"A:{\"title\":\"蜜雪冰城隐藏菜单大公开\",\"content\":\"姐妹们!今天给大家带来的是我最近疯狂上头的宝藏奶茶店—\",\"tags\":\"[\\n \\\"#蜜雪冰城(张江店)\\\",\\n \\\"#蚂蚁探店\\\",\\n \\\"#上海奶茶推荐\\\"]\"}"
)
.build();
Message userMsg = Message.builder()
.role(Role.USER.getValue())
.content(prompt)
.build();
ResponseFormat jsonMode = ResponseFormat.builder().type(ResponseFormat.JSON_OBJECT).build();
GenerationParam param = GenerationParam.builder()
.apiKey(apikey)
.model(model)
.messages(Arrays.asList(systemMsg, userMsg))
.resultFormat(GenerationParam.ResultFormat.MESSAGE)
.responseFormat(jsonMode)
.topP(0.8)
.enableSearch(false)
.build();
GenerationResult result = generation.call(param);
return result.getOutput().getChoices().get(0).getMessage().getContent();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
简单说明一下方法,参数中apiKey就是百炼中申请的apikey,model是模型名称,prompt就是用户指令,即前面异步用户端的指令。关键的是responseFormat,这个参数是返回结果格式,这里设置为json,这样返回结果会更清晰。否则返回的是以json开头的markdown格式,无法直接反序列化。这时候我们Service层就可以直接调用这个方法,并处理返回内容返回给前端了。
@Override
public void doAppNoteAICreate(XfaceAppNoteAICreateRequestDTO requestDTO, XfaceAppNoteAICreateResponseDTO responseDTO) {
log.info("App开始AI生成笔记");
RsOrderMast rsOrderMast = orderTemplate.existOrder(requestDTO.getOrderId(), requestDTO.getContext());
if (rsOrderMast.getStatus() == OrderStatusEnum.WaitingUpload.getValue()) {
String prompt = "我正在\"" + rsOrderMast.getStoreName() +
"\"这家店探店,请使用小红书风格,生成一遍爆款笔记文章,要求500字以上,放在content字段,并生成20字以内的标题,放在title字段,最后生成10个热门话题标签,标签必须包含\"" + rsOrderMast.getStoreName() +
"\"和\"蚂蚁探店\",组成数组,放在tags字段中,最后以json的格式输出title, content和tags";
try {
String output = AIUtils.generateOrderNote(baiLianConfig.getApiKey(), baiLianConfig.getModel(), prompt);
JSONObject jsonObject = JSONObject.parseObject(output);
String title = jsonObject.getString("title");
String content = jsonObject.getString("content");
List<String> tags = jsonObject.getObject("tags", List.class);
responseDTO.setTitle(title);
responseDTO.setContent(content);
responseDTO.setTagList(tags);
} catch (NoApiKeyException | InputRequiredException e) {
log.info("笔记生成失败:" + e.getMessage());
throw new BaseException("笔记生成失败");
}
} else {
throw new BaseException(ErrorEnum.OrderStatusIsNotWaitingUpload.getMessage(), ErrorEnum.OrderStatusIsNotWaitingUpload.getCode());
}
log.info("App结束AI生成笔记");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
这样前端小程序就是调用普通接口,一次性得到比较内容。
# 2. 流式返回大模型内容
在AI 应用开发中,流式输出(SSE,Server-Sent Events) 是一种常见的方式,特别适用于大模型的 API 调用,比如 ChatGPT, DeepSeek, GLM .... 接口。SSE 允许服务器向客户端推送数据流,避免长时间阻塞请求,提高用户体验。并且我们选择Flux进行异步流式响应。
我这里选择直接返回大模型生成的笔记内容到前端,所以选择每chunk返回的事字符串,而不是json对象,也可以根据情况返回大模型返回的json内容。 Controller层的内容如下:
@BusinessLogger(value = "AppNoteAIStreamCreate")
@ApiOperation(value = "AppNoteAIStreamCreate App-笔记AI流式生成", httpMethod = "POST")
@PostMapping(value = "/rs/v1/AppNoteAIStreamCreate", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@ResponseBody
public Flux<ServerSentEvent<String>> processRequest(@Valid @RequestBody XfaceAppNoteAIStreamCreateRequestDTO requestDTO) {
return this.appNoteService.doAppNoteAIStreamCreate(requestDTO);
}
2
3
4
5
6
7
比较简单,直接调用服务层方法返回结果即可。注意返回的类型为:Flux<ServerSentEvent
服务层的代码如下:
@Override
public Flux<ServerSentEvent<String>> doAppNoteAIStreamCreate(XfaceAppNoteAIStreamCreateRequestDTO requestDTO) {
log.info("App开始AI流式生成笔记");
RsOrderMast rsOrderMast = orderTemplate.existOrder(requestDTO.getOrderId(), requestDTO.getContext());
if (rsOrderMast.getStatus() != OrderStatusEnum.WaitingUpload.getValue()) {
throw new BaseException(ErrorEnum.OrderStatusIsNotWaitingUpload.getMessage(), ErrorEnum.OrderStatusIsNotWaitingUpload.getCode());
}
String prompt = "我正在\"" + rsOrderMast.getStoreName() +
"\"这家店探店,请使用小红书风格,生成一遍爆款笔记标题和文章内容,标题不可以超过20字,并且包裹在【和】直接,标题不可以包含换行符,文章内容要求500字以上,并且不要有文字加粗的双星号输出,笔记不需要话题标签";
Generation generation = new Generation();
Message userMsg = Message.builder()
.role(Role.USER.getValue())
.content(prompt)
.build();
GenerationParam param = GenerationParam.builder()
.apiKey(baiLianConfig.getApiKey())
.model(baiLianConfig.getModel())
.messages(Arrays.asList(userMsg))
.resultFormat(GenerationParam.ResultFormat.MESSAGE)
.incrementalOutput(true)
.topP(0.8)
.enableSearch(false)
.build();
Flowable<GenerationResult> result = null;
try {
result = generation.streamCall(param);
} catch (NoApiKeyException | InputRequiredException e) {
log.info("笔记生成失败:" + e.getMessage());
}
return Flux
.from(result)
.flatMapSequential(message -> Mono.fromCallable(() -> {
String output = message.getOutput().getChoices().get(0).getMessage().getContent();
return output;
}).subscribeOn(Schedulers.boundedElastic()))
.map(output -> {
return ServerSentEvent.<String>builder()
.data(output)
.build();
})
.concatWith(Flux.just(ServerSentEvent.<String>builder().comment("").build()))
.onBackpressureBuffer(10)
.doOnError(e -> {
log.error("笔记生成失败:" + e.getMessage());
})
.doOnNext(e -> {
log.info("Chunk sent: " + e.data());
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
这里一次性生成笔记的标题和内容,笔记标题放在【】中间,前端拿到数据后,将【】中间的内容作为标题,将剩余的内容作为内容,么个chunk中我只返回了data,可以根据自己的请生成ServerSentEvent,比如增加id, event等。最后一个chunk位空内容。
注:这里有一个坑,需要注意,接口虽然以流式返回了,但小程序可能还是一次性拿到所有chunk的内容,这是因为Nginx有代理缓冲,需要关闭。
# SSE 连接时的超时时间
proxy_read_timeout 86400s;
# 取消缓冲
proxy_buffering off;
2
3
4
至此,后端接口编写完成,下面开始编写前端小程序。
# 三、前端小程序
前端小程序目前采用的是flyio进行的网络请求,flyio类似axios是一个基于Promise的http请求库,使用起来非常简单,这里就不多赘述了。但作者也是很多年没更新过了,翻看了一下github,好像并不支持SSE请求,虽然也提到了请求二进制数据,但并没有单独处理chunk的方法,还是在then里处理,并且要修改现在的网络封装,所以就先直接用uniapp的request了。代码如下:
async function onAiStreamCreateClick() {
data.isTitle = true
data.title = ''
data.content = ''
const params = {
orderId: orderId.value ? orderId.value : undefined
}
const headers: Record<string, any> = {
'Content-Type': 'application/json; charset=UTF-8',
Accept: 'text/event-stream'
}
const token = getAccessToken()
if (token !== undefined) {
headers.accessToken = token
}
uni.showLoading({
title: '正在生成中...'
})
const task = uni.request({
url: baseUrl + '/api/rs/v1/AppNoteAIStreamCreate',
method: 'POST',
timeout: 120000,
data: { ...params, context: requestContextConfig },
header: headers,
responseType: 'arraybuffer',
enableChunked: true,
success: (res) => {
console.log('success')
},
fail: (err) => {
console.log(err.errMsg)
},
complete: (res) => {
uni.hideLoading()
data.content += ` ${data.topics.join(' ')}`
}
})
// 返回请求头信息
task.onHeadersReceived((e) => {
uni.hideLoading()
console.log(e)
})
task.onChunkReceived((e: any) => {
// const text = new TextDecoder().decode(e.data)
let arrayBuffer = e.data
const uint8Array = new Uint8Array(arrayBuffer)
const text = new TextEncoding.TextDecoder('utf-8').decode(uint8Array)
// console.log(text)
handleChunkText(text)
})
}
function handleChunkText(text: string) {
// 先获取标题
if (data.isTitle) {
if (text.startsWith('data:【')) {
const message = text.slice(6)
if (message.indexOf('data') > 0) {
// 一次返回的内容包括多个data
const messages = text.split('data:')
messages.forEach((item) => {
data.title += item
})
} else {
data.title += message
}
} else if (text.indexOf('data:') >= 0) {
const messages = text.split('data:')
for (let i = 1; i < messages.length; i++) {
const message = messages[i]
const dataContent = message.trimEnd()
if (dataContent.indexOf('】') >= 0) {
data.title += dataContent.slice(0, dataContent.indexOf('】'))
data.content += dataContent.slice(dataContent.indexOf('】') + 1)
data.isTitle = false
} else {
data.title += dataContent
}
}
} else {
const dataContent = text.trimEnd()
if (dataContent.indexOf('】') >= 0) {
data.title += dataContent.slice(0, dataContent.indexOf('】'))
data.content += dataContent.slice(dataContent.indexOf('】') + 1)
data.isTitle = false
} else {
data.title += dataContent
}
}
} else {
// 文章内容
if (text.indexOf('data:') >= 0) {
const messages = text.split('data:')
for (let i = 1; i < messages.length; i++) {
const message = messages[i]
const dataContent = message === '\n\n' ? message : message.trimEnd()
data.content += dataContent
}
} else {
data.content += text.trimEnd()
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
重点配置请求头Accept: 'text/event-stream'
和请求时responseType: 'arraybuffer', enableChunked: true
之后就可以在onChunkReceived回调函数中处理大模型返回的内容了。
注意:这里有个坑,chunk回调函数中返回的内容是arrybuffer, 需要进行decode,刚开始我用的TextDecoder,也就是:const text = new TextDecoder().decode(e.data)
这在模拟器上是可以的,但在真机上找不到的。
所以需要用到一个第三方库text-encoding-shim。
# 总结:
- 集成阿里的大模型调用sdk, dashscope-sdk-java
- 根据自己的spring boot版本选择合适的reactor-core版本
- 创建spring boot接口和服务,推荐请求选择流式返回
- 前端获取流式数据,并处理返回内容拼接展现,即可实现的打字机效果(类似ChatGPT和Deepseek聊天生成的返回内容效果)