我们之前讨论并实践过通过常规的函数调用来实现 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. 查看Mysql数据库数据量大小、表大小、索引大小

    通过MySQL的information_schema数据库,可查询数据库中每个表占用的空间.表记录的行数: 该库中有一个TABLES表,这个表主要字段分别是: TABLE_SCHEMA:数据库名 TA ...

  2. win11 与VMware pro16不兼容或者是不能嵌套虚拟或者此平台不支持虚拟化的Intel VT-x/EPT等问题

    如遇 不用去掉啥windows沙盒,不用关掉什么Hyper-V. 可以在window11的应用里面的可选功能把虚拟平台的勾去掉然后重启一下 而虚拟机不在配置处理器的时候不勾选下图这个 就会出现这种情况 ...

  3. ExtJS & Asp.NET

    企业应用中,要快速开发Web应用,前端使用ExtJS还行,包含许多常用的控件,图标,配色方案... 帖上部分载图: 完全可订制的登录界面: 可调整的布局: 可综合使用的模态对话框: 树结构及动态加载: ...

  4. pytorch的四个hook函数

    训练神经网络模型有时需要观察模型内部模块的输入输出,或是期望在不修改原始模块结构的情况下调整中间模块的输出,pytorch可以用hook回调函数来实现这一功能.主要使用四个hook注册函数:regis ...

  5. 移动端NES网页模拟器(1)

    前言 移动端浏览器是没有实体键盘的,想要操作游戏就必须为其设置虚拟按键,通过虚拟按键(按钮)的标识与实体键盘的keyCode进行绑定,来达到想要的效果. 这个随笔只封装NES游戏手柄右边的按键,不包含 ...

  6. (一)Springboot + vue + 达梦数据库构建RBAC权限模型前后端分离脚手架保姆级教程(界面截图)

    用户登录  系统首页  用户列表  添加用户  修改用户  角色列表  添加角色  修改角色  

  7. 人工智能大语言模型起源篇,低秩微调(LoRA)

    上一篇: <规模法则(Scaling Law)与参数效率的提高> 序言:您在找工作时会不会经常听到LoRA微调,这项技术的来源就是这里了. (12)Hu.Shen.Wallis.Allen ...

  8. docker compose的安装

    1,安装docker ### CentOS8 默认是会读取centos.org的mirrorlist的,所以一般来说是不需要配置镜像的. # step 1: 安装必要的一些系统工具 sudo yum ...

  9. R数据分析,codewar的年终总结,和一周年总结,寒假快乐呀

    前阵子单位各个部门都在要求弄总结,想想自己这个公众号也写了快一年了,专门回去翻了翻,这个公众号发布的第一篇文章是在2021年的1月17日,我想2022年的1月17日我就把现在敲的文字推出来吧,也算是一 ...

  10. 《刚刚问世》系列初窥篇-Java+Playwright自动化测试-8- 元素高级定位技巧(详细教程)

    1.简介 随着网页的复杂性和动态性的增加,自动化测试变得越来越重要.Playwright作为一款强大的无头浏览器测试库,提供了多种元素定位方式,使得我们能够轻松地对网页进行自动化操作.在基础的定位方式 ...