加载动画无法提供任何信息。当一个 AI 任务持续数分钟,或者因为第三次重试而重新进入队列时,你需要看到它的实时状态。Server-Sent Events (SSE) 可以为你提供这种可见性,且无需 WebSockets 那样的握手开销,也不需要长轮询 (long polling) 那样的复杂编排。服务器只需保持单个 HTTP 响应开启,并在情况发生变化时推送纯文本更新,客户端则在数据到达时即刻读取。
如果连接断开,你可能并不希望从头开始。一个构建良好的 SSE 流会记住你当前的位置。仅使用 Node.js 20 和标准库,你就可以实现这一点,无需任何外部依赖包。
传输格式是什么样的
SSE 消息是简单的文本。服务器会写入三项内容:一个可选的事件名称 (event name)、一个必填的 data 字段,以及一个作为保存点的 id 字段。每条记录都以两个换行符结尾——即一个标记边界的空行。
在传输线路上,一个健康的流可能看起来像这样:
id: 14
event: status
data: {"phase":"testing","progress":43}
id: 15
event: status
data: {"phase":"retrying","attempt":2}
浏览器的 EventSource 客户端会自动读取这些行。它会为每个数据块触发一个事件,并在内部存储最新的 id。如果 TCP 连接断开,客户端会等待并尝试重新连接,然后将存储的标识符作为 Last-Event-ID 请求头发送回服务器。这个请求头正是该模式能够奏效的核心原因。如果没有它,你就无法拥有持久化的游标。
在 Node.js 中搭建服务器
Node 内置的 http 模块可以直接处理这一点。当请求进入时,设置正确的响应头,以便客户端知道这是一个流而不是一个普通的页面:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
移除缓冲。代理服务器和框架有时会批量处理响应,这会破坏实时感,因此请在发送每个数据块后立即执行 flush。
先发送 ID,然后是事件类型,接着是有效载荷数据,最后是结束用的空行。顺序之所以重要,是因为 ID 必须在空行之前到达,以便客户端能够捕获它。如果你使用的是原生的 response.write(),输出内容实际上是:
response.write(`id: ${cursor}\n`);
response.write(`event: ${eventName}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);
末尾的 \n\n 并非装饰。SSE 解析器将其视为记录终止符。如果遗漏了它,客户端会一直挂起等待更多数据。
游标至关重要
新的 HTTP 连接并不保证能获得最新的状态。当客户端重新连接时,Last-Event-ID 请求头会告诉你他们收到的最后一条消息。你的任务是从下一条消息开始恢复,而不是从头开始。
这意味着需要在服务器端维护一个有序的事件日志或日志文件。在演示环境中,使用内存数组即可;但在生产环境中,你需要更持久的方案——例如追加到数据库日志、Redis stream 或预写日志 (write-ahead journal) 中——因为服务器重启不应抹除历史记录并迫使每个客户端从零开始。
使用单调递增的整数或 ULID 对你的事件进行索引。当重连请求进入时,查询 id > lastEventId 的事件,并按顺序重放它们。如果你有数百条积压的消息,可以加入微小的延迟或进行分批发送,但务必按时间顺序(从旧到新)发送,以便客户端能够按时间线重建状态。
预见重复
网络并非百分之百可靠。服务器可能会发送一个事件,但丢失了 TCP 确认包,然后在超时后再次发送。从一开始设计时,就要针对“至少一次交付” (at-least-once delivery) 进行考虑。
在客户端,去重成本很低。可以维护一个以事件 ID 为键的 Map。当新事件到达时,检查该 Map。如果 ID 已存在,则静默丢弃该重复项。由于你的服务器分配的是确定性的 ID,这使得重复项变得无害。Map 不需要无限增长,一旦确认某个事件已安全处理,就可以移除旧的 ID。对于浏览器客户端,几百个条目的滑动窗口通常就足够了。
当游标过期时
最终,客户端可能会在数小时或数天后重新连接。如果你的历史记录缓冲区仅覆盖最近的一千条事件,而客户端落后了两千条,那么重放缺失的部分是不可能的。
不要流式传输部分历史记录,这会导致客户端处于不一致的状态。相反,应该检测到游标已过期,并发送一个完整的快照 (snapshot) 作为下一个事件。快照应该携带一个新的游标,将客户端锚定到当前状态。从那时起,实时增量 (deltas) 即可恢复正常。在你的协议中清晰地记录这一边界,以便客户端代码知道何时应该重置其本地模型,而不是进行追加。
保护流
开放的 SSE 端点是极具吸引力的攻击目标。任何人都可以长时间占用连接,而重放请求可能会放大对你存储层的读取负载。
为端点设置适当的授权机制。由于浏览器的 EventSource 不支持自定义请求头,请将 Token 放在查询字符串中,或者使用具有严格 SameSite 策略的 Cookie。在分配流资源之前,请务必验证 Token。
设置历史记录限制和每个用户的配额。限制每个任务存储的事件数量,并限制每个客户端的并发连接数。记录断开连接和重放行为,以便你能发现频繁请求游标端点的异常客户端。
模式具有通用性
这种方法并不仅限于 HTTP。当你转向 WebSockets、消息队列或智能体间(agent-to-agent)接口时,同样的规则依然适用。传输方式可能会发生变化——你可能会使用二进制帧或主题订阅——但底层问题是完全相同的。你需要一个游标、一个持久化日志、至少一次(at-least-once)语义、客户端去重,以及在游标失效时回退到全量快照的机制。只要解决了一次状态收敛问题,你就可以将其通过 TCP、WebSocket 或像 RabbitMQ 这样的消息代理进行传输,而无需重新设计核心逻辑。
保持简单
Server-Sent Events 之所以有效,是因为它们基于普通的 HTTP。代理服务器可以识别它们,负载均衡器可以对其进行健康检查,调试也像使用 curl 一样简单。但如果你忽略了边缘情况,这种简单性就会消失。构建游标,预料到重放,在客户端进行去重,并在历史记录用尽时进行快照。做到这些,你的长时间运行的 AI 任务就能诚实地报告其进度,即使在 Wi-Fi 信号不稳定、服务器重启或浏览器偶尔在夜间进入休眠的情况下也是如此。
