
规范变了什么,见我的《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 了,怎么排障
请求跨多实例,日志是散的。现在能做的是两件事:
- 结构化日志:每次工具调用打一条,带请求级标识
- 请求级标识 :客户端在
_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。能重试是好事,但工具可能已经被执行了两次------查门店销售这种只读的没事,能产生副作用的工具(下单、改库存)就会出事故。
最低成本的做法两条:
- 客户端给带副作用的调用带一个幂等键(idempotency-key),服务端对同一键只执行一次;
- 工具本身设计成天然幂等(同样的入参,结果一致),副作用操作先查后写。
这条在本机 mock 里演不出来------mock 无副作用------所以它不进"实测"小节,列为清单项,留给你的真实环境验证。
深一层的幂等 + 断点续跑,见第(十六)篇长流程状态管理。
小结
- 迁移完成 ≠ 能上生产:真正的坑集中在补参、状态、兼容三处,都在"迁完之后"才出现
- 无状态换来可扩展性,代价是"回头问"和"记住"这两件事都得搬到外面------搬到哪、要不要搬,是迁完后最花时间的判断
生产落地 Checklist
| # | 项 | 级别 |
|---|---|---|
| 1 | 工具缺参时返回 inputRequired,不抛裸异常 |
必做 |
| 2 | 客户端补参 / 重试逻辑已实现 | 必做 |
| 3 | 多轮上下文已外置,多实例下验证过 | 必做 |
| 4 | 老客户端有兼容路径,可灰度切换 | 必做 |
| 5 | 回滚方案(切回 legacy 端点)随时可执行 | 必做 |
| 6 | 每次工具调用有结构化日志 + 请求级标识 | 必做 |
| 7 | tools/list 配了 ttlMs 缓存 |
建议 |
| 8 | 外部存储(Redis)有降级方案 | 建议 |
| 9 | 无 session 后的排障手册已就绪 | 建议 |
| 10 | 迁移前后压测对比过 | 建议 |
| 11 | 带副作用的工具调用具备幂等保护(幂等键或天然幂等) | 必做 |
📦 本文完整可运行代码 (tag: v08)
- 国内访问 / 点 star:https://gitee.com/ethanliang2016/mcp-in-action
- GitHub(需代理):https://github.com/ethanliang2016/mcp-in-action
- 克隆:
git clone https://gitee.com/ethanliang2016/mcp-in-action.git
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。