1. 从一次线上事故说起:为什么Stateless架构值得聊
去年年底,我负责维护的一个智能体调度系统出了一次挺尴尬的故障。当时业务侧反馈,某个自动化流程在高峰期频繁超时,重试之后偶尔还会出现状态错乱——同一个任务被两个节点同时执行,结果写入了两份互相冲突的数据。排查了大半天,最后定位到根因:会话状态被绑定在了单个服务实例的内存里,一旦实例重启或者负载均衡把请求打到别的节点,整个上下文就断了。
这个问题其实在分布式系统里算是老生常谈,但放在MCP(Model Context Protocol,模型上下文协议)这个场景下,它变得格外棘手。因为MCP要处理的不只是普通的HTTP请求,还涉及工具调用、资源订阅、多轮对话上下文、流式响应等一堆有状态的操作。如果协议层面没有把状态管理这件事想清楚,那上层应用就得自己填坑,而且每个团队填法还不一样。
2026年这次MCP的重大更新,核心就是围绕Stateless架构做了一次彻底的重构。简单说,它把"状态"从协议的核心路径里剥离出去,让服务端可以像处理普通无状态请求一样处理MCP调用,状态则交给外部存储或者客户端自己维护。这个变化听起来只是架构层面的调整,但它带来的影响远不止性能优化那么简单——它直接改变了MCP服务的部署方式、扩展策略、容错逻辑,甚至影响了工具开发者写代码的习惯。
这篇文章适合三类人看:一是正在用MCP搭建智能体应用的开发者,二是负责MCP服务端运维和扩展的工程师,三是想搞清楚这次更新到底值不值得跟进的技术决策者。我会从架构设计的底层逻辑讲起,把Stateless到底解决了什么问题、怎么落地、有哪些坑,尽量说透。文中涉及的具体实现细节,部分是基于公开协议文档的解读,部分是我在实际项目中的经验补充,我会明确标注哪些是推测、哪些是实测。
2. Stateless架构到底改了什么:核心设计思路拆解
2.1 旧版MCP的状态绑定问题出在哪
要理解Stateless的价值,得先看清楚旧版MCP的状态是怎么处理的。在之前的协议设计里,一个MCP会话(session)通常包含这么几类状态:连接状态(客户端和服务端之间的长连接)、上下文状态(多轮对话的历史消息、工具调用的中间结果)、资源状态(订阅的资源变更通知、缓存的数据)、认证状态(token、权限范围)。
这些状态在旧版里默认是绑定在服务端实例上的。也就是说,客户端第一次请求打到节点A,后续请求如果打到节点B,节点B是不认识这个会话的。为了保证一致性,常见的做法有两种:一是用粘性会话(sticky session),让同一个客户端的请求始终路由到同一个节点;二是做会话复制,把状态同步到所有节点。
粘性会话的问题很明显:节点挂了,会话就丢了;扩容的时候,新节点分不到流量,因为老会话都粘在旧节点上;负载均衡稍微抖一下,用户体验就崩了。会话复制呢,同步延迟和一致性问题又很难搞定,尤其是上下文数据量大的时候,网络开销非常可观。
这里有个常见的误解:很多人以为MCP的状态问题只是"多轮对话历史"这一件事。实际上,工具调用的中间态、资源订阅的游标、流式响应的分片序号,全都是状态。这些状态如果处理不好,比对话历史丢失更致命。
2.2 Stateless的核心思路:状态外置与请求自包含
2026年这次更新,核心思路可以概括成一句话:让每个MCP请求都自包含足够的信息,服务端不需要在内存里记住任何东西。
具体来说,协议层面做了这么几件事:
第一,会话标识与状态存储解耦。客户端在每次请求里带上一个会话ID,但这个ID不再对应服务端内存里的某个对象,而是作为外部存储(比如Redis、数据库、对象存储)的索引键。服务端拿到ID,去外部存储里读状态,处理完再写回去。服务端实例本身是无状态的,随便哪个节点都能处理。
第二,上下文数据显式传递。以前有些实现会把上下文缓存在服务端,靠会话ID隐式关联。现在协议要求客户端在请求里显式带上必要的上下文,或者带上一个指向外部存储的引用。服务端不再假设"我记得你上次说了什么"。
第三,流式响应的状态管理下移。流式响应在旧版里需要服务端维护一个持续的连接状态,记录已经发送到哪个分片。新版把流式响应的状态管理交给了客户端和传输层,服务端只负责按请求生成内容,分片的组装和重试由客户端处理。
第四,资源订阅改为拉取模式为主。旧版的资源订阅是服务端主动推送变更通知,服务端需要记住谁订阅了什么。新版更倾向于客户端按需拉取,或者通过一个独立的事件通道来传递变更,这个通道本身也是无状态的。
2.3 为什么是现在:Stateless的收益与代价
你可能会问,既然Stateless这么好,为什么以前不这么做?原因在于,Stateless是有代价的。
收益方面,最直接的是水平扩展变得极其简单。服务端实例可以随意增减,不需要考虑会话迁移。容错能力也上了一个台阶,节点挂了直接重启,请求打到别的节点照样能处理。部署方式更灵活,可以跑在Serverless环境里,按需拉起,用完就销毁。对于MCP这种经常需要突发扩容的场景(比如某个工具突然被大量调用),这个优势非常明显。
代价方面,每次请求都要读写外部存储,延迟会增加。如果外部存储成为瓶颈,整体性能反而可能下降。另外,客户端需要承担更多的状态管理责任,协议变复杂了,工具开发者的心智负担也重了。还有一个容易被忽略的点:状态外置之后,数据一致性的保证从"服务端内存天然一致"变成了"依赖外部存储的事务能力",这对存储选型提出了更高要求。
我个人的判断是,这次更新对大规模、高并发、多租户的MCP服务是明显利好,对小规模、单机部署的场景则收益有限,甚至可能因为引入外部存储而增加复杂度。所以要不要跟进,得看你的实际业务形态。
3. 落地Stateless架构:关键细节与实操要点
3.1 状态存储选型:别一上来就上重型方案
状态外置的第一步是选存储。我见过不少团队一听说要外置状态,直接上分布式数据库,结果发现大部分请求的状态数据其实很小,读写频率也不高,用重型方案纯属浪费。
我的建议是按状态类型分开选型:
| 状态类型 | 特点 | 推荐存储 | 理由 |
|---|---|---|---|
| 会话上下文 | 读写频繁,数据量中等,允许短暂不一致 | 内存数据库 | 低延迟,支持TTL自动过期 |
| 工具调用中间态 | 生命周期短,通常秒级 | 内存数据库或本地缓存 | 过期快,不需要持久化 |
| 资源订阅游标 | 读写频率低,需要持久化 | 关系型数据库或KV存储 | 数据量小,但丢了要重新同步 |
| 认证与权限 | 读多写少,安全性要求高 | 专用认证服务或加密KV | 不能和业务状态混在一起 |
这里有个实操心得:会话上下文的TTL设置非常关键。设太短,用户多轮对话到一半状态就没了;设太长,存储里堆一堆僵尸会话。我的经验是,根据业务的最长对话时长来定,一般设成最长对话时长的1.5到2倍,同时加一个滑动过期机制,每次访问就续期。
注意:如果你的MCP服务要支持多租户,状态存储的隔离一定要做好。我见过因为会话ID生成规则太简单,导致不同租户的会话ID碰撞,A租户读到了B租户的上下文。这种问题一旦发生,就是严重的数据泄露。
3.2 请求自包含:上下文怎么带才不臃肿
Stateless要求请求自包含,但"自包含"不等于"把所有历史都塞进请求体"。那样的话,请求会越来越大,网络传输和序列化开销都会爆炸。
实际落地时,我推荐用引用+增量的方式:请求里带一个会话ID和一个上下文引用(比如存储里的key),再带上本次新增的上下文片段。服务端根据引用去存储里取历史,和增量合并后处理。这样请求体大小可控,同时保证了状态的完整性。
具体到协议字段的设计,大致是这样的结构:
{ "session_id": "sess_abc123", "context_ref": "ctx:tenant1:sess_abc123", "context_delta": [ {"role": "user", "content": "帮我查一下昨天的订单"} ], "tool_call": { "name": "query_orders", "params": {"date": "2026-01-15"} } }服务端拿到这个请求,先用context_ref去存储里读出完整上下文,追加context_delta,然后执行工具调用。处理完之后,把新的上下文写回存储,更新context_ref指向的数据。
这里有个细节要注意:并发写入的冲突处理。如果同一个会话有两个请求同时到达,都去读上下文、追加、写回,就可能出现覆盖。解决办法有两种:一是用乐观锁,写回时检查版本号,冲突就重试;二是用追加日志的方式,每次只追加不覆盖,读取时按顺序合并。我个人更倾向第二种,实现简单,而且天然支持审计。
3.3 流式响应的无状态化:分片序号谁来管
流式响应是MCP里比较难处理的部分。旧版里,服务端要维护一个流的状态,记录发到哪了、客户端有没有确认。Stateless之后,这个状态得挪走。
新版协议的做法是:服务端把流式响应切分成带序号的分片,每个分片独立生成,不依赖之前的发送状态。客户端负责按序号组装,如果发现序号缺失,就发起一个补拉请求。补拉请求本身也是无状态的,带上缺失的序号范围,服务端重新生成对应的分片。
这个设计的好处是,服务端可以随时中断、重启,客户端只要拿到完整的分片序列就行。坏处是,服务端可能需要重复生成某些分片,如果生成逻辑有副作用(比如调用了外部API),就得做幂等处理。
我的实操建议是:流式响应的分片生成逻辑一定要设计成幂等的。同样的请求参数和序号范围,多次调用应该返回相同的结果。如果底层依赖的外部服务不支持幂等,就在MCP服务端加一层缓存,把生成过的分片缓存起来,补拉时直接返回缓存。
3.4 资源订阅的拉取化改造:从推送到拉
资源订阅在旧版里是服务端主动推送,服务端要维护订阅关系表。Stateless之后,订阅关系可以存在外部存储里,但推送这个动作本身还是有状态的——服务端得知道往哪推、推什么。
新版更推荐的做法是拉取为主、事件通道为辅。客户端定期或者按需拉取资源的最新状态,服务端只负责返回当前状态,不关心谁在订阅。如果需要实时性,可以走一个独立的事件通道,这个通道只负责传递"某个资源变了"的信号,不传递具体内容,客户端收到信号后再去拉取。
这样改造之后,服务端的资源管理逻辑变得非常简单:就是一堆无状态的查询接口。订阅关系的维护、变更通知的触发,都交给外部系统或者客户端自己处理。
提示:拉取模式会增加客户端的请求次数,如果资源变更频繁,可能会造成轮询风暴。建议在客户端加一个退避策略,变更频繁时缩短拉取间隔,变更少时拉长间隔,避免无谓的请求。
4. 实操过程:从旧版迁移到Stateless的完整路径
4.1 迁移前的评估:哪些服务适合先动
迁移不是一蹴而就的,我建议先做一轮评估,把服务按状态复杂度分个类。
低复杂度:只处理单轮请求,不涉及多轮上下文,工具调用没有中间态。这类服务迁移最简单,基本就是把状态存储的读写加上就行。
中复杂度:有多轮对话,但上下文数据量不大,工具调用有简单的中间态。这类服务需要仔细设计上下文引用和增量合并的逻辑。
高复杂度:涉及大量资源订阅、流式响应、复杂的工具调用链。这类服务迁移工作量最大,建议放在最后,等前面的经验积累够了再动。
我当时的做法是,先拿一个低复杂度的查询类服务做试点,跑通整个链路,把存储选型、请求结构、错误处理都验证一遍,然后再逐步推广到中高复杂度的服务。
4.2 第一步:引入状态存储层
试点服务选定后,第一步是引入状态存储层。我选的是内存数据库,因为它延迟低、支持TTL、运维简单。部署上,用主从模式保证可用性,主节点挂了从节点顶上。
存储的key设计有个小技巧:用租户ID+会话ID+状态类型做前缀,这样方便按租户清理数据,也避免了不同状态类型之间的key冲突。比如t1:sess_abc:ctx表示租户1的会话abc的上下文,t1:sess_abc:tool表示工具调用中间态。
value的序列化格式,我用的JSON,因为可读性好,调试方便。如果对性能要求极高,可以换成二进制格式,但会增加调试难度。我的建议是,除非压测发现序列化是瓶颈,否则优先用JSON。
4.3 第二步:改造请求处理链路
请求处理链路的改造是核心。旧版的链路大致是:接收请求 -> 从内存取会话 -> 处理 -> 更新内存会话 -> 返回响应。新版要改成:接收请求 -> 从存储取会话 -> 合并增量 -> 处理 -> 写回存储 -> 返回响应。
这里有个容易踩的坑:存储读写失败的处理。旧版内存操作基本不会失败,新版存储可能因为网络抖动、主从切换等原因失败。如果读失败,是返回错误还是用空上下文继续?如果写失败,是返回错误还是让客户端重试?
我的处理原则是:读失败返回错误,让客户端重试;写失败也返回错误,但要在响应里带上"状态可能未保存"的标记,让客户端知道这次操作的结果可能不完整。千万不要在读失败时用空上下文继续,那样会导致上下文丢失,用户会莫名其妙地发现对话"失忆"了。
4.4 第三步:压测与调优
改造完成后,一定要做压测。我当时的压测方案是:模拟1000个并发会话,每个会话进行10轮对话,每轮包含一次工具调用。压测指标关注三个:P99延迟、存储QPS、错误率。
第一轮压测结果不太理想,P99延迟比旧版高了40%。排查发现,主要开销在存储的读写上,每次请求要读一次写一次,往返延迟累积起来很可观。优化措施有两个:一是加本地缓存,对于短时间内重复读取的会话,缓存在服务端内存里,减少存储访问;二是批量写入,把多次状态更新合并成一次写。
优化之后,P99延迟降到了旧版的1.1倍左右,考虑到Stateless带来的扩展性和容错性提升,这个代价是可以接受的。
注意:本地缓存虽然能降延迟,但会引入一致性问题。如果两个节点都缓存了同一个会话,一个更新了存储,另一个的缓存就脏了。解决办法是设置较短的缓存TTL(比如1秒),或者用发布订阅机制通知缓存失效。我选的是短TTL,实现简单,一致性影响可控。
4.5 第四步:灰度发布与回滚预案
迁移的最后一步是灰度发布。我的做法是,新老两套逻辑同时部署,通过一个开关控制走哪套。先放1%的流量到新版,观察错误率和延迟,没问题再逐步放大到10%、50%、100%。
回滚预案一定要提前准备好。如果新版出问题,开关一关就能切回旧版。但要注意,新旧版的状态存储格式可能不一样,切回旧版时,新版写入的状态旧版可能读不了。我的处理是,灰度期间新旧版共用一套存储格式,旧版读不了新版的字段就忽略,保证基本兼容。
5. 常见问题与排查技巧实录
5.1 状态丢失:会话上下文突然变空
这是迁移后最常见的问题。表现是用户聊到一半,突然发现智能体"不记得"之前说的话了。
排查思路按这个顺序来:先看存储里有没有这个会话的数据,如果没有,说明写入失败了或者TTL过期了;如果有数据但读出来是空的,说明序列化/反序列化有问题;如果存储里有数据且能读出,但请求处理时没用上,说明上下文合并逻辑有bug。
我遇到过一次,原因是TTL设置得太短,用户思考时间稍微长一点,会话就过期了。后来把TTL从5分钟改成30分钟,问题就没了。所以TTL的设置一定要结合真实用户的行为习惯,不能拍脑袋定。
5.2 并发冲突:同一会话的请求互相覆盖
前面提到过,并发写入会导致覆盖。表现是用户快速连续发两条消息,第二条的响应里丢失了第一条的上下文。
解决办法就是前面说的乐观锁或者追加日志。我用的是追加日志,每次请求只追加不覆盖,读取时按时间戳排序合并。这样即使并发,也不会丢数据,最多是顺序有点乱,但顺序问题可以通过时间戳解决。
5.3 存储瓶颈:QPS打满导致超时
压测或者高峰期,存储的QPS可能被打满,导致请求超时。表现是错误率飙升,日志里大量存储超时。
应对措施分短期和长期。短期是加本地缓存、批量写入、限流降级。长期是存储扩容、分片、读写分离。我的经验是,内存数据库的QPS上限通常比关系型数据库高一个数量级,如果内存数据库都扛不住,那说明架构设计有问题,得重新评估状态存储的方案。
5.4 流式响应分片错乱:客户端组装失败
流式响应改造后,偶尔会出现客户端组装失败的情况。排查发现,是分片序号在并发情况下出现了重复或者跳跃。
根因是服务端生成分片时,序号生成逻辑不是原子的。两个请求同时生成分片,可能拿到相同的序号。解决办法是把序号生成放到一个原子操作里,或者用请求ID+分片索引的组合来保证唯一性。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 上下文突然变空 | TTL过期或写入失败 | 检查存储中的会话数据 | 调整TTL,增加写入重试 |
| 并发请求互相覆盖 | 读写竞争 | 检查是否有版本控制 | 乐观锁或追加日志 |
| 存储超时 | QPS打满 | 监控存储QPS和延迟 | 加缓存、批量写、扩容 |
| 分片组装失败 | 序号重复或跳跃 | 检查序号生成逻辑 | 原子生成序号 |
| 迁移后延迟升高 | 存储往返开销 | 对比新旧版延迟 | 本地缓存、批量写 |
5.6 几个我踩过的坑
第一个坑是过度依赖外部存储的事务。我一开始以为存储的事务能解决所有一致性问题,后来发现,跨多个key的事务在内存数据库里支持得并不好,而且性能开销大。后来改成单key操作+应用层补偿,反而更简单可靠。
第二个坑是忽略了客户端的状态管理责任。Stateless之后,客户端要负责带上上下文引用、处理流式分片组装、管理重试逻辑。如果客户端实现得不好,服务端再优雅也没用。所以迁移时一定要把客户端一起改,不能只改服务端。
第三个坑是没有做好监控。Stateless架构下,问题更容易被掩盖,因为服务端看起来"无状态",但实际上状态都在存储里。如果存储出问题,服务端表现可能只是"响应变慢",不容易定位。所以存储的监控一定要做细,QPS、延迟、错误率、容量都要盯。
6. 这次更新对工具开发者的实际影响
6.1 工具实现要幂等
Stateless架构下,工具调用可能因为重试、补拉等原因被重复执行。如果工具本身有副作用(比如写数据库、发消息),重复执行就会出问题。所以工具实现必须幂等,同样的参数多次调用,结果应该一致。
实现幂等的方式有很多:用唯一请求ID做去重、用乐观锁控制写入、用状态机保证操作只能执行一次。具体选哪种,看工具的业务逻辑。我的建议是,在工具入口处统一加一层幂等校验,把请求ID和结果缓存起来,重复请求直接返回缓存结果。
6.2 工具不能依赖服务端内存
以前有些工具会偷懒,把中间结果存在服务端内存里,下次调用时直接取。Stateless之后,这种写法行不通了,因为下次调用可能打到别的节点。工具的所有状态要么通过参数传入,要么存到外部存储,不能依赖服务端内存。
6.3 工具的超时和重试策略要重新设计
Stateless之后,工具调用的链路变长了(多了存储读写),超时时间要相应放宽。同时,重试策略也要调整,因为重试可能导致工具重复执行,必须配合幂等机制。
我的一般建议是:工具的超时时间设置为旧版的1.5倍,重试次数不超过2次,重试间隔用指数退避。如果工具执行时间本来就长,考虑改成异步模式,先返回一个任务ID,客户端轮询结果。
7. 我个人在实际操作中的体会
迁移到Stateless架构这件事,我最大的体会是:它不是一个纯技术问题,而是一个架构权衡问题。Stateless带来的扩展性和容错性提升是实实在在的,但代价是复杂度的转移——从服务端转移到了客户端和存储层。如果你的业务规模不大,或者对延迟极其敏感,盲目跟进可能得不偿失。
另外一个体会是,迁移过程中最耗时的不是写代码,而是想清楚状态到底有哪些、分别该怎么处理。我一开始以为状态就是对话历史,后来发现工具中间态、订阅游标、流式分片全是状态,漏掉任何一个都会出问题。所以迁移前一定要做一次彻底的状态盘点,把所有状态列出来,逐个决定存储方式和生命周期。
最后分享一个小技巧:在请求里加一个调试字段,记录这次请求读了哪些状态、写了哪些状态。平时这个字段不返回给客户端,排查问题时打开,能快速定位状态相关的bug。这个技巧帮我省了不少排查时间,尤其是在并发冲突和状态丢失这类问题上。