接入 MCP(Model Context Protocol)服务器的 AI 应用,一个绕不开的工程问题是:当模型发出工具调用请求,而下游 MCP 服务端超时或连接中断时,客户端如何表现才算健壮。

先看失败本身。MCP 调用本质上是一次客户端-服务端请求。从客户端视角看,失败可以分成几类:

  • 连接建立失败:服务不可达、DNS 解析失败、TLS 握手超时。
  • 读写超时:请求已发出但响应迟迟未到,或响应读取到一半中断。
  • 业务错误返回:语法错误、参数错误、方法不存在、内部错误。
  • 状态未知型失败:请求发送后连接就断了,客户端无法确定服务端是否已经执行。

这四类失败的工程含义完全不同。真正需要重试的主要是前两类和最后一类中的一部分;参数类错误重试没有意义,反而放大负载。关键在于“状态未知”场景——客户端不知道服务端是否已经执行了工具调用。如果被调用的工具是非幂等的(比如下单、发送通知),盲目重试可能导致重复执行。这是重试设计的第一原则:在重试之前,先判断这次调用是否安全重试。

一个安全的重试流程应该先分类,再行动:

  1. 对错误进行分类,标记可重试与不可重试。
  2. 对可重试的调用,采用指数退避 + 最大次数限制。
  3. 对连续失败的调用,进入熔断状态,快速失败。
  4. 对仍然失败的调用,走降级路径。

这里给出一个通用的重试框架(示意代码,不绑定具体语言):

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 客户端场景下,降级策略可以分几个层次:

  1. 缓存结果降级:如果调用的是相对稳定的查询类工具,可以使用最近一次成功结果。注意缓存只适用于无副作用或副作用可接受的调用。
  2. 默认响应降级:对已不可用的下游服务,返回固定的“服务暂不可用”提示或默认值,保证上层流程不中断。
  3. 显式降级与用户沟通:对需要用户决策的调用,降级方案应是“告知用户当前工具不可用、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 客户端与服务器版本的具体文档,确认超时相关参数是否可配置、错误码如何分类、是否支持流式或异步任务,然后在此基础上应用上述重试与降级框架。