云计算百科
云计算领域专业知识百科平台

深入理解 AI Agent · MCP 子系列 #03:多 Server 编排、调用链可观测与连接生命周期管理

协议解决的是"怎么对话",工程化解决的是"怎么在生产环境稳定地对话"。


前一篇我们用 Spring AI 搭建了一个功能完整的 MCP Server,/mcp/sse 端点能正常响应,工具能被 Client 发现和调用。但当你试图把这个系统部署到生产环境时,问题会一个接一个冒出来:连接哪些 Server?配置散落在哪里?一次请求经过了几次 tool call?耗时多少?SSE 长连接断了怎么办?远端超时了怎么降级?

在 Dream-SaaS 平台中,MCP Client 运行在 8090 端口,需要同时连接本地 8095 的 code-review-agent Server、智谱远程 web_search MCP、高德远程 maps MCP——三个传输协议不同的远端服务,两套鉴权机制,三种连接生命周期。本文以 Dream-SaaS 的真实代码为主线,拆解多 Server 编排、调用链路可观测、连接生命周期管理这三个工程化核心问题。

一、从开发到生产:MCP 工程化的三道坎

1.1 部署拓扑:三条链路、三种连接方式

Dream-SaaS 的实际部署拓扑如下:

  • 8090 端口:dream-ai-app(MCP Client),面向用户的问答入口
  • 8095 端口:code-review-agent(MCP Server),提供 review_code 等代码审查工具
  • 远程:智谱 web_search(SSE 协议)、高德 maps(streamable-http 协议)

三条链路,三种连接方式,两套 API Key。这不是假设场景,而是日常开发中真实面对的工程挑战。

1.2 三道坎:编排、观测、生命周期

这引出了工程化落地时必须直面的三道坎:

第一道坎:多 Server 的连接管理与编排。 连接哪些 Server、谁连了谁、配置散落在哪里?当 Client 只连一个 Server 时,一行 URL 就完事。但当 Client 需要同时管理三个异构远端时,配置就会散落在 application.yml 的各个角落,缺乏统一的"谁连接谁"的视图。

第二道坎:工具调用的可观测性。 一次 /mcp/ask 请求可能触发多次 tool call——LLM 可能先调 webSearch 搜索文档,再调 review_code 审查代码。如果每次调用都是"黑盒",出了问题根本无从排查。

第三道坎:连接的生命周期管理。 启动时远端 Server 没活怎么办?SSE 长连接断了怎么办?远端超时了怎么降级?开发人员忘记设置 ZHIPU_API_KEY 环境变量,启动后大量 401 错误刷屏——这些情况如果不在代码层面处理,最终体现为用户侧的"AI 回答超时"或"工具调用异常"。

1.3 方案选型:Client 侧内建 vs 独立网关

在行业里,有些团队选择引入独立的 MCP 网关层来做统一代理;有些团队选择在 Client 侧内建 Registry 和观测能力。Dream-SaaS 选择了后者——不引入额外基础设施,在 Java 应用内部用模块化的方式解决。这种方案更轻量,也更适合 Spring Boot 技术栈的团队。

二、多 Server 编排:Dream-SaaS 的 Registry 名册方案

2.1 五模块拆分:各司其职

当 Client 需要同时连接 code-review(本地 SSE)、智谱(远程 SSE + Authorization)、高德(远程 streamable-http + API Key)时,配置散落在 application.yml 各处是必然的。Dream-SaaS 的解法是拆分为 5 个 Maven 子模块,用一份 Registry YAML 作为连接关系的"单一来源":

子模块职责
dream-saas-mcp-common 模块 ID、连接 ID 等公共常量(McpModuleIds)
dream-saas-mcp-spi McpToolContributor SPI 接口(详见 MCP-02)
dream-saas-mcp-registry 名册 YAML + McpRegistryProperties 配置绑定
dream-saas-mcp-server 聚合 Contributor → SyncToolSpecification,暴露给 Spring AI MCP Server
dream-saas-mcp-client ChatClient 装配、REST 诊断端点、调用观测、连接诊断

Server 侧的工具注册机制(McpToolContributor SPI、McpToolContributorAggregator 聚合器)已在 MCP-02 中详细拆解,这里聚焦 Client 侧的编排。

模块常量的定义值得单独看一下。McpModuleIds 用编译期常量确保 moduleId 全局唯一,避免"字符串散落"带来的拼写错误:

public final class McpModuleIds {
public static final String CODE_REVIEW = "code-review";
public static final String GRAPH = "graph";
public static final String REPO_ANALYSIS = "repo-analysis";
private McpModuleIds() {}
}

2.2 McpRegistryProperties:配置绑定的完整实现

McpRegistryProperties 是 Registry YAML 到 Java 对象的桥梁,使用 Spring Boot 的 @ConfigurationProperties 自动绑定。以下是完整源码:

@ConfigurationProperties(prefix = "dream-saas.mcp.registry")
public class McpRegistryProperties {

/** 逻辑 Server 登记(module-id 对应哪个业务 Contributor) */
private Map<String, ServerEntry> servers = new LinkedHashMap<>();

/** Client 实际连接参数(与 spring.ai.mcp.client.sse.connections 键一致) */
private Map<String, ConnectionEntry> connections = new LinkedHashMap<>();

/** 某应用应启用哪些 connection-id(如 dream-ai-app → 8095 + 智谱) */
private Map<String, ClientProfileEntry> clientProfiles = new LinkedHashMap<>();

public List<String> connectionIdsForProfile(String profileId) {
ClientProfileEntry profile = clientProfiles.get(profileId);
if (profile == null || profile.getConnectionIds() == null) {
return List.of();
}
return new ArrayList<>(profile.getConnectionIds());
}

public static class ServerEntry {
private String moduleId;
private String url;
private String sseEndpoint = "/mcp/sse";
private boolean enabled = true;
// getters/setters 省略
}

public static class ConnectionEntry {
private String transport = "sse";
private String url;
private String sseEndpoint;
/** streamable-http 路径(与 spring.ai.mcp.client.streamable-http.connections.*.endpoint 一致) */
private String endpoint;
// getters/setters 省略
}

public static class ClientProfileEntry {
private List<String> connectionIds = new ArrayList<>();
// getters/setters 省略
}
}

三个核心字段对应 YAML 的三个段落:

  • servers:逻辑 Server 登记,记录 module-id、URL、SSE 端点路径
  • connections:连接元数据,记录传输协议(sse/streamable-http)和目标 URL
  • clientProfiles:Client Profile,声明某个应用应该连接哪些 connection-id

connectionIdsForProfile() 方法是 Client Profile 机制的核心入口——不同应用通过传入不同的 profileId,获取自己应该建立的连接列表,实现按需裁剪连接范围。

2.3 Registry YAML:连接关系的单一来源

Client 侧采用**"双轨配置"**策略:一份 Registry YAML 记录"逻辑上有哪些 Server、哪些 Connection",另一份 Spring AI 配置控制"实际发起哪些连接"。

dream-saas:
mcp:
registry:
# 逻辑 Server 定义
servers:
code-review-local:
module-id: code-review
url: ${MCP_CODE_REVIEW_SERVER_URL:http://127.0.0.1:8095}
sse-endpoint: /mcp/sse
enabled: true

# Client Profile:哪个应用连哪些 Connection
client-profiles:
dream-ai-gateway:
connection-ids:
– code-review-local
– zhipu-web-search
– amap-maps

# Connection 元数据
connections:
code-review-local:
transport: sse
url: http://127.0.0.1:8095
zhipu-web-search:
transport: sse
url: https://open.bigmodel.cn
amap-maps:
transport: streamable-http
url: https://mcp.amap.com

# Spring AI 实际建连(键名必须与 registry.connections 一致)
spring:
ai:
mcp:
client:
sse:
connections:
code-review-local:
url: ${MCP_CODE_REVIEW_SERVER_URL:http://127.0.0.1:8095}
sse-endpoint: /mcp/sse
zhipu-web-search:
url: https://open.bigmodel.cn
sse-endpoint: /api/mcp/web_search/sse?Authorization=${ZHIPU_API_KEY:}
streamable-http:
connections:
amap-maps:
url: https://mcp.amap.com
endpoint: /mcp?key=${AMAP_MAPS_API_KEY:}

这套设计的核心价值在于 Client Profile:dream-ai-gateway 的 profile 声明了它需要连接三个 Server,而另一个应用可以声明只连接 code-review-local。不同应用按需裁剪连接范围,避免启动时建一堆用不到的远端连接。

2.4 双轨配置的一致性保障

两套配置的键名必须一致——code-review-local、zhipu-web-search、amap-maps 在 dream-saas.mcp.registry.connections 和 spring.ai.mcp.client.sse.connections 中同时出现。Registry 负责"文档化 + 诊断",Spring AI 配置负责"实际建连"。

这种"双轨"设计看似冗余,实则有明确的工程意义:Registry YAML 是给人和诊断工具看的"声明式元数据",Spring AI 配置是给框架消费的执行配置。两者键名对齐后,McpClientManager 可以在启动时自动比对一致性。

2.5 McpClientManager.logRegistryAlignment():启动时的配置对齐校验

McpClientManager 在启动阶段执行名册对齐检查,核心代码如下:

public class McpClientManager {

private final McpRegistryProperties registryProperties;
private final Environment environment;
private final String clientProfileId;

public void logRegistryAlignment() {
List<String> expected = registryProperties.connectionIdsForProfile(clientProfileId);
log.warn(
"[MCP-CLIENT] registry profile={} expectedConnections={} registryConnections={}",
clientProfileId,
expected,
registryProperties.getConnections().keySet());
McpConnectionDiagnostics.logProbeResultsAsync(log, environment);
}

public Map<String, Object> snapshot() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("clientProfileId", clientProfileId);
body.put("expectedConnectionIds",
registryProperties.connectionIdsForProfile(clientProfileId));
body.put("connections", registryProperties.getConnections());
body.put("servers", registryProperties.getServers());
return body;
}
}

启动时会输出类似 [MCP-CLIENT] registry profile=dream-ai-gateway expectedConnections=[code-review-local, zhipu-web-search, amap-maps] 的对齐日志。运维人员一眼就能确认"谁该连、谁实际连了"。如果 expectedConnections 和 registryConnections 不一致,说明双轨配置出现了偏差,需要立即排查。

注意 logRegistryAlignment() 的最后一步调用了 McpConnectionDiagnostics.logProbeResultsAsync()——配置对齐检查完成后,紧接着就是连接可达性探测,两者构成了启动阶段的完整诊断链。

2.6 default vs prod:生产配置瘦身策略

开发环境和生产环境的配置差异是工程化落地的关键环节。以下是 dream-saas-mcp-registry-default.yml(开发环境)的关键片段:

# 开发环境:连接所有 Server
dream-saas:
mcp:
registry:
client-profiles:
dream-ai-gateway:
connection-ids:
– code-review-local
– zhipu-web-search
– amap-maps
connections:
code-review-local:
transport: sse
url: ${MCP_CODE_REVIEW_SERVER_URL:http://127.0.0.1:8095}
zhipu-web-search:
transport: sse
url: https://open.bigmodel.cn
sse-endpoint: /api/mcp/web_search/sse?Authorization=${ZHIPU_API_KEY:}
amap-maps:
transport: streamable-http
url: https://mcp.amap.com
endpoint: /mcp?key=${AMAP_MAPS_API_KEY:}

spring:
ai:
mcp:
client:
sse:
connections:
code-review-local:
url: ${MCP_CODE_REVIEW_SERVER_URL:http://127.0.0.1:8095}
zhipu-web-search:
url: https://open.bigmodel.cn
sse-endpoint: /api/mcp/web_search/sse?Authorization=${ZHIPU_API_KEY:}
streamable-http:
connections:
amap-maps:
url: https://mcp.amap.com
endpoint: /mcp?key=${AMAP_MAPS_API_KEY:}

生产环境 dream-saas-mcp-registry-prod.yml 做了大幅瘦身:

# 现网 MCP 名册:仅本机 code-review,避免无 Key 的外部连接拖垮启动
dream-saas:
mcp:
registry:
servers:
code-review-local:
module-id: code-review
url: ${MCP_CODE_REVIEW_SERVER_URL:http://127.0.0.1:8095}
sse-endpoint: ${MCP_CODE_REVIEW_SSE_PATH:/mcp/sse}
enabled: true
client-profiles:
dream-ai-gateway:
connection-ids:
– code-review-local # 只连本地 Server
connections:
code-review-local:
transport: sse
url: ${MCP_CODE_REVIEW_SERVER_URL:http://127.0.0.1:8095}

spring:
ai:
mcp:
client:
sse:
connections:
code-review-local:
url: ${MCP_CODE_REVIEW_SERVER_URL:http://127.0.0.1:8095}
streamable-http:
connections: {} # 显式清空外部连接

对比两个配置,差异一目了然:

配置项default(开发)prod(生产)
client-profiles connection-ids 3 个(code-review + 智谱 + 高德) 1 个(code-review)
SSE 连接数 2 1
streamable-http 连接数 1 0(connections: {})
需要的环境变量 ZHIPU_API_KEY、AMAP_MAPS_API_KEY、MCP_CODE_REVIEW_SERVER_URL 仅 MCP_CODE_REVIEW_SERVER_URL

这不是"偷工减料",而是务实的工程决策。生产环境没有配置智谱和高德的 API Key,如果仍然尝试建连,SSE 长连接会反复失败,产生大量 SSE stream observed an error 警告,甚至拖垮应用启动。

值得注意的技术细节:streamable-http.connections: {} 不是"没写",而是显式清空。在 YAML 中如果只是不写内容,Spring Boot 的 relaxed binding 可能会从 default profile 继承默认值。用 connections: {} 显式设为空 Map,才能真正覆盖 default 配置。

三、可观测性:让每次 MCP 调用都有迹可循

当 Client 同时连接多个 MCP Server 时,一次 /mcp/ask 请求可能触发多次 tool call。如果每次调用都是"黑盒",出了问题根本无从排查。Dream-SaaS 的观测方案分三层:调用级日志、启动级诊断和全链路追踪。

3.1 LoggingToolCallback:装饰器模式的调用日志

LoggingToolCallback 用装饰器模式包装原始 ToolCallback,在每次工具调用前后插入日志。这是整个观测体系的基础:

public final class LoggingToolCallback implements ToolCallback {

private static final Logger log = LoggerFactory.getLogger(
McpClientObservability.LOGGER_NAME);

private final ToolCallback delegate;

private LoggingToolCallback(ToolCallback delegate) {
this.delegate = delegate;
}

public static ToolCallback wrap(ToolCallback delegate) {
if (delegate == null) return null;
return new LoggingToolCallback(delegate);
}

@Override
public String call(String toolInput) {
String toolName = toolName();
String connection = resolveConnection(toolName);
int inputLen = toolInput == null ? 0 : toolInput.length();
long startMs = System.currentTimeMillis();

log.info("[MCP-TOOL] invoke | connection={} | tool={} | inputChars={}",
connection, toolName, inputLen);
try {
String result = delegate.call(toolInput);
long endMs = System.currentTimeMillis();
int resultLen = result == null ? 0 : result.length();
log.info("[MCP-TOOL] success | connection={} | elapsedMs={} | resultChars={}",
connection, endMs – startMs, resultLen);
appendToolSpan(toolName, connection, true, startMs, endMs, inputLen, resultLen, null);
return result;
} catch (RuntimeException ex) {
long endMs = System.currentTimeMillis();
log.info("[MCP-TOOL] failed | connection={} | elapsedMs={} | error={}",
connection, endMs – startMs, ex.getMessage());
appendToolSpan(toolName, connection, false, startMs, endMs, inputLen, 0, ex.getMessage());
throw ex;
}
}
}

装饰器模式的设计考量:这里选择装饰器而非 AOP 或 Filter,原因有三——第一,ToolCallback 是 Spring AI 的接口,装饰器是最自然的扩展方式;第二,装饰器可以拿到 toolName() 和 resolveConnection() 的上下文信息,这些在 AOP 的 JoinPoint 中不容易获取;第三,装饰器的包装顺序可控,避免被其他 AOP 切面干扰。

resolveConnection() 通过工具名前缀推断连接归属:

private static String resolveConnection(String toolName) {
if (toolName == null || toolName.isBlank()) return "unknown";
if (toolName.startsWith("maps_")) return "amap-maps";
if (toolName.startsWith("webSearch")) return "zhipu-web-search";
if (toolName.startsWith("review_") ||
toolName.startsWith("list_") ||
toolName.startsWith("get_")) return "code-review-local";
if (toolName.startsWith("graph_")) return "dream-ai-graph";
return "unknown";
}

包装发生在 McpConfig 的 ChatClient 装配阶段:

@Bean("mcpChatClient")
public ChatClient mcpChatClient(ChatModel chatModel,
ObjectProvider<SyncMcpToolCallbackProvider> provider) {
ChatClient.Builder builder = ChatClient.builder(chatModel);
provider.ifAvailable(p -> {
ToolCallback[] wrapped = Arrays.stream(p.getToolCallbacks())
.map(LoggingToolCallback::wrap) // 逐个包装
.toArray(ToolCallback[]::new);
builder.defaultToolCallbacks(wrapped);
});
return builder.build();
}

这里使用 ObjectProvider<SyncMcpToolCallbackProvider> 而非直接注入,是关键的设计选择。ObjectProvider 是 Spring 的延迟查找机制,如果 SyncMcpToolCallbackProvider 不存在(比如 spring.ai.mcp.client.enabled=false),ifAvailable() 会静默跳过,不会导致 Bean 创建失败。

3.2 McpClientObservability:观测工具方法集

McpClientObservability 是观测体系的工具类,提供了工具清单提取、连接分组、响应摘要等辅助方法。以下是核心实现:

public final class McpClientObservability {

public static final String LOGGER_NAME = "com.zhu.dream.ai.mcp.client.observability";

/** 提取 provider 中所有工具名(排序后返回) */
public static List<String> toolNames(SyncMcpToolCallbackProvider provider) {
if (provider == null) return List.of();
return Arrays.stream(provider.getToolCallbacks())
.map(ToolCallback::getToolDefinition)
.map(def -> def != null ? def.name() : "?")
.sorted()
.toList();
}

/** 按 connection 分组工具名(基于命名约定) */
public static Map<String, List<String>> groupToolsByConnection(
List<String> toolNames, List<String> configuredConnections) {
Map<String, List<String>> groups = new LinkedHashMap<>();
for (String connection : configuredConnections) {
groups.put(connection, new ArrayList<>());
}
groups.put("unclassified", new ArrayList<>());

for (String name : toolNames) {
String group = classifyToolConnection(name, configuredConnections);
groups.computeIfAbsent(group, k -> new ArrayList<>()).add(name);
}
groups.entrySet().removeIf(e -> e.getValue().isEmpty());
return groups;
}

/** 文本截断(日志安全) */
public static String truncate(String text, int maxChars) {
if (text == null) return "";
if (text.length() <= maxChars) return text;
return text.substring(0, maxChars) + "…";
}
}

groupToolsByConnection() 的设计意图是:启动时让运维人员一眼看清"每个 connection 下面挂了哪些 tool"。分组的依据是工具名前缀与 resolveConnection() 相同的命名约定。unclassified 分组用来收集无法归类的工具,方便排查是否有工具名不符合约定。

truncate() 方法看似简单,但在日志场景下非常重要——用户问题可能很长,直接打到日志里会导致日志行膨胀,影响日志采集和检索。统一截断到 120 字符(/mcp/ask 中的用法),既保留了足够的上下文信息,又避免日志爆炸。

3.3 McpClientStartupReporter:启动级诊断

调用级日志解决的是"运行时可观测",但很多 MCP 连接问题发生在启动阶段。McpClientStartupReporter 在启动完成后打印一份"工具清单":

public void report() {
boolean mcpClientEnabled = environment.getProperty(
"spring.ai.mcp.client.enabled", Boolean.class, false);
List<String> configuredConnections =
McpClientObservability.configuredConnectionNames(environment);
SyncMcpToolCallbackProvider provider =
syncMcpToolCallbackProvider.getIfAvailable();
if (provider == null) {
log.info("[MCP-CLIENT] startup | enabled={} | "
+ "provider=missing | configuredConnections={}",
mcpClientEnabled, configuredConnections);
return;
}
List<String> toolNames = McpClientObservability.toolNames(provider);
Map<String, List<String>> groups =
McpClientObservability.groupToolsByConnection(
toolNames, configuredConnections);
log.info("[MCP-CLIENT] startup | enabled={} | "
+ "toolCount={} | toolGroups={}",
mcpClientEnabled, toolNames.size(), groups);
}

启动后你会看到这样的日志:

[MCP-CLIENT] startup | enabled=true | toolCount=5 | toolGroups={
code-review-local=[review_code, suggest_fix, analyze_complexity],
zhipu-web-search=[webSearch],
amap-maps=[maps_direction_driving, maps_geo]
}

每个 connection 下面挂了哪些 tool,一目了然。如果 provider=missing,说明 Spring AI 的 MCP Client 没有正确初始化。如果某个 connection 下 tool 数量为 0,说明对应 Server 的工具列表未成功拉取。

3.4 一次 /mcp/ask 的完整链路

McpAskController 是 Dream-SaaS 的 MCP 问答入口,它展示了一条完整的调用链路。以下是 executeAsk() 的关键片段:

private AskResult executeAsk(String question, String userId) {
String q = question == null ? "" : question;
String traceId = "mcp-" + UUID.randomUUID().toString()
.replace("-", "").substring(0, 16);
SyncMcpToolCallbackProvider provider = syncMcpToolCallbackProvider.getIfAvailable();
int toolCount = provider == null ? 0 : provider.getToolCallbacks().length;

log.info("[MCP-CLIENT] ask start | traceId={} | toolCount={} | question={}",
traceId, toolCount, McpClientObservability.truncate(q, 120));

long startMs = System.currentTimeMillis();
ObserveCallerContext.set(userId);
ObserveCallerContext.setRunId(traceId);
try {
ChatResponse chatResponse = chatClient.prompt().user(q).call().chatResponse();
AskSummary summary = McpClientObservability.summarizeAskResponse(chatResponse);
String content = McpClientObservability.extractAnswerText(chatResponse);
long durationMs = System.currentTimeMillis() – startMs;

// 补一条 chat span,工具由 LoggingToolCallback 写入
RunObserveBuffer.append(traceId,
RunObserveBuffer.chatSpan("mcp-chat-" + shortId(), null,
"mcp-ask", true, startMs, System.currentTimeMillis(),
Map.of("dreamsaas.source", "mcp",
"gen_ai.operation.name", "chat",
"mcp.registryToolCount", toolCount)));

log.info("[MCP-CLIENT] ask done | traceId={} | durationMs={} | "
+ "answerChars={} | modelToolCalls={}",
traceId, durationMs, summary.answerChars(), summary.toolCallsSummary());
return new AskResult(content, toolCalls, durationMs, traceId);
} finally {
List<AgentSpanSnapshot> live = RunObserveBuffer.drain(traceId);
AgentTraceCapture cap = traceCapture.getIfAvailable();
if (cap != null) {
cap.capture(toSnapshot(traceId, userId, q, content, ok,
startMs, endMs, live, toolCalls, toolCount));
}
ObserveCallerContext.clear();
}
}

一次完整的请求链路如下:

  • 请求到达:生成唯一 traceId,记录当前可用工具数
  • LLM 决策:ChatClient.prompt().user(q).call() 将问题发给大模型,模型决定调用哪些 MCP 工具
  • 工具执行:每个 MCP 工具调用经过 LoggingToolCallback,产生 [MCP-TOOL] invoke / success / failed 日志
  • Trace 采集:通过 RunObserveBuffer 收集所有 span(chat span + tool spans),finally 块中通过 AgentTraceCapture 持久化
  • 结果汇总:响应体包含 answer、toolCalls、durationMs、traceId 四个关键字段

对应日志链路:

[MCP-CLIENT] ask start | traceId=mcp-a1b2c3d4e5f67890 | toolCount=5 | question=帮我审查这段代码…
[MCP-TOOL] invoke | connection=code-review-local | tool=review_code | inputChars=256
[MCP-TOOL] success | connection=code-review-local | elapsedMs=1823 | resultChars=1024
[MCP-TOOL] invoke | connection=code-review-local | tool=suggest_fix | inputChars=1024
[MCP-TOOL] success | connection=code-review-local | elapsedMs=982 | resultChars=512
[MCP-CLIENT] ask done | traceId=mcp-a1b2c3d4e5f67890 | durationMs=3842 | answerChars=386

从 ask start 到 ask done,中间夹着每次工具调用的 invoke / success——这就是 MCP 工程化中最朴素的"全链路追踪"。不需要引入 OpenTelemetry 之类的重型框架,用装饰器模式 + 结构化日志就能覆盖核心场景。

四、连接生命周期管理:探测、重试与优雅降级

前三节解决了"连哪些 Server"、"调用过程怎么看"的问题。但还有一个关键场景没有覆盖:连接的生命周期。一个 MCP Server 不是永远在线的,SSE 长连接可能因为网络抖动断开,远端服务可能超时。Dream-SaaS 的 McpConnectionDiagnostics 提供了一套完整的连接生命周期管理方案。

4.1 启动时的异步探测

应用启动完成后,McpClientManager.logRegistryAlignment() 在完成名册对齐校验之后,调用 McpConnectionDiagnostics.logProbeResultsAsync() 对所有已配置的连接执行异步探测。核心探测入口:

public static void logProbeResultsAsync(Logger log, Environment env) {
List<ConnectionTarget> targets = resolveConnectionTargets(env);
if (targets.isEmpty()) {
log.warn("[MCP-CLIENT] connection probe | no connections found");
return;
}
// 先同步打印配置信息
for (ConnectionTarget target : targets) {
String cred = credentialHint(target);
log.warn("[MCP-CLIENT] connection config | id={} | transport={} | "
+ "target={} | credentialHint={}",
target.id(), target.transport(),
maskSecrets(target.fullUrl()),
cred != null ? cred : "none");
}
// 再异步执行探测
CompletableFuture.runAsync(() -> logProbeResults(log, env))
.exceptionally(ex -> {
log.warn("[MCP-CLIENT] connection probe | async error={}", ex.toString());
return null;
});
}

异步探测的 CompletableFuture 用法值得展开讨论。这里的设计意图是:启动阶段的主线程不应该被网络探测阻塞。如果 3 个连接中有 1 个远端无响应,同步探测会导致应用启动多等 2 秒(TCP 超时),这在 CI/CD 环境中累积起来很可观。CompletableFuture.runAsync() 将探测任务提交到 ForkJoinPool,主线程可以立即继续执行后续的 Bean 初始化。

探测策略按传输协议差异化:

  • SSE 连接:用 TCP probe 检测目标端口是否可达(不对 SSE 发 GET 请求,避免长连接阻塞启动)
  • streamable-http 连接:用 HTTP HEAD 请求检测端点是否存活
  • 超时控制:TCP 探测超时 2 秒,HTTP 探测超时 2 秒,所有连接的探测总超时 8 秒

TCP probe 的实现:

private static ProbeResult probeTcp(ConnectionTarget target) {
try {
URI uri = URI.create(target.fullUrl());
String host = uri.getHost();
int port = uri.getPort();
if (port <= 0) {
port = "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port),
(int) TCP_TIMEOUT.toMillis());
}
return new ProbeResult(target.id(), target.transport(),
maskSecrets(target.fullUrl()), true, null, null,
"TCP 可达(SSE 不对 GET 做 probe,避免长连接阻塞)");
} catch (Exception e) {
Throwable root = rootCause(e);
return failed(target, root.getClass().getSimpleName(), null, root);
}
}

这是一个容易踩坑的设计决策:SSE 端点响应 GET 请求后会保持长连接不关闭。如果对 SSE 端点发 HTTP GET 做健康检查,这个连接会一直挂着,阻塞探测线程。所以只做 TCP 层的端口可达性检测——知道端口通了就够了。

4.2 错误分类与智能提示

McpConnectionDiagnostics 对探测失败的情况进行分类,给出可直接执行的排查建议:

错误类型触发条件诊断提示
CREDENTIAL URL 中的 ${ZHIPU_API_KEY:} 解析为空 环境变量 ZHIPU_API_KEY 未设置
ConnectException TCP probe 抛出 Connection refused 目标 Server 未启动或端口不可达
HTTP 401/403 HTTP HEAD 返回 401 或 403 API Key 无效或已过期
ProbeTimeout 探测超过总超时(8s) 探测超时:不对 SSE 发 GET,避免长连接阻塞启动

credentialHint() 方法专门处理凭据缺失的情况:

public static String credentialHint(ConnectionTarget target) {
String fullUrl = target.fullUrl();
if (containsEmptyQueryParam(fullUrl, "Authorization")) {
return "环境变量 ZHIPU_API_KEY 未设置(sse-endpoint 中 Authorization= 为空)";
}
if (containsEmptyQueryParam(fullUrl, "key")) {
return "环境变量 AMAP_MAPS_API_KEY 未设置(endpoint 中 key= 为空)";
}
if ("code-review-local".equals(target.id())
&& (fullUrl == null || fullUrl.isBlank())) {
return "code-review-local 的 url 未配置,检查 MCP_CODE_REVIEW_SERVER_URL";
}
return null;
}

probeFailureHint() 则根据异常类型和 HTTP 状态码匹配排查建议:

public static String probeFailureHint(ConnectionTarget target,
Throwable error, Integer httpStatus) {
String credentialHint = credentialHint(target);
if (credentialHint != null) return credentialHint;
if (error instanceof ConnectException) {
if ("code-review-local".equals(target.id())) {
return "TCP 连接被拒绝:8095 未监听。先启动 code-review-agent";
}
return "TCP/网络不可达:检查 url 是否正确、远端服务是否启动";
}
if (httpStatus != null) {
if (httpStatus == 401 || httpStatus == 403) {
return "认证失败:检查 API Key 是否有效、是否过期";
}
}
if (error instanceof HttpTimeoutException || error instanceof TimeoutException) {
return "探测超时:远端无响应或网络慢";
}
return "SDK 层 WARN「SSE stream observed an error」请对照 connectionId 与 target";
}

这种"不只告诉你错了,还告诉你怎么改"的设计,大幅降低了新人的排查成本。

maskSecrets() 方法确保 URL 在日志中脱敏:

public static String maskSecrets(String url) {
if (url == null || url.isBlank()) return "";
return url.replaceAll("(?i)(Authorization=)[^&]*", "$1***")
.replaceAll("(?i)([?&]key=)[^&]*", "$1***")
.replaceAll("(?i)([?&]token=)[^&]*", "$1***");
}

正则匹配覆盖了三种常见的凭据参数名:Authorization、key、token。无论 URL 中携带哪种凭据,日志中都会显示为 ***。

4.3 SSE 长连接断开与自动重连

SSE 是一种长连接协议。网络抖动、Server 重启、负载均衡器超时等情况都可能导致 SSE 连接断开。Spring AI 的 MCP Client 内置了 SSE 重连机制——当 SSE 连接断开时,Client 会自动尝试重新建立连接,日志中会出现 SSE stream observed an error 后跟重新连接的尝试。这种重连是透明的,应用代码无需关心。

但要注意:重连期间发起的工具调用会失败。这就是为什么 LoggingToolCallback 的 failed 日志非常关键——通过 error= 字段可以快速判断是"连接断开导致调用失败"还是"工具执行本身出错"。

streamable-http 的处理方式不同。streamable-http 本质上是一次性的 HTTP 请求,每次工具调用发起独立的请求-响应,不存在"长连接断开"的问题。这也是 MCP 2026-07-28 新规范将 streamable-http 提升为推荐传输的原因之一——它天然更适合生产环境中的负载均衡和无状态部署。

4.4 远程 MCP 超时的降级策略

远程 MCP Server(智谱、高德)的响应时间不可控。Dream-SaaS 的降级策略分两层:

  • 连接级超时:Spring AI 的 SSE/streamable-http 传输层配置了连接超时和读取超时。超时后底层抛出异常,LoggingToolCallback 捕获并记录 [MCP-TOOL] failed 日志
  • 应用级降级:/mcp/ask 端点在工具调用失败时,返回结构化的错误信息——包含失败的 connection、tool 名称和错误类型,而不是直接抛 500。LLM 可以据此决定"不依赖该工具,用已有信息继续回答"

从日志关键字可以追踪降级的完整链路:[MCP-TOOL] invoke → [MCP-TOOL] failed | elapsedMs=2003(超时)→ 应用层返回结构化错误给 LLM。elapsedMs 字段是判断"是超时还是执行慢"的关键依据。

4.5 /mcp/status:综合诊断端点

诊断和探测的最终产出,通过 /mcp/status 端点暴露给运维和开发人员。McpClientStatusController 整合了所有观测信息:

@RestController
@RequestMapping("/mcp")
public class McpClientStatusController {

@GetMapping("/status")
public Map<String, Object> status() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("mcpClientEnabled", mcpClientEnabled);
body.put("askEnabled", askEnabled);
body.put("observabilityLogger", McpClientObservability.LOGGER_NAME);
body.put("platform", "dream-ai-mcp");

// Registry 名册快照
McpClientManager manager = mcpClientManager.getIfAvailable();
if (manager != null) {
body.put("registry", manager.snapshot());
}

// 连接探测结果
body.put("configuredConnections",
McpClientObservability.configuredConnectionNames(environment));
body.put("connectionProbes",
McpConnectionDiagnostics.probeAll(environment).stream()
.map(McpConnectionDiagnostics::toStatusMap).toList());

// Provider 状态和工具清单
SyncMcpToolCallbackProvider provider =
syncMcpToolCallbackProvider.getIfAvailable();
if (provider == null) {
body.put("providerReady", false);
body.put("toolCount", 0);
body.put("hint", "spring.ai.mcp.client.enabled=true 且 MCP Server 就绪…");
return body;
}

List<String> toolNames = McpClientObservability.toolNames(provider);
body.put("providerReady", true);
body.put("toolCount", toolNames.size());
body.put("tools", toolNames);
body.put("toolGroups",
McpClientObservability.groupToolsByConnection(
toolNames, configuredConnections));
return body;
}
}

这个端点返回的信息包括:

  • mcpClientEnabled / askEnabled:功能开关状态
  • registry:名册快照(profile、expectedConnections、connections、servers)
  • connectionProbes:每个连接的探测结果(reachable、errorType、hint)
  • toolGroups:工具按 connection 分组的清单

部署后的标准验收流程是:启动应用 → 查看启动日志中的 [MCP-CLIENT] startup 和 [MCP-CLIENT] connection probe → 访问 /mcp/status 确认所有 connection 状态 → 通过 /mcp/ask 发送测试请求验证端到端链路。这套流程让 MCP 的部署验收从"凭感觉"变成了"看数据"。

总结

MCP 工程化的三道坎——多 Server 编排、调用可观测、连接生命周期管理——不是理论问题,而是每个落地 MCP 的团队都会遇到的实际挑战。Dream-SaaS 的选择是:不引入额外基础设施,用模块化的 Registry 名册、装饰器模式的观测日志、异步探测与错误分类机制来解决。这套方案轻量、可控、可演进。

回顾整个 MCP 子系列的三篇文章:

  • MCP-01 协议全解:拆解了 MCP 的协议设计——JSON-RPC 消息格式、Host/Client/Server 三角架构、传输层解耦
  • MCP-02 Server 开发实战:从代码层面构建了 MCP Server——McpToolContributor SPI、@Tool 注解、SSE 传输配置、2026-07-28 无状态新规范
  • MCP-03 工程化实战:以 Dream-SaaS 真实代码为主线,解决了多 Server 编排、可观测性、连接生命周期管理三大生产级问题

下一篇我们将跳出 MCP 的工具调用范畴,进入 Agent 记忆系统(#05)——Agent 怎么记住上下文、怎么积累长期知识、怎么在对话中实现"真正的理解"而非每次都从零开始。

— 深入理解 AI Agent · MCP 子系列 · 第 3 篇 —

有问题评论区见,欢迎交流~

赞(0)
未经允许不得转载:网硕互联帮助中心 » 深入理解 AI Agent · MCP 子系列 #03:多 Server 编排、调用链可观测与连接生命周期管理
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!