使用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
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-latestqwen3-maxqwen3-235b-a22b-thinking-2507qwen-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⭐️!