一、前言
目前熟悉大模型应用开发的人都或多或少的了解过workFlow的概念,在笔者之前的文章里实现的大模型应用大多只能实现一个简单的功能例如上下文对话、读取本地文件、调用远程MCP服务等。 但是实际场景中我们的实际需求会复杂的多,可能需要涉及多次调用大模型,并调用多个工具,调用大模型时可能还存在流程控制(条件、循环),甚至需要并行调用大模型和代码逻辑片段。 对于这种需求,单纯的使用一个智能体往往不能很好的完成任务,往往需要多个智能体之间的配合与编排。
目前在大模型开发领域实现ChatWorkFlow(又称为智能体编排应用)的主流方案主要有下面两种:
1、图编排模式:以dify为代表,通过低代码的方式,在web页面上选择组件和大模型实现编排。国内的大模型开发平台、例如百炼、COZE都是这种类似的模式。 优点是流程直观,低代码模式,开发门槛低,上手快,响应需求变动的能力也比较强。缺点是受制于平台能力,只能在限定的框架内执行功能,性能也受底层平台的限制。
2、纯代码开发模式:以langchain、langGraph的python大模型开发框架,以纯代码的方式实现流程编排,优点是功能强大,能满足极度复杂的编排需求。缺点是有一定的学习成本,需要了解langchain和langGraph的设计思想。
其实以目前的大模型应用开发现状来看,笔者更推荐图编排模式,这对于开发复杂度不高的大模型应用而言这个方案几乎可以说是最快也是最直观的,例如百炼平台目前的流程搭建甚至可以直接添加MCP服务节点并且拥有极为丰富的功能插件,基本上能满足大部分的开发需求
那么回到SpringAI ,目前 SpringAI 有没有对应的流程编排能力呢?很遗憾目前SpringAI的流程编排能力尚不完善,上面提到的两种流程编排方式目前支持的代码语言主要以Js和python为主。
那么是不是使用SpringAI 无法实现流程编排了呢?当然不是,本文介绍一种借助liteFlow来实现SpringAI智能体编排的设计思路。
二、案例介绍
本文以一个AI新闻助手的案例来说明此次的编排场景
AI新闻助手具备下述四个功能:
- 用户询问最近的热点新闻,展示热点新闻标题列表,并可发送相关细节数据信息给到显示端,也就是说需要生成一份用于页面渲染的数据信息还需要生成一份文字信息给到客户。
- 用户提问某一条新闻的内容,可以获取从历史对话中找到对应的新闻信息发送信息展示端,还需要大模型总结新闻内容语音播报给客户。
- 用户指定搜索关键字,可以搜索关键字对应的相关新闻
- 用户询问新闻之外的内容时,需要大模型基于已有知识回答
对于这样的一个需求,单一的Agent已经不能很好的完成任务了,这时候往往需要搭配代码以及多个Agent协同配合来完成这样的一个需求,这时候就需要一个流程引擎来把关键节点组织起来来实现功能。
二、案例实现
基于这样的一个需求,本文核心借助LiteFlow来实现这样的一功能,整体流程设计如下图所示:

IntentionClassifyNode :意图识别节点,在这个节点中借助Agent(IntentionClassifyAgent)分析用户的输入内容属于哪个分类,起到一个路由的作用,分类结果分为三种,分别为获取新闻列表、获取新闻内容和其他(日常闲聊),对于不同的分类后续会进行不同的流程处理,这个节点在LiteFlow中是一个选择节点。
ParamAnalyseNode:参数提取节点,在这个节点中会借助Agent(ParamAnalyseAgent) 分析用户的输入,提取出查找新闻列表的关键参数(一个为查找条目总数,一个为搜索关键字),这个参数会传递给下一个节点用于获取新闻标题列表。
GetHotNewsListNode:获取新闻标题列表节点,这个节点会依据上一个节点得到的新闻查询参数执行服务调用,获取到对应新闻对象列表,然后会异步的将完整的新闻列表信息通过SSE的方式推送给端侧并交付下一个节点进行下一步处理
GetHotNewsContentNode:获取新闻内容节点,这个节点会借助Agent(HotNewsContentAgent)分析用户的输入,推测当前用户询问的具体是哪一条新闻的内容,并从多轮对话上下文变量中获取到对应的新闻信息,借助MCP服务工具调用查找到对应的新闻内容,并做内容总结,还会将新闻原内容异步的以SSE的方式推送给端侧。
NormalChatNode:这个节点会借助Agent(NormalChatNode)分析用户的输入,以固定的人设基于大模型本身的知识储备进行回答。
OutputNode:这个节点会借助Agent(UniOutputAgent)将需要进行文字输出的内容,按照要求进行转述,变成固定格式的内容进行输出(方便端侧解析)。
三、实现细节
3.1 变量存储
实现智能体编排除了编排流程之外最重要的就是对话中涉及的一些变量的管理。 一次智能体调用可能涉及大量的函数和模型调用,而这些调用的结果,需要能够正确的在整个链路传递,这样在智能体调用之前就可以把这些中间结果当做Prompt的一部分,来让Agent正确的处理数据,
本文在实现这个功能的时候根据变量的生命周期对变量划分为三种类型
- 单轮对话级别:一轮对话中需要在多个智能体之间进行数据传递的变量
- 多轮对话级别:多轮对话中被频繁引用或者需要引用的历史对话中的信息中的变量
- 全局级别:用户级别需要持久记忆的变量信息,例如用户姓名,配置信息等
单轮对话级别在本案例中可以认为是一次工作流执行过程中的变量传递,这个可以借助LiteFlow的上下文变量来解决,通过继承NodeComponent
即可调用 getContextBean 获取整个节点之间的的上下文信息,上下文信息也可以作为一条通信总线,起到节点间数据传递的作用。
java
protected NewAgentChatFlowContext getContextBean() {
return getContextBean(NewAgentChatFlowContext.class);
}
多轮对话级别的变量在本案例中例如新闻列表信息,因为在询问完新闻列表之后可能会针对其中的某一条新闻进行发问,所以必须记忆上一次新闻列表的内容,这样才能保证后续提问的回答正确,如何实现呢?
有三种方案可以进行实现
- 通过继承
MessageChatMemoryAdvisor
复写aroundCall方法,在对话结束后往chatMemory中注入变量信息 - 自己管控ChatMemory,在适当时机更新ChatMemory的变量内容
- 存入全局级别的变量池中(例如redis,内存缓存中),然后设置适当的过期时间
对于第二种方案可以做如下代码设计
java
public static void putVariableIntoMemoryWithReplace(String chatId, String key, String value) {
if (!StringUtils.hasText(key) || !StringUtils.hasText(value)) {
return;
}
ChatMemory chatMemory = getChatMemory(chatId);
List<Message> messages = chatMemory.get(chatId, 50);
// 裁剪一下对话记忆,将之前保存的变量数据给清理掉
if (!CollectionUtils.isEmpty(messages)) {
List<Message> newMessages = new ArrayList<>();
for (Message message : messages) {
if (message instanceof SystemMessage systemMessage) {
if (systemMessage.getText().contains(VARIABLE_PREFIX) && systemMessage.getText().contains(key)) {
continue;
}else{
newMessages.add(message);
}
} else {
newMessages.add(message);
}
}
// 清理会话记忆重新设置
chatMemory.clear(chatId);
chatMemory.add(chatId, newMessages);
}
chatMemory.add(chatId, new SystemMessage(String.format(MEMORY_VARIABLE, key, value)));
}
当需要设置多轮对话变量的时候,调用putVariableIntoMemoryWithReplace 方法将变量内容存于会话记忆中,需要注意的是建议设定一个前缀,因为如果需要进行变量替换的时候设定一个前缀有助于快速找到对应的变量信息
对于全局级别的变量,例如:用户名称用户偏好等信息,这些内容无论何时开启对话都需要记忆这些内容,这时候使用Redis 和 数据库都是不错的选择,本文在进行实际代码实现时为了简单使用了Guava内存缓存的方式记录变量。
java
public class GuavaCacheTools {
// 创建一个缓存实例
static Cache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(100)
.build();
public static void put(String chatId, String key, String value) {
if (StringUtils.hasText(key) && StringUtils.hasText(value)) {
cache.put(chatId+":"+ key, value);
}
}
public static String get(String chatId, String key) {
if (StringUtils.hasText(key) && StringUtils.hasText(chatId)) {
return cache.getIfPresent(chatId+":"+ key);
}
return "";
}
}
3.2 关键Agent设计
Agent的设计有下面几个细节需要注意
- 需要使用提示词模板,注入关键的上下文信息,并在合适时机记录全局级别变量和多轮对话变量信息
- 需要让大模型的输出,以一个固定格式进行输出,自由度不能太高
下面以新闻内容分析Agent为例说明细节:
java
@Component
@Slf4j
public class HotNewsContentAgent {
public static final String SYS_PROMPT = """
你是一个获取热点新闻内容的助手,你可以调用工具getMainContentOfNews获取新闻内容
获取新闻内容需要传递新闻链接(newsUrl),这个信息你可以在这个新闻信息列表里进行获取
新闻列表:
{last_search_news_list}
你按照用户提问判断用户提问对应的是列表中的哪一条新闻,如果新闻列表没有提供或者无法判断是哪一条新闻则直接返回"无法判断询问的是哪条内容呢"
如果能找到对应的新闻信息,新闻信息中的newsUrl就是新闻地址 就可以调用工具getMainContentOfNews获取新闻内容
获取到新闻内容之后,你需要根据新闻内容做总结,并将总结内容输出。
""";
private final ChatClient chatClient;
public HotNewsContentAgent(ChatClient.Builder chatClientBuilder, ToolCallbackProvider tools) {
this.chatClient = chatClientBuilder
.defaultTools(tools)
.defaultOptions(DashScopeChatOptions.builder().withTopP(0.7).build())
.build();
}
public Flux<ChatResponse> getHotNewsContent(String userInput, String chatId) {
// 获取对话上下文级别的变量信息
String lastSearchNewsList = getLastSearchNewsList(chatId);
log.info("getHotNewsContent最近一次查询处理的新闻列表为={}", lastSearchNewsList);
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(SYS_PROMPT);
Message systemMessage = systemPromptTemplate.createMessage(Map.of("last_search_news_list", lastSearchNewsList));
Message userMessage = new UserMessage(userInput);
Prompt prompt = new Prompt(List.of(userMessage, systemMessage));
return chatClient.prompt(prompt)
.advisors(new MessageChatMemoryAdvisor(ChatMemoryTools.getChatMemory(chatId))).stream().chatResponse();
}
private String getLastSearchNewsList(String chatId) {
ChatMemory chatMemory = ChatMemoryTools.getChatMemory(chatId);
return ChatMemoryTools.getVariableFromMemory(chatId, "lastSearchNewsList");
}
}
在这个Agent设计中,我们使用了提示词模板,将多轮对话的关键变量(新闻列表)放进了提示词中,这样这个Agent在进行回答时结果会更加正确。
在SpringAI中还提供了结构化输出的能力,执行时机如下图所示:
具体到代码实现层面,使用下面的代码即可进行标准化的输出,这样大模型就不会额外新增一些别的信息,做到输出可控:
java
ChatMemory chatMemory = ChatMemoryTools.getChatMemory(chatId);
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(SYS_PROMPT);
Message systemMessage = systemPromptTemplate.createMessage(Map.of("news_search_param", JSON.toJSONString(newsParam)));
Message userMessage = new UserMessage(userInput);
List<HotNews> hotNewsList = chatClient
.prompt()
.system(systemMessage.getText())
.user(userMessage.getText())
.advisors(new MessageChatMemoryAdvisor(chatMemory))
.call()
// 通过Entity 方法保证输出的内容是符合Java Bean 定义的
.entity(new ParameterizedTypeReference<>() {
});
四、总结
本文介绍了一种基于LiteFlow和SpringAI的智能体流程编排方案,通过LiteFlow可以实现丰富且灵活的流程编排能力,此外通过ChatMemeory、Cache以及LiteFlow的上下文解决了各种生命周期变量的存储问题。
目前来看借助LiteFlow可以作为SpringAI实现中等复杂度的智能体流程编排的一种设计方案,但是相比较Dify和langGraph还是过于繁琐,在代码实现过程中所需要编写的代码量整体偏大,此外LiteFlow在循环流程控制方面支持有限,所以还是非常期待Spring社区未来能发布更为方便使用的官方流程编排方案。