从零构建一个 MCP Agent 运行时(一):为什么我放弃了 Spring AI
用了半年 Spring AI 和 LangChain4j 之后,我决定自己写一个 MCP Agent Runtime。这篇文章讲讲我的理由和整体架构设计。
先说结论
Spring AI 和 LangChain4j 的问题不在于它们功能不够多。恰恰相反——它们功能太多了,多到让我觉得是在用一把瑞士军刀砍树。
Agent Runtime 的核心需求其实不多:接收 LLM 的 tool call 请求、找到对应的 tool、执行它、把结果返回给 LLM。仅此而已。
Spring AI 把这件事变成了一个巨大的抽象层。你要理解 ToolCallback、ToolCallbackProvider、MethodToolCallbackProvider、ChatClient 的 tool 注入机制、MCP client/server starter 的自动配置链路……当你只想加一个 tool 的时候,Spring AI 让你先过一遍它的 SPI 加载流程。这在 demo 阶段还好,一旦进入生产,每个抽象层都可能成为排查问题的瓶颈。
我遇到过一个具体场景:某个 tool 执行超时了,Spring AI 的默认超时策略是在 ChatClient 层面全局兜底,而不是在 tool 执行层面做精细控制。我想给不同的 tool 设置不同的超时时间,翻了一圈源码发现得自己重写 ToolExecutionRequest 的处理逻辑。
那一刻我意识到:与其在一个框架的抽象层里打洞,不如自己写一个刚好的。
我需要什么
先理清楚我到底要什么。
一个 Agent Runtime 的核心流程极其简单:
LLM 返回 tool call → 解析 schema → 查找 tool → 执行 → 结果注入 → 下一轮
就这么 6 步。任何框架只要把这几步做好,就满足了 80% 的需求。剩下的 20% 是各种边缘情况:错误重试、上下文管理、结构化输出兼容、可观测性。
所以我想要的 Runtime 应该满足几个原则:
第一,透明。 每个 tool call 的生命周期我必须能完全控制。从 LLM 返回 FunctionCall 到 tool 执行完返回结果,每一步我都能插桩、拦截、修改。
第二,轻量。 不要自动配置。不要隐式注入。我显式注册 tool,显式声明每个 tool 的参数 schema,显式控制执行策略。
第三,生产友好。 内置重试、超时、熔断、监控。这些不是附加功能,而是 Runtime 的核心能力。
技术选型的逻辑
技术栈我选了 Java 17 + Spring Boot 3.x + MCP4J(MCP Java SDK)。
为什么是 Java 而不是 Python?两个原因。一是我的生产环境基础设施是 Java 生态的——Spring Cloud、Nacos、Sentinel,这些在存量系统里已经跑了好几年,Agent Runtime 要和它们深度集成。二是 MCP4J 的纯 Java 实现让我可以脱离 Python 的 GIL 限制和依赖管理噩梦。Python 生态在 AI 应用层确实强,但在工程层,Java 在并发控制、类型安全、生产化运维上的积累不是 Python 能比的。
为什么是 Spring Boot 而不是 Quarkus?因为我懒。Spring Boot 的生态太成熟了,从 Actuator 到 Micrometer,从事务管理到 AOP,我需要的东西基本都是现成的。Quarkus 的冷启动确实快,但对于一个需要和 LLM 交互的长时间运行服务,启动那几百毫秒的差异不重要。
MCP4J 是核心依赖。它提供了 MCP 协议的 Java 实现——会话管理、JSON-RPC 通信、Tool/Resource/Prompt 的原生 schema 定义。我需要的是在它上面加一层 Runtime 逻辑,而不是重新实现协议层。
整体架构
这是我设计的 Runtime 架构,从下往上分四层:
┌──────────────────────────────────────────────────────┐
│ Agent 应用层 │
│ (业务编排、多 Agent 路由、Chain/Graph 执行器) │
├──────────────────────────────────────────────────────┤
│ Runtime 核心层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │ Tool │ │ Tool │ │ Structured Output │ │
│ │ Registry │ │ Executor │ │ Compat Layer │ │
│ └──────────┘ └──────────┘ └──────────────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │ Context │ │ State │ │ Observability │ │
│ │ Manager │ │ Manager │ │ (Tracing/Metrics) │ │
│ └──────────┘ └──────────┘ └──────────────────────┘ │
├──────────────────────────────────────────────────────┤
│ MCP 协议层 │
│ (MCP4J: JSON-RPC, Transport, Session Management) │
├──────────────────────────────────────────────────────┤
│ LLM 接入层 │
│ (OpenAI / Claude / DeepSeek API Adapter) │
└──────────────────────────────────────────────────────┘
MCP 协议层:MCP4J 负责。包括 JSON-RPC 的消息编解码、Transport 管理(STDIO / SSE / WebSocket)、Server/Client 会话生命周期。我不在这里做任何魔改,完全遵循 MCP 规范。
Runtime 核心层:这是我写这个系列的核心。6 个模块:
- Tool Registry:tool 的声明、注册、动态发现、Schema 描述。不是简单用一个 @Tool 注解就完事了,我要类型安全的参数校验和 JSON Schema 自动生成。
- Tool Executor:从 LLM 返回的 FunctionCall 到实际调用的完整链路。参数解析、类型转换、执行、超时控制、错误重试。
- Structured Output Compat Layer:推理模型(o1、DeepSeek-R1)和指令模型在 tool call 输出格式上的差异,需要一个兼容层做适配。
- Context Manager:上下文污染防护、渐进式披露策略、Token 预算管理、对话窗口压缩。
- State Manager:Session 状态的增量管理、Tool 结果缓存、对话历史的分片存储。
- Observability:Trace ID 贯穿全链路,Tool 执行耗时/成功率监控,LLM 输入输出记录。
Agent 应用层:业务方。基于 Runtime 核心层编排多 Agent 流程、路由逻辑、Chain/Graph 执行器。这部分不在这个系列的重点范围内,但会穿插在实战 demo 里展示。
项目骨架
不废话,直接上代码。这是一个最小可用的 Spring Boot 项目结构:
mcp-agent-runtime/
├── pom.xml
├── src/main/java/com/mcpruntime/
│ ├── McpAgentRuntimeApplication.java
│ ├── core/
│ │ ├── registry/
│ │ │ ├── ToolRegistry.java
│ │ │ └── ToolDefinition.java
│ │ ├── executor/
│ │ │ ├── ToolExecutor.java
│ │ │ └── ToolExecutionResult.java
│ │ ├── context/
│ │ │ └── ContextManager.java
│ │ ├── state/
│ │ │ └── StateManager.java
│ │ ├── structured/
│ │ │ └── StructuredOutputAdapter.java
│ │ └── observability/
│ │ └── AgentTracer.java
│ ├── mcp/
│ │ ├── McpServerConfig.java
│ │ └── McpClientConfig.java
│ └── runtime/
│ └── AgentRuntime.java
这是 pom.xml 的核心依赖:
<properties>
<java.version>17</java.version>
<spring-boot.version>3.4.1</spring-boot.version>
<mcp4j.version>0.9.0</mcp4j.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- MCP4J -->
<dependency>
<groupId>org.springframework.experimental</groupId>
<artifactId>mcp</artifactId>
<version>${mcp4j.version}</version>
</dependency>
<!-- JSON Schema for tool parameter validation -->
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.5.5</version>
</dependency>
<!-- Observability -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
</dependencies>
这是启动类的骨架:
@SpringBootApplication
public class McpAgentRuntimeApplication {
public static void main(String[] args) {
SpringApplication.run(McpAgentRuntimeApplication.class, args);
}
@Bean
public AgentRuntime agentRuntime(
ToolRegistry toolRegistry,
ToolExecutor toolExecutor,
McpServerConfig serverConfig) {
return new AgentRuntime(toolRegistry, toolExecutor, serverConfig);
}
}
这个系列接下来的每一篇,都会在这个骨架的基础上新增模块。
一个 tool 注册的预告
第一篇不展开太多,但我想让你看看这套 Runtime 和 Spring AI 在 tool 注册上的根本差异。
Spring AI 的做法:
@Service
public class WeatherService {
@Tool(name = "get_weather", description = "获取某个城市的天气")
public String getWeather(String city) {
// ...
}
}
// 然后在配置里:
@Bean
public List<ToolCallback> weatherTools(WeatherService service) {
return List.of(ToolCallbacks.from(service));
}
看起来很简洁对吧?但问题藏在细节里:
ToolCallbacks.from()通过反射扫描@Tool方法,你无法精细控制参数的 JSON Schema 生成策略- 参数类型推断完全依赖方法的 Java 类型,如果你想对同一个 String 参数声明不同的 enum 约束,做不到
- 无法在注册阶段对 tool 做拦截器链的绑定——如果你想给
get_weather加一个 rate limiter,得在WeatherService里手动写
这套 Runtime 的做法:
@Configuration
public class WeatherToolRegistration {
@Bean
public ToolDefinition weatherTool() {
return ToolDefinition.builder()
.name("get_weather")
.description("获取某个城市的天气")
.parameterSchema(JsonSchema.builder()
.addProperty("city", SchemaProperty.STRING
.withDescription("城市名,如 北京、上海、东京")
.withEnum("北京", "上海", "广州", "深圳", "东京"))
.addProperty("unit", SchemaProperty.STRING
.withDefault("celsius")
.withEnum("celsius", "fahrenheit"))
.build())
.executor(ctx -> {
String city = ctx.getArg("city");
String unit = ctx.getArgOrDefault("unit", "celsius");
return weatherService.fetchWeather(city, unit);
})
.withInterceptor(new RateLimitInterceptor(10, Duration.ofMinutes(1)))
.withInterceptor(new RetryInterceptor(3, Duration.ofSeconds(2)))
.build();
}
}
每个 tool 的声明是显式的、自包含的。Schema 和 executor 在一个地方定义,拦截器链在注册时绑定。没有反射扫描,没有隐式注入。你看这段代码就能知道这个 tool 会怎么被调用、执行、和兜底。
接下来
这个系列会按前面提到的 8 个模块展开。下一篇讲 Tool Registry 的核心实现——类型安全的 Schema 描述、动态发现机制、以及如何做到让调用方只写业务逻辑,不碰协议细节。
代码仓库已经在搭了,每篇文章对应一个 git tag,可以直接 checkout 跑起来。
好,先聊到这。
评论
发表评论