AI 应用开发
字数: 0 字 时长: 0 分钟
本文将以 Spring AI 为框架接入阿里通义系列大模型,实现基于大模型开发的 AI 恋爱大师 应用,为恋爱小白回答亲密关系问题。在这个过程中,也将学习实践 AI 应用开发场景主流技术,如 Spring AI特性、提示词优化、RAG知识库、工具调用、MCP协议等。
大模型接入
1. 创建 API key
要接入阿里通义千问系列大模型,首先需要在阿里云百炼平台创建 API key
作为后续应用程序中接入大模型的凭证。
2. 初始化应用
Spring AI 默认没有支持所有的大模型,更多的是支持兼容 OpenAI API 大模型的集成,参考 Spring AI 官方模型对比。因此,我们直接使用 Spring AI Alibaba框架,它在 Spring AI 基础上进行了增强,不仅能直接集成阿里系大模型,而且与标准的 Spring AI 保持兼容。
引入 Spring AI Alibaba 依赖
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0-M6.1</version>
</dependency>
引入 Spring AI Alibaba 依赖后,需要配置我们在阿里云百炼平台创建的 API key
,并指定大模型名称。
spring:
ai:
dashscope:
api-key: 阿里云百炼 API key
chat:
options:
model: qwen-turbo
大模型名称可以在阿里云百炼模型广场进行查看不同模型对应的模型 code:
3. 基础对话测试
Spring AI Alibaba 实现了 Spring AI 的 ChatModel 接口,即 DashScopeChatModel 类,用于接入阿里通义千问系列大模型。
Spring AI 提供了 ChatClient API 来和 AI 大模型交互,我们编写一个基础的单元测试类来测试最基础的 AI 对话功能:
系统提示词
上面的对话例子可以看出 AI 扮演的是一个通用大模型的角色,我们要开发一个恋爱大师应用,需要为 AI 大模型指定一个系统提示词,相当于一段系统预设,定义了 AI 的行为模式、专业性与交互风格。
指定一段系统提示词,定义 AI 扮演恋爱大师角色
private static final String SYSTEM_PROMPT = "扮演深耕恋爱心理领域的专家。开场向用户表明身份,告知用户可倾述恋爱难题。" +
"围绕单身、恋爱、已婚三种状态提问:单身状态询问社交圈拓展及追求心仪对象的困扰:" +
"恋爱状态询问沟通、习惯差异引发的矛盾:已婚状态询问家庭责任与亲属关系处理的问题。" +
"引导用户详述事情经过、对方反应及自身想法,以便给出专属解决方案。";
多轮对话实现
上面我们实现了单轮用户与 AI 大模型交互,但如何实现多轮对话呢?也就是如何让 AI 大模型“有记忆”呢?
- 需要创建一个标识 chatId 作为整个会话的唯一标识
- 需要维护一个历史对话列表来保存整个会话的多轮聊天记录
- 每次与 AI 大模型交互时,都将历史对话记录添加到输入中
这是多轮对话实现的思路,Spring AI 提供了相应的 API 用以实现多轮对话功能。
Chat Memory
Spring AI 提供了 ChatMemory 接口,负责历史对话的存储,定义了保存消息、查询消息、清空历史消息的方法。
1. 基于内存的对话记忆
ChatMemory 提供了默认实现 InMemoryChatMemory,基于内存保存对话记录。
@Component
public class LoveApp {
private final ChatClient chatClient;
private static final String SYSTEM_PROMPT = "扮演深耕恋爱心理领域的专家。开场向用户表明身份,告知用户可倾述恋爱难题。" +
"围绕单身、恋爱、已婚三种状态提问:单身状态询问社交圈拓展及追求心仪对象的困扰:" +
"恋爱状态询问沟通、习惯差异引发的矛盾:已婚状态询问家庭责任与亲属关系处理的问题。" +
"引导用户详述事情经过、对方反应及自身想法,以便给出专属解决方案。";
public LoveApp(ChatModel dashscopeChatModel) {
// 初始化基于内存的对话记忆
ChatMemory chatMemory = new InMemoryChatMemory();
chatClient = ChatClient.builder(dashscopeChatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory)
)
.build();
}
public String doChat(String message, String chatId) {
ChatResponse chatResponse = chatClient.prompt()
.user(message)
.advisors(advisorSpec -> advisorSpec
.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId) // 会话 ID
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10) // 历史对话存储轮数
)
.call()
.chatResponse();
String content = chatResponse.getResult().getOutput().getText();
return content;
}
}
多轮对话测试发现 AI “有了记忆”:
2. 自定义基于文件的对话记忆
Spring AI 默认提供了基于内存的会话记忆,但这种方式一是会话量大时会大量消耗内存资源,二是无法持久化保存会话记录。因此我们可以自定义实现 ChatMemory ,将会话记忆保存到文件或数据库中相比内存最麻烦的一点是涉及到会话数据的序列化与反序列化,这里选择以 kryo 的方式进行序列化:
- 引入依赖
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
<version>5.6.2</version>
</dependency>
- 实现 ChatMemory 接口
/**
* 基于文件持久化的对话记忆
*/
public class FileBasedChatMemory implements ChatMemory {
private final String BASE_DIR;
private static final Kryo kryo = new Kryo();
static {
kryo.setRegistrationRequired(false);
// 设置实例化策略
kryo.setInstantiatorStrategy(new StdInstantiatorStrategy());
}
// 构造对象时,指定文件保存目录
public FileBasedChatMemory(String dir) {
this.BASE_DIR = dir;
File baseDir = new File(dir);
if (!baseDir.exists()) {
baseDir.mkdirs();
}
}
@Override
public void add(String conversationId, List<Message> messages) {
List<Message> conversationMessages = getOrCreateConversation(conversationId);
conversationMessages.addAll(messages);
saveConversation(conversationId, conversationMessages);
}
@Override
public List<Message> get(String conversationId, int lastN) {
List<Message> allMessages = getOrCreateConversation(conversationId);
return allMessages.stream()
.skip(Math.max(0, allMessages.size() - lastN))
.toList();
}
@Override
public void clear(String conversationId) {
File file = getConversationFile(conversationId);
if (file.exists()) {
file.delete();
}
}
private List<Message> getOrCreateConversation(String conversationId) {
File file = getConversationFile(conversationId);
List<Message> messages = new ArrayList<>();
if (file.exists()) {
try (Input input = new Input(new FileInputStream(file))) {
messages = kryo.readObject(input, ArrayList.class);
} catch (IOException e) {
e.printStackTrace();
}
}
return messages;
}
private void saveConversation(String conversationId, List<Message> messages) {
File file = getConversationFile(conversationId);
try (Output output = new Output(new FileOutputStream(file))) {
kryo.writeObject(output, messages);
} catch (IOException e) {
e.printStackTrace();
}
}
private File getConversationFile(String conversationId) {
return new File(BASE_DIR, conversationId + ".kryo");
}
}
- 应用示例
public LoveApp(ChatModel dashscopeChatModel) {
// 基于文件的对话记忆
// 指定会话记忆文件存放位置
String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory";
ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
chatClient = ChatClient.builder(dashscopeChatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory)
)
.build();
}
Advisors
Spring AI 使用 Advisor (顾问)机制来增强 AI 的能力,可以理解为一系列可插拔的拦截器,上面实现多轮对话的代码示例中就已经用到了 MessageChatMemoryAdvisor
,我们可以应用多个 Advisor 组成一个拦截器链(责任链模式) ,通过 Ordered 来指定执行顺序。
Advisor 的原理如下,可以在输入给 AI 大模型前提供前置增强,在大模型返回结果后提供后置增强:
自定义日志 Advisor
我们可以自定义 Advisor 来针对我们的业务进行增强,比如日志记录:
- CallAroundAdvisor:用于处理同步请求和响应(非流式)
- StreamAroundAdvisor:用于处理流式请求和响应
public class MyLoggerAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
private AdvisedRequest before(AdvisedRequest request) {
System.out.println("AI Request: " + request.userText()); // 模拟前置日志打印 (AI 请求)
return request;
}
private void observeAfter(AdvisedResponse advisedResponse) {
// 模拟后置日志打印 (AI 响应)
System.out.println("AI Response: " +
advisedResponse.response().getResult().getOutput().getText());
}
@Override
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
before(advisedRequest);
AdvisedResponse advisedResponse = chain.nextAroundCall(advisedRequest);
observeAfter(advisedResponse);
return advisedResponse;
}
@Override
public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) {
before(advisedRequest);
Flux<AdvisedResponse> advisedResponseFlux = chain.nextAroundStream(advisedRequest);
return new MessageAggregator().aggregateAdvisedResponse(advisedResponseFlux,this::observeAfter);
}
@Override
public String getName() {
return this.getClass().getSimpleName();
}
@Override
public int getOrder() {
return 0; // 执行顺序,值越小优先级越高
}
}
自定义 Re-Reading Advisor
Re-Reading (重读)的原理是让模型重写阅读问题来提高推理能力,不过成本会加倍!谨慎使用。
/**
* 自定义 Re2 Advisor
* 可提高大型语言模型的推理能力
*/
public class ReReadingAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
private AdvisedRequest before(AdvisedRequest advisedRequest) {
Map<String, Object> advisedUserParams = new HashMap<>(advisedRequest.userParams());
advisedUserParams.put("re2_input_query", advisedRequest.userText());
return AdvisedRequest.from(advisedRequest)
.userText("""
{re2_input_query}
Read the question again: {re2_input_query}
""")
.userParams(advisedUserParams)
.build();
}
@Override
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
return chain.nextAroundCall(this.before(advisedRequest));
}
@Override
public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) {
return chain.nextAroundStream(this.before(advisedRequest));
}
@Override
public int getOrder() {
return 0;
}
@Override
public String getName() {
return this.getClass().getSimpleName();
}
}
结构化输出
Spring AI 提供了结构化输出转换器(Structured OutPut Converter)用于将大模型返回的文本输出转换为结构化数据格式。
- FormatProvider 接口:提供特定的格式指令给 AI 模型
- Converter<String,T> 接口:负责将模型的文本输出转换为指定的目标类型 T
在调用大模型之前, FormatProvider 为 AI 模型提供特定的指令格式,使其能够生成可以通过 Converter 转换为指定目标类型的文本输出。
恋爱报告功能示例
我们要求 AI 大模型每次回答都生成一个恋爱报告,标题为用户名,内容为建议列表。
- 引入结构化输出格式转换依赖
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-generator</artifactId>
<version>4.37.0</version>
</dependency>
- 代码示例
record LoveReport(String title, List<String> suggestions){}
public LoveReport doChatWithReport(String message,String chatId) {
return chatClient.prompt()
.system(SYSTEM_PROMPT + "每次对话后都要生成恋爱结果,标题为{用户名}的恋爱报告,内容为建议列表")
.user(message)
.advisors(advisorSpec -> advisorSpec
.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10)
)
.call()
.entity(LoveReport.class);
}
- 测试发现,返回结果已经按照我们指定的格式进行结构化输出了