第十六话|SSE 是什么?为什么它会用在 AI Agent 开发中

Agent 会调用模型和工具,但用户怎样实时看到它正在做什么?从 HTTP、SSE 与 WebSocket 的边界出发,亲手跑通一条 Agent Run 事件流。

系列 GYA(Get Your Agent) 第 16 / 12 篇
主题 SSEHTTPWebSocketAI AgentJava

前面把 Agent 的循环、状态、记忆和工具接起来之后,用户仍可能面对一个黑盒。

他发出“帮我检查这个仓库的测试为什么失败”,页面却在几十秒内没有任何变化。Agent 也许正在生成,也许正在调用工具,也许已经失败;用户无从分辨。

问题出在传输方式上:普通 HTTP 是一问一答——客户端发一个请求,服务端算完最后一次性返回完整响应。Agent 干活要几十秒,响应就空白几十秒。要让 Agent 不再像黑盒,前端不只需要最终答案,还要持续接收文本增量、工具状态和 Run 的终态。

SSE(Server-Sent Events)就是把这类服务端到浏览器的连续事件放进 HTTP 的一种标准方式。结论先说:SSE 不负责让 Agent 变聪明;它负责把 Agent 已经发生的事,按事件边界可靠地呈现给 UI。

一、旧方案为什么看不见

“给我一个答案”用普通 HTTP 正合适:提交命令,取回结果,一次交互结束。但“让我看着你干活”不行——服务端在没有完整结果前不会返回响应,浏览器也就拿不到任何进度。

要让用户看见进度,服务端必须在计算完成前就开始输出,并且持续输出。这正是 SSE 站在 HTTP 上的理由:一个响应保持打开,服务端不断写入“有边界的小段文本”。

这里有个经常被混在一起的概念要拆开说清楚。HTTP 持久连接(persistent connection / Keep-Alive)指的是底层连接可以复用、承载多次请求响应,它降低建连成本,但服务端不会在没有新请求时主动推送业务消息。RFC 9112 §9.3 长轮询是应用层写法:服务端先不立即返回,等到有数据或超时才结束响应,浏览器收到后再发起下一次请求。它们都不等于“一个持续打开、服务端主动下推的响应”。

四种常用方式的层次差别,一张表就能看明白:

方式它解决什么通信方向一次交互是否持续Agent 中合适的职责
普通 HTTP提交命令、取得一次结果双向,但一问一答创建 Run、提交用户输入、取消任务、确认敏感工具调用
HTTP 持久连接复用底层连接,减少建连成本仍是多次 HTTP 请求/响应通常由客户端、网关和连接池处理,不是事件通道方案
SSE服务端持续发送有边界的文本事件服务端 → 浏览器文本增量、工具进度、Run 状态、最终结果
WebSocket持续的双向消息双向协同编辑、高频客户端指令、双方都需随时发消息的交互

WebSocket 与 HTTP 的关系也要说准确:它用 HTTP Upgrade 完成开场握手,握手成功后双方交换的是 WebSocket 帧,而不是继续交换 HTTP 响应体。RFC 6455 §4

关键洞察:持久连接是连接复用策略;SSE 是 HTTP 上的事件流格式;WebSocket 是经 HTTP 握手切换出的双向协议。它们不在同一抽象层。

二、亲眼看:SSE 在线上传了什么

浏览器用 EventSource 发起请求,服务端以 Content-Type: text/event-stream 返回响应,并持续写入按行组织的 UTF-8 文本。每个事件以空行结束。WHATWG HTML Standard §9.2

下面是一个完整事件,不是一段“随便输出的文本”:

id: 42
event: text.delta
data: {"runId":"run_123","delta":"正在读取测试报告…"}

四个常用字段的职责如下。

字段协议层含义在 Agent 流中的用法
data事件数据;可以多行,浏览器会按规范合并放 JSON payload,例如文本增量或工具结果摘要
event事件类型;省略时是默认 message区分 text.deltatool.startedrun.completed
id更新浏览器保存的最后事件 ID用作断线续传的游标;服务端仍需校验 Run 归属
retry建议浏览器下一次重连前等待的毫秒数仅调整重连等待,不解决事件去重或业务恢复

注意最后一行的空行不是装饰。它是事件边界:如果连接在空行前结束,尚未完成的事件不会被分发。WHATWG HTML Standard §9.2.5–9.2.6

浏览器端不需要自行解析每一行:

const source = new EventSource(`/runs/${runId}/events`);

source.addEventListener("text.delta", (event) => {
  const { delta } = JSON.parse(event.data);
  appendAssistantText(delta);
});

source.addEventListener("run.completed", () => {
  source.close();
});

EventSource 对断开连接具备重连处理,并在已有事件 ID 时把 Last-Event-ID 带回服务端。它提供的是恢复的协议钩子,不是“消息恰好一次送达”的保证;应用仍要定义续传、去重和终态行为。WHATWG HTML Standard §9.2.3–9.2.4

三、事件的形状由业务决定:Agent Run 事件契约

只把模型 token 一段段传到 UI,用户会看到“它在说话”,但仍不知道它是否在行动。

对一个会调用工具的 Agent,更有用的做法是把 Run 生命周期投影成一组应用层事件:

例如,“检查测试为什么失败”这个 Run 在客户端看来可以是这样一串事件:

event: text.delta
data: {"runId":"run_123","delta":"我先运行测试。"}

event: tool.started
data: {"runId":"run_123","tool":"run_tests","callId":"call_7"}

event: tool.completed
data: {"runId":"run_123","callId":"call_7","summary":"3 个测试失败"}

event: run.completed
data: {"runId":"run_123"}

这些名称不是 SSE 规定的字段值,而是本文建议的业务事件契约。SSE 只规定如何分帧、如何指定事件类型、如何传递最后事件 ID;tool.started 是否存在、数据结构是什么、是否向用户展示工具结果,都由你的 Agent 产品和安全策略决定。

这和 Java 微服务中的“领域事件”和“Kafka 的传输记录”很像:Kafka 不替你定义订单状态机;SSE 也不替你定义 Agent Run 状态机。先定义业务状态和权限边界,再选择传输通道。

四、动手:跑通一条本地 Agent Run 事件流

这一节的 demo 不调用真实模型,避免让 API Key、模型费用或网络问题遮住协议本身。服务端固定模拟一个 Run:它先发送文本增量,再报告工具开始、工具完成,最后发送 Run 完成事件。

配套代码有两份等价实现:Python 位于 GYA 的 c16-sse-agent-stream/,Java 位于 GYA-Java 的同名 Gradle 子模块。正文以 Python 为主,Java 版用于验证同一协议不依赖语言或 Spring 框架。

Python:标准库即可启动

前置环境:Python 3.9+ 与 curl,无第三方依赖、无 API Key。

终端一启动服务:

git clone git@github.com:renxin2024/GYA.git
cd GYA/c16-sse-agent-stream
python3 main.py --once

终端二订阅:

curl -N http://127.0.0.1:8765/events

我在本机实际收到的输出如下:

id: 1
event: text.delta
data: {"runId":"run_demo","delta":"我先运行测试。"}

id: 2
event: tool.started
data: {"runId":"run_demo","tool":"run_tests","callId":"call_1"}

id: 3
event: tool.completed
data: {"runId":"run_demo","callId":"call_1","summary":"3 个测试失败"}

id: 4
event: run.completed
data: {"runId":"run_demo"}

这里最重要的不是 sleep(0.1),而是每次写完一帧后立刻 flush,并且用最后那个空行完成事件。把空行删掉,再用浏览器 EventSource 接收时,读者会发现它不会把这一帧当作完成的事件。

Java:同一事件流,不换协议

前置环境:JDK 21 与 curl。GYA-Java 使用仓库自带的 Gradle Wrapper;首次运行会按仓库配置下载 Gradle 和 Jackson。

终端一启动:

git clone git@github.com:renxin2024/GYA-Java.git
cd GYA-Java
./gradlew :c16-sse-agent-stream:run --args="--once"

终端二订阅:

curl -N http://127.0.0.1:8766/events

Java 版构建通过,并按相同顺序输出 text.deltatool.startedtool.completedrun.completed。核心的分帧代码和 Python 同构,只是把字符串拼接换成了 Jackson 序列化 payload:

private static byte[] encode(Event event) throws IOException {
    // SSE 是 UTF-8 的按行协议。最后的空行是事件边界;删除它会让
    // EventSource 持续等待,而不是把前面的字段作为一条完整事件分发出去。
    String frame = "id: " + event.id() + "\n"
            + "event: " + event.type() + "\n"
            + "data: " + JSON.writeValueAsString(event.data()) + "\n\n";
    return frame.getBytes(StandardCharsets.UTF_8);
}

Event 是一个只保存 idtypedata 的 record;EVENTS 里和 Python 一样放着四个固定事件。既然协议的“形状”完全由分帧代码决定,换语言、换框架都不会改变这条事件流的语义——这正是把协议层与业务层分开的价值。

如果 Java 版本因 toolchain 无法启动,先运行 java -version 确认实际环境是 JDK 21。这个 demo 故意保持 Java 21,不为了迁就本机默认 JDK 而降低系列约束。另外,不要把 Python 与 Java 输出的 JSON 键顺序当作正确性标准:JSON 字段顺序不属于 SSE 契约。

五、能连通 ≠ 体验到好:三个坑

连得上、有输出,不代表体验是对的。下面是三个最常见的“能连通、但体验很差”的问题。

1. 服务端在发,用户却最后一次性看到

SSE 需要端到端按事件及时传输。规范明确提醒:不恰当的块缓冲可能延迟事件分发。WHATWG HTML Standard §9.2.5

因此排查顺序不该从“模型为什么这么慢”开始,而应沿着浏览器 → CDN/网关 → 反向代理 → 应用服务器逐跳确认:响应类型是否正确、是否有缓冲、应用是否真的在事件产生时写出。curl 先关掉客户端缓冲(curl -N);如果 curl 能一帧帧出来、浏览器不行,问题大概率在中间层。具体关闭何种缓冲取决于网关产品和部署方式,不能从一段通用配置照抄。

2. 断线后重复显示,或者漏掉关键状态

浏览器会携带 Last-Event-ID 重连,但服务端必须决定这个 ID 的语义:它是某个 Run 内单调递增的序号,还是全局事件 ID?事件保留多久?补发窗口过期时如何回退?

一个可维护的最小约定是:runId + eventId 唯一;客户端按该组合去重;服务端只允许已授权的主体按 runId 查询或续传。这里的授权检查不能因为客户端带了一个事件 ID 就被跳过。

3. Run 已结束,连接和资源还在等待

run.completedrun.failedrun.cancelled 这类终态应有清晰语义:服务端发送终态事件后结束流,客户端收到后关闭 EventSource 并更新界面。心跳注释可以帮助发现中间链路的空闲超时,但心跳不应伪装成业务进度。MDN SSE 指南

关键洞察:SSE 解决“事件怎样流到浏览器”;事件是否可恢复、可授权、可解释,仍是 Agent Run 模型的责任。

六、回到开场:黑盒变成了可见的事件流

现在回头看开头的那个 Run。“帮我检查这个仓库的测试为什么失败”在 demo 里变成了一条可见的时间线:先是“我先运行测试。”的文本增量,然后是工具开始、工具完成(3 个测试失败),最后 Run 完成。用户不再对着空白页面猜 Agent 是不是卡死了——他能看到 Agent 正在做什么、做到哪了、结果如何。

这就是这一话真正改变的东西:“它在动”从结论变成了过程。 SSE 不负责让 Agent 变聪明,它负责把 Agent 正在做和已经做的事,按事件边界可靠地送到浏览器;之后要不要展示工具结果、要不要拒绝敏感调用,仍然是产品与安全决策。

七、选型:什么时候选 SSE,什么时候不选

如果 UI 的主要需求是“服务端持续告诉用户发生了什么”,SSE 是很自然的第一候选:浏览器有原生 EventSource,协议有事件类型和重连语义,接入普通 HTTP 基础设施的成本通常较低。

如果客户端也需要高频、低延迟地主动发消息,例如多人协同编辑、实时控制面板或双向交互游戏,再评估 WebSocket。若只是提交用户问题、取消 Run 或同意执行高风险工具,普通 HTTP 往往更直观,也更容易把请求、权限校验和审计记录放在清晰的命令边界上。

不要用“WebSocket 更实时”替代选型分析。对 Agent 来说,最关键的问题是:消息主要朝哪个方向流?是否需要浏览器原生重连与事件 ID?你的 Run 是否有可恢复的状态模型?

八、下一步

这一话放在 GYA 的生产级补充位置:它不增加 Agent 的推理能力,却决定用户能否看见、理解和恢复一次正在运行的 Agent 任务。

从开场那个 Run 继续往后走,下一步应把同样的事件契约接到真实的 Run 状态存储、鉴权与可观测性中——而不是继续往 SSE 帧里塞业务判断。

参考资料

  1. WHATWG HTML Standard — Server-sent events
  2. RFC 9112 — HTTP/1.1 Persistence
  3. RFC 6455 — The WebSocket Protocol
  4. MDN — Using server-sent events
  5. Spring Framework — MVC SSE
  6. Spring Framework — WebFlux return values

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。