MCP 会话断了怎么续:微软 AI Agent 入门课的 resumable client

2026-08-18

长任务跑到一半,客户端被 Ctrl+C 掉了,或者网络断了。重新起客户端,屏幕上打出「Resuming existing session…」,然后就没有然后了——没有进度日志,没有报错,也没有任务完成。读 ai-agents-for-beginners 第 11 课那套 MCP 示例的代码就会发现,有好几条路径都会导向这种「看着像在恢复、实际什么也没恢复」的结果。

这一课的示例代码在 11-agentic-protocols/code_samples/mcp-agents/ 下,自带一份 README。顺带说一句,课程正文 11-agentic-protocols/README.md 里我们没有找到指向这份示例的说明,所以第一次翻到它多半是靠目录,不是靠正文。

下面按排查的顺序走:先确认是不是「续跑」这一环出的问题,再看仓库代码里给出的处置,最后说清楚什么情况下根本不该往这个方向查。

一、先看客户端读的是哪个 token 文件

示例里有两个客户端,各自管各自的 token,文件名还不一样。

client/resumable_client.py 顶上写死的是:

# Token file location
TOKEN_FILE = Path(".mcp_resumption_token.json")

而交互式客户端 client/client.py 用的是 client/utils.py 里的 TokenManager,它的默认值是:

DEFAULT_TOKEN_FILE = "resumption_tokens.json"

两个文件名都是仓库当前代码里的取值,随版本可能变动。要点是:client.py 起的任务,拿 resumable_client.py 去续,读的压根不是同一个文件,于是走的是「没有 token」的新会话分支,任务从头开始,而屏幕上不会有任何报错。

两个路径都是相对路径,相对进程的工作目录。Windows 侧这一条更容易踩:你在 PowerShell 里 cdmcp-agents 目录起了服务端,又开了一个终端在别的目录起客户端,token 文件就落在了另一个地方;Linux/macOS 同理,只是习惯上大家更容易保持在同一个目录里。所以判定动作很简单:到你实际启动客户端的那个目录下,看这个 JSON 文件在不在。

resumable_client.py 存进去的字段是固定的一组:

    tokens = {
        "session_id": session_id,
        "resumption_token": resumption_token,
        "protocol_version": protocol_version,
        "tool_name": "long_running_agent",
        "tool_args": {}
    }

注意 tool_name 是写死的 long_running_agent——这个精简客户端只负责续这一个工具。想续 travel_agent 那种带 elicitation 的流程,是 client.py 的活,它的 TokenManager.save_tokens() 才会把 last_tool / last_args 存下来。

二、再看服务端有没有开事件存储

续跑靠的是服务端的事件存储。server/server.pyrun_server() 里这一行决定了有没有:

    event_store = SimpleEventStore() if with_event_store else None

对应的命令行开关是 --no-event-store。启动后日志会二选一地打出 Event store enabled - resumption supportedEvent store disabled - no resumption supportmain() 里调了 logging.basicConfig(level=logging.INFO),所以这两行看得到。

反过来,客户端把日志压得很狠。resumable_client.pylogging.basicConfig(level=logging.WARNING),还把 mcp.client.streamable_http 单独设成 ERROR,另外挂了一个过滤器:

class SSEFilter(logging.Filter):
    def filter(self, record):
        return not (
            "Error parsing SSE message" in record.getMessage() or
            "Invalid JSON: EOF while parsing" in record.getMessage()
        )

也就是说,客户端这边「安静」不等于「正常」。排查时把判断依据放在服务端日志上,比盯客户端屏幕靠谱。

三、被存下来的到底是什么

在翻存储实现之前,先弄清存的是什么,否则「重放」这个词很容易被想象成「任务从断点接着算」。

server/server.pylong_running_agent 的循环体,每一步做的是发一条日志通知:

                    await ctx.session.send_log_message(
                        level="info",
                        data=f"Processing step {current_step}/{steps} ({progress_percent}%)",
                        logger="long_running_agent",
                        related_request_id=ctx.request_id,
                    )

travel_agentresearch_agent 走的则是 send_progress_notification(),参数里带 progress_token=ctx.request_id。这些从服务端流向客户端的消息,就是会经由传输层落进 event store 的东西——存的是消息,不是任务的执行状态。任务本身仍然跑在服务端那个协程里,客户端断开期间它该跑还在跑;重连之后补上来的,是断开这段时间里错过的那些通知。

这也解释了 store_event(stream_id, message) 为什么第一个参数是 stream_id:一个会话里可以有多条流,每条流对应一次带响应流的请求。下一节里两个实现在「重放范围」上的分歧,分歧点就在这里。

四、事件存到哪、重放哪一段

server/event_store.py 里有两个实现,都继承 EventStore,接口是同一对方法:store_event(stream_id, message) 返回 EventIdreplay_events_after(last_event_id, send_callback) 返回 StreamId | None

SimpleEventStore 的存储就是一个列表,类文档字符串自己写明是 “Simple in-memory event store for testing resumption functionality”——用于测试。它的 id 是一个自增计数器转成的字符串。重放时线性扫这个列表找 last_event_id,关键在找不到的那一支:

        if start_index is None:
            # If event ID not found, start from beginning
            start_index = 0
            logger.info("Event ID not found, starting from beginning")

两件事由此确定。其一,服务端进程一重启,列表就空了,客户端手里那个 token 对应的 id 在新进程里当然找不到,于是 start_index = 0,而空列表从 0 开始也没有东西可发,日志会是 Replayed 0 events, stream_id: None。其二,它的重放循环遍历的是 self._events[start_index:] 全部内容,没有按 stream 过滤stream_id 只是从第一条被重放的事件上取一次。

PersistentEventStore 落 SQLite,表结构就三列:

                CREATE TABLE IF NOT EXISTS events (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    stream_id TEXT NOT NULL,
                    message TEXT NOT NULL
                )

消息体用 TypeAdapter(JSONRPCMessage) 序列化成 JSON 存进 message 列,读回来时 validate_json 反序列化。数据库操作放在 asyncio.to_thread 里执行,代码注释写的理由是 “Run DB operation in thread pool to avoid blocking event loop”,连接也因此带了 check_same_thread=False

它的重放范围和 SimpleEventStore 明显不同,_fetch_events_sync() 分两步:

        cursor.execute("SELECT stream_id FROM events WHERE id = ?", (target_id,))
        cursor.execute(
            "SELECT id, stream_id, message FROM events WHERE id > ? AND stream_id = ? ORDER BY id ASC",
            (target_id, stream_id)
        )

先用 last_event_id 反查它属于哪个 stream,再只取这个 stream 里 id 更大的那些。所以同样是「断线重连」,两个实现给出的重放集合可能完全不是一回事:内存版把之后的所有事件都发一遍,SQLite 版只发同一条流的。

边界处理也是反的。SQLite 版遇到 id 不是整数会记 Invalid event ID format 并返回 None,查不到这条 id 会记 Event ID {target_id} not found 同样返回 None,上层随即打出 Could not resume stream from event {last_event_id}——明确失败;内存版则是悄悄从头再来。排查时这两种日志形态的区别,比任何猜测都直接。

还有两个细节值得记:反序列化失败的那条事件会被 except 掉、只记一行 Failed to deserialize event,重放继续往下走,客户端那边就是中间少了一段;clear_events() 除了 DELETE FROM events,还会 DELETE FROM sqlite_sequence WHERE name='events' 把自增序列归零,而这张表里没有任何区分「哪一次运行」的字段,只能按 id 查——清库之后 id 会从头再来,客户端手里的旧 token 与新库里的 id 是否会撞上,代码里没有做区分。

顺带一提:server.pyfrom .event_store import SimpleEventStorePersistentEventStore 在仓库里没有被任何地方引用。想用它得自己接,接口是现成的,create_server_app() 的签名是 (event_store: Optional[EventStore] = None) -> Starlette

from .event_store import PersistentEventStore
from .server import create_server_app

with PersistentEventStore("events.db") as event_store:
    app = create_server_app(event_store)

"events.db"PersistentEventStore.__init__storage_path 的默认值,仓库当前代码如此,随版本可能变动;它同样是相对路径,Windows 上换个工作目录启动就是另一个库文件。上面这段为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。

五、客户端恢复时到底做了什么

resumable_client.py 有 token 时的动作有三处,都值得单独看。

第一,把会话信息塞进请求头:

        headers[MCP_SESSION_ID_HEADER] = existing_tokens["session_id"]
        headers[MCP_PROTOCOL_VERSION_HEADER] = existing_tokens.get("protocol_version", "2025-03-26")

这两个常量是从 mcp.server.streamable_http 导入的。

第二,连接时 terminate_on_close=False,代码里紧跟的注释就是 # Enable resumption——不关闭会话,服务端那边才留得住。

第三,有 token 时跳过 session.initialize(),走的是 print("✅ Using existing session (no initialization needed)") 这一支;只有全新会话才初始化。

六、怎么算续上了

三个信号一起看:服务端出现 Replaying events after {id} 且随后的 Replayed N events 里 N 大于 0;客户端 message_handler 开始打 📡 [long_running_agent] ...(这是 LoggingMessageNotification 分支的输出,long_running_agent 的进度是用 send_log_message 发的,不是进度通知);任务正常结束后 clear_resumption_tokens() 会把 token 文件删掉。

反过来,任务结束了但 token 文件还在,说明走的是 except 那一支,注释写着 # Keep tokens in case we want to retry。这时候可以用 --clear-tokens 清掉重来,它和 --url 一起在 main() 的 argparse 里定义,--url 的默认值是 http://127.0.0.1:8006/mcp,这是仓库当前代码里的默认值,随版本可能变动;服务端 main() 那边 --port 的默认值与之对应。

七、什么情况说明不是这个原因

  • 服务端日志里一条 Stored event ... for stream ... 都没有:事件根本没进存储,问题在连接或传输层,不在重放。create_server_app() 里的 TransportSecuritySettings 只放行 allowed_hosts=["127.0.0.1:*", "localhost:*"] 与对应的 http:// origin,你换个主机名或 IP 连过去,卡住的是这一关。
  • token 文件压根没生成TokenManager.save_tokens() 会先挡掉空的 session_id / resumption_token,也会挡掉以 pending_temp_ 开头的占位串并打印 Cannot save placeholder token。这是「token 没拿到」,与重放范围无关。
  • 第一次断线能续、第二次续不上:看 resumable_client.py 里那个 if/else——只有新任务分支把 on_resumption_token_update 挂进 ClientMessageMetadata,走恢复分支时 metadata 里只有 resumption_token,没有更新回调。也就是说恢复期间不会再写新 token,文件里躺的还是上一次那个。这属于客户端示例的行为,不是事件存储的问题。
  • 你续的不是 long_running_agentresumable_client.py 的请求参数写死 name="long_running_agent", arguments={},别的工具它不发。

最后一句提醒:这套东西横跨课程仓的示例代码和 MCP Python SDK 两层,token 在 SDK 内部怎么映射成重放用的 last_event_id,示例代码里没有写,别在这一层上想当然。


本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码, 因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。 该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。 文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。