1. 这次重写到底动了什么:从 Session 到 Stateless 的底层逻辑
MCP 把自己推翻重写这件事,在圈子里其实没引起太大动静,但我身边几个真正在生产环境里跑 MCP 服务的朋友,反应都挺一致:早该这么干了。原因很简单,之前那套基于 Session 的设计,在单机 demo 里看着优雅,一旦上规模、上容器、上多副本,问题就全冒出来了。这次重写的核心,就是把Session 这个状态载体彻底拿掉,转向Stateless的请求模型,同时把Sampling这个曾经被寄予厚望的能力废掉,换成了MRTR(Multi-Round Tool Resolution,多轮工具解析)的思路。
先说清楚 MCP 是什么。MCP 全称 Model Context Protocol,是一套让模型和外部工具、数据源之间建立标准通信的协议。你可以把它理解成模型世界的 USB-C 接口——不管对面是数据库、文件系统、还是某个业务系统,只要按 MCP 的规范暴露能力,模型就能统一调用。这个定位决定了它必须足够轻、足够稳、足够好扩展,而旧版的 Session 机制恰恰在这三点上都拖了后腿。
旧版 MCP 的工作方式是这样的:客户端先和服务器建立一个 Session,服务器在内存里维护这个 Session 的上下文,包括已注册的工具列表、认证状态、临时缓存等。后续所有请求都挂在这个 Session 上。听起来没问题,但实际部署时会遇到几个硬伤。第一,水平扩展困难。你有三个 MCP 服务副本,客户端第一次连到副本 A 建立了 Session,第二次请求被负载均衡打到副本 B,B 根本不认识这个 Session,直接报错。要解决就得做 Session 共享,引入 Redis 之类的集中存储,架构复杂度立刻上一个台阶。第二,连接恢复成本高。网络抖动导致连接断开,Session 就没了,客户端得重新握手、重新拉工具列表,用户体验很差。第三,调试和排查困难。Session 状态散落在服务端内存里,出问题时你很难复现当时的上下文。
新版直接把这些全砍了。每个请求都是自包含的,该带的认证信息、该带的上下文,全部在请求里带齐。服务端不再维护任何跨请求的状态,来一个请求处理一个,处理完就忘。这就是Stateless的核心含义。带来的好处非常直接:任意副本都能处理任意请求,负载均衡随便打;服务重启不影响客户端,因为压根没有需要恢复的会话;排查问题时,一个请求的完整信息就在那一个请求里,抓包就能看清全部。
那 Sampling 为什么废了?Sampling 原本的设计意图是让 MCP 服务端能够反向请求模型做一些采样或生成,比如服务端在处理工具调用时,需要模型帮忙判断一下该走哪个分支。这个能力听起来很美好,但实际用起来问题一大堆。首先是循环依赖风险,服务端调模型,模型又调服务端,很容易绕进去。其次是责任边界模糊,到底谁该为这次采样负责、计费算谁的、超时怎么算,全是扯皮的事。最后是实现复杂度爆炸,每个客户端都要实现一套反向调用的通道,兼容性极差。新版用 MRTR 替代,思路变成:服务端不主动调模型,而是把"我还需要一轮信息"这个诉求作为响应返回给客户端,由客户端决定要不要发起下一轮。控制权回到客户端手里,链路清晰,责任明确。
这里有个关键点很多人没注意到:Stateless 不等于无状态设计。服务端当然可以有缓存、有连接池、有内部状态,但这些状态不能和某个特定客户端的会话绑定。换句话说,状态可以是全局共享的、可重建的,但不能是会话私有的、不可恢复的。这个区分很重要,搞混了就会写出"伪 Stateless"的代码——表面上没有 Session 对象,实际上在内存里偷偷维护了一个 map,key 是客户端标识,那和旧版没本质区别。
我实测下来,改成 Stateless 之后,最直观的变化是部署脚本简单了一大截。以前要配 Session 亲和性、要挂共享存储、要考虑优雅下线时怎么迁移会话,现在这些全删了。K8s 里就是一个无状态 Deployment,副本数随便调,滚动更新毫无压力。这个收益在生产环境里是实打实的。
2. 新旧协议对照:你学的教程为什么过期了
现在网上能搜到的 MCP 教程,绝大多数还停留在旧版模型上。你去照着搭,会发现两个典型症状:要么代码里到处是session_id的传递,要么在纠结 Sampling 怎么配置。这些教程不是错,是过期了。我把新旧两版的关键差异整理成一张表,你对照着看就明白自己手上的资料是哪一代的。
| 维度 | 旧版(Session 模型) | 新版(Stateless 模型) |
|---|---|---|
| 连接建立 | 先握手建 Session,后续复用 | 无握手,每请求独立 |
| 状态维护 | 服务端内存维护会话上下文 | 服务端不维护会话状态 |
| 工具列表获取 | 建 Session 时一次性拉取 | 每请求可携带,或客户端缓存 |
| 反向调用 | Sampling,服务端主动调模型 | MRTR,服务端返回诉求由客户端决策 |
| 水平扩展 | 需 Session 共享或亲和性 | 任意副本可处理 |
| 断线恢复 | 需重新握手 | 无需恢复,直接重发请求 |
| 认证方式 | 常绑定在 Session 上 | 每请求携带凭证 |
| 调试难度 | 高,状态分散 | 低,请求自包含 |
看这张表你就明白了,为什么很多老教程里的"最佳实践"现在变成了"反模式"。比如旧版推荐在 Session 建立时缓存工具列表,减少后续请求体积。新版里这个缓存要么放客户端,要么每请求带,服务端不背这个锅。再比如旧版处理认证,很多实现是把 token 和 Session 绑定,Session 有效期内不用重复校验。新版必须每请求校验,虽然多了点开销,但换来的是无状态,这笔账划算。
MRTR 到底怎么工作,这里展开说一下,因为这是新版里最容易被误解的部分。假设客户端发起一个工具调用请求,服务端处理到一半发现信息不够,比如需要用户确认某个参数,或者需要模型再判断一次。旧版的做法是服务端通过 Sampling 通道反向请求模型,拿到结果继续处理,整个过程对客户端是黑盒。新版的做法是服务端直接返回一个特殊响应,大意是"我需要你再提供 X 信息"或者"我需要你先完成 Y 步骤"。客户端收到后,决定是补充信息重发,还是终止流程。这个"多轮"就体现在这里——一次工具解析可能需要客户端和服务端来回几轮,但每一轮都是独立的、自包含的请求。
这个设计的好处在于可观测性。旧版 Sampling 的反向调用藏在服务端内部,你在客户端侧只能看到最终结果,中间发生了什么完全不知道。新版每一轮都是显式的请求响应,你在客户端就能看到完整的交互链路,出问题一眼就能定位是哪一轮、哪个环节。对于生产环境排查问题,这个价值太大了。
还有个细节,新版对工具列表的动态性支持更好。旧版工具列表在 Session 建立时确定,中途服务端加了新工具,已建立的 Session 看不到。新版每请求都可以重新解析工具列表,服务端随时上下线工具,客户端下一轮就能感知。这对于工具频繁变动的场景,比如插件化系统,是刚需。
我踩过的一个坑是:刚开始迁移时,习惯性地在客户端维护了一个"当前会话"的概念,把工具列表缓存在里面,结果服务端工具更新了,客户端还在用旧的,调用报错。后来改成每次请求前根据场景决定是否刷新工具列表,或者给缓存加一个较短的 TTL,问题才解决。这个经验说明,Stateless 要求客户端也调整心态,不能再依赖服务端帮你维护上下文。
3. 迁移实操:把旧代码改成 Stateless 的完整步骤
光讲概念没用,直接上迁移步骤。我拿一个典型的旧版 MCP 服务端实现来改,你可以对照自己的代码同步操作。假设你有一个基于 Python 的 MCP 服务,旧版大概长这样:启动时初始化一个 SessionManager,每个客户端连接进来分配一个 session_id,后续请求都带着这个 id 来查上下文。
3.1 第一步:干掉 SessionManager
旧代码里通常有一个全局的 SessionManager,负责创建、查询、销毁会话。迁移的第一步就是把这个东西整个删掉。别想着保留它做兼容,留着就是隐患,早晚有人会去用。删掉之后,所有依赖get_session(session_id)的地方都会报错,这些报错点就是你需要改的地方,正好当检查清单用。
删掉之后,原来存在 Session 里的东西要重新安排去处。我列一下常见的几类:
- 认证信息:改成每请求从 header 或请求体中解析,解析完即用即弃。
- 工具列表:改成每请求动态生成,或者由客户端携带。
- 临时缓存:如果确实是跨请求需要的,改成全局缓存加合理的 key,注意 key 不能是 session_id。
- 用户偏好:这类个性化数据要么放客户端,要么放独立的配置服务,不要塞进 MCP 服务端。
3.2 第二步:请求体补全自包含信息
旧版请求可能只带一个 session_id 加少量参数,因为大部分上下文服务端都有。新版要求每个请求自包含,所以请求体要补全。以工具调用为例,新版请求至少应该包含:认证凭证、要调用的工具名、工具参数、以及可选的上下文提示。下面是一个请求体的示例结构:
{ "auth": { "token": "xxxxx", "timestamp": 1730000000 }, "tool": "query_database", "params": { "sql": "select * from users limit 10" }, "context": { "trace_id": "abc-123", "client_version": "2.0.0" } }注意trace_id这个字段,Stateless 之后排查问题全靠它。每个请求带一个唯一 id,服务端日志里打出来,出问题时拿这个 id 去日志系统一搜,整条链路清清楚楚。这个习惯一定要养成,比 Session 时代的调试体验好太多。
3.3 第三步:把 Sampling 调用改成 MRTR 响应
这是改动量最大的一块。旧代码里如果有调用 Sampling 的地方,比如:
# 旧版:服务端主动调模型 result = sampling_client.sample(prompt="这个参数该填什么?")新版要改成返回一个 MRTR 响应,告诉客户端"我需要更多信息":
# 新版:返回 MRTR 响应,由客户端决策 return { "status": "need_more_info", "reason": "missing_parameter", "required": ["target_table"], "hint": "请提供目标表名" }客户端收到这个响应后,可以选择补充参数重发请求,也可以直接终止。控制权在客户端,服务端只负责表达诉求。这个改动看起来简单,但需要重新梳理整个交互流程,因为原来藏在服务端内部的往返,现在都暴露到客户端和服务端之间了。
3.4 第四步:认证改成无状态校验
旧版认证往往和 Session 绑定,登录一次,Session 有效期内都算已认证。新版每请求都要校验。这里有个性能考量:如果每请求都去查数据库或调认证服务,开销不小。常见的优化是用自包含的令牌,比如 JWT,服务端本地就能验签,不用外部依赖。令牌里带上必要的声明,比如用户 id、权限范围、过期时间,服务端验签通过就直接用。
注意:无状态认证不等于不校验。我见过有人为了省事,在 Stateless 改造后直接把认证逻辑删了,理由是"反正没 Session 了"。这是严重的安全漏洞,千万别这么干。
3.5 第五步:压测验证无状态特性
改完之后必须压测,而且要专门验证无状态特性。具体做法是:起多个服务副本,用负载均衡随机分发请求,看是否有请求因为"找不到会话"而失败。如果全部成功,说明无状态改造到位。再做一个测试:压测过程中随机重启某个副本,看客户端是否有请求失败。理论上不应该有失败,因为请求打到其他副本照样能处理。
我当时的压测脚本大概是这样,用 Python 的并发库模拟多客户端:
import concurrent.futures import requests def call_mcp(i): resp = requests.post("http://mcp-service/execute", json={ "auth": {"token": "test-token"}, "tool": "echo", "params": {"msg": f"req-{i}"} }) return resp.status_code with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor: results = list(executor.map(call_mcp, range(1000))) print(f"成功: {results.count(200)}, 失败: {len(results) - results.count(200)}")跑下来 1000 个请求全成功,且压测中途重启副本也没有失败,才算过关。
4. 常见问题与排查技巧实录
迁移过程中遇到的问题,我整理成了一张速查表,基本都是实际踩过的,你对照着排查能省不少时间。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 请求随机失败,报"会话不存在" | 还有残留的 Session 依赖 | 全局搜索 session_id 关键字 | 彻底清除会话相关代码 |
| 工具列表不更新 | 客户端缓存了旧列表 | 检查客户端缓存逻辑 | 加 TTL 或每请求刷新 |
| 认证偶发失败 | 令牌过期或时钟偏移 | 检查令牌 exp 和服务器时间 | 同步时钟,合理设置有效期 |
| MRTR 循环不终止 | 客户端未正确处理 need_more_info | 打印每轮响应 | 加最大轮次限制 |
| 压测时性能下降 | 每请求重复初始化重资源 | 检查是否有连接池 | 用全局连接池,注意线程安全 |
| 日志无法关联 | 缺少 trace_id | 检查请求体 | 强制每请求带 trace_id |
重点说几个容易忽略的。MRTR 循环不终止这个问题很隐蔽,因为客户端如果没正确处理need_more_info响应,可能会一直重发同样的请求,服务端一直返回同样的诉求,死循环。解决办法是在客户端加一个最大轮次限制,比如最多 5 轮,超过就报错终止。这个限制值根据业务定,一般 3 到 5 轮够用了。
性能下降也是常见问题。Stateless 之后每请求都要做认证、解析工具列表,如果这些操作里有重资源初始化,比如每次新建数据库连接,性能会明显下降。解决办法是把这些重资源做成全局的、线程安全的池子,请求来了从池里取,用完还回去。注意池子本身不能和会话绑定,否则又回到老路了。
还有个坑是工具列表的动态解析开销。如果工具列表很大,每请求都重新生成一遍,CPU 开销不小。优化思路是加一层缓存,但缓存的 key 不能是会话,可以是"工具版本号"或者"租户 id"这类全局维度。工具没变就复用缓存,变了就重建。这样既保证动态性,又控制开销。
提示:迁移期间建议保留旧版接口一段时间做灰度,新老并行,确认新版稳定后再下线旧版。直接一刀切风险太大。
最后分享一个排查技巧:Stateless 之后,请求就是最好的调试单元。遇到问题,先把出问题的那个请求完整抓下来,包括 header、body、trace_id,然后拿 trace_id 去日志系统搜,整条链路一目了然。这个体验比 Session 时代强太多,Session 时代你还要先想办法复现当时的会话状态,往往复现不出来。所以迁移完之后,一定要把 trace_id 机制建起来,这是 Stateless 架构下最重要的可观测性基础设施。
我个人在实际操作中的体会是,这次重写表面上是删功能,实际上是把复杂度从服务端转移到了协议层和客户端。服务端变简单了,但客户端要承担更多决策责任,协议要表达更丰富的信息。这个转移是值得的,因为服务端通常是最难扩展、最难运维的部分,把它做简单,整体收益远大于客户端的额外工作量。至于那些还停在旧版教程上的资料,建议直接跳过,从新版协议文档重新学起,别在过时的东西上浪费时间。