Spring AI 1.0 Function Calling 生产级落地:从踩坑到最佳实践

随着 Spring AI 1.0 GA 版本的正式发布,Java 后端开发者接入大模型的能力有了质的飞跃。其中,Function Calling(函数调用/工具调用) 是让 LLM 从“聊天机器人”转变为“智能 Agent”的核心能力。

然而,在实际生产环境落地过程中,许多团队发现:Demo 跑通了,但一上量就报错;或者模型死活不调用工具;又或者传参格式千奇百怪。

本文将基于 Spring AI 1.0.0-M6/GA 版本,深入剖析 Function Calling 的底层机制,分享我在企业级项目中踩过的坑,并提供一套高可用、可维护的生产级代码范式。


一、 核心概念澄清:Tool vs Function

在 Spring AI 1.0 中,官方逐渐将术语从 Function 向 Tool 迁移(参考 MCP 协议趋势)。虽然 @Bean Function 依然有效,但推荐使用更语义化的 ToolCallback 接口。

为什么推荐 Tool?

  1. 上下文感知:Tool 可以访问 ChatClientRequestSpec,获取当前会话状态。
  2. 动态注册:支持运行时根据用户权限动态加载/卸载工具。
  3. 更好的测试性:解耦了业务逻辑与 Spring Bean 生命周期。

二、 标准实现范式(推荐写法)

很多教程还在使用旧的 @Bean + Function<String, String> 写法,这种方式缺乏类型安全且难以处理复杂对象。

✅ 最佳实践:基于 Record 的类型安全定义

假设我们需要一个“查询实时天气”的工具。

1. 定义输入/输出 DTO

利用 Java Record 自动生成不可变类,配合 Jackson 注解确保 JSON 映射准确。

import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;

@JsonClassDescription("查询指定城市的实时天气信息")
public record WeatherRequest(
    @JsonProperty(required = true)
    @JsonPropertyDescription("城市名称,例如:北京、上海")
    String city,
    
    @JsonPropertyDescription("温度单位,celsius或fahrenheit,默认celsius")
    String unit
) {}

public record WeatherResponse(
    String city,
    double temperature,
    String condition,
    long timestamp
) {}
2. 实现 ToolCallback

不要直接在 Controller 里写逻辑,封装为独立的 Tool 组件。

import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.stereotype.Component;

@Component
public class WeatherTool implements ToolCallback {

    private final WeatherService weatherService; // 你的业务服务

    public WeatherTool(WeatherService weatherService) {
        this.weatherService = weatherService;
    }

    @Override
    public String getName() {
        return "get_current_weather"; // ⚠️ 关键:工具名需全局唯一,建议使用蛇形命名法
    }

    @Override
    public String getDescription() {
        return "当用户询问天气情况时,必须调用此工具。仅支持中国境内城市。";
    }

    @Override
    public Class<?> getInputType() {
        return WeatherRequest.class;
    }

    @Override
    public Object apply(Object input, ChatMemory chatMemory) {
        // 1. 类型转换与安全校验
        if (!(input instanceof WeatherRequest request)) {
            throw new IllegalArgumentException("Invalid input type for WeatherTool");
        }
        
        // 2. 业务逻辑执行
        try {
            // 模拟耗时操作
            return weatherService.query(request.city(), request.unit());
        } catch (Exception e) {
            // ⚠️ 关键点:永远不要让异常穿透到 LLM,返回友好的错误提示字符串
            return "Error: Failed to fetch weather data. Reason: " + e.getMessage();
        }
    }
}
3. 注册到 ChatClient
@Bean
public ChatClient chatClient(ChatClient.Builder builder, List<ToolCallback> toolCallbacks) {
    return builder
            .defaultSystem("你是一个有用的天气助手。如果不知道答案,请说不知道,不要编造。")
            .defaultTools(toolCallbacks) // 自动注入所有实现的 ToolCallback Bean
            .build();
}

三、 生产环境高频踩坑与解决方案

🕳️ 坑位 1:模型“无视”工具描述,拒绝调用

现象:用户问“北京今天多少度?”,模型回答“我无法获取实时数据”。

原因分析:

  1. Description 太模糊:LLM 需要明确的触发条件。
  2. Temperature 设置过高:创意模式下模型倾向于自由发挥而非严格遵循指令。
  3. System Prompt 冲突:系统提示词未强调“优先使用工具”。

✅ 解决方案:

  • 优化 Description:采用 When... Use... 句式。

    Bad: "Query weather" Good: "Use this tool when the user asks about current weather conditions, temperature, or forecast for a specific location."

  • 调整采样参数:
    OpenAiChatOptions options = OpenAiChatOptions.builder()
        .temperature(0.0) // 工具调用场景建议设为 0 或极低值
        .internalToolExecutionEnabled(true) // 确保开启内部执行
        .build();
    

🕳️ 坑位 2:参数解析失败 / JSON Schema 异常

现象:日志报错 Conversion from JSON to ... failed 或 Missing required field。

原因分析:

  1. 嵌套对象过深:部分小模型对深层嵌套 JSON 处理能力弱。
  2. 枚举值未明确:LLM 生成了不在允许范围内的字符串。
  3. 字段名歧义:如 id 这种通用字段名,模型可能混淆。

✅ 解决方案:

  • 扁平化结构:尽量使用平铺的参数列表,避免复杂的嵌套 Record。
  • 显式约束枚举:
    @JsonPropertyDescription("Unit of measurement: 'c' for Celsius, 'f' for Fahrenheit")
    String unit;
    
  • 增加 Fallback 逻辑:在 apply 方法中捕获反序列化异常,返回具体的字段缺失提示,让 LLM 有机会重试(ReAct 模式的基础)。

🕳️ 坑位 3:长耗时工具导致请求超时

现象:工具执行需要 5-10 秒(如调用外部慢速 API),HTTP 请求直接 Timeout。

原因分析:Spring AI 默认的 HTTP 客户端超时时间较短,且同步阻塞等待结果。

✅ 解决方案:

  • 配置 RestClient 超时:
    @Bean
    public RestClient.Builder restClientBuilder() {
        return RestClient.builder()
            .requestFactory(new HttpComponentsClientHttpRequestFactory())
            .messageConverters(...) // 略
            ;
    }
    // 并在 application.yml 中配置 spring.http.client.read-timeout=30s
    
  • 异步化改造(进阶):对于超过 3 秒的操作,建议将工具设计为“提交任务”,返回 TaskID,再配合另一个“查询任务状态”的工具,由 Agent 轮询。

四、 调试技巧:如何看清 LLM 到底发了什么?

很多时候 Bug 不在代码,而在 Prompt。你需要看到原始的 tool_calls 报文。

1. 开启 WireMock 或 Logging

在 application.yml 中开启 Feign/RestTemplate 日志:

logging:
  level:
    org.springframework.web.client.RestTemplate: DEBUG
    # 如果使用 WebClient
    reactor.netty.http.client: DEBUG

2. 自定义 Advisor 拦截请求

编写一个 CallAdvisor 打印发送给 LLM 的完整 Messages 和接收到的 Response。

@Component
public class DebugLoggingAdvisor implements CallAdvisor {
    
    @Override
    public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
        // 打印发送给模型的 System Prompt 和 User Message
        log.info("[LLM Request] Tools: {}", advisedRequest.toolNames());
        
        AdvisedResponse response = chain.nextAroundCall(advisedRequest);
        
        // 打印模型返回的原始文本及可能的 Tool Calls
        log.info("[LLM Response] Content: {}", response.response().getResult().getOutput().getText());
        
        return response;
    }
    
    // ... 其他必要方法实现
}

五、 总结与展望

Spring AI 1.0 的 Function Calling 已经足够成熟,能够支撑大多数企业级场景。要做好它,核心不在于框架本身,而在于:

  1. Prompt Engineering:像写 API 文档一样写 @Description。
  2. 防御性编程:永远假设 LLM 会传入非法参数、超长字符串或不存在的 ID。
  3. 可观测性:必须记录每一次 Tool 调用的入参、出参、耗时和 Token 消耗。

下一步,建议结合 Spring AI Alibaba 或 LangChain4j 探索多步推理(Chain-of-Thought)和自主 Agent 的构建。

互动:你在落地 Spring AI 工具调用时遇到过什么奇葩 Bug?欢迎在评论区留言讨论!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐