1. 这次重写到底动了什么:从“有状态会话”到“无状态请求”
MCP 把自己推翻重写这件事,我第一反应是去翻自己去年写的那套接入笔记,结果发现里面一大半内容已经对不上了。核心变化就两个词:Session 没了、Sampling 废了。如果你手上还留着 2025 年那批教程,照着敲大概率会在第一步就卡住——不是你的环境问题,是协议本身换了骨架。
先说清楚 MCP 是什么,避免有刚入门的同学看懵。MCP 全称 Model Context Protocol,是一套让模型和外部工具、数据源之间标准化对话的协议。你可以把它理解成“模型世界的 USB-C 接口”:以前每接一个工具就要写一套私有适配,现在大家约定一个插头形状,谁都能插。这个类比不新鲜,但确实准确。
那这次重写到底改了什么?一句话概括:从“长连接 + 会话保持”转向“无状态请求 + 显式上下文传递”。以前客户端和服务端建立连接后会维持一个 Session,双方在这个会话里交换能力、缓存状态、维持采样通道;现在这套机制被拆掉了,每次请求都要自带上下文,服务端不再替你记任何东西。
为什么这么改?我自己的判断是三个现实压力逼出来的。第一是横向扩展,有状态会话意味着请求必须粘在同一个实例上,扩容时状态迁移极其痛苦,无状态之后任何实例都能处理任何请求,负载均衡终于能正常工作了。第二是部署形态多样化,MCP 服务不再只跑在长驻进程里,边缘函数、短生命周期容器、甚至一次性的任务执行环境都要能承载,这些环境根本活不过一个会话的生命周期。第三是调试和可观测性,有状态的东西出问题最难查,因为“上一次请求做了什么”会污染“这一次请求”,无状态之后每个请求都是自解释的,日志一看就明白。
至于 Sampling 被废,这个改动更微妙。Sampling 原本是让服务端在需要的时候反向请求客户端去调用模型,相当于服务端能“借”客户端的模型能力。听起来很美,实际用起来问题一堆:权限边界模糊、计费归属不清、递归调用容易失控、超时链路极长。我见过不止一个团队在这上面踩坑,服务端一个 Sampling 请求发出去,客户端那边模型排队,最后超时返回,两边日志对不上,排查半天。现在这套机制被 MRTR 取代,方向从“服务端反向借模型”变成“服务端明确告诉客户端我需要什么,客户端自己决定怎么满足”。
这里必须提一下MRTR,这是新架构里的关键概念。MRTR 我理解成“多轮请求-响应”的显式化:以前靠 Session 隐式维持的多轮交互,现在变成协议层面显式定义的请求序列。每一次往返都是一个完整的、可独立理解的请求,上下文通过参数传递而不是靠会话记忆。这个设计的好处是链路清晰,坏处是你得自己管上下文了——这正是很多人升级后第一个不适应的地方。
Stateless这个词在热搜里出现不是偶然。无状态是整个重写的底层哲学,Session 和 Sampling 的移除都是它的推论。理解了无状态,你就能预判后面所有的 API 变化方向:凡是依赖“服务端记住点什么”的设计,都会被改成“请求里带全信息”。
提示:如果你现在维护的 MCP 服务依赖 Session 做鉴权缓存或能力协商,升级前务必先梳理清楚哪些状态是“必须跨请求保留”的,这些状态要么下沉到请求参数,要么外置到独立的存储层,不能指望协议帮你兜着。
2. Session 移除后的连锁反应:鉴权、上下文、并发全要重做
Session 没了,最直接受冲击的是鉴权。以前很多实现是“首次连接时握手鉴权,之后靠 Session 标识复用身份”,现在每次请求都得自带凭证。这不是简单地加个头就完事,因为凭证的传递方式、有效期管理、刷新逻辑全变了。
我实测下来,比较稳妥的做法是把鉴权信息放进每个请求的元数据里,配合短时效令牌。令牌有效期建议控制在分钟级,因为无状态下你没法主动“注销”一个会话,只能靠过期来收敛风险。刷新逻辑放在客户端,服务端只做校验,这样职责清晰。如果你还在用长效令牌图省事,一旦泄露就是长期敞口,无状态架构下这个风险被放大了。
上下文传递是第二个大坑。以前 Session 里可以缓存“这个用户上次查了什么”“当前对话进行到第几轮”,现在这些都得显式带上。我的经验是只传必要的、不可重建的上下文,能通过 ID 重新查出来的东西就别塞进请求体,否则请求会越来越臃肿。举个具体例子:多轮对话场景下,你不需要把完整历史都传过去,传一个对话 ID 加上最近一轮的关键信息就够了,服务端按需回查。
并发行为的变化也值得单独说。有状态会话天然串行化了同一会话内的请求,你不用担心两个请求同时改同一份状态。无状态之后,同一个逻辑会话的多个请求可能被分发到不同实例并行执行,竞态条件从“不太可能”变成“随时可能”。我踩过一次坑:两个请求几乎同时到达,都基于同一个旧状态做增量更新,结果后写的覆盖了先写的。解决办法要么在业务层加乐观锁(版本号比对),要么把需要串行的操作收敛到单一入口。
下面这张表是我整理的 Session 移除前后对照,方便你快速定位自己代码里哪些地方要改:
| 维度 | 旧架构(有 Session) | 新架构(无状态) | 改造要点 |
|---|---|---|---|
| 鉴权 | 握手时鉴权,Session 复用 | 每请求自带凭证 | 短时效令牌 + 客户端刷新 |
| 上下文 | 服务端缓存会话状态 | 请求显式携带 | 只传不可重建的信息 |
| 并发 | 同会话串行 | 可能并行 | 乐观锁或收敛入口 |
| 扩容 | 需粘性会话 | 任意实例 | 负载均衡策略简化 |
| 调试 | 状态污染难查 | 请求自解释 | 日志按请求 ID 聚合 |
还有一点容易被忽略:错误处理语义变了。以前 Session 失效会有一个明确的“会话过期”错误,客户端可以据此重新握手。现在没有会话概念,错误更多是“凭证无效”“上下文缺失”这类,客户端需要根据错误类型决定是重试、刷新凭证还是补上下文。我建议在客户端封装一层错误分类逻辑,别让上层业务代码去判断原始错误码。
注意:无状态不等于无脑重试。对于非幂等操作,重试前必须确认服务端是否支持幂等键,否则一次网络抖动可能导致重复执行。这个坑我在生产环境见过,代价不小。
3. Sampling 退场与 MRTR 登场:反向调用为什么被砍掉
Sampling 被废这件事,我觉得是这次重写里最需要花时间理解的部分,因为它不只是删了个功能,而是改变了客户端和服务端的权力结构。
旧架构里 Sampling 的本质是服务端拥有发起模型调用的能力。服务端在处理请求时,如果发现需要模型帮忙(比如要做个摘要、要生成个查询语句),它可以反向请求客户端:“帮我调一下模型,提示词我给你。”客户端收到后去调模型,再把结果回传。这个设计在 demo 里很优雅,但生产环境里问题成堆。
第一个问题是权限边界。服务端凭什么能触发模型调用?这个调用算谁的成本?如果服务端被恶意控制,它能不能通过 Sampling 让客户端疯狂调模型烧钱?这些在协议层面没有很好的答案。第二个问题是递归风险,服务端调模型,模型可能又触发工具调用,工具调用又回到服务端,链路一长就容易失控。第三个问题是超时管理,Sampling 的往返链路比普通请求长得多,超时设置很难兼顾,设短了误杀,设长了拖垮整体响应。
MRTR 的思路完全不同。它不再让服务端“反向借能力”,而是让服务端明确声明自己的需求,由客户端决定如何满足。具体来说,服务端在响应里可以带上“我需要一个模型生成的 X”这样的结构化描述,客户端拿到后自己决定是调模型、查缓存还是走别的路径,然后把结果作为下一次请求的输入传回去。控制权完全在客户端手里。
这个转变带来的实际影响是:你的服务端代码里所有依赖 Sampling 的地方都要重写成“声明需求 + 等待输入”的模式。我改造时发现,原来一段 Sampling 调用大概十行代码,改成 MRTR 模式后变成“返回需求描述”加“处理回传输入”两段,代码量增加了,但逻辑清晰多了,因为每一步都是显式的,不再有隐藏的反向调用。
MRTR 的“多轮”特性也值得展开。一次完整的交互可能包含多个往返:客户端发请求 → 服务端返回需求 → 客户端满足需求 → 客户端带结果再请求 → 服务端返回最终结果。每一轮都是独立的 HTTP 请求(或等价的传输),没有隐式状态。这就要求你在客户端维护一个“交互状态机”,知道当前进行到哪一轮、还缺什么输入。
我整理了一个 MRTR 交互的典型时序,用文字描述:第一轮客户端发起初始请求,服务端判断需要额外信息,返回一个带需求标记的响应;客户端解析需求,调用相应能力(模型、数据库、外部 API),把结果组装成第二轮请求;服务端拿到结果,如果还需要更多信息就继续返回需求,否则返回最终响应。整个过程中,服务端不持有任何跨请求状态,所有中间结果都在客户端手里。
提示:MRTR 模式下,客户端成了状态的实际持有者。这意味着客户端的可靠性直接决定交互成败,建议在客户端做好中间结果的持久化,避免进程崩溃导致整个交互丢失。
对比一下 Sampling 和 MRTR 的核心差异:
| 维度 | Sampling(旧) | MRTR(新) |
|---|---|---|
| 发起方 | 服务端反向发起 | 服务端声明需求,客户端发起 |
| 控制权 | 服务端 | 客户端 |
| 状态持有 | 服务端会话 | 客户端 |
| 权限边界 | 模糊 | 清晰,客户端决定 |
| 超时管理 | 长链路难控 | 每轮独立可控 |
| 递归风险 | 高 | 低,客户端可拦截 |
4. 迁移实操:把 2025 年的教程代码改成新架构
光讲概念没用,我拿一段典型的旧代码来演示怎么改。假设你有一个 MCP 服务,功能是“根据用户问题查询数据库并返回结果”,旧实现里用了 Session 存用户身份、用了 Sampling 生成查询语句。
旧代码大概长这样(伪代码):
# 旧架构:依赖 Session 和 Sampling def handle_request(session, user_question): user = session.get("user") # 从会话取用户 if not user: raise SessionExpired() # 反向 Sampling 让客户端调模型生成 SQL sql = session.sampling( prompt=f"把问题转成SQL: {user_question}" ) result = db.query(sql) session.set("last_query", sql) # 存会话 return result新架构下,这段代码要拆成两轮 MRTR 交互。第一轮服务端不直接生成 SQL,而是返回一个需求:
# 新架构第一轮:声明需求,不持有状态 def handle_request(request): user = request.metadata.get("user_token") # 从请求取,不从会话 if not validate_token(user): return error("invalid_token") question = request.params.get("question") # 声明需要模型生成 SQL,但不自己调 return need_input( need_type="model_generate", payload={"prompt": f"把问题转成SQL: {question}"}, next_action="execute_query" )客户端收到这个需求后,自己去调模型生成 SQL,然后发起第二轮请求:
# 新架构第二轮:客户端带回模型结果 def handle_request(request): user = request.metadata.get("user_token") if not validate_token(user): return error("invalid_token") sql = request.params.get("generated_sql") # 客户端传回来的 if not sql: return error("missing_context") result = db.query(sql) return success(result)改完之后你会发现几个明显变化。第一,服务端函数不再接收 session 参数,所有输入都从 request 里来。第二,鉴权从“会话级”变成“请求级”,每个请求都要校验。第三,模型调用从服务端转移到客户端,服务端只声明需求。第四,没有 session.set 这种操作了,需要保留的东西要么返回给客户端,要么存到外部存储。
迁移过程中我总结了几个容易出错的点。第一是忘记处理“需求未被满足”的情况,客户端可能因为各种原因无法满足服务端的需求,这时候服务端要能优雅降级,而不是死等。第二是令牌刷新时机,无状态下令牌过期是常态,客户端要在收到 invalid_token 时自动刷新并重试,而不是直接报错给用户。第三是幂等性,MRTR 多轮交互中,如果某一轮网络失败需要重试,要确保重试不会导致重复执行,建议每轮请求带一个唯一的交互 ID。
还有一个实操细节:MRTR 的轮次上限要设。理论上服务端可以无限次返回需求,客户端无限次满足,但实际中必须有个上限,比如 5 轮或 10 轮,超过就报错。我见过因为逻辑 bug 导致无限循环的案例,两边都在正常响应,就是永远不结束,最后把资源耗光。
注意:迁移时不要试图“兼容”旧架构。我试过在无状态服务里模拟 Session,结果代码复杂度翻倍,还引入了新的 bug。既然协议变了,就老老实实按新范式写,短痛好过长痛。
5. 常见问题与排查技巧实录
升级过程中我踩的坑和帮别人排查的问题,整理成一张速查表,你遇到类似症状可以直接对照:
| 症状 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 请求随机失败,重试就好 | 令牌过期未刷新 | 看失败请求的时间间隔 | 客户端加自动刷新 |
| 上下文丢失,服务端说缺参数 | 客户端没传全 | 对比请求体和需求描述 | 检查 MRTR 状态机 |
| 并发下数据错乱 | 竞态条件 | 看是否有并行同会话请求 | 加乐观锁或收敛入口 |
| 交互永远不结束 | MRTR 轮次无上限 | 看交互轮次计数 | 设轮次上限 |
| 鉴权通过但权限不对 | 令牌里权限信息缺失 | 解码令牌看 claims | 令牌携带完整权限 |
| 服务端内存持续增长 | 残留的会话缓存没清 | 看是否有全局字典 | 移除所有会话存储 |
除了表里的,还有几个我印象深刻的排查经历。有一次线上出现间歇性 401,但令牌明明没过期。查了半天发现是多实例部署下时钟不同步,某个实例的时间比标准时间快了几分钟,导致它认为令牌已过期。无状态架构下每个实例独立校验令牌,时钟同步变得更重要,建议所有实例接同一个时间源。
还有一次是 MRTR 交互中客户端把模型结果传回去,服务端说格式不对。原因是客户端和服务端对“需求描述”的理解不一致,服务端期望的是纯 SQL 字符串,客户端传了个 JSON 对象。这类问题的根源是需求描述不够明确,后来我们在需求里加了严格的 schema 定义,客户端按 schema 组装,问题就没了。
关于性能,无状态架构理论上更容易扩展,但我也见过改完之后反而变慢的。原因是每次请求都重新鉴权和重新查上下文,如果这些操作很重,累积起来开销不小。优化方向是把鉴权结果做短时缓存(注意是缓存不是会话),以及把上下文查询做批量合并。我实测下来,合理缓存后性能比旧架构还好,因为不再有会话粘性和状态迁移的开销。
最后分享一个调试技巧:给每个请求打上唯一的 trace ID,并在所有日志里带上它。无状态架构下请求之间没有天然关联,靠 trace ID 才能把一次完整交互的多个轮次串起来。这个习惯养成后,排查 MRTR 问题的时间能缩短一大半。
6. 我对这次重写的个人判断
说实话,刚看到 Session 和 Sampling 被砍的时候,我是有点抵触的,毕竟旧架构用熟了,迁移成本实打实。但改完两个项目之后,我的看法变了:这次重写是把 MCP 从“demo 友好”推向“生产友好”的关键一步。有状态会话在演示里很优雅,一到真实部署就各种别扭;Sampling 在单机玩具里很酷,一到多租户环境就权限混乱。无状态和 MRTR 虽然让开发者多写一些代码,但换来的是可预测、可扩展、可排查的系统行为。
如果你现在还在犹豫要不要升级,我的建议是看你的部署形态。如果只是本地跑跑、单实例、用户量小,旧架构还能凑合;但凡涉及多实例、弹性扩缩、多租户,新架构的优势会立刻体现出来。迁移的痛是一次性的,不迁移的痛是持续的。
至于那些 2025 年的教程,该扔就扔吧。协议在演进,抱着旧文档不放只会让自己越来越被动。我现在的习惯是每次升级前先读一遍变更说明,把受影响的功能列出来,逐个验证,比盲目照搬教程靠谱得多。