一、前言

1.1 灵魂拷问:真的需要 MCP 协议吗?

在[上一篇文章](飞书机器人+MCP Client实战-Spring Boot 3智能桥接方案.md)中,我们搭建了一个完整的 MCP Client,通过 MCP 协议连接 MCP Server,实现了飞书机器人 → LLM 智能决策 → MCP 工具调用的全链路。

架构是这样的:

飞书 Bot → Spring Boot(MCP Client)→ MCP 协议 → MCP Server → 工具方法

跑是跑起来了,但本人不才,心里一直有个疑问:

我的工具方法就是几个 Java 函数,为什么要绕一大圈通过 MCP 协议去调用?直接函数调用不行吗?

答案是:当然行!

Spring AI 本身就内置了 Function Calling(函数调用) 能力,LLM 可以根据工具描述自动选择调用哪个工具。我们完全不需要 MCP Client、不需要 MCP Server、不需要 MCP 协议,只需要:

  1. 把工具方法注册为 Spring AI 的 @Tool
  2. 让 ChatClient 通过 Function Calling 直接调用
  3. 飞书机器人接收消息 → 调用 ChatClient → 返回结果

整个 MCP 中间层全部省掉。

1.2 本文目标

在上一篇文章的基础上,用 Spring AI Function Calling 替代 MCP Client + MCP Server,实现一个更简洁的飞书机器人智能助手。

对比项 上篇方案(MCP Client) 本篇方案(Function Calling)
协议依赖 MCP 协议(JSON-RPC) 无,直接方法调用
组件数量 MCP Client + MCP Server + 飞书模块 ChatClient + 飞书模块
网络开销 HTTP 请求到 MCP Server 本地方法调用,零网络开销
工具发现 MCP 协议动态发现 Spring Bean 自动注册
适用场景 跨进程/跨服务/跨语言工具调用 单体应用内的工具调用
代码量 多(两套服务) 少(一套服务搞定)

1.3 整体架构

在这里插入图片描述

1.4 数据流向

在这里插入图片描述

对比上篇的 12 步流程,这里精简到了 11 步,关键是 去掉了 MCP 协议的序列化/反序列化和 HTTP 通信开销

二、核心原理

2.1 Spring AI Function Calling 工作机制

Spring AI 的 Function Calling 是这么工作的:

  1. 工具注册:用 @Tool 注解标记 Java 方法,Spring AI 自动提取方法签名、参数类型和描述信息,生成工具定义(JSON Schema)
  2. 工具发现:ChatClient 启动时,自动收集所有注册的 ToolCallback,在请求 LLM 时附带工具列表
  3. 智能决策:LLM 根据用户消息和工具描述,决定是否调用工具、调用哪个工具、传什么参数
  4. 自动执行:Spring AI 框架收到 LLM 的工具调用指令后,自动反射调用对应的 Java 方法
  5. 结果回传:工具执行结果回传给 LLM,LLM 生成最终回复

整个过程,工具调用就是本地方法调用,没有网络请求,没有协议序列化。

2.2 Function Calling 内部交互流程

很多人好奇:LLM 怎么知道要调用哪个工具?Spring AI 又怎么知道要执行哪个方法?来看这个时序图:

@Tool 方法 MiniMax LLM Spring AI Agent @Tool 方法 MiniMax LLM Spring AI Agent tools: [{name: "getWeather", description: "...", parameters: {...}}] LLM 决策:需要调用 getWeather 工具 ① Chat Request messages + tools 定义(JSON Schema) ② Tool Call Response tool_calls: [{name: "getWeather", arguments: {cityName: "北京"}}] ③ 反射调用 getWeather("北京") ④ 返回结果 {city: "北京", temperature: "22°C", ...} ⑤ Tool Result Message role: "tool", content: "{city: 北京, ...}" ⑥ Final Response "北京今天天气晴朗,气温 22°C..."

关键点

  • 第 ① 步:Spring AI 自动把 @Tool 注解的方法转换成 JSON Schema,作为 tools 参数发给 LLM
  • 第 ② 步:LLM 分析用户问题,自主决定调用哪个工具,返回工具名和参数
  • 第 ③④ 步:Spring AI 框架通过反射找到对应的 Java 方法并执行
  • 第 ⑤⑥ 步:工具结果回传给 LLM,LLM 生成人类可读的最终回复

整个过程对开发者完全透明,你只需要定义工具方法,框架帮你搞定一切。

2.3 为什么可以替代 MCP Client

MCP Client 做的事 Function Calling 的对应
连接 MCP Server,发现工具 Spring 容器自动注入 ToolCallback
将工具定义发给 LLM ChatClient 自动附带工具列表
接收 LLM 的工具调用指令 Spring AI 框架内部处理
通过 MCP 协议调用远程工具 直接反射调用本地 Java 方法
将工具结果返回给 LLM 框架自动处理

本质上,MCP Client 就是一个"工具调用的中间人"。如果你的工具就在同一个 JVM 里,这个中间人完全可以不要。

2.4 什么时候该用 MCP,什么时候不该用

说了这么多"不需要 MCP",但 MCP 并非没有价值。关键看你的场景:

场景 推荐方案 原因
工具和 Agent 在同一个应用 Function Calling 简单直接,零开销
工具是独立部署的服务 MCP 需要跨进程通信
工具用不同语言编写 MCP 协议层解耦,语言无关
工具需要被多个 Agent 共享 MCP 一次部署,多处复用
快速原型/内部工具 Function Calling 开发效率最高
企业级工具平台 MCP 标准化、可治理

一句话总结:工具在同一个 JVM 里,用 Function Calling;工具在不同进程/不同机器/不同语言,用 MCP。

三、依赖配置

3.1 Maven 依赖

创建一个新的 Spring Boot 项目(或在现有项目上改造),pom.xml 如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.5</version>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>feishu-spring-ai-agent</artifactId>
    <version>1.0.0</version>
    
    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.1.5</spring-ai.version>
    </properties>
    
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    
    <dependencies>
        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- Spring AI MiniMax(可替换为其他 LLM 提供商) -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-minimax</artifactId>
        </dependency>
        
        <!-- 飞书 SDK -->
        <dependency>
            <groupId>com.larksuite.oapi</groupId>
            <artifactId>oapi-sdk</artifactId>
            <version>2.4.4</version>
        </dependency>
        
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <!-- Test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    
    <repositories>
        <repository>
            <id>public</id>
            <name>aliyun nexus</name>
            <url>https://maven.aliyun.com/repository/public</url>
            <releases>
                <enabled>true</enabled>
            </releases>
        </repository>
    </repositories>
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

对比上篇:去掉了 spring-ai-starter-mcp-client,因为不需要 MCP Client 了。工具定义直接用 Spring AI 的 @Tool 注解,不需要 MCP 协议。

3.2 应用配置

application.yml

server:
  port: 8081

spring:
  ai:
    minimax:
      api-key: "${MINIMAX_API_KEY}"
      chat:
        options:
          model: abab6.5s-chat
          temperature: 0.7

# ============ 飞书配置 ============
feishu:
  app-id: "${FEISHU_APP_ID}"
  app-secret: "${FEISHU_APP_SECRET}"
  event-mode: websocket  # 开发环境用 websocket,生产用 webhook
  # Webhook 模式配置(生产环境)
  encrypt-key: "${FEISHU_ENCRYPT_KEY:}"
  verification-token: "${FEISHU_VERIFICATION_TOKEN:}"
  callback-path: /feishu/event/callback

logging:
  level:
    com.example.feishuagent: debug

对比上篇:去掉了整个 spring.ai.mcp.client 配置块,因为不需要连接 MCP Server 了。干净利落。

四、核心代码实现

4.1 项目结构

feishu-spring-ai-agent/
├── pom.xml
├── src/main/java/com/example/feishuagent/
│   ├── FeishuAgentApplication.java       # 启动类
│   ├── config/
│   │   └── ChatConfig.java               # ChatClient + 工具注册
│   ├── tool/
│   │   ├── WeatherTool.java              # 天气查询工具
│   │   ├── OrderTool.java                # 订单查询工具
│   │   └── DocumentTool.java             # 文档查询工具
│   └── feishu/                           # 飞书模块
│       ├── config/
│       │   ├── FeishuClientConfig.java   # 飞书客户端配置
│       │   └── FeishuWebSocketClient.java # WebSocket 客户端
│       ├── controller/
│       │   └── FeishuWebhookController.java # Webhook 回调(生产环境)
│       ├── listener/
│       │   └── FeishuEventListener.java  # 事件监听器
│       ├── sender/
│       │   └── FeishuMessageSender.java  # 消息发送器
│       └── model/
│           └── FeishuMessage.java        # 消息模型
└── src/main/resources/
    └── application.yml

4.2 工具定义(@Tool 注解)

这是和上篇最大的区别——工具不再通过 MCP 协议暴露,而是直接用 @Tool 注解标记为 Spring AI 的函数调用工具。

WeatherTool.java
package com.example.feishuagent.tool;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.Random;

/**
 * 天气查询工具
 */
@Service
@Slf4j
public class WeatherTool {

    private static final String[] CONDITIONS = {"晴朗", "多云", "阴天", "小雨", "大雨", "雷雨"};
    private final Random random = new Random();

    @Tool(description = "根据城市名称获取当前天气信息,包括温度、湿度、天气状况等")
    public Map<String, Object> getWeather(
            @ToolParam(description = "城市名称,例如:北京、上海、广州") String cityName) {
        
        log.info("查询城市天气: {}", cityName);
        
        int temperature = random.nextInt(35) - 5;
        int humidity = random.nextInt(80) + 20;
        int windSpeed = random.nextInt(30) + 1;
        String condition = CONDITIONS[random.nextInt(CONDITIONS.length)];
        
        return Map.of(
                "city", cityName,
                "temperature", temperature + "°C",
                "humidity", humidity + "%",
                "windSpeed", windSpeed + " km/h",
                "condition", condition,
                "advice", getAdvice(condition, temperature)
        );
    }

    private String getAdvice(String condition, int temperature) {
        if (temperature < 0) return "天气寒冷,注意保暖";
        if ("大雨".equals(condition) || "雷雨".equals(condition)) return "有雨,建议携带雨伞";
        return "天气不错,适合外出";
    }
}
OrderTool.java
package com.example.feishuagent.tool;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * 订单查询工具
 */
@Service
@Slf4j
public class OrderTool {

    private static final Map<String, Order> ORDERS = new HashMap<>();
    
    static {
        ORDERS.put("ORD-1001", new Order("ORD-1001", "iPhone 16 Pro", "已发货", 
                LocalDate.now().plusDays(2), 8999.00));
        ORDERS.put("ORD-1002", new Order("ORD-1002", "MacBook Air M3", "处理中", 
                LocalDate.now().plusDays(5), 9499.00));
        ORDERS.put("ORD-1003", new Order("ORD-1003", "AirPods Pro 2", "已完成", 
                LocalDate.now().minusDays(3), 1899.00));
    }

    @Tool(description = "根据订单 ID 查询订单状态和详细信息")
    public Map<String, Object> getOrderStatus(
            @ToolParam(description = "订单编号,例如:ORD-1001") String orderId) {
        
        log.info("查询订单状态: {}", orderId);
        
        Order order = ORDERS.get(orderId);
        if (order == null) {
            return Map.of("error", "订单不存在: " + orderId);
        }
        
        return Map.of(
                "orderId", order.orderId(),
                "productName", order.productName(),
                "status", order.status(),
                "estimatedDelivery", order.estimatedDelivery().toString(),
                "price", order.price()
        );
    }

    @Tool(description = "查询用户的所有历史订单列表")
    public List<Map<String, Object>> getOrderHistory(
            @ToolParam(description = "用户 ID") String userId) {
        
        log.info("查询用户订单历史: {}", userId);
        
        return ORDERS.values().stream()
                .map(order -> Map.<String, Object>of(
                        "orderId", order.orderId(),
                        "productName", order.productName(),
                        "status", order.status(),
                        "price", order.price()
                ))
                .collect(Collectors.toList());
    }

    record Order(String orderId, String productName, String status, 
                 LocalDate estimatedDelivery, Double price) {}
}
DocumentTool.java
package com.example.feishuagent.tool;

import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;

/**
 * 文档查询工具
 */
@Service
@Slf4j
public class DocumentTool {

    @Tool(description = "获取城市天气文档,包含气候特征、最佳旅游季节等信息")
    public String getWeatherDocument(
            @ToolParam(description = "城市名称,例如:北京、上海") String city) {
        
        log.info("查询城市天气文档: {}", city);
        
        return "# " + city + " 天气文档\n\n" +
               "## 气候特征\n" +
               city + "属于亚热带季风气候,四季分明,雨量充沛。年平均气温 15-20°C,\n" +
               "夏季炎热潮湿,冬季温和少雨。\n\n" +
               "## 最佳旅游季节\n" +
               "春秋两季是最佳旅游时间,气候宜人,适合户外活动。\n" +
               "春季(3-5月)百花盛开,秋季(9-11月)天高气爽。\n\n" +
               "## 注意事项\n" +
               "夏季需防暑降温,冬季需备薄外套。雨季请随身携带雨具。";
    }
}

关键变化

  • @McpTool@Tool(Spring AI 的工具注解)
  • @McpToolParam@ToolParam(Spring AI 的参数注解)
  • 去掉了 McpSyncServerExchange 参数(不需要 MCP 交换对象)
  • 工具就是普通的 Spring Bean,不需要任何协议相关的代码

4.3 ChatClient 配置

创建 ChatConfig.java,配置 ChatClient 并注册所有工具:

package com.example.feishuagent.config;

import com.example.feishuagent.tool.WeatherTool;
import com.example.feishuagent.tool.OrderTool;
import com.example.feishuagent.tool.DocumentTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@RequiredArgsConstructor
@Slf4j
public class ChatConfig {

    @Bean
    public ToolCallbackProvider toolCallbackProvider(
            WeatherTool weatherTool,
            OrderTool orderTool,
            DocumentTool documentTool) {
        
        return MethodToolCallbackProvider.builder()
                .toolObjects(weatherTool, orderTool, documentTool)
                .build();
    }

    @Bean
    public ChatClient chatClient(ChatClient.Builder builder, 
                                  ToolCallbackProvider toolCallbackProvider) {
        return builder
                .defaultToolCallbacks(toolCallbackProvider)
                .defaultSystem("你是一个智能助手,可以查询天气、订单和文档信息。" +
                        "请根据用户的问题,自动调用合适的工具来获取信息并给出回答。" +
                        "回答要简洁友好,使用中文。")
                .build();
    }
}

核心逻辑

  1. ToolCallbackProvider 扫描工具对象上的 @Tool 注解,自动生成工具定义
  2. ChatClient 通过 defaultToolCallbacks() 注册这些工具
  3. 每次对话时,LLM 都能看到工具列表并自主决策是否调用

对比上篇:上篇需要配置 MCP Client 连接远程 MCP Server,通过 MCP 协议发现工具。这里直接注入 Spring Bean,零网络开销。

4.4 飞书消息模型

创建 FeishuMessage.java 封装飞书消息:

package com.example.feishuagent.feishu.model;

import com.lark.oapi.service.im.v1.model.P2MessageReceiveV1;
import lombok.Data;

@Data
public class FeishuMessage {
    
    private String messageId;
    private String chatId;
    private String chatType;  // "p2p" 单聊, "group" 群聊
    private String userId;
    private String text;
    private boolean mentionBot;
    
    public static FeishuMessage from(P2MessageReceiveV1 event) {
        FeishuMessage msg = new FeishuMessage();
        msg.setMessageId(event.getEvent().getMessage().getMessageId());
        msg.setChatId(event.getEvent().getMessage().getChatId());
        msg.setChatType(event.getEvent().getMessage().getChatType());
        msg.setUserId(event.getEvent().getSender().getSenderId().getOpenId());
        
        // 解析消息内容
        String content = event.getEvent().getMessage().getContent();
        msg.setText(parseTextContent(content));
        
        // 判断是否 @ 了机器人
        msg.setMentionBot(isMentionBot(event));
        
        return msg;
    }
    
    private static String parseTextContent(String content) {
        try {
            com.fasterxml.jackson.databind.ObjectMapper mapper = 
                new com.fasterxml.jackson.databind.ObjectMapper();
            var node = mapper.readTree(content);
            return node.get("text").asText();
        } catch (Exception e) {
            return content;
        }
    }
    
    private static boolean isMentionBot(P2MessageReceiveV1 event) {
        var mentions = event.getEvent().getMessage().getMentions();
        return mentions != null && mentions.length > 0;
    }
    
    public String getTextWithoutMention() {
        if (!mentionBot) return text;
        return text.replaceAll("@\\S+\\s*", "").trim();
    }
    
    public boolean isP2P() {
        return "p2p".equals(chatType);
    }
}

4.5 飞书事件监听器

创建 FeishuEventListener.java 处理飞书消息事件:

package com.example.feishuagent.feishu.listener;

import com.example.feishuagent.feishu.model.FeishuMessage;
import com.example.feishuagent.feishu.sender.FeishuMessageSender;
import com.lark.oapi.event.EventDispatcher;
import com.lark.oapi.service.im.ImService;
import com.lark.oapi.service.im.v1.model.P2MessageReceiveV1;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.CompletableFuture;

@Configuration
@RequiredArgsConstructor
@Slf4j
public class FeishuEventListener {
    
    private final ChatClient chatClient;
    private final FeishuMessageSender messageSender;
    
    @Value("${feishu.verification-token:}")
    private String verificationToken;
    
    @Value("${feishu.encrypt-key:}")
    private String encryptKey;
    
    @Bean
    public EventDispatcher eventDispatcher() {
        return EventDispatcher.newBuilder(verificationToken, encryptKey)
            .onP2MessageReceiveV1(new ImService.P2MessageReceiveV1Handler() {
                @Override
                public void handle(P2MessageReceiveV1 event) {
                    // 异步处理,避免阻塞飞书事件回调线程
                    CompletableFuture.runAsync(() -> handleMessage(event));
                }
            })
            .build();
    }
    
    private void handleMessage(P2MessageReceiveV1 event) {
        try {
            FeishuMessage msg = FeishuMessage.from(event);
            
            // 单聊消息直接处理,群聊消息需要 @ 机器人才处理
            if (!msg.isP2P() && !msg.isMentionBot()) {
                log.debug("群聊消息未 @ 机器人,忽略: {}", msg.getText());
                return;
            }
            
            log.info("收到飞书消息 - chatType: {}, chatId: {}, userId: {}, text: {}", 
                msg.getChatType(), msg.getChatId(), msg.getUserId(), msg.getText());
            
            // 去除 @ 提及后的纯文本
            String userText = msg.getTextWithoutMention();
            
            // 直接调用 ChatClient,LLM 通过 Function Calling 自动决策工具调用
            String result = chatClient.prompt()
                .user(userText)
                .call()
                .content();
            
            // 发送结果回飞书(防止 LLM 返回空内容)
            if (result == null || result.isBlank()) {
                result = "抱歉,暂时无法处理您的请求,请稍后再试。";
            }
            messageSender.sendTextMessage(msg.getChatId(), result);
            
        } catch (Exception e) {
            log.error("处理飞书消息异常", e);
        }
    }
}

注意:这里用 CompletableFuture.runAsync() 实现异步,而不是 @Async 注解。因为 Spring AOP 代理对同类内部方法调用不生效,@Async 加在 private 方法上更是直接无效。CompletableFuture 简单直接,不依赖 Spring 代理机制。

对比上篇:上篇有一个独立的 McpExecutor 作为中间层,这里直接省掉了。事件监听器拿到消息后直接调用 ChatClient,因为不需要 MCP Client 做桥接了。

4.6 飞书消息发送器

创建 FeishuMessageSender.java

package com.example.feishuagent.feishu.sender;

import com.lark.oapi.Client;
import com.lark.oapi.service.im.v1.enums.CreateMessageReceiveIdTypeEnum;
import com.lark.oapi.service.im.v1.enums.MsgTypeEnum;
import com.lark.oapi.service.im.v1.model.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Slf4j
public class FeishuMessageSender {
    
    private final Client feishuClient;
    
    public void sendTextMessage(String chatId, String text) {
        try {
            String safeText = escapeJson(text);
            String truncatedText = truncateToLimit(safeText, 7900);
            
            String content = String.format("{\"text\":\"%s\"}", truncatedText);
            
            CreateMessageReq req = CreateMessageReq.newBuilder()
                .receiveIdType(CreateMessageReceiveIdTypeEnum.CHAT_ID)
                .createMessageReqBody(CreateMessageReqBody.newBuilder()
                    .receiveId(chatId)
                    .msgType(MsgTypeEnum.MSG_TYPE_TEXT.getValue())
                    .content(content)
                    .build())
                .build();
            
            var resp = feishuClient.im().message().create(req);
            
            if (resp.success()) {
                log.info("消息发送成功 - chatId: {}, messageId: {}", 
                    chatId, resp.getData().getMessageId());
            } else {
                log.error("消息发送失败 - code: {}, msg: {}", 
                    resp.getCode(), resp.getMsg());
            }
            
        } catch (Exception e) {
            log.error("发送飞书消息异常", e);
        }
    }
    
    private String escapeJson(String text) {
        return text.replace("\\", "\\\\").replace("\"", "\\\"")
                   .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
    }
    
    private String truncateToLimit(String text, int limit) {
        return text.length() <= limit ? text : text.substring(0, limit - 10) + "\n...(内容已截断)";
    }
}

4.7 飞书客户端配置

创建 FeishuClientConfig.java

package com.example.feishuagent.feishu.config;

import com.lark.oapi.Client;
import com.lark.oapi.event.EventDispatcher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@RequiredArgsConstructor
@Slf4j
public class FeishuClientConfig {
    
    @Value("${feishu.app-id}")
    private String appId;
    
    @Value("${feishu.app-secret}")
    private String appSecret;
    
    @Bean
    public Client feishuClient() {
        return Client.newBuilder(appId, appSecret).build();
    }
    
    @Bean(initMethod = "start")
    @ConditionalOnProperty(name = "feishu.event-mode", havingValue = "websocket")
    public FeishuWebSocketClient feishuWebSocketClient(EventDispatcher eventDispatcher) {
        log.info("初始化飞书 WebSocket 客户端");
        return new FeishuWebSocketClient(appId, appSecret, eventDispatcher);
    }
}

4.8 WebSocket 客户端

创建 FeishuWebSocketClient.java

package com.example.feishuagent.feishu.config;

import com.lark.oapi.event.EventDispatcher;
import com.lark.oapi.ws.Client;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class FeishuWebSocketClient {
    
    private final Client wsClient;
    
    public FeishuWebSocketClient(String appId, String appSecret, EventDispatcher eventDispatcher) {
        this.wsClient = new Client.Builder(appId, appSecret)
            .eventHandler(eventDispatcher)
            .autoReconnect(true)
            .build();
    }
    
    public void start() {
        log.info("飞书 WebSocket 客户端启动,发起 WSS 连接...");
        wsClient.start();
    }
}

4.9 Webhook 回调控制器(生产环境)

创建 FeishuWebhookController.java

package com.example.feishuagent.feishu.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.lark.oapi.core.request.EventReq;
import com.lark.oapi.event.EventDispatcher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@ConditionalOnProperty(name = "feishu.event-mode", havingValue = "webhook")
@RequiredArgsConstructor
@Slf4j
public class FeishuWebhookController {
    
    private final EventDispatcher eventDispatcher;
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    @Value("${feishu.callback-path:/feishu/event/callback}")
    private String callbackPath;
    
    @PostMapping("${feishu.callback-path:/feishu/event/callback}")
    public Map<String, String> handleEvent(@RequestBody String body) {
        log.debug("收到飞书 Webhook 回调: {}", body);
        
        try {
            // 处理 URL 验证挑战
            var node = objectMapper.readTree(body);
            if (node.has("type") && "url_verification".equals(node.get("type").asText())) {
                return Map.of("challenge", node.get("challenge").asText());
            }
            
            // 使用 EventDispatcher 解析并分发事件
            EventReq eventReq = new EventReq();
            eventReq.setBody(body.getBytes());
            eventReq.setHttpPath(callbackPath);
            eventDispatcher.parseReq(eventReq);
            
        } catch (Exception e) {
            log.error("事件处理失败", e);
        }
        
        return Map.of("code", "0");
    }
}

4.10 启动类

package com.example.feishuagent;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class FeishuAgentApplication {

    public static void main(String[] args) {
        SpringApplication.run(FeishuAgentApplication.class, args);
    }
}

注意:不需要 @EnableAsync。上篇用了 @Async 注解来异步处理消息,本篇改用 CompletableFuture.runAsync() 在事件监听器内部实现异步,不依赖 Spring 的异步代理机制,所以启动类更简洁。

五、飞书应用配置

飞书应用的创建和配置与上篇完全一致,这里简要列出关键步骤(详细说明请参考上篇文章):

5.1 创建飞书应用

  1. 登录 飞书开放平台
  2. 创建企业自建应用,获取 App IDApp Secret
  3. 开启机器人能力

5.2 配置权限

权限标识 用途
im:message 发送消息
im:message:readonly 接收消息事件

5.3 配置事件订阅

WebSocket 模式(开发环境)

  1. 进入「事件订阅」→ 选择「使用长连接接收事件」
  2. 添加事件:im.message.receive_v1
  3. 勾选:获取 @ 当前机器人的消息 + 读取用户发给机器人的单聊消息

Webhook 模式(生产环境)

  1. 进入「事件订阅」→ 选择「将事件发送至开发者服务器」
  2. 配置回调地址:https://your-domain.com/feishu/event/callback
  3. 记录 Encrypt Key 和 Verification Token

5.4 配置环境变量

export MINIMAX_API_KEY=your_minimax_api_key
export FEISHU_APP_ID=cli_xxxxxxxxxx
export FEISHU_APP_SECRET=xxxxxxxxxxxxxxxx
# Webhook 模式额外需要
export FEISHU_ENCRYPT_KEY=xxxxxxxx
export FEISHU_VERIFICATION_TOKEN=xxxxxxxx

六、运行与测试

6.1 启动服务

# 设置环境变量
export MINIMAX_API_KEY=your_key
export FEISHU_APP_ID=cli_xxx
export FEISHU_APP_SECRET=xxx

# 启动
mvn spring-boot:run

启动日志:

INFO  --- FeishuAgentApplication : Starting FeishuAgentApplication
INFO  --- FeishuClientConfig : 初始化飞书 WebSocket 客户端
INFO  --- FeishuWebSocketClient : 飞书 WebSocket 客户端启动,发起 WSS 连接...
INFO  --- FeishuAgentApplication : Started FeishuAgentApplication in 3.2 seconds

6.2 飞书群聊测试

在飞书群中 @ 机器人发送消息:

测试 1:天气查询

用户:
@Spring Ai Agent 今天天气

Spring Ai Agent
北京今天的天气情况是小雨,温度为10°C,湿度为37%,风速为19 km/h。天气不错,适合外出。

后台日志:

INFO  收到飞书消息 - chatType: group, chatId: oc_xxx, userId: ou_xxx, text: @_user_1 今天天气
INFO  查询城市天气: 北京
INFO  消息发送成功 - chatId: oc_xxx, messageId: om_xxx

测试 2:订单查询

用户:
@Spring Ai Agent 订单 ORD-1003 详细信息

Spring Ai Agent
您查询的订单 ORD-1003 的详细信息如下:AirPods Pro 2,价格为1899元,订单状态已显示为已完成,预计送达日期为2026年7月1日。

测试 3:文档查询

用户:
@Spring Ai Agent 给我看看上海的天气文档

Spring Ai Agent
上海的天气文档如下:上海属于亚热带季风气候,四季分明,雨量充沛。年平均气温在15-20°C之间,夏季炎热潮湿,冬季温和少雨。最佳旅游时间是春秋两季,气候宜人,非常适合户外活动。春季(3-5月)是百花盛开的时节,而秋季(9-11月)则是天高气爽的好季节。需要注意的是,在夏季要防止高温和潮湿,冬季则应准备一些薄外套。此外,雨季时请记得随身携带雨具。

测试 4:闲聊(不触发工具)

用户:
@Spring Ai Agent 你是?

Spring Ai Agent
我是智能助手,可以帮您查询天气、订单和文档信息。请问有什么我可以帮助您的吗?

测试 5:不存在的订单(错误处理)

用户:
@Spring Ai Agent 查一下订单 ORD-9999

Spring Ai Agent
对不起,您查询的订单 ORD-9999 不存在,请您确认订单号是否正确。

七、踩坑记录

实战过程中遇到的一些坑,分享给大家避坑:

7.1 @Async 的坑

最初我想用 @Async 注解来异步处理飞书消息,结果发现:

// ❌ 这样写不会异步执行!
@Bean
public EventDispatcher eventDispatcher() {
    return EventDispatcher.newBuilder(...)
        .onP2MessageReceiveV1(event -> {
            handleMessage(event);  // 同类内部调用,@Async 不生效
            return null;
        })
        .build();
}

@Async
private void handleMessage(P2MessageReceiveV1 event) { ... }

原因:Spring 的 @Async 基于 AOP 代理实现,有两个限制:

  1. private 方法:代理无法拦截 private 方法,@Async 直接失效
  2. 同类内部调用:即使改成 public,同一个类内部的方法调用不走代理,@Async 也不生效

解决方案:用 CompletableFuture.runAsync() 替代,简单直接:

// ✅ 正确做法
CompletableFuture.runAsync(() -> handleMessage(event));

7.2 工具描述的重要性

LLM 是根据 @Tooldescription 来决定是否调用工具的。描述写得不好,LLM 就会选错工具或者不调用工具。

// ❌ 描述太模糊,LLM 不知道什么时候该调用
@Tool(description = "查询信息")
public Map<String, Object> query(String id) { ... }

// ✅ 描述清晰具体,LLM 能准确判断
@Tool(description = "根据订单 ID 查询订单状态和详细信息")
public Map<String, Object> getOrderStatus(
    @ToolParam(description = "订单编号,例如:ORD-1001") String orderId) { ... }

经验:description 要写清楚"这个工具做什么"和"参数是什么格式",最好给个示例值。

7.3 飞书消息长度限制

飞书单条文本消息有长度限制(约 8KB),LLM 返回的长文本可能超限。FeishuMessageSender 中做了截断处理:

private String truncateToLimit(String text, int limit) {
    return text.length() <= limit ? text : 
        text.substring(0, limit - 10) + "\n...(内容已截断)";
}

7.4 JSON 转义

LLM 返回的内容可能包含换行符、引号等特殊字符,直接拼 JSON 会导致飞书 API 报错。必须做 JSON 转义:

private String escapeJson(String text) {
    return text.replace("\\", "\\\\")
               .replace("\"", "\\\"")
               .replace("\n", "\\n")
               .replace("\r", "\\r")
               .replace("\t", "\\t");
}

八、总结

8.1 两种方案对比

对比维度 MCP Client 方案 Function Calling 方案
架构复杂度 高(两套服务 + MCP 协议) 低(一套服务搞定)
开发效率 中(需要维护两套代码) 高(一个项目,工具就是普通 Bean)
运行时开销 有 HTTP + JSON-RPC 开销 零额外开销,本地方法调用
工具发现 MCP 协议动态发现 Spring 容器自动注入
调试难度 较高(需要同时调试两个服务) 低(单进程,断点直达工具方法)
跨语言能力 支持(协议层解耦) 不支持(JVM 内)
跨服务复用 支持(MCP Server 独立部署) 不支持(工具绑定在 Agent 内)
适合场景 微服务/多语言/工具平台 单体应用/快速原型/内部工具

8.2 核心要点

  1. Spring AI 的 Function Calling 天然支持工具调用,不需要额外的 MCP 协议层
  2. @Tool 注解 让工具定义变得极其简单,就是普通的 Java 方法
  3. ChatClient 自动处理 工具发现、LLM 决策、方法调用的全流程
  4. 飞书模块完全复用,事件监听和消息发送的逻辑与上篇一致
  5. 选择依据:工具在同一个 JVM 用 Function Calling,工具在不同进程/语言用 MCP

8.3 最佳实践建议

  • 快速原型/内部工具:直接用 Function Calling,开发效率最高
  • 工具需要被多个 Agent 共享:用 MCP,一次部署多处复用
  • 混合方案:核心工具用 Function Calling(本地高性能),外部工具用 MCP(跨服务集成)
  • 生产环境:飞书用 Webhook 模式 + 负载均衡,开发环境用 WebSocket 零配置

8.4 一句话总结

工具就在手边,何必绕远路?Function Calling 让 LLM 直接调用 Java 方法,省掉 MCP 中间层,代码更少、速度更快、调试更简单。


感谢各位看官的一路陪伴,大家都再接再厉!

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐