接入 MCP(Model Context Protocol)服务器的 AI 应用,一个绕不开的工程问题是:当模型发出工具调用请求,而下游 MCP 服务端超时或连接中断时,客户端如何表现才算健壮。
先看失败本身。MCP 调用本质上是一次客户端-服务端请求。从客户端视角看,失败可以分成几类:
- 连接建立失败:服务不可达、DNS 解析失败、TLS 握手超时。
- 读写超时:请求已发出但响应迟迟未到,或响应读取到一半中断。
- 业务错误返回:语法错误、参数错误、方法不存在、内部错误。
- 状态未知型失败:请求发送后连接就断了,客户端无法确定服务端是否已经执行。
这四类失败的工程含义完全不同。真正需要重试的主要是前两类和最后一类中的一部分;参数类错误重试没有意义,反而放大负载。关键在于“状态未知”场景——客户端不知道服务端是否已经执行了工具调用。如果被调用的工具是非幂等的(比如下单、发送通知),盲目重试可能导致重复执行。这是重试设计的第一原则:在重试之前,先判断这次调用是否安全重试。
一个安全的重试流程应该先分类,再行动:
- 对错误进行分类,标记可重试与不可重试。
- 对可重试的调用,采用指数退避 + 最大次数限制。
- 对连续失败的调用,进入熔断状态,快速失败。
- 对仍然失败的调用,走降级路径。
这里给出一个通用的重试框架(示意代码,不绑定具体语言):
MAX_RETRIES = 3
BASE_DELAY = 0.5 # 秒
MAX_DELAY = 8.0 # 秒
def call_with_retry(client, request):
attempt = 0
while True:
try:
return client.invoke(request)
except Exception as e:
if not is_retryable(e) or attempt >= MAX_RETRIES:
raise
delay = min(BASE_DELAY * (2 ** attempt), MAX_DELAY)
delay += random.uniform(0, delay * 0.2) # 加抖动
time.sleep(delay)
attempt += 1
代码里几个要点:
- 指数退避(0.5s、1s、2s、4s)给服务端恢复留出时间窗口。
- 加入随机抖动(jitter),避免多个客户端同时重试形成“重试风暴”。
- 最大重试次数必须存在且不宜过大。常见实践是 2-3 次,这里的 3 只是示例值,实际需要根据服务端表现和用户可容忍等待时间决定。
- 超时本身要分层:连接超时和读超时分开设置。连接超时连不上就快速失败;读超时要给足服务端处理时间。如果只有一个总超时,慢响应很容易被误判为不可用。
比重试更重要的是熔断机制。重试只解决偶发问题,不解决“服务端已过载”的问题。如果服务端已经扛不住,持续重试只会让情况更糟。熔断器维护一个连续失败计数,超过阈值后直接短路一段时间,期间所有请求立即失败并走降级,不再进入重试循环。熔断进入半开状态后放少量探活请求,成功则关闭熔断,失败则继续打开。
熔断阈值怎么设?没有通用答案,但有一个原则:阈值应基于服务端恢复所需时间来设计,而不是拍脑袋定一个固定数字。实际落地可以先用保守值(比如连续失败 5 次触发熔断,熔断 30 秒),再根据线上监控调整。
降级是重试仍失败后的最后一道防线。MCP 客户端场景下,降级策略可以分几个层次:
- 缓存结果降级:如果调用的是相对稳定的查询类工具,可以使用最近一次成功结果。注意缓存只适用于无副作用或副作用可接受的调用。
- 默认响应降级:对已不可用的下游服务,返回固定的“服务暂不可用”提示或默认值,保证上层流程不中断。
- 显式降级与用户沟通:对需要用户决策的调用,降级方案应是“告知用户当前工具不可用、AI 正在使用兜底策略”,而不是让 AI 把兜底结果伪装成真实结果。
一个示意性的降级实现:
def invoke_with_fallback(client, request, cache_key=None):
try:
result = client.invoke(request)
if cache_key and is_cacheable(request):
cache.set(cache_key, result)
return result, "live"
except UnavailableError:
if cache_key and cache.get(cache_key):
return cache.get(cache_key), "cache"
return default_response(request), "degraded"
这里有一个容易被忽略的问题:降级响应的可观测性。缓存的旧值、默认值、真实值混在一起,如果不在响应中标记来源,排障时根本无法定位。建议在响应中加入来源标记,例如 source: "live" | "cache" | "degraded",并记录到日志。这样才能区分“结果本来就是空值”和“结果来自降级路径”。
长任务场景值得单独讨论。MCP 服务端可能存在执行时间较长的工具调用。这时候,“客户端超时”不代表“工具执行失败”。处理思路有两种:一种是在协议支持范围内调大读超时并配合流式响应;另一种是把长任务建模为异步操作——先返回任务标识,再轮询或订阅结果。具体采用哪种,取决于服务端实现是否支持异步模式,集成方不能凭空假设。还有一个通用建议:不要对长任务使用与普通请求相同的短超时和激进重试策略,否则会产生大量重复任务。
最后,给出一组可直接对照的工程检查清单:
- 请求超时是否分层配置?连接超时与读超时是否分离?
- 错误分类中,是否明确哪些错误可重试、哪些不可重试?
- 被调用的工具是否幂等?非幂等调用的重试是否被禁止?
- 重试次数是否设置了最大上限?退避是否是指数退避且带抖动?
- 是否实现了熔断?熔断后的降级路径是否完整?
- 降级响应是否带有来源标记?日志能否区分真实结果与兜底结果?
- 长任务调用是否与普通请求使用不同的超时和重试策略?
- 重试、熔断、降级三者是否形成完整链路,而不是互相独立?
这八项不是标准,而是一套评估基线。实际配置多少秒、重试几次,必须结合你自己的服务端表现来调整。协议不规定这些值,工程上也不能指望规定。本文没有依赖特定 MCP 实现细节,因为不同客户端与服务器版本的差异客观存在。落地时的正确做法是:查阅你所用 MCP 客户端与服务器版本的具体文档,确认超时相关参数是否可配置、错误码如何分类、是否支持流式或异步任务,然后在此基础上应用上述重试与降级框架。