Skip to content
阅读进度0%
Agent待复核

为什么流式接口经常返回 streamUrl:从一次 Agent 请求理解流式任务架构

CSDN 原文全文镜像:文章摘要:文章探讨了复杂Agent系统中streamUrl的设计原理。传统聊天系统采用单请求流式响应,而Agent系统将任务创建(POST /runs)与事件订阅(GET /events)分离,通过streamUrl实现执行与观察的解耦……

CSDN 原文镜像

本文为作者 CSDN 博客的全文镜像,原文发布于 2026-08-10。为适配本站结构,仅补充了站内元数据与来源说明,正文主体保持原文内容。

在这里插入图片描述

前言

在接入大模型、Agent 或异步任务系统时,我们经常会看到接口返回类似这样的结构:

json
<span class="token punctuation">{<!-- --></span>
<span class="token string-property property">"runId"</span><span class="token operator">:</span> <span class="token string">"run_001"</span><span class="token punctuation">,</span>
<span class="token string-property property">"messageId"</span><span class="token operator">:</span> <span class="token string">"msg_001"</span><span class="token punctuation">,</span>
<span class="token string-property property">"status"</span><span class="token operator">:</span> <span class="token string">"running"</span><span class="token punctuation">,</span>
<span class="token string-property property">"streamUrl"</span><span class="token operator">:</span> <span class="token string">"/api/runs/run_001/events"</span>
<span class="token punctuation">}</span>

很多第一次看到 streamUrl 的人会有一个疑问:

既然已经调用了接口,为什么不直接在当前请求里持续返回流式内容,还要额外返回一个 URL?

实际上,这背后反映的是两种完全不同的系统设计思路。

简单聊天系统往往把“创建任务”和“接收流式结果”放在同一个 HTTP 请求里;而复杂 Agent 系统更倾向于把这两件事情拆开:

text
创建任务
+
订阅任务事件

streamUrl 的本质,就是后者。

它不是一个“下载最终答案的地址”,而是:

某个持续运行任务对应的实时事件订阅入口。

理解这一点之后,Agent 中的 SSE、Run、断线恢复、页面刷新、事件回放等设计就能串起来了。


一、最简单的流式接口是怎么做的

先从普通大模型聊天开始。

用户发送请求:

http
POST /api/chat

服务端收到请求后调用模型,并直接通过当前 HTTP Response 持续向客户端写入内容:

text
data: {"delta":"你好"}

data: {"delta":",我是"}

data: {"delta":"智能助手"}

data: [DONE]

整个生命周期可以理解为:

text
发送请求

模型开始生成

当前 HTTP 连接持续返回 Token

模型完成

连接关闭

这种设计非常简单。

前端只需要发一次请求,然后一直读取响应流即可。

对于普通 Chatbot,这通常完全够用,因为一次请求的生命周期非常清晰:

text
用户提问

模型生成

生成完成

问题在于,Agent 往往不是这样。


二、Agent 的生命周期通常比 HTTP 请求复杂得多

假设用户提交一个任务:

帮我分析这份 300 页的招标文件,并生成风险报告。

后台可能经历:

text
创建 Agent Run

读取文件

PDF 解析

OCR

知识库检索

模型分析

调用工具

生成报告

保存 Artifact

Run 完成

整个过程可能持续几十秒,甚至几分钟。

期间用户可能:

  • 切换到另一个会话;
  • 刷新页面;
  • 关闭浏览器;
  • 网络断开;
  • 稍后重新进入;
  • 在另一个标签页查看任务;
  • 等 Agent 跑完后再回来。

如果 Agent 的运行生命周期完全绑定在最开始那条 HTTP 请求上:

text
POST /agent/run

│ 长时间保持连接


Agent Runtime

就会遇到一个很明显的问题:

text
页面刷新

HTTP 连接断开

Agent 怎么办?

理想情况下,Agent 应该继续在后台运行。

但此时原来的 HTTP Response 已经不存在了。

这说明:

Agent Run 和浏览器当前这条网络连接,本来就不应该是同一个东西。

于是系统开始把两者拆开。


三、streamUrl 的核心:把“执行任务”和“观察任务”分离

更成熟的设计通常是:

text
第一步:创建任务
第二步:订阅任务事件

例如:

http
POST /api/runs

服务端创建 Agent Run 后立即返回:

json
<span class="token punctuation">{<!-- --></span>
<span class="token string-property property">"runId"</span><span class="token operator">:</span> <span class="token string">"run_001"</span><span class="token punctuation">,</span>
<span class="token string-property property">"status"</span><span class="token operator">:</span> <span class="token string">"running"</span><span class="token punctuation">,</span>
<span class="token string-property property">"streamUrl"</span><span class="token operator">:</span> <span class="token string">"/api/runs/run_001/events"</span>
<span class="token punctuation">}</span>

这时 POST /api/runs 已经结束了。

Agent 则继续在后台运行。

前端随后拿 streamUrl 建立 SSE:

javascript
<span class="token keyword">const</span> source <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name">EventSource</span><span class="token punctuation">(</span>
<span class="token string">"/api/runs/run_001/events"</span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>

于是整个架构变成:

text
                创建任务

前端 ── POST ──→ Agent Run

│ 后台继续执行

Event Stream


前端 ── GET ─── streamUrl

此时:

text
Run

代表真正的后台执行。

而:

text
streamUrl

只代表:

当前客户端通过什么地址观察这个 Run 的实时变化。

这是 Agent 架构中非常重要的一次解耦。


四、streamUrl 到底是什么

streamUrl 并不是 SSE 协议规定的标准字段。

它只是业务系统常用的一个命名。

也可能叫:

text
eventsUrl
subscribeUrl
streamEndpoint
eventStreamUrl

本质都一样:

指向一个可以建立持续事件连接的 HTTP 地址。

例如:

text
/api/runs/run_001/events

这个接口通常返回:

http
Content-Type: text/event-stream

然后持续发送事件:

text
event: run.started
id: 1001
data: {"runId":"run_001"}

event: message.item.created
id: 1002
data: {"itemId":"text_001"}

event: message.item.delta
id: 1003
data: {"delta":"我会先分析"}

event: tool.started
id: 1004
data: {"tool":"parse_document"}

event: tool.completed
id: 1005
data: {"tool":"parse_document"}

event: message.item.delta
id: 1006
data: {"delta":"这份文件存在三项风险"}

event: run.completed
id: 1007
data: {"runId":"run_001"}

因此,更准确地说:

text
streamUrl
=
Run Event Stream Subscription URL

也就是:

某一次 Run 的事件订阅地址。


五、为什么 Agent 更适合“POST 创建 + GET SSE”

这其实是一个控制面和事件面的分离。

可以把整个 Agent 系统理解成:

text
Command
+
Event

用户通过普通 HTTP 接口发出命令:

text
发送消息
取消任务
重试任务
确认审批

例如:

http
POST /runs
POST /runs/{id}/cancel
POST /approvals/{id}/approve

而 Agent 的运行变化则通过流式接口持续推送:

text
run.started
plan.updated
message.delta
tool.started
tool.completed
artifact.created
approval.required
run.completed

于是整个架构非常清晰:

text
REST API
负责告诉 Agent:
“你要做什么”

SSE
负责告诉前端:
“Agent 现在发生了什么”

这比把所有事情强行放在一条长连接中更加适合复杂 Agent 系统。


六、为什么关闭 streamUrl 不应该停止 Agent

一旦 Run 和 Stream 被拆开,一个重要原则就出现了:

text
关闭 SSE

取消 Run

假设用户正在查看:

text
Conversation A

Agent 正在执行。

用户切换到:

text
Conversation B

前端可以关闭 Conversation A 对应的 EventSource:

javascript
eventSource<span class="token punctuation">.</span><span class="token function">close</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

这只是表示:

当前页面不再订阅 Conversation A 的实时事件。

但后台 Agent 仍然继续执行:

text
Agent Runtime

调用模型

执行工具

保存事件

更新状态

如果用户真的想停止任务,应该调用单独的取消接口:

http
POST /api/runs/run_001/cancel

所以应该明确区分两个动作:

text
unsubscribe
停止看

cancel
停止做

这是设计 Agent UI 时非常重要的概念。


七、streamUrl 为什么天然适合页面刷新和重新进入

这也是它最有价值的地方之一。

假设 Agent 当前运行到:

text
Event 1005

前端已经收到:

text
1001
1002
1003

随后用户刷新页面。

刷新后:

text
EventSource 对象消失
前端内存消失
原来的 HTTP 连接消失

但后台 Agent 并不会因此停止。

它继续产生:

text
1004
1005
1006

用户重新进入页面时,可以先获取会话状态:

http
GET /api/conversations/conv_001

返回:

json
<span class="token punctuation">{<!-- --></span>
<span class="token string-property property">"activeRun"</span><span class="token operator">:</span> <span class="token punctuation">{<!-- --></span>
<span class="token string-property property">"id"</span><span class="token operator">:</span> <span class="token string">"run_001"</span><span class="token punctuation">,</span>
<span class="token string-property property">"status"</span><span class="token operator">:</span> <span class="token string">"running"</span><span class="token punctuation">,</span>
<span class="token string-property property">"streamUrl"</span><span class="token operator">:</span> <span class="token string">"/api/runs/run_001/events"</span><span class="token punctuation">,</span>
<span class="token string-property property">"lastEventId"</span><span class="token operator">:</span> <span class="token string">"1003"</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span>

这时前端重新连接:

text
streamUrl
+
lastEventId

就可以继续恢复。

因此可以这样理解:

text
streamUrl
回答:
“去哪里接收事件?”

lastEventId
回答:
“从哪里继续?”

这两个参数通常天然配套。


八、streamUrl 和 Event Replay 是如何配合的

一个生产级 Agent 系统通常不会只做实时 SSE,而会保存事件日志。

例如:

text
1001 run.started
1002 message.item.created
1003 message.item.delta
1004 tool.started
1005 tool.completed
1006 message.item.delta
1007 run.completed

假设客户端最后确认收到:

text
1003

然后断线。

重新连接时:

http
GET /api/runs/run_001/events?after=1003

服务端先补发:

text
1004
1005
1006
1007

然后如果 Run 还没有结束,再进入实时等待。

整个流程就变成:

text
连接 streamUrl

读取 after / lastEventId

补发历史事件

追平当前状态

继续监听实时事件

因此,一个完整的 stream 接口往往同时承担两种职责:

text
Replay
+
Live Stream

既能补历史,又能接实时。


九、为什么还需要 Snapshot,不能只靠 streamUrl

有了事件日志之后,一个新的问题出现了。

假设一个会话已经运行很久,产生了 10 万条 message.delta

用户重新进入页面时,如果从第一条事件开始重放:

text
1
2
3
……
100000

显然很低效。

所以系统通常还会保存 Snapshot。

例如数据库中保存:

text
agent_message.content
run.status
tool_call.status
artifact
plan

用户进入页面时:

text
先读取 Snapshot

快速恢复当前完整界面

然后通过:

text
streamUrl + lastEventId

补上 Snapshot 之后的新事件。

因此,Agent 状态恢复通常采用:

text
Snapshot
+
Event Log
+
Live Stream

三者职责分别是:

text
Snapshot
告诉你:
“现在是什么样”

Event Log
告诉你:
“中间发生了什么”

Live Stream
告诉你:
“现在正在发生什么”

streamUrl 主要负责后两部分。


十、为什么后端直接返回 streamUrl,而不是前端自己拼

很多系统的 stream 地址其实非常规律:

text
/api/runs/{runId}/events

那么前端完全可以:

javascript
<span class="token keyword">const</span> streamUrl <span class="token operator">=</span>
<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">/api/runs/</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${<!-- --></span>runId<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">/events</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">;</span>

这是可以的。

但在更复杂的系统里,让后端返回 streamUrl 会更灵活。

首先,它可以减少前后端对 URL 结构的耦合。

如果以后接口从:

text
/api/runs/{id}/events

变成:

text
/api/v2/streams/{streamId}

前端无需修改 URL 拼接逻辑,只需要继续使用:

javascript
<span class="token keyword">new</span> <span class="token class-name">EventSource</span><span class="token punctuation">(</span>result<span class="token punctuation">.</span>streamUrl<span class="token punctuation">)</span><span class="token punctuation">;</span>

其次,Stream 本身可能逐渐成为独立资源。

例如:

text
runId = run_001

streamId = stream_8899

此时:

text
Run

和:

text
Stream

甚至不一定严格一对一。

此外,后端还可以直接返回带鉴权能力的临时地址:

text
https://stream.example.com/events/abc
?token=xxx
&expires=...

这样前端无需理解:

  • Stream 服务部署在哪里;
  • Token 如何签名;
  • 当前应该连接哪个区域;
  • 网关如何路由;
  • Stream ID 如何生成。

十一、streamUrl 还能帮助独立部署 SSE Gateway

随着 Agent 平台规模增加,普通 API 和 SSE 长连接的运行特征会越来越不一样。

普通 API 通常是:

text
请求

快速处理

返回

连接结束

而 SSE 是:

text
建立连接

持续保持几分钟

不断发送事件

Run 结束后关闭

两者对于基础设施的要求不同。

SSE 更关注:

  • 长连接数量;
  • Connection Timeout;
  • Nginx Buffering;
  • Keepalive;
  • 网关最大连接数;
  • 心跳;
  • 断线检测;
  • 水平扩容。

因此大型架构可能逐渐拆成:

text
              ┌── Agent API Service
客户端 ───────┤
└── SSE Gateway

创建任务:

text
POST https://api.example.com/runs

返回:

json
<span class="token punctuation">{<!-- --></span>
<span class="token string-property property">"runId"</span><span class="token operator">:</span> <span class="token string">"run_001"</span><span class="token punctuation">,</span>
<span class="token string-property property">"streamUrl"</span><span class="token operator">:</span> <span class="token string">"https://stream.example.com/runs/run_001/events"</span>
<span class="token punctuation">}</span>

然后浏览器直接连接专门的 Stream 服务。

此时 streamUrl 就不仅仅是一个接口路径,而是:

后端告诉客户端此次任务应该去哪里订阅事件。


十二、streamUrl 和 WebSocket 有什么不同

WebSocket 的典型模型是:

text
Browser

WebSocket

Server

客户端和服务器都可以在同一条连接上主动发送消息。

因此可以把:

text
发送消息
工具状态
Token 输出
审批操作

全部塞进同一条 WebSocket。

而 SSE 通常采用:

text
普通 HTTP
负责上行控制

SSE
负责下行事件

例如:

text
POST /runs
POST /runs/{id}/cancel
POST /approvals/{id}/approve

GET /runs/{id}/events

这非常符合大多数 Agent 产品的交互特点。

用户主动操作并没有那么高频,通常只是:

text
发送问题
点击取消
确认审批
重新执行

而服务端事件却很多:

text
message.delta
tool.started
tool.completed
plan.updated
artifact.created
run.progress
run.completed

也就是:

text
客户端 → 服务端
低频

服务端 → 客户端
高频

这种情况下:

text
REST + SSE

往往非常自然。


十三、一个推荐的 Agent Stream 接口设计

创建 Run:

http
POST /api/conversations/{conversationId}/messages

返回:

json
<span class="token punctuation">{<!-- --></span>
<span class="token string-property property">"messageId"</span><span class="token operator">:</span> <span class="token string">"msg_001"</span><span class="token punctuation">,</span>
<span class="token string-property property">"runId"</span><span class="token operator">:</span> <span class="token string">"run_001"</span><span class="token punctuation">,</span>
<span class="token string-property property">"status"</span><span class="token operator">:</span> <span class="token string">"running"</span><span class="token punctuation">,</span>
<span class="token string-property property">"streamUrl"</span><span class="token operator">:</span> <span class="token string">"/api/runs/run_001/events"</span>
<span class="token punctuation">}</span>

前端连接:

javascript
<span class="token keyword">const</span> source <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name">EventSource</span><span class="token punctuation">(</span>streamUrl<span class="token punctuation">)</span><span class="token punctuation">;</span>

Stream 接口:

http
GET /api/runs/run_001/events

支持:

text
after
Last-Event-ID

事件格式:

text
event: message.item.delta
id: 1058
data: {...}

建议至少包含:

text
eventId
sequence
type
runId
conversationId
timestamp
payload

Run 结束时明确发送:

text
run.completed
run.failed
run.cancelled

前端收到终态事件后关闭连接。

如果页面刷新,则:

text
读取 Conversation Snapshot

识别 activeRun

拿到 streamUrl

拿到 lastEventId

重新订阅

这样整个 Agent 对话的恢复链路就完整了。


十四、常见误区

第一个误区是把 streamUrl 当成最终结果下载地址。

实际上它通常是事件订阅地址,不代表最终结果本身。

第二个误区是 SSE 断开就停止 Agent。

SSE 是观察通道,Run 才是执行主体。

第三个误区是每次重新进入页面都重新创建 Run。

正确方式应该是发现旧 Run 仍然在执行,然后重新连接旧 Run 的 streamUrl。

第四个误区是只有 streamUrl,没有事件持久化。

这种情况下虽然可以实时推送,但用户离开期间产生的事件仍然无法补发。

第五个误区是只保存 Event Log,不保存 Snapshot。

这样长会话每次恢复都需要重放大量事件,成本很高。


结语

streamUrl 看起来只是接口返回中的一个 URL,但它背后其实代表了一种非常重要的系统设计:

将任务执行生命周期与当前浏览器连接生命周期解耦。

在简单聊天中,可以直接使用:

text
POST
+
Streaming Response

但在复杂 Agent 系统中,更适合:

text
POST 创建 Run
+
GET streamUrl 订阅事件

于是:

text
Run
负责真正执行任务

streamUrl
负责观察 Run

Event Log
负责保存运行过程

Snapshot
负责恢复完整状态

lastEventId
负责断线续接

最终形成:

text
用户提交任务

创建 Agent Run

立即返回 runId + streamUrl

Agent 在后台持续执行

事件写入 Event Log

前端通过 streamUrl 实时订阅

页面刷新 / 网络断开

读取 Snapshot + lastEventId

重新连接 streamUrl

补发遗漏事件

继续实时接收

因此,可以用一句话理解 streamUrl

streamUrl 不是“答案在哪里”,而是“这个持续运行的任务,现在应该去哪里监听它发生了什么”。

当 Agent 逐渐从简单问答走向工具调用、长任务、后台执行、Human-in-the-loop 和多 Agent 协作时,这种“Command API + Event Stream”的设计会越来越重要。

基于 VitePress 构建