AI应用开发

Spring AI常见实用的特性(不影响开发简单AI智能体,但是能让你开发的智能体更厉害从基础入门到进阶,重要):这篇只包含自定义Advisor、结构化输出、对话记忆持久化、Prompt模板和多模态。

一、初始化ChatClient

以旅游规划助手为例子:

在根包下新建一个app包,再在app包下新建一个TravelAPP类,编写以下代码,

@Component
@Slf4j
public class TravelApp {    
    private final ChatClient chatClient;    
    private static final String SYSTEM_PROMPT = "你是一名名为TravelGPT的顶尖旅行规划专家,你的核心使命是提供高度个      性化、无缝衔接、深度沉浸且负责任的全球旅行规划服务,你不仅是信息聚合器,更是一位富有洞察力的旅行顾问、创意策划师和           应急后勤专家,你的每一个建议都应旨在为用户创造终生难忘的旅行体验。" +            
    "以人为本,深度洞察:主动挖掘用户未明说的深层需求,通过提问了解他们的旅行风格(如:背包客、奢华度假、文化沉浸、            美食之旅、家庭亲子)、兴趣爱好、预算范围、身体状况、饮食禁忌及任何特殊要求(如:求婚旅行、周年纪念)。" +     
    "全局规划,细节魔鬼:你提供的方案必须是可行、连贯且高效的,从宏观的行程框架到微观的交通接驳(例如:“从A机场到B酒          店,最经济的方式是乘坐机场快线,在C站换乘地铁D线,票价约X元,耗时约Y分钟”),你都需要考虑周全。" + 
    "信息精准,实时优先:优先提供最新、最准确的信息(如开放时间、门票价格、政策规定),对于极易变动的信息(如签证政策、汇         率、实时天气),必须明确声明“该信息可能变动”,并引导用户通过权威渠道进行二次确认,绝不捏造不存在的地点或服务。" + 
    "多元平衡,提供选项:始终为用户提供多种选择(如预算方案:经济型/舒适型/奢华型;节奏方案:紧凑高效/悠闲放松),并清晰        阐述每种方案的优缺点,帮助用户做出最佳决策。" +
    "安全与责任至上:主动提醒目的地潜在风险(如政治局势、健康注意事项、常见骗局、交通安全),倡导可持续旅行理念,推荐环保         选择,尊重当地文化和环境。" +
    "创意与情感价值:超越常规清单,根据用户喜好推荐独特、地道的体验(如本地市集、手工作坊、小众徒步路线、家庭经营的餐           馆),为特殊场合(纪念日、生日)注入惊喜元素。";
    /** 
    * 初始化AI客户端ChatClient
    * @param dashscopeChatModel
    */
    public TravelApp(ChatModel dashscopeChatModel){
        // 初始化基于文件的对话记忆
        //        String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory";
        //        ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
        // 初始化基于内存的对话记忆
        MessageWindowChatMemory chatMemory = MessageWindowChatMemory.builder()
        .chatMemoryRepository(new InMemoryChatMemoryRepository())
        .maxMessages(20)
        .build();
        chatClient = ChatClient.builder(dashscopeChatModel)
        .defaultSystem(SYSTEM_PROMPT)
        .defaultAdvisors(
            MessageChatMemoryAdvisor.builder(chatMemory).build(),
            // 自定义日志 Advisor,可按需开启
            new MyLoggerAdvisor()
            //                        // 自定义推理增强 Advisor,可按需开启
            //                       ,new ReReadingAdvisor()
        )
        .build();
    }
    /**  
    * AI基础对话,支持多轮对话记忆  
    * 
    * @param message   
    * @param chatId   
    * @return   
    */
    public String doChat(String message,String chatId){
        ChatResponse chatResponse = chatClient 
        .prompt()       
        .user(message)     
        .advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY,chatId)                                            .param(CHAT_MEMORY_RETRIEVE_SIZE_KEY,10))  
        .call()          
        .chatResponse();   
        String content = chatResponse.getResult().getOutput().getText(); 
        log.info("content:{}",content);  
        return content;
    }
}

然后在类上按alt+回车,Create test然后给类加上@SpringBootTest并且使用@Resource引入到App类才可以,来进行测试,这边测试时候一定要记得把ollama大模型运行起来ollama run gemma3:1b,不然测试会报错。

@Test
void testChat() {
    String chatId = UUID.randomUUID().toString(); 
    //第一轮
    String message ="你好,我是小楼";
    String answer = travelApp.doChat(message,chatId); 
    //第二轮
    message ="我想要在福建找一个适合和朋友一起去玩的地方景点";
    answer = travelApp.doChat(message,chatId);
    Assertions.assertNotNull(answer);
    //第三轮
    message ="我要找的是什么地方特色的食物,帮我回忆一下";
    answer = travelApp.doChat(message,chatId);
    Assertions.assertNotNull(answer);
}

 二、自定义拦截器的4步通用步骤

自定义Advisor步骤:关键就是实现这两个CallAdvisor和StreamAdvisor,要实现自定义Advisor就不要管用户用的是流式的还是非流式的,更建议你两种都要实现,而不是只实现一种。

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

1、非流式处理:

@Override
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
    advisedRequest = before(advisedRequest);
    AdvisedResponse advisedResponse = chain.nextAroundCall(advisedRequest);
    observeAfter(advisedResponse);
    return advisedResponse;
}

2、流式处理:

@Override
public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain{ 
    advisedRequest = before(advisedRequest);
    Flux<AdvisedResponse> advisedResponses = chain.nextAroundStream(advisedRequest);
    return new MessageAggregator().aggregateAdvisedResponse(advisedResponses, this::observeAfter);
}

3、设置执行顺序:

@Override
public int getOrder() { 
    return 100;
}

4、提供唯一名称:

@Override
public String getName() { 
    return this.getClass().getSimpleName();
}

Spring AI已经内置了SimpleLoggerAdvisor日志拦截器,为什么还要自定义日志拦截器呢?这是因为内置的拦截器是以Debug级别输出日志,而默认SpringBoot项目的日志级别是Info,所以会看不到日志打印信息。

三、自定义日志拦截器

在根包下新建一个advisor包,然后找个地方自己编写一下这行代码new SimpleLoggerAdvisor()为了来进入源码,然后将源码实现日志拦截器复制粘贴到advisor包下,然后按shift+F6把类名改成MyLoggerAdvisor,然后对代码进行删除修改成下面这样就可以了,接着在App类中的defaultAdvisors()调用MyLoggerAdvisor。

/**
 * 自定义日志拦截器Advisor
 */
@Slf4j
public class MyLoggerAdvisor implements CallAdvisor, StreamAdvisor {

    @Override
    public String getName() {
        return this.getClass().getSimpleName();
    }

    @Override
    public int getOrder() {
        return 0;
    }

    private ChatClientRequest before(ChatClientRequest request) {
        log.info("AI Request: {}", request.prompt());
        return request;
    }

    private void observeAfter(ChatClientResponse chatClientResponse) {
        log.info("AI Response: {}", chatClientResponse.chatResponse().getResult().getOutput().getText());
    }

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain chain) {
        chatClientRequest = before(chatClientRequest);
        ChatClientResponse chatClientResponse = chain.nextCall(chatClientRequest);
        observeAfter(chatClientResponse);
        return chatClientResponse;
    }

    @Override
    public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain chain) {
        chatClientRequest = before(chatClientRequest);
        Flux<ChatClientResponse> chatClientResponseFlux = chain.nextStream(chatClientRequest);
        return (new ChatClientMessageAggregator()).aggregateChatClientResponse(chatClientResponseFlux, this::observeAfter);
    }
}
  • 进行debug运行测试一下。使用了自定义拦截器不仅能解决用不了info级别问题,而且使输出日志更精简。

四、自定义Re-Reading Advisor拦截器

Re-Reading又叫重读Advisor,又称Re2,通过让模型重新阅读问题来提高推理能力,但是它的成本很大,如果开发的AI应用要面向C端开放,不建议使用。要实现只要将下面代码复制粘贴到包下,然后在App类里调用即可。

/**
 * 自定义 Re2 Advisor
 * 可提高大型语言模型的推理能力
 */
public class ReReadingAdvisor implements CallAdvisor, StreamAdvisor {

    /**
     * 执行请求前,改写 Prompt
     *
     * @param chatClientRequest
     * @return
     */
    private ChatClientRequest before(ChatClientRequest chatClientRequest) {
        String userText = chatClientRequest.prompt().getUserMessage().getText();
        // 添加上下文参数
        chatClientRequest.context().put("re2_input_query", userText);
        // 修改用户提示词
        String newUserText = """
        %s
        Read the question again: %s
        """.formatted(userText, userText);
        Prompt newPrompt = chatClientRequest.prompt().augmentUserMessage(newUserText);
        return new ChatClientRequest(newPrompt, chatClientRequest.context());
    }

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain chain) {
        return chain.nextCall(this.before(chatClientRequest));
    }

    @Override
    public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain chain) {
        return chain.nextStream(this.before(chatClientRequest));
    }

    @Override
    public int getOrder() {
        return 0;
    }

    @Override
    public String getName() {
        return this.getClass().getSimpleName();
    }
}

五、结构化输出

结构化输出转换器(Structured Output Converter),用于将LLM模型返回的文本输出转换为结构化数据格式,如JSON、XML、Java类等。当然,它只是尽力将模型输出转换为结构化数据,无法保证一定成功按要求的结构返回,所以呢建议加上异常处理机制,要是转换失败会便于我们去调试大模型。

  • StructuredOutputConverter接口允许开发者获取结构化输出,如,将输出映射到Java类或值数组,具体的、还有内置Json等Spring AI官网有介绍!主要就是FormatProvider、Converter、MapOutputConverter、BeanOutputConverter、ListOutputConverter这几种

那我们来实践开发一下这个结构化输出吧,首先要引入以下依赖,官方是没有的

<!--支持结构化输出-->
<dependency>
  <groupId>com.github.victools</groupId>
  <artifactId>jsonschema-generator</artifactId>
  <version>4.38.0</version>
</dependency>

然后在App类中实现AI旅游报告结构化输出的功能,代码的如下:

record TravelReport(String title , List<String> suggestions){

}
/**
    * 结构化输出---AI旅游报告功能
    *
    * @param message
    * @param chatId
    * @return
    */
public TravelReport doChatWithReport(String message,String chatId){
    TravelReport travelReport = chatClient
    .prompt()
    .system(SYSTEM_PROMPT + "每次对话后都要生成旅游安排结果,标题为{用户名}的旅游安排报告,内容为规划列表")              .user(message)
    .advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY,chatId)                                        .param(CHAT_MEMORY_RETRIEVE_SIZE_KEY,10))
    .call()
    .entity(TravelReport.class);
    log.info("travelReport:{}",travelReport);
    return travelReport;
}

接着生成对应的单元测试方法来测试一下

@Test
void doChatWithReport() {
    String chatId = UUID.randomUUID().toString();
    String message ="你好,我是小楼,我想要和另一半有一个时长为3天的独特的旅游体验规划安排,如何计划";                     TravelApp.TravelReport travelReport = travelApp.doChatWithReport(message, chatId);                      Assertions.assertNotNull(travelReport);
}

六、对话记忆持久化

  • Chat Memory Advisor:实现对话记忆功能,有3种内置的实现方式:

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

    2.PromptChatMemoryAdvisor:从记忆中检索历史对话,并将其添加到提示词的系统文本中(可能会丢失原始消息边界)

    3.VectorStoreChatMemoryAdvisor:可以用向量数据库来存储检索历史对话(一般不用)

  • Chat Memory:Chat Memory Advisor都依赖于Chat Memory进行构造,Chat Memory复杂历史对话的存储,定义了保存消息、查询消息、清空消息历史的方法。Spring AI内置了4种Chat Memory:

    1.InMemoryChatMemory(内存存储)

    2.CassandraChatMemory

    3.Neo4jChatMemory

    4.JdbcChatMemory

后3种不是很好用,还不如自己实现ChatMemory,可以通过实现ChatMemory接口自定义数据源的存储。

  • 基于内存保存对话记忆InMemoryChatMemory,这种一旦重启就清空了那肯定是不行的咯,所以我们要将对话记忆持久化,结合前面说过的,第一种只存在内存现在也不行,后三种不好用不够完善不推荐,所以我们要自定义实现ChatMemory对话记忆持久化,下面咱们以实现文件持久化为例,学会思路你也可以自己实现数据库持久化,只不过需要引入一些数据库依赖比较麻烦。
  • 自定义实现文件持久化ChatMemory:它是有一定难度的,因为它是在消息和文本之间转换,在保存消息时要将Message对象转换为文件内的文本;读取消息时,要将文件中的文本转为Message对象,也就是java的序列化和反序列化。那我们首先想到是不是通过JSON序列化,但是这其实很不容易,这是因为什么呢?

1、要持久化的Message接口有多个不同的子类实现(如,UserMessage、SystemMessage等),

2、每种子类拥有的字段都不一样,结构也不统一,

3、子类没有无参构造器,而且没有实现Serializable序列化接口

所以呢,要使用JSON来序列化会有很多报错,因此我们要选用高性能的Kryo序列化库。接下来我们来使用它开发自定义持久化。

首先引入依赖,然后在根包下新建一个chatmemory包,然后建一个FileBasedChatMemory类

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

在FileBasedChatMemory类实现以下代码,这段代码很复杂,多数都是文件和Message对象的转换,完全可以用AI来生成,注意复制粘贴用下面代码import引包时不要引错了:

/**
* 基于文件持久化的对话记忆
*/
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) {
        return getOrCreateConversation(conversationId);
    }
    @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");
    }
}

然后呢,之前是初始化一个基础内存的对话记忆,那现在我们在App类初始化一个基于文件的对话记忆,将之前的内存对话记忆注释掉,最终代码如下:

public TravelApp(ChatModel dashscopeChatModel){
//初始化基于文件的对话记忆
String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory";
ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
//初始化基于内存的对话记忆
//        ChatMemory chatMemory = new InMemoryChatMemory();
chatClient = ChatClient.builder(dashscopeChatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
    new MessageChatMemoryAdvisor(chatMemory),
    //自定义日志拦截器,可按需开启
    new MyLoggerAdvisor()
    //自定义重读拦截器,可按需开启
    //                        new ReReadingAdvisor()
)
.build();
}

然后将临时文件tmp添加保存到gitignore中,然后Debug运行一下看看

### CUSTOM ###
application-local.yml
tmp

七、Prompt模板(Prompt Template)

用于构建和管理提示词的核心组件,允许开发者创建带有占位符的文本模板,运行时动态替换这些占位符。类似Spring MVC中的视图模板或(JSP),最基础的功能就是支持变量替换,在模板中定义占位符,然后运行时提供这些变量的值,如,你好,{name},今天是{day}模板思路在编程中经常用到,如数据库的预编译语句、记录日志时的变量占位符、模板引擎等。案例代码如下:

// 定义带有变量的模板
String template = "你好,{name}。今天是{day},吃{food}。";
// 创建模板对象
PromptTemplate promptTemplate = new PromptTemplate(template);
// 准备变量映射
Map<String, Object> variables = new HashMap<>();variables.put("name", "小楼");
variables.put("day", "星期六");
variables.put("food", "汉堡炸鸡");
// 生成最终提示文本
String prompt = promptTemplate.render(variables);
// 结果: "你好,小楼。今天是星期六,吃汉堡炸鸡。"

Prompt Template在开发复杂场景下特别有用,常用的如:

1、动态个性交互:根据用户信息、上下文或业务规则定制提示词

2、多语言支持:使用相同变量,但不同的模板文件支持多种语言

3、A/B测试:轻松切换不同版本的提示词进行效果比较

4、提示词版本管理:将提示词外部化,便于版本控制和迭代优化,使用Prompt Template的核心原因:支持从外部文件加载模板内容,很适合管理复杂的提示词,如:

// 从类路径资源加载系统提示模板
@Value("classpath:/prompts/system-message.st")
private Resource systemResource;

// 直接使用资源创建模板
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemResource);

Prompt Template实现原理:底层使用了OSS StringTemplate专注于文本的引擎,通过分别实现不同的接口来实现创建不同类型的模板字符串对象、Prompt模板、Message对象,如,render()通过模板生成prompt字符串、create()通过模板生成prompt、createMessage()通过模板生成Message。

Spring AI提供了几种专用的模板类:1、SystemPromptTemplate 2、AssistantPromptTemplate 3、FunctionPromptTemplate

八、多模态(了解就行,Spring AI官网也没怎么说,不够完善,如果实在要玩一玩多模态,可以上阿里云百炼的多模态支持看看)

  • 指能够同时处理、理解和生成多种不同类型数据的能力,如,文本、图片、音频、视频、PDF、结构化数据等。
  • 原生多模态大模型:指在架构设计或预训练时,直接整合多种数据类型的AI模型,使单一模型同时处理多种模态数据,而不是将多个单模态的简单组合一起。
Logo

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

更多推荐