受众:需要从智能体获得实时 token 级输出的 SDK 用户
(TypeScript、Go、任何会说 HTTP 的)。如果只需要单条完整回复,
阻塞 JSON 变体(不带
Accept: text/event-stream)更简单 —— 从
调用智能体 开始。openapi.beeos.ai 暴露的每个 SSE 接入面的规范参考。
三个端点都遵循同一 Content-Type: text/event-stream 框架,但事件
命名约定略有不同;本指南并排呈现,帮你挑对端点 + 写对重连循环。
1. 三个 SSE 接入面
三者都要
Authorization: Bearer <JWT or oag_...>。oag_ key 是
user-scoped 的 —— 任何 key 只要 owner 拥有底层任务 / 会话,就能
invoke 或开流。鉴权模型见
认证与 API Key § 鉴权(v1.1.0 起 per-route
scope 已下线)。
2. invoke SSE 流(一次性)
event: 名 —— 客户端按 JSON type 分派):
agent_reply_error,你拿到的是同一个 done
frame 但带额外字段:
agent_offline、service_timeout、
agent_rejected 等),你先拿到 error frame、再拿到 done
(流的”单一关闭信号”—— 两个 frame 携带相同 code,只 key 在
done 的调用方仍能正确分派,audit-v4 P1-1):
此端点上没有
event: 行。标准 EventSource 风格客户端收到的
所有 frame 都是默认 "message" 事件;按 data.type 分派。客户端必备逻辑(invoke)
- 随 delta 到达,把
delta.text追加到运行 buffer。 - 把第一个
doneframe 当终止 —— 关闭连接。 done.is_error === true时把done.code+done.error暴露给 调用方。不要对agent_reply_error(in-band)盲目重试;但对service_timeout/agent_offline(传输层)要重试。
done.text 字段是完整拼接后的回复 —— 增量是 UX 锦上添花,不是
真相。不需要流式 UX 的 SDK 可以完全忽略 delta,只消费 done。
3. 任务 / 会话 SSE 流(命名事件)
tasks/{id}/events 和 conversations/{id}/events 用同一种 framing:
SSEStreamMessage):
Envelope v3(ADR-0022 + ADR-0023,v1.1 GA):Agent 回复采用”单行原位
变更”模型 —— 同一
message_id 跨 N 个 streaming 帧(每帧都是 body /
parts 的累积快照),最后恰好一帧 state="completed"(或
failed / cancelled)作为终态。beeos-claw 不再写 per-token 的
agent_reply_delta 行,新建通道上根本不会出现该类型;为读取历史
通道,GET /messages?include_deltas=true 仍然有效。终止 event: end
连接关闭前恰好发一次。reason 区分原因:
end frame 是唯一信号 —— 没有 in-band keepalive 注释,也没有
Connection: close 语义。看到 end 不要用 since=lastOffset
重连 —— 通道已消失或终止;重连不会回放任何新东西。
客户端必备逻辑(任务 / 会话)
4. since cursor(断线 / 重连补偿)
since 是通道单调日志上的整数 offset。
/tasks/{id}/events 和 /conversations/{id}/events 都接受它作为
query 参数。语义:
since=0(或省略)—— 回放完整历史,然后继续流式发新事件。 适合晚到附加方想要完整 transcript。since=N(N > 0)—— 回放所有 offset> N的事件,然后继续流式。 在重连时用:传入最后观察到的frame.offset,避免重复或漏掉。
同样的 cursor 还充当非流式
GET /messages 端点上的分页 key:
?since=<lastOffset>&limit=200 返回 offset > lastOffset 的最多 200
frame。流式 + 轮询混用没问题。OpenAPI v1.1(ADR-0022 + ADR-0023):GET /messages 默认过滤掉
临时流式 chunk(agent_reply_delta、agent_thought_chunk、
agent_message_chunk)。v3 envelope(ADR-0023)落地后,live 的
agent_reply 行会在 body 里直接携带完整累积文本,不再需要
include_deltas=true 来重建文本;新的 beeos-claw 通道根本不
会写 agent_reply_delta 行。该 flag 仅为读取历史通道(pre-v3)
保留。latest_offset 仍然反映完整的服务端最大值,所以
since=<latest_offset> 不管怎样都能续到正确位置。边界情况
-
连接在收到任何 frame 之前掉了。
lastOffset仍为0—— 用since=0重连即从头开始回放。invoke SSE 上(无 offset)必须 整体重发 invoke(底层 chat_message 没持久化,因为你没拿到message_id做幂等重试)。 -
最后一个 frame 是
agent_message_chunk然后连接掉了。 用since=<该 chunk 的 offset>重连。你会拿到所有剩余 chunk 加上最终agent_reply。已渲染的文本没有重复风险 —— 每个 chunk 有不同的message_id。 - 同一客户端对同一任务开两个 SSE 连接。 两者都拿到完整实时流。 Message Service 是扇出 —— 网关上没有”你已订阅”的语义。
-
重连时收到
backfill_truncated帧(自 OpenAPI v1.1 / ADR-0022)。当通道闲置足够久使临时 stream 在你的Last-Event-ID之前老化失踪时,服务端会在正常replay_complete之前发一个backfill_truncated事件。形状:恢复策略,按保真度从高到低:- 重放幸存的 durable 行 —— 拉
GET /messages?since=<since>(仅 durable 行);你会丢掉逐 token chunk,但能恢复终态回复 / 非 chunk 状态。服务端仅对临时类型发此 帧,所以chat_message/agent_reply/agent.input_required等仍然在持久化日志里。 - 快进 —— 直接用
since=replay_complete.latest_offset续传, 接受中间 token chunk 已经丢失。官方 SDK 默认就这么做,因为 chunk 是渲染 UX 不是 system-of-record 数据。
backfill_truncated的 SDK 应当把该帧当作 no-op 消息处理 —— 它是纯信息性的,不改变协议的其它部分。 - 重放幸存的 durable 行 —— 拉
5. Keepalive
两个 SSE handler 目前都不发显式 keepalive 注释。策略:浏览器 / EventSource
EventSource 在 socket close 时自动重连。用 since=<lastOffset>
query 参数补偿间隙。注意:EventSource 不能设 header,所以 oag_
key 要么:
- 手写
fetch+ 手动 SSE 解析(推荐做生产 —— 让你对 header、重试、 重连时机有精确控制);或 - 把 token 嵌入 URL(
?access_token=...)—— 避免:URL 会出现在 CDN 日志 / 代理 access log / 浏览器历史里。
Node.js(server-to-server)
用fetch + 把 response.body 当 async iterable,或
eventsource 包(支持
自定义 header)。在 onerror 上做指数退避,传入最后观察到的 offset:
Go
github.com/r3labs/sse/v2 开箱处理重连 + offset 补偿。传
since=<offset> 让它自动续传。
服务端考量
如果客户端在企业代理 / NLB / CDN 后面,idle 连接上限可能比你的 回合节奏短。常见基础设施上的实测上限:
无论如何客户端都必须处理重连。
since= cursor 就是为此而存在,
让重连无损。
6. error frame 对 done(invoke)/ end(task & conversation)
不同端点关闭语法不同;读错是最常见的 SDK bug。
所以匹配规则是:
- invoke:按
data.type === "done"决定 “停”。 - task:按
event === "end"(命名 SSE 事件)决定 “停”;之前的 都是message。 - conversation:和 task 一样,但
end仅在显式删除 / 上游关闭 时触发,不在任何每回合终止时触发。
7. 实例 —— 健壮的任务监视器(TypeScript)
下面这段展示任务 SSE 接入面完整的”出错重连 + offset 续传”循环。 有意没用 SDK,让 wire 机制看得见。event === "end" 的 break 换成 “停在显式
unsubscribe 标志”,因为会话不会因每回合回复而终止。
8. Frame 重放与幂等
每个 frame 的message_id 在通道内唯一。重连引起的回放在你
lastOffset 追踪丢失更新时可能重发已见过的 frame —— UI 代码
应当按 message_id 去重。
内部 Message Service 用 offset-only 排序不变量(每通道单调),所以
看到两个 message_id 相同但 offset 不同的 frame 是值得报告的 bug。
9. 常见错误
- 重连时
lastOffset = 0。 重放整个通道历史,浪费带宽,把已 渲染的重复 frame 灌进 UI。永远追踪最新观察到的frame.offset并作为since=传入。 - 把
event: end当可恢复错误。 不是 —— 它意味着不会有更多 frame。重连只是再得到一次end(或如果通道被驱逐,得到 404)。 关闭连接走人。 - 对 invoke SSE 按
event名分支。 invoke SSE 发无名 frame; 按event === "message"分发的客户端会把每个 frame 当可忽略。 invoke 用JSON.parse(data).type === "done";task / conversation 用event === "end"。 - 把
Accept: text/event-stream和POST /tasks/...混用。 任务 SSE 接入面是另一个GET /events端点,不是 create 调用。POST /tasks始终返回 JSON 200 + task ID;然后在GET /tasks/{id}/events上开 SSE。 - 把 token 嵌入 URL。 通过支持自定义 header 的 SSE 客户端设
Authorization:(或fetch+ 手动解析)。URL 会落到代理 / CDN access log 里。
10. 另请参阅
- 调用智能体 —— 非流式变体
- 会话 vs 任务 —— 何时选哪种 SSE 接入面
- 错误参考 ——
error/done.codeframe 里 能出现的每个code - OpenAPI 契约:
backend/openapi/beeos-platform-v1.yaml(搜text/event-stream找 schema) - SSE DTO 定义:
backend/services/openapi-gateway/internal/dto/sse.go