【第56篇】Graph Example —— MCP-Node 模块

  • 想理解 Spring AI Alibaba 的图计算框架是怎么运转的 ------ 不是看 API 文档那种"怎么调",而是看"为什么这样设计";
  • 要在项目里接入 MCP(Model Context Protocol)工具调用 ------ 想知道节点是怎么找到工具、怎么把工具"喂"给大模型的;
  • 准备把这个模块搬到生产环境 ------ 想知道现在的代码缺什么、哪里埋了坑、怎么补。

一、概述

1.1 mcp-node

mcp-node 是一个 "会调用外部工具的 AI 计算节点"。它接收用户的提问,让大模型(通义千问/DashScope)决定要不要调用外部工具(比如查天气、查时间),然后把结果整理好返回。

1.2 位置

复制代码
用户提问 → [图计算引擎] → [MCP 节点] → [大模型 + 工具] → 返回结果

mcp-node 就是这个流水线里的 "大模型+工具"这一环 。它自己不直接对外暴露大模型能力,而是通过 Spring AI Alibaba 的 Graph Core 框架,把自己注册成一个"节点",让上游的图引擎来调度。

1.3 核心能力清单

能力 说明
RESTful 接口 通过 HTTP 接收查询,返回 JSON
MCP 工具集成 按节点名动态加载指定的 MCP 工具
图状态管理 用 StateGraph 管理数据在节点间的流转
流式响应 支持大模型的流式输出,再拼接成完整结果
异步执行 节点内部用异步包装,避免阻塞图引擎

1.4 依赖关系全景

基础设施
Spring AI Alibaba 生态
mcp-node 模块
Controller

McpController
节点逻辑

McpNode
图配置

McpGraphConfiguration
工具筛选器

McpClientToolCallbackProvider
Graph Core

状态图引擎
DashScope Starter

通义千问客户端
MCP Client WebFlux

MCP协议客户端
Spring Boot Web

HTTP容器


二、架构设计

2.1 分层架构:四层

很多 Spring Boot 项目都是"Controller → Service → Mapper"三层。这个模块没有数据库,所以它的分层逻辑是 "按职责边界" 而非"按技术惯性":

复制代码
┌─────────────────────────────────────────────┐
│  📡 HTTP 层 (Controller)                     │
│  职责:验参、转协议、统一响应格式              │
│  对应类:McpController                        │
├─────────────────────────────────────────────┤
│  🧠 业务层 (Node)                            │
│  职责:理解查询、调用 AI、收集结果              │
│  对应类:McpNode                              │
├─────────────────────────────────────────────┤
│  ⚙️ 编排层 (Configuration)                   │
│  职责:把节点组装成图、定义数据怎么流           │
│  对应类:McpGraphConfiguration                │
├─────────────────────────────────────────────┤
│  🔧 工具层 (Tool)                            │
│  职责:从一堆工具里,挑出当前节点需要的          │
│  对应类:McpClientToolCallbackProvider        │
└─────────────────────────────────────────────┘

注

因为 Graph Core 框架的编程模型是 "节点(Node)" 而非 "服务(Service)"。每个节点是一个自闭包的计算单元,有输入(State)、有输出(Map)。这种设计让节点可以被任意编排、复用、替换,而 Service 往往隐含了"业务上下文"的假设。

2.2 图结构:

这是整个模块最核心的设计。图计算框架用 有向图 描述计算流程:
把 query 塞进去
产出 mcp_content
START
mcp 节点

(异步执行)
END

  • START:图的入口,不是真实节点,只是一个标记;
  • mcp :真实工作节点,内部是 McpNode 的异步包装;
  • END:图的出口,标记计算完成。

这里有个容易混淆的点 :mcp-node 既是模块名,又是图里的节点名。模块名是项目标识,节点名是图里的逻辑标识,两者碰巧一样。

2.3 一次完整的请求生命周期

🔧 MCP工具回调 🤖 ChatClient (DashScope) ⚙️ McpNode (异步包装) 📊 StateGraph (图引擎) 🎛️ McpController 👤 用户/客户端 🔧 MCP工具回调 🤖 ChatClient (DashScope) ⚙️ McpNode (异步包装) 📊 StateGraph (图引擎) 🎛️ McpController 👤 用户/客户端 🔧 构造 RunnableConfig thread_id 用于状态隔离 📝 从 State 取出 query 🧠 大模型分析意图 "用户问时间,需调用时间工具" 🔗 reduce + block 拼接完整文本 GET /graph/mcp/call? query=现在几点了? 1 invoke(objectMap, config) 2 apply(OverAllState) 3 prompt(query).stream() 4 🕐 调用 get_current_time() 5 "2026-05-19 12:53:24" 6 🌊 Flux<String> 流式返回 7 HashMap {"mcp_content": "现在北京时间12:53"} 8 OverAllState (含历史数据) 9 📄 JSON 响应 10


三、技术栈拆解:

3.1 Spring AI Alibaba ------ 粘合剂

它不是一个大模型,而是一套 "让 Java 开发者用统一接口调用各种大模型" 的框架。

  • DashScope 集成:阿里云通义千问的官方 Java SDK 封装,你不需要自己拼 HTTP 请求、处理鉴权;
  • Graph Core:状态图引擎,借鉴了 LangGraph 的设计思想,让 AI 工作流可以可视化、可调试、可回滚;
  • 统一抽象 :ChatClient、ToolCallback 这些接口,不管你底层接的是通义千问、OpenAI 还是本地模型,上层代码不用改。

3.2 MCP(Model Context Protocol)------ 工具插座

MCP 是 Anthropic 提出的开放协议,你可以把它理解为 "AI 世界的 USB-C 接口"。

没有 MCP 之前:每个工具(查天气、查股票、发邮件)都要写一套适配代码,大模型厂商和工具厂商两两配对,成本极高。

有了 MCP 之后 :工具提供方实现一个标准协议,大模型消费方也按这个协议消费。mcp-node 就是这个消费方------它通过 spring-ai-starter-mcp-client-webflux 接入 MCP 生态,把外部工具变成大模型可用的"手臂"。

3.3 图计算框架 ------ 状态机的高级形态

传统状态机是"状态 A → 事件 → 状态 B",而图计算框架是"节点 A 产出数据 → 数据流入节点 B → 节点 B 加工"。

关键概念:

概念 类比 作用
OverAllState 共享黑板 所有节点都能读写的全局状态,节点间不直接传参,而是通过黑板交换
NodeAction 工序 一个节点的具体计算逻辑
KeyStrategy 黑板擦写规则 定义某个 key 是"覆盖"还是"追加",防止数据冲突

四、代码实现:逐层剥开洋葱

4.1 启动类:最薄的一层

java 复制代码
@SpringBootApplication
public class McpNodeApplication {
    public static void main(String[] args) {
        SpringApplication.run(McpNodeApplication.class, args);
    }
}

点评 :标准到不能再标准。但生产环境建议加上 spring-boot-starter-actuator,否则你部署后连健康检查接口都没有,K8s 探针都配不了。

4.2 控制器:接口的"门面"

当前实现用 GET 方法接收查询参数:

java 复制代码
@GetMapping("/call")
public Map<String, Object> call(
    @RequestParam(value = "query", defaultValue = "北京时间现在几点钟") String query,
    @RequestParam(value = "thread_id", defaultValue = "yingzi") String threadId
) { ... }

这里藏着三个设计问题:

  1. GET 方法语义不当:查询参数放在 URL 里,一旦 query 是长文本或含特殊字符,URL 编码会爆炸。而且 GET 在语义上是"幂等读取",但这里的调用会触发大模型计算,产生副作用和费用;
  2. 返回类型是裸 Map:调用方不知道会收到什么字段,契约模糊;
  3. 异常直接上抛 :GraphRunnerException 直接抛给 Servlet 容器,用户看到的是 500 错误页或者 Tomcat 默认报错,体验极差。

优化方向:

java 复制代码
// 1. 定义契约
public record McpRequest(
    @NotBlank @Size(max=5000) String query,
    @Pattern(regexp="^[a-zA-Z0-9_-]{1,64}$") String threadId
) {}

public record ApiResponse<T>(int code, String message, T data, long timestamp) {}

// 2. 改 POST + 全局异常处理
@RestController
@RequestMapping("/graph/mcp")
public class McpController {
    
    @PostMapping("/call")
    public ResponseEntity<<ApiResponse<Map<String, Object>>> call(
        @Valid @RequestBody McpRequest request
    ) {
        // 记录入参、耗时、出参
        // 任何异常交给 @ControllerAdvice 统一包装
    }
}

4.3 核心节点 McpNode:真正干活的地方

这是整个模块的灵魂。它的核心逻辑只有四步:

java 复制代码
public Map<String, Object> apply(OverAllState state) {
    // 1. 从黑板上读问题
    String query = state.value("query", "");
    
    // 2. 让 ChatClient 去流式对话(大模型自己决定要不要调工具)
    Flux<String> stream = chatClient.prompt(query).stream().content();
    
    // 3. 把流拼成一个字符串
    String result = stream.reduce("", (a, b) -> a + b).block();
    
    // 4. 写回黑板
    return Map.of("mcp_content", result);
}

但这段代码在生产环境有颗"定时炸弹" :.block()。

block() 会把响应式流阻塞在当前线程直到完成。如果并发请求上来,线程池会被迅速耗尽。Spring WebFlux 的线程模型是事件循环 + 少量工作线程,一个 block() 就可能让整个应用"假死"。

优化后的节点应该长这样:

java 复制代码
public class McpNode implements NodeAction {
    
    @Override
    public Map<String, Object> apply(OverAllState state) {
        String query = state.value("query", "");
        long start = System.currentTimeMillis();
        
        try {
            // 带超时、带重试、带错误恢复的流处理
            List<String> chunks = chatClient.prompt(query)
                .stream()
                .content()
                .timeout(Duration.ofSeconds(timeoutSeconds))      // 防 hanging
                .retryWhen(Retry.backoff(maxRetries, Duration.ofSeconds(1))
                    .filter(this::isRetryable))                    // 限流/网络抖动时自动重试
                .collectList()
                .block(Duration.ofSeconds(timeoutSeconds + 5));    // 最终阻塞但带死线
            
            String result = String.join("", chunks);
            
            return Map.of(
                "mcp_content", result,
                "query", query,
                "processing_time_ms", System.currentTimeMillis() - start,
                "timestamp", Instant.now().toString()
            );
        } catch (Exception e) {
            throw new NodeActionException("MCP 节点处理失败: " + e.getMessage(), e);
        }
    }
}

4.4 图配置:把零件组装成流水线

java 复制代码
@Bean
public StateGraph mcpGraph(ChatClient.Builder chatClientBuilder) throws GraphStateException {
    // 定义黑板擦写规则
    KeyStrategyFactory factory = new KeyStrategyFactoryBuilder()
        .addPatternStrategy("query", new ReplaceStrategy())
        .addPatternStrategy("mcp_content", new ReplaceStrategy())
        .build();
    
    return new StateGraph(factory)
        .addNode("mcp", node_async(new McpNode(chatClientBuilder, toolProvider)))
        .addEdge(START, "mcp")
        .addEdge("mcp", END);
}

这里有两个容易被忽略的设计细节:

  1. node_async() 包装器 :它把同步的 McpNode 包成异步节点。Graph Core 的图引擎可以并行调度异步节点,如果未来图里有多个分支(比如"查天气"和"查新闻"并行),这个包装器就是基础;
  2. KeyStrategyFactory :它决定了当两个节点都往黑板上写同一个 key 时怎么处理。ReplaceStrategy 是"后来者覆盖",适合单线程顺序执行;如果未来做并行节点,可能需要 AppendStrategy 或自定义合并策略。

当前图结构太简单了------只有一根直线。生产环境至少应该加上错误处理分支:
通过
失败
成功
失败
次数<<3
次数≥3
START
输入校验
MCP节点
错误处理器
END
重试决策

4.5 工具回调提供者:节点的"工具箱管理员"

这个类的职责是:根据节点名,从所有可用工具中,挑出分配给这个节点的工具。

java 复制代码
public Set<ToolCallback> findToolCallbacks(String nodeName) {
    // 1. 查配置:这个节点绑定了哪些 MCP 客户端?
    Set<String> mcpClients = mcpNodeProperties.getNode2servers().get(nodeName);
    
    // 2. 拼接前缀名(比如 "dashscope__time_tool")
    List<String> prefixedNames = mcpClients.stream()
        .map(c -> McpToolUtils.prefixedToolName(commonName, c))
        .toList();
    
    // 3. 遍历所有工具,按前缀匹配
    Set<ToolCallback> result = new HashSet<>();
    for (ToolCallback tool : allTools) {
        for (String prefix : prefixedNames) {
            if (tool.getToolDefinition().name().startsWith(prefix)) {
                result.add(tool);
            }
        }
    }
    return result;
}

问题诊断:

  • 每次调用都全量遍历 :findToolCallbacks 在每次请求时都会执行,工具多了性能堪忧;
  • startsWith 匹配过于宽松 :如果前缀是 dashscope__time,可能误匹配 dashscope__time_zone 和 dashscope__time_format;
  • 配置为空时静默返回空集合 :调用方(McpNode)拿到空工具集后,大模型就没有任何外部能力,变成"裸聊",但日志里可能没有任何警告。

优化方案:

java 复制代码
@Service
public class McpClientToolCallbackProvider {
    
    // 加缓存:节点名 → 工具集合
    private final Map<String, Set<ToolCallback>> cache = new ConcurrentHashMap<>();
    
    public Set<ToolCallback> findToolCallbacks(String nodeName) {
        return cache.computeIfAbsent(nodeName, this::loadToolCallbacks);
    }
    
    private Set<ToolCallback> loadToolCallbacks(String nodeName) {
        // 空配置时打 WARN,而不是静默
        // 用 equals 或 startsWith(prefix + "_") 精确匹配
        // 加载完成后打印 INFO 日志,方便排查
    }
    
    // 支持配置热更新时清缓存
    public void clearCache() { cache.clear(); }
}

4.6 配置属性:模块的"旋钮"

当前配置类:

java 复制代码
@ConfigurationProperties(prefix = "spring.ai.graph.nodes")
public class McpNodeProperties {
    private Map<String, Set<String>> node2servers;
}

问题 :前缀太泛(spring.ai.graph.nodes 听起来像所有图节点都用这个配置),而且没有任何默认值、没有任何校验。

优化后的配置应该包含:

yaml 复制代码
spring:
  ai:
    graph:
      mcp:
        node2servers:
          mcp-node:
            - my-mcp-server1
            - my-mcp-server2
        timeout-seconds: 120      # AI 调用超时
        max-retries: 3            # 失败重试次数
        cache-enabled: true       # 工具回调是否缓存
        monitoring-enabled: true  # 是否暴露 Micrometer 指标

对应的 Java 类用 @Validated + @Min/@Max 做边界校验,用 @PostConstruct 确保默认节点存在。


五、问题

5.1 架构层:单点故障与流量风险

当前所有请求都指向 DashScope。如果阿里云区域故障、或者账号欠费、或者触发限流,整个模块直接不可用。

防御性设计建议:

  1. 多模型降级:主调通义千问,失败时降级到备用模型(甚至本地小模型做兜底);
  2. 熔断器(Circuit Breaker):连续失败 N 次后,快速失败不再调用 AI,保护双方资源;
  3. 限流(RateLimiter):防止突发流量把 AI 预算打爆。

5.2 代码层:三个具体病灶

病灶 症状 药方
block() 阻塞 并发上来线程池耗尽 用 block(Duration) 加超时,或改全异步
配置硬编码 改超时时间要重新编译 全量接入 @ConfigurationProperties
输入零验证 空 query 导致无意义调用 Bean Validation + 自定义校验器

5.3 设计层:图的能力只用了 10%

现在的图是 START → mcp → END,本质上就是一个函数调用,没有发挥图计算框架的优势。

图计算的真正威力在于:

  • 条件分支:根据 query 意图走不同节点(问时间 → 时间工具节点;问天气 → 天气工具节点);
  • 并行节点:同时查多个信息源,再聚合结果;
  • 循环重试:节点失败时自动回退到重试节点,而不是直接抛异常。

六、替代方案:

6.1 图框架替代

方案 适合场景 迁移成本
Spring State Machine 状态转换明确、节点少的业务 中 ------ 需要把图概念映射到状态机
Apache Camel 企业集成、多协议适配 高 ------ 学习曲线陡峭,但生态极强
Temporal 长时运行、需要持久化状态的工作流 高 ------ 需部署 Temporal Server

我的建议 :如果团队已经在用 Spring AI Alibaba,不要换 。Graph Core 的 API 设计、与 ChatClient 的集成度、以及对 MCP 的原生支持,都是其他框架短期内追不上的。

6.2 MCP 客户端替代

当前用 spring-ai-starter-mcp-client-webflux 是最优解,因为它把 MCP 协议细节(JSON-RPC、工具发现、调用序列化)全封装了。如果直接手写 MCP SDK,你要自己处理:

  • 工具列表的缓存与刷新
  • 调用时的 schema 校验
  • 错误码映射(MCP 协议定义了多种错误类型)

除非你有极强的定制需求(比如要改 MCP 的传输层为 gRPC),否则不建议替换。

6.3 部署架构替代

方案 优点 缺点
单体(当前) 部署简单、本地开发方便 扩展性差、单点故障
微服务 + K8s 独立扩缩容、服务隔离 运维复杂、需要服务发现
Serverless(Lambda/函数计算) 按需付费、自动扩缩容 冷启动延迟、调试困难

推荐路径:开发期用单体 → 测试期用 Docker Compose → 生产期用 K8s + Helm。


七、部署实操:从源码到线上

7.1 环境准备

bash 复制代码
# Ubuntu 示例
sudo apt update
sudo apt install -y openjdk-21-jdk maven

# 验证
java -version   # 需要 17+
mvn -version    # 需要 3.8+

# 配置环境变量(写入 ~/.bashrc)
export AI_DASHSCOPE_API_KEY=your-key-here
export MCP_NODE_PORT=8080

7.2 构建与本地运行

bash 复制代码
cd mcp-node
mvn clean package -DskipTests

# 方式一:Maven 插件
mvn spring-boot:run

# 方式二:直接跑 JAR
java -jar target/mcp-node-1.0.0-SNAPSHOT.jar

# 方式三:生产级 JVM 参数
java -Xms512m -Xmx2g -XX:+UseG1GC \
     -jar target/mcp-node-1.0.0-SNAPSHOT.jar \
     --spring.profiles.active=prod

7.3 Docker 化

dockerfile 复制代码
FROM eclipse-temurin:21-jdk-jammy
WORKDIR /app
COPY target/mcp-node-1.0.0-SNAPSHOT.jar app.jar
COPY src/main/resources/application.yml config/
EXPOSE 8080
ENV SPRING_CONFIG_LOCATION=file:/app/config/application.yml
ENTRYPOINT ["java", "-jar", "app.jar"]
bash 复制代码
docker build -t mcp-node:1.0.0 .
docker run -d -p 8080:8080 \
  -e AI_DASHSCOPE_API_KEY=$AI_DASHSCOPE_API_KEY \
  --name mcp-node mcp-node:1.0.0

7.4 K8s 生产部署(核心清单)

生产环境至少要有这四张 YAML:

  1. Deployment :3 副本,配好 livenessProbe 和 readinessProbe;
  2. Service:ClusterIP,供集群内调用;
  3. Ingress:带 TLS 终止,暴露给外网;
  4. Secret/ConfigMap:API Key 进 Secret,应用配置进 ConfigMap。

关键配置项:

yaml 复制代码
# deployment 片段
resources:
  requests: { cpu: "500m", memory: "512Mi" }
  limits:   { cpu: "2000m", memory: "2Gi" }

livenessProbe:
  httpGet: { path: /actuator/health, port: 8080 }
  initialDelaySeconds: 60  # JVM 启动慢,给足时间
  periodSeconds: 20

# 环境变量从 Secret 注入
env:
  - name: AI_DASHSCOPE_API_KEY
    valueFrom:
      secretKeyRef: { name: ai-secrets, key: dashscope-api-key }

八、测试策略:

8.1 测试方案

确认测试方案后,按方案执行测试。

8.2 核心单元测试示例

McpNode 测试要点:

  • 正常路径:mock ChatClient 返回流,验证结果 map 包含 mcp_content;
  • 异常路径:模拟 AI 服务抛异常,验证是否包装为 NodeActionException;
  • 空输入路径:query 为空时,验证行为(是抛异常还是返回空内容)。

McpController 测试要点:

  • 用 @WebMvcTest + MockMvc,不启动完整 Spring 上下文;
  • mock StateGraph.compile() 和 CompiledGraph.invoke();
  • 验证默认参数生效、验证空结果返回空 map。

8.3 集成测试与手动验证

项目自带的 mcp-node.http 文件可以直接在 IntelliJ IDEA 里运行:

http 复制代码
### 基础调用
GET http://localhost:8080/graph/mcp/call?query=北京时间现在几点钟

### 带线程隔离
GET http://localhost:8080/graph/mcp/call?query=今天天气&thread_id=session-001

### 健康检查(需先加 actuator)
GET http://localhost:8080/actuator/health

或者用 cURL:

bash 复制代码
curl -X POST http://localhost:8080/graph/mcp/call \
  -H "Content-Type: application/json" \
  -d '{"query":"北京今天天气","threadId":"test-001"}'

8.4 性能测试关注点

指标 工具 阈值建议
吞吐量 (TPS) Gatling 视 AI 服务商限流而定,通常 10~50/sec
P99 延迟 JMeter/Gatling < 5s(含大模型推理时间)
JVM 堆内存 Actuator < 80% 限制
线程阻塞 Actuator threaddump 无 BLOCKED 在 block() 的线程

九、总结:一张检查清单

如果你要把这个模块搬上生产,按这个顺序查漏补缺:

markdown 复制代码
□ 1. 控制器改 POST + 加 DTO 校验 + 全局异常处理
□ 2. McpNode 的 block() 加 Duration 超时,或接入 Resilience4j 重试
□ 3. McpClientToolCallbackProvider 加 ConcurrentHashMap 缓存
□ 4. 配置类加 @Validated,补充 timeout、retry、cache 等配置项
□ 5. 加 spring-boot-starter-actuator,暴露 /health、/metrics
□ 6. 图配置里加错误处理节点(至少让异常不裸抛)
□ 7. 写 Dockerfile 和 K8s YAML(Deployment + Service + Ingress)
□ 8. 补充单元测试(Controller、Node、ToolProvider 各一套)
□ 9. 压测一轮,确认线程模型和内存模型健康
□ 10. 配置日志分级:本地 DEBUG,生产 WARN/ERROR
相关推荐
老金带你玩AI2 小时前
ChatGPT dots,到底能替你操多少心?
人工智能
AliCloudROS3 小时前
计算巢 X DeepSeek Harness — 云端智能体工作台
人工智能·阿里云
深蓝AI3 小时前
GitHub的Push一年涨4.9倍:AI Agent为什么逼它重做Git存储?
人工智能·github
stereohomology3 小时前
大模型的观点谨:StoryTold 「Crafting Apps」四件套 · 深度总览
人工智能·llm
代码什么用3 小时前
Spring基础使用
java·后端·spring
运维行者_3 小时前
网络性能监控怎么做?从自动发现到根因分析的4个环节
运维·服务器·网络·人工智能·支持向量机
Su米苏4 小时前
Spring AI 中MCP 与普通 @Tool的区别
java·人工智能·spring
美狐美颜sdk4 小时前
直播APP源码可以直接接入视频美颜sdk吗?技术方案详解
android·人工智能·音视频·美颜sdk·直播美颜sdk
码云数智-园园4 小时前
unique_ptr 还是 shared_ptr?C++ 智能指针选型与内存泄漏实战分析
java·开发语言
问商十三载4 小时前
AI引擎生成式优化实践:刑事辩护律师团队IP构建
大数据·人工智能