首页/文章列表/文章详情

大模型应用开发:利用 Spring-AI 实现高内聚低耦合可扩展的聊天应用

编程知识732025-05-02评论

 概要

本文聚焦如何使用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
  • 对话上下文是只读的,不允许修改
 attributes = new HashMap<>();\n\n    private final User user;\n    private final UserMessage userMessage;\n    private final ChatOption chatOption;\n    private final Conversation conversation;\n\n    public void setAttribute(String key, Object value) {\n       attributes.put(key, value);\n    }\n\n    public Object getAttribute(String key) {\n       return attributes.get(key);\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public  T getAttribute(String key, Class ignored) {\n      return (T) attributes.get(key);\n    }\n\n}\n\n","classes":null}" data-cke-widget-keep-attr="0" data-cke-widget-upcasted="1" data-widget="codeSnippet">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 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 getAttribute(String key, Class 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是如何被实现的。

 

 chatToolSuppliers;\n private final List chatClientSuppliers;\n private final List chatAdvisorSuppliers;\n\n public ChatReply chat(ChatCommand command) throws ChatException {\n try {\n var user = User.mock();\n var chatOption = command.getOption();\n var conversation = getConversation(command.getConversationId());\n var userMessage = createUserMessage(command);\n var context = ChatContext.builder()\n .user(user)\n .userMessage(userMessage)\n .chatOption(chatOption)\n .conversation(conversation)\n .build();\n return this.chat(context);\n } catch (Exception e) {\n throw ChatException.of(\"Something wrong when processing the chat command\", e);\n }\n }\n\n private ChatReply chat(ChatContext context) throws ChatException {\n var tools = getTools(context);\n var advisors = getAdvisors(context);\n var chatClient = getChatClient(context);\n var conversation = context.getConversation();\n var userMessage = context.getUserMessage();\n\n var contents = chatClient\n .prompt()\n .advisors(advisors)\n .messages(conversation.createPromptMessages())\n .messages(userMessage)\n .toolCallbacks(ToolCallbacks.from(tools.toArray()))\n .toolContext(context.getAttributes())\n .stream()\n .content()\n .buffer(CHAT_RESPONSE_BUFFER_SIZE)\n .map(strings -> String.join(\"\", strings));\n\n return ChatReply.builder()\n .contents(contents)\n .build();\n }\n\n private UserMessage createUserMessage(ChatCommand command) {\n return new UserMessage(command.getContent());\n }\n\n private Conversation getConversation(String conversationId) {\n return chatManager.getOrCreateConversation(conversationId);\n }\n\n private List getAdvisors(ChatContext context) {\n return chatAdvisorSuppliers\n .stream()\n .filter(chatAdvisorSupplier -> chatAdvisorSupplier.support(context))\n .map(chatAdvisorSupplier -> chatAdvisorSupplier.getAdvisor(context))\n .toList();\n }\n\n private ChatClient getChatClient(ChatContext context) throws ChatException {\n return chatClientSuppliers\n .stream()\n .filter(chatAdvisorSupplier -> chatAdvisorSupplier.support(context))\n .map(chatAdvisorSupplier -> chatAdvisorSupplier.getChatClient(context))\n .findFirst()\n .orElseThrow(() -> ChatException.of(\"unknown how to create the chat client, maybe you need to add a chat client supplier?\"));\n }\n\n private List getTools(ChatContext context) throws ChatException {\n var tools = chatToolSuppliers\n .stream()\n .filter(supplier -> supplier.support(context))\n .map(supplier -> supplier.getTool(context))\n .toList();\n\n if (tools.isEmpty()) {\n return tools;\n }\n var toolDescription = tools.stream()\n .map(chatTool -> String.format(\"- %s: %s\", chatTool.getName(), chatTool.getDescription()))\n .collect(Collectors.joining(\"\\n\"));\n var systemPrompt = \"You will determine what tools to use based on the user's problem.\" +\n \"Please directly reply the tool names with delimiters ','. \" +\n \"Reply example: tool1,tool2.\" +\n \"The tools are: \\n\" +\n toolDescription;\n\n var toolsDecision = getChatClient(context)\n .prompt()\n .options(ChatOptions.builder()\n .model(CHAT_TOOLS_CHOSEN_MODEL)\n .build())\n .system(systemPrompt)\n .messages(context.getUserMessage())\n .call()\n .content();\n\n if (StringUtils.isBlank(toolsDecision)) {\n return new ArrayList<>();\n }\n\n var chosen = Arrays.asList(toolsDecision.split(\",\"));\n log.info(\"tools chosen: {}\", chosen);\n\n tools = tools.stream()\n .filter(chatTool -> chosen.contains(chatTool.getName()))\n .toList();\n\n return tools;\n }\n}\n","classes":null}" data-cke-widget-keep-attr="0" data-cke-widget-upcasted="1" data-widget="codeSnippet">@Slf4j@Service@RequiredArgsConstructorpublic 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 chatToolSuppliers; private final List chatClientSuppliers; private final List 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 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 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@RequiredArgsConstructorpublic 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@RequiredArgsConstructorpublic 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@Componentpublic 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@RequiredArgsConstructorpublic 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向量数据库,暂时导入这两个包即可。

 

\norg.springframework.ai\nspring-ai-starter-model-openai\n\n\norg.springframework.ai\nspring-ai-advisors-vector-store\n\n","classes":null}" data-cke-widget-keep-attr="0" data-cke-widget-upcasted="1" data-widget="codeSnippet"> <!-- spring AI --> org.springframework.aispring-ai-starter-model-openaiorg.springframework.aispring-ai-advisors-vector-store

代码整体结构

具体代码示例

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

有泥土的路

这个人很懒...

用户评论 (0)

发表评论

captcha