找回密码
 立即注册
首页 业界区 业界 Spring-AI 高阶实战: 基于策略模式的大模型聊天应用架构 ...

Spring-AI 高阶实战: 基于策略模式的大模型聊天应用架构设计与实现

诘琅 2025-6-3 10:42:18
代码示例

https://github.com/aurora-ultra/aurora-spring-ai
概要

本文聚焦如何使用spring-AI来开发大模型应用一些进阶技能,包含一套可落地的技术设计模式,读完你将会学习到:

  • 如何使用Spring-AI 开发大模型对话应用
  • 如何综合设计一套适用Spring-ai的代码结构,为应用提供更好的扩展能力
本文假设读者已经熟悉spring-ai的基本功能以及大模型开发的入门知识,如果你还不熟悉这些基础知识,可以找我仔细学习。
开发目标

我们会简单的模拟豆包的业务模型,开发一个用户与大模型对话的应用程序,我们会从领域模型开始设计,一直到应用模型和应用实现。
由于篇幅有限,我们不展开细节完成每一个功能,这里只介绍核心领域建模和应用的开发模式。
我们将会聚焦一次对话的处理流程,如下图所示:
1.png


  • 本地工具集也就是function calling 可以随时添加,删除,并且根据对话上下文动态抉择
  • 向量数据库搜索可以根据对话上下文选择是否使用,甚至提供多个选择
# 设计领域模型

2.png
3.png


  • Agent 表示一个大模型agent,包括大模型的命名,SystemPrompt,所属用户等
  • Conversation 表示一次对话
  • User 表示正在使用系统的用户
  • ChatMessage表示一个对话消息,一个对话消息由多个内容组成,因为一次对话可以发送包括文本和媒体多条具体内容。
至此,我们简单模拟了豆包的领域模型
4.png

设计应用模型

  既然我们在最开始设计了领域模型,我么也很自然的会设计应用模型,首先应用模型需要一个聚合根,用来表示一次对话的处理环境,我们称之为上下文,然后每次对话会包含很多关键元素,比如用户,模型,时间等,其中还有一个就是本次对话的配置选项,因为在与大模型交互的时候,其实我们难免有一些设置项,比如跟哪个模型对话,是否开启互联网锁搜等。
 
首先设计一个 ChatContext类,用来表示一次对话的上下文核心,这里我们分析如下:

  • 对话上下文包含 when,who,what,where,how 五种元素, 这本纸上就是一个5w2h的分析,只不过没有why和how much, 很明显,why 和how muc事根据需求来的,这里我们先不设计。

    • When - 用户发送消息的时间
    • Who - 发送消息的用户
    • What - 用户发送发的消息
    • Where - 用户处于哪一个对话
    • How - 本次对话有哪些配置选项

  • 对话上下文可以配置标记属性,以便在不同功能之间传递消息,这点类似Servlet技术中方的ServletRequest#getAttribute
  • 对话上下文是只读的,不允许修改@Getter
  1. @Builder
  2. public class ChatContext {
  3.         // when                who                what                        where                        how
  4.         // -------------------------------------------------------------
  5.         // now                user         userMessage                conversation        chatOption
  6.         private final Map<String, Object> attributes = new HashMap<>();
  7.         private final User user;
  8.         private final UserMessage userMessage;
  9.         private final ChatOption chatOption;
  10.         private final Conversation conversation;
  11.         public void setAttribute(String key, Object value) {
  12.                 attributes.put(key, value);
  13.         }
  14.         public Object getAttribute(String key) {
  15.                 return attributes.get(key);
  16.         }
  17.         @SuppressWarnings("unchecked")
  18.         public <T> T getAttribute(String key, Class<T> ignored) {
  19.                 return (T) attributes.get(key);
  20.         }
  21. }
复制代码
接着,我们设计一个用户描述本次对话的功能选项,我们希望如下

  • 系统可以配置本次对话是否启用某一种功能,比如内部文档搜索/互联网资料搜索/是否带有记忆功能/是否开启调试模式等等
  • 用户可以选择跟不同的模型对话;
  • 某些功能有特殊的配置
  1. @Getter
  2. @Builder
  3. @RequiredArgsConstructor
  4. public class ChatOption implements Serializable {
  5.     private final boolean enableInternalSearch;
  6.     private final boolean enableExternalSearch;
  7.     private final boolean enableExampleTools;
  8.     private final boolean enableMemory;
  9.     private final boolean enableDebug;
  10.     private final int retrieveTopK;
  11.     private final String model;
  12. }
复制代码
至此,我们有了可用的对话上下文,可以围绕这个上下文开发对话逻辑了。
设计应用逻辑

首先我们来设计应用的扩展点,其实本质上应该是先设计应用逻辑,再进行重构设计扩展点,但是这里为了行文方便,直接展示下扩展点,免去重构的过程,请读者注意,真实开发的时候不可能一开始就想得到哪些地方需要扩展,一定是先做出基础逻辑,再重构出扩展点点,我们先来分析一下可扩展的点:

  • 对话模型可以切换,系统将会根据上下文推断出本次要使用的模型。
  • 本地方法可以随时增加删除,系统会很久本次上下文推断出需要调用的本地工具。
  • 其他spring-ai框架的的Advisor也可能根据一次对话的上下文被推断出。
由此可见对话上下文是整个应用的重点,所有的功能是否被使用都围绕着这个上下文,并且这些功能在运行的时候会根据上下文动态提供出来,不难看出,这是一个策略模式,于是我们设计如下接口:
  1. public interface ChatAdvisorSupplier {
  2.     boolean support(ChatContext context);
  3.     Advisor getAdvisor(ChatContext context);
  4. }
  5. public interface ChatClientSupplier {
  6.     boolean support(ChatContext context);
  7.     ChatClient getChatClient(ChatContext context);
  8. }
  9. public interface ChatTool {
  10.     String getName();
  11.     String getDescription();
  12. }
  13. public interface ChatToolSupplier {
  14.     boolean support(ChatContext context);
  15.     ChatTool getTool(ChatContext context);
  16. }
复制代码

  • ChatAdvisorSupplier 用来为本次对话提供spring-ai的Advisor
  • ChatClientSupplier 会根据本地对话提供可用的模型client
  • ChatTool 用来表示一个包含本地放的的类,提供了name和desc两个属性,用来让大模型帮我们判断哪些工具在本次对话需要被使用到
  • ChatToolSupplier则会根据当前对话给出哪些本地工具会被使用到。
  几乎每一个接口都有2个方法,一个support,一个getXxx,support用于判断当前的能力是否启用,如果放回true,则表示当前上下文需要这个能力,如果返回false,则当前对话不需要这个能力,这是一个非常典型的策略模式,在spring框架中几乎随处可见。
  下面我们将这些组件串联起来,这样一来,我们的核心交互流程不变,而具体交互流程在策略器中可随时动态增减,当我们开始处理一个对话上下文的时候,首先根据对话上下文找到适合的模型,工具等,这些具体功能由一个个的supplier提供,每个supplier都会根据对话上下文给出自己是否适用,如果适用,我们就让这个supplier提供他的能力,看上去就像下面这样:
5.png

实现应用逻辑

  有了上面的接口,我们实现的应用逻辑就简单起来了,只要将接口的调用编排起来就行,之所以设计接口和调用者的好处,就是以后这个应用的核心逻辑应该会很少变动,不论增加什么功能,几乎这个核心逻辑都不需要做什么改动,这就是所谓的高内聚,低耦合,面向扩展开放,面向修改关闭
  试想一下,如果有一天新增了需求,那么大概率是需要新增某种工具调用,某种advisor的调用,这些都不影响你的核心逻辑,我们只需要新增一个实现,或者修改现有的一个实现。
  我们简单来分析一下这个应用逻辑,他需要接受到对话命令ChatCommand,然后组装出对话上下文ChatContext,接着根据对话上下文找到适合的client, tools,advisors,还要从上下文找出本次要发送给大模型的对话消息,最后将大模型返回的消息包装成我们自己的数据结构(ChatReply)返回就行了
  我们来看一下ChatService是如何被实现的。
  1. @Slf4j
  2. @Service
  3. @RequiredArgsConstructor
  4. public class ChatService {
  5.         public static final int CHAT_RESPONSE_BUFFER_SIZE = 24;
  6.         public static final String CHAT_TOOLS_CHOSEN_MODEL = "gpt-3.5-turbo";
  7.         private final ChatManager chatManager;
  8.         private final List<ChatToolSupplier> chatToolSuppliers;
  9.         private final List<ChatClientSupplier> chatClientSuppliers;
  10.         private final List<ChatAdvisorSupplier> chatAdvisorSuppliers;
  11.         public Conversation startConversation(ConversationStartCommand command) {
  12.                 // todo implement this method
  13.                 throw new NotImplementedException();
  14.         }
  15.         public ChatReply chat(ChatCommand command) throws ChatException {
  16.                 try {
  17.                         var user = User.mock();
  18.                         var chatOption = command.getOption();
  19.                         var conversation = getConversation(command.getConversationId());
  20.                         var userMessage = createUserMessage(command);
  21.                         var context = ChatContext.builder()
  22.                                         .user(user)
  23.                                         .userMessage(userMessage)
  24.                                         .chatOption(chatOption)
  25.                                         .conversation(conversation)
  26.                                         .build();
  27.                         return this.chat(context);
  28.                 } catch (Exception e) {
  29.                         throw ChatException.of("Something wrong when processing the chat command", e);
  30.                 }
  31.         }
  32.         private ChatReply chat(ChatContext context) throws ChatException {
  33.                 var tools = getTools(context);
  34.                 var advisors = getAdvisors(context);
  35.                 var chatClient = getChatClient(context);
  36.                 var conversation = context.getConversation();
  37.                 var userMessage = context.getUserMessage();
  38.                 var contents = chatClient
  39.                                 .prompt()
  40.                                 .advisors(advisors)
  41.                                 .messages(conversation.createPromptMessages())
  42.                                 .messages(userMessage)
  43.                                 .toolCallbacks(ToolCallbacks.from(tools.toArray()))
  44.                                 .toolContext(context.getAttributes())
  45.                                 .stream()
  46.                                 .content()
  47.                                 .buffer(CHAT_RESPONSE_BUFFER_SIZE)
  48.                                 .map(strings -> String.join("", strings));
  49.                 return ChatReply.builder()
  50.                                 .contents(contents)
  51.                                 .build();
  52.         }
  53.         private UserMessage createUserMessage(ChatCommand command) {
  54.                 return new UserMessage(command.getContent());
  55.         }
  56.         private Conversation getConversation(String conversationId) {
  57.                 return chatManager.getOrCreateConversation(conversationId);
  58.         }
  59.         private List getAdvisors(ChatContext context) {
  60.                 return chatAdvisorSuppliers
  61.                                 .stream()
  62.                                 .filter(chatAdvisorSupplier -> chatAdvisorSupplier.support(context))
  63.                                 .map(chatAdvisorSupplier -> chatAdvisorSupplier.getAdvisor(context))
  64.                                 .toList();
  65.         }
  66.         private ChatClient getChatClient(ChatContext context) throws ChatException {
  67.                 return chatClientSuppliers
  68.                                 .stream()
  69.                                 .filter(chatAdvisorSupplier -> chatAdvisorSupplier.support(context))
  70.                                 .map(chatAdvisorSupplier -> chatAdvisorSupplier.getChatClient(context))
  71.                                 .findFirst()
  72.                                 .orElseThrow(() -> ChatException.of("unknown how to create the chat client, maybe you need to add a chat client supplier?"));
  73.         }
  74.         private List<ChatTool> getTools(ChatContext context) throws ChatException {
  75.                 var tools = chatToolSuppliers
  76.                                 .stream()
  77.                                 .filter(supplier -> supplier.support(context))
  78.                                 .map(supplier -> supplier.getTool(context))
  79.                                 .toList();
  80.                 if (tools.isEmpty()) {
  81.                         return tools;
  82.                 }
  83.                 var toolDescription = tools.stream()
  84.                                 .map(chatTool -> String.format("- %s: %s", chatTool.getName(), chatTool.getDescription()))
  85.                                 .collect(Collectors.joining("\n"));
  86.                 var systemPrompt = "You will determine what tools to use based on the user's problem." +
  87.                                 "Please directly reply the tool names with delimiters ',' and reply empty if no tools is usable " +
  88.                                 "Reply example: tool1,tool2." +
  89.                                 "The tools are: \n" +
  90.                                 toolDescription;
  91.                 var toolsDecision = getChatClient(context)
  92.                                 .prompt()
  93.                                 .options(ChatOptions.builder()
  94.                                                 .model(CHAT_TOOLS_CHOSEN_MODEL)
  95.                                                 .build())
  96.                                 .system(systemPrompt)
  97.                                 .messages(context.getUserMessage())
  98.                                 .call()
  99.                                 .content();
  100.                 if (StringUtils.isBlank(toolsDecision)) {
  101.                         return new ArrayList<>();
  102.                 }
  103.                 var chosen = Arrays.asList(toolsDecision.split(","));
  104.                 tools = tools.stream()
  105.                                 .filter(chatTool -> chosen.contains(chatTool.getName()))
  106.                                 .toList();
  107.                 log.info("tools chosen: {}", tools.stream().map(ChatTool::getName).collect(Collectors.toSet()));
  108.                 return tools;
  109.         }
  110. }
复制代码
6.gif


  • 首先ChatService注入了所有的ChatToolSupplier,ChatClientSupplier,ChatAdvisorSupplier接口实例;
  • 当处理ChatCommand的时候,组装出ChatContext;
  • 然后调用一系列的get方法读取相关的策略
  • 最后调用大模型client与之交互
  其中getTools方法相对比较复杂,它先列出了所有的本地工具,然后将用户对话和本地工具描述一起交给了大模型,大模型告诉本地应用那一套functions更适合处理这个问题,然后菜返回本地工具集。之所以这么做,是因为(例如)openai官网明确说明,建议一次对话functions不要太多,最好不要超过20个,因为更多的functions意味着更多的token,也意味着更多的处理时间,而且也没有必要,所以我们选择轻量级的模型gpt3.5来处理工具集的选择,在缩小了工具集之后再与大模型交互。
为应用增加RAG功能

  有了ChatAdvisorSupplier这个接口,我们可以轻易的为应用逻辑增加RAG的功能。在Spring-AI(1.0.0-M8)中,RAG作为一个Advisor被实现,期内部原理就是将用户关键字输入到向量数据局进行搜索,搜索到结果之后组成上下文一起发送给大模型。
  我们已经定义了ChatAdvisorSupplier,所以这里实现这个接口,然后判断support的逻辑也很简单,只要开启了内部搜索,并且没有开启外部搜索,则为本次对话增加rag的能力。
  之所以与外部搜索互斥,是这个例子的设计,并没有什么特殊原因,在你自己的应用中需要有自己的启用策略。
  1. @Slf4j
  2. @Component
  3. @RequiredArgsConstructor
  4. public class InternalSearchAdvisorSupplier implements ChatAdvisorSupplier {
  5.         private final static int DEFAULT_TOP_K = 3;
  6.         private final VectorStore vectorStore;
  7.         private final static PromptTemplate USER_TEXT_ADVISE = PromptTemplate.builder()
  8.                         .template("""
  9.                                         上下文信息如下,用 --------------------- 包围
  10.                                        
  11.                                         ---------------------
  12.                                         {question_answer_context}
  13.                                         ---------------------
  14.                                        
  15.                                         根据上下文和提供的历史信息(而非先验知识)回复用户问题。如果答案不在上下文中,请告知用户你无法回答该问题。
  16.                                         """)
  17.                         .build();
  18.         @Override
  19.         public boolean support(ChatContext context) {
  20.                 return context.getChatOption().isEnableInternalSearch()
  21.                                 && !context.getChatOption().isEnableExternalSearch();
  22.         }
  23.         @Override
  24.         public Advisor getAdvisor(ChatContext context) {
  25.                 return QuestionAnswerAdvisor.builder(vectorStore)
  26.                                 .searchRequest(
  27.                                                 SearchRequest.builder()
  28.                                                                 .topK(NumberUtils.max(context.getChatOption().getRetrieveTopK(), DEFAULT_TOP_K))
  29.                                                                 .build()
  30.                                 )
  31.                                 .promptTemplate(USER_TEXT_ADVISE)
  32.                                 .build();
  33.         }
  34. }
复制代码
 
为应用增加一组Function Calling

我们写一个示例的Tool,提供function calling的功能
  1. @Slf4j
  2. @Component
  3. public class ExampleTool implements ChatTool {
  4.         @Override
  5.         public String getName() {
  6.                 return "天气信息搜索";
  7.         }
  8.         @Override
  9.         public String getDescription() {
  10.                 return """
  11.                                 获取天气预报
  12.                                 """;
  13.         }
  14.         @Tool(description = "get the forecast weather of the specified city and date")
  15.         public String getForecast(@ToolParam(description = "日期") LocalDate date,
  16.                                                           @ToolParam(description = "城市") String city) {
  17.                 return """
  18.                                 - 当前温度:12°C \n
  19.                                 - 天气状况:雾霾 \n
  20.                                 - 体感温度:12°C \n
  21.                                 - 今天天气:大部分地区多云,最低气温9°C \n
  22.                                 - 空气质量:轻度污染 (51-100),主要污染物 PM2.5 75 μg/m³ \n
  23.                                 - 风速:轻风 (2 - 5 公里/小时),西南风 1级 \n
  24.                                 - 湿度:78% \n
  25.                                 - 能见度:能见度差 (1 - 2 公里),2 公里 \n
  26.                                 - 气压:1018 hPa \n
  27.                                 - 露点:8°C \n
  28.                                 """;
  29.         }
  30. }
复制代码
再为这个tool写一个supplier
  1. @Slf4j
  2. @Component
  3. @RequiredArgsConstructor
  4. public class ExampleToolSupplier implements ChatToolSupplier {
  5.         private final ExampleTool exampleTool;
  6.         @Override
  7.         public boolean support(ChatContext context) {
  8.                 return context.getChatOption().isEnableExampleTools();
  9.         }
  10.         @Override
  11.         public ChatTool getTool(ChatContext context) {
  12.                 return exampleTool;
  13.         }
  14. }
复制代码
于是乎,你在没有修改主逻辑的情况下为应用增加了两个功能,这看上去真的很棒!高内聚,低耦合,并且对扩展开放,对修改封闭!
现在,你可以像下面这样,提供更多的扩展能力
7.png

 
8.gif

代码整体结构

9.png

 



来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册