大模型应用开发进阶篇:Spring-AI 结合领域驱动开发设计思想
概要
本文聚焦如何使用spring-AI来开发大模型应用一些进阶技能,包含一套可落地的技术设计模式,读完你将会学习到:
- 如何使用Spring-AI 开发大模型对话应用
- 如何综合设计一套适用Spring-ai的代码结构,为应用提供更好的扩展能力
本文假设读者已经熟悉spring-ai的基本功能以及大模型开发的入门知识,如果你还不熟悉这些基础知识,可以找我仔细学习。
开发目标
我们会简单的模拟豆包的业务模型,开发一个用户与大模型对话的应用程序,我们会从领域模型开始设计,一直到应用模型和应用实现。
由于篇幅有限,我们不展开细节完成每一个功能,这里只介绍核心领域建模和应用的开发模式。
我们将会聚焦一次对话的处理流程,如下图所示:
- 本地工具集也就是function calling 可以随时添加,删除,并且根据对话上下文动态抉择
- 向量数据库搜索可以根据对话上下文选择是否使用,甚至提供多个选择
# 设计领域模型
- Agent 表示一个大模型agent,包括大模型的命名,SystemPrompt,所属用户等
- Conversation 表示一次对话
- User 表示正在使用系统的用户
- 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
大模型应用开发进阶篇:Spring-AI 结合领域驱动开发设计思想的更多相关文章
- mysql 开发进阶篇系列 55 权限与安全(安全事项 )
一. 操作系统层面安全 对于数据库来说,安全很重要,本章将从操作系统和数据库两个层面对mysql的安全问题进行了解. 1. 严格控制操作系统账号和权限 在数据库服务器上要严格控制操作系统的账号和权限, ...
- mysql 开发进阶篇系列 20 MySQL Server(innodb_lock_wait_timeout,innodb_support_xa,innodb _log_*)
1. innodb_lock_wait_timeout mysql 可以自动监测行锁导致的死锁并进行相应的处理,但是对于表锁导致的死锁不能自动监测,所以该参数主要用于,出现类似情况的时候等待指定的时间 ...
- [转]抢先Mark!微信公众平台开发进阶篇资源集锦
FROM : http://www.csdn.net/article/2014-08-01/2820986 由CSDN和<程序员>杂志联合主办的 2014年微信开发者大会 将于8月23日在 ...
- 微信小程序开发——进阶篇
由于项目的原因,最近的工作一直围绕着微信小程序.现在也算告一段落,是时候整理一下这段时间的收获了.我维护的小程序有两个,分别是官方小程序和一个游戏为主的小程序.两个都是用了wepy进行开发,就是这个: ...
- mysql 开发进阶篇系列 47 物理备份与恢复(xtrabackup 的完全备份恢复,恢复后重启失败总结)
一. 完全备份恢复说明 xtrabackup二进制文件有一个xtrabackup --copy-back选项,它将备份复制到服务器的datadir目录下.下面是通过 --target-dir 指定完全 ...
- mysql 开发进阶篇系列 46 物理备份与恢复( xtrabackup的 选项说明,增加备份用户,完全备份案例)
一. xtrabackup 选项说明 在操作xtrabackup备份与恢复之前,先看下该工具的选项,下面记录了xtrabackup二进制文件的部分命令行选项,后期把常用的选项在补上.点击查看xtrab ...
- mysql 开发进阶篇系列 42 逻辑备份与恢复(mysqldump 的完全恢复)
一.概述 在作何数据库里,备份与恢复都是非常重要的.好的备份方法和备份策略将会使得数据库中的数据更加高效和安全.对于DBA来说,进行备份或恢复操作时要考虑的因素大概有如下: (1) 确定要备份的表的存 ...
- mysql 开发进阶篇系列 10 锁问题 (相同索引键值或同一行或间隙锁的冲突)
1.使用相同索引键值的冲突 由于mysql 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然是访问不同行的记录,但如果是使用相同的索引键,是会出现锁冲突的.设计时要注意 例如:city表city_ ...
- AI框架精要:设计思想
AI框架精要:设计思想 本文主要介绍飞桨paddle平台的底层设计思想,可以帮助用户理解飞桨paddle框架的运作过程,以便于在实际业务需求中,更好的完成模型代码编写与调试及飞桨paddle框架的二次 ...
- 领域驱动(DDD)设计和开发实战
领域驱动设计(DDD)的中心内容是如何将业务领域概念映射到软件工件中.大部分关于此主题的著作和文章都以 Eric Evans 的书<领域驱动设计>为基础,主要从概念和设计的角度探讨领域建模 ...
随机推荐
- Lucas 定理证明与扩展
Lucas 定理及其证明.扩展 \[\binom{n}{m}\equiv\binom{n/p}{m/p}\binom{n\bmod p}{m\bmod p}\pmod p,\text{where}\ ...
- 如何基于DeepSeek开展AI项目
关注公众号回复1 获取一线.总监.高管<管理秘籍> 书接上文:DeepSeek怎么突然就比肩GPT了? 最近一直在研究DeepSeek,作为应用层的选手,自然不会傻乎乎的想要去了解底层,我 ...
- CF935D Fafa and Ancient Alphabet 题解
讲一个很暴力的方法(为描述方便,下文 \(a\) 数组代表 \(s1\),\(b\) 数组代表 \(s2\)). 发现假如当前 \(a_i\ne b_i\),就不需要再向下枚举了,于是拥有了分类讨论的 ...
- Thymeleaf select 反显 默认选中
后台代码 List<ExamTestPaperDO> list = examTestPaperService.list(map); model.addAttribute("tes ...
- BUUCTF-Web方向21-25wp
[HCTF 2018]admin 打开环境,有三处提示,一个跳转链接,一个登录注册,一个提示不是admin 点击hctf,无法访问 注册个账号,依旧无法查看,看来需要admin账号 弱口令 爆破密码 ...
- Docker 安装详细步骤
一.安装前的准备 确认系统要求 不同的操作系统对 Docker 的支持有所不同,常见的如 Windows.MacOS 和各种 Linux 发行版. 启用虚拟化(如果需要) 对于某些系统,可能需要在 B ...
- Kubernetes - [04] 常用命令
kubectl 语法 kubectl [command] [TYPE] [NAME] [flags] command:指定在一个或多个资源商要执行的操作.例如:create.get.describe. ...
- P5355 [Ynoi Easy Round 2017] 由乃的玉米田
莫队 + bitset + 根号分支 乘法似乎是简单的,我们可以直接莫队扫描然后枚举较小数 时间 \((n + m) \sqrt n\). 加法是一个经典 idea, 莫队套 bitset,然后利用 ...
- mysql安装以及2059 - Authentication plugin 'caching_sha2_password' cannot be loaded:报错的解决办法
2059 - Authentication plugin 'caching_sha2_password' cannot be loaded: dlopen(../Frameworks/caching_ ...
- linux安装python centos
下载安装包 可以到官网 ftp 地址,复制指定 python 版本源码安装包下载链接 https://www.python.org/ftp/python/ 或者到官网 downloads, 复制指定 ...