- 想理解 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
) { ... }
这里藏着三个设计问题:
- GET 方法语义不当:查询参数放在 URL 里,一旦 query 是长文本或含特殊字符,URL 编码会爆炸。而且 GET 在语义上是"幂等读取",但这里的调用会触发大模型计算,产生副作用和费用;
- 返回类型是裸 Map:调用方不知道会收到什么字段,契约模糊;
- 异常直接上抛 :
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);
}
这里有两个容易被忽略的设计细节:
node_async()包装器 :它把同步的McpNode包成异步节点。Graph Core 的图引擎可以并行调度异步节点,如果未来图里有多个分支(比如"查天气"和"查新闻"并行),这个包装器就是基础;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。如果阿里云区域故障、或者账号欠费、或者触发限流,整个模块直接不可用。
防御性设计建议:
- 多模型降级:主调通义千问,失败时降级到备用模型(甚至本地小模型做兜底);
- 熔断器(Circuit Breaker):连续失败 N 次后,快速失败不再调用 AI,保护双方资源;
- 限流(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:
- Deployment :3 副本,配好
livenessProbe和readinessProbe; - Service:ClusterIP,供集群内调用;
- Ingress:带 TLS 终止,暴露给外网;
- 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