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