KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
SSE 速查 Playbook — keel 龙骨
实时推送与 SSE 的参考信息:SSE 速查 Playbook
一页纸版本。
一、选型
需要客户端高频上行? → WebSocket
数据分钟级更新? → 轮询(别上长连接)
单向持续流(token/进度)? → SSE(默认选择)
需传二进制? → WebSocket
有代理会拦 WS 升级? → SSE
SSE 优势:普通 HTTP 穿透好、断线重连内建、文本可调试、复用 HTTP 基建。
SSE 限制:单向、只能 GET、EventSource 不能自定义头、HTTP/1.1 同域 6 连接、纯文本。
二、协议
id: 1710000000123-0 ← 事件 ID(重连时由 Last-Event-ID 带回)
event: delta ← 事件类型(默认 message)
data: {"text":"你好"} ← 载荷(必须自包含完整 JSON)
← 空行结束一个事件块
: heartbeat ← 冒号开头是注释,常用作心跳
retry: 5000 ← 建议重连间隔(ms)
响应头:
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no
终止重连的正确方式:返回 204 或 Content-Type 不是事件流(否则浏览器无限重连)。
三、服务端(FastAPI 要点)
return StreamingResponse(
event_gen(request, run_id, last_event_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"},
)
四件必做:正确 media_type / no-transform / X-Accel-Buffering: no / finally 清理。
另外:request.is_disconnected() 检测断开;事件源加 block 超时让出控制权。
四、nginx(最容易出问题的地方)
proxy_buffering off; # 头号坑:默认 on 会让客户端一直收不到数据
proxy_cache off;
gzip off; # 压缩会攒批,破坏实时性
chunked_transfer_encoding on;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_read_timeout 3600s; # 默认 60s 会掐断
proxy_send_timeout 3600s;
send_timeout 3600s;
proxy_connect_timeout 5s; # 建立连接仍然要短
超时分层:外层 > 内层 > 心跳间隔 × 2。心跳常取 20~30s(代理空闲超时通常 60s)。
五、续播
事件先落事实日志(Redis Stream / Kafka),SSE 只是日志的订阅视图。
断线 → 浏览器带 Last-Event-ID 重连 → 从断点继续。
- 事件 ID 要:单调递增、唯一、可直接当游标(Redis Stream ID 天然满足)
- 续播只能保证至少一次 → 客户端必须幂等(记 lastSeq,重复丢弃)
- 游标超保留窗口 → 发
restart事件让客户端全量重拉,不要硬塞旧事件 - 心跳不写进事实日志(否则回放出一堆无用心跳)
- 断开 ≠ 取消:取消走独立 API + 持久化标记 + 协作式检查
六、LLM 流式
事件类型:run_started / delta / tool_call / tool_result / citation / error / done / cancelled
- 每个事件带
seq/index,自包含完整 JSON → 幂等容易 - 逐 token 渲染会闪烁 → 节流 50~100ms 或按句发送,代码块未闭合不渲染
- 只关 SSE 不会停止模型 → 必须有取消 API 一路传到模型调用
- 流结束后用全量接口校验一次(SSE 当加速感知,全量当事实)
- 统一应用层事件格式,provider 差异在适配层消化
七、容量与治理
稳态连接数 ≈ 日活 × 同时在线比例 × 平均流时长 / 使用间隔
单连接成本:fd×2、协程×1~2、nginx 连接×2、内存 10~100KB
- ⚠️ 不要每条 SSE 独占一个 Redis 连接 → 每实例共享一个 XREAD 轮询再分发
- 背压:有界队列,满了就断(有事实日志,断开不丢数据)
- 准入三层:全局 / 单用户 / 单资源;超限返回 429,不要返回空流假装成功
- 多实例无需亲和(靠事实日志);但要防重连风暴:
retry+ 随机抖动 + 分批重启 - 指标四项:连接数、事件数、重连次数、TTFB
八、排查("卡住了"怎么办)
# ① 直连应用绕过 nginx
curl -N --max-time 10 http://127.0.0.1:8000/runs/1/stream | head
# ② 经过 nginx 看 TTFB
curl -N -o /dev/null -w 'TTFB %{time_starttransfer}s\n' https://domain/runs/1/stream
① 直连有数据? → 有:中间层问题;无:应用问题
② 服务端在推吗? → 在推:传输问题(缓冲/压缩/超时);没推:应用问题
③ 断开有规律吗? → 固定间隔:超时;随机:网络/客户端
④ 个别还是全部? → 个别:客户端环境;全部:服务端/网关
⑤ 最近有发布吗? → 长连接问题 80% 与配置变更相关
分水岭是第 ② 步 → 服务端必须记录"已发事件数"。
九、安全
- 鉴权:Cookie(首选)/ 一次性 ticket(60s 单次)/ fetch 流(能带 header 但重连自己实现)
- ❌ 不要把长期 token 放 query(会进日志、Referer、历史)
- 日志脱敏:nginx 用
$uri不用$request - 长期连接要周期性复核授权(权限可能在流进行中被撤销)
- 租户隔离:key 带
tenant:前缀;每次订阅校验归属 - 发出前脱敏(SSE 发出即不可撤回)
十、压测与演练
- 长连接压测看:并发连接数 + 每连接事件速率 + TTFB 分位 + 断连次数(不是 QPS)
- 关键验收:TTFB 不应随并发数增长(增长=被缓冲)
- 演练五项:建峰 1.5 倍连接 30 分钟 / 重启实例看重连风暴 / 慢客户端看背压 /
断开后资源 1 分钟回落(泄漏检测)/ 降级开关生效