MCP实战手记(八):从“能跑“到“能上线“——无状态MCP Server的生产落地清单

规范变了什么,见我的《MCP Live 解读》;怎么迁到无状态,见第一季篇(三)。

本文两样都不重复------只讲迁完之后才会遇到的 5 个生产问题。


引子:迁完了,然后呢

第一季篇(三)把服务从 SSE + 有状态迁到了无状态 Streamable HTTP,结论是"90% 的改动在配置与依赖,业务代码基本不动"。服务能跑,工具能调,看起来是完了。

但迁完只是第一步。我把门店运营那套 mock 工具(get_store_sales 查门店销售、get_inventory 查库存、get_device_status 查门店设备、suggest_restock 补货建议)迁完上线,第一天就撞了三类问题:

  • 店长说"查下门店销售",没给门店编码,工具直接报错------以前靠 elicitation 补参,现在这招没了
  • 多轮对话里记着的上文,请求打到第二个实例就没了------会话没了,数据去哪
  • 门店里一批老客户端连不上新服务------旧客户端怎么办

这三个,加上排障和缓存,就是本文要答的 5 个问题。

⚠️ 实证范围 :本文所有实测都在本机 mock 数据 + 双实例 Docker 下完成,是一份"生产准备清单"------告诉你迁完之后要补什么、怎么验证;不是生产环境的压测 / 容量报告(那项在 Checklist 第 10 条,本机做不了,列为"建议"留给你的真实环境)。


问题 1:补参------elicitation 没了怎么办

无状态服务器不支持 elicitation / sampling / ping(服务端不能主动向客户端发请求)。这条限制篇(三)提过,但它带来的第一个真实麻烦是:工具缺参数时没法回头问用户。

替代做法是服务端在返回结果里带 inputRequired,由客户端收集后重发:

java 复制代码
@Tool(name = "get_store_sales", description = "查门店销售")
public StoreSales getStoreSales(@ToolParam(required = false) String storeCode) {
    if (storeCode == null || storeCode.isBlank()) {
        return StoreSales.needInput("storeCode", "要查哪个门店?");  // 带 inputRequired 返回
    }
    return mock.querySales(storeCode);
}

客户端识别到 inputRequired 就收集参数重试。这一步必须客户端配合:服务端自己推不动,参数只能靠客户端带回来。

⚠️ 边界 :inputRequired 是本文自定义的返回约定,不是 MCP 协议标准字段------协议层无状态本身不提供补参机制,补参只能落在应用层约定(各家自己定义)。Spring AI 是否提供一等 API 以官方文档为准,上面用结果对象演示语义。

实测(无参调用,工具返回截自响应 content[0].text):

json 复制代码
{"success":false,"inputRequired":true,"requiredParam":"storeCode","message":"要查哪个门店?可用:ST001 / ST002 / ST003"}

带参重试(storeCode=ST001):

json 复制代码
{"success":true,"inputRequired":false,"message":"天河城店 今日销售 12,480 元,客流 431"}

这里有个很容易踩空的点:@ToolParam 必须写 required = false。不写的话,生成的 JSON Schema 会把 storeCode 列为必填,框架在调用方法前就按 schema 拦下(报校验错误),方法体里的 inputRequired 分支根本走不到。tools/list 里能看到证据------我的入参 schema 是 "required":[]。


问题 2:会话数据外置------哪些必须搬走

无状态后服务端不存会话,原来放会话里的东西得显式外置。我的分法:

类型 处置
多轮上下文、中间结果 必须外置(Redis + TTL)
一次性参数 每次带上,不存
工具列表、静态配置 缓存,不进会话
java 复制代码
// 迁前:从会话里取
// 迁后:显式外置
redis.opsForValue().set("ctx:" + sessionKey, ctx, Duration.ofMinutes(30));

判断标准就一条:这条数据在下一次请求打到另一个实例时还必须要吗? 是就外置,不是就别存。

最容易漏的是"我以为是参数、其实是状态"的那些------比如多轮里用户说过"换成上个月",这个"上个月"就是状态。

我先用内存 Map 试的,单实例跑得好好的;起了第二个实例才暴露。实测对照(同一份代码,只切 store.context.type,两个实例 8088/8089):

memory 模式------8088 写入,8089 读:

text 复制代码
8088: 已保存 key=last_query | 存储=memory(本实例) | 处理实例=8088
8089: 未读到 key=last_query | 存储=memory(本实例) | 处理实例=8089

redis 模式------同样的操作:

text 复制代码
8088: 已保存 key=last_query | 存储=redis(外置共享) | 处理实例=8088
8089: 读到 last_query=user wants last month sales | 存储=redis(外置共享) | 处理实例=8089

同一份代码,只差一个配置项,多实例下结果完全相反。

⚠️ 外置后的第二个坑:谁的数据是最新的。多个实例同时写同一个 key,后写覆盖先写(last-write-wins)------单实例看不出来,双实例并发写同一个门店上下文,就丢更新了。生产上这类状态要按需加版本号 / 乐观锁,或干脆限定"单一写者"。这条同样要等真实并发才验证得到,本机 mock 不触发。

如果你的服务确定只跑单实例,这一整节可以先跳过。 只要你哪天开了第二个实例,它会立刻回来找你。


问题 3:客户端兼容------老客户端怎么办

老客户端(SSE + 有状态)连无状态服务会失败。生产上不能一刀切,做法是两套端点并行、按客户端版本分流:

nginx 复制代码
location /mcp/legacy { proxy_pass http://legacy-sse; }   # 老客户端
location /mcp        { proxy_pass http://stateless;   }   # 新客户端

等量迁移完再摘掉 legacy。所以灰度这一步不能省。

实测(老客户端风格的请求打无状态服务):

text 复制代码
GET /sse  -> 404   (SSE 事件流端点已不存在)
GET /mcp  -> 405   (端点在,但只收 POST)

老客户端连事件流的入口都找不到了:SSE 那条路整个没了,只剩 /mcp 这一个 POST 端点。


问题 4:没 session 了,怎么排障

请求跨多实例,日志是散的。现在能做的是两件事:

  1. 结构化日志:每次工具调用打一条,带请求级标识
  2. 请求级标识 :客户端在 _meta 里带 requestId,服务端打进日志

一条调用日志至少要有这几个字段:requestId、工具名、入参、判定结果、耗时、处理实例------这样跨实例能按 requestId 把同一请求的日志从各台机器捞出来拼成一条。

这能解决"单点看得到",解决不了"全链路看得到"。

全链路的完整做法我放在第(十四)篇 :通过 _meta 注入 W3C traceparent。这条路我已经验证过可行性------MCP Java SDK 2.0.0 里客户端有 CallToolRequest.Builder.meta(...),无状态服务端的 handler 签名是 BiFunction<McpTransportContext, CallToolRequest, ...>,直接能拿到 .meta() 。也就是说不需要 ToolContext(无状态模式下它本来也不适用),原生路径就够用。


问题 5:缓存与动态发现

工具列表每次都拉一遍是浪费。2026-07-28 规范给列表结果加了 ttlMs / cacheScope,可以给 tools/list 设缓存时长,客户端有效期内不重复拉。

server/discover 是动态能力发现:客户端按需发现工具,而不是开局拉全量目录。

⚠️ 这条我还没实测确认它是已发布项还是仍在实验阶段,Java SDK 的支持情况也待验证。本篇先只讲概念不给代码,确认后补。


补充:重试了,但工具被调了两次怎么办

无状态 + 流式 HTTP 下,网络一抖,客户端就会重试同一次 POST。能重试是好事,但工具可能已经被执行了两次------查门店销售这种只读的没事,能产生副作用的工具(下单、改库存)就会出事故。

最低成本的做法两条:

  1. 客户端给带副作用的调用带一个幂等键(idempotency-key),服务端对同一键只执行一次;
  2. 工具本身设计成天然幂等(同样的入参,结果一致),副作用操作先查后写。

这条在本机 mock 里演不出来------mock 无副作用------所以它不进"实测"小节,列为清单项,留给你的真实环境验证。

深一层的幂等 + 断点续跑,见第(十六)篇长流程状态管理。


小结

  1. 迁移完成 ≠ 能上生产:真正的坑集中在补参、状态、兼容三处,都在"迁完之后"才出现
  2. 无状态换来可扩展性,代价是"回头问"和"记住"这两件事都得搬到外面------搬到哪、要不要搬,是迁完后最花时间的判断

生产落地 Checklist

# 项 级别
1 工具缺参时返回 inputRequired,不抛裸异常 必做
2 客户端补参 / 重试逻辑已实现 必做
3 多轮上下文已外置,多实例下验证过 必做
4 老客户端有兼容路径,可灰度切换 必做
5 回滚方案(切回 legacy 端点)随时可执行 必做
6 每次工具调用有结构化日志 + 请求级标识 必做
7 tools/list 配了 ttlMs 缓存 建议
8 外部存储(Redis)有降级方案 建议
9 无 session 后的排障手册已就绪 建议
10 迁移前后压测对比过 建议
11 带副作用的工具调用具备幂等保护(幂等键或天然幂等) 必做

📦 本文完整可运行代码 (tag: v08)

clone 下来 git checkout v08 就能还原本文状态,全 mock 数据,不依赖任何真实系统。

跑通了点个 star ⭐,这是我继续写下去最直接的反馈;跑不通直接提 issue,我看到就回。


环境与版本:Spring Boot 4.0.x / Spring AI 2.0.1 / MCP Java SDK 2.0.0 / JDK 21。

相关推荐
科技看点说3 小时前
AI画面不廉价:质感的三层来源详解
人工智能
某不知名網友3 小时前
ROS2进阶:服务通信、自定义SRV接口、参数服务器与基于服务的参数通信详解
java·开发语言·microsoft
秦先生在广东3 小时前
Agentic Awesome Skills 解析:构建本地优先的 Agent 技能控制平面
人工智能
Wang's Blog3 小时前
Java 项目实战: 交友项目 - MongoDB 入门:体系结构和命令行操作
java·mongodb·交友
小溪学编程3 小时前
将 Bean 放入 Spring 容器中的方式有哪些?
java·后端·spring
径硕科技JINGdigital3 小时前
企业搭建 AI 模型网关,该选择哪些支持多模型接入与统一治理的平台?
人工智能
甲维斯3 小时前
国产Flash大乱斗【1】:DS和MiniMax挺起胸膛!
人工智能
java_nnnn3 小时前
LeetCode刷题滑动窗口(水果成篮+找到字符串中所有字母的异位词)Java
java·开发语言·leetcode
用户3721574261353 小时前
Java 读取、修改和清空 PDF 文档属性(元数据)
java
阿里云大数据AI技术3 小时前
StarRocks × Paimon C++:加速湖上数据分析
大数据·人工智能