Spring Boot项目集成阿里百炼大模型调用

8/3/2025 Java

业务背景如下,在用探店小程序中,用户通过我们的小程序完成探店,需要将小红书笔记的地址提交到订单里,之后后台审核笔记,完成订单。我们发现用户的笔记质量参差不齐,很多人写的像是评论一样。完全没有小红书的风格以及种草的感觉。所以我们一方面想提高我们平台里博主的笔记质量,另一方面也希望在我们平台沉淀用户的笔记。于是我们想通过大模型辅助用户生成笔记,然后用可以一键复制笔记内容,到小红书上去发布(目前小红书没有开放笔记发布接口,不然可以直接帮用户同步笔记到小红书)

具体在小程序中,用户在笔记发布页面,通过点击“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>
1
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);
}
1
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;
}
1
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";
1
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();
}
1
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生成笔记");
}
1
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);
}
1
2
3
4
5
6
7

比较简单,直接调用服务层方法返回结果即可。注意返回的类型为:Flux<ServerSentEvent>, 而不是responseDTO了。

服务层的代码如下:

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

# 总结:

  1. 集成阿里的大模型调用sdk, dashscope-sdk-java
  2. 根据自己的spring boot版本选择合适的reactor-core版本
  3. 创建spring boot接口和服务,推荐请求选择流式返回
  4. 前端获取流式数据,并处理返回内容拼接展现,即可实现的打字机效果(类似ChatGPT和Deepseek聊天生成的返回内容效果)
Last Updated: 8/3/2025, 9:12:47 AM