1. 这次改版到底动了谁的奶酪
如果你最近半年一直在跟着各种教程折腾 MCP(Model Context Protocol),大概率会有一种"刚学会就过时"的挫败感。我上个月把手上几个基于 MCP 的项目做了一次集中升级,结果发现之前写的 Session 管理逻辑、Sampling 调用链几乎全部要推倒重来。这不是小版本迭代,而是协议层面对"状态"这件事的重新定义——Session 被拿掉了,Sampling 的用法被废掉了,取而代之的是 MRTR 和 Stateless 这两个核心概念。
先说清楚这篇文章适合谁看。如果你只是听说过 MCP 这个名字,知道它是给大模型接工具用的协议,那这篇能帮你建立正确的认知框架,避免学到一堆已经过时的写法;如果你已经在用 MCP 做实际项目,比如把内部系统、数据库、设计工具接进 AI 工作流,那这篇基本就是你的迁移指南,我会把改动的来龙去脉、迁移时的坑、以及我实测下来比较稳的做法都摊开讲。
MCP 本质上是一个让模型和外部能力对话的协议层。你可以把它理解成"AI 世界的 USB 接口标准"——以前每个工具都要为每个模型单独适配,现在大家统一插口。但问题在于,早期设计里塞进了太多"有状态"的假设,比如一次会话要维持上下文、Sampling 要让服务端反向请求模型补全。这些设计在单机 demo 里很优雅,一旦上生产、要做水平扩展、要接多个客户端,立刻就变成灾难。这次重写,本质上是把协议从"有状态的长连接思维"拉回到"无状态的请求响应思维"。
我踩过的第一个坑就是:按照旧教程写的 Session 初始化代码,在新版协议下直接报错,而且报错信息非常隐晦,只告诉你某个字段不存在,不告诉你整个模型已经换了。所以下面我会从"为什么改"讲到"怎么改",再到"改完之后哪些老经验还能用"。
2. Session 消失背后的真实动机
2.1 旧版 Session 到底承担了什么角色
在旧版 MCP 里,Session 是一个贯穿始终的概念。客户端和服务端建立连接后,会创建一个 Session 对象,后续所有的工具调用、资源读取、提示模板获取,都挂在这个 Session 下面。Session 里存了什么?至少包括:连接标识、协商好的能力集、上下文状态、以及 Sampling 的回调通道。
这套设计在本地开发时非常顺手。你启动一个服务端进程,客户端连上来,Session 一建,后面所有交互都有"归属感"。但一旦你要做下面任何一件事,麻烦就来了:
- 水平扩展:用户请求打到不同的服务端实例,Session 不在同一个进程里,状态就丢了。
- 多客户端并发:一个服务端要同时服务多个客户端,Session 隔离做得不好就会串数据。
- 无服务部署:Serverless 环境下进程随时销毁,长连接 Session 根本活不下来。
- 调试和复现:出了 bug 想复现,得先复现整个 Session 的生命周期,成本极高。
我印象特别深的一次,是我们把 MCP 服务端部署到容器里做自动扩缩容,结果用户反馈"有时候工具能用,有时候不能用"。排查了半天才发现,是负载均衡把同一个逻辑会话的请求打到了不同实例,而 Session 状态只存在于其中一个实例的内存里。这种问题在旧模型下几乎无解,只能靠粘性会话硬扛,但粘性会话又和弹性扩缩容天然冲突。
2.2 Stateless 不是"没有状态",而是"状态外置"
很多人一听到 Stateless 就以为"协议不管理状态了",这个理解是错的。准确地说,新版 MCP 把状态的管理责任从协议层转移到了应用层。协议本身只负责定义"一次请求怎么发、一次响应怎么回",至于这次请求和上次请求有没有关系,由你自己通过参数、令牌、或者外部存储来串联。
这个转变带来的直接好处是:服务端变成了纯粹的函数式处理器。给它一个请求,它返回一个结果,不依赖任何进程内的历史状态。你可以随便起多少个实例,随便怎么调度,行为都是一致的。
但代价也很明显:以前 Session 帮你隐式携带的东西,现在你得显式传。比如用户身份、租户标识、上一次操作的上下文,这些都要作为请求参数的一部分,或者通过一个轻量的令牌来关联。我在迁移时做的第一件事,就是设计了一套"请求上下文对象",把原来 Session 里存的东西全部打散,该进令牌的进令牌,该进参数的进参数,该落库的落库。
2.3 迁移时最容易忽略的兼容性断层
这里有个非常现实的坑:新旧协议在传输层可能看起来很像,但语义完全不同。你如果只是把旧代码里的 Session 相关调用删掉,以为就能跑,那大概率会在运行时遇到各种奇怪问题。
我遇到过的典型症状包括:
| 症状 | 表面原因 | 真实原因 |
|---|---|---|
| 工具调用偶发失败 | 网络抖动 | 请求缺少必要的上下文参数 |
| 返回结果错乱 | 服务端 bug | 多个请求共用了隐式状态 |
| 初始化超时 | 服务端慢 | 客户端还在等 Session 握手 |
| 能力协商失败 | 版本不匹配 | 旧版能力声明字段已废弃 |
注意:迁移时不要只看编译是否通过,一定要做端到端的集成测试,尤其是并发场景下的测试。单请求跑通不代表多请求没问题。
我的建议是,迁移前先把旧代码里所有和 Session 生命周期绑定的逻辑列一个清单,逐条确认在新模型下由谁负责。这个清单做完,迁移路径基本就清晰了。
3. Sampling 被废之后,反向调用该怎么写
3.1 旧版 Sampling 的设计意图与致命缺陷
Sampling 在旧版 MCP 里是一个相当"聪明"的设计。它的想法是:服务端在处理工具调用时,如果发现需要模型帮忙做一步推理,可以反向向客户端发起一个 Sampling 请求,让客户端去调用模型,把结果拿回来继续处理。这样服务端就不需要自己持有模型访问凭证,模型调用统一由客户端负责。
听起来很美,但实际用起来问题一堆:
- 调用链变成双向的:客户端调服务端,服务端又调客户端,调试时根本分不清谁在等谁。
- 超时和重试逻辑复杂:反向调用的超时谁来管?失败了谁重试?两边都要实现一套。
- 安全边界模糊:服务端能反向触发模型调用,意味着它间接拥有了模型访问能力,权限模型很难收敛。
- 流式场景几乎不可用:一旦涉及流式输出,双向调用的协调成本高到离谱。
我在一个需要多步推理的工具里用过 Sampling,结果光是处理"服务端等客户端、客户端等模型、模型超时后服务端还在等"这种嵌套超时,就写了一堆状态机代码。后来干脆放弃,改成服务端自己调模型,虽然违背了原设计意图,但至少逻辑清晰。
3.2 MRTR 接管了什么
MRTR 是这次重写里替代 Sampling 的核心机制。它的思路和 Sampling 完全相反:不再让服务端反向调用客户端,而是把"需要模型参与"这件事变成一次普通的请求-响应往返。
具体来说,当服务端在处理过程中需要模型补全时,它不再发起反向调用,而是返回一个明确的"需要继续处理"的响应,里面带上它需要的输入描述。客户端收到后,自己去调模型,拿到结果,再发起一次新的请求,把结果带回来。服务端收到后继续处理。
这个模型的好处是:
- 调用方向永远是单向的:客户端到服务端,没有反向通道。
- 每次交互都是独立的:不依赖任何长连接状态,天然支持无状态部署。
- 超时和重试逻辑简单:每次请求都是普通请求,用标准的重试策略就行。
- 流式场景友好:每一轮都可以独立流式输出。
代价是交互轮次变多了。原来一次 Sampling 能搞定的事,现在可能要两到三次往返。但考虑到可维护性和可扩展性,这个代价完全值得。
3.3 用 MRTR 重写一个多步推理工具
我拿一个实际场景来演示。假设有个工具叫"分析日志并给出修复建议",流程是:先读取日志,然后让模型分析,最后根据分析结果查知识库。
旧版写法(伪代码):
def analyze_log(session, log_path): log_content = read_file(log_path) # 反向 Sampling 让模型分析 analysis = session.sample( prompt=f"分析以下日志:{log_content}" ) # 根据分析结果查知识库 suggestions = query_knowledge_base(analysis) return suggestions新版 MRTR 写法:
def analyze_log(request): log_content = read_file(request.params["log_path"]) # 返回"需要模型分析"的信号 return { "status": "needs_model_input", "input_schema": { "type": "object", "properties": { "analysis": {"type": "string"} } }, "context": { "log_content": log_content } } def analyze_log_continue(request): # 客户端把模型分析结果带回来了 analysis = request.params["analysis"] log_content = request.context["log_content"] suggestions = query_knowledge_base(analysis) return {"status": "completed", "result": suggestions}看起来代码变多了,但每一段都是纯函数,没有隐藏状态,测试起来非常轻松。我实测下来,这种写法在并发场景下的稳定性比旧版高了一个数量级。
3.4 迁移 Sampling 逻辑时的三个实操要点
第一,把"服务端主动"改成"客户端主动"。原来服务端里所有sample()调用,都要拆成"返回请求 + 接收结果"两段。这个拆分一开始会觉得别扭,但拆完你会发现逻辑反而更清晰了。
第二,上下文要显式传递。旧版 Sampling 时,服务端还能访问 Session 里的上下文。新版里,第一段返回时要把后续需要的上下文放进响应里,客户端在第二段请求时原样带回来。我一般会用一个context字段专门装这些中间数据。
第三,注意幂等性。因为交互轮次变多,客户端可能因为超时重试而重复发起第二段请求。服务端要保证同一个上下文重复处理不会产生副作用。我的做法是给每个上下文生成一个唯一 ID,服务端记录已处理过的 ID,重复的直接返回缓存结果。
4. 无状态化之后,身份和上下文往哪放
4.1 从 Session 里搬出来的东西分类
Session 消失后,原来存在里面的东西需要重新找地方。我把它们分成四类,每类的处理方式不同:
- 身份凭证类:用户是谁、有什么权限。这类东西适合放在请求的认证头里,每次请求都带,服务端无状态校验。
- 会话上下文类:这次对话的历史、之前调用了哪些工具。这类东西适合放在客户端,每次请求时按需带上相关部分。
- 临时中间数据类:MRTR 多轮交互中的中间结果。这类东西适合放在响应和请求的
context字段里,跟着交互走。 - 配置和能力类:服务端支持哪些工具、客户端支持哪些能力。这类东西适合在初始化时协商一次,之后作为常量使用。
这个分类做完,你会发现真正需要"状态"的东西其实很少,大部分都是可以外置的。
4.2 用轻量令牌串联多轮交互
对于需要跨多轮交互保持关联的场景,我推荐用一个轻量的令牌机制。不是传统意义上的 Session ID,而是一个自包含的、带签名的上下文令牌。
具体做法是:服务端在第一轮响应里返回一个令牌,令牌里编码了必要的上下文信息(比如用户 ID、租户 ID、交互轮次),并用服务端密钥签名。客户端在后续请求里带上这个令牌,服务端验签后解出上下文,继续处理。
这样做的好处是:
- 服务端不需要存储任何会话数据,验签即可。
- 令牌可以设置过期时间,天然支持超时清理。
- 令牌内容对客户端不透明,安全性有保障。
我用这套机制替换了原来的 Session 存储,服务端从"有状态服务"变成了"纯计算服务",部署和扩缩容的复杂度直接降了一个档次。
4.3 并发场景下的上下文隔离实测
无状态化之后,并发隔离反而变得简单了。因为每个请求都是独立的,不存在共享状态,自然不会串数据。但有一个地方要注意:如果服务端有外部依赖(比如数据库连接池、缓存客户端),这些依赖本身是有状态的,要做好并发控制。
我做过一个压力测试,用 200 个并发请求打同一个 MCP 服务端,每个请求带不同的上下文令牌。旧版 Session 模型下,偶尔会出现上下文串扰;新版无状态模型下,跑了半小时零错误。这个对比很能说明问题。
提示:无状态不等于无依赖。数据库、缓存、消息队列这些外部依赖的状态管理,仍然需要你认真对待。
5. 老教程里哪些经验还能用,哪些必须扔
5.1 仍然有效的部分
不是所有老经验都过时了。下面这些在旧版和新版里都适用:
- 工具描述要清晰:工具的 name、description、参数 schema 怎么写,新旧版要求一致。描述写得好,模型调用准确率就高。
- 错误处理要规范:返回结构化错误,而不是抛异常。这个原则没变。
- 能力协商要显式:客户端和服务端各自支持什么,要明确声明。只是声明的字段和时机变了。
- 资源读取要幂等:读操作应该无副作用,这个原则在新版里更重要,因为重试变多了。
5.2 必须扔掉的部分
下面这些是旧版特有、新版已经废弃的:
- Session 初始化握手:新版没有 Session 概念,不需要握手。
- 反向 Sampling 调用:用 MRTR 替代。
- 长连接维持:新版推荐用普通请求-响应,不需要维持长连接。
- 服务端主动推送:新版里服务端不主动发起任何东西,所有交互由客户端发起。
我见过有人在新版协议上硬套旧版的 Session 管理,结果写出来的代码又复杂又不稳定。这种"新瓶装旧酒"的做法,还不如直接用旧版。
5.3 一份可对照的迁移检查清单
| 检查项 | 旧版做法 | 新版做法 |
|---|---|---|
| 连接建立 | Session 握手 | 直接发请求 |
| 身份传递 | Session 内隐式携带 | 请求头显式携带 |
| 上下文保持 | Session 存储 | 令牌或参数传递 |
| 模型调用 | 反向 Sampling | MRTR 多轮往返 |
| 并发隔离 | Session 隔离 | 请求天然隔离 |
| 超时处理 | 双向超时协调 | 标准请求超时 |
| 部署模式 | 有状态服务 | 无状态服务 |
这张表我贴在工位上用了两周,迁移时逐项对照,基本没漏东西。
6. 我踩过的三个真实坑和修复过程
6.1 坑一:以为删掉 Session 代码就完事了
第一次迁移时,我的做法很粗暴:把所有session.xxx的调用删掉,把sample()换成直接调模型。结果跑起来发现,工具调用偶尔会返回上一个请求的结果。排查了很久才意识到,虽然我删掉了显式的 Session 代码,但服务端框架底层还在用线程本地存储(ThreadLocal)缓存上下文,并发时串了。
修复方式是彻底移除所有隐式状态,包括框架层的缓存。具体做法是:把服务端处理函数改成纯函数,所有输入从请求参数取,所有输出通过返回值给,中间不写任何全局或线程本地变量。改完之后,串数据的问题再没出现过。
这个坑的教训是:无状态化不是删代码,而是改思维。只要还有任何隐式状态存在,无状态化的好处就享受不到。
6.2 坑二:MRTR 轮次设计不合理导致死循环
MRTR 的多轮交互有个隐患:如果服务端返回"需要模型输入"后,客户端带回来的结果仍然不满足要求,服务端可能再次返回"需要模型输入",形成循环。
我在一个需要模型做结构化抽取的工具里遇到过这个问题。模型第一次返回的格式不对,服务端要求重试,模型第二次还是不对,又重试,来回好几次,最后超时。
修复方式是加轮次上限和降级策略。具体来说:
MAX_ROUNDS = 3 def handle_request(request): round_num = request.context.get("round", 0) if round_num >= MAX_ROUNDS: # 降级:返回部分结果或默认值 return {"status": "completed", "result": fallback_result(request)} # 正常处理 ...同时,在要求模型重试时,把上一次的错误信息也带上,帮助模型修正。这两个措施加上后,死循环问题基本消失。
6.3 坑三:令牌过期时间设置不当
用令牌串联多轮交互时,过期时间设多长是个问题。设太短,用户操作慢一点令牌就失效了;设太长,安全性又打折扣。
我一开始设了 5 分钟,结果发现有些复杂工具调用加上模型推理,很容易超过 5 分钟,用户就遇到"令牌过期"的报错。后来改成滑动过期:每次请求都刷新令牌的过期时间,只要用户还在操作,令牌就一直有效;用户停止操作超过 15 分钟,令牌才真正失效。
这个策略在安全性和可用性之间取得了比较好的平衡。实现上也不复杂,就是在验签通过后,重新签发一个过期时间延后的令牌返回给客户端。
7. 无状态 MCP 服务的部署与压测体会
7.1 部署形态的变化
无状态化之后,MCP 服务端的部署形态可以非常灵活。我现在用的是最普通的容器化部署,前面挂负载均衡,后面按 CPU 使用率自动扩缩容。因为服务端不存任何状态,扩缩容时不需要考虑会话迁移,新实例起来就能直接服务。
对比旧版,以前部署时要考虑:
- 粘性会话配置
- 会话状态的外部存储(Redis 之类)
- 实例下线时的会话迁移
- 扩缩容时的会话预热
现在这些全都不需要了。部署配置简化了一大半,运维成本明显下降。
7.2 压测数据与瓶颈定位
我做了一轮压测,对比新旧模型下的表现。测试场景是同一个工具调用,并发从 10 逐步加到 500。
| 并发数 | 旧版 QPS | 新版 QPS | 旧版错误率 | 新版错误率 |
|---|---|---|---|---|
| 10 | 85 | 92 | 0% | 0% |
| 50 | 210 | 380 | 0.5% | 0% |
| 100 | 280 | 650 | 2.1% | 0% |
| 200 | 310 | 890 | 5.8% | 0.1% |
| 500 | 320 | 950 | 12.4% | 0.3% |
新版在高并发下的优势非常明显。旧版的瓶颈主要在会话状态同步上,并发越高,同步开销越大,错误率也越高。新版没有这个问题,QPS 随并发线性增长,直到触及 CPU 瓶颈。
7.3 监控指标该看哪些
无状态服务的监控和传统服务不太一样。我重点关注这几个指标:
- 请求延迟分布:不只看平均值,要看 P95 和 P99,因为无状态服务的延迟应该很稳定,长尾延迟往往意味着外部依赖有问题。
- MRTR 轮次分布:统计每个请求平均经过几轮 MRTR 交互,轮次异常升高通常意味着模型输出质量下降或工具描述有问题。
- 令牌验签失败率:这个指标升高说明客户端令牌管理有问题,或者密钥轮换没做好。
- 外部依赖错误率:数据库、缓存、模型服务的错误率,这些是无状态服务的主要故障源。
我把这几个指标做成了看板,日常巡检基本够用。
8. 写给还在用旧教程的人
如果你手上的项目还在用旧版 MCP,我的建议是:不要急着全量迁移,但一定要开始做技术预研。旧版在短期内还能跑,但生态会逐渐向新版倾斜,工具链、文档、社区示例都会更新。等到你不得不迁的时候再动手,成本会高很多。
迁移的优先级我建议这样排:先迁那些并发压力大、扩展需求强的服务;再迁那些逻辑简单、迁移成本低的工具;最后处理那些依赖 Sampling 深度、迁移复杂的部分。每迁一个,做一轮完整的回归测试,确保行为一致。
还有一个很实际的建议:把协议版本作为配置项管理起来。不要硬编码在代码里,这样将来协议再演进时,切换成本会低很多。我在项目里加了一个mcp_protocol_version的配置,服务端根据这个配置决定用哪套处理逻辑,过渡期非常有用。
最后说一句掏心窝的话:MCP 这次重写,短期看是给大家添了麻烦,长期看是把一个 demo 级协议推向了生产级。Session 和 Sampling 的移除,换来的是可扩展、可测试、可运维。我迁移完之后最大的感受是,代码变简单了,出问题时排查路径也清晰了。这种"先痛后快"的升级,值得认真对待。