大模型应用开发进阶篇:Spring-AI 结合领域驱动开发设计思想

概要

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

  • 如何使用Spring-AI 开发大模型对话应用
  • 如何综合设计一套适用Spring-ai的代码结构,为应用提供更好的扩展能力

本文假设读者已经熟悉spring-ai的基本功能以及大模型开发的入门知识,如果你还不熟悉这些基础知识,可以找我仔细学习。

开发目标

我们会简单的模拟豆包的业务模型,开发一个用户与大模型对话的应用程序,我们会从领域模型开始设计,一直到应用模型和应用实现。

由于篇幅有限,我们不展开细节完成每一个功能,这里只介绍核心领域建模和应用的开发模式。

我们将会聚焦一次对话的处理流程,如下图所示:

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

# 设计领域模型

  1. Agent 表示一个大模型agent,包括大模型的命名,SystemPrompt,所属用户等
  2. Conversation 表示一次对话
  3. User 表示正在使用系统的用户
  4. ChatMessage表示一个对话消息,一个对话消息由多个内容组成,因为一次对话可以发送包括文本和媒体多条具体内容。

至此,我们简单模拟了豆包的领域模型

设计应用模型

首先设计一个 ChatContext类,用来表示全部对话的上下文核心,这里我们分析如下:

  • 对话上下文包含 when,who,what,where,how 五种元素

    • When - 用户发送消息的时间
    • Who - 发送消息的用户
    • What - 用户发送发的消息
    • Where - 用户处于哪一个对话
    • How - 本次对话有哪些配置选项
  • 对话上下文可以配置标记属性,以便在不同功能之间传递消息,这点类似Servlet技术中方的ServletRequest#getAttribute

  • 对话上下文是只读的,不允许修改

    import java.util.HashMap;
    import java.util.Map;
    import com.github.aurora.ultra.chat.domain.Conversation;
    import com.github.aurora.ultra.chat.domain.User;
    import lombok.Builder;
    import lombok.Getter;
    import org.springframework.ai.chat.messages.UserMessage;

    @Getter
    @Builder
    public class ChatContext {
    // when who what where how
    // -------------------------------------------------------------
    // now user userMessage conversation chatOption

    复制代码
      private final Map<String, Object> attributes = new HashMap<>();
    
      private final User user;
      private final UserMessage userMessage;
      private final ChatOption chatOption;
      private final Conversation conversation;
    
      public void setAttribute(String key, Object value) {
         attributes.put(key, value);
      }
    
      public Object getAttribute(String key) {
         return attributes.get(key);
      }
    
      @SuppressWarnings("unchecked")
      public <T> T getAttribute(String key, Class<T> ignored) {
        return (T) attributes.get(key);
      }

    }

至此,我们有了可用的对话上下文,可以围绕这个上下文开发对话逻辑了。

设计应用逻辑

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

我们先来分析一下可扩展的点:

  • 对话模型可以切换,系统将会根据上下文推断出本次要使用的模型。
  • 本地方法可以随时增加删除,系统会很久本次上下文推断出需要调用的本地工具。
  • 其他spring-ai框架的的Advisor也可能根据一次对话的上下文被推断出。

由此可见对话上下文是整个应用的重点,所有的功能是否被使用都围绕着这个上下文,并且这些功能在运行的时候会根据上下文动态提供出来,不难看出,这是一个策略模式,于是我们设计如下接口:

复制代码
public interface ChatAdvisorSupplier {
    boolean support(ChatContext context);
    Advisor getAdvisor(ChatContext context);
}
public interface ChatClientSupplier {
    boolean support(ChatContext context);
    ChatClient getChatClient(ChatContext context);
}
public interface ChatTool {
    String getName();
    String getDescription();
}
public interface ChatToolSupplier {
    boolean support(ChatContext context);
    ChatTool getTool(ChatContext context);
}

  • ChatAdvisorSupplier 用来为本次对话提供spring-ai的Advisor
  • ChatClientSupplier 会根据本地对话提供可用的模型client
  • ChatTool 用来表示一个包含本地放的的类,提供了name和desc两个属性,用来让大模型帮我们判断哪些工具在本次对话需要被使用到
  • ChatToolSupplier则会根据当前对话给出哪些本地工具会被使用到。

下面我们将这些组件串联起来,这样一来,我们的核心交互流程不变,而具体交互流程在策略器中可随时动态增减。

实现应用逻辑

我们来看一下ChatService是如何被实现的。

复制代码
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {
    public static final int CHAT_RESPONSE_BUFFER_SIZE = 24;
    public static final String CHAT_TOOLS_CHOSEN_MODEL = "gpt-3.5-turbo";

    private final ChatManager chatManager;

    private final List<ChatToolSupplier> chatToolSuppliers;
    private final List<ChatClientSupplier> chatClientSuppliers;
    private final List<ChatAdvisorSupplier> chatAdvisorSuppliers;

    public ChatReply chat(ChatCommand command) throws ChatException {
       try {
          var user = User.mock();
          var chatOption = command.getOption();
          var conversation = getConversation(command.getConversationId());
          var userMessage = createUserMessage(command);
          var context = ChatContext.builder()
                .user(user)
                .userMessage(userMessage)
                .chatOption(chatOption)
                .conversation(conversation)
                .build();
          return this.chat(context);
       } catch (Exception e) {
          throw ChatException.of("Something wrong when processing the chat command", e);
       }
    }

    private ChatReply chat(ChatContext context) throws ChatException {
       var tools = getTools(context);
       var advisors = getAdvisors(context);
       var chatClient = getChatClient(context);
       var conversation = context.getConversation();
       var userMessage = context.getUserMessage();

       var contents = chatClient
             .prompt()
             .advisors(advisors)
             .messages(conversation.createPromptMessages())
             .messages(userMessage)
             .toolCallbacks(ToolCallbacks.from(tools.toArray()))
             .toolContext(context.getAttributes())
             .stream()
             .content()
             .buffer(CHAT_RESPONSE_BUFFER_SIZE)
             .map(strings -> String.join("", strings));

       return ChatReply.builder()
             .contents(contents)
             .build();
    }

    private UserMessage createUserMessage(ChatCommand command) {
       return new UserMessage(command.getContent());
    }

    private Conversation getConversation(String conversationId) {
       return chatManager.getOrCreateConversation(conversationId);
    }

    private List<Advisor> getAdvisors(ChatContext context) {
       return chatAdvisorSuppliers
             .stream()
             .filter(chatAdvisorSupplier -> chatAdvisorSupplier.support(context))
             .map(chatAdvisorSupplier -> chatAdvisorSupplier.getAdvisor(context))
             .toList();
    }

    private ChatClient getChatClient(ChatContext context) throws ChatException {
       return chatClientSuppliers
             .stream()
             .filter(chatAdvisorSupplier -> chatAdvisorSupplier.support(context))
             .map(chatAdvisorSupplier -> chatAdvisorSupplier.getChatClient(context))
             .findFirst()
             .orElseThrow(() -> ChatException.of("unknown how to create the chat client, maybe you need to add a chat client supplier?"));
    }

    private List<ChatTool> getTools(ChatContext context) throws ChatException {
       var tools = chatToolSuppliers
             .stream()
             .filter(supplier -> supplier.support(context))
             .map(supplier -> supplier.getTool(context))
             .toList();

       if (tools.isEmpty()) {
          return tools;
       }
       var toolDescription = tools.stream()
             .map(chatTool -> String.format("- %s: %s", chatTool.getName(), chatTool.getDescription()))
             .collect(Collectors.joining("\n"));
       var systemPrompt = "You will determine what tools to use based on the user's problem." +
             "Please directly reply the tool names with delimiters ','. " +
             "Reply example: tool1,tool2." +
             "The tools are: \n" +
             toolDescription;

       var toolsDecision = getChatClient(context)
             .prompt()
             .options(ChatOptions.builder()
                   .model(CHAT_TOOLS_CHOSEN_MODEL)
                   .build())
             .system(systemPrompt)
             .messages(context.getUserMessage())
             .call()
             .content();

       if (StringUtils.isBlank(toolsDecision)) {
          return new ArrayList<>();
       }

       var chosen = Arrays.asList(toolsDecision.split(","));
       log.info("tools chosen: {}", chosen);

       tools = tools.stream()
             .filter(chatTool -> chosen.contains(chatTool.getName()))
             .toList();

       return tools;
    }
}

  • 首先ChatService注入了所有的ChatToolSupplier,ChatClientSupplier,ChatAdvisorSupplier接口实例;
  • 当处理ChatCommand的时候,组装出ChatContext;
  • 然后调用一系列的get方法读取相关的策略
  • 最后调用大模型client与之交互

其中getTools方法相对比较复杂,它先便利了所有的本地工具,然后将用户对话和本地工具描述一起交给了大模型,大模型告诉本地应用那一套functions更适合处理这个问题,然后菜返回本地工具集。之所以这么做,是因为(例如)openai官网明确说明,建议一次对话functions不要太多,最好不要超过20个,因为更多的functions意味着更多的token,也意味着更多的处理时间,而且也没有必要。

为应用增加RAG功能

有了ChatAdvisorSupplier这个接口,我们可以轻易的为应用逻辑增加RAG的功能。

复制代码
@Slf4j
@Component
@RequiredArgsConstructor
public class InternalSearchAdvisorSupplier implements ChatAdvisorSupplier {
    private final static int DEFAULT_TOP_K = 3;

    private final VectorStore vectorStore;

    private final static String USER_TEXT_ADVISE = """
          上下文信息如下,用 --------------------- 包围
          
          ---------------------
          {question_answer_context}
          ---------------------
          
          根据上下文和提供的历史信息(而非先验知识)回复用户问题。如果答案不在上下文中,请告知用户你无法回答该问题。
          """;

    @Override
    public boolean support(ChatContext context) {
       return context.getChatOption().isEnableInternalSearch();
    }

    @Override
    public Advisor getAdvisor(ChatContext context) {
       return QuestionAnswerAdvisor.builder(vectorStore)
             .searchRequest(
                   SearchRequest.builder()
                         .topK(NumberUtils.max(context.getChatOption().getRetrieveTopK(), DEFAULT_TOP_K))
                         .build()
             )
             .userTextAdvise(USER_TEXT_ADVISE)
             .build();
    }

}

这里我们规定,只要chatOption里面开启了InternalSearch开关,则应用RAG功能。你只要看一下下面的ChatOption类的设计,就瞬间明白了这个设计。

复制代码
@Getter
@Builder
@RequiredArgsConstructor
public class ChatOption implements Serializable {

    private final boolean enableInternalSearch;
    private final boolean enableExternalSearch;
    private final boolean enableExampleTools;
    private final boolean enableMemory;
    private final boolean enableDebug;

    private final int retrieveTopK;

    private final String model;
}

为应用增加一组Function Calling

我们写一个示例的Tool,提供function calling的功能

复制代码
@Slf4j
@Component
public class ExampleTool implements ChatTool {

    @Override
    public String getName() {
       return "SampleTool";
    }

    @Override
    public String getDescription() {
       return """
             contains methods: forecast,
             get date time,
             operate local file,
             """;
    }

    @Tool(description = "Get the current date and time in the user's timezone")
    public String getCurrentDateTime() {
       return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
    }

    @Tool(description = "get the forecast weather of the specified city and date")
    public String getForecast(@ToolParam(description = "日期") LocalDate date,
                        @ToolParam(description = "城市") String city) {
       return """
             - 当前温度:12°C \n
             - 天气状况:雾霾 \n
             - 体感温度:12°C \n
             - 今天天气:大部分地区多云,最低气温9°C \n
             - 空气质量:轻度污染 (51-100),主要污染物 PM2.5 75 μg/m³ \n
             - 风速:轻风 (2 - 5 公里/小时),西南风 1级 \n
             - 湿度:78% \n
             - 能见度:能见度差 (1 - 2 公里),2 公里 \n
             - 气压:1018 hPa \n
             - 露点:8°C \n
             """;
    }
}

再为这个tool写一个supplier

复制代码
@Slf4j
@Component
@RequiredArgsConstructor
public class ExampleToolSupplier implements ChatToolSupplier {

    private final ExampleTool exampleTool;

    @Override
    public boolean support(ChatContext context) {
       return context.getChatOption().isEnableExampleTools();
    }

    @Override
    public ChatTool getTool(ChatContext context) {
       return exampleTool;
    }
}

于是乎,你在没有修改主逻辑的情况下为应用增加了两个功能,这看上去真的很棒!高内聚,低耦合,并且对扩展开放,对修改封闭!

现在,你可以像下面这样,提供更多的扩展能力

# Maven

首先配置maven配置,导入spring-ai的核心包,这里我们目前只用到了openai和rag向量数据库,暂时导入这两个包即可。

复制代码
      <!-- spring AI -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-advisors-vector-store</artifactId>
        </dependency>

代码整体结构

具体代码示例

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

相关推荐
美酒没故事°20 小时前
Open WebUI安装指南。搭建自己的自托管 AI 平台
人工智能·windows·ai
鸿乃江边鸟20 小时前
Nanobot 从onboard启动命令来看个人助理Agent的实现
人工智能·ai
本旺20 小时前
【Openclaw 】完美解决 Codex 认证失败
ai·codex·openclaw·小龙虾·gpt5.4
张張40821 小时前
(域格)环境搭建和编译
c语言·开发语言·python·ai
乐鑫科技 Espressif21 小时前
使用 MCP 服务器,把乐鑫文档接入 AI 工作流
人工智能·ai·esp32·乐鑫科技
语戚21 小时前
Stable Diffusion 入门:架构、空间与生成流程概览
人工智能·ai·stable diffusion·aigc·模型
最初的↘那颗心21 小时前
Agent 实战:构建第一个 Agent 与记忆系统设计
java·大模型·agent·spring ai·记忆系统
俊哥V21 小时前
每日 AI 研究简报 · 2026-04-08
人工智能·ai
rrrjqy1 天前
什么是RAG?
ai
Flittly1 天前
【SpringAIAlibaba新手村系列】(15)MCP Client 调用本地服务
java·笔记·spring·ai·springboot