飞书机器人+Spring AI Function Calling实战-扔掉MCP Client让LLM直接操控工具
一、前言
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 协议,只需要:
- 把工具方法注册为 Spring AI 的
@Tool - 让 ChatClient 通过 Function Calling 直接调用
- 飞书机器人接收消息 → 调用 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 是这么工作的:
- 工具注册:用
@Tool注解标记 Java 方法,Spring AI 自动提取方法签名、参数类型和描述信息,生成工具定义(JSON Schema) - 工具发现:ChatClient 启动时,自动收集所有注册的
ToolCallback,在请求 LLM 时附带工具列表 - 智能决策:LLM 根据用户消息和工具描述,决定是否调用工具、调用哪个工具、传什么参数
- 自动执行:Spring AI 框架收到 LLM 的工具调用指令后,自动反射调用对应的 Java 方法
- 结果回传:工具执行结果回传给 LLM,LLM 生成最终回复
整个过程,工具调用就是本地方法调用,没有网络请求,没有协议序列化。
2.2 Function Calling 内部交互流程
很多人好奇:LLM 怎么知道要调用哪个工具?Spring AI 又怎么知道要执行哪个方法?来看这个时序图:
关键点:
- 第 ① 步: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();
}
}
核心逻辑:
ToolCallbackProvider扫描工具对象上的@Tool注解,自动生成工具定义ChatClient通过defaultToolCallbacks()注册这些工具- 每次对话时,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 创建飞书应用
- 登录 飞书开放平台
- 创建企业自建应用,获取 App ID 和 App Secret
- 开启机器人能力
5.2 配置权限
| 权限标识 | 用途 |
|---|---|
im:message |
发送消息 |
im:message:readonly |
接收消息事件 |
5.3 配置事件订阅
WebSocket 模式(开发环境):
- 进入「事件订阅」→ 选择「使用长连接接收事件」
- 添加事件:
im.message.receive_v1 - 勾选:获取 @ 当前机器人的消息 + 读取用户发给机器人的单聊消息
Webhook 模式(生产环境):
- 进入「事件订阅」→ 选择「将事件发送至开发者服务器」
- 配置回调地址:
https://your-domain.com/feishu/event/callback - 记录 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 代理实现,有两个限制:
- private 方法:代理无法拦截 private 方法,
@Async直接失效 - 同类内部调用:即使改成 public,同一个类内部的方法调用不走代理,
@Async也不生效
解决方案:用 CompletableFuture.runAsync() 替代,简单直接:
// ✅ 正确做法
CompletableFuture.runAsync(() -> handleMessage(event));
7.2 工具描述的重要性
LLM 是根据 @Tool 的 description 来决定是否调用工具的。描述写得不好,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 核心要点
- Spring AI 的 Function Calling 天然支持工具调用,不需要额外的 MCP 协议层
@Tool注解 让工具定义变得极其简单,就是普通的 Java 方法- ChatClient 自动处理 工具发现、LLM 决策、方法调用的全流程
- 飞书模块完全复用,事件监听和消息发送的逻辑与上篇一致
- 选择依据:工具在同一个 JVM 用 Function Calling,工具在不同进程/语言用 MCP
8.3 最佳实践建议
- 快速原型/内部工具:直接用 Function Calling,开发效率最高
- 工具需要被多个 Agent 共享:用 MCP,一次部署多处复用
- 混合方案:核心工具用 Function Calling(本地高性能),外部工具用 MCP(跨服务集成)
- 生产环境:飞书用 Webhook 模式 + 负载均衡,开发环境用 WebSocket 零配置
8.4 一句话总结
工具就在手边,何必绕远路?Function Calling 让 LLM 直接调用 Java 方法,省掉 MCP 中间层,代码更少、速度更快、调试更简单。
感谢各位看官的一路陪伴,大家都再接再厉!
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐
所有评论(0)