【第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 工作流可以可视化、可调试、可回滚;
  • 统一抽象ChatClientToolCallback 这些接口,不管你底层接的是通义千问、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_zonedashscope__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 副本,配好 livenessProbereadinessProbe
  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
相关推荐
KaMeidebaby3 小时前
卡梅德生物技术快报|Fab 抗体文库构建标准化实验流程与数据复盘
服务器·前端·数据库·人工智能·算法
程序猿乐锅3 小时前
【Tilas|第十篇】万字讲解SpringAOP知识点
java·开发语言·idea·tlias
zhougl9963 小时前
Maven build配置 补
java·maven
Seven973 小时前
dubbo服务调用源码
java
想你依然心痛3 小时前
HarmonyOS 6(API 23)实战:基于悬浮导航、沉浸光感与HMAF的“直播智脑“——PC端AI智能体电商直播中控台
人工智能·华为·harmonyos
qcx233 小时前
【AI Daily】每日AI日报
人工智能·llm·agent·daily
2zcode3 小时前
基于深度学习与STM32的野猪检测与预警系统
人工智能·stm32·深度学习·野猪检测
长谷深风1113 小时前
多线程并发实战:从原理到应用【个人八股】
java·并发编程·线程安全·java多线程·synchronized·锁升级
咖啡八杯3 小时前
GoF设计模式——原型模式
java·后端·设计模式·原型模式