代码示例
https://github.com/aurora-ultra/aurora-spring-ai
概要
本文聚焦如何使用spring-AI来开发大模型应用一些进阶技能,包含一套可落地的技术设计模式,读完你将会学习到:
- 如何使用Spring-AI 开发大模型对话应用
- 如何综合设计一套适用Spring-ai的代码结构,为应用提供更好的扩展能力
本文假设读者已经熟悉spring-ai的基本功能以及大模型开发的入门知识,如果你还不熟悉这些基础知识,可以找我仔细学习。
开发目标
我们会简单的模拟豆包的业务模型,开发一个用户与大模型对话的应用程序,我们会从领域模型开始设计,一直到应用模型和应用实现。
由于篇幅有限,我们不展开细节完成每一个功能,这里只介绍核心领域建模和应用的开发模式。
我们将会聚焦一次对话的处理流程,如下图所示:
- 本地工具集也就是function calling 可以随时添加,删除,并且根据对话上下文动态抉择
- 向量数据库搜索可以根据对话上下文选择是否使用,甚至提供多个选择
# 设计领域模型
- Agent 表示一个大模型agent,包括大模型的命名,SystemPrompt,所属用户等
- Conversation 表示一次对话
- User 表示正在使用系统的用户
- ChatMessage表示一个对话消息,一个对话消息由多个内容组成,因为一次对话可以发送包括文本和媒体多条具体内容。
至此,我们简单模拟了豆包的领域模型
设计应用模型
既然我们在最开始设计了领域模型,我么也很自然的会设计应用模型,首先应用模型需要一个聚合根,用来表示一次对话的处理环境,我们称之为上下文,然后每次对话会包含很多关键元素,比如用户,模型,时间等,其中还有一个就是本次对话的配置选项,因为在与大模型交互的时候,其实我们难免有一些设置项,比如跟哪个模型对话,是否开启互联网锁搜等。
首先设计一个 ChatContext类,用来表示一次对话的上下文核心,这里我们分析如下:
- 对话上下文包含 when,who,what,where,how 五种元素, 这本纸上就是一个5w2h的分析,只不过没有why和how much, 很明显,why 和how muc事根据需求来的,这里我们先不设计。
- When - 用户发送消息的时间
- Who - 发送消息的用户
- What - 用户发送发的消息
- Where - 用户处于哪一个对话
- How - 本次对话有哪些配置选项
- 对话上下文可以配置标记属性,以便在不同功能之间传递消息,这点类似Servlet技术中方的ServletRequest#getAttribute
- 对话上下文是只读的,不允许修改@Getter
设计应用逻辑
首先我们来设计应用的扩展点,其实本质上应该是先设计应用逻辑,再进行重构设计扩展点,但是这里为了行文方便,直接展示下扩展点,免去重构的过程,请读者注意,真实开发的时候不可能一开始就想得到哪些地方需要扩展,一定是先做出基础逻辑,再重构出扩展点点,我们先来分析一下可扩展的点:
- 对话模型可以切换,系统将会根据上下文推断出本次要使用的模型。
- 本地方法可以随时增加删除,系统会很久本次上下文推断出需要调用的本地工具。
- 其他spring-ai框架的的Advisor也可能根据一次对话的上下文被推断出。
由此可见对话上下文是整个应用的重点,所有的功能是否被使用都围绕着这个上下文,并且这些功能在运行的时候会根据上下文动态提供出来,不难看出,这是一个策略模式,于是我们设计如下接口:
- ChatAdvisorSupplier 用来为本次对话提供spring-ai的Advisor
- ChatClientSupplier 会根据本地对话提供可用的模型client
- ChatTool 用来表示一个包含本地放的的类,提供了name和desc两个属性,用来让大模型帮我们判断哪些工具在本次对话需要被使用到
- ChatToolSupplier则会根据当前对话给出哪些本地工具会被使用到。
几乎每一个接口都有2个方法,一个support,一个getXxx,support用于判断当前的能力是否启用,如果放回true,则表示当前上下文需要这个能力,如果返回false,则当前对话不需要这个能力,这是一个非常典型的策略模式,在spring框架中几乎随处可见。
下面我们将这些组件串联起来,这样一来,我们的核心交互流程不变,而具体交互流程在策略器中可随时动态增减,当我们开始处理一个对话上下文的时候,首先根据对话上下文找到适合的模型,工具等,这些具体功能由一个个的supplier提供,每个supplier都会根据对话上下文给出自己是否适用,如果适用,我们就让这个supplier提供他的能力,看上去就像下面这样:
实现应用逻辑
有了上面的接口,我们实现的应用逻辑就简单起来了,只要将接口的调用编排起来就行,之所以设计接口和调用者的好处,就是以后这个应用的核心逻辑应该会很少变动,不论增加什么功能,几乎这个核心逻辑都不需要做什么改动,这就是所谓的高内聚,低耦合,面向扩展开放,面向修改关闭。
试想一下,如果有一天新增了需求,那么大概率是需要新增某种工具调用,某种advisor的调用,这些都不影响你的核心逻辑,我们只需要新增一个实现,或者修改现有的一个实现。
我们简单来分析一下这个应用逻辑,他需要接受到对话命令ChatCommand,然后组装出对话上下文ChatContext,接着根据对话上下文找到适合的client, tools,advisors,还要从上下文找出本次要发送给大模型的对话消息,最后将大模型返回的消息包装成我们自己的数据结构(ChatReply)返回就行了
我们来看一下ChatService是如何被实现的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
|
@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
Conversation startConversation(ConversationStartCommand command) {
// todo implement this method
throw
new
NotImplementedException();
}
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 ',' and reply empty if no tools is usable "
+
"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(
","
));
tools = tools.stream()
.filter(chatTool -> chosen.contains(chatTool.getName()))
.toList();
log.info(
"tools chosen: {}"
, tools.stream().map(ChatTool::getName).collect(Collectors.toSet()));
return
tools;
}
}
|
- 首先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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
@Slf4j
@Component
@RequiredArgsConstructor
public
class
InternalSearchAdvisorSupplier
implements
ChatAdvisorSupplier {
private
final
static
int
DEFAULT_TOP_K =
3
;
private
final
VectorStore vectorStore;
private
final
static
PromptTemplate USER_TEXT_ADVISE = PromptTemplate.builder()
.template(
""
"
上下文信息如下,用 --------------------- 包围
---------------------
{question_answer_context}
---------------------
根据上下文和提供的历史信息(而非先验知识)回复用户问题。如果答案不在上下文中,请告知用户你无法回答该问题。
""
")
.build();
@Override
public
boolean
support(ChatContext context) {
return
context.getChatOption().isEnableInternalSearch()
&& !context.getChatOption().isEnableExternalSearch();
}
@Override
public
Advisor getAdvisor(ChatContext context) {
return
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(
SearchRequest.builder()
.topK(NumberUtils.max(context.getChatOption().getRetrieveTopK(), DEFAULT_TOP_K))
.build()
)
.promptTemplate(USER_TEXT_ADVISE)
.build();
}
}
|
为应用增加一组Function Calling
我们写一个示例的Tool,提供function calling的功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
@Slf4j
@Component
public
class
ExampleTool
implements
ChatTool {
@Override
public
String getName() {
return
"天气信息搜索"
;
}
@Override
public
String getDescription() {
return
""
"
获取天气预报
""
";
}
@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
""
";
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@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;
}
}
|
现在,你可以像下面这样,提供更多的扩展能力
代码整体结构
原创作者: mrye 转载于: https://www.cnblogs.com/mrye/p/18857558