项目正要上线,第一轮真实的业务对话却卡了整整20秒,随后控制台抛出一段让人血压升高的异常:
reactor.core.Exceptions$ReactorException: java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 20000ms in 'source(MonoDefer)'用Spring AI框架接入MCP时,这个报错几乎每次都能精准地把“AI应用跑不通”的锅甩到响应式编程头上。但它真不是表面上看的“请求超时”那么简单。本文就围绕这句Did not observe any item or terminal signal within 20000ms in source(Mono...)展开,从原理、排查到修复,完整拆一遍我实际踩过的链路。
1. 报错现场:一条20秒就破裂的异步链路
先还原一下现象。我当时在本地跑一个Spring AI聊天应用,通过MCP客户端接了一个订单查询工具。输入框里敲入“查一下订单10001的状态”,前端转了20秒,随后接口返回500。日志尾部就是开头那句话。
这个异常来自Project Reactor,字面意思是:有一个Mono数据源在20000毫秒内既没有发射任何数据项(item),也没有发出终止信号(terminal signal)。响应式流里,没有终止信号就意味着订阅方会永远等下去,所以等待方在超时条件满足后强行跳出,把异常抛到业务层。
问题在于,为什么Spring AI调用MCP工具会撞上这个等待机制?
Spring AI底层大量使用Reactor做异步编排,MCP客户端调用工具的结果会被包装成Mono交给上层统一处理。当MCP服务器侧没有任何有效响应时,这个Mono会一直保持“静默”。如果没有显式超时保护,请求会无限挂住;正是因为某一层设置了20秒的等待窗口,错误才被及时抛了出来,让服务以500收场,而不是全体卡死。
我发现这类报错出现的高频场景有两类:
- 应用启动后第一次对话就超时,等几分钟再调用反而正常;
- 某些调用正常,某些调用随机超时,和MCP服务器当时的负载、外部依赖状态有关系。
如果你和我一样,第一反应是“把超时调大再看”,那这篇文章不只是想告诉你调参,而是想让你把“为什么这个Mono拿不到信号”彻底拆透。因为调超时往往只是把问题往后推了20秒,服务器侧真正的问题还在原地等你处理。
2. 拆解Mono超时的底层逻辑:信号、阻塞与20秒
要定位这种问题,得先理解Reactor里的两个概念:item和terminal signal。
一个Mono管道,生命周期里最多发射一个数据项(对应onNext),然后以onComplete或onError结束。onComplete和onError统称为终止信号。调用Mono.block(Duration)时,当前线程会阻塞住,持续盯着管道,直到发生三种情况之一:收到数据项、收到终止信号、等待时间超过传入的Duration。
如果等待20秒还没等到任何信号,Reactor就抛出开头那个TimeoutException。错误里in 'source(MonoDefer)这一小段,是Reactor在异常信息中附带的管道源头描述,指向那个具体Mono的工厂类型。
这里要澄清一个常见的误解:20秒超时不代表网络请求超时。网络层可能早就连通了,TCP握手也完成了,但如果对端没有返回任何符合协议的内容,响应式管道依然拿不到信号。在MCP场景里,这通常意味着:请求发出去了,但服务器侧没有给出回答,或者回答在某个环节丢失。
另外要注意,出现这种超时不一定只在显式block()调用的代码处。Spring AI内部,异步链路也可能因为某个订阅方等待超时直接把异常抛出来。排查的时候不要死盯着业务代码里有没有block,而要找到真正被卡住的那个Mono在哪一段管道上。
20秒这个数字也不是Reactor的固定默认值。Reactor原生的block()不传参数时,默认是无限等待。20000ms这个值一定是某个应用层的配置写进去的。Spring AI的MCP客户端配置项里有一个request-timeout,默认值往往就是20秒。也就是说,这段消息背后有两层含义:第一,某个MCP请求没在20秒内完成;第二,这个时间窗口是框架给的,而不是网络栈给的。
既然超时窗口和MCP请求直接相关,自然引出下一个问题:到底哪一段请求被卡住了?Socket没通?通了但MCP Server不打回应?打了回应但信息不合法?三种情况在错误消息里看起来完全相同,但排查路径完全不同,这也是很多人反复查不出结果的原因。
3. 排查链条:从堆栈根追踪到MCP进程的“断线点”
排查的目标只有一个:找到断线点,即被卡住的那个调用到底发生在哪一层。下面是我的排查路线,按从外到内的顺序展开。
3.1 打开Reactor调试开关
第一步,先把Reactor的调试开关打开。在启动类里加一行:
reactor.core.publisher.Hooks.onOperatorDebug();加了之后,异常堆栈会附带完整的操作链组装信息,能看到McpToolCallback、Mono、Flux这些符号是在哪段代码里被串联起来的。对响应式编程不熟的人,这一步能省掉大量瞎猜的时间。
3.2 看日志时间线
第二步,看完整日志。重点不是看异常本身,而是看异常之前MCP客户端和MCP服务器分别打印了什么。我把日志级别临时调低过:
logging: level: org.springframework.ai: DEBUG io.modelcontextprotocol: DEBUG如果MCP客户端打印了request相关日志,但到某个时间点之后没有对应的response日志,说明断点很可能在服务器侧。反之,如果连request日志都没有,问题要往前看,可能在Spring AI的工具查找或参数序列化环节。
3.3 绕开Spring AI,直连MCP服务器
第三步,是最关键的一步:绕开Spring AI,直接用MCP Java SDK写一个最小测试类连服务器。
McpClient syncClient = McpClient.sync("http://localhost:8080/mcp") .requestTimeout(Duration.ofSeconds(10)) .build(); var tools = syncClient.listTools(); System.out.println(tools);这段代码能在几秒内回答一个关键问题:MCP服务器本身可用吗?工具列表能不能正常拉取?如果这个测试也超时,说明问题根本不在Spring AI,而在MCP服务器端。如果测试正常,那么问题在Spring AI与MCP的集成层,比如工具注册名不匹配、Spring AI配置指向了错误端口。
这一步我强烈建议不要省。我见过太多人在不明真相的情况下把Spring AI配置反复改来改去,结果最后发现MCP服务器进程压根没起来。
3.4 定位卡在哪个工具上
第四步,如果是某个工具单独超时,其他工具正常,那就把范围缩小到具体工具实现。我给MCP服务器端的工具方法加了日志:
@Tool(description = "查询订单状态") public String queryOrder(String orderId) { long start = System.currentTimeMillis(); log.info("queryOrder begin, orderId={}", orderId); String result = orderService.query(orderId); log.info("queryOrder end, cost={}ms", System.currentTimeMillis() - start); return result; }通过前后两条日志的时间差,能判断是哪一步耗时。我遇到过工具里的SQL在慢查询状态下跑了十几秒,前一条日志正常出现,后一条迟迟不来。这种明显不是Spring AI的锅,而是业务工具实现本身需要优化。
3.5 确认是“没有响应”还是“响应格式错误”
最后一步,如果服务器明明发了响应,但Spring AI那边还是超时,要考虑协议解析层。比如用stdio方式运行MCP服务器时,stdout混入了非JSON文本行,客户端解析失败,请求照样会被挂到超时。这一步最好给MCP服务器额外做一次输出重定向,把stdout打印的内容全部清干净,确保stdout只走协议数据。
排查到这里,一般都能抓住根因了。简单总结一下我的判断顺序:先确认MCP服务器活着,再确认请求链路通,再确认工具能返回,最后确认协议干净。
4. 高发原因清单:哪种MCP配置最容易栽在这上面
为了让你少走弯路,我把Spring AI项目里比较常见、容易触发这类Mono超时的场景整理成了一份清单。
4.1 stdout污染
stdio传输模式下,MCP客户端会启动一个子进程,通过stdin/stdout交换JSON-RPC消息。MCP协议要求stdout上只能出现JSON-RPC响应。如果开发者在MCP服务器代码里用System.out.println打印日志,或者框架在启动时往stdout输出Banner,都会污染协议流。客户端拿到无法解析的行,直接丢弃或抛异常,Mono自然等不到信号。
这个坑非常隐蔽,因为它和业务逻辑无关。我见过有同事在一个Python MCP服务器里用print("server start")做启动提示,结果Java客户端这边全部超时。
4.2 MCP服务器初始化时间太长
很多MCP工具在启动时会加载模型、连接数据库、拉取远程配置。如果初始化总耗时超过20秒,工具还没注册完成,客户端此时发起调用,调用就会挂在路上。应用重启后的第一通对话超时,过几分钟再试反而正常,基本就是这个原因。
4.3 工具内部同步阻塞与Reactor线程不匹配
Spring AI的工具调用链路是异步的。当MCP工具实现里有比较重的同步操作,例如Thread.sleep、数据库连接等待、外部REST同步调用,且没有做好超时兜底,就会拖垮响应式调度线程池。一旦线程池被占满,后续所有工具调用都在排队,前端表现就是一批请求集体超时。
4.4 SSE远程服务器的网络问题
如果MCP服务器是独立部署的SSE服务,跨机器调用就存在网络抖动、防火墙限制、连接复用失效等问题。常见于A机器启动Spring AI,B机器启动MCP服务器,防火墙只开了TCP端口但限制并发,或者反向代理把长连接断掉了。这类问题直接用curl测SSE端点通常看不出来,因为命令行测的是短连接,而Reactor用的是长连接复用。
4.5 工具注册名与调用名不一致
Spring AI通过工具名找到对应的ToolCallback。MCP服务器返回的工具列表里名字叫queryOrder,但Spring AI发出的调用请求里名字是query_order,两端对不上,MCP服务器就会响应tool not found,或者干脆不响应。这类错误有时候会立刻返回error,有时候会静默超时,很难靠猜判断。
我把这五类情况整理成了一张速查表:
| 场景 | 表现 | 大概率原因 |
|---|---|---|
| 重启后第一通对话必超时 | 后续对话正常 | 初始化时间超过20秒 |
| 所有工具调用都超时 | 无任何MCP响应日志 | stdout污染或连接不通 |
| 特定工具超时 | 其他工具正常 | 工具内部阻塞或执行过慢 |
| 远程服务器间歇性超时 | 有时成功有时失败 | 网络或SSE连接复用问题 |
| 报tool not found后超时 | 工具名对不上 | 注册名与调用名不一致 |
用这张表去对照,基本能把问题从“整个系统”快速收敛到“某一个环节”。
5. 修复实战:从临时止血到代码级根治
下面给出一套可以直接落地的修复方案。按“先止血、后根治”的顺序写。
5.1 临时止血:调整request-timeout
如果确认是MCP服务器初始化超过20秒,但又不能立刻优化启动流程,可以先把Spring AI的MCP客户端超时参数调大:
spring: ai: mcp: client: request-timeout: 60s这个参数直接控制MCP请求的等待时长。但我得强调:调大超时不根治任何问题,它只是把异常爆发点往后推。如果服务器真的没响应,你只是从一个20秒的报错换成一个60秒的报错。这个改动只推荐用于联调环境下临时恢复,不建议直接上生产。
5.2 根治一:清理stdio输出
如果MCP服务器是stdio模式,把所有日志统一改到stderr或日志框架,检查有没有任何System.out、print之类的代码。还要确认没人给MCP服务器启动命令加过会往stdout打印内容的参数。
如果你用的是Python写MCP工具,要确认logging模块的handler绑定到了stderr而不是stdout。这是个几分钟能修完、但能省下几十小时排查时间的点。
5.3 根治二:初始化逻辑与工具注册解耦
MCP服务器的启动流程要遵循“轻启动”原则。不要在@PostConstruct里做重活,不要启动时就连接所有外部依赖。把非即时性资源放到容器就绪后再异步加载,工具注册和方法实现之间不耦合初始化状态。例如,用@EventListener(ApplicationReadyEvent.class)来触发耗时初始化:
@Component public class McpDataInitializer { @EventListener(ApplicationReadyEvent.class) public void init() { CompletableFuture.runAsync(() -> { // 预加载模型、拉取配置、建立连接池 }); } }这样客户端在启动后的任意时刻发起调用,基本都能拿到可用状态。
5.4 根治三:工具实现内部加入降级和超时
业务工具方法有条件的话,把外部依赖调用包上自己的超时控制。Java里的HttpClient自带超时参数,用起来直接:
HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .build(); HttpRequest request = HttpRequest.newBuilder(uri) .timeout(Duration.ofSeconds(5)) .build();这样工具自身不会成为最长不可中断的任务。即使上游变慢,最多5秒就能返回一个降级结果,MCP客户端不会在整个链路里无限等待。
5.5 根治四:对齐工具注册名
检查Spring AI侧的工具名和MCP服务器侧的工具名是否一致。不一致的话,改配置比改代码代价小。Spring AI的MCP客户端配置里可以指定需要加载的工具列表:
spring: ai: mcp: client: tool-names: - queryOrder - getUserInfo对齐之后重启应用,再触发相同对话,应该能直接拿到正常结果。
5.6 根治五:SSE模式加读超时
对于SSE远程服务器,在客户端配置里合理设置连接超时和读取超时。同时检查服务器端有没有挂反向代理,代理的空闲连接超时时间要大于客户端请求超时时间,否则长连接在某个时间点被代理回收,客户端还表现一切正常。
所有修复完成之后,不要只看一次调用成功就结案。我习惯做一轮持续测试,连续调用工具十几次,观察有没有偶发超时。如果偶发率在1%到5%之间,多数情况还是传输层或线程池问题,需要继续深挖。
6. 我总结的诊断手册:遇到此错误先查这五处
把前面的经验浓缩成一套查找顺序,团队遇到同类报错按这个顺序查,基本都能在半小时内定位问题。
- 先确认MCP服务器是否正常启动。用直连客户端测试工具列表,排除“服务器没起来”这种最简单的情况。
- 再确认stdout是否干净。把MCP服务器的标准输出重定向到文件,或者直接搜代码里的
System.out.println、print。 - 再看请求时间线。对比Spring AI侧和MCP服务器侧的日志,确认请求有没有出网、有没有入网、响应有没有回来。
- 核对工具名。对比Spring AI配置的tool-names和MCP服务器实际暴露的工具名称。
- 最后看工具实现。检查有没有慢SQL、外部调用无超时、初始化重逻辑。
我个人感触最深的是第一和第二个检查点。前两次遇到这种问题时,我都直接扎进业务代码里找问题,翻了几小时一无所获;后来先做隔离测试,再看进程输出,基本一碰一个准。
另外补充一个技巧:如果你在Spring AI项目里同时接入了多个MCP服务器,这个错误会变得更加迷惑,因为多个Mono可能在同一批次里等待。建议给每个MCP服务器配置独立的客户端入口,并分别设置独立的超时时间,这样至少能快速分辨是哪条链路卡住。我在生产环境里用过这个方案,排障速度提升非常明显。
最后再说一点:把Hooks.onOperatorDebug()放到本地启动脚本里保留,生产环境默认关闭。这样在本地复现任何一个MCP超时问题时,堆栈都会直接指向调用链的最终组装位置,省去大量人工推演步骤。排查验证完再移除开关就行,不会给线上带来额外开销。