从零构建一个 MCP Agent 运行时(一):为什么我放弃了 Spring AI

用了半年 Spring AI 和 LangChain4j 之后,我决定自己写一个 MCP Agent Runtime。这篇文章讲讲我的理由和整体架构设计。

先说结论

Spring AI 和 LangChain4j 的问题不在于它们功能不够多。恰恰相反——它们功能太多了,多到让我觉得是在用一把瑞士军刀砍树。

Agent Runtime 的核心需求其实不多:接收 LLM 的 tool call 请求、找到对应的 tool、执行它、把结果返回给 LLM。仅此而已。

Spring AI 把这件事变成了一个巨大的抽象层。你要理解 ToolCallbackToolCallbackProviderMethodToolCallbackProviderChatClient 的 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));
}

看起来很简洁对吧?但问题藏在细节里:

  1. ToolCallbacks.from() 通过反射扫描 @Tool 方法,你无法精细控制参数的 JSON Schema 生成策略
  2. 参数类型推断完全依赖方法的 Java 类型,如果你想对同一个 String 参数声明不同的 enum 约束,做不到
  3. 无法在注册阶段对 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 跑起来。

好,先聊到这。

评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个