TL;DR(面试前记这一段):Agent 把生成文字、调用工具、任务结束这些变化变成事件,客户端通过 SSE 持续接收并更新界面。任务执行和网络发送分开,事件先存进有大小限制的缓冲区,再分别发给各个客户端,避免一个客户端接收得慢就拖住整个任务。断线后任务继续执行,客户端重连时带回标志该任务的幂等键和最后处理的事件编号,服务端从后面补发,避免重复执行;重复的跳过,旧事件已经清理、补不全时,就重新读取已保存的消息和任务状态。自动重连不等于自动补发,服务端必须保存事件并实现补发。

Agent 执行一个任务,可能要生成文本、调用工具、等待结果,再继续处理。客户端如果只等最后的响应,用户就看不到中间发生了什么。流式传输的做法是:服务端每完成一小步,就把这一步的变化发给客户端,客户端随时更新界面。

要把它做好,除了能及时发送,还要处理两个常见情况:客户端接收得慢,以及连接中途断开。基本做法是把任务执行和网络发送分开,保存最近的事件,让客户端记住自己收到的位置。

一、围绕事件为核心:把执行过程变成客户端能理解的事件#

模型生成了一段文字、工具开始执行、工具返回结果、任务完成,都可以产生一条事件。事件里要说明发生了什么、属于哪个任务和哪条消息,以及具体内容。客户端据此追加文字、更新工具状态,或者结束等待。

事件可以带“新增的内容”,也可以带“当前的完整内容”,但两端必须约定清楚。例如收到一段文字时,客户端需要知道应该追加到原文后面,还是替换原文,否则重复发送一次就可能把界面弄错。

为了后续补发,需要给同一任务的事件按顺序编号。事件编号表示先后位置,消息编号表示内容属于哪条消息,两者用途不同。客户端应用完一条事件后,记下它的编号,已经处理过的事件再次到达时就跳过。

二、传输协议:客户端主动连接,服务端持续发送#

在这里,网页或终端是客户端,运行 Agent 的程序是服务端。客户端主动连接服务端,服务端需要监听端口,并提供客户端能访问到的地址。 客户端接收推送不需要另外开放端口,服务端通过已经建立的连接把数据发回来。

如果只需要偶尔查看任务状态,客户端定时请求一次就够了,这叫轮询。长轮询则让服务端等到有新内容或请求超时再回复,客户端收到后继续发下一次请求。如果需要连续显示文字和执行进度,用 SSE 通常更直接:客户端发起 HTTP 请求,服务端保持响应打开,按照约定的格式不断写入事件,响应类型是 text/event-stream。

WebSocket 允许双方通过同一条连接持续发送消息,适合双方都要频繁实时发送的场景。普通 Agent 对话用 SSE 接收输出,另发 HTTP 请求提交问题、取消任务,通常就够用。WebSocket 本身不提供断线补发,选择它也仍然需要实现事件保存和恢复。

浏览器提供的 EventSource 可以解析 SSE,并在连接中断后自动尝试重连,但只能发 GET 请求,不能自定义请求头,而且需要服务端实现重连时的补发机制。需要 POST 或 Bearer 认证头时,可以用 fetch 读取响应流,配合 SSE 解析库处理事件;重连和重试需要由应用或所用的库负责。SSE 是数据格式,使用它不等于必须使用 EventSource。

三、流程设计:“执行任务—存入内存—发送事件—断点续传”#

任务要能在订阅断开后继续运行,且不应阻塞等待某个客户端接收数据。 常用实现是:Agent 把新事件写入该任务的缓冲区,也就是暂存最近事件的地方;服务端另外为每个订阅客户端安排发送逻辑,各自按顺序读取事件并发送。这样一个任务可以被多个客户端查看,某个客户端变慢不会直接卡住任务和其他客户端,避免慢消费者阻塞生产者。

这里保存着三种不同的信息:服务端按顺序保留事件内容;每条订阅的发送逻辑记着下一条准备发什么;客户端记着最后成功处理了哪条。发送成功不等于客户端已经处理成功,所以重连时应以客户端带回的位置为准。服务端可以在连接结束时丢掉这条订阅的临时位置,之后根据客户端提供的编号重新开始读取。

断点续传时,无论是网络断线还是页面刷新,都要用原任务 ID 和最后成功处理的事件编号(Last-Event-ID)重新订阅原任务。 服务端从这个位置之后开始补发,再继续发送新事件,补发期间产生的事件也不能漏掉。原任务 ID 用来确定“继续看哪个任务”,Last-Event-ID 用来确定“从哪里接着看”;这两个值如何找回,这里不展开。

SSE 正文里的 id: 用来标记事件编号。使用原生 EventSource 时,网络中断后,同一个对象会自动尝试重连,并通过 Last-Event-ID 请求头回传它记录的事件编号。 页面刷新后原对象已销毁,需要应用重新建立订阅并带回恢复位置;使用 fetch 读取 SSE 时,重连由应用或所用的库负责。自动重连不等于自动补发,服务端仍然必须保存事件并实现补发。

重复到达的事件按编号跳过;旧事件已被清理、无法补全时,服务端要明确告知存在缺口,客户端重新读取已保存的消息和任务状态,任务仍在运行就从这份状态对应的位置继续订阅。收到任务结束事件后,客户端主动关闭订阅,避免 EventSource 再次重连。

服务端可以分别提供提交任务、订阅任务事件、读取已保存结果的接口;取消任务通过明确的请求完成。也可以把提交和首次订阅合在一个接口里,但重连时必须能找回原任务,不能每连一次就重新执行。

部署多个服务端实例时,关键是处理订阅请求的实例能否读到任务事件。只存在某个进程内存里的事件,其他实例读不到;需要跨实例重连时,可以使用共享存储。已有连接由接收它的实例负责发送,断开后的新连接可以落到另一个能读取这些事件的实例,并不因为使用了长连接就必须选主。

四、提交请求超时,重试不能再执行一遍#

还有一种断线发生在任务刚提交时:服务端可能已经开始执行,但客户端没收到响应,连任务编号都不知道。此时客户端需要重发提交请求,服务端要能识别这是同一次提交。

做法是客户端在首次提交前生成一个请求标识,重试时继续使用同一个标识,这通常叫幂等键。服务端记录这个标识对应的任务,再收到同样的请求就返回原任务,而不是新建任务;同一个标识带了不同内容则拒绝。检查和创建需要一起协调,避免两个同时到达的重试都创建成功。这个标识还要关联调用用户,多实例部署时也要能共享查询结果。

拿到原任务编号后,客户端就可以继续订阅;已经收到过部分事件的,再带上最后处理的事件编号补发。用户真正提交下一轮任务时,要换一个新的请求标识。

五、客户端接收得慢,数据不能无限积压#

客户端接收得慢时,底层网络会限制发送速度,但不会自动限制应用层的事件积压。 可以把数据经过的位置理解为:

Agent 生成事件 → 应用层事件缓冲 → 服务端网络发送缓冲 → 网络 → 客户端接收缓冲 → 浏览器处理事件

以 TCP 为例,客户端来不及读取、接收缓冲区逐渐填满时,会通过接收窗口告诉发送端减少发送,窗口为零时暂停发送新的数据。服务端发送缓冲区也满了以后,阻塞式写入会等待;非阻塞式写入可能只写入一部分,或返回“暂时不可写”,应用需要等待可写后再继续。异步框架通常通过等待可写通知处理这种情况,不一定阻塞整个线程。这叫流量控制,不是浏览器主动拒绝 SSE 事件;而且页面更新慢,也不一定代表底层读取慢,浏览器可能仍在接收并缓存数据。

底层暂时发不出去时,如果 Agent 仍然持续生成事件,尚未发出的内容就会积压在应用层。 如果 Agent 每生成一段内容就等待网络发送,慢客户端就会拖住任务;如果发送逻辑独立运行,等待通常只影响这一条订阅,但任务事件缓冲、框架内部的发送队列仍要设置上限,不能把“独立发送”理解为无限排队。

应用可以根据业务选择处理方式:

发送等待不会必然在固定时间后自动断开,更不会因此关闭整个服务。 应用、代理或底层网络可能因各自的超时策略关闭连接,也可能长时间等待。服务端可以设置写入超时或最大落后范围,超过限制就结束这条订阅、释放资源,任务仍按第三节的设计继续运行。

以上清理事件、只保存当前内容或结果的方式,前提是业务允许丢失中间过程。恢复当前界面,不等于恢复所有中间事件。 如果要求逐条事件都不能丢,就需要持久保存事件,并在存储跟不上时限制生成速度或拒绝更多任务,不能仅靠删除旧事件控制内存。