我们之前讨论并实践过通过常规的函数调用来实现 AI Agent 的设计和实现。但是,有一个关键点我之前并没有详细讲解。今天我们就来讨论一下,如何让大模型只决定是否调用某个函数,但是Spring AI 不会在内部处理函数调用,而是将其代理到客户端。然后,客户端负责处理函数调用,将其分派到相应的函数并返回结果。

好的,我们开始。

函数调用

核心代码

函数调用是开发AI Agent的关键组成部分,它使得AI能够与外部系统、数据库或其他服务进行交互,从而提升了其功能性和灵活性。所以开发必须要适用于支持函数调用的聊天模型,在Spring AI中处理函数调用也仅仅是一行代码,核心代码如下,我们看下:

if (!isProxyToolCalls(prompt, this.defaultOptions)
&& isToolCall(response, Set.of(OpenAiApi.ChatCompletionFinishReason.TOOL_CALLS.name(),
OpenAiApi.ChatCompletionFinishReason.STOP.name()))) {
var toolCallConversation = handleToolCalls(prompt, response);
return this.internalCall(new Prompt(toolCallConversation, prompt.getOptions()), response);
}

假设我们已经开发并集成了一个天气查询函数,当我们向大模型提出类似“长春天气咋样”这样的请求时,大模型会自动识别并选择调用相应的函数。在这个过程中,handleToolCalls 方法通过反射机制来动态地调用正确的天气查询方法,接着该方法会递归调用 internalCall 方法,继续处理后续的逻辑。需要注意的是,关于反射机制和递归调用的具体实现细节,在前文中已经有所说明,因此此处不再赘述。

判断是否是函数

protected boolean isToolCall(Generation generation, Set<String> toolCallFinishReasons) {
var finishReason = (generation.getMetadata().getFinishReason() != null)
? generation.getMetadata().getFinishReason() : "";
return generation.getOutput().hasToolCalls() && toolCallFinishReasons.stream()
.map(s -> s.toLowerCase())
.toList()
.contains(finishReason.toLowerCase());
}

isToolCall的核心逻辑就是要判断大模型返回的信息是否正确,OpenAI的API文档如下:

重写判断

如果你的大模型返回的格式不一样,那么重写方法即可,比如minimax就重写了,我们看下:

protected boolean isToolCall(Generation generation, Set<String> toolCallFinishReasons) {
if (!super.isToolCall(generation, toolCallFinishReasons)) {
return false;
}
return generation.getOutput()
.getToolCalls()
.stream()
.anyMatch(toolCall -> org.springframework.ai.minimax.api.MiniMaxApiConstants.TOOL_CALL_FUNCTION_TYPE
.equals(toolCall.type()));
}

他在原有的基础上又再次判断了一下toolCall.type是否为function,因为minimax不仅支持function类型的type,还支持web_search,看下官方文档,如图所示:

不要细究为什么他会有这个类型,只需要明白你可以根据不同大模型接口重写isToolCall方法判断即可!

函数自动调用开关

前面提到之所以会默认调用函数并再次进行大模型调用以进行润色并返回参考结果,关键原因在于 isProxyToolCalls 参数默认设置为 false。这个参数充当了一个控制开关,用来决定是由用户自行处理相关逻辑,还是由 Spring AI 自动进行处理并进行润色。

具体而言,用户可以通过设置该开关来选择是手动管理流程,还是让系统自动完成这一过程。以下是该控制开关的核心代码示例:

OpenAiChatOptions openAiChatOptions = OpenAiChatOptions.builder().withProxyToolCalls(true).build();

此时一旦你打开此开关,你就需要自己进行处理本次结果了。大模型将仅返回调用的参数以及其思考过程的输出,具体内容如下所示:

没返回参数等信息,是因为我把其他信息丢弃了,你可以这样写:

 ChatResponse content = this.chatClient
.prompt(systemPrompt)
.user(userInput)
.options(openAiChatOptions)
.advisors(messageChatMemoryAdvisor,myLoggerAdvisor)
.functions("CurrentWeather","TravelPlanning","toDoListFunctionWithContext","myWorkFlowServiceCall")
.call();
// .content();这里只返回string,也就是思考结果

好的。一旦你获得了 ChatResponse 类的实例后,你就可以根据需要自由地操作该对象,并调用其中的各种函数了。你不必从头编写所有的代码,实际上,你可以参考 OpenAI 提供的测试样例,这样会大大简化你的开发过程。以下是一个参考示例:

FunctionCallback functionDefinition = new FunctionCallingHelper.FunctionDefinition("getWeatherInLocation",
"Get the weather in location", """
{
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state e.g. San Francisco, CA"
},
"unit": {
"type": "string",
"enum": ["C", "F"]
}
},
"required": ["location", "unit"]
}
"""); @Autowired
private OpenAiChatModel chatModel; private FunctionCallingHelper functionCallingHelper = new FunctionCallingHelper(); @SuppressWarnings("unchecked")
private static Map<String, String> getFunctionArguments(String functionArguments) {
try {
return new ObjectMapper().readValue(functionArguments, Map.class);
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
} // Function which will be called by the AI model.
private String getWeatherInLocation(String location, String unit) { double temperature = 0; if (location.contains("Paris")) {
temperature = 15;
}
else if (location.contains("Tokyo")) {
temperature = 10;
}
else if (location.contains("San Francisco")) {
temperature = 30;
} return String.format("The weather in %s is %s%s", location, temperature, unit);
} void functionCall() throws JsonMappingException, JsonProcessingException { List<Message> messages = List
.of(new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?")); var promptOptions = OpenAiChatOptions.builder().functionCallbacks(List.of(this.functionDefinition)).build(); var prompt = new Prompt(messages, promptOptions); boolean isToolCall = false; ChatResponse chatResponse = null; do { chatResponse = this.chatModel.call(prompt); isToolCall = this.functionCallingHelper.isToolCall(chatResponse,
Set.of(OpenAiApi.ChatCompletionFinishReason.TOOL_CALLS.name(),
OpenAiApi.ChatCompletionFinishReason.STOP.name())); if (isToolCall) { Optional<Generation> toolCallGeneration = chatResponse.getResults()
.stream()
.filter(g -> !CollectionUtils.isEmpty(g.getOutput().getToolCalls()))
.findFirst(); AssistantMessage assistantMessage = toolCallGeneration.get().getOutput(); List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>(); for (AssistantMessage.ToolCall toolCall : assistantMessage.getToolCalls()) { var functionName = toolCall.name(); String functionArguments = toolCall.arguments(); @SuppressWarnings("unchecked")
Map<String, String> argumentsMap = new ObjectMapper().readValue(functionArguments, Map.class); String functionResponse = getWeatherInLocation(argumentsMap.get("location").toString(),
argumentsMap.get("unit").toString()); toolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), functionName,
ModelOptionsUtils.toJsonString(functionResponse)));
} ToolResponseMessage toolMessageResponse = new ToolResponseMessage(toolResponses, Map.of()); List<Message> toolCallConversation = this.functionCallingHelper
.buildToolCallConversation(prompt.getInstructions(), assistantMessage, toolMessageResponse); prompt = new Prompt(toolCallConversation, prompt.getOptions());
}
}
while (isToolCall); logger.info("Response: {}", chatResponse); assertThat(chatResponse.getResult().getOutput().getText()).contains("30", "10", "15");
}

这段代码采用了 while 循环来实现默认情况下调用大模型进行润色的逻辑。你可以选择去掉这一部分逻辑,改为直接调用你自己定义的函数,这样就可以绕过大模型的润色过程,直接将结果返回给客户端。通过这种方式,你能够轻松实现类似市面上大多数智能体平台所提供的功能:即在不同场景下,可以选择是否使用固定格式的回答,或是直接采用大模型的回答。

聊天记录维护

这里有几个需要特别注意的关键点。首先,你必须将每次调用后的结果主动封装并更新到历史聊天记录中。如果不这样做,一旦信息顺序或格式出现混乱,系统会直接报错。因此,确保按正确的顺序进行操作是至关重要的。正常的操作流程应遵循如下顺序:

你可以看到测试样例中是有这一步操作的,在这一行代码buildToolCallConversation,代码追到后面就是这样的核心逻辑,代码如下:

protected List<Message> buildToolCallConversation(List<Message> previousMessages, AssistantMessage assistantMessage,
ToolResponseMessage toolResponseMessage) {
List<Message> messages = new ArrayList<>(previousMessages);
messages.add(assistantMessage);
messages.add(toolResponseMessage);
return messages;
}

总结

通过今天的讨论,我们首先了解了如何实现函数调用的基础机制,通过核心代码示例展示了如何在Spring AI中进行函数的动态调用。在此过程中,关键的isToolCall方法和函数自动调用开关的使用,确保了我们可以根据具体需求调整函数调用的方式,甚至完全由客户端来接管函数执行。此外,通过维护聊天记录并精心管理工具调用的顺序,我们能确保AI的行为更为可控和稳定。

总的来说,今天的分享为大家提供了一种新的思路,使得在开发AI Agent时,我们不仅仅依赖大模型的内建能力,还可以通过客户端控制函数的调用和返回结果,从而打造更加灵活和高效的智能系统。这种方式无疑为开发者提供了更多定制化的选择,提升了开发过程的自由度和效率。


我是努力的小雨,一个正经的 Java 东北服务端开发,整天琢磨着 AI 技术这块儿的奥秘。特爱跟人交流技术,喜欢把自己的心得和大家分享。还当上了腾讯云创作之星,阿里云专家博主,华为云云享专家,掘金优秀作者。各种征文、开源比赛的牌子也拿了。

想把我在技术路上走过的弯路和经验全都分享出来,给你们的学习和成长带来点启发,帮一把。

欢迎关注努力的小雨,咱一块儿进步!

深入解析 Spring AI 系列:解析函数调用的更多相关文章

  1. Spring Boot 系列教程11-html页面解析-jsoup

    需求 需要对一个页面进行数据抓取,并导出doc文档 html解析器 jsoup 可直接解析某个URL地址.HTML文本内容.它提供了一套非常省力的API,可通过DOM,CSS以及类似于JQuery的操 ...

  2. Spring源码解析系列汇总

    相信我,你会收藏这篇文章的 本篇文章是这段时间撸出来的Spring源码解析系列文章的汇总,总共包含以下专题.喜欢的同学可以收藏起来以备不时之需 SpringIOC源码解析(上) 本篇文章搭建了IOC源 ...

  3. Spring Boot系列(三):Spring Boot整合Mybatis源码解析

    一.Mybatis回顾 1.MyBatis介绍 Mybatis是一个半ORM框架,它使用简单的 XML 或注解用于配置和原始映射,将接口和Java的POJOs(普通的Java 对象)映射成数据库中的记 ...

  4. Spring Boot系列(四):Spring Boot源码解析

    一.自动装配原理 之前博文已经讲过,@SpringBootApplication继承了@EnableAutoConfiguration,该注解导入了AutoConfigurationImport Se ...

  5. Spring Cloud系列(三):Eureka源码解析之服务端

    一.自动装配 1.根据自动装配原理(详见:Spring Boot系列(二):Spring Boot自动装配原理解析),找到spring-cloud-starter-netflix-eureka-ser ...

  6. Spring Cloud系列(四):Eureka源码解析之客户端

    一.自动装配 1.根据自动装配原理(详见:Spring Boot系列(二):Spring Boot自动装配原理解析),找到spring-cloud-netflix-eureka-client.jar的 ...

  7. Spring源码解析-ioc容器的设计

    Spring源码解析-ioc容器的设计 1 IoC容器系列的设计:BeanFactory和ApplicatioContext 在Spring容器中,主要分为两个主要的容器系列,一个是实现BeanFac ...

  8. Spring技术内幕——深入解析Spring架构与设计原理(一)IOC实现原理

    IOC的基础 下面我们从IOC/AOP开始,它们是Spring平台实现的核心部分:虽然,我们一开始大多只是在这个层面上,做一些配置和外部特性的使用工作,但对这两个核心模块工作原理和运作机制的理解,对深 ...

  9. Spring 是如何解析泛型 - ResolvalbeType

    Spring 是如何解析泛型 - ResolvalbeType Spring 系列目录(https://www.cnblogs.com/binarylei/p/10198698.html) Java ...

  10. Spring Framework框架解析(1)- 从图书馆示例来看xml文件的加载过程

    引言 这个系列是我阅读Spring源码后的一个总结,会从Spring Framework框架的整体结构进行分析,不会先入为主的讲解IOC或者AOP的原理,如果读者有使用Spring的经验再好不过.鉴于 ...

随机推荐

  1. 一文彻底弄懂JUC工具包的Semaphore

    Semaphore 是 Java 并发包 (java.util.concurrent) 中的重要工具,主要用于控制多线程对共享资源的并发访问量.它可以设置"许可证"(permit) ...

  2. 高性能的Reactor和Proactor模式学习

    0.引言 在上一篇的笔记中,我们学习了操作系统提供的高效I/O管理技术,主要用于解决服务器在高并发场景下的资源浪费和瓶颈问题.但是在实际的代码编写中,要是我们都全部调用底层的I/O多路复用接口来编写网 ...

  3. Taro首个支持鸿蒙的 UI 库,同时还兼容 React Native、小程序、H5

    Taro 4.0 已经推出一段时间了,4.0 版本主要是支持了鸿蒙端的开发以及 Vite 编译工具的支持.duxapp 在这段时间也跟随 Taro 的脚步,实现的对鸿蒙端的支持,并且也将之前的 dux ...

  4. AI运动小程序开发常见问题集锦二

    截止到现在写博文时,我们的AI运动识别小程序插件已经迭代了23个版本,成功应用于健身.体育.体测.AR互动等场景:为了让正在集成或者计划进行功能扩展优化的用户,少走弯路.投入更少的开发资源,针对近期的 ...

  5. Blazor 组件库 BootstrapBlazor 中EditorForm组件介绍

    组件介绍 EditorForm 组件是一个非常实用的组件,当进行数据编辑时,仅需要将 Model 属性赋值即可. 绑定模型默认自动生成全部属性,可以通过设置 AutoGenerateAllItem 更 ...

  6. The 2023 ICPC Asia Hong Kong Regional Programming Contest

    The 2023 ICPC Asia Hong Kong Regional Programming Contest A. TreeScript 给你一个根,让你构造一棵树,每个节点被创造的时候必须知道 ...

  7. Codeforces Round 797 (Div

    Codeforces Round 797 (Div. 3) Price Maximization 给定\(n\)个商品(n为偶数),每个商品的重量为\(a_i\),你需要将其两两打包,打包的成本为\( ...

  8. 使用nginx 解决开发过程中的跨域问题

    遇到的问题 在开发vue 前端程序时,我们会创建多个项目,比如用户管理为一个应用,系统管理为一个应用,这样多个应用势必需要开多个端口,这样问题就来了,我们在登录后会生成一个token,这个token我 ...

  9. 不求甚解--详解ansible-playbook中roles的用法

    前言 本文将详细介绍ansible-playbook中roles的各种用法,它允许你将相关的任务.变量.处理器.文件和模板等集合在一起,以便于在不同的项目中复用 环境准备 组件 版本 操作系统 Ubu ...

  10. python+playwright安装+使用vsocde运行代码

    python虚拟环境 1.安装python,环境配置 2.修改pip镜像源 3.新增虚拟环境 注意路径,例子的路径是在python的目录下生成一个venv文件夹 进入venv文件夹,使用virtual ...