还在用简单 AI 对话?Spring AI 自定义工具 + MCP 协议直接打通外部服务!
前言
本文的示例基于上一篇博客Spring AI 对话记忆不丢失!MySQL 主存 + Redis 缓存实战(免费模型调用+附源码)-CSDN博客的 已有项目继续开发 。如果你对项目结构、基础配置(ChatClient、ChatMemory、双写策略等)不清晰,建议先回顾上一篇内容。由于本人水平有限,文中内容若有疏漏、错误或优化空间,欢迎各位读者批评指正。
项目仓库地址:https://gitcode.com/coderKJX/SpringAiChatMemoryDemoPro.git
1、项目背景:从「纯对话」到「能动手的 AI」
在上一篇博客中,我们搭建了一个具备多会话管理、MySQL + Redis 双写持久化的 Spring AI 对话系统。但那时的 AI 只能 纯文本对话 ——用户问什么它答什么,无法执行任何外部操作。
本次迭代,我们在原有项目基础上新增 两大核心能力 :
| 能力 | 技术方案 | 效果 |
|---|---|---|
| AI画图+图片存储 | Spring AI自定义Tool(@Tool)+MinIO对象存储 | 用户输入指令(如“画一只狗”),AI自动生成图片并上传至MinIO,返回访问链接。 |
| 联网搜索 | MCP协议接入智谱Web Search Prime服务 | 用户查询实时信息时,AI自动调用搜索引擎获取最新结果并生成回答。 |
最终效果:你的 AI 从一个 只会聊天的聊天机器人 ,进化为一个 能画图、能搜索、能操作外部服务的智能 Agent 。
2、Spring AI 自定义工具调用 —— 让 AI 学会「画图并存储」
2.1 核心概念:什么是 Spring AI 的 Tool?
根据 Spring AI Tools 官方文档(工具调用 :: Spring AI 参考 - Spring 框架) , 工具调用(Tool Calling) 是 AI 应用中的核心模式: 模型只能 请求 工具调用并提供输入参数,而应用程序负责 执行 工具调用并返回结果。模型永远无法直接访问作为工具提供的任何 API——这是一项关键的安全考虑。
2.2 项目核心依赖准备
<!-- 🆕 OpenAI兼容图像模型(用于Kolors免费文生图) --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <!-- 🆕 MinIO 对象存储客户端 --> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> </dependency>2.3 核心配置
spring: ai: # OpenAI 兼容配置(此处对接硅基流动 API,支持图片生成) openai: base-url: https://api.siliconflow.cn # 第三方大模型 API 地址 api-key: ${SILICONFLOW_API_KEY} # 模型密钥(从环境变量读取) image: options: model: Kwai-Kolors/Kolors # 图片生成模型(快手 Kolors) # ==================== MinIO 对象存储配置 ==================== # 用于 Spring AI 自定义工具:生成图片后自动上传存储 minio: endpoint: http://localhost:9000 # MinIO 服务地址 access-key: ${MINIO_ACCESS_KEY} # MinIO 访问密钥 secret-key: ${MINIO_SECRET_KEY} # MinIO 密钥 bucket-name: ai-images # 存储图片的桶名称(AI 生成图片统一存放)2.4minio注意事项
关于minio的下载参考该博主的博客:在Windows上MinIO的安装与使用(保姆教程)_minio安装windows-CSDN博客
2.4.1MinioConfig.java — 创建 MinioClient Bean:
package com.cg.config; import io.minio.MinioClient; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @Slf4j public class MinioConfig { @Getter @Value("${minio.endpoint}") private String endpoint; @Value("${minio.access-key}") private String accessKey; @Value("${minio.secret-key}") private String secretKey; @Getter @Setter @Value("${minio.bucket-name}") private String bucketName; @Bean public MinioClient minioClient() { log.info("初始化 MinIO 客户端,endpoint: {}", endpoint); return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .region("us-east-1") .build(); } }2.4.2MinioUtil.java — 封装上传、删除、URL 生成等核心操作
package com.cg.utils; import com.cg.config.MinioConfig; import io.minio.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.io.InputStream; /** * MinIO 工具类 */ @Component public class MinioUtil { private static final Logger log = LoggerFactory.getLogger(MinioUtil.class); private final MinioClient minioClient; private final MinioConfig minioConfig; public MinioUtil(MinioClient minioClient, MinioConfig minioConfig) { this.minioClient = minioClient; this.minioConfig = minioConfig; } /** * 检查存储桶是否存在,不存在则创建并设置为公开访问 */ public void ensureBucketExists() { try { String bucketName = minioConfig.getBucketName(); boolean exists = minioClient.bucketExists(BucketExistsArgs.builder() .bucket(bucketName) .build()); if (!exists) { minioClient.makeBucket(MakeBucketArgs.builder() .bucket(bucketName) .build()); log.info("✅ 创建 MinIO 存储桶: {}", bucketName); } // 无论存储桶是否存在,都尝试设置为公开访问 setBucketPublicPolicy(bucketName); } catch (Exception e) { log.error("❌ 检查/创建存储桶失败", e); throw new RuntimeException("MinIO 存储桶初始化失败", e); } } /** * 设置存储桶为公开访问 * * @param bucketName 存储桶名称 */ private void setBucketPublicPolicy(String bucketName) { try { // 设置公开访问策略 String policy = String.format(""" { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": {"AWS": ["*"]}, "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::%s/*"] } ] } """, bucketName); minioClient.setBucketPolicy( SetBucketPolicyArgs.builder() .bucket(bucketName) .config(policy) .build() ); log.info("✅ 设置存储桶为公开访问: {}", bucketName); } catch (Exception e) { log.warn("⚠️ 设置存储桶公开访问失败: {}", e.getMessage()); } } /** * 上传文件 * * @param objectName 对象名称(文件路径) * @param inputStream 文件输入流 * @param contentType 文件类型 * @return 文件访问 URL */ public String uploadFile(String objectName, InputStream inputStream, String contentType) { return uploadFile(objectName, inputStream, -1, contentType); } public String uploadFile(String objectName, InputStream inputStream, long fileSize, String contentType) { try { ensureBucketExists(); String bucketName = minioConfig.getBucketName(); long objectSize = fileSize > 0 ? fileSize : -1; minioClient.putObject( PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(inputStream, objectSize, 10485760) .contentType(contentType) .build() ); log.info("✅ 文件上传成功: {}, 大小: {}字节", objectName, objectSize > 0 ? objectSize : "未知"); return getFileUrl(objectName); } catch (Exception e) { log.error("❌ 文件上传失败: {}", objectName, e); throw new RuntimeException("文件上传失败", e); } } /** * 获取文件访问 URL(公开访问) * * @param objectName 对象名称 * @return 公开访问 URL */ public String getFileUrl(String objectName) { // 返回公开访问 URL(无需签名) String endpoint = minioConfig.getEndpoint(); String bucketName = minioConfig.getBucketName(); return String.format("%s/%s/%s", endpoint, bucketName, objectName); } /** * 删除文件 * * @param objectName 对象名称 */ public void deleteFile(String objectName) { try { String bucketName = minioConfig.getBucketName(); minioClient.removeObject( RemoveObjectArgs.builder() .bucket(bucketName) .object(objectName) .build() ); log.info("✅ 文件删除成功: {}", objectName); } catch (Exception e) { log.warn("⚠️ 文件删除失败(可能已不存在): {}, error={}", objectName, e.getMessage()); } } }注意 :我们使用 公开桶策略 而非预签名 URL。原因是预签名 URL 有时效性限制,且在前端渲染场景下每次请求都需要重新签名。公开桶配合 s3:GetObject 策略可以让前端直接 <img src="..."> 显示图片。
2.5定义自定义工具类
这是整个功能的核心!使用 Spring AI 的 @Tool 注解将普通 Java 方法声明为 AI 可调用的工具。
AiTools.java — AI 工具集:
package com.cg.tools; import com.cg.context.ChatContextHolder; import com.cg.entity.AiGeneratedImage; import com.cg.service.ImageStorageService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.image.ImagePrompt; import org.springframework.ai.image.ImageResponse; import org.springframework.ai.openai.OpenAiImageModel; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.ai.zhipuai.ZhiPuAiImageModel; import org.springframework.stereotype.Component; /** * AI 工具集 * 统一管理所有 通过AI实现的工具方法 */ @Slf4j @Component @RequiredArgsConstructor public class AiTools { private final OpenAiImageModel imageModel; // 智谱AI 图像模型 private final ImageStorageService imageStorageService; // 复用现有服务 /** * 图像生成+存储工具 * 复用 ImageStorageService 完成存储逻辑 * * @param prompt 图像描述 * @return 生成的图像信息 */ @Tool(description = "图像生成与存储工具。当用户要求画图、生成图片、创建图像时调用。" + "会自动将生成的图片存储到 MinIO 并关联当前对话。" + "参数 prompt 是对图像的详细描述。"+ "返回值包含图片的存储地址,必须在回复中告知用户这个地址。") public String generateAndStoreImage( @ToolParam(description = "图像的详细描述,包含主体、场景、风格等要素") String prompt) { log.info("🎨 AI 调用图像生成+存储工具, 描述={}", prompt); try { // Step 1: 调用智谱AI生成图片 ImageResponse response = imageModel.call(new ImagePrompt(prompt)); String originalUrl = response.getResult().getOutput().getUrl(); log.info("✅ AI图片生成成功, 原始URL: {}", originalUrl); // Step 2: 调用现有服务完成存储(下载→上传MinIO→保存数据库) AiGeneratedImage imageRecord = imageStorageService.storeImage(prompt, originalUrl); // Step 3: 设置当前会话的图片ID ChatContextHolder.setImageId(imageRecord.getId()); return "📷 图片已生成并保存!\n" + "- 描述:" + prompt + "\n" + "- 图片地址:" + imageRecord.getMinioUrl(); } catch (Exception e) { log.error("❌ 图像生成+存储失败: {}", e.getMessage(), e); return "图片生成失败: " + e.getMessage(); } } }核心注解解析 :
①@Tool :标记此方法为 AI 可调用的工具。 description 字段 极其重要 ——它是 AI 判断何时调用该工具的唯一依据。写得越清晰准确,AI 的调用就越精准。
②@ToolParam :描述参数的含义,帮助 AI 正确构造调用参数。
③@Component :让 Spring 管理该类的生命周期,后续可通过 .defaultTools() 注册到 ChatClient。
2.5 .1图片存储服务实现
工具方法本身只负责"触发",具体的存储逻辑封装在 Service 层,保持职责清晰。
ImageStorageServiceImpl.java — 核心存储逻辑:
数据实体 AiGeneratedImage 对应数据库表 ai_generated_image ,字段包括: id 、 prompt (描述)、 originalUrl (原始 URL)、 minioUrl (MinIO 地址)、 fileName 、 fileSize 、 createdAt 。
package com.cg.service.serviceImpl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.cg.entity.AiGeneratedImage; import com.cg.entity.CgChatMessage; import com.cg.mapper.AiGeneratedImageMapper; import com.cg.mapper.CgChatMessageMapper; import com.cg.service.ImageStorageService; import com.cg.utils.MinioUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.net.URI; import java.time.LocalDateTime; import java.util.List; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; /** * 图片存储服务实现类 */ @Slf4j @Service @RequiredArgsConstructor public class ImageStorageServiceImpl implements ImageStorageService { private final MinioUtil minioUtil; private final AiGeneratedImageMapper imageMapper; private final CgChatMessageMapper chatMessageMapper; @Override public AiGeneratedImage storeImage( String prompt, String originalUrl) { try { // 1. 下载图片到内存(同时获取文件大小) log.info("📥 开始下载图片: {}", originalUrl); InputStream rawStream = URI.create(originalUrl).toURL().openStream(); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); rawStream.transferTo(buffer); byte[] imageBytes = buffer.toByteArray(); long fileSize = imageBytes.length; rawStream.close(); log.info("📥 图片下载完成,大小: {} 字节 ({}KB)", fileSize, fileSize / 1024); // 2. 生成文件名 String fileName = UUID.randomUUID() + ".png"; // 3. 上传到MinIO ByteArrayInputStream uploadStream = new ByteArrayInputStream(imageBytes); String minioUrl = minioUtil.uploadFile(fileName, uploadStream, fileSize, "image/png"); log.info("✅ 图片上传到MinIO成功: {}", minioUrl); // 4. 构建实体并保存到数据库 AiGeneratedImage image = new AiGeneratedImage(); image.setPrompt(prompt); image.setOriginalUrl(originalUrl); image.setMinioUrl(minioUrl); image.setFileName(fileName); image.setFileSize(fileSize); image.setCreatedAt(LocalDateTime.now()); imageMapper.insert(image); log.info("✅ 图片记录已保存到数据库,ID: {}, 大小: {}KB", image.getId(), fileSize / 1024); return image; } catch (Exception e) { log.error("❌ 存储图片失败: originalUrl={}, error={}", originalUrl, e.getMessage(), e); throw new RuntimeException("存储图片失败: " + e.getMessage(), e); } } @Override public void deleteByImageIds(List<Long> imageIds) { if (imageIds == null || imageIds.isEmpty()) return; log.info("🗑️ 根据ID列表删除图片,数量: {}", imageIds.size()); for (Long imageId : imageIds) { try { // 1. 查询图片记录 AiGeneratedImage image = imageMapper.selectById(imageId); if (image == null || image.getFileName() == null) continue; // 2. 从 MinIO 删除文件 try { minioUtil.deleteFile(image.getFileName()); log.info("✅ MinIO 文件已删除: {}", image.getFileName()); } catch (Exception e) { log.warn("⚠️ MinIO 文件删除失败(可能已不存在): fileName={}, error={}", image.getFileName(), e.getMessage()); } // 3. 从数据库删除记录 imageMapper.deleteById(imageId); log.info("✅ 数据库图片记录已删除: ID={}, fileName={}", imageId, image.getFileName()); } catch (Exception e) { log.warn("⚠️ 删除图片失败: ID={}, error={}", imageId, e.getMessage(), e); } } log.info("✅ 批量删除图片完成"); } @Override public void deleteByConversationId(String conversationId) { log.info("🗑️ 根据会话ID删除所有关联图片: conversationId={}", conversationId); try { // 1. 查询该会话下所有消息中的 imageId LambdaQueryWrapper<CgChatMessage> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(CgChatMessage::getConversationId, conversationId) .isNotNull(CgChatMessage::getImageId) .gt(CgChatMessage::getImageId, 0L); List<CgChatMessage> messagesWithImages = chatMessageMapper.selectList(wrapper); if (messagesWithImages.isEmpty()) { log.info("📭 该会话没有关联图片: conversationId={}", conversationId); return; } // 2. 收集所有不重复的 imageId Set<Long> imageIds = messagesWithImages.stream() .map(CgChatMessage::getImageId) .filter(id -> id != null && id > 0) .collect(Collectors.toSet()); log.info("📋 发现 {} 张图片需要删除", imageIds.size()); // 3. 调用批量删除方法 deleteByImageIds(imageIds.stream().toList()); } catch (Exception e) { log.error("❌ 删除会话图片失败: conversationId={}, error={}", conversationId, e.getMessage(), e); } } }2.6 将工具注册到 ChatClient
AiConfig.java — ChatClient 配置:
package com.cg.config; import com.cg.repository.DualWriteChatMemoryRepository; import com.cg.tools.AiTools; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.prompt.SystemPromptTemplate; import org.springframework.ai.tool.ToolCallbackProvider; import org.springframework.ai.zhipuai.ZhiPuAiChatModel; import org.springframework.ai.zhipuai.api.ZhiPuAiApi; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.reactive.function.client.WebClient; import reactor.netty.http.client.HttpClient; import java.time.Duration; import java.util.Map; @Configuration public class AiConfig { //从classpath资源文件注入系统提示词 @Value("classpath:/prompts/system-prompt.st") private Resource systemPromptResource; @Value("${spring.ai.zhipuai.base-url}") private String baseUrl; @Value("${spring.ai.zhipuai.api-key}") private String apiKey; /** * 自定义智谱AI API配置,设置2分钟超时 */ @Bean public ZhiPuAiApi zhiPuAiApi() { // 配置 Netty HTTP 客户端超时 HttpClient httpClient = HttpClient.create() .responseTimeout(Duration.ofMinutes(2)) // 响应超时:2分钟 .doOnConnected(conn -> conn .addHandlerLast(new io.netty.handler.timeout.ReadTimeoutHandler(120)) // 读取超时:120秒 .addHandlerLast(new io.netty.handler.timeout.WriteTimeoutHandler(120)) // 写入超时:120秒 ); // 创建 WebClient.Builder 并注入自定义的 HttpClient WebClient.Builder webClientBuilder = WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)); // 使用 Builder 模式创建 ZhiPuAiApi return ZhiPuAiApi.builder() .apiKey(apiKey) .baseUrl(baseUrl) .webClientBuilder(webClientBuilder) .build(); } /* // 默认使用内存存储 @Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder().build(); } */ /** * 自定义智谱AI聊天模型 */ @Bean public ZhiPuAiChatModel zhiPuAiChatModel(ZhiPuAiApi zhiPuAiApi) { return new ZhiPuAiChatModel(zhiPuAiApi); } /** * 使用双写模式存储会话记忆 * MySQL 作为主存储(同步写入) * Redis 作为缓存层(异步写入,6小时过期) */ @Bean public ChatMemory chatMemory(DualWriteChatMemoryRepository dualWriteChatMemoryRepository) { return MessageWindowChatMemory.builder() .chatMemoryRepository(dualWriteChatMemoryRepository) .maxMessages(20)// 保留最近20条消息 .build(); } /** * 配置ChatClient */ @Bean public ChatClient chatClient(@Qualifier("zhiPuAiChatModel")ChatModel chatModel, ChatMemory chatMemory, AiTools aiTools, ToolCallbackProvider toolCallbackProvider) { // 渲染系统提示词模板,注入AI角色名称等固定变量 SystemPromptTemplate template = new SystemPromptTemplate(systemPromptResource); Message systemMessage = template.createMessage(Map.of("botName", "大肘子")); return ChatClient.builder(chatModel) .defaultSystem(systemMessage.getText())//ChatClient 直接支持 Resource等 .defaultAdvisors( new SimpleLoggerAdvisor(), MessageChatMemoryAdvisor.builder(chatMemory).build()) .defaultTools(aiTools)//添加工具 .defaultToolCallbacks(toolCallbackProvider)//添加mcp .build(); } }注意事项 :
①defaultTools(aiTools) 接收的是 带 @Tool 注解的组件对象 ,Spring AI 会自动扫描其中所有标注了 @Tool 的方法并注册。
②如果你有多个工具类,可以用 .defaultTools(toolA, toolB, toolC) 或传入 List/Object[]。
③ToolCallbackProvider 是 Spring AI MCP Client 自动提供的 Bean,用于聚合所有 MCP 连接的工具回调(下一节详解)
2.7 系统提示词引导 AI 使用工具
光注册了工具还不够——需要在系统提示词中 明确告诉 AI 什么时候该用什么工具 :
system-prompt.st(关键片段):
你是和平精英里的战术高手,名字叫:{botName}。 你最擅长安静隐蔽、耐心蹲守、占据绝佳视野、抓住时机轻松取胜,是圈内公认的苟分大神。 你的风格: 1. 语气冷静低调,带点小调皮、小狡黠,说话简短干脆,有老玩家内味儿。 2. 口头禅风格:悄悄发育、别出声、耐心等着、敌人自己送机会、懂的都懂。 3. 只教最稳打法:选隐蔽点位、绕开正面冲突、安静进圈、敌动我静、敌过我动。 4. 从不主动冲突,主打一个舒服、安全、高效率拿名次的思路。 5. 喜欢用“我”来称呼自己。 6. 如果用户要求画图、生成图片,或者当你主动提议画图后用户表示同意(如说"可以"、"好"、"行"、"画吧"等),你必须立即使用图像生成工具来满足需求。 7. 重要:当你使用图像生成工具生成图片后,必须在回复的最后单独一行添加图片的URL地址,地址后面绝对不能有任何内容,这样前端解析后,用户才能看到生成的图片。 8. 你具备联网搜索能力。当用户询问以下类型的问题时,你必须先使用搜索工具获取最新信息后再回答: - 实时新闻、时事热点、最新动态 - 具体数据、价格、排名等可能变化的信息 - 用户明确要求搜索或查询的内容 - 你不确定或知识库中可能过时的信息 搜索后基于结果给出准确回答,并简要说明信息来源。 始终以{botName}的身份交流,保持幽默风趣、文明健康,只分享游戏经验与趣味战术。2.8 常见踩坑与注意事项
问题与解决方案对照表
| 问题描述 | 原因分析 | 解决方案 |
|---|---|---|
| AI 不调用画图工具 | @Tool 的 description 描述模糊或不完整 | 用自然语言明确描述调用时机、参数含义及返回值用途 |
| 前端图片无法显示 | MinIO 返回的预签名 URL 已过期 | 改用公开桶策略,直接拼接 URL 访问资源 |
| 文件大小存为 null | 上传时未传递 fileSize 参数 | 在 storeImage() 中读取字节流计算文件大小后再上传 |
| 同一次对话多次画图时图片错乱 | 未通过 ThreadLocal 关联 imageId | 使用 ChatContextHolder 在工具调用链路中传递上下文以保证消息一致性 |
关键点说明
@Tool 描述规范
需清晰定义触发条件(如“当用户请求生成图像时调用”)、参数说明(如“prompt: 描述图像的文本”)及返回值用途(如“返回图片的存储路径”)。
MinIO 公开桶策略
将桶设置为公开可读,通过固定格式的 URL(如http://minio-server/bucket-name/file-key)直接访问,避免预签名过期问题。
文件大小获取逻辑
在存储前通过InputStream读取文件字节流,使用available()或循环读取计算总大小,确保fileSize参数不为空。
上下文传递机制
利用ChatContextHolder绑定当前会话的imageId到线程局部变量,确保同一会话的多次工具调用共享同一上下文,避免数据错乱。
3、MCP 协议接入 —— 让 AI 具备联网搜索能力
3.1 什么是 MCP?
根据 Spring AI MCP 官方文档(模型上下文协议 (MCP) :: Spring AI 参考 - Spring 框架) :MCP(Model Context Protocol,模型上下文协议) 是一种标准化协议,使 AI 模型能够以结构化方式与外部工具和资源交互。可以将其视为 AI 模型与现实世界之间的桥梁 ——允许它们通过一致的接口访问数据库、API、文件系统和其他外部服务。
3.2 为什么选择 MCP 而非自己写工具?
两者是互补关系,不是替代关系。内部能力用 @Tool ,外部能力用 MCP。
| 维度 | @Tool 工具 | MCP 外部服务 |
|---|---|---|
| 适用场景 | 内部业务逻辑(如画图、查数据库) | 接入第三方能力(如搜索、天气、文档分析) |
| 部署方式 | 代码随应用启动自动生效 | 需要连接远程 MCP Server |
| 维护成本 | 自己维护全部逻辑 | 由服务提供方维护 |
| 扩展性 | 每个新功能都要写代码 | 配置即可接入新 Server |
| 本项目案例 | 图片生成 + 存储 ✅ | 网页搜索 ✅ |
3.3 选择 MCP 服务:智谱 Web Search Prime
我们选择 智谱 AI 官方提供的 Web Search Prime MCP 服务,原因: 免费额度、云端托管 ,无需自己部署 、官方维护、SSE 协议支持 ,与 Spring AI MCP Client 天然兼容、官方 SSE 端点: https://api.z.ai/api/mcp/web_search_prime/sse?Authorization={YOUR_API_KEY}
3.4核心依赖和配置
3.4.1Maven 依赖
<!-- mcp服务 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> </dependency>3.4.2application.yml 配置 MCP 连接
spring: ai: # MCP 协议配置(用于外部联网搜索服务) mcp: client: toolcallback: enabled: true # 开启工具回调,支持 AI 自动调用外部服务 sse: connections: web-search-prime: # 自定义 MCP 连接名称 url: https://api.z.ai # MCP 服务端地址 sse-endpoint: api/mcp/web_search_prime/sse?Authorization=${ZHIPUAI_API_KEY} # 搜索 SSE 接口配置解读 :
①toolcallback.enabled: true :让 Spring AI 自动将 MCP Server 暴露的工具注册为 ToolCallback ,这样 ChatClient 就能像调用本地 @Tool 一样调用 MCP 工具。
②sse-endpoint :智谱 Web Search Prime 使用 SSE(Server-Sent Events)协议,认证信息通过 URL Query Parameter 传递。
③${ZHIPUAI_API_KEY} :复用你已有的智谱 API Key。
3.5注册 MCP 工具到 ChatClient
@Bean public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory, AiTools aiTools, ToolCallbackProvider toolCallbackProvider) { // 🆕 MCP 工具提供者 // ... system message 构建省略 ... return ChatClient.builder(chatModel) .defaultSystem(systemMessage.getText()) .defaultAdvisors(/* ... */) .defaultTools(aiTools) // 本地 @Tool 工具(图片生成) .defaultToolCallbacks(toolCallbackProvider) // 🆕 MCP 工具(网页搜索等) .build(); }⚠️ 这里有一个极易踩坑的点 :如果你的 ChatClient 是手动创建的(如本项目中使用了自定义 ZhiPuAiApi 和超时配置),那么 MCP 工具不会自动注入 !必须显式调用 ①.defaultToolCallbacks(toolCallbackProvider) 才能把 MCP 工具注册进去。否则运行时会报 No ToolCallback found for tool name: xxx 错误。
②ToolCallbackProvider 是 Spring AI MCP Client Starter 自动注册的 Bean,它会聚合所有 application.yml 中配置的 MCP 连接所提供的工具。
4.扩展
以上两个实战覆盖了 Spring AI 最核心的两大扩展机制。但根据官方文档,Spring AI 的能力远不止于此。
4.1更多工具类型
| 能力 | 工具说明 | 适用场景 |
|---|---|---|
| 多模态工具 | 处理图像/视频/音频分析的 MCP 工具 | 内容审核、视觉问答 |
| 数据库查询工具 | 通过 @Tool 连接 JDBC/JPA 执行 SQL | 让 AI 查询业务数据 |
| 邮件/消息工具 | 发送邮件、企业微信/钉钉通知 | 自动化办公流程 |
| 文件系统工具 | 读写本地/远程文件 | 文档处理、代码生成写入 |
4.2更多 MCP 传输协议
| 协议 | 特点 | 适用场景 |
|---|---|---|
| STDIO | 标准输入输出,进程间通信 | 本地 MCP Server(如 Claude Code) |
| Streamable-HTTP | 双向 HTTP 流 | 需要复杂交互的远程 Server |
| Stateless Streamable-HTTP | 无状态 HTTP | 高并发、负载均衡场景 |
| SSE | 单向服务器推送 | 本项目使用的协议 |
4.3高级 Tool 特性
| 特性 | 说明 |
|---|---|
| Tool Callback | 在工具执行前后插入自定义逻辑(如日志、鉴权、计费)。 |
| 并行工具调用 | AI 同时调用多个独立工具,提升响应速度。 |
| 工具权限控制 | 通过allowed_tools白名单限制 AI 可调用的工具范围。 |
| 结构化输出 | 强制工具返回特定 JSON Schema 格式的结果。 |
4.4MCP Server 端开发
本项目仅作为 MCP Client(消费者) 接入外部服务。Spring AI 同样支持作为 MCP Server(提供者) 向外暴露工具:
①STDIO Server :适合 CLI 工具集成
②WebMVC SSE Server :适合 HTTP 服务暴露
③WebFlux Server :适合响应式场景
这意味着你可以把自己的 Spring Boot 应用变成一个 MCP Server,供其他 AI 应用(如 Claude Desktop、VS Code Cline)调用。
