博客.
博客.
(联系方式)

保持联系

如果你想联系我,无论是讨论合作机会、分享想法,还是只是打个招呼,都可以通过以下方式找到我。

(最后更新)

版权所有 / 2026

(快捷键)C
返回文章列表
2026年9月25日

断线是常态:SSE 流式协议的工程实践

太长不读

做 AI 测试平台这一年,SSE 用了一整年:后端服务、Web 前端、CLI 工具三个端,都围着它踩过一圈坑。这篇把这些坑和对应的解法整理出来,包括断线检测、事件信封、断线续流、重连策略和协议纪律。整理下来,有几处体会和我原来想的不太一样:一是断线不是异常,是常态,要设计进去,而不是处理掉;二是客户端断了,后端最好别取消——不是不想,是取消不掉,不如把「跑完」和「有人看」解耦;三是重连不能一刀切,「等服务」和「等人」是两种断法,得区别对待。读完这篇,大伙对 SSE 在真实产品里怎么落地,应该能有些更清晰的感知。

断线是常态,不是异常

先算一笔账。一个 AI Agent 任务跑十几分钟,中间还挂着 HITL 门卡等人确认。这十几分钟里,用户切个网络、合上电脑去开会、顺手刷新一下页面——任何一个动作,都会要了一条 SSE 连接的命。

还不止用户这边。中间链路也在凑热闹:网关有空闲超时,流安静太久就掐;公司 VPN 有心跳,断了重连一次连接就换一条;多实例部署下,重连的请求经过负载均衡,还不一定回到原来那台机器。也就是说,就算你的代码一个 bug 没有,断线照样天天发生。

传统请求-响应模型里,断网是万分之一的异常,弹个 toast 就完了。长连接场景里,它是每天都会发生多次的常态。世界观不同,设计就不同:把断线当异常,思路是「处理掉」——报错、重试、提示用户;把断线当常态,思路是「设计进去」——后端怎么存现场,前端怎么接回来,协议怎么保证不重不漏。这篇讲的都是后者。

断线检测:一个容易被误判的地方

Node 里检测 SSE 客户端断开,直觉写法是监听请求的 close 事件:

// 别这么写,血泪教训
req.on('close', () => abort());

这段代码在老版本 Node 上没问题,升到 18 之后,会话开始莫名其妙地「自杀」。查了两天才发现:Node 18 起,请求体消费完,req 就会触发 close。注意,是请求侧读完了,不是连接断了。POST 的 body 一读完,close 触发,Agent 被自己人 abort 了。

这个坑刁钻在方向上:它不是漏报,是误报。漏报最多浪费点资源,误报是把好好活着的会话杀掉,而且只在升级运行时的那一刻才开始发作,复盘时很难往这上面想。

正确的姿势是监听响应侧。响应流关闭,才是客户端真的走了:

res.on('close', () => {
  disconnected = true; // 只标记,不取消
});

注意注释里那句「只标记,不取消」,这是下一节的主题。

只感知,不取消

客户端都走了,后端为什么不取消任务?省资源不是应该的吗?

答案是取消不掉。我们的 Agent 是 LLM 子进程,通过一个进程内桥接层和主服务通信,这个桥没法真正终止它。流式生成到一半收到 SIGTERM,状态只会更脏。取消按钮按了没反应,不如不装。

所以后端的策略是「只感知,不取消」:断线后照常消费事件、照常发 done、照常把结果写库。前端什么时候回来,走「会话历史快照重取」恢复现场,用户看到的是一个从未中断过的世界。

断线后后端继续跑,前端快照恢复

事件信封与事件溯源

这套玩法靠两个基建撑着。

一是统一的事件信封。所有事件,不管哪个阶段、哪种类型,一个格式:

interface StreamEvent {
  stage: string;      // 阶段:analyze / generate / execute ...
  event_id: number;   // 进程内单调递增,去重和排序全靠它
  ts: number;         // 毫秒时间戳
  session_id: string; // 会话归属
  payload: unknown;   // 真正的内容
}

event_id 进程内单调递增,是续流和去重的基石。没有它,前端收到两个一样的事件,分不清是重发,还是业务上真的发了两次。

二是事件溯源。所有事件 append-only 写进 JSONL,一条不改。有了这个,断线续流就是水到渠成:客户端带着最后收到的 event_id 调续流接口,服务端把之后的事件重放一遍。和看视频断网一个道理——回来拖一下进度条,不用从头播。

前端续流状态机

前端的核心是一台续流状态机,几个设计点依次说。

首连 /run 和续流 /events/stream?afterSeq=N 返回同一份信封,走同一个 dispatch。回放历史和接收实时在代码里是一条路,不存在两套渲染逻辑。去重靠 meta.seq 水位线:比水位低的事件直接丢弃。断线重连那一瞬,服务端补发和实时推送很容易撞车,水位一卡就稳了。

重连策略分两种场景,这是我觉得最值得单独说的设计:

场景重连间隔次数上限设计直觉
普通断流2s / 4s / 8s 指数退避最多 3 次,耗尽提示用户手动重试真挂了的服务,每秒捅一次也活不过来
门卡挂起固定 5s无限次,不消耗退避次数等待用户操作,不算断流故障

HITL 门卡挂起是 AI 流程的特产:Agent 跑到某个节点停下来等人审,人可能去吃饭了,一挂就是几小时。挂起期间连接被网关掐掉是大概率事件——这不是故障,是在等人。所以门卡期间切固定 5s 无限续流;普通断流才走指数退避,三次耗尽就停下来,把选择权还给用户。

正常退避 vs 门卡挂起续流

状态机的骨架大概长这样:

async function pump(afterSeq: number) {
  const gen = ++connectionGeneration; // 连接代数 +1,旧连接的回调全部作废
  try {
    for await (const evt of sse('/events/stream?afterSeq=' + afterSeq)) {
      if (gen !== connectionGeneration) return; // 串台防护
      if (evt.meta.seq <= watermark) continue;  // 水位去重
      watermark = evt.meta.seq;
      dispatch(evt);                            // 回放与实时,同一个出口
      if (evt.type === 'gate') enterGateMode(); // 进入门卡挂起态
    }
  } catch (e) {
    if (inGate) return retryIn(5000);                 // 门卡期:5s 固定,无限次
    if (retry++ < 3) return retryIn(2 ** retry * 1000); // 普通断流:2s/4s/8s
    giveUp(); // 三次耗尽,提示用户手动重试
  }
}

connectionGeneration(连接代数)是防串台用的。断线重连时,旧连接的回调可能没死透——Promise 悬着、定时器挂着,新连接已经建起来了,两个连接同时往 UI 写数据就乱了。解法很朴素:新建连接时全局计数器加一,回调先比对代数,不一致直接返回。旧连接的残党,自然消亡。

CLI:三条写进文档的协议纪律

我做的 CLI 工具消费 SSE 的方式很糙:直接起 curl -N 子进程。代码里留了一行注释:「这条链路上 curl 的流式行为已被验证过。」翻译一下:别换实现,换出问题自己负责。

# 这条链路上 curl 的流式行为已被验证过
curl -N -s "$BASE/run" --data "$BODY"

CLI 这边真正值钱的,是三条协议纪律,都是事故换来的。

第一,门卡的 check-response 必须在 SSE 连接存活期间发出去。 断流后补发会触发服务端降级,降级重试再耗尽,这个节点就被永久跳过。出过一次事故:一个门卡节点被跳过,下游 81559 条用例没生成。数字这么精确,是因为事故报告里就是这么写的。

第二,断线绝不重发启动接口。 启动接口不幂等,每调一次新建一条任务。断线重发,旧任务变孤儿,新任务从头跑,双份资源双份快乐——并没有。断了就走续流接口,带着上次的 seq 回来。

第三,终态判定只认外层任务。 子流程的事件也混在同一条流里,而且子流程事件也带 terminal 标记。看到 terminal 就 break,子流程一结束,主任务就被误杀。终态判定必须校验事件归属。

这三条,没有一条靠代码写得好就能避免,全是协议设计阶段就要想清楚的事。

出口过滤:什么话能跟用户说

LLM 是话痨,会往外吐各种旁白:「好的,我现在开始分析」「让我想想下一步」。调试时有用,怼到用户脸上就是噪音。

我们的做法是双通道。默认通道走文案白名单,只有登记过的用户向文案能出去,模型旁白一律拦下;编排层自己的提示文案标记 {trusted: true},走受控通道放行。两条通道各有独立开关,出问题可以单独灰度、单独回滚。这个设计救过场:一次白名单文案配错,关掉开关就止血,不用发版。

出口事件另有 schema 校验,off / on / warn 三态。新事件源先开 warn,只告警不拦截,观察一阵没问题再切 on。一上来就 on,你会发现全世界的历史事件都不合规。

连接会断,会话别断

细节讲完了,收个尾。SSE 做深了,本质是一件事:把连接和会话解耦。连接廉价、短命、随时会死,刷新一次页面就换一条;会话昂贵、长命、必须永生,跑了十几分钟的进度,不能因为切个网络就没了。

这套思路落到工程上,就是前面各节的合订本:后端用事件溯源把会话从连接里捞出来,断了照跑;前端用水位和连接代数把「接回来」做成确定性动作;CLI 用三条纪律守住协议的边界。每一节单独看都是小事,合起来才是「断线不伤筋动骨」的底气。

这个结构其实不新鲜,TCP 和会话层就是这么分工的:TCP 会断,会话层靠重连和状态恢复往前走。支撑它的是一种双向的信任——前端信后端还在跑,后端信前端会带着 seq 回来。信任到位了,断线就从事故变成了设计里的一环。

连接与会话解耦

SSE 协议本身半小时能讲完,真正的成本都在「断」字上。把断想清楚,连自然就顺了。