Spring AI Alibaba - 基础案例演示

Spring AI Alibaba(SAA)简介

生态对照

Spring AI Alibaba 是阿里云与 Spring 官方团队联合开发的 Spring AI 官方生态分支。它基于 Spring AI 的统一抽象,专门针对中国本土的 AI 生态(主要是阿里云百炼平台通义千问(Qwen)、以及国内部署的 DeepSeek 等大模型)进行了深度适配。它可以一键自动装配百炼 API、原生支持国内开发者常用的流式输出、向量数据库,并推出了配套的 spring-ai-alibaba-agent-framework,让国内开发者能更方便写出复杂的 AI Agent。

关于 Spring AI、Spring AI Alibaba、LangChain4j 三者各自的定位,可以用下面的表格总结:


选型参考

  • 如果是国内商业项目/政企项目,且计划接入阿里云百炼、通义千问或合规的国内 DeepSeek,Spring AI Alibaba 是毫无疑问的最优解,它省去了你 90% 的适配心力。
  • 如果你在做国际化项目,或者是老牌的 Spring Boot 3.x 拥趸,喜欢简洁的类声明,首选 Spring AI。
  • 如果项目需要实现极其复杂的 Agent、多步 RAG 检索、或者需要对接各种奇葩的第三方工具和向量库,且对 Spring 官方血统没有硬性强迫症,那么现阶段 LangChain4j 依然是功能最稳健的“重工业级武器”。


基础案例

依赖配置

父项目依赖配置参考:父项目 POM

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
<dependencies>
<!--基于 netty 的响应式、非阻塞的 Web 服务底座-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

<!--https://java2ai.com/docs/quick-start-->
<!--阿里云百炼(DashScope)平台,实现通义千问、DeepSeek 等云端模型的聊天与生图功能-->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>

<!--阿里 Agent 增强包,用来给模型套上大脑外挂,实现多 Agent 协同、上下文记忆和自动化工具调用(Tool Calling)-->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-agent-framework</artifactId>
</dependency>

<!--提供标准 OpenAI 协议客户端,用来连接所有兼容 OpenAI 格式的接口-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

<!--Spring AI 提供的一键无缝连接本地运行的 Ollama 服务依赖-->
<!--<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>-->
</dependencies>


配置文件

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
server:
port: 8080
servlet:
encoding:
charset: UTF-8
enabled: true
force: true

spring:
ai:
dashscope: # spring-ai-alibaba-starter-dashscope 跑远程(只能跑远程的dashscope),默认注入的 bean 名称 dashScopeChatModel
api-key: ${QWEN_API_KEY}
openai: # spring-ai-starter-model-openai 跑本地,默认注入的 bean 名称 openAiChatModel
api-key: api-key-xxx
base-url: http://localhost:11434
chat:
options:
model: gemma3:1b
# openai: # spring-ai-starter-model-openai 跑远程
# api-key: ${QWEN_API_KEY}
# base-url: https://dashscope.aliyuncs.com/compatible-mode
# chat:
# options:
# model: qwen-plus
# ollama: # spring-ai-starter-model-ollama 跑本地,默认注入的 bean 名称 ollamaChatModel
# base-url: http://localhost:11434
# chat:
# model: gemma3:1b

logging:
level:
org.springframework.ai.chat.client.advisor: DEBUG
org.springframework.ai: DEBUG
org.springframework.web.client.RestClient: DEBUG # 打印和 LLM 的交互


基础配置类

SsaLLMConfig

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
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import java.time.Duration;

@Configuration
public class SsaLLMConfig {

private static final String apiKey;
static {
apiKey = System.getenv("QWEN_API_KEY");
}

/**
* 自定义 RestClient 定制器:全局注入超长超时时间(读取 5 分钟 / 连接 100 秒),防止大模型深度思考或首次加载时触发连接断开。
*/
@Bean
public RestClientCustomizer restClientCustomizer() {
return restClientBuilder -> {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setReadTimeout(Duration.ofSeconds(300)); // 全局注入,解决超时
factory.setConnectTimeout(Duration.ofSeconds(100));
restClientBuilder.requestFactory(factory);
};
}

/**
* 阿里云百炼底层原生 API 客户端:封装了基于 API-Key 的身份认证,是通义和云端 DeepSeek 统一底座的底层通信连接器。
*/
@Bean("dashScopeApi")
public DashScopeApi dashScopeApi() {
return DashScopeApi.builder().apiKey(apiKey).build();
}

/**
* 通义千问大模型 Bean:基于 DashScope 适配,全局默认调用官方“qwen-plus”主力模型。
*/
@Bean("qwenChatModel")
public ChatModel qwenChatModel(DashScopeApi dashScopeApi) {
return DashScopeChatModel.builder()
.dashScopeApi(dashScopeApi)
.defaultOptions(DashScopeChatOptions.builder().model("qwen-plus").build())
.build();
}

/**
* 云端 DeepSeek 大模型 Bean:同样接入百炼平台,全局默认调用官方“deepseek-v4-flash”低延迟高性价比大模型。
*/
@Bean("deepseekChatModel")
public ChatModel deepseekChatModel(DashScopeApi dashScopeApi) {
return DashScopeChatModel.builder()
.dashScopeApi(dashScopeApi)
.defaultOptions(DashScopeChatOptions.builder().model("deepseek-v4-flash").build())
.build();
}

/**
* 本地 ollamaChatModel
*/
/*@Bean(name = "ollamaChatModel")
public ChatModel ollamaChatModel() {
return OllamaChatModel.builder()
.ollamaApi(OllamaApi.builder().baseUrl("http://localhost:11434").build())
.defaultOptions(OllamaChatOptions.builder().model("gemma3:1b").build())
.build();
}*/

/**
* 通义千问流式交互客户端:包装了通义模型,并内置了 SimpleLoggerAdvisor 日志切面
*/
@Bean("dashScopeChatClient")
public ChatClient dashScopeChatClient(@Qualifier("dashScopeChatModel") ChatModel dashScopeChatModel) {
return ChatClient.builder(dashScopeChatModel)
.defaultAdvisors(new SimpleLoggerAdvisor()) // 开发开启打印日志,需要开启 logging.level.org.springframework.ai.chat.client.advisor: DEBUG
.build();
}

/**
* DeepSeek 流式交互客户端:包装了云端 DeepSeek 模型,同样内置了日志切面
*/
@Bean("deepseekChatClient")
public ChatClient deepseekChatClient(@Qualifier("deepseekChatModel") ChatModel deepseekChatModel) {
return ChatClient.builder(deepseekChatModel)
.defaultAdvisors(new SimpleLoggerAdvisor()) // 开发开启打印日志,SimpleLoggerAdvisor 的默认日志输出级别是 debug
.build();
}
}


Hello Wolrd 冒烟测试

HelloWorldController

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
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

@RestController
@RequestMapping("/hello")
public class HelloWorldController {

@Resource(name = "openAiChatModel")
private ChatModel chatModel;
@Resource(name = "deepseekChatClient")
private ChatClient chatClient; // dashscope 专用调用方式,底层依赖 chatModel

@GetMapping(value = "/sayHello1", produces = MediaType.TEXT_PLAIN_VALUE)
public Mono<String> sayHello1(@RequestParam(required = false, defaultValue = "-1") Long userId,
@RequestParam(defaultValue = "你是谁") String message) {
return Mono.fromCallable(() -> chatModel.call(message))
.subscribeOn(Schedulers.boundedElastic());
}

@GetMapping(value = "/sayHello2", produces = MediaType.TEXT_PLAIN_VALUE)
public Flux<String> sayHello2(@RequestParam(required = false, defaultValue = "-1") Long userId,
@RequestParam(defaultValue = "你是谁") String message) {
return chatModel.stream(message);
}

@GetMapping(value = "/sayHello3", produces = MediaType.TEXT_PLAIN_VALUE)
public Mono<String> sayHello3(@RequestParam(required = false, defaultValue = "-1") Long userId,
@RequestParam(defaultValue = "你是谁") String message) {
return Mono.fromCallable(() -> chatClient.prompt()
.user(message)
.call()
.content()
).subscribeOn(Schedulers.boundedElastic());
}

@GetMapping(value = "/sayHello4", produces = MediaType.TEXT_PLAIN_VALUE)
public Flux<String> sayHello4(@RequestParam(required = false, defaultValue = "-1") Long userId,
@RequestParam(defaultValue = "你是谁") String message) {
return chatClient.prompt()
//.system("你是一个 Owlias 知识小助手")
.user(message)
.stream()
.content();
}
}

resources/public/index.html

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
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Owlias 聊天室 (SSE版)</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 20px auto; padding: 0 10px; }
#chatBox { border: 1px solid #ccc; height: 400px; overflow-y: auto; padding: 10px; margin-bottom: 10px; }
.user-msg { color: blue; margin: 10px 0; }
.bot-msg { color: green; margin: 10px 0; white-space: pre-wrap; }
#inputArea { display: flex; gap: 10px; }
input { flex: 1; padding: 8px; }
button { padding: 8px 15px; }
</style>
</head>
<body>

<h2>Owlias 问答聊天室</h2>

<div id="chatBox"></div>
<div id="inputArea">
<input type="text" id="msgInput" placeholder="输入消息,按回车发送..." onkeydown="if(event.key==='Enter') send()">
<button onclick="send()">发送</button>
</div>

<script>
const chatBox = document.getElementById('chatBox');
const msgInput = document.getElementById('msgInput');

async function send() {
const message = msgInput.value.trim();
if (!message) return;

// 1. 渲染用户发送的消息
appendMessage('我: ' + message, 'user-msg');
msgInput.value = '';

// 2. 创建一个用于承载 AI 回复的容器(因为是流式,需要一点点追加内容)
const botDiv = appendMessage('AI: ', 'bot-msg');

try {
// 3. 使用 fetch 获取后端的流式响应 (对应你的 sayHello2)
const response = await fetch(`/hello/sayHello2?userId=1&message=${encodeURIComponent(message)}`);
if (!response.ok) throw new Error('网络请求失败');

// 4. 读取二进制流并由原生 TextDecoder 转换为文本
const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
const { value, done } = await reader.read();
if (done) break;

// 将接收到的数据块追加到 AI 回复的容器中
botDiv.textContent += decoder.decode(value, { stream: true });

// 滚动到底部
chatBox.scrollTop = chatBox.scrollHeight;
}
} catch (error) {
botDiv.textContent += '[ 发生错误: ' + error.message + ' ]';
}
}

function appendMessage(text, className) {
const div = document.createElement('div');
div.className = className;
div.textContent = text;
chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight;
return div;
}
</script>
</body>
</html>


提示词相关测试

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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import com.demo04.tools.WeatherTool;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.Map;

/**
* 提示词测试
*/
@RestController
@RequestMapping("/prompt-test")
public class PromptTestController {

@Resource(name = "deepseekChatClient")
private ChatClient chatClient;

@Resource(name = "deepseekChatModel")
private ChatModel chatModel;

@Resource
private WeatherTool weatherTool;

/**
* 测试 chatClient system 类提示词
*/
@GetMapping(value = "/test01-edu-assistant", produces = MediaType.TEXT_PLAIN_VALUE)
public Flux<String> test01(@RequestParam(required = false, defaultValue = "-1") Long userId,
@RequestParam String message) {
return chatClient.prompt()
.system("你是一个教育小助手专家,请你只回答教育类问题,其他问题回复,我只能回答教育相关问题,其他无可奉告。")
.user(message)
.stream()
.content();
}

/**
* 测试自己组装消息提示词
*/
@GetMapping(value = "/test02-story-assistant", produces = MediaType.TEXT_PLAIN_VALUE)
public Flux<String> test02(@RequestParam(required = false, defaultValue = "-1") Long userId,
@RequestParam String message) {
SystemMessage systemMessage = new SystemMessage("你是一个讲故事小能手,每个故事限定在200字以内。");
UserMessage userMessage = new UserMessage(message);
Prompt prompt = new Prompt(systemMessage, userMessage);
Flux<ChatResponse> stream = chatModel.stream(prompt);
return stream.mapNotNull(response -> response.getResult().getOutput().getText());
}

/**
* 测试 tool 类型消息提示词,实现 calling function
* 注意 tool calling 或称 function calling 生效的前提是需要大模型的支持!
*/
@GetMapping(value = "/test03-weather-assistant", produces = MediaType.TEXT_PLAIN_VALUE)
public Flux<String> test03(@RequestParam String message) {
return chatClient.prompt()
.system("你是一个贴心的生活气象小助手。")
.user(message)
.tools(weatherTool) // 👉🏻 绑定 tool bean,自动扫描该 bean 中带有 @Tool 注解的方法
.stream()
.content();
}

@GetMapping(value = "/test031-weather-assistant", produces = MediaType.TEXT_PLAIN_VALUE)
public Flux<String> test031(@RequestParam String message) {
ToolCallingChatOptions weatherChatOptions = ToolCallingChatOptions.builder()
.toolCallbacks(ToolCallbacks.from(new WeatherTool())) // 注入多个工具类,可以不使用 @Component
.build();
Flux<ChatResponse> stream = chatModel.stream(Prompt
.builder()
.messages(new SystemMessage("你是一个贴心的生活气象小助手。"), new UserMessage(message))
.chatOptions(weatherChatOptions)
.build());
return stream.mapNotNull(response -> response.getResult().getOutput().getText());
}

/**
* 测试提示词模板
*/
@GetMapping(value = "/test04")
public Flux<String> test04(String topic, String outputFormat, String wordCount) {
PromptTemplate promptTemplate = new PromptTemplate("""
讲一个关于{topic}的故事,并以{output_format}的格式输出,字数在{word_count}左右。
""");
Prompt prompt = promptTemplate.create(
Map.of("topic", topic,
"output_format", outputFormat,
"word_count", wordCount)
);
return chatClient.prompt(prompt)
.stream()
.content();
}

/**
* 外部读取提示词模板文件
*/
@Value("classpath:/prompt/story-system-prompt.txt")
private org.springframework.core.io.Resource storyPromptTemplate;
@GetMapping(value = "/test05")
public Flux<String> test05(String topic, String outputFormat, String wordCount) {
PromptTemplate promptTemplate = new PromptTemplate(storyPromptTemplate);
Prompt prompt = promptTemplate.create(
Map.of("topic", topic,
"output_format", outputFormat,
"word_count", wordCount)
);
return chatClient.prompt(prompt)
.stream()
.content();
}
}

WeatherTool:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;

@Slf4j
@Component("weatherTool")
public class WeatherTool {

public record WeatherRequest(String location) {}
public record WeatherResponse(String location, String temperature, String info, String wind) {}

@Tool(description = "根据城市名称或地点获取当前的实时天气预报")
public WeatherResponse chatWeather(WeatherRequest request) { // 模拟天气业务逻辑
log.info("请求获取天气信息:{}", request);
if (request.location().contains("北京")) {
return new WeatherResponse("北京", "1℃", "晴朗", "北风3级");
} else if (request.location().contains("上海")) {
return new WeatherResponse("上海", "2℃", "大雨", "东风2级");
} else {
return new WeatherResponse(request.location(), "20℃", "阴天", "微风");
}
}
}


格式化输出测试

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
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;

/**
* 格式化输出测试类
*/
@RestController
@RequestMapping("/output-format-test")
public class OutputFormatTestController {
record StudentRecord(String id, String name, String email) {}

@Resource(name = "deepseekChatClient")
private ChatClient chatClient;

@Resource(name = "openAiChatModel")
private ChatModel chatModel;

/**
* 告诉大模型:“请把结果按照 StudentRecord 的结构组织成标准 JSON 返回”
* 学号是111,姓名张三,邮箱是huhu@126.com --> {} 结构化数据
*/
@GetMapping(value = "/test01", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<StudentRecord> test01(@RequestParam(required = false, defaultValue = "-1") Long userId,
@RequestParam String message) {
return Mono.fromSupplier(() ->
chatClient.prompt()
.system("你是一个专业的学校教务信息提取助手。请从用户的文字中精准提取学生的信息。")
.user(userSpec -> userSpec.text(message)) // 用 Lambda 简化你原本的匿名内部类
.call()
.entity(StudentRecord.class)
).subscribeOn(Schedulers.boundedElastic());
}

@GetMapping(value = "/test02", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<StudentRecord> test02(@RequestParam(required = false, defaultValue = "-1") Long userId,
@RequestParam String message) {
return Mono.fromSupplier(() -> {
// 创建一个结构化输出转换器(指定为你定义的 Record 结构)
BeanOutputConverter<StudentRecord> converter = new BeanOutputConverter<>(StudentRecord.class);
// 拿到 Spring AI 帮我们自动生成的 JSON 约束提示词(包含 JSON Schema)
String jsonFormatInstructions = converter.getFormat();
// 手动拼接 Prompt,把约束提示词追加到用户的输入中,告诉模型必须按这个格式回复
SystemMessage systemMessage = new SystemMessage("你是一个专业的学校教务信息提取助手。请从用户的文字中精准提取学生的信息。");
UserMessage userMessage = new UserMessage(message + "\n" + jsonFormatInstructions);
Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
// 调用底层的 chatModel 发起请求,此时拿到的返回值是纯文本 (String)
String rawJsonResult = chatModel.call(prompt).getResult().getOutput().getText();
// 手动调用转换器,将纯文本 JSON 反序列化为我们的 StudentRecord 实例
return converter.convert(rawJsonResult);
}).subscribeOn(Schedulers.boundedElastic());
}
}


会话记忆支持

继续引入依赖:

1
2
3
4
5
<!--阿里平台 chatModel 会话记忆 redis 实现支持-->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory-redis</artifactId>
</dependency>

修改 SaaLLMConfig:

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
/**
* 增加:基于 Lettuce 的 Redis 聊天记忆存储库:
*/
@Bean
public LettuceRedisChatMemoryRepository lettuceRedisChatMemoryRepository() {
return LettuceRedisChatMemoryRepository.builder()
.host("127.0.0.1")
.port(6379)
.timeout(5000)
.build();
}

/**
* 增加:对话记忆管理器(滑动窗口模式):
* 包装 Redis 存储库,设置 maxMessages(10) 表示每个会话只保留最近 10 条消息送往大模型,防止 Token 溢出。
*/
@Bean
public ChatMemory chatMemory(LettuceRedisChatMemoryRepository repository) {
return MessageWindowChatMemory.builder() ////
.chatMemoryRepository(repository) ////
.maxMessages(10) // 最多存10条记忆消息
.build();
}

/**
* 通义千问流式交互客户端:
* 挂载 MessageChatMemoryAdvisor,使其具备从 Redis 自动读取/保存历史上下文的能力。
*/
@Bean("dashScopeChatClient")
public ChatClient dashScopeChatClient(@Qualifier("dashScopeChatModel") ChatModel dashScopeChatModel, ChatMemory chatMemory) {
return ChatClient.builder(dashScopeChatModel)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
new SimpleLoggerAdvisor()) // 开发开启打印日志,需要开启 logging.level.org.springframework.ai.chat.client.advisor: DEBUG
.build();
}

/**
* DeepSeek 流式交互客户端:
* 共享同一个 Redis 记忆管理器,各会话通过各自的 conversationId 在 Redis 中天然隔离。
*/
@Bean("deepseekChatClient")
public ChatClient deepseekChatClient(@Qualifier("deepseekChatModel") ChatModel deepseekChatModel, ChatMemory chatMemory) {
return ChatClient.builder(deepseekChatModel)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
new SimpleLoggerAdvisor())
.build();
}

测试 controller:

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
/**
* 会话记忆测试
*/
@RestController
@RequestMapping("/chat-memory-test")
public class ChatMemoryTestController {

@Resource(name = "deepseekChatClient")
private ChatClient chatClient;

/**
* 在 Spring AI 中,advisors(顾问)的作用类似于 Spring 框架里的 AOP(面向切面编程)拦截器或
* Web 开发中的 Filter(过滤器)。它允许你在提示词(Prompt)发送给大模型之前,或者在模型的回复
* 返回给用户之后,动态地拦截并塞入一些“全局横切逻辑”,而不需要污染你的核心业务代码。
*/
@GetMapping(value = "/test01", produces = MediaType.TEXT_PLAIN_VALUE)
public Flux<String> sayHello4(@RequestParam Long userId,
@RequestParam String message) {
return chatClient.prompt()
.user(message)
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, userId)) ////
.stream()
.content();
}
}

对应 redis 的数据结构(用户 userId=2),key 的名称定义在 BaseRedisChatMemoryRepository:


万象文生图

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
import jakarta.annotation.Resource;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImageOptionsBuilder;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.stream.Collectors;

/**
* 万象LLM - 文生图测试
*/
@RestController
@RequestMapping("/image-model-test")
public class ImageModelTestController {

/**
* 注入 Spring AI 标准的 ImageModel 接口
* dashScope 自动装配默认的 ImageModel Bean 名称为 dashScopeImageModel
*/
@Resource(name = "dashScopeImageModel")
private ImageModel imageModel;


/**
* 基础文生图接口:输入提示词,返回生成的图片 URL
* 示例:/image-model-test/generate?prompt=海边性感比基尼美女
*/
@GetMapping("/generate")
public Mono<String> generateImage(@RequestParam String prompt) {
return Mono.fromCallable(() -> {
// 1. 构建图片生成的配置参数(指定分辨率)
var options = ImageOptionsBuilder.builder()
//.model("wanx-v1")
.height(1024)
.width(1024)
.build();
// 2. 将提示词与配置打包为 ImagePrompt
ImagePrompt imagePrompt = new ImagePrompt(prompt, options);
// 3. 调用万象模型生成图片
ImageResponse response = imageModel.call(imagePrompt);
// 4. 获取生成的图片 URL(通义万相返回的是一个有有效期的临时托管 URL)
return response.getResult().getOutput().getUrl();
}).subscribeOn(Schedulers.boundedElastic());
}


/**
* 高阶文生图接口:支持指定生成数量和更加细致的参数
* 示例:/image-model-test/generate-batch?prompt=水墨画风格的江南水乡&count=2
*/
@GetMapping("/generate-batch")
public Mono<List<String>> generateImageBatch(@RequestParam String prompt,
@RequestParam(defaultValue = "1") int count) {
return Mono.fromCallable(() -> {
// 1. 配置参数,通过 withN(count) 指定一次性生成的图片张数
var options = ImageOptionsBuilder.builder()
//.model("wanx-v1")
.height(768)
.width(1024)
.N(count)
.build();
ImagePrompt imagePrompt = new ImagePrompt(prompt, options);
ImageResponse response = imageModel.call(imagePrompt);
// 2. 链式解析返回的多张图片结果,提取出所有的 URL 列表
return response.getResults().stream()
.map(result -> result.getOutput().getUrl())
.collect(Collectors.toList());
}).subscribeOn(Schedulers.boundedElastic());
}
}


CosyVoice 文生音

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
import com.alibaba.cloud.ai.dashscope.audio.DashScopeAudioSpeechModel;
import com.alibaba.cloud.ai.dashscope.audio.DashScopeAudioSpeechOptions;
import jakarta.annotation.Resource;
import org.springframework.ai.audio.tts.TextToSpeechPrompt;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

/**
* 万象LLM / 百炼平台 - 文生音(语音合成TTS)测试控制器
*/
@RestController
@RequestMapping("/speech-test")
public class SpeechSynthesisTestController {

/**
* 注入 Spring AI Alibaba 的语音合成模型实例
* 引入依赖后默认由 Starter 自动装配,Bean 名称通常为 "dashScopeSpeechSynthesisModel"
*/
@Resource(name = "dashScopeSpeechSynthesisModel")
private DashScopeAudioSpeechModel speechSynthesisModel;

/**
* 1. 同步语音合成接口:输入文本,直接返回完整的 MP3 二进制流
* 浏览器访问该接口会自动作为音频文件播放或下载
* 示例:/speech-test/synthesis?text=你好,我是人工智能助手,很高兴为你服务。
*/
@GetMapping(value = "/synthesis", produces = "audio/mpeg")
public Flux<DataBuffer> synthesizeSpeech(@RequestParam(defaultValue = "支付宝到账100元") String text,
ServerHttpResponse response) {
DashScopeAudioSpeechOptions options = DashScopeAudioSpeechOptions.builder()
.model("cosyvoice-v2")
.voice("longyingxiao")
.speed(1.0)
.build();
TextToSpeechPrompt prompt = new TextToSpeechPrompt(text, options);
return speechSynthesisModel.stream(prompt)
.map(chunk -> {
byte[] audio = chunk.getResult().getOutput();
return response.bufferFactory().wrap(audio);
});
}
}


RAG 使用案例

这里基于 redis 8 实现。 redis 8 服务端环境准备可以参考本站 Langchan4j 社区版 redis ChatMemoryStore 进行安装。除此之外,我们还需要引入支持 redis RAG 的依赖包:

1
2
3
4
5
6
<!--spring ai redis 向量数据库支持-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-redis</artifactId>
<version>1.1.8</version>
</dependency>

注入 VectorStore,以及为 ChatClient 装配 VectorStore Advisor 切面。更新 SsaLLMConfig:

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
@Bean
@Primary
public VectorStore customRedisVectorStore(DashScopeEmbeddingModel embeddingModel) {
JedisPooled jedisPooled = new JedisPooled("127.0.0.1", 6379);
return RedisVectorStore.builder(jedisPooled, embeddingModel)
.indexName(VectorDataInitializer.INDEX_NAME) // ⚠️ 事先执行 FT.DROPINDEX owlias-rag-idx,防止旧的 prefix 污染!
.prefix(VectorDataInitializer.PREFIX)
// 显式指定向量字段名为 "embedding",与百炼写入的 JSON 保持完全一致
.embeddingFieldName(VectorDataInitializer.EMBEDDING_FIELD_NAME)
// 如果你之前改了 content 字段,也可以在这里显式指定(默认是 content)
.contentFieldName(VectorDataInitializer.CONTENT_FIELD_NAME)
// vectorAttrs.put("DIM", this.embeddingModel.dimensions());
.initializeSchema(true)
.build();
}

/**
* DeepSeek 流式交互客户端:包装了云端 DeepSeek 模型,同样内置了日志切面
* 挂载 MessageChatMemoryAdvisor(此处为简单测试,先注释掉用户会话记忆功能)
* 挂载 RetrievalAugmentationAdvisor
*/
@Bean("deepseekChatClient")
public ChatClient deepseekChatClient(@Qualifier("deepseekChatModel") ChatModel deepseekChatModel,
ChatMemory chatMemory,
VectorStore customRedisVectorStore) {
return ChatClient.builder(deepseekChatModel)
.defaultAdvisors(
//MessageChatMemoryAdvisor.builder(chatMemory).build(),
RetrievalAugmentationAdvisor
.builder()
.documentRetriever(VectorStoreDocumentRetriever
.builder().vectorStore(customRedisVectorStore)
.build())
.build(),
new SimpleLoggerAdvisor())
.build();
}

VectorDataInitializer:用于本地知识库的初始化(已去重)。

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
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.redis.RedisVectorStore;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import redis.clients.jedis.JedisPooled;
import java.nio.charset.StandardCharsets;
import java.util.List;

@Slf4j
@Component
public class VectorDataInitializer implements CommandLineRunner {
public static final String INDEX_NAME = "owlias-rag-idx";
public static final String PREFIX = "owlias:";
public static final String EMBEDDING_FIELD_NAME = "embedding";
public static final String CONTENT_FIELD_NAME = "content";

private final VectorStore vectorStore;
public VectorDataInitializer(@Qualifier("customRedisVectorStore") VectorStore vectorStore) {
// 显式使用 @Qualifier 确保注入的是我们自定义的 customRedisVectorStore,同时断开依赖环
this.vectorStore = vectorStore;
}

@Value("classpath:rag/ops.txt")
private org.springframework.core.io.Resource resource;


@Override
public void run(String... args) {
try {
/*List<Document> documents = vectorStore.similaritySearch("编码:0003"); // 检索测试:
List<String> list = documents.stream().map(Document::getText).toList();
System.out.println(list); */

log.info("============== [CommandLineRunner] 清扫旧数据 ==============");
if (vectorStore instanceof RedisVectorStore) {
JedisPooled jedis = (JedisPooled) vectorStore.getNativeClient().get();
// 找出所有物理存在的 Key,比如 ["owlias:abc", "owlias:def"]
var keys = jedis.keys(PREFIX + "*");
if (keys != null && !keys.isEmpty()) {
// 剥离掉前缀 "owlias:",还原成底层的 Document ID -> ["abc", "def"]
List<String> documentIds = keys.stream()
.map(key -> key.substring(PREFIX.length()))
.toList();
// 优雅地交给 vectorStore 批量删除
vectorStore.delete(documentIds);
log.info("已成功通过 VectorStore 清理了 {} 条历史向量数据。", documentIds.size());
}
}

log.info("============== [CommandLineRunner] 开始初始化 RAG 本地知识库 ==============");
// 1. 使用 TextReader 载入 classpath 下的文本资源
TextReader textReader = new TextReader(resource);
List<Document> rawDocuments = textReader.get();
log.info("读取原始文档成功,共 {} 篇。", rawDocuments.size());

// 2. 初始化文本切片器
TokenTextSplitter splitter = new TokenTextSplitter();
List<Document> splitDocuments = splitter.apply(rawDocuments);
List<Document> deduplicatedDocuments = splitDocuments.stream()
.filter(doc -> doc != null && StringUtils.hasText(doc.getText()))
.map(doc -> {
// 根据文本内容计算唯一的 MD5 签名
String contentMd5 = DigestUtils.md5DigestAsHex(doc.getText().getBytes(StandardCharsets.UTF_8));
// 把随机 UUID 替换为文本内容的 MD5:利用带 ID 的构造函数重新包装 Document,这样入库时 Redis 里的 Key 就会变成定长的 owlias:[md5_hash]
return new Document(contentMd5, doc.getText(), doc.getMetadata());
}).toList();
/*List<Document> splitDocuments = rawDocuments.stream() // 按行切分,每一行会独立成为一个 Redis Key
.flatMap(doc -> Arrays.stream(doc.getText().split("\n")))
.filter(line -> !line.trim().isEmpty())
.map(line -> new Document(line.trim()))
.toList();*/
log.info("文本切片完成,切分后生成 {} 个知识片段。", deduplicatedDocuments.size());

// 3. 将切片后的文档写入 Redis 向量库
vectorStore.accept(deduplicatedDocuments);
log.info("============== RAG 本地知识库成功注入 Redis 向量库 ==============");
} catch (Exception e) {
log.error("初始化 RAG 知识库失败,请检查 redis 连接或存储路径!", e);
}
}
}

业务测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping("/rag-test")
public class RagTestController {

@Resource
@Qualifier("deepseekChatClient")
private ChatClient chatClient;

/**
* 基于 Redis 向量库的本地专属 RAG 问答接口
* 示例:/rag-test/chat?code=编码0003的含义是什么
*/
@GetMapping(value = "/chat", produces = MediaType.TEXT_PLAIN_VALUE)
public Flux<String> ragTestChat(@RequestParam String code) {
return chatClient.prompt()
.system("你是一个运维工程师,按照编码给出对应的故障解释,否则回复找不到信息。")
.user(code)
.stream()
.content();
}
}

测试验证:

1
2
3
$ curl -N http://localhost:8080/rag-test/chat?code=编码0002的响应含义是什么

编码0002表示处理中。

redis 的数据结构:

redis 的索引结构:

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
> FT.INFO owlias-rag-idx
1) "index_name"
2) "owlias-rag-idx"
...
5) "index_definition"
...
3) "prefixes"
4) 1) "owlias:" #### 划定地盘,相当于告诉 Redis:“只要是 owlias: 开头的 Key,都归我这个索引管!
5) "default_score"
6) "1"
7) "attributes"
8) 1) 1) "identifier"
2) "$.content" #### $.content 字段做成倒排索引
3) "attribute"
4) "content"
5) "type"
6) "TEXT"
7) "WEIGHT"
8) "1"
2) 1) "identifier"
2) "$.embedding"
3) "attribute"
4) "embedding"
5) "type"
6) "VECTOR"
7) "algorithm"
8) "HNSW"
9) "data_type"
10) "FLOAT32"
11) "dim"
# 维度 1024,向量其实就是空间里的坐标。因为有 1024 个数字,所以它是一个1024维超空间里的一个点。
# 为了让你搜索时能够秒回,Redis 采用了 HNSW(层次化导航小世界) 算法来组织这些点,通俗来说就是
# Redis 把这些坐标点连成了一张“蜘蛛网”(图结构)。当用户提问 “编码是 0002 的响应含义是什么” 时,
# LLM 会把这句话也转成一个 1024 维的临时向量坐标,假设是点X,Redis 拿着点X,在这张蜘蛛网上顺着
# 丝线顺藤摸瓜,看哪个已有的点离X最近。它不看绝对距离,而是看这两个向量在空间里的夹角。
# 夹角越小,余弦相似度越高,说明含义越接近!
12) "1024"
13) "distance_metric"
14) "COSINE"
15) "M"
16) "16"
17) "ef_construction"
18) "200"
9) "num_docs"
10) "1"
11) "max_doc_id"
12) "12"
...