MCP 弃用 SSE?Node 分帧漏 81.7% 事件

MCP 弃用 SSE?Node 分帧漏 81.7% 事件

MCP 规范把老的 HTTP+SSE 传输标记为废弃、改推 Streamable HTTP 之后,"SSE 要凉了"的说法到处都是。我把自己那个零依赖的 Node 远程 AI 编程助手真跑了一遍:凉的不是 SSE,是手写分帧------缓冲区写错一行,随机分片下 81.7% 的事件直接蒸发。

一、这个项目到底在干什么

Remote AI Coder 是一个零第三方依赖的 Node.js 服务:PC 上跑一个 Agent,手机浏览器发提示词,中继服务器用 SSE 把 Agent 的工具调用、代码生成、文件变更一条条推到手机上。

链路是三段:

text 复制代码
手机浏览器 ──POST /api/prompt──▶ 中继服务器 ──SSE(role=agent)──▶ PC Agent
手机浏览器 ◀──SSE(role=client)──  中继服务器 ◀──POST /api/event──  PC Agent

整个项目只有 6 个源文件,全部用 Node 内置模块写成,npm install 都不需要:

文件 大小 职责
server.js 8718 B HTTP + SSE 中继,会话/事件历史
agent.js 4877 B PC 侧 Agent,收提示词并回推事件
engine/mock.js 5325 B 零配置演示引擎
engine/llm.js 3795 B 接 OpenAI 兼容接口
engine/cliAdapter.js 2196 B 接管本地 CLI 工具的 stdout
web/app.js 13923 B 前端 EventSource 渲染

我用项目自带的 mock 引擎真跑了一轮 (直接 import engine/mock.js,不是复刻),拿到这一组真实数字:

指标 实测值
一轮任务产出事件数 30 个
事件类型分布 run_start 1 / status 2 / tool_start 5 / tool_end 5 / token 15 / file_change 1 / run_end 1
整轮耗时 4079 ms
token 拼接出的正文 158 字符
全部事件 JSON 3158 B
编码成 SSE 后 3398 B

二、技术选型:SSE 到底该不该被淘汰

这是这次实测最想说清楚的事。MCP 弃用的是"用 SSE 同时承担上行和下行、且要求服务端保持有状态长连接"这套组合,不是 SSE 这个协议本身。

方案 上行 下行 断线恢复 浏览器成本 适合场景
短轮询 每次请求 每次响应 天然无状态 极低 分钟级更新
长轮询 挂起请求 一次响应 要自己记游标 低 兼容性兜底
SSE 另开 HTTP 一条长连接推送 靠 Last-Event-ID EventSource 原生 LLM 逐字输出、Agent 进度
WebSocket 同一条 同一条 要自己做 要写心跳重连 语音、协同编辑
Streamable HTTP 同一端点 POST 按需升级为 SSE 会话 ID 恢复 无原生封装 MCP 工具服务

结论很直接:Agent 进度推流这种"客户端发一次、服务端吐很久"的形态,SSE 依然是最优解 。浏览器原生 EventSource 帮你把分帧、重连都做了,服务端就是一个 text/event-stream 响应头加 res.write。

真正会坑人的,是你绕开 EventSource、在 Node 侧自己手写 SSE 解析的时候。

三、中继层:两条 SSE 与一份事件历史

中继服务器同时维护两个订阅集合:clients 是手机,agents 是 PC,共用同一个 /api/stream 端点,靠 role 参数区分身份。

js 复制代码
// server.js ------ /api/stream 的关键分支
res.writeHead(200, {
  'content-type': 'text/event-stream; charset=utf-8',
  'cache-control': 'no-cache, no-transform',   // 禁止中间层缓存与转换
  connection: 'keep-alive',
  'x-accel-buffering': 'no',                   // 关掉 Nginx 的响应缓冲,否则 SSE 会被攒够一包才发
});
res.write('retry: 2000\n\n');                  // 告诉浏览器断线 2 秒后自动重连
sseSend(res, { type: 'hello', role, session, serverTime: Date.now() });

if (role === 'client') {
  s.clients.add(res);
  for (const ev of s.events) sseSend(res, ev); // 新设备中途加入,先把历史整段回放一遍
  sseSend(res, { type: 'prompt_history', prompts: s.prompts });
} else if (role === 'agent') {
  s.agents.add(res);
}
const ping = setInterval(() => sseKeepAlive(res), 25000);   // 每连接一个定时器
req.on('close', () => {
  clearInterval(ping);
  s.clients.delete(res);
  s.agents.delete(res);                        // 断线即清理,不留僵尸订阅者
});

这段里有三个容易被当成装饰品的细节:

  • x-accel-buffering: no 不能删。Nginx 默认会缓冲上游响应,SSE 会被攒满一个 buffer 才真正下发,表现出来就是"点了发送半天没反应",而且本地直连测试完全复现不出来。
  • cache-control: no-transform 针对的是某些压缩中间件,它们会把流式响应改成一次性输出。
  • 心跳 25 秒一次 ,每连接一个 setInterval。1000 个在线连接时一小时写 14.4 万次约 1.10 MB,10000 个连接就是 144 万次、10.99 MB。字节数无所谓,但定时器数量是随连接数线性增长的,量大了要换成统一的全局心跳。

事件历史存在 s.events 数组里,上限 MAX_HISTORY = 2000,超了就 shift() 掉最老的一条。我对比了两种裁剪写法:

裁剪方式 1 万次 push 10 万次 push
每次超限都 shift() 0.9 ms 10.6 ms
攒够再 splice() 批量裁 0.5 ms 4.1 ms

差距不算致命(2.6 倍),但既然已经知道上限是固定值,用环形缓冲区更干净。

四、核心原理:SSE 的帧边界到底在哪

SSE 的报文极其简单,一个事件就是若干行 field: value,以一个空行(两个换行)结束:

js 复制代码
// server.js ------ 中继下发一个事件的全部逻辑
function sseSend(res, obj) {
  try {
    // "data: " + JSON + "\n\n":\n\n 才是事件的分界符,不是单个 \n
    res.write(`data: ${JSON.stringify(obj)}\n\n`);
  } catch {
    /* ignore broken pipe */
  }
}

function sseKeepAlive(res) {
  try {
    // 以冒号开头的是注释行,浏览器 EventSource 会忽略,只用来保活
    res.write(`: ping\n\n`);
  } catch {
    /* ignore */
  }
}

关键点有三个:

  • 分隔符是 \n\n 不是 \n 。按 \n 切也能跑,但那是在切"行"而不是切"事件"。
  • data: 后面那个空格会被 line.slice(5) 一起切掉,所以 JSON 里不会出现多余空格。
  • 注释行(: 开头)不产生事件,所以心跳帧不会污染业务流。

服务端写起来没难度,难度全在读的那侧:HTTP 是字节流,一个 SSE 帧完全可能被 TCP 切成两半。

五、同一个项目里的两种写法:一个对一个错

这个项目里出现了三处手写 SSE 解析,写法分成两派。

4.1 正确写法:agent.js

js 复制代码
// agent.js ------ 缓冲区定义在循环外,跨 chunk 累积
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';                       // ← 关键:循环外,不会丢
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });   // 累加到同一个 buf
  let idx;
  while ((idx = buf.indexOf('\n\n')) >= 0) {        // 按 \n\n 切事件
    const chunk = buf.slice(0, idx);
    buf = buf.slice(idx + 2);                       // 残余留在 buf 里等下个 chunk
    for (const line of chunk.split('\n')) {
      if (!line.startsWith('data:')) continue;
      const data = line.slice(5).trim();
      if (!data) continue;
      let msg;
      try { msg = JSON.parse(data); } catch { continue; }
      if (msg.type === 'prompt') handlePrompt(msg.id, msg.text);
    }
  }
}

4.2 错误写法:engine/llm.js

js 复制代码
// engine/llm.js ------ buf 定义在 while 内部,每个 chunk 都被重置
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value, { stream: true });
  let buf = chunk;                 // ← 每个 chunk 重新开局,上一个 chunk 的尾巴直接丢掉
  let idx;
  while ((idx = buf.indexOf('\n')) >= 0) {   // 而且按 \n 切,不是 \n\n
    const line = buf.slice(0, idx).trim();
    buf = buf.slice(idx + 1);
    if (!line.startsWith('data:')) continue;
    const data = line.slice(5).trim();
    if (data === '[DONE]') continue;
    try {
      const json = JSON.parse(data);
      const delta = json.choices?.[0]?.delta?.content || '';
      if (delta) full += delta;
    } catch { /* ignore */ }
  }
  // while 结束时 buf 里剩下的半行,没有任何人接管 → 永久丢失
}

两处差别只有两行,后果差了一个数量级。我把 mock 引擎真跑出来的 30 个事件编码成 3398 B 的 SSE 字节流,再按不同分片喂给这两种解析器:

分片方式 段数 agent.js 写法 llm.js 写法 漏掉
固定 64 B 47 30 / 30 0 / 30 100.0%
固定 256 B 12 30 / 30 20 / 30 33.3%
固定 1024 B 3 30 / 30 28 / 30 6.7%
固定 4096 B 1 30 / 30 30 / 30 0.0%
随机 1~128 B(1000 次均值) - 30.00 / 30 5.50 / 30 81.7%
随机 1~512 B(1000 次均值) - 30.00 / 30 20.53 / 30 31.6%
随机 1~2048 B(1000 次均值) - 30.00 / 30 27.55 / 30 8.2%

单个 SSE 帧大约 113 B,所以当分片小于或接近帧长时,几乎每一帧都会被切断,而每切断一次就丢一整条事件。分片 64 B 时 30 条一条都没解析出来。

为什么这么惨?一帧被切成两半时,前半截因为没有 \n 而被整段丢弃;后半截在下个 chunk 里成了"不以 data: 开头的行",又被 continue 跳过。一次切断 = 一条事件凭空消失,而且没有任何报错。

4.3 改法

js 复制代码
// 修复版:缓冲区提到循环外 + 按 \n\n 切 + 跳过注释行与多 data 行
let buf = '';
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  let idx;
  while ((idx = buf.indexOf('\n\n')) >= 0) {
    const raw = buf.slice(0, idx);
    buf = buf.slice(idx + 2);
    let payload = '';
    for (const line of raw.split('\n')) {
      if (line.startsWith(':')) continue;      // 注释行/心跳
      if (!line.startsWith('data:')) continue;
      payload += line.slice(5).trim();         // 多行 data 要拼接
    }
    if (!payload || payload === '[DONE]') continue;
    try {
      const json = JSON.parse(payload);
      const delta = json.choices?.[0]?.delta?.content || '';
      if (delta) { full += delta; yield { type: 'token', text: delta }; }  // 边到边吐
    } catch { /* ignore */ }
  }
}

六、性能账:这一套链路的钱花在哪

6.1 上行:每个事件一次 HTTP POST

Agent 回推事件走的是 POST /api/event,一条事件一个请求 。我起了个真实服务,用 rawHeaders 把请求行和头逐字节重建后实测:

指标 实测值
单个事件本身 JSON 平均 104 B
单条 POST 完整报文 411 B
协议 + session/token 包装开销 307 B,占 74.7%
相对事件本身的放大 3.95x
相对长连接方案的放大 3.66x
单条耗时(本机环回) 6.11 ms

外推到一整轮任务:

事件数 POST 方案 长连接方案
30 12.0 KB / 30 次往返 3.3 KB / 0 次额外往返
100 40.2 KB / 100 次往返 11.0 KB / 0 次额外往返
500 200.8 KB / 500 次往返 54.8 KB / 0 次额外往返

跨网 RTT 30 ms 时,30 个事件光往返等待就 900 ms。上行改成一条复用连接(WebSocket 或 Streamable HTTP 那种单端点)能直接把这部分砍掉。

6.2 下行:假流式的延迟

llm.js 先把整个流式响应攒进 full,再 for (const word of ...) { yield token; await sleep(8) } 回放。这意味着首字延迟等于模型的完整生成时间:

回复长度 回放耗时 端到端(按 40 tok/s 估) 首字延迟
50 词 0.4 s 1.4 s 1.0 s
200 词 1.6 s 5.6 s 4.0 s
500 词 4.0 s 14.0 s 10.0 s
1000 词 8.0 s 28.0 s 20.0 s
2000 词 16.0 s 56.0 s 40.0 s

真流式的首字延迟通常在 0.3~0.8 s。修好第四节那个解析器之后,把 yield 直接放进分帧循环就能边到边吐,不用改任何其他东西。

6.3 前端:每个 token 重渲一遍全文

js 复制代码
// web/app.js ------ token 事件的处理
case 'token': {
  const r = getRun(ev.runId);
  const el = ensureTextBlock(r);
  r.currentText += ev.text;
  el.innerHTML = renderMarkdown(r.currentText);  // ← 每来一个词,全文重新解析 + 替换 innerHTML
  autoscroll();
  break;
}

我把 renderMarkdown 从源码里抠出来真跑了一遍,15 个 token 的场景下调用了 15 次、累计扫过 91 行、耗时 0.77 ms;只渲染最后一次是 0.03 ms,差 30.8 倍。这是 O(n²):

token 数 最终正文 累计扫描文本 相当于正文的倍数
200 约 2.1 KB 205.7 KB 100x
500 约 5.3 KB 1.29 MB 250x
1000 约 10.5 KB 5.14 MB 500x

1000 个 token(也就一篇短文的长度)要反复解析 5 MB 文本,还伴随着 1000 次 innerHTML 赋值引发的整棵子树重建。

6.4 广播没有背压

js 复制代码
// server.js
function broadcast(session, set, obj) {
  for (const res of set) sseSend(res, obj);   // 不检查 res.write() 的返回值
}

res.write() 返回 false 表示内核缓冲已满,应该暂停等 drain。项目里直接无视。我用一个"连上就不读"的客户端做了实测:

已写入 应发字节 滞留在用户态缓冲 进程堆
20000 条 3.04 MB 0.19 MB 11.7 MB
50000 条 7.62 MB 2.99 MB 28.3 MB
100000 条 15.25 MB 7.66 MB 53.6 MB

发出去 15.25 MB,一半卡在内存里,堆从十几兆涨到 53.6 MB、rss 108.8 MB。Agent 一次批量输出就能把中继进程顶起来,慢客户端越多越放大。

6.5 会话号与重连重放的两笔账

会话号是 crypto.randomBytes(5),只有 40 bit。按生日悖论估算碰撞概率:

累计创建会话数 5 字节 session id 碰撞概率
1 万 0.0045%
10 万 0.4537%
100 万 36.54%

同一次创建的 token 是 16 字节(128 bit),100 万个会话碰撞概率仍是 0,可见只是 id 这一处取短了。

另一笔是重连重放。服务端只发 data: 不发 id:,所以 EventSource 断线重连时只能全量重发:

已积累事件数 重连一次重复下发 重复 5 次累计
100 条 10.3 KB 0.05 MB
1000 条 102.8 KB 0.50 MB
2000 条 205.6 KB 1.00 MB

七、踩坑记录

  1. 缓冲区写在循环里,81.7% 的事件静默消失 (第四节)。这是最隐蔽的一个:不报错、不抛异常,只是手机上少显示几段文字。判定办法很简单------看 let buf 是不是在 while 外面。
  2. 按 \n 切而不是 \n\n 切 。单个 \n 切的是行,事件的官方分界是两个换行。巧合的是大部分帧前后各有一个空行,所以小规模测试经常"看起来是对的"。
  3. 假流式让首字延迟等于总生成时间 。修好解析后记得把 yield 挪进分帧循环,别攒完再播。
  4. 前端 O(n²) 重渲染。改成"只把最后一个 block 重新渲染,前面的 DOM 不动",或者干脆节流到每 50 ms 一次。
  5. 广播不看背压 。res.write() 返回 false 就挂起等 drain,否则慢客户端会把服务端内存吃干。
  6. 重连等于全量重放 。服务端只发 data: 不发 id:,浏览器 EventSource 断线后 2 秒自动重连,服务端把 s.events 从头到尾再发一遍。已积累 2000 条时,重连一次重复下发 205.6 KB,重复 5 次就是 1.00 MB,前端会看到一堆重复卡片。正确做法是给每个事件编 id:,客户端带 Last-Event-ID 续传。
  7. 上行一事件一 POST,74.7% 是协议开销。高频事件场景务必换成复用连接。
  8. session id 只有 5 字节(40 bit) 。crypto.randomBytes(5) 的碰撞概率按生日估算:累计 10 万个会话 0.45%,100 万个会话 36.54%。token 是 16 字节很安全,但 id 撞了就会串到别人的会话里。
  9. handlePrompt 没有 await,stopFlags 也从不清理。多个提示词会并发跑,且每个 runId 在 Map 里永久留一条记录。
  10. token 走 query string 。EventSource 不能自定义请求头,只能拼在 URL 上,于是 token 会进 Nginx 访问日志和浏览器历史。

八、如果重做一次,我会怎么改

  • 保留 SSE 做下行,上行换成同一条长连接(或 Streamable HTTP 那种单端点 POST),省掉 74.7% 的协议开销和 N 次往返。
  • 服务端给每个事件加 id:,用 Last-Event-ID 做断点续传,干掉全量重放。
  • res.write() 返回 false 时挂起,监听 drain 再继续。
  • 前端渲染改成增量,或者至少做 rAF 节流。
  • session id 提到 16 字节,跟 token 一个量级。

一句话总结这次实测:SSE 没有过时,过时的是"以为 SSE 简单到不用认真分帧"这件事。协议本身只有一个分界符规则,守住了它就是零依赖、零心智负担的推流方案;守不住,81.7% 的数据会安静地消失在你眼前。


完整工程(含 mock/llm/cli 三种引擎、一键启动脚本和修复后的解析器)已经整理好了,需要的同学评论区扣「源码」,我看到会一一回复;也欢迎关注我,后面会把这套远程 Agent 链路继续往下写。

相关推荐
跨境Jacky5 小时前
选品数据采集,MCP比爬虫稳在哪?亚马逊到1688实测
跨境电商·mcp·sorftime
vjmap6 小时前
Cesium + MCP:在 Cursor、Claude Code 里一句话生成地图视频,唯杰 AI 地图动画上线
gis·cesium·mcp
ynchyong8 小时前
MCP Server 与 Function Calling
functioncalling·mcp·mcp server
猫头_11 小时前
MCP 开发实战:把你自己的后端 API 接入 Agent
人工智能·typescript·mcp
hey you~11 小时前
个体户门店办理400电话,如何对接订单系统实现来电弹窗
node.js·webhook·400电话·门店数字化·来电弹窗·订单系统对接
第404块砖头15 小时前
Spring Boot 4.1 + Spring AI 2.0:我如何用 Java 微服务把 RAG、知识图谱和 MCP 协议串成一个 Agent 平台
知识图谱·知识库·graph·rag·mcp·spring boot 4.1·spring ai 2.0
xrlfreedom16 小时前
MCP 面试高频考点:把知识库 RAG 封装成 Tool 时,提示注入防护与人机协同确认怎么答?
mcp·提示注入防护·人机协同确认
情 九1 天前
MCP 实战 06:把 HTTP 接口接成工具,让 AI 帮你查数据
人工智能·网络协议·http·mcp
lgcgkCQ1 天前
【AI 转型实战 1】SpringAI 入门:搭建基础大模型对话能力
java·ai·agent·rag·spring ai·mcp·langchain4j