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。

相关推荐
阿拉斯攀登1 小时前
从需求文档到方案-无人售货机纯视觉与免密支付
架构
故七月1 小时前
GEO 信源评估体系:如何判断一条网页信源能否被大模型采信
前端·网络·人工智能
阿拉斯攀登1 小时前
双门两路锁四摄-售货机硬件形态的软件建模
架构
枫叶丹41 小时前
AI 进入日常工作后,任务应该怎样重新拆分
人工智能·chatgpt·开源·agent·codex
码农-0041 小时前
优秀设备预测性维护领域AI Agent开源项目探索
人工智能
AOI小白新手上路1 小时前
深度学习模块缝合方法论 · 人物蒸馏笔记
人工智能·笔记·深度学习
weixin_404551242 小时前
自动化数据库理解:用 AI 与 LLM 自动推断表的用途与关系
数据库·人工智能·自动化
deepseek232 小时前
OpenAI Dots 常驻智能体拆解:聊天免费、主动研究只读、委派才计费,动作分级才是本体
人工智能·openai·agent
人工智能AI技术2 小时前
Laya与Jev深度对比:Agent场景专用判断模型落地实战
人工智能