SpringAI 整合MCP实现联网搜索 (基于tavily)

使用SpringAI执行联网搜索

本文属于我的AI应用学习笔记的一部分,更多内容请见:我的专栏

文档参考:SpringAI-MCP

MCP即模型上下文协议 ,是一种由Anthropic推出的开放标准,旨在统一大型语言模型(LLM)与外部数据源和工具之间的通信协议。它的主要目的是解决AI模型因数据孤岛限制而无法充分发挥潜力的问题,使得AI应用能够安全地访问和操作本地及远程数据。MCP就像AI世界的"USB-C接口",提供了一种标准化的方法,将AI模型连接到各种数据源和工具,从而提高AI系统的可靠性和效率,通过一个统一规范的接口,就可以让大模型访问外部的工具,并执行各种智能化操作,比如帮忙回邮件、填表单等。

注意:使用SpringAI进行开发需要JDK17+,这里使用的是JDK21。

SpringAI是一个主流的Java大模型开发框架,可以简化我们的AI应用开发,本文将将初步介绍使用SpringAI集成tavily搜索服务,利用MCP实现联网搜索的方法。

完整项目地址:ai-chat-demo

其中,mcp-server分支为本文搭建的mcp服务,main分支为mcp客户端应用

阅读本文前应该有的知识基础

  • Java基础
  • Java Web基础
  • 数据库基础
  • 包管理 (Maven、gradle等)
  • Spring基础、SSM整合、SpringBoot等
  • SpringAI基础,参考我的专栏之前的文章,了解如何搭建SpringAI基础应用

SpringAI官方提供的架构图如上,我们的应用即MCP Client,调用MCP Server的搜索服务,即我们接下来要同时开发服务端和客户端。


你可以选择任何你想用的搜索服务,并自行进行MCP Server开发,这里用的是tavily

1. 获取API KEY

首先访问官网官方API平台(没有注册先登录注册): tavily-home

然后点击复制API KEY,接下来要用到,如下图:

2.搭建MCP-Server

MCP-Server源代码仓库

2.1 准备工作

首先,引入Maven依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
    <version>1.1.0-RC1</version>
</dependency>

然后,根据官方文档写配置文件:

yml 复制代码
spring:
  application:
    name: mcp-server
  ai:
    mcp:
      server:
        version: 1.0.0
        name: mcp-server
        type: ASYNC
        instructions: "This server provides internet information tools and resources"
        sse-message-endpoint: /mcp/search/sse
        capabilities:
            tool: true
            resource: true
            prompt: true
            completion: true

server:
  port: 8577

之后,编写控制器接口SSE,需要暴露一个端口以供消息推流,否则客户端启动报错。

SSE管理器:

java 复制代码
/**
 * 统一处理sse连接
 */
@Component
@Slf4j
public class SseEmitterManager {
    // 支持的同时在线人数=SESSION_LIMIT-1 有一个监控sse
    private static final int SESSION_LIMIT = 1001;

    private final Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>();

    /**
     * 将对话请求加入队列
     */
    public boolean addEmitter(String sessionId, SseEmitter emitter) {
        if (emitterMap.size() < SESSION_LIMIT) {
            emitterMap.put(sessionId, emitter);
            // 推流当前线程数
            this.notifyThreadCount();
            return true;
        }
        return false;
    }

    /**
     * 获取Map中的sse连接
     */
    public SseEmitter getEmitter(String sessionId) {
        return emitterMap.get(sessionId);
    }

    public void removeEmitter(String sessionId) {
        emitterMap.remove(sessionId);
        // 推流当前线程数
        this.notifyThreadCount();
    }

    public boolean isOverLoad() {
        return emitterMap.size() >= SESSION_LIMIT;
    }

    public int getEmitterCount() {
        return emitterMap.size();
    }
    
    /**
     * 获取线程监控实例
     */
    public void addThreadMonitor() {
        // 添加一个线程监控
        emitterMap.put("thread-monitor", new SseEmitter(0L));
    }

    /**
     * 手动发送消息,通知当前占用的线程数
     */
    public void notifyThreadCount() {
        SseEmitter sseEmitter = emitterMap.get("thread-monitor");
        try {
            if (emitterMap.containsKey("thread-monitor")) {
                sseEmitter.send(this.getEmitterCount());
            }else {
                addThreadMonitor();
                sseEmitter = emitterMap.get("thread-monitor");
                sseEmitter.send(this.getEmitterCount());
            }
        } catch (IOException e) {
            sseEmitter.completeWithError(e);
            emitterMap.remove("thread-monitor"); // 清除失效连接
        }
    }

    /**
     * 将sse连接全部关闭
     */
    public void closeAll() {
        for (SseEmitter emitter : emitterMap.values()) {
            emitter.complete();
        }
        emitterMap.clear();
    }

}

Web控制器:

java 复制代码
@RestController
@RequiredArgsConstructor
@RequestMapping("/mcp/search")
@Slf4j
public class McpController {

    private final SseEmitterManager sseEmitterManager;

    public static final String DEFAULT_EMITTER_ID = "search-emitter";

    @PostConstruct
    public void init() {
        // 添加一个线程监控
        sseEmitterManager.addThreadMonitor();
        // 添加一个默认的 SSE 监听器
        // 注册 emitter
        SseEmitter emitter = new SseEmitter(0L); // 永不超时
        String emitterId = DEFAULT_EMITTER_ID; // 或生成唯一ID

        emitter.onCompletion(() -> {
            log.info("MCP SSE 连接完成");
            sseEmitterManager.removeEmitter(emitterId);
        });

        emitter.onTimeout(() -> {
            log.warn("MCP SSE 连接超时");
            sseEmitterManager.removeEmitter(emitterId);
        });

        emitter.onError(e -> {
            log.error("MCP SSE 连接错误", e);
            sseEmitterManager.removeEmitter(emitterId);
        });

        sseEmitterManager.addEmitter(emitterId, emitter);
    }

    // 仅用于 SSE 通道建立
    @GetMapping("/sse")
    public SseEmitter connect() {
        // 这里不处理query,因为客户端首次连接只是建立会话
        SseEmitter emitter = sseEmitterManager.getEmitter(DEFAULT_EMITTER_ID);
        if (emitter == null) {
            log.warn("MCP SSE 监听器未注册");
            sseEmitterManager.addEmitter(DEFAULT_EMITTER_ID, new SseEmitter(0L));
        }
        return sseEmitterManager.getEmitter(DEFAULT_EMITTER_ID);
    }

}

2.2 编写检索服务

java 复制代码
@Slf4j
@Service
public class SearchServiceImpl implements SearchService {

    private final WebClient webClient;

//    private final SseEmitterManager sseEmitterManager;

    // 这里使用自己的tavily API KEY
    private static final String baseUrl = "https://api.tavily.com";

    private static final String bearerToken = "tvly-dev-***";

    public SearchServiceImpl(
            SseEmitterManager sseEmitterManager) {
//        this.sseEmitterManager = sseEmitterManager;
        this.webClient = WebClient.builder()
                .baseUrl(baseUrl)
                .build();
    }

    @Override
    @Tool(name = "webSearch", description = "Searching information from the internet")
    public String search(
            @ToolParam(description = "搜索关键词")
            String query) {

        if (StrUtil.isBlank(query)) {
            return "none";
        }

        // 构建请求体
        SearchRequest req = new SearchRequest();
        req.setQuery(query);
        req.setTopic("general");
        req.setSearchDepth("basic");
        req.setChunksPerSource(3);
        req.setMaxResults(5);
        req.setTimeRange(null);
        req.setDays(3);
        req.setIncludeAnswer(true);
        req.setIncludeRawContent(false);
        req.setIncludeImages(false);
        req.setIncludeImageDescriptions(false);
        req.setIncludeDomains(Collections.emptyList());
        req.setExcludeDomains(Collections.emptyList());

        try {
            // 发送请求并等待响应(同步风格)
            SearchResponse resp = webClient.post()
                    .uri("/search")
                    .header("Authorization", "Bearer " + bearerToken)
                    .header("Content-Type", "application/json")
                    .bodyValue(req)
                    .retrieve()
                    .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
                            ClientResponse::createException)
                    .bodyToMono(SearchResponse.class)
                    .block(Duration.ofSeconds(15)); // 超时时间可调整

            if (resp == null) {
                return "No results returned.";
            }

            // 构建输出文本
            StringBuilder out = new StringBuilder();
            out.append("Query: ").append(resp.getQuery()).append("\n\n");

            if (resp.getAnswer() != null && !resp.getAnswer().isBlank()) {
                out.append("Answer (summary):\n").append(resp.getAnswer()).append("\n\n");
            }

            List<SearchResult> results = resp.getResults();
            if (results == null || results.isEmpty()) {
                out.append("No results returned.");
            } else {
                out.append("Top results:\n");
                int idx = 1;
                for (SearchResult r : results.stream().limit(5).toList()) {
                    out.append(idx++).append(". ").append(safe(r.getTitle())).append("\n");
                    out.append(" URL: ").append(safe(r.getUrl())).append("\n");
                    if (r.getContent() != null && !r.getContent().isBlank()) {
                        // 截断content到合理长度以免返回过长文本
                        String snip = r.getContent().length() > 400 ? r.getContent().substring(0, 400) + "..." : r.getContent();
                        out.append(" Snippet: ").append(snip).append("\n");
                    }
                    out.append(" Score: ").append(r.getScore()).append("\n\n");
                }
            }

            out.append("API response_time: ").append(safe(resp.getResponseTime())).append("s");
            // 发送结果
//            sseEmitterManager.getEmitter(DEFAULT_EMITTER_ID).send(out.toString(), MediaType.TEXT_PLAIN);
            return out.toString();
        } catch (Exception ex) {
            log.error("搜索服务错误: ", ex);
            return "Error: " + ex.getMessage();
        }
    }

    private String safe(String s) {
        return s == null ? "" : s;
    }

    @Setter
    @Getter
    public static class SearchRequest {
        // getters / setters
        private String query;
        private String topic;
        @JsonProperty("search_depth")
        private String searchDepth;
        @JsonProperty("chunks_per_source")
        private Integer chunksPerSource;
        @JsonProperty("max_results")
        private Integer maxResults;
        @JsonProperty("time_range")
        private Object timeRange;
        private Integer days;
        @JsonProperty("include_answer")
        private Boolean includeAnswer;
        @JsonProperty("include_raw_content")
        private Boolean includeRawContent;
        @JsonProperty("include_images")
        private Boolean includeImages;
        @JsonProperty("include_image_descriptions")
        private Boolean includeImageDescriptions;
        @JsonProperty("include_domains")
        private List<String> includeDomains;
        @JsonProperty("exclude_domains")
        private List<String> excludeDomains;

    }

    @Setter
    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class SearchResponse {
        // getters / setters
        private String query;
        private String answer;
        private List<String> images;
        private List<SearchResult> results;
        @JsonProperty("response_time")
        private String responseTime;

    }

    @Setter
    @Getter
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class SearchResult {
        // getters / setters
        private String title;
        private String url;
        private String content;
        private Double score;
        @JsonProperty("raw_content")
        private String rawContent;
    }

}

2.3 在启动类中声明回调工具方法

java 复制代码
@SpringBootApplication
public class AiApplication {

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

    // 自动配置将自动将工具回调注册为 MCP 工具。
    // 您可以有多个 Bean 生成 ToolCallbacks。自动配置将合并它们。
    @Bean
    public ToolCallbackProvider searchTools(SearchService searchService) {
        return MethodToolCallbackProvider.builder().toolObjects(searchService).build();
    }

}

到此,我们完成了一个可以供SpringAI MCP客户端调用的MCP Server,你也可以在这个应用的基础上添加其它模块以丰富应用功能。

3.配置MCP-Client

注意:不同的模型工具调用能力不同,百炼平台的一些模型在流式输出调用MCP工具的时候会报错,目前经过测试,在百炼平台工具上MCP工具调用能力较强的模型ID有:

  • qwen-turbo-latest
  • qwen3-max
  • qwen3-235b-a22b-thinking-2507
  • qwen-plus-latest

而下列模型在使用过程中出现不同程度的错误:

  • qwen3-235b-a22b-instruct-2507如果使用.stream()流式输出,会在调用过程中报错null or empty toolName,而直接使用.call()则不会
  • DeepSeek系列无法调用MCP Server的工具

以上结论在版本SpringAI 1.1.0-RC1测试得出,目前不确定是SpringAI的问题还是模型本身的问题


3.1 准备工作

引入Maven依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-client</artifactId>
    <version>1.1.0-RC1</version>
</dependency>

编写配置文件

yml 复制代码
spring:
  ai:
    mcp:
      client:
        enabled: true
        name: chat-client
        version: 1.0.0
        request-timeout: 120s
        type: ASYNC # 类型同步SYNC或者异步ASYNC
        sse:
          connections:
            search:
              url: http://localhost:8577/mcp/search/

3.2 测试

编写测试类

java 复制代码
@Slf4j
@SpringBootTest
public class SpringAITests {

    // 自动注入的toolCallbackProvider
    @Autowired
    private AsyncMcpToolCallbackProvider toolCallbackProvider;

    @Test
    public void springAIStreamChatWithMemoClient() throws InterruptedException {

        // 计时器
        CountDownLatch countDownLatch = new CountDownLatch(1);

        OpenAiApi openAiApi = OpenAiApi.builder()
                // 百炼平台 API KEY
                .apiKey("sk-***")
                .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
                .completionsPath("/chat/completions")
                .build();

        OpenAiChatOptions chatOptions = OpenAiChatOptions.builder()
                .maxTokens(2048)
                .temperature(0.85)
                .topP(0.9)
                .model("qwen3-max")
                .streamUsage(true)
                .build();

        ChatModel chatModel = OpenAiChatModel.builder()
                .openAiApi(openAiApi)
                .defaultOptions(chatOptions)
                .build();

        ChatClient chatClient = ChatClient.builder(chatModel)
                // 默认工具调用
                .defaultTools(new DateTimeTools())
                // 默认系统提示词
                .defaultSystem("你是一个由losgai开发的、友善的AI助手,请保持你的语气平和、耐心、礼貌。")
                .build();

        ToolCallback[] toolCallbacks = toolCallbackProvider.getToolCallbacks();

        // 反应式对话流
        Flux<ChatResponse> responseFlux = chatClient.prompt()
                .user("帮我搜索英雄联盟S15世界赛结果")
                .toolCallbacks(toolCallbacks)
                .stream()
                .chatResponse();

        // 用于跟踪最后一个 ChatResponse
        AtomicReference<ChatResponse> lastResponse = new AtomicReference<>();

        // 订阅 Flux 实现流式输出(控制台输出或 SSE 推送)
        StringBuffer res = new StringBuffer();
        responseFlux.subscribe(
                token -> {
                    // 获取当前输出内容片段
                    if(token.getResult()!=null){
                        log.info("输出内容:{}", token.getResult().getOutput().getText());
                        res.append(token.getResult().getOutput().getText());
                    }
                    // 更新最后一个响应
                    lastResponse.set(token);
                },
                // 反应式流在报错时会直接中断
                error -> {
                    log.error("出错:", error);
                    // 错误,停止倒计时
                    countDownLatch.countDown();
                }, // 错误处理
                () -> {// 流结束
                    log.info("\n回答完毕!");
                    // 从最后一个响应中获取 Token 使用信息
                    ChatResponse chatResponse = lastResponse.get();
                    if (chatResponse != null) {
                        Usage usage = chatResponse.getMetadata().getUsage();
                        log.info("===== Token 使用统计 =====");
                        log.info("输入 Token 数(Prompt Tokens): {}", usage.getPromptTokens());
                        log.info("输出 Token 数(Completion Tokens): {}", usage.getCompletionTokens());
                        log.info("总 Token 数(Total Tokens): {}", usage.getTotalTokens());
                        log.info("回复汇总:\n{}", res);
                    } else {
                        log.warn("未获取到 Token 使用信息,可能模型未返回或配置未启用");
                    }
                    countDownLatch.countDown();
                });

        // 阻塞主线程最多60s 等待结果
        countDownLatch.await(60, TimeUnit.SECONDS);
    }
}

测试结果

md 复制代码
com.losgai.ai.SpringAITests              : ===== Token 使用统计 =====
com.losgai.ai.SpringAITests              : 输入 Token 数(Prompt Tokens): 2167
com.losgai.ai.SpringAITests              : 输出 Token 数(Completion Tokens): 261
com.losgai.ai.SpringAITests              : 总 Token 数(Total Tokens): 2428
com.losgai.ai.SpringAITests              : 回复汇总:
根据搜索结果,**英雄联盟S15全球总决赛**已于2025年11月在成都举行,最终结果如下:

- **冠军**:**T1战队**
- **亚军**:**KT战队**
- **决赛比分**:**T1 3:2 KT**

### 关键亮点:
1. **T1战队成功实现三连冠**(S13、S14、S15),成为英雄联盟历史上首支达成此成就的战队。
2. **Faker(李相赫)斩获个人第六个世界赛冠军**,进一步巩固其"传奇中单"的地位。
3. **FMVP(总决赛最有价值选手)**:**Gumayusi**(T1下路选手)。

比赛过程非常激烈,双方打满五局,最终T1在决胜局中凭借出色的团队配合和战术执行拿下胜利。这场决赛不仅展现了LCK赛区的强大实力,也为全球观众奉献了一场精彩绝伦的对决! 🏆

如果需要更多细节(如每局比赛的BP或关键操作),可以告诉我!

当然,我们还可以自定义增强工具调用阶段的逻辑,例如下面的CustomMcpToolCallbackProvider.java

java 复制代码
/**
 * 自定义MCP工具回调提供器,添加日志功能
 */
@Slf4j
@Component
@Primary
@RequiredArgsConstructor
public class CustomMcpToolCallbackProvider extends AsyncMcpToolCallbackProvider {

    private final AsyncMcpToolCallbackProvider delegate;

    @NotNull
    @Override
    public ToolCallback[] getToolCallbacks() {
        ToolCallback[] originalCallbacks = delegate.getToolCallbacks();

        if (originalCallbacks.length == 0) {
            return originalCallbacks;
        }

        // 包装每个工具回调,添加SSE通知
        return Arrays.stream(originalCallbacks)
                .map(this::wrapNotification)
                .toArray(ToolCallback[]::new);
    }

    private ToolCallback wrapNotification(ToolCallback originalCallback) {

        return new ToolCallback() {

            @Override
            public ToolDefinition getToolDefinition() {
                ToolDefinition toolDefinition = originalCallback.getToolDefinition();
                log.info("获取工具定义: {}", toolDefinition);
                return toolDefinition;
            }

            @Override
            public ToolMetadata getToolMetadata() {
                ToolMetadata toolMetadata = originalCallback.getToolMetadata();
                log.info("MCP工具调用开始: {}", toolMetadata);
                return toolMetadata;
            }

            @Override
            public String call(String toolInput, ToolContext toolContext) {
                String call = ToolCallback.super.call(toolInput, toolContext);
                log.info("MCP工具调用结束: {}", call);
                return call;
            }

            @Override
            public String call(String functionInput) {
                String toolName = getToolDefinition().name();
                if (StrUtil.isBlank(functionInput)) {
                    return "none";
                }
                try {
                    log.info("MCP工具调用开始: {} 参数: {}", toolName, functionInput);
                    // 执行实际的工具调用
                    String result = originalCallback.call(functionInput);
                    log.info("MCP工具调用成功: {} 结果: {}", toolName, result);

                    return result;
                } catch (Exception e) {
                    log.error("MCP工具调用失败: {} 错误: {}", toolName, e.getMessage(), e);
                    throw e;
                }
            }
        };
    }
}

增强的日志输出:

bash 复制代码
获取工具定义: DefaultToolDefinition[name=webSearch, description=Searching information from the internet, inputSchema={"type":"object","properties":{"query":{"type":"string","description":"搜索关键词"}},"required":["query"],"additionalProperties":false}]
MCP工具调用开始: DefaultToolMetadata[returnDirect=false]
获取工具定义: DefaultToolDefinition[name=webSearch, description=Searching information from the internet, inputSchema={"type":"object","properties":{"query":{"type":"string","description":"搜索关键词"}},"required":["query"],"additionalProperties":false}]
MCP工具调用开始: DefaultToolMetadata[returnDirect=false]
获取工具定义: DefaultToolDefinition[name=webSearch, description=Searching information from the internet, inputSchema={"type":"object","properties":{"query":{"type":"string","description":"搜索关键词"}},"required":["query"],"additionalProperties":false}]
MCP工具调用开始: webSearch 参数: {"query": "英雄联盟S15世界赛结果"}
MCP工具调用成功: webSearch 结果: [{"text":"\"Query: 英雄联盟S15世界赛结果\\n\\nAnswer (summary):\\nT1 won the 2025 League of Legends World Championship S15, defeating KT 3-2 in the finals. Faker earned his sixth championship title. T1 became the first team to achieve three consecutive world championships.\\n\\nTop results:\\n1. T1 夺得英雄联盟S15 总决赛冠军,成为首支「三连冠」战队 - NOWRE\\n URL: https://nowre.com/lifestyle/1004558/t1-duodeyingxionglianmeng-s15-zongjuesaiguanjunchengweishouzhisanlianguanzhandui/\\n Snippet: 在2025 英雄联盟全球总决赛成都的决赛舞台上,两支来自LCK 战队T1 和KT 打满五场,最终T1 在决胜局赢下比赛,夺得本次世界赛冠军。T1 中单选手李相赫(Faker)收获了\\n Score: 0.99979013\\n\\n2. T1夺得英雄联盟S15世界总决赛冠军,再创电竞高峰\\n URL: https://lufkindailynews.com/test/?s-news-7346505-2025-11-10-t1-clinches-championship-titles-at-league-of-legends-s15-world-finals\\n Snippet: T1夺得英雄联盟S15世界总决赛冠军,再创电竞高峰. 2025年11月10日. 赛况回顾:T1力克KT战队,稳获全球冠军. 2025年,英雄联盟全球总决赛(World\\n Score: 0.9997695\\n\\n3. 【英雄联盟世界赛】T1夺得S15冠军,Faker斩获第六冠:比起所有的 ...\\n URL: https://www.youtube.com/watch?v=DTv2svlGH_c\\n Snippet: 【英雄联盟世界赛】T1夺得S15冠军,Faker斩获第六冠:比起所有的成就和记录,我更在意今天是不是一场精彩的比赛!谢谢KT,我们共同呈现了一场精彩绝伦的比赛!\\nLPL TV\\n31400 subscribers\\n29 likes\\n4179 views\\n9 Nov 2025\\n订阅非常适合上传视频!\\n如果您设置了通知,则上传后可以立即看到它!\\n►订阅频道(Subscribe) :  https://goo.gl/bwEQRX\\n►Contact me: wilsonyang218@gmail.com\\nThank you for your coopertation.\\n14 comments\\n\\n Score: 0.9988575\\n\\n4. T1击败KT,斩获《英雄联盟》S15总决赛冠军 - 机核\\n URL: https://www.gcores.com/articles/206710\\n Snippet: 游戏库 播单 专题 预告 下载机核 APP # T1击败KT,斩获《英雄联盟》S15总决赛冠军 恭喜T1! YT174 小时前发布于资讯 本文系用户投稿,不代表机核网观点 在成都东安湖体育公园多功能体育馆举行的《英雄联盟》2025全球总决赛现场,LCK 的两只战队迎来内战。 最终, T1战队以3比2战胜了KT 战队,斩获冠军! Gumayusi选手获得FMVP 。 值得一提的是,这也是 T1 队史的第六座召唤师冠军奖杯,也创下了三连冠的惊人记录。此前 T1 的战绩为:S3冠军、S5冠军、S6冠军、S7亚军、S12亚军、S13冠军、S14冠军。 王冠之重,王者承担,无数斑驳星光汇聚之处即是希望!怀揣一颗炽热、真诚且纯粹的心,就能看清那象征至高荣誉奖杯之上的光辉。争者留其名,2025全球总决赛冠亚军决赛最终之战,再次恭喜 T1! 在赛场中,今年 29 岁的选手 Faker(李相赫)再度展现了自...\\n Score: 0.99843913\\n\\n5. 恭喜T1拿下英雄联盟S15世界冠军!3-2击败KT,Faker实现6冠王荣誉\\n URL: https://news.qq.com/rain/a/20251109A04ROT00\\n Snippet: # 恭喜T1拿下英雄联盟S15世界冠军!3-2击败KT,Faker实现6冠王荣誉 贝塔看比赛 2025-11-09 21:14发布于湖北游戏领域创作者 S15全球总决赛KT和T1的决赛BO5,双方打满5局之后,T1以3-2的战绩击败KT,拿下世界冠军!Faker完成6冠王的成就! 第一局KT上单兰博,打野猴子,中单瑞兹,下路艾希加布隆;T1上单安蓓萨,打野赵信,中单岩雀,下路韦鲁斯加波比。对线期KT节奏起飞,河道击杀oner赵信,BDD瑞兹拿到一血,随后T1下路失误,KT打出1换2,上路KT野辅及时支援打出0换2,前期KT推掉了上路一血塔,拿到了2条小龙,经济领先2K。 中期双方争夺峡谷先锋,Doran安蓓萨直接开到Deokdam艾希,T1打出0换2后拉扯打厄塔汗,KT其他人想要阻止被T1团灭,T1拿下厄塔汗。28分钟双方中路混战,虽然Oner赵信开团率先被秒,但是BDD瑞兹被keria波...\\n Score: 0.9973061\\n\\nAPI response_time: 1.24s\""}]
MCP工具调用结束: [{"text":"\"Query: 英雄联盟S15世界赛结果\\n\\nAnswer (summary):\\nT1 won the 2025 League of Legends World Championship S15, defeating KT 3-2 in the finals. Faker earned his sixth championship title. T1 became the first team to achieve three consecutive world championships.\\n\\nTop results:\\n1. T1 夺得英雄联盟S15 总决赛冠军,成为首支「三连冠」战队 - NOWRE\\n URL: https://nowre.com/lifestyle/1004558/t1-duodeyingxionglianmeng-s15-zongjuesaiguanjunchengweishouzhisanlianguanzhandui/\\n Snippet: 在2025 英雄联盟全球总决赛成都的决赛舞台上,两支来自LCK 战队T1 和KT 打满五场,最终T1 在决胜局赢下比赛,夺得本次世界赛冠军。T1 中单选手李相赫(Faker)收获了\\n Score: 0.99979013\\n\\n2. T1夺得英雄联盟S15世界总决赛冠军,再创电竞高峰\\n URL: https://lufkindailynews.com/test/?s-news-7346505-2025-11-10-t1-clinches-championship-titles-at-league-of-legends-s15-world-finals\\n Snippet: T1夺得英雄联盟S15世界总决赛冠军,再创电竞高峰. 2025年11月10日. 赛况回顾:T1力克KT战队,稳获全球冠军. 2025年,英雄联盟全球总决赛(World\\n Score: 0.9997695\\n\\n3. 【英雄联盟世界赛】T1夺得S15冠军,Faker斩获第六冠:比起所有的 ...\\n URL: https://www.youtube.com/watch?v=DTv2svlGH_c\\n Snippet: 【英雄联盟世界赛】T1夺得S15冠军,Faker斩获第六冠:比起所有的成就和记录,我更在意今天是不是一场精彩的比赛!谢谢KT,我们共同呈现了一场精彩绝伦的比赛!\\nLPL TV\\n31400 subscribers\\n29 likes\\n4179 views\\n9 Nov 2025\\n订阅非常适合上传视频!\\n如果您设置了通知,则上传后可以立即看到它!\\n►订阅频道(Subscribe) :  https://goo.gl/bwEQRX\\n►Contact me: wilsonyang218@gmail.com\\nThank you for your coopertation.\\n14 comments\\n\\n Score: 0.9988575\\n\\n4. T1击败KT,斩获《英雄联盟》S15总决赛冠军 - 机核\\n URL: https://www.gcores.com/articles/206710\\n Snippet: 游戏库 播单 专题 预告 下载机核 APP # T1击败KT,斩获《英雄联盟》S15总决赛冠军 恭喜T1! YT174 小时前发布于资讯 本文系用户投稿,不代表机核网观点 在成都东安湖体育公园多功能体育馆举行的《英雄联盟》2025全球总决赛现场,LCK 的两只战队迎来内战。 最终, T1战队以3比2战胜了KT 战队,斩获冠军! Gumayusi选手获得FMVP 。 值得一提的是,这也是 T1 队史的第六座召唤师冠军奖杯,也创下了三连冠的惊人记录。此前 T1 的战绩为:S3冠军、S5冠军、S6冠军、S7亚军、S12亚军、S13冠军、S14冠军。 王冠之重,王者承担,无数斑驳星光汇聚之处即是希望!怀揣一颗炽热、真诚且纯粹的心,就能看清那象征至高荣誉奖杯之上的光辉。争者留其名,2025全球总决赛冠亚军决赛最终之战,再次恭喜 T1! 在赛场中,今年 29 岁的选手 Faker(李相赫)再度展现了自...\\n Score: 0.99843913\\n\\n5. 恭喜T1拿下英雄联盟S15世界冠军!3-2击败KT,Faker实现6冠王荣誉\\n URL: https://news.qq.com/rain/a/20251109A04ROT00\\n Snippet: # 恭喜T1拿下英雄联盟S15世界冠军!3-2击败KT,Faker实现6冠王荣誉 贝塔看比赛 2025-11-09 21:14发布于湖北游戏领域创作者 S15全球总决赛KT和T1的决赛BO5,双方打满5局之后,T1以3-2的战绩击败KT,拿下世界冠军!Faker完成6冠王的成就! 第一局KT上单兰博,打野猴子,中单瑞兹,下路艾希加布隆;T1上单安蓓萨,打野赵信,中单岩雀,下路韦鲁斯加波比。对线期KT节奏起飞,河道击杀oner赵信,BDD瑞兹拿到一血,随后T1下路失误,KT打出1换2,上路KT野辅及时支援打出0换2,前期KT推掉了上路一血塔,拿到了2条小龙,经济领先2K。 中期双方争夺峡谷先锋,Doran安蓓萨直接开到Deokdam艾希,T1打出0换2后拉扯打厄塔汗,KT其他人想要阻止被T1团灭,T1拿下厄塔汗。28分钟双方中路混战,虽然Oner赵信开团率先被秒,但是BDD瑞兹被keria波...\\n Score: 0.9973061\\n\\nAPI response_time: 1.24s\""}]
2025-11-13T14:03:01.616+08:00  INFO 22732 --- [ai] [oundedElastic-2] c.l.a.u.CustomMcpToolCallbackProvider    : 获取工具定义: DefaultToolDefinition[name=webSearch, description=Searching information from the internet, inputSchema={"type":"object","properties":{"query":{"type":"string","description":"搜索关键词"}},"required":["query"],"additionalProperties":false}]

上面的组件为工具调用阶段提供了日志,在实际应用开发中,可以考虑在这个阶段发送SSE状态通知等操作。

3.3 应用集成

修改我们的ChatClientFactory.java组件,新注入对应的AsyncMcpToolCallbackProvider或刚才自定义的CustomMcpToolCallbackProvider,并添加.toolCallbacks参数即可:

java 复制代码
@Component
@RequiredArgsConstructor
@Slf4j
public class ChatClientFactory {

    // 根据配置id来分配不同的Client,复用
    private final Map<Integer, ChatClient> clientCache = new ConcurrentHashMap<>();

    private final MybatisChatMemory mybatisChatMemory;

    private final RagService ragService;

    private final EmbeddingStoreFactory embeddingStoreFactory;

    private final SseEmitterManager sseEmitterManager;

//        private final AsyncMcpToolCallbackProvider toolCallbackProvider;

    // 自定义MCP工具回调提供器,添加日志调试
    private final CustomMcpToolCallbackProvider toolCallbackProvider;

    private static final String INDEX_FINDING_SYSTEM_MSG = "You are an expert-level AI Routing Agent. " +
            "Your sole task is to select the most relevant index name from a given list based on a user's question.\n"
            +
            "\n" +
            "Your workflow is as follows:\n" +
            "1.  Analyze the user's question to understand its core intent and subject matter.\n" +
            "2.  Examine each name in the "Index Name List" to understand the data domain it represents.\n"
            +
            "3.  Perform a semantic match to determine which index is most likely to contain the information needed to answer the user's question.\n"
            +
            "\n" +
            "You must strictly adhere to the following rules:\n" +
            "-   **Unique Output**: Your response MUST be one of the index names from the list, or the string "0".\n"
            +
            "-   **No Explanation**: Do NOT include any explanations, justifications, apologies, or any form of additional text. For example, do not say "I think the best match is a". You must only output "a".\n"
            +
            "-   **Definition of "0"**: If the user's question is small talk, a greeting, completely unrelated to any of the indexes, or if you cannot determine a clear correlation, you MUST output "0".\n"
            +
            "-   **Exact Match**: The outputted index name must be an exact, case-sensitive match to the string provided in the list.\n"
            +
            "-   **Decisive Choice**: When multiple options seem partially relevant, choose the one that is most centrally and directly related. If you cannot make a clear best choice, default to outputting "0" to avoid incorrect routing.";

    /**
     * 根据 AI 配置创建或复用 ChatClient
     */
    public ChatClient getOrCreateClient(AiConfig aiConfig) {
        return clientCache.computeIfAbsent(aiConfig.getId(), k -> createClient(aiConfig));
    }

    /**
     * 真正创建 ChatClient 的方法
     */
    private ChatClient createClient(AiConfig aiConfig) {
        OpenAiApi openAiApi = OpenAiApi.builder()
                .apiKey(aiConfig.getApiKey())
                .baseUrl(aiConfig.getApiDomain())
                .completionsPath("/chat/completions")
                .build();

        OpenAiChatOptions chatOptions = OpenAiChatOptions.builder()
                .maxTokens(aiConfig.getMaxContextMsgs())
                .temperature(aiConfig.getTemperature())
                .topP(aiConfig.getSimilarityTopP())
                .model(aiConfig.getModelId())
                .streamUsage(true)
                .build();

        ChatModel chatModel = OpenAiChatModel.builder()
                .openAiApi(openAiApi)
                .defaultOptions(chatOptions)
                .build();

        return ChatClient.builder(chatModel)
                // 默认工具调用
                .defaultTools(new DateTimeTools())
                // 默认系统提示词
                .defaultSystem("you are a helpful ai assistant developed by losgai." +
                        "You are connected to a Web Search Tool. " +
                        "If the user asks something you cannot answer confidently, " +
                        "always call the tool 'webSearch' with the query string.")
                .build();
    }

    /**
     * 构建流式对话响应
     */
    public Flux<ChatResponse> streamChat(
            AiConfig aiConfig,
            List<String> urlList,
            String userMsg,
            String conversationId) {
        // 状态通知的sse
        if (sseEmitterManager.getEmitter(conversationId) == null) {
            sseEmitterManager.addEmitter(conversationId, new SseEmitter(30000L));
        }
        SseEmitter emitter = sseEmitterManager.getEmitter(conversationId);
        try {
            emitter.send("正在连接服务器...");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        ChatClient chatClient = getOrCreateClient(aiConfig);

        List<String> indexes = List.of();
        try {
            indexes = ragService.getIndexes();
        } catch (IOException e) {
            log.error("获取向量索引列表失败!");
        }

        // RAG检索
        Advisor retrievalAugmentationAdvisor = null;

        if (CollUtil.isNotEmpty(indexes)) {

            try {
                emitter.send("正在检索知识库...");
            } catch (IOException e) {
                throw new RuntimeException(e);
            }

            Set<String> indexSet = Set.copyOf(indexes);
            String INDEX_FINDING_USER_MSG = "Index Name List:" +
                    indexes +
                    "User Question:" +
                    userMsg;

            // 首先通过大模型,选择合适的RAG索引
            String indexName = chatClient.prompt()
                    .system(INDEX_FINDING_SYSTEM_MSG)
                    .user(INDEX_FINDING_USER_MSG)
                    .call()
                    .content();
            // 索引合法,构建检索器
            if (indexSet.contains(indexName)) {
                // 构建检索器
                ElasticsearchVectorStore vectorStore = embeddingStoreFactory
                        .createVectorStore(indexName);
                // 构建召回器
                retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
                        .documentRetriever(VectorStoreDocumentRetriever.builder()
                                // 相似度阈值,0.5表示只有当检索结果的相似度分数 ≥ 0.50 时,才返回上层
                                .similarityThreshold(0.1)
                                // 注入的向量存储
                                .vectorStore(vectorStore)
                                .build())
                        .queryAugmenter(ContextualQueryAugmenter.builder()
                                // 使用参数,允许查找的结果为空
                                .allowEmptyContext(true)
                                .build())
                        .build();
            }
        } else {
            log.info("跳过RAG步骤");
        }

        try {
            emitter.send("正在检索...");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        ToolCallback[] toolCallbacks = toolCallbackProvider.getToolCallbacks();

        // 多模态输入
        if (CollUtil.isNotEmpty(urlList) && aiConfig.getModelType() == 2) {
            List<Media> mediaList = urlList.stream().map(url -> {
                try {
                    MimeType mimeType = url.endsWith("png") ? MimeTypeUtils.IMAGE_PNG
                            : MimeTypeUtils.IMAGE_JPEG;
                    return Media.builder().mimeType(mimeType).data(new UrlResource(url)).build();
                } catch (MalformedURLException e) {
                    throw new RuntimeException(e);
                }
            }).toList();

            if (retrievalAugmentationAdvisor == null) {
                return chatClient.prompt()
//                        .toolCallbacks(toolCallbacks)
                        .user(u -> u.text(userMsg).media(mediaList.toArray(new Media[0])))
                        .advisors(MessageChatMemoryAdvisor.builder(mybatisChatMemory).build())
                        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                        .stream()
                        .chatResponse();
            }
            return chatClient.prompt()
//                    .toolCallbacks(toolCallbacks)
                    .user(u -> u.text(userMsg).media(mediaList.toArray(new Media[0])))
                    .advisors(MessageChatMemoryAdvisor.builder(mybatisChatMemory).build())
                    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                    .advisors(retrievalAugmentationAdvisor)
                    .stream()
                    .chatResponse();
        }

        if (retrievalAugmentationAdvisor == null) {
            return chatClient.prompt()
                    .toolCallbacks(toolCallbacks)
                    .user(userMsg)
                    .advisors(MessageChatMemoryAdvisor.builder(mybatisChatMemory).build())
                    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                    .stream()
                    .chatResponse();
        }

        // 普通文本
        return chatClient.prompt()
                .toolCallbacks(toolCallbacks)
                .user(userMsg)
                .advisors(MessageChatMemoryAdvisor.builder(mybatisChatMemory).build())
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                .advisors(retrievalAugmentationAdvisor)
                .stream()
                .chatResponse();
    }
}

结果演示:


总结

🎉本文介绍了使用SpringAI结合MCP实现联网搜索的方法,包括服务端和客户端的搭建,以及应用的整合。希望本文能帮到大家,如果喜欢别忘了点赞、关注、收藏,或者帮忙在github点一个star⭐️!

相关推荐
朝新_1 小时前
【统一功能处理】从入门到源码:拦截器学习指南(含适配器模式深度解读)
数据库·后端·mybatis·适配器模式·javaee
q***7481 小时前
私有化部署DeepSeek并SpringBoot集成使用(附UI界面使用教程-支持语音、图片)
spring boot·后端·ui
❀͜͡傀儡师1 小时前
使用DelayQueue 分布式延时队列,干掉定时任务!
java·分布式·delayqueue·spingboot
失散132 小时前
分布式专题——55 ElasticSearch性能调优最佳实践
java·分布式·elasticsearch·架构
Java水解2 小时前
Spring WebFlux 核心操作符详解:map、flatMap 与 Mono 常用方法
后端·spring
Java水解2 小时前
MySQL 慢查询 debug:索引没生效的三重陷阱
后端·mysql
jonyleek2 小时前
【JVS更新日志】低代码、APS排产、物联网、企业计划11.12更新说明!
java·物联网·低代码·前端框架·团队开发
keke_俩个科2 小时前
实战派 JMeter 指南:核心功能、并发压测实操与常见问题解决方案
java·jmeter·spring·spring cloud·tomcat
青梅主码2 小时前
介绍一下我开发的一款新工具:函数图像绘制工具
后端