ai应用开发的基础

Prompt

Prompt 工程(Prompt Engineering)又叫提示词工程,简单来说,就是输入给 AI 的指令。

高质量的 Prompt 可以显著提升 AI 输出的质量

ChatClient

ChatClient 是实现 “人与 AI 对话” 的工具或组件

可通过ChatClient封装调用ai

 public String doChat(String message, String chatId) {
     // 构建对话请求:包含用户消息、会话ID(用于记忆查询)、历史消息读取数量
     ChatResponse chatResponse = chatClient
             .prompt()
             .user(message)  // 用户输入消息
             .advisors(spec -> spec
                 .param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)  // 指定会话ID
                 .param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))  // 读取最近10条历史消息
             .call()  // 调用AI模型
             .chatResponse();
 ​
     // 提取AI回复内容并返回
     return chatResponse.getResult().getOutput().getText();
 }

.call() 调用的 AI 模型由配置文件中指定

 spring:
   ai:
     dashscope:
       api-key: 你的API密钥
       chat:
         options:
           model: qwen-plus  # 这里指定了具体模型

通过ChatClient调用与上次文章提到的SpringAiInvoke的调用的差别

springAiInvoke`的调用ai

 package com.yupi.aitest01mysql.invoke;
 ​
 import jakarta.annotation.Resource;
 import org.springframework.ai.chat.messages.AssistantMessage;
 import org.springframework.ai.chat.model.ChatModel;
 import org.springframework.ai.chat.prompt.Prompt;
 import org.springframework.boot.CommandLineRunner;
 import org.springframework.stereotype.Component;
 ​
 @Component
 public class SpringAiInvoke implements CommandLineRunner {
 ​
     @Resource
     private ChatModel dashscopeChatModel;
 ​
     @Override
     public void run(String... args) throws Exception {
         AssistantMessage assistantMessage = dashscopeChatModel.call(new Prompt("我是鼠鼠"))
                 .getResult()
                 .getOutput();
         System.out.println(assistantMessage.getText());
     }
 }
 ​

1,SpringAiInvoke的调用方式:直接调用ChatModel(基础层)

2,ChatClientChatModel的上层封装,通过ChatClient`封装调用(高级层),这是 Spring AI 提供的高级封装调用

3,两者本质都是通过 Spring AI 框架调用 AI 模型,但:

  • ChatModel底层接口,负责与模型服务直接通信

  • ChatClient业务层工具,在通信基础上增加了对话管理、参数配置等功能

这两种调用方式本质都是Spring AI 框架中调用ai的方式

Advisors

Spring AI 使用 Advisors(拦截器)机制来增强 AI 的能力,在调用 AI 前和调用 AI 后可以执行一些额外的操作

  • 比如调用 AI 前,想统一给所有请求加个前缀提示(比如 “请用中文回答”),Advisors 可以在发送请求前自动加上;

  • 比如 AI 返回结果后,想过滤掉敏感内容,Advisors 可以在结果返回给用户前先 “过一遍筛子”;

  • 再比如想记录每次 AI 调用的耗时、请求内容,Advisors 可以悄悄把这些信息记下来,不影响主流程。

保持单一职责:每个 Advisor 应专注于一项特定任务

用法——可以直接为 ChatClient 指定默认拦截器——在.defaultAdvisors(中指定用的拦截器 )

 chatClient chatClient = ChatClient.builder(chatModel) //传入要使用的大模型
     .defaultAdvisors(
         new MessageChatMemoryAdvisor(chatMemory), // 对话记忆 advisor
         new QuestionAnswerAdvisor(vectorStore)    // RAG 检索增强 advisor
     )
     .build();

拦截器的配置方式

defaultAdvisors() 配置拦截器的场景:全局通用逻辑,适合在初始化 ChatClient 时通过 defaultAdvisors() 配置。

advisors() 配置拦截器的场景:单个请求的特殊逻辑,当某个增强器只需要对当前请求生效,适合在构建具体请求时用 advisors() 配置。

 // 全局默认增强器(所有请求都生效)
 ChatClient chatClient = ChatClient.builder(aiClient)
         .defaultAdvisors(new MyLoggerAdvisor()) // 全局日志
         .build();
 ​
 // 第一个请求:需要 RAG 增强(仅当前请求生效)
 chatClient.prompt()//开始构建一个对话请求(创建一个提示信息构建器)
         .user("恋爱中如何道歉?")
         .advisors(new QuestionAnswerAdvisor(loveAppVectorStore)) // 仅这个请求用 RAG
         .call();
 ​
 // 第二个请求:不需要 RAG(不添加额外增强器,仅用全局默认的日志)
 chatClient.prompt()
         .user("今天天气如何?")
         .call();

==.prompt()开启单次对话请求== 每次调用.prompt(),都会基于全局 ChatClient创建一个**独立的对话请求对象** **ChatClient` 是全局客户端**

一些常见的Advisors

Spring AI 的 ChatMemoryAdvisor——实现对话记忆功能

  • MessageChatMemoryAdvisor:从记忆中检索历史对话,并将其作为消息集合添加到提示词中(首选)

  • PromptChatMemoryAdvisor:从记忆中检索历史对话,并将其添加到提示词的系统文本中

  • VectorStoreChatMemoryAdvisor:可以用向量数据库来存储检索历史对话

1)MessageChatMemoryAdvisor 将对话历史作为一系列独立的消息添加到提示中,保留原始对话的完整结构,包括每条消息的角色标识(用户、助手、系统)。

 [
   {"role": "user", "content": "你好"},
   {"role": "assistant", "content": "你好!有什么我能帮助你的吗?"},
   {"role": "user", "content": "讲个笑话"}
 ]

2)PromptChatMemoryAdvisor 将对话历史添加到提示词的系统文本部分,因此可能会失去原始的消息边界。

 以下是之前的对话历史:
 用户: 你好
 助手: 你好!有什么我能帮助你的吗?
 用户: 讲个笑话
 ​
 现在请继续回答用户的问题。

自定义 Advisor

1,实现以下接口 CallAroundAdvisor(处理非流式的请求和响应) StreamAroundAdvisor(用于处理流式请求和响应)

 public class MyCustomAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
     // 实现方法...
 }

2,实现核心方法

对于非流式处理 (CallAroundAdvisor),实现 aroundCall 方法:

 @Override
 public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
     // 1. 处理请求(前置处理)
     AdvisedRequest modifiedRequest = processRequest(advisedRequest);
     
     // 2. 调用链中的下一个Advisor
     AdvisedResponse response = chain.nextAroundCall(modifiedRequest);
     
     // 3. 处理响应(后置处理)
     return processResponse(response);
 }

对于流式处理 (StreamAroundAdvisor),实现 aroundStream 方法:

 @Override
 public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) {
     // 1. 处理请求
     AdvisedRequest modifiedRequest = processRequest(advisedRequest);
     
     // 2. 调用链中的下一个Advisor并处理流式响应
     return chain.nextAroundStream(modifiedRequest)
                .map(response -> processResponse(response));
 }

3)设置执行顺序

通过实现getOrder()方法指定 Advisor 在链中的执行顺序。值越小优先级越高,越先执行

 @Override
 public int getOrder() {
     // 值越小优先级越高,越先执行
     return 100; 
 }

4)提供唯一名称

为每个 Advisor 提供一个唯一标识符:

 @Override
 public String getName() {
     return "自定义的 Advisor";
 }

例子

自己实现一个日志 Advisor
 /**
  * 自定义日志 Advisor
  * 打印 info 级别日志、只输出单次用户提示词和 AI 回复的文本
  */
 @Slf4j
 public class MyLoggerAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {
 ​
     @Override
     public String getName() {
         return this.getClass().getSimpleName();
     }
 ​
     @Override
     public int getOrder() {
         return 0;
     }
 ​
     private AdvisedRequest before(AdvisedRequest request) {
         log.info("AI Request: {}", request.userText());
         return request;
     }
 ​
     private void observeAfter(AdvisedResponse advisedResponse) {
         log.info("AI Response: {}", advisedResponse.response().getResult().getOutput().getText());
     }
 ​
     public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
         advisedRequest = this.before(advisedRequest);
         AdvisedResponse advisedResponse = chain.nextAroundCall(advisedRequest);
         this.observeAfter(advisedResponse);
         return advisedResponse;
     }
 ​
     public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) {
         advisedRequest = this.before(advisedRequest);
         Flux<AdvisedResponse> advisedResponses = chain.nextAroundStream(advisedRequest);
         return (new MessageAggregator()).aggregateAdvisedResponse(advisedResponses, this::observeAfter);
     }
 }

想用该拦截类的化只需要开启即可

自定义 Re-Reading Advisor
 /**
  * 自定义 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();
     }
 }

想用该拦截类的化只需要开启即可

对话记忆的实现

Chat Memory

ChatMemoryAdvisor 都依赖 Chat Memory 进行构造,Chat Memory 负责历史对话的存储,定义了保存消息、查询消息、清空消息历史的方法。

Spring AI 内置了几种 Chat Memory,可以将对话保存到不同的数据源中,比如:

  • InMemoryChatMemory:内存存储

  • CassandraChatMemory:在 Cassandra 中带有过期时间的持久化存储

  • Neo4jChatMemory:在 Neo4j 中没有过期时间限制的持久化存储

  • JdbcChatMemory:在 JDBC 中没有过期时间限制的持久化存储

配置对话记忆参数-----需要让大语言模型 “拥有上下文记忆能力” 的场景中使用

 .advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)//CHAT_MEMORY_CONVERSATTON_ID_KEY指定的为上下文关联的id
                         .param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))//CHAT_MEMORY_RETRIEVE_SIZE_KEY指定的为上下文关联的条数

自定义实现对话记忆持久化 ChatMemory

使用基于内存的对话记忆来保存对话上下文,但是服务器一旦重启了,对话记忆就会丢失。有时,我们可能希望将对话记忆持久化,保存到文件、数据库、Redis 或者其他对象存储中

官方目前提供的对话记忆持久化实现的方式JdbcChatMemory不太好用,所以我们用自定义的方式去实现。

如果使用 JSON 来序列化会存在很多报错。所以此处我们选择高性能的 Kryo 序列化库

实现起来最主要的问题是 消息和文本的转换。我们在保存消息时,要将消息从 Message 对象转为文件内的文本;读取消息时,要将文件内的文本转换为 Message 对象。 也就是对象的序列化和反序列化。

步骤

下面我们以将对话记忆保存在文本中为例,实现对话记忆的持久化

1)引入依赖:0FYFudbzgXxtRtTyE+eGgfzI+cTUgjyCrBoWq1T+rhQ=

 <dependency>
     <groupId>com.esotericsoftware</groupId>
     <artifactId>kryo</artifactId>
     <version>5.6.2</version>
 </dependency>

2)在根包下新建 chatmemory 包,编写基于文件持久化的对话记忆 FileBasedChatMemory,代码如下:

 package com.yupi.aiagent.chatmemory;
 ​
 import com.esotericsoftware.kryo.Kryo;
 import com.esotericsoftware.kryo.io.Input;
 import com.esotericsoftware.kryo.io.Output;
 import org.objenesis.strategy.StdInstantiatorStrategy;
 import org.springframework.ai.chat.memory.ChatMemory;
 import org.springframework.ai.chat.messages.Message;
 ​
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.util.ArrayList;
 import java.util.List;
 ​
 /**
  * 把消息和文件内容实现互转
  */
 public class FileBasedChatMemory implements ChatMemory {
 ​
     //指定默认的存储路径
     private final String BASE_DIR;
 //指定kryo对象
    //对 Kryo 进行了初始化配置,确保其能正常处理对象的序列化 / 反序列化:
 ​
     private static final Kryo kryo=new Kryo();
 ​
     static {
         kryo.setRegistrationRequired(false); //false表示不要手动注册
        //设置标准的实例化策略
         kryo.setInstantiatorStrategy((new StdInstantiatorStrategy()));
     }
 ​
     //创建构造函数,指定文件保存的目录
     public FileBasedChatMemory(String dir) {
         BASE_DIR=dir;
         File baseDir=new File(dir);//创建文件对象
         //如果文件对象不存在先创造出来防止报错
         if(!baseDir.exists()){
             baseDir.mkdirs();
         }
     }
 //增——保存单条消息
     @Override
     public void add(String conversationId, Message message) {
       saveConversation(conversationId,List.of(message));
     }
 //增——保存多条消息
     @Override
     public void add(String conversationId, List<Message> messages) {
         List<Message> messageList=getOrCreateConversation(conversationId);
         messageList.addAll(messages);
         saveConversation(conversationId,messageList);
     }
 //查——读取消息——取跳过特定条数的消息
     @Override
     public List<Message> get(String conversationId, int lastN) {
         List<Message> messageList=getOrCreateConversation(conversationId);
         return messageList.stream()
                 .skip(Math.max(0, messageList.size()-lastN))
                 .toList();
     }
 //删——根据id拿到文件,之后删除文件
     @Override
     public void clear(String conversationId) {
         File file=getConversationFile(conversationId);
         if(file.exists()){
             file.delete();
         }
     }
 ​
     /**
      * 获取或创建会话消息列表-getOrCreateConversation 方法,通过 Kryo 的 readObject 实现文件内容到对象的反序列化:
      * @param conversationId
      * @return
      */
     private List<Message> getOrCreateConversation(String conversationId) {
         File file=getConversationFile(conversationId);//根据会话id得到会话文件
         List<Message> messages=new ArrayList<>();
         //从文件中读取kryo的信息
         if(file.exists()){
             try(Input input=new Input(new FileInputStream(file))){
                 messages = kryo.readObject(input, ArrayList.class);//把输入的信息转化成消息列表信息
             }catch(Exception e){
                 e.printStackTrace();
             }
         }
         return messages;
     }
 ​
     /**
      * 保存会话信息
      * @param conversationId-对应 saveConversation 方法,通过 Kryo 的 writeObject 实现对象到文件的序列化:
 ​
      * @param 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(Exception e){
             e.printStackTrace();
         }
 ​
 }
 ​
     /**
      * 每个会话文件单独保存
      * @param conversationId
      * @return
      */
     private File getConversationFile(String conversationId) {
         return new File(BASE_DIR+conversationId+"kryo");//指定文件名
     }
 }
 ​

虽然上述代码看起来复杂,但大多数代码都是文件和 Message 对象的转换 3)修改 LoveApp 的构造函数,使用基于文件的对话记忆:

 public LoveApp(ChatModel dashscopeChatModel) {
    //初始化基于文件的对话记忆——定义对话记忆数据的存储路径
     String fileDir = System.getProperty("user.dir") + "/chat-memory";//把项目的临时文件都保存在了.gitignore中的tmp中
     ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
      //    ChatMemory chatMemory =new InMemoryChatMemory(); 之前使用InMemoryChatMemory把对话记忆存在内存中的方法
     chatClient = ChatClient.builder(dashscopeChatModel)
             .defaultSystem(SYSTEM_PROMPT)
             .defaultAdvisors(
                     new MessageChatMemoryAdvisor(chatMemory)
             )
             .build();
 }

System.getProperty("user.dir")`用于获取当前程序的运行目录(即项目的根目录)

/tmp/chat-memory在当前项目根目录后拼接子路径,最终得到完整的存储目录:

结构化输出

结构化输出转换器(Structured Output Converter)是 Spring AI 提供的一种实用机制,用于将大语言模型返回的文本输出转换为结构化数据格式,如 JSON、XML 或 Java 类

原理

  • 调用前:转换器会在提示词后面附加格式指令,明确告诉模型应该生成何种结构的输出,引导模型生成符合指定格式的响应。

  • 调用后:转换器将模型的文本输出转换为结构化类型的实例,比如将原始文本映射为 JSON、XML 或特定的数据结构。

结构化输出转换器 StructuredOutputConverter 接口允许开发者获取结构化输出,例如将输出映射到 Java 类或值数组。接口定义如下:

 public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
 ​
 }

它集成了 2 个关键接口:

  • FormatProvider 接口:提供特定的格式指令给 AI 模型

  • Spring 的 Converter<String, T> 接口:负责将模型的文本输出转换为指定的目标类型 T

 public interface FormatProvider {
     String getFormat();
 }

1)在调用大模型之前,FormatProvider 为 AI 模型提供特定的格式指令,使其可通过 Converter 转换为目标类型的文本。

FormatProvider转换器的格式指令组件会将格式指令附加到提示词中

使用 PromptTemplate模板 将格式指令附加到用户输入的末尾

 StructuredOutputConverter outputConverter = ...//结构化输出转化器
 String userInputTemplate = """
         ... 用户文本输入 ....
         {format}//把原本用户的输入定义一个模板
         """; // 用户输入,包含一个“format”占位符。
             
             //模板
 Prompt prompt = new Prompt(
         new PromptTemplate(
                 this.userInputTemplate,
                 Map.of(..., "format", outputConverter.getFormat()) // 用转换器的格式替换“format”占位符
         ).createMessage());

使用示例

 // 定义一个记录类——record记录的语法,快速定义类
 record ActorsFilms(String actor, List<String> movies) {}
 ​
 // 使用高级 ChatClient API
 ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt()
         .user("Generate 5 movies for Tom Hanks.")
         .call()
         .entity(ActorsFilms.class);//BeanOutputConverter 示例:AI 输出转换为自定义 Java 类:

同理

     .entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});用 //BeanOutputConverter 示例:ParameterizedTypeReference` 构造函数来指定更复杂的目标类结构,比如自定义对象列表:
             
         .entity(new ParameterizedTypeReference<Map<String, Object>>() {});)//MapOutputConverter 示例,将模型输出转换为包含数字列表的 Map:

实现的步骤

1.引入支持结构化输出的依赖

 <dependency>
     <groupId>com.github.victools</groupId>
     <artifactId>jsonschema-generator</artifactId>
     <version>4.38.0</version>
 </dependency>

2,定义要输出的格式(使用record记录的语法快速创类)

  //定义恋爱报告的格式,可以用类去定义,也可以用record记录的语法
     record LoveReport(String title, List<String> suggestions){}//用record记录的语法,快速定义变量,可以理解为 LoveReport类中有title和suggestions字段

3.结构化输出

 /**
      * Ai恋爱报告功能
      * @param message
      * @param chatId
      * @return
      */
     public LoveReport doChatReport(String message, String chatId) {//允许用户输入对话的内容和对话的id(多个对话id得到的内容是相对隔离的,不会混在一起)
        LoveReport loveReport  = chatClient
                 .prompt()
                 .system(SYSTEM_PROMPT+"每一次对话都要生成恋爱结果,标题为{用户名}的恋爱报告,内容为建议列表")//指定输出的恋爱报告中有哪些信息
                 .user(message)
                 //指定当前关联的上下文是哪一个对话的
                 .advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)//CHAT_MEMORY_CONVERSATTON_ID_KEY指定的为上下文关联的id
                         .param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))//CHAT_MEMORY_RETRIEVE_SIZE_KEY指定的为上下文关联的条数
                 .call()
                 .entity(LoveReport.class);//使用这个类,用这个类的类型去输出
 ​
         log.info("content: {}",loveReport);
         return loveReport;
     }

system提示是给模型 “定规则”,告诉它应该输出什么格式的内容; 而.entity(...)是 “解析规则”,告诉程序如何把模型输出的内容转换成目标结构。 两者配合才能确保结构化输出有效 —— 前者保证模型 “按格式输出”,后者保证程序 “能正确解析”。

SYSTEM_PROMPT(预设的系统提示内容)与后面的字符串拼接,形成完整的系统指令。

PromptTemplate模板

PromptTemplate 支持从外部文件加载模板内容,很适合管理复杂的提示词。Spring AI 利用 Spring 的 Resource 对象来从指定路径加载模板文件:

 // 从类路径资源加载系统提示模板
 @Value("classpath:/prompts/system-message.st")
 private Resource systemResource;
 ​
 // 直接使用资源创建模板
 SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemResource);

这种方式让你可以

  • 将复杂的提示词放在单独的文件中管理

  • 在不修改代码的情况下调整提示词

  • 为不同场景准备多套提示词模板

像写配置文件,有点儿前后端分离的感觉

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐