ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringAI Function Calling实战:打通大模型与外部系统的智能应用开发

SpringAI Function Calling实战:打通大模型与外部系统的智能应用开发 1. 项目概述当SpringAI需要“打电话”给外部世界如果你正在用SpringAI构建一个智能对话应用比如一个能帮你订机票的聊天机器人你可能会遇到一个核心瓶颈大模型本身是个“信息孤岛”。它知道怎么跟你聊天能生成流畅的文本但它不知道今天的航班有哪些、价格多少、座位是否充足。这些实时、动态、结构化的数据都存在于你的业务系统、数据库或第三方API里。传统的做法可能是让AI生成一段包含用户需求的JSON然后你再去写一堆解析和调用的代码这个过程既繁琐又容易出错。这就是“Function Calling”要解决的痛点。它不是真的让AI去执行函数而是一种标准化的通信协议。简单来说就是你先告诉AI“我这里有这么几个工具函数可以用这是它们的名字、描述和参数格式。”然后当用户说“帮我订一张明天北京飞上海的机票”时AI不会直接回答“好的”而是会返回一个结构化的调用请求“请调用search_flights函数参数是departure_city北京arrival_city上海date明天。” 你的后端程序收到这个请求后就可以真正地去调用你的航班查询服务拿到结果后再交给AI由AI组织成自然语言回复给用户“找到以下航班CA1501 08:00起飞 经济舱价格1200元...”所以这个项目的核心价值在于它让SpringAI应用从一个“能说会道的文秘”进化成了一个“能协调各方资源的项目经理”。它打通了AI模型与外部业务系统的任督二脉是实现真正实用化AI应用的关键一步。无论你是想做一个智能客服、一个数据分析助手还是一个自动化工作流触发器只要涉及到需要查询外部数据或触发外部动作的场景Function Calling都是你必须掌握的技术。2. SpringAI中Function Calling的设计哲学与实现原理2.1 核心概念拆解为什么是“调用”而不是“执行”首先要厘清一个关键概念在SpringAI的Function Calling语境下AI模型永远不直接执行任何代码。这是一个至关重要的安全边界。模型的工作是“理解”和“规划”它根据你的函数定义和用户对话的历史判断当前是否需要、以及需要调用哪个函数并严格按照你定义的JSON Schema格式生成一个函数调用请求。这个过程可以类比为“点餐”。你是顾客用户AI是服务员模型厨房是你的后端系统。服务员AI有一本菜单函数定义列表上面写着“宫保鸡丁口味微辣/中辣/重辣”。你告诉服务员“来个辣点的宫保鸡丁”。服务员不会自己跑进厨房炒菜而是会写一张标准的点菜单函数调用请求“宫保鸡丁一份口味中辣”然后把这张单子递给后厨你的后端系统。后厨根据单子做菜再把做好的菜函数执行结果交给服务员由服务员端给你并说“您的宫保鸡丁来了请慢用。”SpringAI将这个“点餐-做菜-上菜”的流程抽象为三个核心组件FunctionCallback 这是“厨房”。它是一个接口你需要实现它在里面编写实际调用外部系统如数据库、REST API、内部服务的代码。它接收AI生成的参数执行逻辑并返回结果。FunctionCallbackRegistration 这是“菜单制作和递单员”。它负责将你实现的FunctionCallback包装起来并关联一个唯一的函数名和描述注册到AI模型中。同时它也负责在运行时接收AI的调用请求并分发给对应的FunctionCallback执行。工具函数定义 这是“菜单”本身。它通过Description等注解以JSON Schema的形式定义了函数的名称、描述和参数结构。这个定义必须足够清晰AI才能正确理解何时该调用它。2.2 SpringAI 1.0.0-M5的架构演进在SpringAI的早期版本和1.0.0-M5之后的版本中Function Calling的实现有显著优化更加强调声明式和类型安全。传统方式较为底层你需要手动构建ChatOptions在其中以ListFunctionCallback的形式添加函数并处理原始的ChatResponse来解析function call的消息。这种方式控制力强但代码繁琐。现代方式推荐基于RegisterBean或ServiceSpringAI鼓励你将一个ServiceBean直接暴露为函数。你只需要在这个Bean的方法上使用Description注解来描述函数和参数SpringAI会在运行时自动发现并注册它。这极大地简化了配置。例如定义一个查询天气的函数Service public class WeatherService { Description(根据城市名称查询实时天气信息返回温度和天气状况。) public WeatherInfo getWeather(Description(要查询天气的城市名称例如北京、上海) String city) { // 这里实现调用真实天气API的逻辑 return new WeatherInfo(city, 22, 晴); } }SpringAI会自动为getWeather方法生成函数定义并在对话中适时调用它。这种方式让业务逻辑和AI集成变得非常清晰和自然。2.3 底层协议与OpenAI等模型的交互SpringAI本身是一个抽象层它背后的实际模型可能是OpenAI的GPT-4、Anthropic的Claude或是开源的Llama 3.1等。Function Calling的协议最初由OpenAI制定现在已成为事实标准。当使用OpenAI作为后端时SpringAI会将你的函数定义列表在每次API调用时通过tools参数传递给OpenAI。OpenAI的模型在生成回复时如果判断需要调用函数就会在响应消息的tool_calls字段中返回一个或多个工具调用请求。SpringAI的ChatClient会解析这个响应自动调用对应的FunctionCallback并将执行结果作为新的上下文消息附加到对话历史中再次请求模型生成面向用户的最终回复。这个流程对开发者基本透明。你只需要关注1. 定义好函数2. 实现回调逻辑。SpringAI会处理好中间的序列化、反序列化和对话管理。3. 从零开始构建一个具备外部查询能力的智能助手让我们通过一个完整的案例构建一个“智能企业知识库助手”。这个助手不仅能回答通用问题还能查询公司内部的员工信息模拟从HR系统查询和项目状态模拟从项目管理工具查询。3.1 环境准备与依赖配置首先创建一个新的Spring Boot项目3.x版本并添加必要的依赖。这里以OpenAI为例你也可以替换为其他模型的starter。!-- pom.xml -- dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- SpringAI OpenAI Starter -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0-M5/version !-- 请使用最新版本 -- /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies在application.yml中配置你的OpenAI API密钥和基础URL如果你使用Azure OpenAI或其他代理spring: ai: openai: api-key: ${OPENAI_API_KEY:你的密钥} chat: options: model: gpt-4o-mini # 或 gpt-4-turbo, 确保模型支持function calling temperature: 0.7注意gpt-3.5-turbo的某些旧版本对Function Calling支持不完善建议使用gpt-4-turbo、gpt-4o或gpt-4o-mini以获得最佳效果和稳定性。temperature参数设置为0.7能在创造性和确定性之间取得较好平衡。3.2 定义领域模型与函数回调我们定义两个核心业务模型员工和项目。// Employee.java Data AllArgsConstructor public class Employee { Description(员工工号) private String employeeId; Description(员工姓名) private String name; Description(所属部门) private String department; Description(电子邮箱) private String email; } // Project.java Data AllArgsConstructor public class Project { Description(项目ID) private String projectId; Description(项目名称) private String projectName; Description(当前状态: PLANNING, IN_PROGRESS, DELAYED, COMPLETED) private String status; Description(项目负责人姓名) private String leadName; }接下来实现两个FunctionCallback。这里我们模拟数据真实场景中应替换为数据库或API调用。// EmployeeService.java Service public class EmployeeService { // 模拟一个内存中的员工数据库 private final MapString, Employee employeeDb Map.of( E1001, new Employee(E1001, 张三, 研发部, zhangsancompany.com), E1002, new Employee(E1002, 李四, 市场部, lisicompany.com), E1003, new Employee(E1003, 王五, 产品部, wangwucompany.com) ); Description(根据员工姓名或工号查询员工详细信息。至少需要提供姓名或工号中的一个。) public Employee findEmployee( Description(员工工号例如E1001) Nullable String employeeId, Description(员工姓名例如张三) Nullable String name) { // 模拟查询逻辑 if (employeeId ! null !employeeId.isBlank()) { return employeeDb.get(employeeId); } if (name ! null !name.isBlank()) { return employeeDb.values().stream() .filter(emp - name.equals(emp.getName())) .findFirst() .orElse(null); } // 如果两个参数都为空可以返回null或抛出异常AI会在提示中说明需要参数 return null; } } // ProjectService.java Service public class ProjectService { private final MapString, Project projectDb Map.of( P2024-001, new Project(P2024-001, SpringAI智能助手开发, IN_PROGRESS, 张三), P2024-002, new Project(P2024-002, 官网重构, COMPLETED, 李四), P2024-003, new Project(P2024-003, Q3市场推广, PLANNING, 王五) ); Description(根据项目ID或项目名称查询项目当前状态。) public Project getProjectStatus( Description(项目唯一ID例如P2024-001) Nullable String projectId, Description(项目名称关键字例如SpringAI) Nullable String projectNameKeyword) { if (projectId ! null !projectId.isBlank()) { return projectDb.get(projectId); } if (projectNameKeyword ! null !projectNameKeyword.isBlank()) { return projectDb.values().stream() .filter(proj - proj.getProjectName().contains(projectNameKeyword)) .findFirst() .orElse(null); } return null; } }关键点解析Description注解这是SpringAI用于生成函数JSON Schema的核心。务必为函数本身和每个参数提供清晰、准确的描述。描述的质量直接决定了AI模型能否正确理解和使用该函数。例如“查询员工信息”就比“获取员工数据”更好。参数设计我们设计了可选的、多条件的查询参数Nullable。这给了AI更大的灵活性。用户可能说“查一下张三”也可能说“工号E1001是谁”。AI会根据你的描述尝试填充它能识别的参数。模拟数据在实际项目中employeeDb和projectDb应替换为Repository层进行真正的数据库查询或HTTP客户端调用。3.3 配置与启用Function Calling在SpringAI的现代架构中如果你像上面一样使用了Service并标注了Description那么默认情况下当你在Controller中注入ChatClient时这些函数已经被自动注册了。但是为了更精细地控制我们可以通过配置ChatOptions来显式指定使用哪些函数并设置一些调用策略。// ChatConfig.java Configuration public class ChatConfig { Bean Primary public ChatOptions chatOptions() { // 这里可以配置全局的ChatOptions例如温度、模型等 return OpenAiChatOptions.builder() .withModel(gpt-4o-mini) .withTemperature(0.7) // 你可以选择不在这里指定tools让SpringAI自动发现所有Description函数 // .withTools(...) // 如果需要手动指定可以在这里配置 .build(); } }创建一个REST Controller来提供聊天接口// AIChatController.java RestController RequestMapping(/api/chat) public class AIChatController { private final ChatClient chatClient; public AIChatController(ChatClient chatClient) { this.chatClient chatClient; } PostMapping public String chat(RequestBody UserMessage userMessage) { // 构建一个简单的用户消息 UserMessage message new UserMessage(userMessage.getContent()); // 调用ChatClient。SpringAI会自动处理函数调用循环。 ChatResponse response chatClient.call(new Prompt(message)); // 返回AI的最终回复 return response.getResult().getOutput().getContent(); } Data public static class UserMessage { private String content; // 省略构造器和getter/setter } }到这里核心功能已经完成SpringAI会在后台自动完成以下工作发现EmployeeService.findEmployee和ProjectService.getProjectStatus这两个函数并生成它们的工具定义。在每次调用chatClient.call()时将这些工具定义发送给OpenAI。解析OpenAI的响应如果包含工具调用则自动执行对应的Java方法。将工具执行的结果作为新的上下文消息再次发送给OpenAI获取整合了外部信息的最终回复。将最终回复返回给调用者。3.4 测试与验证启动应用使用Postman或curl进行测试。测试用例1查询员工信息POST /api/chat Content-Type: application/json { content: 我们公司有个叫李四的员工吗他是哪个部门的 }预期AI回复“是的李四是我们公司的员工。他隶属于市场部工号是E1002邮箱是lisicompany.com。”背后发生的事AI理解问题需要查询员工信息。AI生成对findEmployee函数的调用请求参数为name李四。SpringAI执行EmployeeService.findEmployee(李四)得到Employee对象。AI收到员工数据组织成自然语言回复。测试用例2混合查询{ content: 张三在负责什么项目那个项目现在进度怎么样了 }预期AI回复“张三目前正在负责‘SpringAI智能助手开发’项目项目ID: P2024-001。该项目当前状态是‘进行中’。”背后发生的事AI首先需要知道“张三”是谁可能先调用findEmployee确认员工存在或获取工号。然后AI需要查询项目它可能会尝试调用getProjectStatus并使用leadName张三或从第一步结果中推断出的信息作为参数。SpringAI依次执行这些函数调用AI综合所有信息生成最终回复。实操心得在测试时打开Spring Boot的Debug日志logging.level.org.springframework.aiDEBUG会非常有帮助。你能清晰地看到AI发送的请求体包含工具定义、响应体包含工具调用以及SpringAI执行工具的过程。这是排查函数是否被正确识别和调用的最佳手段。4. 高级技巧与生产环境实战指南4.1 函数设计的艺术提升AI调用准确率函数设计是Function Calling成功与否的基石。糟糕的设计会导致AI无法理解、错误调用或遗漏调用。原则一单一职责描述精确一个函数只做一件事。getWeather就比getWeatherAndSetAlarm好。描述要使用动词宾语的结构明确输入输出。差处理数据优根据用户ID和日期范围查询该用户的订单交易记录返回订单列表。原则二参数设计考虑容错与多模态查询就像我们例子中的findEmployee支持通过ID或姓名查询。这符合人类的对话习惯。用户可能说“查一下E1001”也可能说“找张三的资料”。为关键参数添加Nullable注解并在函数内部实现优先级逻辑或复合查询。原则三利用枚举和严格类型对于状态、类型等参数使用枚举或明确的字符串常量并在描述中写清楚可选值。Description(更新任务状态。状态必须为以下之一TODO, IN_PROGRESS, BLOCKED, DONE) public void updateTaskStatus(String taskId, Description(任务新状态) TaskStatus status) { ... }这能极大减少AI因参数格式不匹配而导致的调用错误。4.2 处理复杂对话流与多轮函数调用在复杂的对话中AI可能需要连续调用多个函数或者根据前一个函数的结果决定下一个调用。SpringAI的ChatClient.call()方法默认就支持这种多轮交互因为它内部维护了一个ChatResponse的上下文其中包含了完整的消息历史用户消息、AI消息、工具调用消息、工具执行结果消息。但是你需要管理好对话的Session。通常的做法是为每个用户或每个聊天线程创建一个独立的ChatClient实例或至少维护一个独立的ListMessage上下文。在Web应用中你可以将上下文存储在HttpSession或分布式缓存如Redis中。Service public class ChatSessionService { private final MapString, ListMessage sessionContexts new ConcurrentHashMap(); public String chat(String sessionId, String userInput) { ListMessage messages sessionContexts.computeIfAbsent(sessionId, k - new ArrayList()); messages.add(new UserMessage(userInput)); Prompt prompt new Prompt(messages); ChatResponse response chatClient.call(prompt); // 将本次交互的所有新消息AI回复、工具调用、工具结果都添加到上下文中 messages.addAll(response.getResults().get(0).getOutput().getMessages()); // 通常只需要保留最后N轮对话以避免token超限这里简单示例 return response.getResult().getOutput().getContent(); } }关键陷阱Token限制与上下文管理大模型有上下文窗口限制如128K。每次对话你发送的整个消息历史包括所有函数调用和结果的JSON都会消耗Token。如果对话轮次很多或者函数返回的数据量很大比如查询了一个包含100条记录的列表很容易触达限制导致模型无法继续处理。解决方案摘要Summarization对于函数返回的庞大数据不要原样塞回上下文。可以设计一个“摘要函数”让AI先对数据进行摘要或者在你的回调函数中先进行一步数据精简。选择性记忆定期清理旧的、不重要的消息。只保留最近几轮对话和关键的系统指令。分页查询设计函数时支持分页参数如getRecentOrders(userId, page, size)避免一次性返回过多数据。4.3 错误处理与稳定性保障在真实环境中外部系统可能不可用、网络可能超时、参数可能无效。必须为Function Calling添加健壮的错误处理。策略一在FunctionCallback内部捕获异常在回调函数内部使用try-catch捕获所有已知异常并返回一个结构化的错误信息而不是让异常抛出导致整个AI调用链中断。public Object someFunction(String param) { try { // 调用外部API return externalService.call(param); } catch (ExternalServiceException e) { // 返回一个AI能理解的错误描述 return Map.of( error, true, message, 调用XX系统失败原因 e.getMessage(), suggestion, 请检查参数是否正确或稍后重试。 ); } }策略二使用SpringAI的FunctionCallbackWrapper进行包装SpringAI提供了工具类来包装你的回调允许你定义错误处理逻辑。FunctionCallback wrappedCallback FunctionCallbackWrapper.builder(new MyCallback()) .withErrorHandler((functionCallbackContext, throwable) - { // 自定义错误处理逻辑可以返回一个特定的错误结果对象 return new MyErrorResult(Function failed, throwable.getMessage()); }) .build();策略三设置超时与重试如果调用的是远程HTTP服务务必在HTTP客户端如RestTemplate、WebClient或Feign Client上配置合理的连接超时和读取超时。对于暂时性故障可以考虑加入重试机制使用Spring Retry等但要注意幂等性。4.4 安全与权限考量让AI能够调用业务函数是一个强大的能力但也带来了安全风险。必须确保AI调用的函数在授权范围内。输入验证与净化永远不要相信AI生成的参数。在FunctionCallback内部必须对输入参数进行严格的验证长度、类型、范围、SQL注入/XSS过滤等就像处理任何用户输入一样。基于会话的权限控制在回调函数中可以访问当前的ConversationContext或通过ThreadLocal等方式获取当前用户身份。根据用户角色决定是否允许执行该函数或访问特定数据。Service public class SecureEmployeeService { Autowired private SecurityContext securityContext; Description(查询员工信息仅限HR和管理员) public Employee findEmployeeSecure(String employeeId) { Authentication auth securityContext.getAuthentication(); if (!auth.getAuthorities().contains(ROLE_HR) !auth.getAuthorities().contains(ROLE_ADMIN)) { return null; // 或抛出自定义异常在错误处理中返回友好提示 } // ... 查询逻辑 } }审计日志记录所有AI发起的函数调用包括调用时间、函数名、参数、执行结果可脱敏。这对于问题排查、安全审计和了解AI行为模式至关重要。5. 常见问题排查与性能优化5.1 函数不被调用诊断步骤一览表问题现象可能原因排查步骤与解决方案AI完全忽略函数直接回答“我不知道”1. 函数未正确注册到模型。2. 函数描述不清晰AI不理解何时使用。3. 用户问题确实不需要调用函数。1. 检查日志确认启动时是否输出了注册函数的日志。2. 将Description写得更具体明确使用场景。例如“当用户询问公司内部人员联系方式或部门信息时调用此函数。”3. 在Prompt的系统指令中明确要求AI使用工具。例如“你是一个助手可以调用工具来查询信息。当用户问题涉及公司数据时请优先使用工具。”AI尝试调用函数但参数错误或为空1. 参数描述不清。2. 用户表述模糊AI无法提取有效参数。3. 参数定义为非空但AI传了空。1. 为每个参数提供示例值如Description(城市名称例如北京、上海)。2. 在函数实现中增加对空参数的健壮性处理返回友好错误。3. 考虑设计多轮对话当参数不足时让AI主动追问用户。调用超时或响应慢1. 外部API响应慢。2. 模型生成速度慢。3. 网络延迟。1. 为外部调用设置超时如3-5秒并实现降级策略返回缓存数据或提示“查询中”。2. 考虑使用更快的模型如gpt-4o-mini比gpt-4-turbo快。3. 使用异步调用将函数调用改为返回Mono或CompletableFutureSpringAI支持异步工具调用可以避免阻塞。返回结果后AI的总结回复质量差1. 函数返回的数据结构太复杂或非结构化。2. 返回数据量过大干扰了AI的总结能力。1. 优化返回对象使其字段命名清晰、值简洁。避免嵌套过深。2. 对数据进行预处理只返回核心字段。或者在函数中先进行初步的文本摘要再将摘要交给AI。5.2 性能优化实战批量处理与缓存如果AI可能频繁查询相同数据如公司部门列表可以在FunctionCallback中引入缓存如Caffeine。首次查询后缓存结果短期内相同查询直接返回缓存大幅降低外部系统压力。异步函数调用对于耗时的IO操作如调用多个外部API使用异步非阻塞的实现。SpringAI支持返回Mono的反应式类型。Description(异步查询多个信息源) public MonoCompositeResult fetchDataAsync(String query) { return Mono.zip( callExternalApi1(query), callExternalApi2(query) ).map(tuple - new CompositeResult(tuple.getT1(), tuple.getT2())); }精简上下文如前所述定期清理对话历史。对于长会话可以尝试只保留最近几轮对话和系统指令或者让AI主动对历史对话进行摘要。5.3 调试技巧窥探AI与函数的对话启用DEBUG日志是最基本的。此外你可以在FunctionCallback的实现中在开头和结尾打印详细的入参和出参日志。更有用的是一些AI提供商如OpenAI的API返回中包含usage字段记录了本次调用消耗的Prompt Token和Completion Token数量。监控这个数据有助于你优化提示词和函数设计控制成本。最后一个我个人在实践中总结出的黄金法则是从最简单的单个函数开始测试。确保它能被稳定、正确地调用和返回后再逐步增加函数复杂度和对话复杂度。同时为你的AI应用编写一套完整的集成测试用例模拟各种用户提问场景这是保证线上稳定性的不二法门。Function Calling将AI从“玩具”变成了“工具”而一个可靠的工具必须经过严格的测试和打磨。
返回列表