Administrator
发布于 2026-05-12 / 1937 阅读
44

MCP 无状态化改造对架构的影响

长连接撑不住了,我们花了三周做无状态化

我们最早那版 MCP Server 用的是 SSE 传输,从 2025 年 6 月跑到现在。四月份开始出问题:Agent 数量从 3 个涨到 11 个,MCP Server 的连接数冲到 4,000+,然后就是各种诡异的断连和内存上涨。

三月份决定做无状态化改造,前后三周。这篇记录改造的动机、具体改了什么、以及对架构和运维的实际影响。数据都是我们生产环境的真数。

先说长连接给了我们什么麻烦

原来的架构是这样:每个 Agent 实例和每个 MCP Server 实例之间维持一条 SSE 长连接,服务端用 sessionId 区分会话,会话状态存在内存里。

问题出在规模上来之后:

指标2025-092026-03变化
Agent 实例数642
MCP Server 实例492.3×
活跃长连接24378(理论 378)
实际连接数314,127133×
单实例内存1.2 GB4.8 GB
日均断连次数121,840153×

「理论连接数 378,实际 4,127」这一行是问题核心。连接泄漏了

原因有几层:

  • Agent 实例在 K8s 里滚动发布,旧实例的连接没有优雅关闭,服务端要等心跳超时(我们设的 90 秒)才回收;
  • Agent 侧的重连逻辑有 bug,断连后重连但不释放旧连接对象;
  • 中间的 Nginx 有连接数限制,偶尔会主动断开,触发雪崩式重连。

4,127 条连接,每条对应一个会话状态对象(含对话历史、工具执行上下文),平均 1.2 MB。这就是 4.8 GB 内存的来源。

更麻烦的是断连。1,840 次/天意味着平均每分钟就有 1.3 次。每次断连如果正好有工具调用在飞,就丢失了,Agent 侧要重试。我们的工具调用失败率从 0.3% 涨到了 2.7%。

无状态化到底改什么

MCP 的 Streamable HTTP 传输在 2025 年就取代了 SSE 成为推荐方式,我们是拖到今年才动。它的核心变化:

维度SSE(HTTP+SSE 双通道)Streamable HTTP
通道POST 发请求 + GET /sse 收响应单个 POST,响应可选流式
会话必须,Mcp-Session-Id 必需可选,支持完全无状态
服务端状态内存里存会话可以什么都不存
断线重连需要客户端维护天然支持,请求自带全部上下文
负载均衡需要会话粘滞随便轮询
水平扩容扩了旧连接还在老实例上随时扩,无影响

最关键的一条是「会话可选」。我们选的是完全无状态模式:每次请求自带全部所需信息,服务端不存任何跨请求的状态。

服务端的改造点

改造分四块,从易到难。

一、传输层配置

最直接的一步,改配置就行:

# 改前
spring:
  ai:
    mcp:
      server:
        protocol: SSE
        type: SYNC
        capabilities:
          tool: true

# 改后
spring:
  ai:
    mcp:
      server:
        protocol: STREAMABLE
        type: SYNC
        # 关键:不保留会话,每次请求独立
        keep-alive-interval: 30s      # 流式响应时的心跳,防中间设备超时
        capabilities:
          tool: true
          resource: true
          prompt: false
server:
  tomcat:
    threads:
      max: 400                        # IO 密集,调大
    connection-timeout: 20000
    keep-alive-timeout: 30000
  # 关键:允许 POST 返回 SSE 格式的流式响应
  max-http-response-header-size: 16KB

keep-alive-interval 这个参数要说一下。Streamable HTTP 在流式模式下还是 SSE 格式,如果长时间没有数据,中间的 Nginx / ELB 会掐断。我们设了 30 秒心跳。

二、会话状态外置

这是工作量的大头。原来存在内存里的会话状态,全部要挪到 Redis。

先看我们原来存了什么:

// 改造前的内存态
public class McpSession {
    private String sessionId;
    private String tenantId;              // 租户标识
    private String userId;                // 用户
    private List<ToolCallRecord> history; // 本会话的工具调用历史
    private Map<String, Object> scratchpad; // 工具之间的临时数据
    private Instant lastActive;
}

改造后,按用途拆成了三份,存的地方不一样:

数据原位置新位置理由
tenantId / userId会话内存JWT claims(请求头带)天然无状态,无需存储
工具调用历史会话内存Redis(TTL 30 分钟)审计和限流需要,但不需要永久
scratchpad会话内存不存,改为工具返回值传递这是我们做的最大的简化

第三行值得展开。scratchpad 是工具之间共享数据的地方——比如「查询订单」的结果给「申请退款」用。有状态模式下很自然,无状态模式下必须改。

我们的解法是:不允许工具之间有隐式共享状态,需要传递的数据通过返回值显式传递

// 改前:靠 scratchpad 共享
@McpTool(name = "queryOrder")
public OrderResult queryOrder(String orderNo, McpSyncRequestContext ctx) {
    Order order = orderService.query(orderNo);
    ctx.session().setAttribute("currentOrder", order);   // 隐式状态
    return toResult(order);
}

@McpTool(name = "applyRefund")
public RefundResult applyRefund(String reason, McpSyncRequestContext ctx) {
    Order order = (Order) ctx.session().getAttribute("currentOrder");  // 依赖上一步
    return refundService.apply(order, reason);
}

// 改后:显式传递句柄
@McpTool(name = "queryOrder", description = "查询订单,返回 handle 供后续操作使用")
public OrderResult queryOrder(String orderNo) {
    Order order = orderService.query(orderNo);
    // handle 是一个自包含的加密串,包含订单号和校验信息
    String handle = handleEncoder.encode(order.getOrderNo(), order.getVersion());
    return OrderResult.of(order).withHandle(handle);
}

@McpTool(name = "applyRefund", description = "申请退款,需要 queryOrder 返回的 handle")
public RefundResult applyRefund(
        @McpToolParam(description = "queryOrder 返回的 handle") String handle,
        @McpToolParam(description = "退款原因") String reason) {

    OrderRef ref = handleEncoder.decode(handle);    // 无状态解码
    return refundService.apply(ref.orderNo(), reason);
}

handle 的设计要点:自包含(不需要查库就能解出必要信息)、带签名(防篡改)、带版本(乐观锁)。我们的实现是 Base64(AES(orderNo + version + 签名)),128 字节。

public String encode(String orderNo, long version) {
    byte[] payload = (orderNo + "|" + version + "|" + System.currentTimeMillis()).getBytes(UTF_8);
    byte[] encrypted = aes.encrypt(payload);
    byte[] sig = hmac(encrypted);                  // 防篡改
    return Base64.getUrlEncoder().encodeToString(
        ByteBuffer.allocate(encrypted.length + sig.length)
                  .put(encrypted).put(sig).array());
}

这个改动带来的一个额外好处:工具之间的依赖关系变显式了,模型更容易理解。原来 applyRefund 的签名里没有任何线索表明它依赖 queryOrder,现在 handle 参数明明白白写在那里,description 里也说了。工具调用的顺序错误率从 4.2% 降到 1.1%。

三、鉴权方式改变

有状态模式下,鉴权在建立 SSE 连接时做一次,之后复用会话。无状态模式下每个请求都要鉴权,所以必须做得足够轻。

我们改成了 JWT + 本地验签,不查库不查 Redis:

@Component
public class McpAuthFilter implements Filter {

    // 公钥本地缓存,10 分钟刷新一次,验签完全本地化
    private volatile PublicKey publicKey;

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
        String token = extractBearer((HttpServletRequest) req);
        if (token == null) {
            reject(resp, 401, "missing token");
            return;
        }

        Claims claims;
        try {
            claims = Jwts.parser().verifyWith(publicKey).build()
                          .parseSignedClaims(token).getPayload();
        } catch (JwtException e) {
            reject(resp, 401, "invalid token");
            return;
        }

        // 校验 scope,MCP 工具按 scope 授权
        RequestContext.set(new Principal(
            claims.getSubject(),
            claims.get("tenant", String.class),
            claims.get("scope", List.class)));

        try {
            chain.doFilter(req, resp);
        } finally {
            RequestContext.clear();       // 务必清理,避免线程复用串号
        }
    }
}

实测验签耗时 0.08ms,相对 200ms 的工具调用可以忽略。如果这里要查一次 Redis 或数据库,无状态化的收益就损失大半了

另外令牌 TTL 从 2 小时缩短到 15 分钟。无状态模式下刷新令牌的代价很低(就是重新发一个请求),没必要给长 TTL。

四、流式响应的处理

这一块踩了个坑。Streamable HTTP 的响应可以是普通 JSON,也可以是 SSE 流。我们有些工具(比如长文本生成)是流式的。

问题在于:流式响应中间失败了,无状态模式下没法「接着上次的进度继续」。有状态模式下服务端记得输出到哪了,客户端可以带 Last-Event-ID 续传。

我们的选择是:工具调用结果不走流式。工具返回的是数据,不是给用户的文本,本来就没必要流式。真正需要流式的(模型生成的回答)发生在 Agent 侧,不经过 MCP。

// MCP Server 侧:工具调用一律同步返回完整结果
@McpTool(name = "generateReport")
public ReportResult generateReport(String templateId) {
    return reportService.generate(templateId);   // 同步,不流式
}

// Agent 侧:拿到工具结果后再流式输出给用户
return chatClient.prompt(...)
    .toolCallbacks(mcpCallbacks)
    .stream()
    .content();     // 这里才流式

这个分工理清之后,MCP Server 的实现简单了很多。整个改造过程中,这块反而是删代码。

对扩展性的影响

改造完成后最直观的收益是扩容变简单了。

维度改造前改造后
负载均衡策略必须会话粘滞(NGINX sticky)轮询(round-robin)
扩容生效时间等旧连接自然断开,约 15 分钟立即
缩容安全性要手动 drain,否则丢会话直接杀
单实例内存4.8 GB1.1 GB
实例数(同流量)95
连接数4,1270 长连接(短连接按需)

内存从 4.8 GB 降到 1.1 GB,主要是没了会话对象。实例从 9 个缩到 5 个,算力没变(工具执行本身不重),只是不再需要为连接数买单。

还有个意外的好处:K8s 的 HPA 终于能正常工作了。以前用连接数做扩容指标,但连接数在扩完容之后不会立刻下降(旧连接还在老实例上),导致扩了又扩。现在用 CPU 和 QPS 指标,响应及时。

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  minReplicas: 3
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 60
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 180    # 缩容慢一点
    scaleUp:
      stabilizationWindowSeconds: 0      # 扩容立即

对运维的影响

运维侧的变化比架构侧更让人舒服。

排查问题变简单了

以前出问题要问「这个 sessionId 在哪个实例上」,然后去那台机器翻日志。现在每个请求都是独立的,任何一个实例上的日志都包含完整信息。

我们把 sessionId 换成了一个贯穿全链路的 conversationId,它只是一个日志字段,不再对应任何服务端状态:

// Agent 侧生成,通过请求头传递,服务端只用来打日志
POST /mcp HTTP/1.1
Host: order-tools.mcp.svc
Authorization: Bearer eyJhbGciOi...
X-Conversation-Id: 7f3a2c91-4b6e-4f21-9a8d
Content-Type: application/json

{"jsonrpc":"2.0","id":1,"method":"tools/call",
 "params":{"name":"queryOrder","arguments":{"orderNo":"SO20260115001"}}}

日志查询从「先找实例再查日志」变成了「按 conversationId 全局搜」。

发布变安全了

以前发布 MCP Server 要挑业务低峰,因为滚动发布会断掉一批连接,而断连可能导致正在进行的工具调用失败。现在随便发。

我们的发布流程从「每周四晚上 10 点,人工值守」变成了「CI 自动发布,随时可发」。这个变化对团队节律的影响比预想的大。

多了个 Redis 依赖

代价也要说清楚:无状态不是真的零状态,我们把状态推到了 Redis。目前 Redis 里存的是:

  • 工具调用历史(审计用,TTL 30 分钟);
  • 限流计数(TTL 按需,1 分钟~1 天);
  • 幂等键(TTL 24 小时)。

Redis 的内存占用 3.2 GB,QPS 峰值 1.8 万。这是个新的故障点,我们给它加了主从和降级策略——Redis 挂了的时候,限流降级为本地限流,审计降级为写日志,核心的工具调用不受影响。

改造的成本和坑

三周时间,1 个人(我)+ 半个人(另一个同事兼职)。工作量分布:

工作项人日备注
配置和传输层切换1最顺利
会话状态外置5scratchpad 改造占 3.5 天
鉴权重构2.5包括 Agent 侧的令牌刷新逻辑
工具签名改造(23 个工具)4主要是加 handle、改 description
压测和灰度3
文档和回滚预案1
合计16.5

踩的坑:

  1. Nginx 的 proxy_buffering 又来了。 流式响应被缓冲,客户端收不到。这个问题我们在别的地方踩过一次,这次又踩,说明团队没有沉淀。后来加到了部署检查清单里;
  2. Agent 侧的 SDK 版本不一致。 11 个 Agent 用了 4 个不同版本的 MCP 客户端,其中 2 个版本的 Streamable HTTP 实现有 bug(不发送 Accept 头导致服务端不知道该返回流式还是非流式)。统一升级到最新版解决;
  3. 幂等键忘了做。 无状态模式下重试更频繁,没有幂等会重复执行。我们在改造中期才想起来,返工了 1.5 天;
  4. handle 的大小没控制好。 第一版把整个订单对象序列化进去,handle 长 2 KB,塞在参数里很难看。后来改成只放 ID 和版本。

最终数据

指标改造前改造后
活跃长连接4,1270
单实例内存4.8 GB1.1 GB
实例数95
日均断连1,8400(无连接概念)
工具调用失败率2.7%0.4%
工具 P99 延迟420ms180ms
扩容生效时间~15 分钟即时
发布窗口每周四 22:00随时
机器成本-44%

P99 从 420ms 降到 180ms,这个提升超出预期。分析下来主要是两个原因:一是内存压力小了,GC 从 3.4 次/秒降到 0.7 次/秒;二是轮询负载均衡比会话粘滞更均匀(粘滞模式下有些实例被热点会话压着)。

小结

这件事的本质是:我们为了「保持会话状态」付出的代价,远高于状态本身的价值

回过头看,当初选 SSE + 内存会话,是因为「对话式交互天然有会话」这个直觉。但 MCP 的工具调用其实不是对话——它是一次性的、独立的请求。把对话模型套在无状态的操作上,从一开始就错了。

无状态化逼着我们做的最大改变,是把工具之间的隐式耦合(scratchpad)改成显式传递(handle)。这个改动表面上是为了无状态,实际收益最大的是可理解性——模型更容易理解工具之间的依赖,我们自己也更容易维护。

给要做同类改造的人两个建议:第一,先把工具之间的共享状态梳理清楚,这是工作量的大头,也决定了改造后的设计质量;第二,鉴权一定要做成纯本地验签,任何一次远程调用都会把无状态化的收益吃掉一部分。

还有一点:无状态不等于没有状态,只是把状态推到了该待的地方(Redis、令牌、请求参数)。想清楚每一份状态「该待在哪」,比纠结「要不要无状态」更重要。

参考