如果你最近正在搭 MCP(Model Context Protocol)服务,大概率绕不开一个问题:传输层到底用 SSE 还是 streamable_http?像光子AI这种前期用 HTTP+SSE 方式跑通、后期又想平滑迁到 streamable_http 的项目,如果没把这两种方案的原理吃透,改代码的时候会非常难受——接口看着差不多,行为却完全不是一回事。
这篇文章我会把前后差异拆成几个层面来讲:先解释它们出现的背景,再对比二者的交互模型和协议细节,然后直接进源码看光子AI里传输层具体改了什么,最后给出一份能落地的迁移指南和问题排查清单。不管你是服务端维护者,还是客户端 SDK 调用方,看完应该都能搞清楚“为什么”以及“该怎么选”。
1. 先说背景:MCP 的传输层是怎么一步步变成现在这样的
1.1 什么是 MCP 传输层
MCP 服务端和客户端之间传的是 JSON-RPC 2.0 消息。传输层就是负责把这份 JSON-RPC 消息从 A 点搬到 B 点的那条路。MCP 规范里提到了几种传输方式:stdio、HTTP+SSE,以及现在的 streamable_http。
stdio 适合本地进程,比如让 AI 编辑器直接拉起一个本地 Python 服务,父子进程通过标准输入输出通信。但一旦服务部署到远端,就必须走 HTTP。早期很多 MCP server 用的是“HTTP+SSE”这套方案:客户端通过 POST 往一个消息端点发请求,服务器通过一个长连接的 SSE 流把响应推回来。
这套方案在当时能跑,但用起来很别扭。因为 SSE 本质上是一条“服务器往客户端方向”的单向消息通道,客户端要想发消息,还得再单独开一个 POST 通道。结果就是一个 MCP 服务需要暴露两个端点、维护两种连接状态,前后端联调时经常出现“消息发出去了,但响应不知道走哪条通道回来”的困惑。
1.2 从 stdio 到 HTTP,再到 SSE 和 streamable_http
先把演进路线理清楚。最早的 MCP server 大多跑在本地,用 stdio 就够了。后来大家想连远程服务,才开始用 HTTP。
第一代 HTTP 方案就是那个“双端点”模型:一个/sse端点用于服务器推送,一个/message端点用于客户端发消息。客户端要先 GET/sse建立事件流,拿到 SSE 连接之后,再把真正的 JSON-RPC 请求 POST 到/message上,服务器收到后通过已经建立的 SSE 连接把响应推回来。
这种设计最大的问题在于“请求”和“响应”没有绑定在同一个 HTTP 事务里。你发一条tools/call请求,无法直接复用这次 POST 的响应连接,必须靠 SSE 连接跟踪一条消息属于哪个请求。代理层、负载均衡器、网关拿不到完整的“请求—响应”关系,有时候还会因为空闲连接超时把 SSE 断开。
streamable_http 的出现就是为了解决这些问题。它在 2025 年前后成为 MCP 规范推荐的 HTTP 传输方式。核心思想很直接:单一端点,一次 POST 请求可以返回一个流式响应,并且请求和响应都在同一个 HTTP 连接上完成。
1.3 光子AI 为什么需要关注这个区别
光子AI 是我维护的一个 MCP 服务框架,最初为了快速兼容当时的编辑器生态,直接用了 SSE 方案。后来接入方越来越多,有人要跑长时间的工具调用,有人要拿流式日志,还有人要把服务挂到现有的 API 网关后面。SSE 的“双通道”模型就成了瓶颈。
最让我头疼的一个场景是“多轮工具调用”。客户端发一个tools/call,服务器要等外部工具执行几十秒,这期间如果 SSE 连接被中间层断开,整个会话就废了。streamable_http 把请求和响应的生命周期放进同一个 HTTP 事务里,超时语义更清晰,断线重连也更容易实现。所以后面我们花了几个迭代专门做迁移。
理解背景之后,下面进入正题,把两种传输方式的机制差异掰开揉碎。
2. SSE 与 Streamable HTTP 的机制差异,逐层拆开看
2.1 SSE 的真实姿态:单向推送,双向全靠拼
SSE 是浏览器原生支持的一个老技术,Media Type 是text/event-stream。服务器可以持续往客户端推数据,每条事件用data:开头,事件之间用空行分隔。
MCP 早期看上它,是因为它天生适合 JSON-RPC 的异步响应:服务器什么时候处理完,就什么时候把结果推给客户端。但问题在于,SSE 本身是单向的。服务器能推,客户端不能通过同一条连接任意发消息。
所以 MCP 的 HTTP+SSE 方案实际上是用两个通道“拼”出一个双向通信:
- 客户端先发一个
GET /sse,建立 SSE 长连接,这条连接唯一的用途是接收服务器推送。 - 客户端要发请求时,再发一个
POST /message,把 JSON-RPC 消息放到 POST 请求体里。 - 服务器处理完消息后,找到这个客户端对应的 SSE 连接,把结果作为一条 SSE 事件推过去。
这带来的问题有几个。
第一,客户端必须在发起任何请求之前先建立 SSE 连接。那这个连接算“会话”的一部分,还是独立资源?规范早期没说清楚,导致不同实现里的会话管理千奇百怪。
第二,一个 POST 请求只负责把消息送进去,响应却是另一个连接上的事件。中间任何一个代理如果把 SSE 连接缓存、缓冲或者断掉,客户端表现就是“发出去没回应”。
第三,POST 通道的响应其实只能返回一个.空或简单确认,真正的业务响应全在 SSE 流里。调试的时候很反直觉,因为你抓包会看到一个 POST 立即返回了 200,但业务结果却在另一条流里慢慢飘。
2.2 Streamable HTTP:把 HTTP 变成“可流式请求/响应”
streamable_http 不是发明了一个新协议,而是重新设计了一种 HTTP 传输语义。它把整个 MCP 交互收敛到一个 URL 上,比如/mcp,客户端通过 POST 向这个端点发送 JSON-RPC 请求,服务器可以选择:
- 返回一个完整的
application/json响应,适合简单的、一次性完成的请求; - 或者返回一个
text/event-stream响应,适合异步、流式、需要持续推送状态的请求。
这跟 SSE 最大的区别是:text/event-stream响应不是一条额外的、独立的“推送通道”,而是这一次 HTTP 请求的响应体。也就是说,客户端发出POST /mcp,就在这一次请求里拿着响应流读取结果,而不是事先去开一条 SSE 通道。
比如用标准 fetch 客户端,代码是这个感觉:
const res = await fetch("https://mcp.example.com/mcp", { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", method: "tools/call", params: { name: "get_weather", arguments: { city: "北京" } }, id: 1 }) }); // 如果服务端返回的是流,就在这里逐块读取 const reader = res.body.getReader();这种设计带来的好处非常明显:一次 HTTP 事务里就能完成“请求—流式响应”的完整链路。负载均衡可以按 HTTP 请求做路由,网关也能正确识别超时和取消。
2.3 核心差异对照表
我直接在项目文档里维护了一张对照表,方便团队里新同学快速理解:
| 对比项 | HTTP+SSE | streamable_http |
|---|---|---|
| 端点数量 | 至少两个:/sse和/message | 一个统一端点,比如/mcp |
| 请求模型 | GET 建流,POST 发消息,两个通道分离 | 每次交互都是一个 HTTP 请求/响应 |
| 响应方式 | 服务器通过 SSE 连接主动推 | 响应体可以是 JSON,也可以是 SSE 流 |
| 会话管理 | 早期依赖全局 SSE 连接标识,实现不统一 | 通过Mcp-Session-Id头维护会话 |
| 流式处理 | SSE 天然支持,但需要额外维护连接池 | 原生支持流式响应,且请求响应绑定 |
| 取消操作 | 要额外发取消事件,难追踪 | 可以直接中断 HTTP 请求/断开连接 |
| 中间层兼容性 | SSE 长连接容易被代理断开或缓冲 | 更接近传统 HTTP 请求,兼容性更好 |
| 协议演进 | 早期方案,正被逐步替代 | 新规范推荐,生态支持越来越多 |
这里的“前后区别”,在我看来有两层含义。第一层是“前后端交互逻辑的区别”,第二层是“协议版本前后的区别”。从 HTTP+SSE 换到 streamable_http,不只是换一个 URL 的事,前后端的连接模型和消息路由逻辑都要跟着改。
理解完机制差异,我们再看真实代码。下面这一段是光子AI SDK 里传输层重构的核心逻辑。
3. 光子AI 的源码实现:传输层到底改了什么
3.1 服务端端点定义:从两个路由变成一个路由
早期光子AI 的 MCP server 是标准的“SSE 双端点”写法。用 Node.js 和 Express 大概长这样:
// 早期:HTTP+SSE 方案的端点定义 app.get("/sse", (req, res) => { // 建立 SSE 长连接 res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); // 把这个响应对象保存到连接池 const sessionId = crypto.randomUUID(); sseSessions.set(sessionId, res); // 初始化事件 res.write(`data: ${JSON.stringify({ jsonrpc: "2.0", method: "endpoint", params: { uri: `/message?sessionId=${sessionId}` } })}\n\n`); }); app.post("/message", async (req, res) => { const { sessionId } = req.query; const message = req.body; const sessionRes = sseSessions.get(sessionId); if (!sessionRes) { res.status(404).json({ error: "SSE session not found" }); return; } // 执行 MCP 核心逻辑 const response = await handleMCPMessage(message); // 结果通过 SSE 连接推给客户端 sessionRes.write(`data: ${JSON.stringify(response)}\n\n`); // POST 本身只返回一个空确认 res.json({ ok: true }); });这个代码看着简单,但痛点很多。sseSessions这个 Map 必须由开发者自己维护生命周期。客户端断线了,不一定能及时触发清理;多实例部署时,sseSessions存在单机内存里,负载均衡一转发,第二个实例根本找不到前一个实例创建的 SSE 会话。
后来我们迁移到 streamable_http,端点定义变成了这样:
// 新:streamable_http 单端点 app.post("/mcp", async (req, res) => { // 通过请求头处理会话 const sessionId = req.headers["mcp-session-id"] as string | undefined; // 未初始化时,先走 initialize 流程,创建会话 if (!sessionId) { const initResult = await handleInitialize(req.body); res.setHeader("mcp-session-id", initResult.sessionId); } // 直接处理消息,并流式返回响应 const mcpResponse = await handleMCPMessage(req.body, sessionId); res.status(mcpResponse.status); res.setHeader("Content-Type", mcpResponse.contentType); if (mcpResponse.contentType === "text/event-stream") { // 将 JSON-RPC 响应包装成 SSE 格式 res.write(`data: ${JSON.stringify(mcpResponse.body)}\n\n`); res.end(); } else { res.json(mcpResponse.body); } });从“两个路由+全局连接池”变成“一个路由+无状态请求”,这个变化是本质性的。现在我可以把服务随便扔到 Kubernetes 里跑多个副本,每个请求都自带上下文,不需要跨实例共享连接状态。
3.2 连接与会话生命周期:从 Map 到请求头
SSE 方案的会话生命周期不直观。客户端要先连上,服务端才能发事件;客户端掉线,服务端可能还傻傻留着那个 Map 里的引用。久而久之就是内存泄漏。
streamable_http 的会话管理则优雅很多。客户端每发起一个请求,都会通过mcp-session-id请求头带上会话 ID。服务端第一次收到没有该头部时,就认为这是新会话;响应用同一个头部把新的 session ID 带回去,后续请求都复用这个 ID。
这里我贴一段光子AI 里处理会话的中间件逻辑,省略了具体鉴权,但框架是清晰的:
async function sessionMiddleware(req, res, next) { const sessionId = req.headers["mcp-session-id"]; let session; if (sessionId && sessions.has(sessionId)) { session = sessions.get(sessionId); // 刷新会话过期时间 session.lastActiveAt = Date.now(); } else { // 创建新会话 const newSessionId = crypto.randomUUID(); session = { id: newSessionId, createdAt: Date.now(), lastActiveAt: Date.now(), context: {}, }; sessions.set(newSessionId, session); res.setHeader("mcp-session-id", newSessionId); } req.mcpSession = session; next(); }对比 SSR 方案的sseSessions,这个抽象极大简化了多路复用。客户端不需要为一个长连接死磕,可以随时断开再重连;只要 session ID 还在有效期内,服务端就能把上下文恢复过来。
3.3 响应流处理:从手动 write 到流式响应体
SSE 方案中最难维护的是“手动往连接里写数据”。比如服务端要连续推送多个进度事件,你必须在代码里反复调用sessionRes.write(...)。如果某一步忘记加空行,客户端解析就会卡住。而且这种写法很难配合现代 Web 框架的流式响应机制。
streamable_http 在光子AI 里的实现更结构化。我们用TransformStream把 JSON-RPC 消息包装成 SSE 格式,再通过 Web 标准的Response返回。
function createSSEStream(getIterator: () => AsyncIterator<any>) { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { try { for await (const message of getIterator()) { const sseChunk = `data: ${JSON.stringify(message)}\n\n`; controller.enqueue(encoder.encode(sseChunk)); } controller.close(); } catch (err) { controller.error(err); } } }); return stream; } app.post("/mcp", async (req, res) => { // 处理消息,拿到一个异步迭代器 const messageIterator = handleMCPMessageStream(req.body); // 直接把流作为响应体,交给框架管理生命周期 const sseStream = createSSEStream(messageIterator); res.status(200); res.setHeader("Content-Type", "text/event-stream"); sseStream.pipeTo(res); });这个版本的代码相比手动 write 有两点好处。第一,流的开和关都由ReadableStream生命周期管理,不会出现写了但是忘记end()的情况。第二,如果客户端中途断开,pipeTo会感知到,从而触发取消逻辑,底层异步任务也能及时停止。
3.4 取消与错误处理:从鸡肋到标准机制
SSE 方案下,取消一个长时间运行的工具调用非常麻烦。因为请求在 POST/message那边已经“结束”了,真正干活的是服务器后台任务。你只能再发一条notifications/cancelled消息,告诉服务器取消某个任务。但这条消息本身走什么通道?还是同一个 POST 通道。如果那条消息丢了,任务就会继续白跑。
streamable_http 方案下,取消变得非常自然。如果客户端不再需要这次请求的响应,直接中断 HTTP 请求即可。服务端可以通过req.aborted或标准的AbortSignal感知到连接断开,然后停止对应的工具调用。
下面是光子AI 里工具调用的简化实现,我特意加了取消监听:
app.post("/mcp", async (req, res) => { const { signal } = req; const message = req.body; if (message.method === "tools/call") { const toolName = message.params.name; const args = message.params.arguments; // 将 AbortSignal 传给工具执行函数 const result = await runTool(toolName, args, { signal }); // 如果请求被中断,runTool 内部会收到 abort 事件并停止 res.json({ jsonrpc: "2.0", result, id: message.id, }); } });而runTool内部只需要监听 signal:
async function runTool(name, args, { signal }) { return new Promise((resolve, reject) => { const work = doHeavyWork(args); signal.addEventListener("abort", () => { work.cancel(); reject(new Error("Request aborted")); }); work.onComplete(resolve); }); }这个取消链路在旧的 SSE 方案里几乎无法干净实现。所以你如果遇到“服务端工具卡死但客户端已经超时”的困扰,迁移到 streamable_http 大概率能解决。
源码层面的东西讲完,接下来是实操。我从光子AI 的项目里整理了一份迁移清单,照着做基本不会踩太大的坑。
4. 从 SSE 迁移到 Streamable HTTP 的实操指南
4.1 先判断你要不要迁移
不是所有场景都需要立刻迁移。如果服务只是本地或内网使用,客户端又是买断式的老版本 MCP 客户端,SSE 还能继续用。但如果出现以下任何一条,我建议尽早迁:
- 服务需要部署到公网,并且要过网关/负载均衡;
- 客户端需要长时间等待工具执行,经常超过 30 秒;
- 服务需要支持多个并发会话,且希望做水平扩容;
- 客户端需要取消长时间运行的请求;
- 新接入方明确只支持 streamable_http。
光子AI 的判断标准很简单:只要新客户端还在用 SSE,我们就保留兼容层;但只要内部工具调用链路上出现过一次“连接断开导致任务无结果”,就把它记为迁移候选。实际操作中,迁移一个服务大概需要一到两天。
4.2 迁移时的关键配置点
迁移不只是改服务端代码,客户端也必须跟着换。你需要关注几个关键点。
端点变更。服务端不再提供/sse和/message,改为/mcp。客户端 SDK 里的 transport 初始化代码要改成指向新端点。
会话头。客户端必须处理mcp-session-id响应头,并在后续所有 POST 请求里带上它。很多 SDK 最初的实现并没做这点,需要自己补。
内容协商。请求头里的Accept建议设置为application/json, text/event-stream。这样服务端可以根据请求类型返回 JSON 或流,兼容性最好。
超时与缓冲。如果服务前面挂了 Nginx,一定记得关闭 SSE 缓冲。下面这段配置是我们在光子AI 环境里验证过的:
location /mcp { proxy_pass http://mcp_backend; # 关键:关闭缓冲,保证流式响应实时到达客户端 proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; # 避免代理把连接吞掉 proxy_http_version 1.1; proxy_set_header Connection ""; }如果没有关闭proxy_buffering,客户端看到的流式输出会变成“攒到一大坨才推一次”,体验非常差,有些客户端甚至会干脆报错。
4.3 兼容旧客户端的降级方案
迁移不是一刀切。一个稳妥的做法是让服务端同时支持新旧两种端点。老客户端继续打/sse+/message,新客户端走/mcp。两者共用底层消息处理逻辑,只差传输层。
光子AI 的做法是把核心 handler 抽象成handleMCPMessage,然后两个传输层各自调用:
// 老客户端专用:SSE 端点 app.get("/sse", handleSSETransport); app.post("/message", handleMessageTransport); // 新客户端专用:streamable_http 端点 app.post("/mcp", handleStreamableTransport); // 核心逻辑完全不区分传输层 async function handleMCPMessage(message, session) { // 路由到 tools/resources/prompts 等 }只要核心逻辑不依赖“我是谁给我发的”,两个传输层就能并行存在。这样你可以逐步让老客户端升级,不用在某一天凌晨强制切换。
5. 常见问题与排查技巧实录
5.1 三个高频问题,直接看表
我整理了一份问题速查表,都是光子AI 内网和技术社区里反复出现的问题。
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 初始化 connect 请求没有响应,客户端超时 | 客户端未正确处理mcp-session-id,或服务端需要先建立 SSE 连接才能接收 initialize | 确保initialize请求本身就是第一个 HTTP 请求,不要依赖 SSE 预连接 |
| 流式响应不实时,一停停半天 | 代理层开启了缓冲 | 检查 Nginx / API 网关配置,关闭proxy_buffering |
| 工具执行到一半连接断开,任务没结果 | 客户端或代理层超时时间太短 | 服务端支持 AbortSignal 取消,客户端调大 readTimeout,网关超时调到 > 工具最大执行时间 |
| POST /message 返回 404 | 客户端还在用旧端点 | 检查客户端 transport 配置,确认是否指向新/mcp端点 |
| 会话找不到,每次请求都重新初始化 | 客户端没把mcp-session-id请求头带回来 | 在客户端 HTTP 层全局拦截响应头,并写入后续请求 |
| SSE 连接频繁断开,不稳定 | 网络设备空闲超时,或者代理超时小于客户端心跳间隔 | 服务端定期发注释事件作为心跳,比如: ping,同时调大代理超时 |
5.2 用 curl 直接验证服务端行为
排查问题最快的方式是直接拿 curl 打服务端。如果你想看新端点返回的是 JSON 还是 SSE,可以这样测:
curl -N -X POST https://mcp.example.com/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'首次请求会返回mcp-session-id响应头,你可以用-i看:
curl -i -N -X POST https://mcp.example.com/mcp ...如果返回头里有mcp-session-id,说明会话创建成功。接下来再发tools/list时,就必须手动带上这个头。
curl -N -X POST https://mcp.example.com/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "mcp-session-id: 你的_session_id" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'如果这一步返回了正常 JSON,说明传输层基本没问题。如果返回 404 或提示 session 失效,再去查会话中间件的逻辑。
5.3 容易忽略的几个小坑
第一个坑是响应头大小写。HTTP 头本身不区分大小写,但不少客户端 SDK 在读取mcp-session-id时写死了小写。你服务端如果发的是Mcp-Session-Id,有些严格匹配的客户端就会读不到。光子AI 的兼容做法是,在响应时同时写两种大小写,或者用后端框架的规范化 header 输出,再在客户端做一次容错。
第二个坑是SSE 格式里的空行不能省。SSE 协议规定事件必须以两个换行符结束。很多手写实现只记得写data: xxx,忘了结尾的\n\n,客户端解析就会出现最后一条消息丢失或延迟。用ReadableStream包装时,务必把\n\n拼全。
第三个坑是不要为短连接设置长超时。streamable_http 虽然支持流式响应,但很多请求其实是普通 JSON 响应,比如tools/list。如果服务端没有内容协商,客户端也没发Accept: text/event-stream,响应体就不会是流。这时如果代理层还傻傻地等流结束,就会拖慢整个请求。建议服务端根据Accept头做智能判断,客户端也不需要盲目依赖流式。
第四个坑是不要在 POST 请求里缓存。因为 POST 也可以有流式响应,所以浏览器、网关、SDK 都不能主动缓存 POST 结果。如果哪一层加了缓存,客户端拿到的就是旧的、不完整的流,排查起来非常隐蔽。
6. 一点个人经验,写在最后
我做光子AI 传输层迁移前后加起来踩了大概两周的坑,最后最深的体会是:SSE 与 streamable_http 的差异,核心不在事件格式,而在“请求-响应”模型的重构。你可以在不换语言、不换框架的情况下完成迁移,但必须把“长连接池”的老思维丢掉,换成“每个请求自带上下文”的新思维。
另一个体会是,协议迁移最难的往往不是服务端,而是客户端 SDK 的适配。服务端就算把/mcp端点实现得再规范,如果客户端不重试、不处理 session 头、不解析流式响应,体验仍然起不来。所以迁移时最好以一条真实业务链路为准,比如从“初始化→列出工具→调用工具→拿到结果”完整跑通,再放开流量。
最后分享一个排查小技巧:如果发现客户端和服务端对流式响应的解析不一致,可以在服务端临时用普通 JSON 响应替代流式响应,先看业务链路通不通。业务通了再切回流式,问题范围一下就缩小了。这个思路对任何协议迁移都适用。
如果你也在做类似的 MCP 服务改造,建议先按上面的对照表评审一遍自己的调用场景,再决定迁不迁、怎么迁。至少从我这个项目的实际结果看,streamable_http 带来的收益非常明确:部署更简单,故障更少,客户端交互也更可控。