前言本文的示例基于上一篇博客Spring AI 对话记忆不丢失MySQL 主存 Redis 缓存实战免费模型调用附源码-CSDN博客的 已有项目继续开发 。如果你对项目结构、基础配置ChatClient、ChatMemory、双写策略等不清晰建议先回顾上一篇内容。由于本人水平有限文中内容若有疏漏、错误或优化空间欢迎各位读者批评指正。项目仓库地址https://gitcode.com/coderKJX/SpringAiChatMemoryDemoPro.git1、项目背景从「纯对话」到「能动手的 AI」在上一篇博客中我们搭建了一个具备多会话管理、MySQL Redis 双写持久化的 Spring AI 对话系统。但那时的 AI 只能 纯文本对话 ——用户问什么它答什么无法执行任何外部操作。本次迭代我们在原有项目基础上新增 两大核心能力 能力技术方案效果AI画图图片存储Spring AI自定义ToolToolMinIO对象存储用户输入指令如“画一只狗”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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency !-- MinIO 对象存储客户端 -- dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.7/version /dependency2.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 Beanpackage 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(ListLong 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 LambdaQueryWrapperCgChatMessage wrapper new LambdaQueryWrapper(); wrapper.eq(CgChatMessage::getConversationId, conversationId) .isNotNull(CgChatMessage::getImageId) .gt(CgChatMessage::getImageId, 0L); ListCgChatMessage messagesWithImages chatMessageMapper.selectList(wrapper); if (messagesWithImages.isEmpty()) { log.info( 该会话没有关联图片: conversationId{}, conversationId); return; } // 2. 收集所有不重复的 imageId SetLong 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 将工具注册到 ChatClientAiConfig.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 框架 MCPModel 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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-client/artifactId /dependency3.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 使用 SSEServer-Sent Events协议认证信息通过 URL Query Parameter 传递。③${ZHIPUAI_API_KEY} 复用你已有的智谱 API Key。3.5注册 MCP 工具到 ChatClientBean 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 CodeStreamable-HTTP双向 HTTP 流需要复杂交互的远程 ServerStateless 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调用。