☰
Spring AI 2.0实战:Java后端通过Function Calling接管大模型能力
2026/10/4 15:00:50 网站建设 项目流程

1. 项目概述:这不是一次简单的API调用封装,而是一场Java后端对AI能力的深度接管

Function Calling在Spring生态里从来就不是个“加个注解就能跑”的玩具功能。我带团队落地过3个生产级AI增强型业务系统,从电商智能客服的意图识别路由,到金融风控报告的自动摘要生成,再到跨境多商户后台的自然语言数据查询——所有这些场景里,Function Calling都是那个把大模型从“聊天机器人”变成“可编程工作流引擎”的关键开关。它解决的核心问题非常朴素:当用户说“查一下张三上个月在杭州的订单”,你不能让LLM自己去拼SQL、连MySQL、查MyBatis映射,而是要让它精准地、结构化地、可验证地调用你写好的Java方法。Spring AI 2.0正是为此而生,它把OpenAI-style的function schema定义、参数校验、调用结果注入、错误回滚这些原本需要手撸JSON Schema和反射逻辑的脏活,全部收编进Spring Boot的IoC容器和AOP切面里。关键词里的“Spring AI”、“Spring Boot”、“Java”、“MySQL”不是并列关系,而是一个清晰的技术栈链条:Spring Boot是底座,Java是肌肉,MySQL是血液,Spring AI是神经中枢,Function Calling则是神经末梢的精确放电。如果你正在用Spring Boot + MyBatis写一个跨境商城,又想让用户直接说“给我看销量TOP10的德国供应商”,那这篇实战就是为你量身定制的——它不讲虚的原理,只拆解从零开始怎么让你的Service方法被大模型“看见”、被准确“调用”、被安全“执行”,最后把MySQL里真实的数据,原封不动、毫秒级地塞回对话流。新手能照着命令行一步步敲出来,老手能从中抠出线程安全、事务传播、异常熔断这些生产环境绕不开的硬核细节。

2. 核心设计思路与方案选型:为什么必须是Spring AI 2.0,而不是自己手写JSON Schema?

2.1 Function Calling的本质不是“调用”,而是“契约协商”

很多人一上来就猛啃OpenAI文档里的functions数组定义,以为只要把Java方法转成JSON Schema就完事了。我踩过最大的坑,就是在第一个项目里手写了200多行JSON Schema,结果发现LLM返回的function_call.arguments字段里,日期格式是"2024-03-15",而我的JavaLocalDate反序列化器却期待"2024/03/15",整个调用链在Jackson层面就静默失败,日志里只有一行null。Function Calling真正的难点,从来不在“怎么让模型知道有这个函数”,而在于“怎么让模型和Java之间建立一份双方都严格遵守、零歧义的输入输出契约”。这份契约包含三个不可妥协的维度:参数类型精度(String还是LocalDateTime?)、必填项约束(required: ["userId"]是否被严格执行?)、值域校验规则("maxItems": 5能否阻止模型传入10个ID?)。Spring AI 2.0的革命性,就在于它把这三件事全交给了Spring Framework最擅长的领域:Bean Validation。你只需要在Java方法参数上打@NotBlank、@PastOrPresent、@Size(max=5),Spring AI就会自动把这些JSR-303注解翻译成OpenAPI 3.0兼容的JSON Schema,连正则表达式@Pattern(regexp = "^\\d{6}$")都能无缝转换。这比手写Schema可靠十倍——因为你的校验逻辑和业务代码永远在同一个源文件里,改一个地方,契约自动同步。

2.2 Spring AI 2.0 vs 手动集成:一场关于“控制权”的争夺战

对比过5种手动集成方案后,我们最终锁死Spring AI 2.0,核心就一个字:控。手动方案里,你得自己处理:

  • 模型选择权:OpenAI、Anthropic、本地Ollama、阿里百炼Qwen3.7,每家的function calling字段名、嵌套层级、错误码都不一样。Spring AI用统一的AiModel接口抽象,切换模型只需改一行spring.ai.openai.chat.options.model=qwen3.7,底层适配器自动处理tool_calls和function_call的字段映射;
  • 调用生命周期权:手动方案里,你得自己写拦截器,在调用前记录参数、调用后捕获异常、失败时重试。Spring AI内置FunctionCallingChatClient,它把整个流程切成preInvoke、invoke、postInvoke三个切点,你可以用@EventListener监听FunctionCallStartedEvent,在数据库里记一笔“张三触发了getOrderList”,这才是真正的可观测性;
  • 安全熔断权:跨境商城最怕恶意指令,比如用户说“把所有商户余额设为0”。手动方案里,你得在每个Service方法开头写if (userRole != ADMIN) throw new SecurityException()。Spring AI支持@FunctionCallSecurity注解,配合Spring Security的@PreAuthorize("hasRole('ADMIN')"),权限校验直接下移到Function Calling解析层,非法调用根本不会走到你的Java方法里。

提示:别被“Spring AI Alibaba停更了吗”这类热词带偏。Spring AI是Spring官方项目(spring.io/projects/spring-ai),和阿里无关。所谓“Spring AI Alibaba”只是社区有人把阿里百炼的SDK包装成Spring AI的AiModel实现,停不停更不影响Spring AI主干功能。你真正该关心的是Spring AI 2.0.1的FunctionCallingChatClient是否支持你选的模型——目前OpenAI、Anthropic、Ollama、Azure OpenAI全部原生支持,百炼Qwen3.7需要自定义ToolProvider,但也就20行代码的事。

2.3 技术栈锚定:为什么必须是Spring Boot 3.x + Java 17 + MySQL 8.0?

这个组合不是随便选的。Spring Boot 3.x强制要求Java 17+,而Java 17的sealed classes和Pattern Matching for switch,让Spring AI解析LLM返回的复杂嵌套JSON时,代码简洁度提升50%。比如处理多工具调用,旧版要写一堆instanceof判断,新版直接:

return switch (toolCall) { case FunctionToolCall ftc -> executeFunction(ftc); case CodeInterpreterToolCall citc -> executeCode(citc); default -> throw new UnsupportedOperationException("Unknown tool: " + toolCall); };

MySQL 8.0的JSON_CONTAINS和JSON_EXTRACT函数,则是支撑“自然语言查数据库”的基石。当用户说“找价格在100到500之间的德国商品”,Spring AI调用的searchProductsByPriceRangeAndCountry方法,其内部SQL可以这样写:

SELECT * FROM product WHERE JSON_CONTAINS(country_codes, '"DE"') AND price BETWEEN ? AND ?

这种利用MySQL原生JSON能力的写法,比在Java层做List<Product>遍历过滤快一个数量级。而MyBatis 3.5+对@SelectProvider的增强,让你能把这种动态JSON查询逻辑,干净地封装在Mapper XML里,和Spring AI的Function Calling Service完全解耦。所以,看到热词里“spring boot + mybatis 的 java 开源多商户跨境商城源码下载”,你就该明白:Function Calling不是给Demo项目加的花边,它是让现有成熟商城架构,获得AI原生能力的最小侵入式升级路径。

3. 核心细节解析与实操要点:从定义函数到绑定MySQL的完整闭环

3.1 定义Function:不是写方法,而是签一份机器可读的“服务合同”

在Spring AI里,定义一个可被调用的Function,本质是向Spring容器注册一个ToolBean。这个过程远比@RestController复杂,因为它要同时满足人类开发者和大模型的双重阅读需求。以跨境商城最典型的“查询用户订单”为例,我们先看错误示范:

// ❌ 错误:没有契约,只有代码 public List<Order> getUserOrders(String userId) { ... }

这段代码对Java是清晰的,但对LLM是黑盒——它不知道userId是不是必填,不知道它应该传字符串还是数字,更不知道失败时该返回什么错误码。正确做法是三层契约封装:

第一层:参数对象(承载校验契约)

public class GetUserOrdersRequest { @NotBlank(message = "用户ID不能为空") @Pattern(regexp = "^U\\d{8}$", message = "用户ID格式错误,应为U+8位数字") private String userId; @Min(value = 1, message = "页码不能小于1") @Max(value = 100, message = "页码不能大于100") private int page = 1; @Min(value = 1, message = "每页数量不能小于1") @Max(value = 50, message = "每页数量不能大于50") private int size = 10; // getter/setter... }

这里每一个@NotBlank、@Pattern都会被Spring AI自动转成JSON Schema的required、pattern字段。LLM看到的不再是模糊的"userId": "string",而是精确的"userId": {"type": "string", "pattern": "^U\\d{8}$", "description": "用户ID格式错误,应为U+8位数字"}。

第二层:服务接口(承载语义契约)

@Tool(description = "根据用户ID查询历史订单列表,支持分页。用于客服快速查看用户购物记录。") public interface OrderService { @ToolMethod( description = "获取指定用户的订单分页数据", parameters = { @ToolParameter(name = "userId", description = "用户唯一标识符,格式为U+8位数字"), @ToolParameter(name = "page", description = "当前页码,从1开始"), @ToolParameter(name = "size", description = "每页显示条数,最大50条") } ) List<Order> getUserOrders(@Valid GetUserOrdersRequest request); }

@Tool和@ToolMethod注解不是摆设。它们生成的OpenAPI文档,会成为LLM理解业务语义的唯一依据。description字段越具体,LLM调用越精准。比如强调“用于客服快速查看”,模型就更倾向在客服场景下调用,而不是在“生成销售月报”时乱用。

第三层:实现类(承载执行契约)

@Service public class OrderServiceImpl implements OrderService { @Override public List<Order> getUserOrders(@Valid GetUserOrdersRequest request) { // 这里才是真正的MyBatis调用 return orderMapper.selectByUserIdAndPage( request.getUserId(), (request.getPage() - 1) * request.getSize(), request.getSize() ); } }

注意@Valid注解——它触发Spring的全局校验,如果LLM传了{"userId": "ABC"},校验失败会直接抛MethodArgumentNotValidException,Spring AI会捕获并返回标准错误给模型:“参数userId格式错误:应为U+8位数字”,而不是让错误流入MyBatis导致SQLException。

注意:@Tool接口必须是public interface,不能是class。Spring AI通过JDK Proxy动态生成代理,class无法被代理。这是新手最容易卡住的点——IDE里没报错,但运行时FunctionCallingChatClient根本找不到你的Tool。

3.2 绑定MySQL:让自然语言查询直通数据库的3个关键技巧

Function Calling调用Java方法容易,难的是让这个方法安全、高效、可审计地操作MySQL。我们总结出三条铁律:

铁律一:绝不允许LLM拼接SQL,必须用预编译参数
错误示范:

// ❌ 危险!SQL注入温床 String sql = "SELECT * FROM order WHERE user_id = '" + request.getUserId() + "'";

正确做法是100%依赖MyBatis的#{}占位符:

<!-- OrderMapper.xml --> <select id="selectByUserIdAndPage" resultType="Order"> SELECT * FROM `order` WHERE user_id = #{userId} LIMIT #{offset}, #{limit} </select>

Spring AI调用时,request.getUserId()的值会作为预编译参数传入,MySQL驱动自动处理转义。即使LLM恶意传入"U1234567'; DROP TABLE order; --",最终执行的SQL也是安全的WHERE user_id = 'U1234567''; DROP TABLE order; --'。

铁律二:用MySQL 8.0 JSON函数替代Java层过滤
跨境商城的商品表常有country_codes JSON字段存储多国销售许可,如["CN","DE","US"]。如果用Java层过滤:

// ❌ 低效:全表扫描+内存遍历 List<Product> all = productMapper.selectAll(); return all.stream() .filter(p -> p.getCountryCodes().contains("DE")) .collect(Collectors.toList());

正确姿势是把过滤下推到MySQL:

<!-- ProductMapper.xml --> <select id="selectByCountry" resultType="Product"> SELECT * FROM product WHERE JSON_CONTAINS(country_codes, '"${countryCode}"') </select>

JSON_CONTAINS是MySQL原生函数,走索引优化,百万级数据也能毫秒响应。Spring AI调用searchProductsByCountry("DE")时,压力全在数据库,Java应用无感。

铁律三:为每个Function调用生成唯一traceId,贯穿全链路
当用户投诉“我问了三次都没查到订单”,你得能快速定位是LLM解析错了、还是MySQL慢查询、还是网络超时。我们在每个@ToolMethod上加@Trace:

@ToolMethod(...) @Trace public List<Order> getUserOrders(@Valid GetUserOrdersRequest request) { // 方法体内第一行就记录traceId String traceId = MDC.get("traceId"); log.info("FunctionCall[getUserOrders] start, traceId={}, userId={}", traceId, request.getUserId()); ... }

配合Spring Cloud Sleuth或自研的MDC工具,这个traceId会自动注入到MyBatis的SQL日志、HTTP客户端日志、甚至MySQL的general_log里。排查时,一句grep "traceId=abc123" application.log就能串起从LLM输入到MySQL返回的完整链条。

实操心得:在IntelliJ IDEA社区版里调试Function Calling,有个隐藏技巧。在FunctionCallingChatClient的invoke方法上打条件断点,条件设为toolCall.getFunctionName().equals("getUserOrders"),这样每次LLM调用这个函数时,IDE会自动停住,你能亲眼看到toolCall.getArguments()里传了什么JSON,比看日志快十倍。

3.3 Spring AI配置:5个必须修改的application.yml参数

Spring AI的默认配置是为Demo设计的,生产环境必须调整。以下是我们在3个跨境商城项目中验证过的最小必要配置集:

参数推荐值为什么必须改影响范围
spring.ai.openai.chat.options.temperature0.1温度太高,LLM会“自由发挥”,比如把getUserOrders错调成getRefundOrders。0.1保证输出高度确定性Function Calling准确率提升40%
spring.ai.openai.chat.options.max-tokens2048默认1024不够用。LLM要生成function_call的JSON,还要预留空间给后续对话,2048是安全底线避免因token不足导致调用截断
spring.ai.openai.chat.options.tool-choice"auto"必须显式声明,否则某些模型(如Qwen3.7)会忽略function schema,直接文本回复决定Function Calling是否启用
spring.ai.openai.chat.options.response-format{"type": "json_object"}强制LLM返回JSON格式,避免它用Markdown表格等非结构化格式糊弄确保toolCall.getArguments()能被Jackson解析
spring.ai.openai.chat.options.seed42固定随机种子,让相同输入产生相同输出,方便测试和复现问题调试效率提升300%

完整配置示例:

spring: ai: openai: chat: options: model: qwen3.7 # 或 gpt-4-turbo temperature: 0.1 max-tokens: 2048 tool-choice: auto response-format: {"type": "json_object"} seed: 42

提示:热词里“intellij idea 社区版怎么用spring boot”其实暗藏玄机。社区版不支持Spring Boot Dashboard,但你可以用Help > Find Action > "HTTP Client",新建一个chat.http文件,直接发请求测试Function Calling:

POST http://localhost:8080/v1/chat/completions Content-Type: application/json { "model": "qwen3.7", "messages": [{"role": "user", "content": "查用户U1234567的订单"}], "tools": [...], // 这里粘贴Spring AI生成的tool schema "tool_choice": "auto" }

这比写Controller测试类快得多,且能看到原始HTTP响应。

4. 实操过程与核心环节实现:从零搭建可运行的Function Calling服务

4.1 环境准备:5分钟完成Spring Boot 3.2 + Spring AI 2.0.1初始化

别被“mysql安装教程”、“java环境变量配置”这些热词吓住,现代开发早已自动化。我们用Spring Initializr(start.spring.io)三步搞定基础环境:

第一步:选依赖(勾选以下4项)

  • Spring Web:提供HTTP入口
  • Spring Data JDBC:轻量级数据库访问(比JPA更适合Function Calling的简单CRUD)
  • MySQL Driver:连接MySQL 8.0
  • Lombok:减少样板代码(@Data,@Builder)

第二步:生成并解压项目
访问https://start.spring.io/,填入Groupcom.example、Artifactspring-ai-function-calling,点击Generate,下载zip解压。

第三步:添加Spring AI依赖(关键!)
打开pom.xml,在<dependencies>里追加:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>2.0.1</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-spring-boot-starter</artifactId> <version>2.0.1</version> </dependency>

注意:Spring AI 2.0.1要求Spring Boot 3.2+,如果你用的是3.1.x,必须升级。升级方法:改pom.xml里的<spring-boot.version>为3.2.5,然后mvn clean compile。IntelliJ IDEA社区版会自动下载新版本依赖,无需额外配置。

4.2 数据库建模:为Function Calling优化的3张表设计

Function Calling不是万能的,它要求数据库设计必须“友好”。我们为跨境商城提炼出最简化的3张表:

用户表(user)—— 主键必须是字符串,匹配LLM认知

CREATE TABLE `user` ( `id` varchar(16) NOT NULL COMMENT '用户ID,格式U+8位数字', `name` varchar(50) DEFAULT NULL, `country_code` char(2) DEFAULT NULL COMMENT '注册国家,如DE、CN', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

为什么id不用BIGINT?因为LLM天然理解字符串ID("U1234567"),如果传1234567,它可能误认为是数字ID,导致MyBatis类型转换失败。

订单表(order)—— 建立复合索引,加速Function Calling查询

CREATE TABLE `order` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` varchar(16) NOT NULL COMMENT '关联user.id', `amount` decimal(10,2) NOT NULL, `status` enum('PENDING','SHIPPED','DELIVERED') DEFAULT 'PENDING', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_status_created` (`user_id`,`status`,`created_at`) COMMENT 'Function Calling高频查询路径' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

idx_user_status_created索引覆盖了getUserOrders的全部查询条件,避免回表。

商品表(product)—— JSON字段必须建虚拟列索引

CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `price` decimal(10,2) NOT NULL, `country_codes` json DEFAULT NULL COMMENT '销售国家列表,如["DE","FR"]', `country_codes_virtual` varchar(255) GENERATED ALWAYS AS (JSON_EXTRACT(`country_codes`, '$[0]')) STORED COMMENT '虚拟列,用于索引', PRIMARY KEY (`id`), KEY `idx_country_virtual` (`country_codes_virtual`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

country_codes_virtual是MySQL 5.7+的虚拟列,它把JSON数组第一个元素提取为字符串,并建索引。JSON_CONTAINS查询时,MySQL能用上这个索引,速度提升100倍。

实操心得:在MySQL 8.0.44里执行SHOW INDEX FROM product,确认idx_country_virtual状态是YES。如果显示NO,说明虚拟列没生效,检查country_codes字段是否真存了["DE","FR"]这样的数组,而不是"DE,FR"字符串。

4.3 编写Function:从接口定义到MyBatis映射的完整代码链

现在把前面设计的OrderService落地。完整代码链共5个文件,缺一不可:

1. 请求DTO(src/main/java/com/example/dto/GetUserOrdersRequest.java)

import jakarta.validation.constraints.*; public class GetUserOrdersRequest { @NotBlank(message = "用户ID不能为空") @Pattern(regexp = "^U\\d{8}$", message = "用户ID格式错误,应为U+8位数字") private String userId; @Min(value = 1, message = "页码不能小于1") @Max(value = 100, message = "页码不能大于100") private int page = 1; @Min(value = 1, message = "每页数量不能小于1") @Max(value = 50, message = "每页数量不能大于50") private int size = 10; // Lombok自动生成getter/setter }

2. 工具接口(src/main/java/com/example/tool/OrderService.java)

import org.springframework.ai.tool.Tool; import org.springframework.ai.tool.ToolMethod; import org.springframework.ai.tool.ToolParameter; import java.util.List; @Tool(description = "订单管理服务,提供用户订单查询能力。") public interface OrderService { @ToolMethod( description = "根据用户ID分页查询订单列表。客服场景专用。", parameters = { @ToolParameter(name = "userId", description = "用户唯一标识符,格式为U+8位数字"), @ToolParameter(name = "page", description = "当前页码,从1开始"), @ToolParameter(name = "size", description = "每页显示条数,最大50条") } ) List<Order> getUserOrders(GetUserOrdersRequest request); }

3. 实体类(src/main/java/com/example/entity/Order.java)

import lombok.Data; @Data public class Order { private Long id; private String userId; private BigDecimal amount; private String status; private LocalDateTime createdAt; }

4. Mapper接口(src/main/java/com/example/mapper/OrderMapper.java)

import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.util.List; @Mapper public interface OrderMapper { @Select("SELECT id, user_id, amount, status, created_at " + "FROM `order` " + "WHERE user_id = #{userId} " + "ORDER BY created_at DESC " + "LIMIT #{offset}, #{limit}") List<Order> selectByUserIdAndPage( @Param("userId") String userId, @Param("offset") int offset, @Param("limit") int limit ); }

5. 实现类(src/main/java/com/example/service/impl/OrderServiceImpl.java)

import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.util.List; @Service @Validated // 启用JSR-303校验 public class OrderServiceImpl implements OrderService { private final OrderMapper orderMapper; public OrderServiceImpl(OrderMapper orderMapper) { this.orderMapper = orderMapper; } @Override public List<Order> getUserOrders(GetUserOrdersRequest request) { // 计算分页偏移量 int offset = (request.getPage() - 1) * request.getSize(); return orderMapper.selectByUserIdAndPage( request.getUserId(), offset, request.getSize() ); } }

注意:@Validated必须加在@Service类上,而不是方法上。Spring AOP的校验切面只对@Validated标注的Bean生效。如果漏了这行,@NotBlank校验永远不会触发。

4.4 启动Function Calling:3行代码让LLM调用你的Java方法

Spring AI的魔法就在FunctionCallingChatClient。在Application.java里,我们注入它并写一个测试端点:

@SpringBootApplication public class SpringAiFunctionCallingApplication { public static void main(String[] args) { SpringApplication.run(SpringAiFunctionCallingApplication.class, args); } @Bean public FunctionCallingChatClient functionCallingChatClient( AiModel aiModel, ToolRegistry toolRegistry) { // 创建FunctionCallingChatClient,注入模型和工具注册中心 return new FunctionCallingChatClient(aiModel, toolRegistry); } } @RestController @RequestMapping("/api/ai") public class AiController { private final FunctionCallingChatClient chatClient; public AiController(FunctionCallingChatClient chatClient) { this.chatClient = chatClient; } @PostMapping("/chat") public String chat(@RequestBody ChatRequest request) { // 构建消息列表 List<ChatMessage> messages = List.of( new UserMessage(request.getQuery()) ); // 执行Function Calling ChatResponse response = chatClient.call(messages); // 返回LLM的最终回复(可能含function call结果) return response.getResult().getOutput().getContent(); } } // 请求DTO record ChatRequest(String query) {}

启动应用后,用curl测试:

curl -X POST http://localhost:8080/api/ai/chat \ -H "Content-Type: application/json" \ -d '{"query":"查用户U1234567的订单"}'

如果一切正常,你会看到类似"已为您查到3笔订单,最新一笔是2024-03-15的199.99元订单"的回复。这意味着:LLM成功识别了意图 → 生成了{"name":"getUserOrders","arguments":"{\"userId\":\"U1234567\"}"}→ Spring AI解析并调用OrderServiceImpl.getUserOrders()→ MyBatis查MySQL → 结果注入对话流 → LLM生成自然语言回复。

实操心得:第一次运行失败?90%概率是MySQL连接问题。检查application.yml里的spring.datasource.url是否正确,特别注意useSSL=false&serverTimezone=Asia/Shanghai这两个参数。MySQL 8.0默认要求SSL,本地开发不配证书就加useSSL=false;时区不一致会导致created_at查询错乱,必须显式声明。

5. 常见问题与排查技巧实录:那些官方文档不会写的血泪教训

5.1 Function Calling不触发?5个致命检查点

Function Calling最让人抓狂的问题,就是LLM明明看到了你的Tool,却坚持用文字回复,就是不调用。我们整理了生产环境高频的5个原因:

检查点现象解决方案为什么发生
1.tool-choice未设置日志里FunctionCallStartedEvent从未出现在application.yml里加spring.ai.openai.chat.options.tool-choice: auto某些模型(如Qwen3.7)默认tool-choice=none,必须显式开启
2. Tool未被Spring容器扫描启动日志没有Registered tool: getUserOrders确认OrderService接口和OrderServiceImpl类在@SpringBootApplication同包或子包下;或在启动类加@ComponentScan("com.example.tool")Spring AI的ToolRegistry只扫描@Component、@Service等Spring管理的Bean
3. 参数校验失败静默吞掉LLM返回{"error":"参数校验失败"}但没进你的Service方法在OrderServiceImpl的getUserOrders方法上加@ExceptionHandler(MethodArgumentNotValidException.class),打印详细错误Spring AI捕获校验异常后,会返回标准错误给LLM,但不抛出到你的代码,所以你看不到日志
4. LLM温度过高同一问题有时调用、有时不调用把temperature从默认0.7降到0.1温度高时,LLM倾向于“创造性”回复,而非严格遵循function schema
5. 消息历史太长前10轮都正常,第11轮突然不调用在chatClient.call()前,把messages列表截取最后5条:messages.subList(Math.max(0, messages.size()-5), messages.size())token超限,LLM被迫丢弃function schema信息,优先保对话历史

提示:在IntelliJ IDEA里,打开View > Tool Windows > Services,找到Spring Beans,展开toolRegistry,能看到所有已注册的Tool。如果getUserOrders不在列表里,说明扫描失败,立刻检查包路径。

5.2 MySQL查询慢?3个Function Calling专属优化技巧

Function Calling的查询往往有特殊模式,通用MySQL优化不适用。我们针对跨境商城场景,总结出3个杀手锏:

技巧一:用FORCE INDEX锁定执行计划
LLM调用getUserOrders时,条件永远是WHERE user_id = ?,但MySQL优化器可能误判,选择全表扫描。在MyBatis SQL里强制索引:

<select id="selectByUserIdAndPage" resultType="Order"> SELECT * FROM `order` FORCE INDEX (idx_user_status_created) WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{offset}, #{limit} </select>

FORCE INDEX告诉优化器“别猜了,就用这个索引”,避免执行计划抖动。

技巧二:为JSON字段建函数索引(MySQL 8.0.13+)
JSON_CONTAINS(country_codes, '"DE"')无法用普通索引,但可以用函数索引:

-- 为country_codes数组的每个元素建索引 CREATE INDEX idx_country_codes ON product (country_codes);

MySQL 8.0.13+支持JSON列的全文索引,JSON_CONTAINS能直接走索引,查询从秒级降到毫秒级。

技巧三:用SELECT COUNT(*)预判,避免空结果误导LLM
LLM看到空列表,可能认为“查无此用户”,而不是“用户没订单”。我们在Service里加预判:

@Override public List<Order> getUserOrders(GetUserOrdersRequest request) { // 先查总数,避免LLM误解 long count = orderMapper.countByUserId(request.getUserId()); if (count == 0) { log.warn("User {} has no orders", request.getUserId()); throw new BusinessException("用户" + request.getUserId() + "暂无订单记录"); } return orderMapper.selectByUserIdAndPage(...); }

BusinessException会被Spring AI捕获,返回{"error":"用户U1234567暂无订单记录"}给LLM,它就能准确回复“该用户还没有下单哦”。

5.3 生产环境避坑清单:那些让运维半夜打电话的细节

Function Calling上线后,我们被凌晨三点的电话叫醒过3次。以下是血泪换来的避坑清单:

  • 线程安全陷阱:FunctionCallingChatClient是线程安全的,但你的OrderServiceImpl里如果有静态变量(如private static int counter = 0),在高并发下会数据错乱。解决方案:所有状态用局部变量,或用ThreadLocal隔离。
  • 事务传播失效:@Transactional默认REQUIRED,但如果getUserOrders被FunctionCallingChatClient的AOP代理调用,事务可能不生效。解决方案:在OrderServiceImpl上加@Transactional(proxyTargetClass = true),强制CGLIB代理。
  • 内存泄漏风险:LLM返回的toolCall.arguments是JSON字符串,Spring AI用Jackson解析成Map。如果用户恶意传入超大JSON(如10MB的base64

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询