前面把 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.delta、tool.started、run.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.delta、tool.started、tool.completed、run.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 是一个只保存 id、type、data 的 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.completed、run.failed、run.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 帧里塞业务判断。
留言
欢迎分享你的想法。评论提交后会在审核通过后显示。