协议参考

August 14, 2026 · View on GitHub

English | 简体中文

协议参考

WHIP 信令

信令遵循 RFC 9725

步骤方法路径请求体响应
1POST/whipSDP offer(application/sdp201 Created,返回 SDP answer、Location: /whip/{sessionId}ETagAccept-Patch
2PATCH/whip/{sessionId}ICE 重启片段(application/trickle-ice-sdpfrag200 OK,返回服务端片段与新的 ETag
3DELETE/whip/{sessionId}200 OK
POST/whip?resume={token}SDP offer(application/sdp201 Created,重新挂接到该令牌对应的会话 —— StreamCore 扩展,见会话恢复
OPTIONS/whip/whip/{sessionId}204 No Content,带 Accept-Post: application/sdpAccept-Patch: application/trickle-ice-sdpfrag

POST /whip 按客户端 IP 限流(每分钟 30 个会话);PATCH 使用独立计数(每分钟 60 次重启),因此网络频繁抖动的客户端不会消耗掉自己新建会话的额度。超出任一限制后服务返回 429 Too Many Requests 并带上 Retry-After 头。两者都会建立或重新收集 ICE,因此即使未开启鉴权也会被限流。

客户端创建 SDP offer、收集 ICE 候选并 POST/whip。服务端创建 peer、收集自己的候选,并连同服务端生成的会话 ID 一起返回 answer。不使用 trickle ICE,也没有常驻信令连接。

本实现与 WHIP 的核心流程一致:以 application/sdp 发起 POST,用 201 Created 返回 answer,用 Location 给出会话 URL,用 ETag 标识 ICE 会话,用 PATCH 做 ICE 重启,用 DELETE 销毁,用 OPTIONS 返回 Accept-Post,并在双端做完整 ICE 收集。音频为 sendrecv,并带一个用于双向事件的 DataChannel。

ICE 重启

短暂的网络事件 —— 手机在 Wi-Fi 与蜂窝之间切换、笔记本更换网络、空闲后 NAT 重新绑定 —— 会中断连通性,但通话本身并未结束。若用重新 POST offer 的方式恢复,会分配新的会话、新的流水线和新的 LLM 客户端,对话历史与滚动摘要随之丢失,开场白也会重播。PATCH 恢复的是同一条连接:ICE 凭据与候选是新的,但 PeerConnection、DTLS 关联、媒体轨道以及正在运行的流水线都保持不变。

客户端重新收集 ICE(浏览器中为 pc.restartIce()),并把得到的凭据与候选发送到 Location 头给出的会话 URL:

PATCH /whip/{sessionId} HTTP/1.1
Content-Type: application/trickle-ice-sdpfrag
If-Match: "<etag>"

a=ice-ufrag:ZjRk
a=ice-pwd:AYk4ZQlPQeZ1AyJEkxUFXA
m=audio 9 UDP/TLS/RTP/SAVPF 111
a=mid:0
a=candidate:1 1 udp 2130706431 198.51.100.7 51000 typ host

服务端返回 200 OK,带上自己的片段与轮换后的 ETag

HTTP/1.1 200 OK
Content-Type: application/trickle-ice-sdpfrag
ETag: "<new-etag>"

a=ice-ufrag:MInq
a=ice-pwd:9NAlFOwsD1owEQGZjnjqvSVU
m=audio 9 UDP/TLS/RTP/SAVPF 111
a=mid:0
a=candidate:1 1 udp 2130706431 198.51.100.1 39132 typ host
a=end-of-candidates

If-Match 可选,但只要携带就会校验:其值必须是当前 ETag(或 *),否则服务端返回 412 Precondition Failed,并在响应中给出当前的 tag。其他情况:

状态码含义
404 Not Found会话已被回收或从未存在 —— 用 POST 重新拨号。
405 Method Not Allowed片段只有候选、没有 ice-ufrag/ice-pwd。按 RFC 9725 §4.4.1,trickle ICE 是可选的且本服务未实现;请收集完整后再 PATCH。
409 Conflict会话存在,但没有已协商的 peer 可供重启。
415 Unsupported Media TypeContent-Type 不是 application/trickle-ice-sdpfrag

disconnected 连接状态是暂时的,本身绝不会拆毁 peer —— ICE 可以自行恢复;若无法恢复,Pion 会在约 25 秒后升级为 failed(该状态才会拆毁)。处于 disconnected 期间,服务端会在 DataChannel 上发出 connection 事件,客户端可据此提示“正在重连…”,恢复后再发一次。

恢复阶梯

上面两种机制并非二选一,而是有先后顺序的;除 Python 外的每个 SDK 都会依次执行:

时机机制保留下来的东西
连接处于 disconnectedICE 重启PATCH全部。同一个 PeerConnection、同一个 DTLS、同样的媒体轨道 —— 传输层之上毫无察觉。
连接已 failed会话恢复POST ?resume=对话本身。传输被彻底重建,但历史、滚动摘要与智能体的记忆都会延续。
令牌过期或会话已被回收通话可用,但对话是空白的。服务端会通过 X-Resume-Status: expired 告知客户端。

两个阶段共用同一个截止时间。disconnected 大约 25 秒后升级为 failed,此后服务端还会把这段被遗弃的对话保留 server.session_grace_ms(默认 30 秒)才回收。花在重启上的时间就是不能用于重拨的时间,因此把 reconnectAttempts 设得很大,留给 resumeAttempts 的预算就更少。

为什么两者都要,而不是只用会话恢复?因为 ICE 重启是无感的:没有新的 DTLS 握手,没有轨道更替,除了 ICE 重新探测之外也没有空档。而恢复式重拨要付出一次完整的重新协商与短暂静音。只要 ICE 重启可用,它总是更好的选择;而在它不可用时,会话恢复是唯一还能奏效的手段。

会话恢复(Session resume)

这是 StreamCore 的扩展,并非 RFC 9725 的一部分。

ICE 重启能恢复「坏掉」的传输,但无法恢复「已经消失」的传输:超过约 25 秒后连接进入 failed,服务端已关闭该 peer,也就没有什么可重启的了。被切到后台、被挂起或断网一分钟的客户端正属于这种情况;任何 WebRTC 栈本身就无法执行 ICE 重启的客户端也是如此 —— Python SDK 就是其中之一。

会话恢复正是为这种情况准备的:允许一次全新的 POST 重新挂接到那条已断连接原本进行中的对话。传输是新的,但会话、带有消息历史的 LLM 客户端、转写记录与滚动摘要都不是新的,开场白也不会重播。

每次 POST 的响应都会带上一个令牌和一个状态:

HTTP/1.1 201 Created
Location: /whip/{sessionId}
X-Resume-Status: new
X-Resume-Token: qcH8tnK-zT8...

恢复时,把该令牌作为查询参数重新拨号:

POST /whip?resume=qcH8tnK-zT8... HTTP/1.1
Content-Type: application/sdp
X-Resume-Status含义
new未携带令牌,是一次全新的对话。
resumed已重新挂接。Location 仍是原会话 URL,智能体记得这通电话。
expired令牌未知、已被使用,或其会话已被回收。通话可用,但对话是空白的 —— 智能体不记得此前的内容。

携带过期令牌的重拨绝不会被直接拒绝,因为让整通电话失败比丢失历史更糟。状态头正是客户端用来区分二者的依据;悄无声息地从头开始,恰恰是这个令牌要防止的那种「失忆」。

有两条性质可以依赖:

  • 令牌一次性使用。 每次响应都会签发新令牌并让上一个失效,因此从日志或代理中截获的令牌,在合法客户端重拨的那一刻就已作废。
  • 令牌不是会话 ID。 会话 ID 会出现在资源 URL 和日志里;而一个能访问进行中对话的凭据不应如此,所以它是取自 crypto/rand 的 32 字节。

时间窗口为 server.session_grace_ms(默认 30 秒),从最后一个 peer 离开时开始计算。调大它可以给网络不稳定的客户端更多时间,代价是被遗弃的对话会在内存中保留更久。

实时(语音到语音)会话不提供恢复:它们的历史保存在服务商那一侧,新的服务商连接无法继承,因此宁可不签发令牌,也不承诺服务端无法兑现的连续性。

会话生命周期

会话会在客户端发送 DELETE 时移除,或在没有已连接 peer 的时间超过 server.session_grace_ms(默认 30 秒)后被回收。这段宽限期正是为 ICE 重启或重新拨号留出的窗口;而定期清扫则确保通话中途消失、因而根本无法发送 DELETE 的客户端,不会让会话泄漏到进程结束。

实时事件

客户端必须在生成 offer 之前创建一个标签为 events 的 DataChannel。服务端会发送:

类型载荷说明
transcript{ "type": "transcript", "text": string, "final": boolean }用户转写更新
response{ "type": "response", "text": string }流式回复文本
state{ "type": "state", "state": "listening" | "thinking" | "speaking" }智能体轮次状态,用于 UI 指示
timing{ "type": "timing", "stage": string, "ms": number }pipeline.debug = true 时的时延数据
connection{ "type": "connection", "state": "reconnecting" | "connected" }传输已断开并正在恢复,或已恢复

目前的 timing 阶段:llm_first_tokentts_first_byte

客户端在同一通道上发送的消息会被路由进流水线 —— 目前用于 vision.analyze 插件消费的摄像头图像分片。

鉴权

设置 server.jwt_secret 后,/whip 会要求 Authorization: Bearer <jwt>。设置该项时,服务还会暴露 POST /token,签发有效期 1 小时的 HS256 token。再设置 server.api_key,则 /token 本身也要求 Authorization: Bearer <api_key>,这样只有你的后端才能签发会话 token。两者默认都为空,即关闭鉴权。

通话方身份

POST /token 接受一个可选的请求体,用于标明这个 token 属于谁:

{ "resource_id": "user_8891" }

该值会作为 sub claim 签进 token,/whip 再以 resource_id 字段转发给外部 agent(参见自带 agent)。session_id 划定的是一次对话,而它划定的是跨越所有对话的那个人——正是它让 agent 能认出昨天挂断、今天又打回来的来电者。

请在这里签发,而不要让客户端自行上报:/token 由你的后端调用,它持有 API key,本来就知道当前登录的是哪个用户;而 /whip 是由浏览器调用的。页面无法伪造一个它签不出来的 claim。

没有 token 端点、直接拨 /whip 的服务端客户端可以改为发送:

X-StreamCore-Resource-Id: +14155550123

该请求头仅在请求不带已签名 claim 时才会被采纳,因此永远无法覆盖 claim。它同时也不在 CORS 的 Access-Control-Allow-Headers 列表中,这意味着浏览器根本发不出这个头——它是留给受信任的服务端调用方的,例如 sip-server,它会把来电号码填进去。

身份始终是可选的。不提供身份的部署只会在 agent 请求中省略 resource_id,agent 退回到按会话划定记忆。